Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,21 @@ export default class Messages extends Component<Signature> {
}
```

### 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.
Expand Down
67 changes: 67 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TData, TVariables extends OperationVariables>(
args: () => QueryPositionalArgs<TData, TVariables>
) {
return queryResource<TData, TVariables>(() => {
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<UserInfoQuery, UserInfoQueryVariables>(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 |
Expand All @@ -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 |
17 changes: 17 additions & 0 deletions docs/fetching/mutations.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,20 @@ export default class CreateNote extends Component {
</template>
}
```

## 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<CreateNoteMutation, CreateNoteMutationVariables>(
() => [CREATE_NOTE]
);
}
```

See the [ember-resources documentation](https://ember-resources.pages.dev/) for more on the `@use` decorator and resource patterns.
18 changes: 18 additions & 0 deletions docs/fetching/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<GetNotesQuery, GetNotesQueryVariables>(() => [
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.
21 changes: 21 additions & 0 deletions docs/fetching/subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion glimmer-apollo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
130 changes: 101 additions & 29 deletions glimmer-apollo/src/-private/mutation.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,7 +12,6 @@ import type {
OperationVariables,
MaybeMasked,
} from '@apollo/client';
import type { TemplateArgs } from './types';

type Maybe<T> = T | undefined | null;

Expand All @@ -34,17 +29,34 @@ export type MutationPositionalArgs<
TVariables extends OperationVariables = OperationVariables,
> = [DocumentNode, MutationOptions<TData, TVariables>?];

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<TemplateArgs<MutationPositionalArgs<TData, TVariables>>> {
> {
@tracked loading = false;
@tracked called = false;
@tracked error?: ErrorLike;
@tracked data: Maybe<MaybeMasked<TData>>;
@tracked promise!: Promise<Maybe<MaybeMasked<TData>>>;

async mutate(
#stopped = false;
#getArgs: () => MutationPositionalArgs<TData, TVariables>;

constructor(getArgs: () => MutationPositionalArgs<TData, TVariables>) {
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<TData, TVariables>,
Expand All @@ -53,12 +65,16 @@ export class MutationResource<
MutationOptions<TData, TVariables>,
'variables' | 'mutation'
>,
): Promise<Maybe<MaybeMasked<TData>>> {
): Promise<Maybe<MaybeMasked<TData>>> => {
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) {
Expand All @@ -76,47 +92,49 @@ export class MutationResource<
} as ApolloMutationOptions<TData, TVariables>),
)
.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<void> {
return settled(this.promise);
}
// Arrow property so `this` is preserved when accessed through the Proxy.
settled = (): Promise<void> => settled(this.promise);

#onComplete(result: MutateResult<MaybeMasked<TData>>): void {
#onComplete(
result: MutateResult<MaybeMasked<TData>>,
onComplete?: (data: Maybe<MaybeMasked<TData>>) => 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<MaybeMasked<TData>>) => 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) {
Expand All @@ -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<TData, TVariables>) {
return resource(({ on, owner }) => {
const state = new MutationState<TData, TVariables>(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<LoginMutation, LoginMutationVariables>(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<TData, TVariables> | undefined)
| MutationOptions<TData, TVariables>,
) {
const optionsThunk =
typeof thunkOrOptions === 'function'
? thunkOrOptions
: () => thunkOrOptions;
return mutationResource<TData, TVariables>(() => [
document,
optionsThunk(),
]);
}
resourceFactory(inner);
return inner;
}
Loading