diff --git a/README.md b/README.md index cbb762c..5fd111c 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,21 @@ export default class Messages extends Component { } ``` +### Resource factories + @use (alternative) + +If you use [`ember-resources`](https://github.com/NullVoxPopuli/ember-resources), you can also use the lower-level resource factories (`queryResource`, `mutationResource`, `subscriptionResource`) with the `@use` decorator. This removes the need to pass `this` and is useful for composing custom resource factories. See the [ember-resources documentation](https://ember-resources.pages.dev/) for details. + +```glimmer-ts +import { use } from 'ember-resources'; +import { queryResource, gql } from 'glimmer-apollo'; + +export default class Todos extends Component { + @use todos = queryResource(() => [ + gql`query { todos { id description } }`, + ]); +} +``` + ### setClient(ctx, client[, clientId]) Where `ctx` is an object with owner. diff --git a/UPGRADE.md b/UPGRADE.md index c5ffb47..2cd454c 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -144,6 +144,71 @@ In Apollo Client 4, `refetch()` on a query with `fetchPolicy: 'standby'` (i.e. a In 0.8.x, `#onNextResult` checks for `result.error` in the subscription's `next` callback and routes it to the error handler if present. +## Internal: ember-resources + +In 0.8.x, glimmer-apollo uses [`ember-resources`](https://github.com/NullVoxPopuli/ember-resources) as the underlying resource framework. The `useQuery`, `useMutation`, and `useSubscription` APIs are unchanged -- existing code continues to work without modification. + +### New peer dependency + +`ember-resources` ^7.0.0 is now a required peer dependency: + +```bash +pnpm add ember-resources +``` + +### Resource factories (alternative API) + +In addition to the existing `useQuery`/`useMutation`/`useSubscription` functions, 0.8.x also exports lower-level resource factories (`queryResource`, `mutationResource`, `subscriptionResource`). These can be used with the [`@use` decorator](https://ember-resources.pages.dev/) from `ember-resources`, which removes the need to pass a context object: + +```typescript +import { use } from 'ember-resources'; +import { queryResource } from 'glimmer-apollo'; + +export default class Notes extends Component { + @use notes = queryResource(() => [GET_NOTES]); +} +``` + +The resource factories are useful for composing custom wrappers: + +```typescript +import { queryResource, type QueryPositionalArgs } from 'glimmer-apollo'; +import type { OperationVariables } from '@apollo/client'; + +function myCustomQuery( + args: () => QueryPositionalArgs +) { + return queryResource(() => { + const [query, options] = args(); + return [query, { ...options, fetchPolicy: 'network-only' }]; + }); +} +``` + +### Curried resource factories + +For queries/mutations/subscriptions that are used in multiple places, `createQueryResource`, `createMutationResource`, and `createSubscriptionResource` bake the document in once and return a reusable factory: + +```typescript +import { use } from 'ember-resources'; +import { createQueryResource } from 'glimmer-apollo'; + +const userInfo = createQueryResource(USER_INFO); + +export default class UserProfile extends Component { + @use query = userInfo(() => ({ variables: { id: this.args.userId } })); +} +``` + +### Removed type exports + +The `UseQuery`, `UseMutation`, and `UseSubscription` helper types have been removed. Use `QueryResource`, `MutationResource`, and `SubscriptionResource` instead: + +```diff +-import type { UseQuery } from 'glimmer-apollo'; ++import type { QueryResource } from 'glimmer-apollo'; +``` + ## Breaking changes summary | Change | Reason | @@ -156,3 +221,5 @@ In 0.8.x, `#onNextResult` checks for `result.error` in the subscription's `next` | Query/mutation errors no longer clear `data` | Apollo Client 4 provides error state alongside data; `errorPolicy: 'all'` now works correctly | | Templates must check `error` before rendering `data` | `data` persists on error; skipping the check renders stale data silently | | Subscription `#onNextResult` checks `result.error` | Routes errors delivered via the `next` callback to the error handler | +| `ember-resources` ^7.0.0 required as peer dependency | Resource framework used internally; enables `@use` decorator and resource factory APIs | +| `UseQuery`, `UseMutation`, `UseSubscription` types removed | Use `QueryResource`, `MutationResource`, `SubscriptionResource` instead | diff --git a/docs/fetching/mutations.md b/docs/fetching/mutations.md index df30f63..2f39184 100644 --- a/docs/fetching/mutations.md +++ b/docs/fetching/mutations.md @@ -466,3 +466,20 @@ export default class CreateNote extends Component { } ``` + +## Resource factories + @use (alternative) + +If you use [`ember-resources`](https://github.com/NullVoxPopuli/ember-resources), you can use the `mutationResource` factory with the `@use` decorator instead of `useMutation`. This removes the need to pass a context object and is useful for composing custom resource factories. + +```ts +import { use } from 'ember-resources'; +import { mutationResource } from 'glimmer-apollo'; + +export default class CreateNote extends Component { + @use createNote = mutationResource( + () => [CREATE_NOTE] + ); +} +``` + +See the [ember-resources documentation](https://ember-resources.pages.dev/) for more on the `@use` decorator and resource patterns. diff --git a/docs/fetching/queries.md b/docs/fetching/queries.md index c5e21d8..54d813f 100644 --- a/docs/fetching/queries.md +++ b/docs/fetching/queries.md @@ -475,3 +475,21 @@ A function that instructs the query to stop polling after a previous call to `st A function that enables you to execute a subscription, usually to subscribe to specific fields that were included in the query. This function returns another function that you can call to terminate the subscription. + +## Resource factories + @use (alternative) + +If you use [`ember-resources`](https://github.com/NullVoxPopuli/ember-resources), you can use the `queryResource` factory with the `@use` decorator instead of `useQuery`. This removes the need to pass a context object and is useful for composing custom resource factories. + +```ts +import { use } from 'ember-resources'; +import { queryResource } from 'glimmer-apollo'; + +export default class Notes extends Component { + @use notes = queryResource(() => [ + GET_NOTES, + { variables: { isArchived: this.isArchived } } + ]); +} +``` + +See the [ember-resources documentation](https://ember-resources.pages.dev/) for more on the `@use` decorator and resource patterns. diff --git a/docs/fetching/subscriptions.md b/docs/fetching/subscriptions.md index 10f4c61..30911e9 100644 --- a/docs/fetching/subscriptions.md +++ b/docs/fetching/subscriptions.md @@ -404,3 +404,24 @@ const wsLink = new WebSocketLink({ } }); ``` + +## Resource factories + @use (alternative) + +If you use [`ember-resources`](https://github.com/NullVoxPopuli/ember-resources), you can use the `subscriptionResource` factory with the `@use` decorator instead of `useSubscription`. This removes the need to pass a context object and is useful for composing custom resource factories. + +```ts +import { use } from 'ember-resources'; +import { subscriptionResource } from 'glimmer-apollo'; + +export default class LatestMessage extends Component { + @use latestMessage = subscriptionResource< + OnMessageAddedSubscription, + OnMessageAddedSubscriptionVariables + >(() => [ + ON_MESSAGED_ADDED, + { variables: { channel: this.args.channel } } + ]); +} +``` + +See the [ember-resources documentation](https://ember-resources.pages.dev/) for more on the `@use` decorator and resource patterns. diff --git a/glimmer-apollo/package.json b/glimmer-apollo/package.json index 232f4a2..4c2873a 100644 --- a/glimmer-apollo/package.json +++ b/glimmer-apollo/package.json @@ -52,6 +52,7 @@ }, "devDependencies": { "@apollo/client": "^4.0.0", + "ember-resources": "^7.0.0", "rxjs": "^7.0.0", "@babel/core": "^7.28.5", "@babel/eslint-parser": "^7.28.5", @@ -83,7 +84,8 @@ "peerDependencies": { "@apollo/client": "^4.0.0", "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0", - "rxjs": "^7.0.0" + "rxjs": "^7.0.0", + "ember-resources": "^7.0.0" }, "ember": { "edition": "octane" diff --git a/glimmer-apollo/src/-private/mutation.ts b/glimmer-apollo/src/-private/mutation.ts index 329adee..67185e3 100644 --- a/glimmer-apollo/src/-private/mutation.ts +++ b/glimmer-apollo/src/-private/mutation.ts @@ -1,11 +1,7 @@ -import { - isDestroyed, - isDestroying, - tracked, - waitForPromise, -} from '../environment.ts'; +import { resource, resourceFactory } from 'ember-resources'; + +import { tracked, waitForPromise, setOwner } from '../environment.ts'; import { getClient } from './client.ts'; -import { Resource } from './resource.ts'; import { settled } from './utils.ts'; import type { @@ -16,7 +12,6 @@ import type { OperationVariables, MaybeMasked, } from '@apollo/client'; -import type { TemplateArgs } from './types'; type Maybe = T | undefined | null; @@ -34,17 +29,34 @@ export type MutationPositionalArgs< TVariables extends OperationVariables = OperationVariables, > = [DocumentNode, MutationOptions?]; -export class MutationResource< +// Unlike QueryState/SubscriptionState (which are driven reactively by the +// resource factory and receive args via their START method), MutationState +// needs the thunk in its constructor because `mutate()` is imperative — +// it must read the current args each time the user calls it. +export class MutationState< TData, TVariables extends OperationVariables = OperationVariables, -> extends Resource>> { +> { @tracked loading = false; @tracked called = false; @tracked error?: ErrorLike; @tracked data: Maybe>; @tracked promise!: Promise>>; - async mutate( + #stopped = false; + #getArgs: () => MutationPositionalArgs; + + constructor(getArgs: () => MutationPositionalArgs) { + this.#getArgs = getArgs; + } + + /** @internal – do not call directly; used by the resource factory. */ + _stop(): void { + this.#stopped = true; + } + + // Arrow property so `this` is preserved when accessed through the Proxy. + mutate = async ( variables?: TVariables, overrideOptions: Omit< MutationOptions, @@ -53,12 +65,16 @@ export class MutationResource< MutationOptions, 'variables' | 'mutation' >, - ): Promise>> { + ): Promise>> => { this.loading = true; - const [mutation, originalOptions] = this.args.positional; + const [mutation, originalOptions] = this.#getArgs(); const options = { ...originalOptions, ...overrideOptions }; const client = getClient(this, options.clientId); + // Capture callbacks now so we use the ones in effect at mutate() time, + // not whatever the thunk returns when the async operation completes. + const { onComplete, onError } = options; + if (!variables) { variables = originalOptions?.variables; } else if (variables && originalOptions?.variables) { @@ -76,47 +92,49 @@ export class MutationResource< } as ApolloMutationOptions), ) .then((result) => { - this.#onComplete(result); + this.#onComplete(result, onComplete, onError); return this.data; }) .catch((error: ErrorLike) => { - this.#onError(error); + this.#onError(error, onError); return this.data; }); return this.promise; - } + }; - settled(): Promise { - return settled(this.promise); - } + // Arrow property so `this` is preserved when accessed through the Proxy. + settled = (): Promise => settled(this.promise); - #onComplete(result: MutateResult>): void { + #onComplete( + result: MutateResult>, + onComplete?: (data: Maybe>) => void, + onError?: (error: ErrorLike) => void, + ): void { this.data = result.data; this.error = result.error; - this.#handleOnCompleteOrOnError(); + this.#handleOnCompleteOrOnError(onComplete, onError); } - #onError(error: ErrorLike): void { + #onError(error: ErrorLike, onError?: (error: ErrorLike) => void): void { this.error = error; this.data = undefined; - this.#handleOnCompleteOrOnError(); + this.#handleOnCompleteOrOnError(undefined, onError); } - #handleOnCompleteOrOnError(): void { + #handleOnCompleteOrOnError( + onComplete?: (data: Maybe>) => void, + onError?: (error: ErrorLike) => void, + ): void { this.loading = false; this.called = true; - // We want to avoid calling the callbacks when this is destroyed. - // If the resource is destroyed, the callback context might not be defined anymore. - if (isDestroyed(this) || isDestroying(this)) { + if (this.#stopped) { return; } - const [, options] = this.args.positional; - const { onComplete, onError } = options || {}; const { data, error } = this; if (onComplete && !error) { @@ -126,3 +144,57 @@ export class MutationResource< } } } + +export type { MutationState as MutationResource }; + +/** + * Create a mutation resource. Can be used with ember-resources' @use decorator + * or in templates via resourceFactory. + */ +export function mutationResource< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>(thunk: () => MutationPositionalArgs) { + return resource(({ on, owner }) => { + const state = new MutationState(thunk); + setOwner(state, owner); + + on.cleanup(() => state._stop()); + + return state; + }); +} +resourceFactory(mutationResource); + +/** + * Create a curried mutation resource factory. Call with a document to get a + * reusable resource that accepts options (or a thunk returning options). + * + * ```ts + * const login = createMutationResource(LOGIN); + * + * // In a class with @use: + * @use mutation = login(() => ({ variables: { username: 'john' } })); + * ``` + */ +export function createMutationResource< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>(document: DocumentNode) { + function inner( + thunkOrOptions?: + | (() => MutationOptions | undefined) + | MutationOptions, + ) { + const optionsThunk = + typeof thunkOrOptions === 'function' + ? thunkOrOptions + : () => thunkOrOptions; + return mutationResource(() => [ + document, + optionsThunk(), + ]); + } + resourceFactory(inner); + return inner; +} diff --git a/glimmer-apollo/src/-private/observable.ts b/glimmer-apollo/src/-private/observable.ts index 8fceea2..4fe3abe 100644 --- a/glimmer-apollo/src/-private/observable.ts +++ b/glimmer-apollo/src/-private/observable.ts @@ -1,17 +1,14 @@ -import { Resource } from './resource.ts'; import type { ObservableQuery, OperationVariables, SubscribeToMoreOptions, UpdateQueryMapFn, } from '@apollo/client'; -import type { TemplateArgs } from './types'; -export default class ObservableResource< +export default class ObservableQueryState< TData, TVariables extends OperationVariables, - Args extends TemplateArgs, -> extends Resource { +> { private observable?: ObservableQuery; protected _setObservable(observable: ObservableQuery) { diff --git a/glimmer-apollo/src/-private/query.ts b/glimmer-apollo/src/-private/query.ts index 2bf09df..bef876e 100644 --- a/glimmer-apollo/src/-private/query.ts +++ b/glimmer-apollo/src/-private/query.ts @@ -1,14 +1,16 @@ import { NetworkStatus } from '@apollo/client'; import { equal } from '@wry/equality'; +import { resource, resourceFactory } from 'ember-resources'; import { - isDestroyed, - isDestroying, tracked, waitForPromise, + setOwner, + createCache, + getValue, } from '../environment.ts'; import { getClient } from './client.ts'; -import ObservableResource from './observable.ts'; +import ObservableQueryState from './observable.ts'; import { createPromise, getFastboot, settled } from './utils.ts'; import type { @@ -20,7 +22,6 @@ import type { ObservableQuery, } from '@apollo/client'; import type { Subscription } from 'rxjs'; -import type { TemplateArgs } from './types'; export type QueryOptions = Omit< ApolloClient.WatchQueryOptions, @@ -38,30 +39,31 @@ export type QueryPositionalArgs< TVariables extends OperationVariables = OperationVariables, > = [DocumentNode, QueryOptions?]; -export class QueryResource< +export class QueryState< TData, TVariables extends OperationVariables = OperationVariables, -> extends ObservableResource< - TData, - TVariables, - TemplateArgs> -> { +> extends ObservableQueryState { @tracked loading = false; @tracked error?: ErrorLike; @tracked data: MaybeMasked | undefined; @tracked networkStatus: NetworkStatus = NetworkStatus.loading; @tracked promise!: Promise; + #stopped = false; #subscription?: Subscription; - #previousPositionalArgs: typeof this.args.positional | undefined; - #firstPromiseReject: (() => unknown) | undefined; - - /** @internal */ - setup(): void { - this.#previousPositionalArgs = this.args.positional; - const [query, options = {} as QueryOptions] = - this.args.positional; + #currentOptions?: QueryOptions; + + /** @internal – do not call directly; used by the resource factory. */ + _start( + query: DocumentNode, + options: QueryOptions = {} as QueryOptions< + TData, + TVariables + >, + ): void { + this.#stopped = false; + this.#currentOptions = options; const client = getClient(this, options.clientId); const fastboot = getFastboot(this); @@ -82,6 +84,7 @@ export class QueryResource< const fetchPolicy = isSkipped ? 'standby' : options.fetchPolicy; if (isSkipped || fetchPolicy === 'standby') { + this.loading = false; if (firstResolve) { firstResolve(); firstResolve = undefined; @@ -97,20 +100,13 @@ export class QueryResource< // Apollo Client 4 defaults notifyOnNetworkStatusChange to true. // We preserve the AC3 default to avoid emitting intermediate loading // states during refetch/fetchMore, which would cause consumers relying - // on synchronous loading checks to see unexpected flickers. Flipping - // to the default true may be the correct approach, but that will require - // a breaking change that would require consumers to handle NetworkStatus - // transitions differently (e.g. refetch, fetchMore, poll). + // on synchronous loading checks to see unexpected flickers. notifyOnNetworkStatusChange: options.notifyOnNetworkStatusChange ?? false, } as ApolloClient.WatchQueryOptions); this._setObservable(observable); // Apollo Client 4: errors arrive via result.error, not the error callback. - // With notifyOnNetworkStatusChange defaulting to true in AC4, the observable - // emits an initial { loading: true } before data arrives. Gate on - // !result.loading so the promise resolves only after the first real result, - // keeping route model hooks and await patterns working correctly. this.#subscription = observable.subscribe((result) => { this.#onComplete(result); if (firstResolve && !result.loading) { @@ -129,16 +125,9 @@ export class QueryResource< } } - /** @internal */ - update(): void { - if (!equal(this.#previousPositionalArgs, this.args.positional)) { - this.teardown(); - this.setup(); - } - } - - /** @internal */ - teardown(): void { + /** @internal – do not call directly; used by the resource factory. */ + _stop(): void { + this.#stopped = true; if (this.#subscription) { this.#subscription.unsubscribe(); } @@ -148,18 +137,14 @@ export class QueryResource< } } - settled(): Promise { - return settled(this.promise); - } + // Arrow property so `this` is preserved when accessed through the Proxy. + settled = (): Promise => settled(this.promise); #onComplete(result: ObservableQuery.Result>): void { const { loading, error, data, networkStatus } = result; this.loading = loading; - // Cast: Apollo Client 4's result type includes DeepPartial to - // account for returnPartialData. We expose the stricter TData since - // consumers who opt into returnPartialData already expect partial shapes. - // If AC4 tightens this typing in a future version, revisit this cast. + // AC4 types data as DeepPartial for returnPartialData; cast to stricter TData. this.data = data as MaybeMasked | undefined; this.networkStatus = networkStatus; this.error = error; @@ -175,13 +160,11 @@ export class QueryResource< } #handleOnCompleteOrOnError(): void { - // We want to avoid calling the callbacks when this is destroyed. - // If the resource is destroyed, the callback context might not be defined anymore. - if (isDestroyed(this) || isDestroying(this)) { + if (this.#stopped) { return; } - const [, options] = this.args.positional; + const options = this.#currentOptions; const { onComplete, onError } = options || {}; const { data, error } = this; @@ -192,3 +175,81 @@ export class QueryResource< } } } + +export type { QueryState as QueryResource }; + +/** + * Create a query resource. Can be used with ember-resources' @use decorator + * or in templates via resourceFactory. + */ +export function queryResource< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>(thunk: () => QueryPositionalArgs) { + return resource(({ on, owner }) => { + let previousArgs: QueryPositionalArgs | undefined; + const state = new QueryState(); + setOwner(state, owner); + + const updateCache = createCache(() => { + const positionalArgs = thunk(); + if (!equal(previousArgs, positionalArgs)) { + if (previousArgs) state._stop(); + previousArgs = positionalArgs; + const [query, options] = positionalArgs; + state._start(query, options); + } + }); + + getValue(updateCache); + + on.cleanup(() => state._stop()); + + return new Proxy(state, { + get(target, key): unknown { + getValue(updateCache); + return Reflect.get(target, key, target); + }, + ownKeys(target): (string | symbol)[] { + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, key): PropertyDescriptor | undefined { + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + }); +} +resourceFactory(queryResource); + +/** + * Create a curried query resource factory. Call with a document to get a + * reusable resource that accepts options (or a thunk returning options). + * + * ```ts + * const userInfo = createQueryResource(USER_INFO); + * + * // In a class with @use: + * @use query = userInfo(() => ({ variables: { id: '1' } })); + * + * // In a template: + * {{#let (userInfo (hash variables=(hash id="1"))) as |q|}} ... {{/let}} + * ``` + */ +export function createQueryResource< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>(document: DocumentNode) { + function inner( + thunkOrOptions?: + | (() => QueryOptions | undefined) + | QueryOptions, + ) { + const optionsThunk = + typeof thunkOrOptions === 'function' + ? thunkOrOptions + : () => thunkOrOptions; + return queryResource(() => [document, optionsThunk()]); + } + resourceFactory(inner); + return inner; +} diff --git a/glimmer-apollo/src/-private/resource.ts b/glimmer-apollo/src/-private/resource.ts deleted file mode 100644 index 926605e..0000000 --- a/glimmer-apollo/src/-private/resource.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { assert } from '@ember/debug'; -import { - setHelperManager, - helperCapabilities, - createCache, - getValue, - setOwner, - destroy, - registerDestructor, - associateDestroyableChild, -} from '../environment.ts'; -import type { TemplateArgs } from './types'; -type Cache = ReturnType>; -import type Owner from '@ember/owner'; - -type HelperDefinition< - Args extends TemplateArgs = TemplateArgs< - readonly unknown[] - >, -> = new (owner: Owner, args: Args) => Resource & {}; - -type Thunk = (...args: any[]) => void; // eslint-disable-line - -export abstract class Resource< - Args extends TemplateArgs = TemplateArgs< - readonly unknown[] - >, -> { - protected readonly args!: Args; - - constructor(ownerOrThunk: Owner | Thunk, args: Args) { - if (typeof ownerOrThunk === 'function') { - // @ts-expect-error This is naughty. - return { definition: this.constructor, args: ownerOrThunk }; - } - - setOwner(this, ownerOrThunk); - this.args = args; - } - - setup(): void {} - - update?(): void; - teardown?(): void; -} - -class ResourceManager { - readonly capabilities = helperCapabilities('3.23', { - hasValue: true, - hasDestroyable: true, - }) as never; - - private readonly owner?: Owner; - - constructor(owner?: Owner) { - this.owner = owner; - } - - createHelper< - Args extends TemplateArgs = TemplateArgs< - readonly unknown[] - >, - >( - definition: - | { - Class: HelperDefinition; - owner: Owner; - } - | HelperDefinition, - args: Args, - ): Cache { - let owner = this.owner; - let Class: HelperDefinition; - - if ('Class' in definition) { - Class = definition.Class; - if (definition.owner) { - owner = definition.owner; - } - } else { - Class = definition; - } - - assert('Cannot create resource without an owner', owner); - - // eslint-disable-next-line @typescript-eslint/unbound-method - const { update, teardown } = Class.prototype as Resource; - - const hasUpdate = typeof update === 'function'; - const hasTeardown = typeof teardown === 'function'; - - let instance: Resource | undefined; - let cache: Cache; - - if (hasUpdate) { - cache = createCache(() => { - if (instance === undefined) { - instance = setupInstance(cache, Class, owner, args, hasTeardown); - } else { - instance.update!(); - } - - return instance; - }); - } else { - cache = createCache(() => { - if (instance !== undefined) { - destroy(instance); - } - - instance = setupInstance(cache, Class, owner, args, hasTeardown); - - return instance; - }); - } - - return cache; - } - - getValue(cache: Cache): Resource | undefined { - const instance = getValue(cache); - - return instance; - } - - getDestroyable(cache: Cache): Cache { - return cache; - } - - //eslint-disable-next-line - getDebugName(fn: (...args: any[]) => void): string { - return fn.name || '(anonymous function)'; - } -} - -function setupInstance>>( - cache: Cache, - Class: HelperDefinition, - owner: Owner, - args: TemplateArgs, - hasTeardown: boolean, -): T { - const instance = new Class(owner, args); - associateDestroyableChild(cache, instance); - instance.setup(); - - if (hasTeardown) { - registerDestructor(instance, () => instance.teardown!()); - } - - return instance as T; -} - -setHelperManager((owner: Owner | undefined) => { - return new ResourceManager(owner); -}, Resource); - -export const ResourceManagerFactory = (owner: Owner | undefined) => { - return new ResourceManager(owner); -}; diff --git a/glimmer-apollo/src/-private/subscription.ts b/glimmer-apollo/src/-private/subscription.ts index aa90a5e..a3029ff 100644 --- a/glimmer-apollo/src/-private/subscription.ts +++ b/glimmer-apollo/src/-private/subscription.ts @@ -1,11 +1,16 @@ +import { resource, resourceFactory } from 'ember-resources'; +import { equal } from '@wry/equality'; + import { getClient } from './client.ts'; import { - isDestroyed, - isDestroying, tracked, waitForPromise, + setOwner, + createCache, + getValue, } from '../environment.ts'; -import { Resource } from './resource.ts'; +import { getFastboot, createPromise, settled } from './utils.ts'; + import type { DocumentNode, ErrorLike, @@ -14,9 +19,6 @@ import type { SubscriptionOptions as ApolloSubscriptionOptions, } from '@apollo/client'; import type { Subscription } from 'rxjs'; -import { equal } from '@wry/equality'; -import { getFastboot, createPromise, settled } from './utils.ts'; -import type { TemplateArgs } from './types'; export type SubscriptionOptions< TData, @@ -34,24 +36,34 @@ export type SubscriptionPositionalArgs< TVariables extends OperationVariables = OperationVariables, > = [DocumentNode, SubscriptionOptions?]; -export class SubscriptionResource< +export class SubscriptionState< TData, TVariables extends OperationVariables = OperationVariables, -> extends Resource< - TemplateArgs> > { @tracked loading = true; @tracked error?: ErrorLike; @tracked data: MaybeMasked | undefined; @tracked promise!: Promise; + #stopped = false; + #completed = false; #subscription?: Subscription; - #previousPositionalArgs: typeof this.args.positional | undefined; + #firstPromiseReject: (() => unknown) | undefined; + #currentOptions?: SubscriptionOptions; + + /** True when a server subscription is active (not completed or stopped). */ + get hasActiveSubscription(): boolean { + return !this.#stopped && !this.#completed && !!this.#subscription; + } - /** @internal */ - setup(): void { - this.#previousPositionalArgs = this.args.positional; - const [query, options] = this.args.positional; + /** @internal – do not call directly; used by the resource factory. */ + _start( + query: DocumentNode, + options?: SubscriptionOptions, + ): void { + this.#stopped = false; + this.#completed = false; + this.#currentOptions = options; const client = getClient(this, options?.clientId); this.loading = true; @@ -62,6 +74,7 @@ export class SubscriptionResource< } let [promise, firstResolve, firstReject] = createPromise(); // eslint-disable-line prefer-const + this.#firstPromiseReject = firstReject; this.promise = promise; const observable = client.subscribe({ query, @@ -70,27 +83,29 @@ export class SubscriptionResource< this.#subscription = observable.subscribe({ next: (result) => { - if (isDestroyed(this) || isDestroying(this)) { + if (this.#stopped) { return; } this.#onNextResult(result); if (firstResolve) { firstResolve(); firstResolve = undefined; + this.#firstPromiseReject = undefined; } }, error: (error: unknown) => { - if (isDestroyed(this) || isDestroying(this)) { + if (this.#stopped) { return; } this.#onError(error); if (firstReject) { firstReject(); firstReject = undefined; + this.#firstPromiseReject = undefined; } }, complete: () => { - if (isDestroyed(this) || isDestroying(this)) { + if (this.#stopped) { return; } this.#onComplete(); @@ -107,27 +122,20 @@ export class SubscriptionResource< } } - /** @internal */ - update(): void { - if ( - !equal(this.#previousPositionalArgs, this.args.positional) || - !this.#subscription - ) { - this.teardown(); - this.setup(); - } - } - - /** @internal */ - teardown(): void { + /** @internal – do not call directly; used by the resource factory. */ + _stop(): void { + this.#stopped = true; if (this.#subscription) { this.#subscription.unsubscribe(); } + if (typeof this.#firstPromiseReject === 'function') { + this.#firstPromiseReject(); + this.#firstPromiseReject = undefined; + } } - settled(): Promise { - return settled(this.promise); - } + // Arrow property so `this` is preserved when accessed through the Proxy. + settled = (): Promise => settled(this.promise); #onNextResult(result: { data?: MaybeMasked; @@ -148,8 +156,7 @@ export class SubscriptionResource< this.data = data; } - const [, options] = this.args.positional; - const { onData } = options || {}; + const { onData } = this.#currentOptions || {}; if (onData) { onData(this.data); } @@ -160,8 +167,7 @@ export class SubscriptionResource< this.data = undefined; this.error = error instanceof Error ? error : new Error(String(error)); - const [, options] = this.args.positional; - const { onError } = options || {}; + const { onError } = this.#currentOptions || {}; if (onError) { onError(this.error); } @@ -170,15 +176,98 @@ export class SubscriptionResource< #onComplete(): void { this.loading = false; - const [, options] = this.args.positional; - const { onComplete } = options || {}; + const { onComplete } = this.#currentOptions || {}; if (onComplete) { onComplete(); } + this.#completed = true; if (this.#subscription) { this.#subscription.unsubscribe(); this.#subscription = undefined; } } } + +export type { SubscriptionState as SubscriptionResource }; + +/** + * Create a subscription resource. Can be used with ember-resources' @use decorator + * or in templates via resourceFactory. + */ +export function subscriptionResource< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>(thunk: () => SubscriptionPositionalArgs) { + return resource(({ on, owner }) => { + let previousArgs: SubscriptionPositionalArgs | undefined; + let finalized = false; + const state = new SubscriptionState(); + setOwner(state, owner); + + const updateCache = createCache(() => { + if (finalized) return; + const positionalArgs = thunk(); + if (!equal(previousArgs, positionalArgs)) { + if (previousArgs) state._stop(); + previousArgs = positionalArgs; + const [query, options] = positionalArgs; + state._start(query, options); + } + }); + + getValue(updateCache); + + on.cleanup(() => { + finalized = true; + state._stop(); + }); + + return new Proxy(state, { + get(target, key): unknown { + getValue(updateCache); + return Reflect.get(target, key, target); + }, + ownKeys(target): (string | symbol)[] { + return Reflect.ownKeys(target); + }, + getOwnPropertyDescriptor(target, key): PropertyDescriptor | undefined { + return Reflect.getOwnPropertyDescriptor(target, key); + }, + }); + }); +} +resourceFactory(subscriptionResource); + +/** + * Create a curried subscription resource factory. Call with a document to get a + * reusable resource that accepts options (or a thunk returning options). + * + * ```ts + * const onMessage = createSubscriptionResource(SUBSCRIPTION); + * + * // In a class with @use: + * @use sub = onMessage(() => ({ variables: { channel: 'general' } })); + * ``` + */ +export function createSubscriptionResource< + TData = unknown, + TVariables extends OperationVariables = OperationVariables, +>(document: DocumentNode) { + function inner( + thunkOrOptions?: + | (() => SubscriptionOptions | undefined) + | SubscriptionOptions, + ) { + const optionsThunk = + typeof thunkOrOptions === 'function' + ? thunkOrOptions + : () => thunkOrOptions; + return subscriptionResource(() => [ + document, + optionsThunk(), + ]); + } + resourceFactory(inner); + return inner; +} diff --git a/glimmer-apollo/src/-private/types.ts b/glimmer-apollo/src/-private/types.ts deleted file mode 100644 index 44934f5..0000000 --- a/glimmer-apollo/src/-private/types.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface TemplateArgs< - T extends readonly unknown[] = readonly unknown[], -> { - positional: T; - named: Record; -} - -export interface Fastboot { - isFastBoot: boolean; - deferRendering(promise: Promise): unknown; -} - -export declare function IWaitForPromise>( - promise: KindOfPromise, - label?: string, -): KindOfPromise; diff --git a/glimmer-apollo/src/-private/usables.ts b/glimmer-apollo/src/-private/usables.ts index 4bbda5c..750f31d 100644 --- a/glimmer-apollo/src/-private/usables.ts +++ b/glimmer-apollo/src/-private/usables.ts @@ -1,8 +1,12 @@ -import { useResource } from './use-resource.ts'; -import { type MutationPositionalArgs, MutationResource } from './mutation.ts'; -import { type QueryPositionalArgs, QueryResource } from './query.ts'; -import { - type SubscriptionPositionalArgs, +import { use } from 'ember-resources'; +import { queryResource } from './query.ts'; +import { mutationResource } from './mutation.ts'; +import { subscriptionResource } from './subscription.ts'; + +import type { QueryPositionalArgs, QueryResource } from './query.ts'; +import type { MutationPositionalArgs, MutationResource } from './mutation.ts'; +import type { + SubscriptionPositionalArgs, SubscriptionResource, } from './subscription.ts'; import type { OperationVariables } from '@apollo/client'; @@ -11,67 +15,28 @@ export function useQuery< TData = unknown, TVariables extends OperationVariables = OperationVariables, >( - parentDestroyable: object, + context: object, args: () => QueryPositionalArgs, ): QueryResource { - return useResource< - QueryPositionalArgs, - QueryResource - >(parentDestroyable, QueryResource, args); + return use(context, queryResource(args)).current; } export function useMutation< TData = unknown, TVariables extends OperationVariables = OperationVariables, >( - parentDestroyable: object, + context: object, args: () => MutationPositionalArgs, ): MutationResource { - return useResource< - MutationPositionalArgs, - MutationResource - >(parentDestroyable, MutationResource, args); + return use(context, mutationResource(args)).current; } export function useSubscription< TData = unknown, TVariables extends OperationVariables = OperationVariables, >( - parentDestroyable: object, + context: object, args: () => SubscriptionPositionalArgs, ): SubscriptionResource { - return useResource< - SubscriptionPositionalArgs, - SubscriptionResource - >(parentDestroyable, SubscriptionResource, args); + return use(context, subscriptionResource(args)).current; } - -export type UseQuery< - TData = unknown, - TVariables extends OperationVariables = OperationVariables, -> = { - args: () => QueryPositionalArgs[1]; - return: QueryResource; - data: TData; - variables: TVariables; -}; - -export type UseMutation< - TData = unknown, - TVariables extends OperationVariables = OperationVariables, -> = { - args: () => MutationPositionalArgs[1]; - return: MutationResource; - data: TData; - variables: TVariables; -}; - -export type UseSubscription< - TData = unknown, - TVariables extends OperationVariables = OperationVariables, -> = { - args: () => SubscriptionPositionalArgs[1]; - return: SubscriptionResource; - data: TData; - variables: TVariables; -}; diff --git a/glimmer-apollo/src/-private/use-resource.ts b/glimmer-apollo/src/-private/use-resource.ts deleted file mode 100644 index 43d999a..0000000 --- a/glimmer-apollo/src/-private/use-resource.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { getOwner } from '@ember/owner'; -import { - invokeHelper, - getValue, - setHelperManager, - createCache, -} from '../environment.ts'; -import { ResourceManagerFactory, Resource } from './resource.ts'; -import type { TemplateArgs } from './types'; -type Cache = ReturnType>; - -type Args = TemplateArgs | TemplateArgs['positional'] | TemplateArgs['named']; - -function normalizeArgs(args: Args): TemplateArgs { - if (Array.isArray(args)) { - return { positional: args, named: {} }; - } - - if ('positional' in args || 'named' in args) { - return { - positional: (args.positional as TemplateArgs['positional']) || [], - named: (args.named as TemplateArgs['named']) || {}, - }; - } - - if (typeof args === 'object') { - return { named: args as TemplateArgs['named'], positional: [] }; - } - - return args; -} - -export function useUnproxiedResource< - TArgs = Args, - T extends Resource = Resource, ->(context: object, Class: object, args?: () => TArgs): { value: T } { - let resource: Cache; - - return { - get value(): T { - if (!resource) { - const owner = getOwner(context); - const definition = { Class, owner }; - setHelperManager(ResourceManagerFactory, definition); - resource = invokeHelper(context, definition, () => { - return normalizeArgs(args?.() || {}); - }) as Cache; - } - - return getValue(resource)!; - }, - }; -} - -export function useResource< - TArgs = Args, - T extends Resource = Resource, ->(context: object, definition: object, args?: () => TArgs): T { - const target = useUnproxiedResource(context, definition, args); - - return new Proxy(target, { - get(target, key): unknown { - const instance = target.value; - const value = Reflect.get(instance, key, instance); - - return typeof value === 'function' ? value.bind(instance) : value; - }, - ownKeys(target): (string | symbol)[] { - return Reflect.ownKeys(target.value); - }, - getOwnPropertyDescriptor(target, key): PropertyDescriptor | undefined { - return Reflect.getOwnPropertyDescriptor(target.value, key); - }, - }) as never as T; -} diff --git a/glimmer-apollo/src/-private/utils.ts b/glimmer-apollo/src/-private/utils.ts index 1dd7926..4484e39 100644 --- a/glimmer-apollo/src/-private/utils.ts +++ b/glimmer-apollo/src/-private/utils.ts @@ -1,5 +1,9 @@ import { getOwner } from '../environment.ts'; -import type { Fastboot } from './types'; + +export interface Fastboot { + isFastBoot: boolean; + deferRendering(promise: Promise): unknown; +} function hasFastBoot(obj: unknown): obj is { FastBoot: unknown } { return Object.prototype.hasOwnProperty.call(obj, 'FastBoot'); diff --git a/glimmer-apollo/src/environment.ts b/glimmer-apollo/src/environment.ts index cdee570..8abd8f2 100644 --- a/glimmer-apollo/src/environment.ts +++ b/glimmer-apollo/src/environment.ts @@ -4,21 +4,9 @@ export { tracked } from '@glimmer/tracking'; import { getOwner as _getOwner } from '@ember/owner'; export { setOwner } from '@ember/owner'; export { getValue, createCache } from '@glimmer/tracking/primitives/cache'; -export { - isDestroying, - isDestroyed, - destroy, - registerDestructor, - associateDestroyableChild, -} from '@ember/destroyable'; +export { registerDestructor } from '@ember/destroyable'; export { waitForPromise } from '@ember/test-waiters'; -export { - invokeHelper, - setHelperManager, - capabilities as helperCapabilities, -} from '@ember/helper'; - export function getOwner(obj: object): Owner | undefined { if ( obj instanceof ApplicationInstance || diff --git a/glimmer-apollo/src/index.ts b/glimmer-apollo/src/index.ts index c41a4e4..3d45e13 100644 --- a/glimmer-apollo/src/index.ts +++ b/glimmer-apollo/src/index.ts @@ -5,12 +5,22 @@ export { clearClients, } from './-private/client.ts'; export { gql } from '@apollo/client'; + +// Resource factories (primary API — use with @use decorator or in templates) +export { queryResource, createQueryResource } from './-private/query.ts'; +export { + mutationResource, + createMutationResource, +} from './-private/mutation.ts'; +export { + subscriptionResource, + createSubscriptionResource, +} from './-private/subscription.ts'; + +// Convenience wrappers (for class-body usage without @use) export { useQuery, useMutation, useSubscription } from './-private/usables.ts'; -export type { - UseQuery, - UseMutation, - UseSubscription, -} from './-private/usables.ts'; + +// Types export type { QueryOptions, QueryResource, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c3e132..12c3ef5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: concurrently: specifier: ^9.2.1 version: 9.2.1 + ember-resources: + specifier: ^7.0.0 + version: 7.0.7(@glimmer/component@2.0.0)(@glint/template@1.7.3) ember-source: specifier: ^6.9.0 version: 6.9.0(@glimmer/component@2.0.0)(rsvp@4.8.5) @@ -409,6 +412,9 @@ importers: ember-resolver: specifier: ^13.1.1 version: 13.1.1 + ember-resources: + specifier: ^7.0.0 + version: 7.0.7(@glimmer/component@2.0.0)(@glint/template@1.7.3) ember-route-template: specifier: ^1.0.3 version: 1.0.3 diff --git a/test-app/app/components/experiment.gts b/test-app/app/components/experiment.gts index d4f3199..4e5e7e1 100644 --- a/test-app/app/components/experiment.gts +++ b/test-app/app/components/experiment.gts @@ -1,5 +1,6 @@ import Component from '@glimmer/component'; -import { useQuery, useMutation, gql } from 'glimmer-apollo'; +import { queryResource, mutationResource, gql } from 'glimmer-apollo'; +import { use } from 'ember-resources'; import { on } from '@ember/modifier'; import type { UserInfoQuery, @@ -27,7 +28,7 @@ const LOGIN = gql` `; export default class PlaygroundExperiment extends Component { - userInfo = useQuery(this, () => [ + @use userInfo = queryResource(() => [ USER_INFO, { variables: { id: '1-with-delay' }, @@ -36,8 +37,7 @@ export default class PlaygroundExperiment extends Component { }, ]); - userInfoWithSkip = useQuery( - this, + @use userInfoWithSkip = queryResource( () => [ USER_INFO, { @@ -49,7 +49,7 @@ export default class PlaygroundExperiment extends Component { ] ); - login = useMutation(this, () => [ + @use login = mutationResource(() => [ LOGIN, { variables: { diff --git a/test-app/package.json b/test-app/package.json index 5699b3e..c8b78ca 100644 --- a/test-app/package.json +++ b/test-app/package.json @@ -58,6 +58,7 @@ "decorator-transforms": "^2.2.2", "ember-auto-import": "^2.12.0", "ember-cli": "~6.9.1", + "ember-resources": "^7.0.0", "ember-cli-babel": "^8.2.0", "ember-cli-htmlbars": "^7.0.0", "ember-load-initializers": "^3.0.1", diff --git a/test-app/tests/integration/components/resource-factory-test.gts b/test-app/tests/integration/components/resource-factory-test.gts new file mode 100644 index 0000000..7d3a004 --- /dev/null +++ b/test-app/tests/integration/components/resource-factory-test.gts @@ -0,0 +1,823 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { click, render, settled, waitUntil } from '@ember/test-helpers'; +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { on } from '@ember/modifier'; +import { use } from 'ember-resources'; +import { setClient, gql } from 'glimmer-apollo'; +import { + queryResource, + mutationResource, + subscriptionResource, + createQueryResource, + createMutationResource, + createSubscriptionResource, +} from 'glimmer-apollo'; +import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'; +import { MockSubscriptionLink } from 'test-app/tests/helpers/mock-subscription-link'; +import { hash } from '@ember/helper'; +import type { + UserInfoQuery, + UserInfoQueryVariables, + LoginMutation, + LoginMutationVariables, + OnMessageAddedSubscription, + OnMessageAddedSubscriptionVariables, +} from 'test-app/mocks/handlers'; + +const USER_INFO = gql` + query UserInfo($id: ID!) { + user(id: $id) { + id + firstName + lastName + } + } +`; + +const LOGIN = gql` + mutation Login($username: String!) { + login(username: $username) { + id + firstName + lastName + } + } +`; + +const SUBSCRIPTION = gql` + subscription OnMessageAdded($channel: String!) { + messageAdded(channel: $channel) { + id + message + } + } +`; + +module('Integration | Components | Resource Factories', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new HttpLink({ uri: '/graphql' }), + }); + setClient(this.owner, client); + }); + + test('queryResource renders loading then data in template', async function (assert) { + class TestComponent extends Component { + @use query = queryResource(() => [ + USER_INFO, + { variables: { id: '1' } }, + ]); + + + } + + await render(); + + assert + .dom('[data-test-id="data"]') + .hasText('Cathaline', 'renders fetched data'); + }); + + test('queryResource re-renders when tracked args change', async function (assert) { + class State { + @tracked id = '1'; + } + const state = new State(); + + class TestComponent extends Component { + @use query = queryResource(() => [ + USER_INFO, + { variables: { id: state.id } }, + ]); + + + } + + await render(); + + assert.dom('[data-test-id="name"]').hasText('Cathaline', 'renders user 1'); + + // Change tracked arg + state.id = '2'; + + await settled(); + + assert + .dom('[data-test-id="name"]') + .hasText('Joth', 'renders user 2 after arg change'); + }); + + test('queryResource renders error state in template', async function (assert) { + class TestComponent extends Component { + @use query = queryResource(() => [ + USER_INFO, + { variables: { id: 'NOT_FOUND' } }, + ]); + + + } + + await render(); + + assert + .dom('[data-test-id="error"]') + .hasText('User not found', 'renders error message'); + }); + + test('mutationResource works in template with click handler', async function (assert) { + class TestComponent extends Component { + @use login = mutationResource( + () => [LOGIN, { variables: { username: 'john' } }] + ); + + doLogin = (): void => { + this.login.mutate(); + }; + + + } + + await render(); + + assert + .dom('[data-test-id="result"]') + .doesNotExist('no result before mutate'); + + await click('[data-test-id="mutate-btn"]'); + + assert + .dom('[data-test-id="result"]') + .hasText('Joth', 'renders mutation result'); + }); + + test('mutationResource prevents callbacks after component teardown', async function (assert) { + let onCompleteCalled = false; + let onErrorCalled = false; + + class State { + @tracked show = true; + } + const state = new State(); + + class TestComponent extends Component { + @use login = mutationResource( + () => [ + LOGIN, + { + variables: { username: 'john' }, + onComplete: () => { + onCompleteCalled = true; + }, + onError: () => { + onErrorCalled = true; + }, + }, + ] + ); + + doLogin = (): void => { + this.login.mutate(); + }; + + + } + + await render( + + ); + + // Fire the mutation via a native DOM click (not the test-helper click, + // which calls settled() internally and waits for the mutation to finish). + // This starts the HTTP request synchronously, then we immediately tear + // down the component so _stop() is called before the response arrives. + const btn = document.querySelector( + '[data-test-id="mutate-teardown-btn"]' + ) as HTMLElement; + btn.click(); + state.show = false; + await settled(); + + assert + .dom('[data-test-id="teardown-mutation"]') + .doesNotExist('component removed'); + assert.false(onCompleteCalled, 'onComplete should not fire after teardown'); + assert.false(onErrorCalled, 'onError should not fire after teardown'); + }); +}); + +module( + 'Integration | Components | Template-Only Resource Factories', + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new HttpLink({ uri: '/graphql' }), + }); + setClient(this.owner, client); + }); + + test('createQueryResource in template-only with direct args', async function (assert) { + const options = { variables: { id: '1' } }; + + await render( + + ); + + assert + .dom('[data-test-id="data"]') + .hasText( + 'Cathaline', + 'renders fetched data via template-only invocation' + ); + }); + + test('createQueryResource in template-only with (hash) helper for variables', async function (assert) { + await render( + + ); + + assert + .dom('[data-test-id="data"]') + .hasText( + 'Joth', + 'renders user 2 via template-only invocation with hash helper' + ); + }); + } +); + +module('Integration | Components | subscriptionResource', function (hooks) { + setupRenderingTest(hooks); + + let link: MockSubscriptionLink; + + hooks.beforeEach(function () { + link = new MockSubscriptionLink(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + }); + setClient(this.owner, client); + }); + + test('subscriptionResource cleans up on component teardown', async function (assert) { + let unsubscribed = false; + link.onUnsubscribe(() => { + unsubscribed = true; + }); + + class State { + @tracked show = true; + } + const state = new State(); + + class TestComponent extends Component { + @use sub = subscriptionResource< + OnMessageAddedSubscription, + OnMessageAddedSubscriptionVariables + >(() => [SUBSCRIPTION, { variables: { channel: 'teardown' } }]); + + + } + + link.simulateResult({ + result: { + data: { + messageAdded: { __typename: 'Message', id: '0', message: 'Hello' }, + }, + }, + }); + + await render( + + ); + + assert + .dom('[data-test-id="message"]') + .hasText('Hello', 'subscription data rendered'); + assert.false(unsubscribed, 'not yet unsubscribed'); + + // Tear down the component by hiding it + state.show = false; + await settled(); + + assert + .dom('[data-test-id="teardown-test"]') + .doesNotExist('component removed'); + assert.true(unsubscribed, 'subscription was unsubscribed on teardown'); + }); + + test('subscriptionResource re-subscribes when tracked args change', async function (assert) { + const channels: string[] = []; + link.onSetup(() => { + channels.push(link.operation?.variables['channel'] as string); + }); + + class State { + @tracked channel = 'general'; + } + const state = new State(); + + class TestComponent extends Component { + @use sub = subscriptionResource< + OnMessageAddedSubscription, + OnMessageAddedSubscriptionVariables + >(() => [SUBSCRIPTION, { variables: { channel: state.channel } }]); + + + } + + link.simulateResult({ + result: { + data: { + messageAdded: { + __typename: 'Message', + id: '0', + message: 'Hello General', + }, + }, + }, + }); + + await render(); + + assert + .dom('[data-test-id="message"]') + .hasText('Hello General', 'renders first channel message'); + + // Change tracked arg — should unsubscribe old and re-subscribe with new channel + state.channel = 'random'; + + // The new subscription needs a result to render + link.simulateResult({ + result: { + data: { + messageAdded: { + __typename: 'Message', + id: '1', + message: 'Hello Random', + }, + }, + }, + }); + + await waitUntil( + () => { + const el = document.querySelector('[data-test-id="message"]'); + return el?.textContent.trim() === 'Hello Random'; + }, + { timeout: 2000 } + ); + + assert + .dom('[data-test-id="message"]') + .hasText('Hello Random', 'renders new channel message after arg change'); + assert.true( + channels.length >= 2, + 'subscription was re-created with new args' + ); + }); + + test('subscriptionResource renders subscription data in template', async function (assert) { + class TestComponent extends Component { + @use sub = subscriptionResource< + OnMessageAddedSubscription, + OnMessageAddedSubscriptionVariables + >(() => [SUBSCRIPTION, { variables: { channel: 'general' } }]); + + + } + + // Queue first result before render + link.simulateResult({ + result: { + data: { + messageAdded: { + __typename: 'Message', + id: '0', + message: 'Hello World', + }, + }, + }, + }); + + await render(); + + assert + .dom('[data-test-id="message"]') + .hasText('Hello World', 'renders first subscription message'); + + // Simulate a second message + link.simulateResult({ + result: { + data: { + messageAdded: { + __typename: 'Message', + id: '1', + message: 'Second Message', + }, + }, + }, + }); + + await waitUntil( + () => { + const el = document.querySelector('[data-test-id="message"]'); + return el?.textContent.trim() === 'Second Message'; + }, + { timeout: 2000 } + ); + + assert + .dom('[data-test-id="message"]') + .hasText('Second Message', 'renders updated subscription message'); + }); +}); + +// Curried resource factory tests (createQueryResource / createMutationResource / createSubscriptionResource) + +const userInfo = createQueryResource( + USER_INFO +); +const loginMutation = createMutationResource< + LoginMutation, + LoginMutationVariables +>(LOGIN); + +module( + 'Integration | Components | Curried Resource Factories', + function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + const client = new ApolloClient({ + cache: new InMemoryCache(), + link: new HttpLink({ uri: '/graphql' }), + }); + setClient(this.owner, client); + }); + + test('createQueryResource with @use and thunk options', async function (assert) { + class TestComponent extends Component { + @use query = userInfo(() => ({ variables: { id: '1' } })); + + + } + + await render(); + + assert + .dom('[data-test-id="data"]') + .hasText('Cathaline', 'renders fetched data via curried factory'); + }); + + test('createQueryResource re-renders when tracked args change', async function (assert) { + class State { + @tracked id = '1'; + } + const state = new State(); + + class TestComponent extends Component { + @use query = userInfo(() => ({ variables: { id: state.id } })); + + + } + + await render(); + + assert + .dom('[data-test-id="name"]') + .hasText('Cathaline', 'renders user 1'); + + state.id = '2'; + await settled(); + + assert + .dom('[data-test-id="name"]') + .hasText('Joth', 'renders user 2 after arg change'); + }); + + test('createQueryResource in template-only with (hash) helper', async function (assert) { + await render( + + ); + + assert + .dom('[data-test-id="data"]') + .hasText('Joth', 'renders user 2 via curried factory in template'); + }); + + test('createMutationResource with @use', async function (assert) { + class TestComponent extends Component { + @use login = loginMutation(() => ({ variables: { username: 'john' } })); + + doLogin = (): void => { + this.login.mutate(); + }; + + + } + + await render(); + + assert + .dom('[data-test-id="result"]') + .doesNotExist('no result before mutate'); + + await click('[data-test-id="mutate-btn"]'); + + assert + .dom('[data-test-id="result"]') + .hasText('Joth', 'renders mutation result via curried factory'); + }); + } +); + +module( + 'Integration | Components | Curried subscriptionResource', + function (hooks) { + setupRenderingTest(hooks); + + let link: MockSubscriptionLink; + + hooks.beforeEach(function () { + link = new MockSubscriptionLink(); + const client = new ApolloClient({ + cache: new InMemoryCache(), + link, + }); + setClient(this.owner, client); + }); + + test('createSubscriptionResource with @use', async function (assert) { + const onMessageAdded = createSubscriptionResource< + OnMessageAddedSubscription, + OnMessageAddedSubscriptionVariables + >(SUBSCRIPTION); + + class TestComponent extends Component { + @use sub = onMessageAdded(() => ({ + variables: { channel: 'general' }, + })); + + + } + + link.simulateResult({ + result: { + data: { + messageAdded: { + __typename: 'Message', + id: '0', + message: 'Hello Curried', + }, + }, + }, + }); + + await render(); + + assert + .dom('[data-test-id="message"]') + .hasText( + 'Hello Curried', + 'renders subscription data via curried factory' + ); + }); + + test('createSubscriptionResource in template-only with (hash) helper', async function (assert) { + const onMessageAdded = createSubscriptionResource< + OnMessageAddedSubscription, + OnMessageAddedSubscriptionVariables + >(SUBSCRIPTION); + + link.simulateResult({ + result: { + data: { + messageAdded: { + __typename: 'Message', + id: '0', + message: 'Template Sub', + }, + }, + }, + }); + + await render( + + ); + + assert + .dom('[data-test-id="message"]') + .hasText( + 'Template Sub', + 'renders subscription data via curried factory in template-only context' + ); + }); + } +); diff --git a/test-app/tests/unit/custom-query-test.ts b/test-app/tests/unit/custom-query-test.ts index 7b45580..ff2f5f2 100644 --- a/test-app/tests/unit/custom-query-test.ts +++ b/test-app/tests/unit/custom-query-test.ts @@ -2,14 +2,14 @@ import { setOwner } from '@ember/owner'; import type Owner from '@ember/owner'; import { destroy } from '@ember/destroyable'; import { tracked } from '@glimmer/tracking'; +import { use } from 'ember-resources'; import { getClient, gql, type QueryPositionalArgs, - type QueryResource, setClient, - useQuery, + queryResource, } from 'glimmer-apollo'; import { module, test } from 'qunit'; @@ -28,13 +28,10 @@ import { type UserInfoQueryVariables, } from '../../app/mocks/handlers'; -function useCustomQuery< +function customQueryResource< TData = unknown, TVariables extends OperationVariables = OperationVariables, ->( - parentDestroyable: object, - args: () => QueryPositionalArgs -): QueryResource { +>(args: () => QueryPositionalArgs) { const customArgs: () => QueryPositionalArgs = function () { const passedArgs = args(); const options = Object.assign( @@ -47,7 +44,7 @@ function useCustomQuery< return [passedArgs[0], options]; }; - return useQuery(parentDestroyable, customArgs); + return queryResource(customArgs); } const USER_INFO = gql` @@ -60,7 +57,7 @@ const USER_INFO = gql` } `; -module('useCustomQuery', function (hooks) { +module('customQueryResource', function (hooks) { let ctx = {}; const owner: Owner = {} as Owner; @@ -82,10 +79,12 @@ module('useCustomQuery', function (hooks) { }); test('it fetches the default query', async function (assert) { - const query = useCustomQuery( + const query = use( ctx, - () => [USER_INFO] - ); + customQueryResource(() => [ + USER_INFO, + ]) + ).current; assert.equal(query.loading, true); assert.equal(query.data, undefined); @@ -103,15 +102,15 @@ module('useCustomQuery', function (hooks) { }); test('it fetches the query', async function (assert) { - const query = useCustomQuery( + const query = use( ctx, - () => [ + customQueryResource(() => [ USER_INFO, { variables: { id: '1' }, }, - ] - ); + ]) + ).current; assert.equal(query.loading, true); assert.equal(query.data, undefined); @@ -134,15 +133,15 @@ module('useCustomQuery', function (hooks) { } const vars = new Obj(); - const query = useCustomQuery( + const query = use( ctx, - () => [ + customQueryResource(() => [ USER_INFO, { variables: { id: vars.id }, }, - ] - ); + ]) + ).current; assert.equal(query.loading, true); assert.equal(query.data, undefined); @@ -159,15 +158,15 @@ module('useCustomQuery', function (hooks) { }); test('it returns error', async function (assert) { - const query = useCustomQuery( + const query = use( ctx, - () => [ + customQueryResource(() => [ USER_INFO, { variables: { id: 'NOT_FOUND' }, }, - ] - ); + ]) + ).current; assert.equal(query.loading, true); assert.equal(query.data, undefined); @@ -180,9 +179,9 @@ module('useCustomQuery', function (hooks) { test('it calls onComplete', async function (assert) { let onCompleteCalled: unknown; - const query = useCustomQuery( + const query = use( ctx, - () => [ + customQueryResource(() => [ USER_INFO, { variables: { id: '2' }, @@ -190,8 +189,8 @@ module('useCustomQuery', function (hooks) { onCompleteCalled = data; }, }, - ] - ); + ]) + ).current; assert.equal(query.data, undefined); await query.settled(); @@ -211,9 +210,9 @@ module('useCustomQuery', function (hooks) { test('it calls onError', async function (assert) { let onErrorCalled: ErrorLike | undefined; - const query = useCustomQuery( + const query = use( ctx, - () => [ + customQueryResource(() => [ USER_INFO, { variables: { id: 'NOT_FOUND' }, @@ -221,8 +220,8 @@ module('useCustomQuery', function (hooks) { onErrorCalled = error; }, }, - ] - ); + ]) + ).current; assert.equal(query.error, undefined); await query.settled(); @@ -235,9 +234,9 @@ module('useCustomQuery', function (hooks) { test('it returns error with data', async function (assert) { let onCompleteCalled: unknown; let onErrorCalled: ErrorLike | undefined; - const query = useCustomQuery( + const query = use( ctx, - () => [ + customQueryResource(() => [ USER_INFO, { variables: { id: '2-with-error' }, @@ -249,8 +248,8 @@ module('useCustomQuery', function (hooks) { onErrorCalled = error; }, }, - ] - ); + ]) + ).current; assert.equal(query.data, undefined); await query.settled(); @@ -285,15 +284,15 @@ module('useCustomQuery', function (hooks) { const client = getClient(ctx); const watchQuery = sandbox.spy(client, 'watchQuery'); - const query = useCustomQuery( + const query = use( ctx, - () => [ + customQueryResource(() => [ USER_INFO, { variables: { id: vars.id }, }, - ] - ); + ]) + ).current; assert.equal(query.data, undefined); await query.settled(); @@ -324,16 +323,16 @@ module('useCustomQuery', function (hooks) { const defaultClientWatchQuery = sandbox.spy(defaultClient, 'watchQuery'); const customClientWatchQuery = sandbox.spy(customClient, 'watchQuery'); - const query = useCustomQuery( + const query = use( ctx, - () => [ + customQueryResource(() => [ USER_INFO, { variables: { id: vars.id }, clientId: 'custom-client', }, - ] - ); + ]) + ).current; await query.settled(); assert.ok( diff --git a/test-app/tests/unit/query-test.ts b/test-app/tests/unit/query-test.ts index 8fc5202..11707b0 100644 --- a/test-app/tests/unit/query-test.ts +++ b/test-app/tests/unit/query-test.ts @@ -463,4 +463,48 @@ module('useQuery', function (hooks) { sandbox.restore(); }); + + test('refetch() on a skipped query permanently un-skips it', async function (assert) { + // In Apollo Client 4, refetch() on a standby query uses reobserve + // to switch fetchPolicy from 'standby' to 'network-only'. This is + // permanent — the query is no longer skipped after refetch(). + const query = useQuery(ctx, () => [ + USER_INFO, + { + variables: { id: '1' }, + skip: true, + }, + ]); + + assert.equal(query.loading, false, 'initially not loading (skipped)'); + assert.equal(query.data, undefined, 'no data while skipped'); + + query.refetch(); + await waitUntil(() => query.data !== undefined); + + assert.equal(query.loading, false, 'loaded after refetch'); + assert.equal(query.data?.user?.id, '1', 'has data after refetch'); + + // Write directly to the cache — if the query is still in standby, + // this update would not reach our subscriber. + client.cache.writeQuery({ + query: USER_INFO, + variables: { id: '1' }, + data: { + user: { + __typename: 'User', + id: '1', + firstName: 'Updated', + lastName: 'McCoy', + }, + }, + }); + + await waitUntil(() => query.data?.user?.firstName === 'Updated'); + assert.equal( + query.data?.user?.firstName, + 'Updated', + 'query receives cache updates after refetch (no longer skipped)' + ); + }); });