Skip to content

Commit

Permalink
Optimistic update types (#490)
Browse files Browse the repository at this point in the history
* document api url config removal

* more doc tweaks

* generate all optional optimistic update type

* undo parameter name tweak

* build errors

* typo

* document "incomplete" optimistic response types

* added changeset

* add missing type parameter

* update integration tests with new optimistic timing

* tweak documentation in mutation api section
  • Loading branch information
AlecAivazis authored Aug 27, 2022
1 parent 5d581c2 commit 71caba7
Show file tree
Hide file tree
Showing 22 changed files with 243 additions and 119 deletions.
5 changes: 5 additions & 0 deletions .changeset/many-horses-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'houdini': patch
---

fix generated types for optimistic responses
4 changes: 4 additions & 0 deletions integration/api/graphql.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ export const resolvers = {
return user;
},
updateUser: async (_, args) => {
if (args.delay) {
await sleep(args.delay);
}

const list = getSnapshot(args.snapshot);
const userIndex = list.findIndex((c) => c.id === `${args.snapshot}:${args.id}`);
if (userIndex === -1) {
Expand Down
2 changes: 1 addition & 1 deletion integration/api/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type Mutation {
enumValue: MyEnum
types: [TypeOfUser!]
): User!
updateUser(id: ID!, name: String, snapshot: String!, birthDate: DateTime): User!
updateUser(id: ID!, name: String, snapshot: String!, birthDate: DateTime, delay: Int): User!
}

interface Node {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
mutation UpdateUser($id: ID!, $name: String, $birthDate: DateTime) {
updateUser(id: $id, name: $name, birthDate: $birthDate, snapshot: "store-user-query") {
updateUser(
id: $id
name: $name
birthDate: $birthDate
snapshot: "update-user-mutation"
delay: 1000
) {
id
name
birthDate
Expand Down
2 changes: 1 addition & 1 deletion integration/src/routes/stores/mutation-scalars/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ test.describe('mutation store', function () {
// make sure that the result updated with unmarshaled data
await expectToBe(
page,
'{"updateUser":{"id":"store-user-query:6","name":"Harrison Ford","birthDate":"1986-11-07T00:00:00.000Z"}}'
'{"updateUser":{"id":"update-user-mutation:6","name":"Harrison Ford","birthDate":"1986-11-07T00:00:00.000Z"}}'
);
});
});
15 changes: 10 additions & 5 deletions integration/src/routes/stores/mutation-update/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
<script lang="ts">
import { GQL_UpdateUser } from '$houdini';
import { GQL_UpdateUser, graphql, MutationUpdateUsersListStore } from '$houdini';
import { stry } from '@kitql/helper';
import type { PageData } from './$types';
export let data: PageData;
$: ({ usersList } = data);
const usersList: MutationUpdateUsersListStore = graphql`
query MutationUpdateUsersList {
usersList(limit: 5, snapshot: "update-user-mutation") {
id
name
...UserInfo
}
}
`;
async function update() {
await GQL_UpdateUser.mutate({
Expand Down
9 changes: 0 additions & 9 deletions integration/src/routes/stores/mutation-update/+page.ts

This file was deleted.

20 changes: 10 additions & 10 deletions integration/src/routes/stores/mutation-update/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,19 @@ test.describe('Mutation Update Page', () => {
await goto(page, routes.Stores_Mutation_Update);

const data = [
'store-user-query:1 - Bruce Willis',
'store-user-query:2 - Samuel Jackson',
'store-user-query:3 - Morgan Freeman',
'store-user-query:4 - Tom Hanks',
'store-user-query:5 - Will Smith'
'update-user-mutation:1 - Bruce Willis',
'update-user-mutation:2 - Samuel Jackson',
'update-user-mutation:3 - Morgan Freeman',
'update-user-mutation:4 - Tom Hanks',
'update-user-mutation:5 - Will Smith'
];

const dataUpdated = [
'store-user-query:1 - Bruce Willis',
'store-user-query:2 - Samuel Jackson',
'store-user-query:3 - Morgan Freeman',
'store-user-query:4 - Tom Hanks',
'store-user-query:5 - tmp name update'
'update-user-mutation:1 - Bruce Willis',
'update-user-mutation:2 - Samuel Jackson',
'update-user-mutation:3 - Morgan Freeman',
'update-user-mutation:4 - Tom Hanks',
'update-user-mutation:5 - tmp name update'
];

// 1 Right data
Expand Down
31 changes: 21 additions & 10 deletions integration/src/routes/stores/mutation/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
<script lang="ts">
import { GQL_AddUser } from '$houdini';
import { graphql, OptimisticUserQueryStore, GQL_UpdateUser } from '$houdini';
import { stry } from '@kitql/helper';
const query: OptimisticUserQueryStore = graphql`
query OptimisticUserQuery {
user(id: "1", snapshot: "update-user-mutation") {
name
}
}
`;
async function add() {
await GQL_AddUser.mutate(
{ name: 'JYC', birthDate: new Date('1986-11-07'), delay: 1000 },
await GQL_UpdateUser.mutate(
{ id: '1', name: 'JYC', birthDate: new Date('1986-11-07') },
{
optimisticResponse: {
addUser: {
id: '???',
name: '...optimisticResponse... I could have guessed JYC!',
birthDate: new Date('1986-11-07')
updateUser: {
id: 'update-user-mutation:1',
name: '...optimisticResponse... I could have guessed JYC!'
}
}
}
Expand All @@ -22,6 +29,10 @@

<button id="mutate" on:click={add}>Add User</button>

<pre>
{stry($GQL_AddUser)}
</pre>
<div id="result">
{$query.data?.user.name}
</div>

<div id="store-value">
{stry($GQL_UpdateUser)}
</div>
26 changes: 4 additions & 22 deletions integration/src/routes/stores/mutation/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ test.describe('Mutation Page', () => {
isOptimisticResponse: false,
variables: null
};
await expectToBe(page, stry(defaultStoreValues) ?? '', 'pre');
await expectToBe(page, stry(defaultStoreValues)!, '[id="store-value"]');
});

test('Add User + Optimistic + Result', async ({ page }) => {
Expand All @@ -24,30 +24,12 @@ test.describe('Mutation Page', () => {
// Await the click to have optimisticResponse data in the store
await locator_click(page, `button[id="mutate"]`);

const optiStoreValues = {
data: {
addUser: {
birthDate: new Date('1986-11-07'),
id: '???',
name: '...optimisticResponse... I could have guessed JYC!'
}
},
errors: null,
isFetching: true,
isOptimisticResponse: true,
variables: {
birthDate: new Date('1986-11-07'),
delay: 1000,
name: 'JYC'
}
};
await expectToBe(page, stry(optiStoreValues) ?? '', 'pre');
await expectToBe(page, '...optimisticResponse... I could have guessed JYC!');

// 2 Real Response
await sleep(2000); // The fake delai is of 1 sec

const pre = await page.locator('pre').textContent();
expect(pre?.trim(), 'Content of <pre> element').not.toContain('...optimisticResponse...'); // So it's the real response (id can change... That's why we don't compare a full result)
expect(pre?.trim(), 'Content of <pre> element').toContain('"isOptimisticResponse": false');
const div = await page.locator('div[id=result]').textContent();
expect(div?.trim()).not.toContain('...optimisticResponse...'); // So it's the real response (id can change... That's why we don't compare a full result)
});
});
5 changes: 2 additions & 3 deletions site/src/routes/api/config.svx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,10 @@ It can contain the following values:
- `exclude` (optional): a pattern that filters out files that match the include pattern
- `projectDir` (optional, default: `process.cwd()`): an absolute path pointing to your SvelteKit project (useful for monorepos)
- `schemaPath` (optional, default: `"./schema.graphql"`): the path to the static representation of your schema, can be a glob pointing to multiple files. One of `schema` or `schemaPath` is required
- `schema` (optional): a string containing your entire schema (mostly useful for testing). One of `schema` or `schemaPath` is required
- `apiUrl` (optional): A url to use to pull the schema. If you don't pass an `apiUrl`, the kit plugin will not poll for schema changes. For more information see the [pull-schema command docs](/api/cli#pull-schema).
- `framework` (optional, default: `"kit"`): Either `"kit"` or `"svelte"`. Used to tell the preprocessor what kind of loading paradigm to generate for you. (default: `kit`)
- `module` (optional, default: `"esm"`): One of `"esm"` or `"commonjs"`. Used to tell the artifact generator what kind of modules to create. (default: `esm`)
- `definitionsPath` (optional, default: `"$houdini/graphql"`): a path that the generator will use to write `schema.graphql` and `documents.gql` files containing all of the internal fragment and directive definitions used in the project.
- `apiUrl` (optional): A url to use to pull the schema. If you don't pass an `apiUrl`, the kit plugin will not poll for schema changes. For more information see the [pull-schema command docs](/api/cli#pull-schema).
- `scalars` (optional): An object describing custom scalars for your project (see below).
- `cacheBufferSize` (optional, default: `10`): The number of queries that must occur before a value is removed from the cache. For more information, see the [Caching Guide](/guides/caching-data).
- `defaultCachePolicy` (optional, default: `"CacheOrNetwork"`): The default cache policy to use for queries. For a list of the policies or other information see the [Caching Guide](/guides/caching-data).
Expand All @@ -32,7 +31,7 @@ It can contain the following values:
- `disableMasking` (optional, default: `false`): A boolean indicating whether fields from referenced fragments should be included in a document's selection set
- `schemaPollHeaders` (optional): An object specifying the headers to use when pulling your schema. Keys of the object are header names and its values can be either a string pointing to the environment variable to use as the header value or a function that takes the current `process.env` and returns the the value to use.
- `schemaPollInterval` (optional, default: `2000`): Configures the schema polling behavior for the kit plugin. If its value is greater than `0`, the plugin will poll the set number of milliseconds. If set to `0`, the plugin will only pull the schema when you first run `dev`. If you set to `null`, the plugin will never look for schema changes. You can see use the [pull-schema command] to get updates.
- `globalStorePrefix` (optional, default: `GQL_`): The default prefix of your global stores. Note: it's nice to have a prefix so that your editor finds all your stores by just typings this prefix.
- `globalStorePrefix` (optional, default: `GQL_`): The default prefix of your global stores. This lets your editor provide autocompletion with just a few characters.

## Custom Scalars

Expand Down
67 changes: 33 additions & 34 deletions site/src/routes/api/mutation.svx
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,12 @@ Send a mutation to the server and updating your client-side cache with any chang
</button>
```

Mutation stores provide a `mutate` method that invokes the specified mutation with the inputs specified by the first argument to the handler. The second argument to the result of `mutation` can be used to configure its behavior. The following values can be provided:
Mutation stores provide a `mutate` method that invokes the specified mutation with the inputs
specified by the first argument to the handler. The second argument to the result of `mutation`
can be used to configure its behavior. The following values can be provided:

- `optimisticResponse` specifies a value to use instead of waiting for the server to return a value. For more information, see the [optimistic responses](#optimistic-responses) section.
- `optimisticResponse` specifies a value to use instead of waiting for the server to return a value.
For more information, see the [optimistic responses](#optimistic-responses) section.

### Notes

Expand Down Expand Up @@ -107,7 +110,11 @@ Take for example, an `TodoItemRow` component:

## Optimistic Responses

A lot of the time we know the value that a mutation will trigger assuming everything goes right. For example, a `toggleItem` mutation in a todo list will invert the value of the `checked` field of a particular item. In these situations, we don't have to wait for a mutation to resolve in order to apply the update to the cache. Instead, we can assume that it will succeed and provide an "optimistic response" for the mutation with the second argument to a mutation handler:
A lot of the time we know the value that a mutation will trigger assuming everything goes right.
For example, a `toggleItem` mutation in a todo list will invert the value of the `checked`
field of a particular item. In these situations, we don't have to wait for a mutation to
resolve in order to apply the update to the cache. Instead, we can assume that it will succeed
and provide an "optimistic response" for the mutation with the second argument to a mutation handler:

```svelte
<script>
Expand Down Expand Up @@ -145,34 +152,26 @@ A lot of the time we know the value that a mutation will trigger assuming everyt
</button>
```

When the mutation resolves, the old values will be erased entirely and the new values will be committed to the cache. If instead the mutation fails, the optimistic changes will be reverted and the handler's promise will reject with the error message as usual.

Remember to always request and specify an `id` when dealing with optimistic responses so that the cache can make sure to update the correct records. Also, it's worth mentioning that you don't have to provide a complete response for an optimistic value, the cache will write whatever information you give it (as long as its found in the mutation body).

## Utilities

### Mutation Function

Sometimes it's convenient to get the `mutate` function out without having to access the store directly.
In those situations, you can use the `mutation` function:

```svelte
<script>
import { graphql, mutation } from '$houdini'

const uncheckItem: UncheckItemStore = mutation(graphql`
mutation UncheckItem($id: ID!) {
uncheckItem(item: $id) {
item {
id
completed
}
}
}
`)
</script>

<button on:click={() => uncheckItem({ id: 'my-item' })}>
Uncheck Item
</button>
```
When the mutation resolves, the old values will be erased entirely and the new values will
be committed to the cache. If instead the mutation fails, the optimistic changes will be
reverted and the handler's promise will reject with the error message as usual.

Remember to always request and specify an `id` when dealing with optimistic responses so
that the cache can make sure to update the correct records. Also, it's worth mentioning that
you don't have to provide a complete response for an optimistic value, the cache will write
whatever information you give it (as long as its found in the mutation body). Because of this,
the store value won't update until the mutation resolves.

### Why is typescript missing fields?

If you are using typescript, you might notice that the generated types for optimistic
responses do not include any fields from fragments that you might have spread in.
While surprising at first, this is by design. We believe that it is a mistake to
tightly couple the invocation of the mutation with a fragment that's defined in
some random file and whose definition might change unknowingly. If it did change,
there would be a nasty error when the runtime tries to look up the schema information
so the generated types are trying to guide you towards a safer practice.

There's no harm in duplicating a field that is part of a fragment so if you are going to
provide an optimistic value, you should add those fields to the explicit selection
set of the mutation.
14 changes: 14 additions & 0 deletions site/src/routes/guides/caching-data.svx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ When the mutation resolves, the old values will be erased entirely and the new v

Remember to always request and specify an `id` when dealing with optimistic responses so that the cache can make sure to update the correct records. Also, it's worth mentioning that you don't have to provide a complete response for an optimistic value, the cache will write whatever information you give it (as long as its found in the mutation body).

### Why is typescript missing fields?

If you are using typescript, you might notice that the generated types for optimistic
responses do not include any fields from fragments that you might have spread in.
While surprising at first, this is by design. We believe that it is a mistake to
tightly couple the invocation of the mutation with a fragment that's defined in
some random file and whose definition might change unknowingly. If it did change,
there would be a nasty error when the runtime tries to look up the schema information
so the generated types are trying to guide you towards a safer practice.

There's no harm in duplicating a field that is part of a fragment so if you are going to
provide an optimistic value, you should add those fields to the explicit selection
set of the mutation.

## Partial Data

As your users navigate through your application, their cache will build up with the data that they encounter. This means that a lot of the times, they will have already seen the data that a new view wants. Houdini's cache can be told to render a view if only some of the necessary data is present using the `@cache` directive:
Expand Down
3 changes: 3 additions & 0 deletions site/src/routes/guides/migrating-to-016.svx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ npx svelte-migrate routes
so unless you are doing something special, you can probably just delete it. If you _are_ doing something special,
make sure that you include `.js` in your extensions so that the generated `+page.js` can be picked up if you use
an automatic loader. Keep in mind there is a new `exclude` value that might be better suited to your situation.
- `apiUrl` has a slightly new behavior. It now controls wether or not the vite plugin will poll for schema changes.
If the latest version of your schema is available locally then you should just omit the value. This is common in
monorepos and GraphQL apis that are resolved with a SvelteKit endpoint.

```diff

Expand Down
5 changes: 3 additions & 2 deletions src/cmd/generators/stores/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,12 @@ export default ${globalStoreName}

const _input = `${artifactName}$input`
const _data = `${artifactName}$result`
const _optimistic = `${artifactName}$optimistic`

// the type definitions for the store
const typeDefs = `import type { ${_input}, ${_data}, MutationStore } from '$houdini'
const typeDefs = `import type { ${_input}, ${_data}, ${_optimistic}, MutationStore } from '$houdini'
export declare class ${storeName} extends MutationStore<${_data} | undefined, ${_input}>{
export declare class ${storeName} extends MutationStore<${_data} | undefined, ${_input}, ${_optimistic}>{
constructor() {}
}
Expand Down
Loading

0 comments on commit 71caba7

Please sign in to comment.