Skip to content

Caching

There is no “response object” in sling_gql. Widgets read the cache, always. A fetch is just a way of filling it. This is what makes optimistic updates trivial and what makes normalization powerful — but it also means you should understand its shape.

Shape: entities, with the roots as entities too

Section titled “Shape: entities, with the roots as entities too”

The cache is a flat map of entities. Every object the response contains that has a __typename and an id is stored once, under Typename:id, and referenced from wherever it appeared. Objects without an id (Stats, PageInfo, Company here) stay inline in their parent. The operation roots are entities like any other (ROOT_QUERY, ROOT_MUTATION):

client.cache.snapshot
{
"ROOT_QUERY": {
"company": { "__typename": "Company", "name": "Sling Space", "ceo": "Alex Corren" },
"stats": { "__typename": "Stats", "totalLaunches": 181, "successRatePct": 89.8 },
"launches_1qouruf": {
"__typename": "LaunchConnection",
"nodes": [ { "__ref": "Launch:launch-181" }, { "__ref": "Launch:launch-180" } ],
"pageInfo": { "__typename": "PageInfo", "hasNextPage": true, "endCursor": "Y3Vyc29yOjE5" },
"totalCount": 181
},
"launch_18di94z": { "__ref": "Launch:launch-181" }
},
"Launch:launch-181": {
"__typename": "Launch", "id": "launch-181", "name": "Crew-5",
"rocket": { "__ref": "Rocket:starship" }, "details": "…"
},
"Rocket:starship": { "__typename": "Rocket", "id": "starship", "name": "Starship" }
}

Field keys are still the selection alias (field name + hash of the arguments), so a response merges without any lookup table. Every Accessor is a (selection node, cache path) pair — ["launches_1qouruf", "nodes", 0, "rocket"] for the rocket of the first row. read walks the path and derefs a Ref transparently whenever it meets one; the generated code never sees references. A path may also start with a Ref to address an entity directly: ["Launch:launch-181" as Ref, "name"].

Normalization only works if id is actually in the response. The generator knows which object types have an id field and emits keyed: true on every field returning one (object('rocket', Rocket.new, keyed: true)). The runtime then prints id next to __typename in every such selection — you never select it by hand:

launches(first: $first) {
__typename
nodes {
__typename
id # added automatically: Launch is keyed
name
}
}

Normalization(keyField: 'uuid') changes the field name (pass --key-field uuid to the generator too); Normalization(identify: (obj) => …) replaces the whole rule; Normalization.none turns it off and gives you the old path-addressed behaviour.

Lookups: launch(id:) is served from the entity

Section titled “Lookups: launch(id:) is served from the entity”

A root field whose only argument is the key and which returns a keyed type (launch(id: ID!): Launch) is emitted with lookup: 'Launch'. When launch_18di94z is not in ROOT_QUERY yet but Launch:launch-181 exists, the accessor is redirected to the entity. Fields the entity already has are read from cache; fields it lacks miss individually and are fetched — only those:

Detail screen opened after the list
query ($id: ID!) {
launch_18di94z: launch(id: $id) {
__typename
id
details # not in the list → fetched
flightNumber # not in the list → fetched
# name, date, status, rocket { name } came from the list's entities
}
}

That is the “no double fetch across paths” property; example/test/app_test.dart asserts it on the real request.

Writes: entities merge, lists are replaced

Section titled “Writes: entities merge, lists are replaced”

Responses are merged, not replaced, at the entity level: widget A fetching me { name } and widget B fetching me { age } end up with one User:1 holding both. A list of references is replaced wholesale by the incoming list (there is nothing to merge element-wise — the elements themselves merge as entities). Inline objects deep-merge as before, and inline lists still merge by index.

Entities that a replaced list no longer references are not removed automatically; cache.gc() sweeps everything unreachable from the roots and returns the keys it dropped.

Every scalar field has a generated setter that writes to the cache:

launch.name = '${launch.name} ✨';

Because there is one Launch:launch-181, setting name on the detail screen updates the list row too. When mutations land, the pattern will be: write optimistically, send the mutation, let the response overwrite, refetch on error.

cache.read('query', ['launches_1qouruf', 'nodes', 0]) works, but nobody can type a hashed alias. Outside a widget build — in an optimistic: callback, after await client.mutate(...), in a push-notification handler — use the typed scope instead:

final cache = client.cacheScope; // SlingScope.of<Query>(context).cacheScope
final launch = cache.launch('launch-181'); // Launch? — the cached entity, or null
launch?.favorite = true; // ordinary generated setter
final name = cache.query.me?.name; // typed root fields, from the cache only

client.cacheScope returns a CacheScope<Query>, a recorder like the one behind every QueryBuilder, with two differences: reads never fetch (a field that is not cached reads as null, an object as a skeleton, and nothing is sent) and it never rebuilds (it records no dependencies). Writes go through the normal write path: every scope that read the field rebuilds, and inside a mutation’s optimistic callback they are journaled and rolled back on failure, exactly like setters on accessors you got from a build.

The generator emits one method per keyed type (extension SlingCacheAccess on CacheScope<Query>):

lib/generated/schema.dart
extension SlingCacheAccess on CacheScope<Query> {
Launch? launch(String id) => entity('Launch', id, Launch.new);
Rocket? rocket(String id) => entity('Rocket', id, Rocket.new);
// …
}

entity(typename, id, ctor) resolves the key through Normalization.lookup — launch('launch-181') addresses Launch:launch-181 — and returns null when that entity is not in the cache: never a skeleton, never a request. The accessor it returns is the ordinary generated Launch, starting at the entity (path == [Ref('Launch:launch-181')]), so everything reachable from it (launch.rocket?.name) reads the same way.

A response replaces a list wholesale, but a mutation’s response rarely contains the list it changed. cacheScope.list(selector) addresses one cached list field — by reading it, so the arguments hash to the same alias as in the widget — and edits its refs:

final cache = client.cacheScope;
final favorites = cache.list((q) => q.me?.favorites); // CacheList<Launch>
favorites.prepend(cache.launch('launch-181')!); // also: append, remove, contains
cache.evict(cache.launch('launch-180')!); // gone from every list

Edits write the whole new list back (Viewer:viewer-1.favorites is the touched key, exactly as if a response had replaced it), are journaled like any CacheWrite inside a mutation’s optimistic callback, and are set-like no-ops on a list that is not cached. Each argument set is its own entry: launches(first: 20) and its next page are separate lists, and the edit applies to the one you name. See Updating lists after a mutation for the full rules.

cache.evict(entity) (or the untyped client.cache.evict('Launch:launch-181')) removes the entity, drops it from every list that referenced it, and blanks object fields that pointed at it so they read as missing and get re-fetched on the next build. The typed form also notifies every scope that read one of those fields.

Every read records the dependency keys it traversed — ROOT_QUERY.launches_1qouruf, Launch:launch-181.name — on the scope that made it (entity.field, see depKey). Every write returns the keys it touched. A scope rebuilds when the two intersect, so:

  • a mutation returning Launch:launch-181 { status } rebuilds the list row and the detail screen, but not a widget that only read the launch’s name;
  • the detail screen’s fetch does not rebuild the header that reads company.

This is what replaced the coarse root-alias intersection of the first PoC iteration.

Reads are synchronous — they happen inside build() — so the store is in memory and stays in this package. What a persistence layer needs is exposed and deliberately small:

  • cache.snapshot — a JSON-able deep copy (refs as {"__ref": key}), and Cache(initial: json) to hydrate it back;
  • cache.onChange — a Stream<Set<String>> of touched dependency keys after every write, for write-behind persistence (sqlite, hive, a file — as separate packages).
  • Eviction of stale entries. maxAge revalidates stale data (see Fetch policies & freshness) but never drops it; Dart has WeakReference and Finalizer, so stale entries could be held weakly and evicted by the GC rather than by timers.
  • Type policies beyond identity (custom merge functions per field, pagination merging à la Apollo relayStylePagination).