diff --git a/.changeset/memo-invalid-null-deps.md b/.changeset/memo-invalid-null-deps.md new file mode 100644 index 000000000000..6b56c48d239b --- /dev/null +++ b/.changeset/memo-invalid-null-deps.md @@ -0,0 +1,10 @@ +--- +'@data-client/normalizr': patch +'@data-client/core': patch +'@data-client/react': patch +'@data-client/vue': patch +--- + +Fix a cached parent showing a stale nested entity after it becomes valid + +When a nested entity was `INVALID` (for example, it failed validation) or `null` in the store, the parent it belongs to kept returning the old result even after that entity was valid again. Parents now update as soon as the nested entity does. diff --git a/.claude/rules/memo-invariants.md b/.claude/rules/memo-invariants.md new file mode 100644 index 000000000000..b7528e91ae58 --- /dev/null +++ b/.claude/rules/memo-invariants.md @@ -0,0 +1,13 @@ +--- +paths: + - "packages/normalizr/src/memo/**" + - "packages/normalizr/src/denormalize/**" + - "packages/normalizr/src/schemas/**" + - "packages/endpoint/src/schemas/**" +--- + + + +# Memo invariants + +Before changing memoization, or a schema's `denormalize`, read "Memo invariants" in `packages/normalizr/AGENTS.md`. diff --git a/.cursor/rules/memo-invariants.mdc b/.cursor/rules/memo-invariants.mdc new file mode 100644 index 000000000000..fc54576f697b --- /dev/null +++ b/.cursor/rules/memo-invariants.mdc @@ -0,0 +1,9 @@ +--- +description: Memoization invariants for normalizr's MemoCache and WeakDependencyMap +globs: packages/normalizr/src/memo/**, packages/normalizr/src/denormalize/**, packages/normalizr/src/schemas/**, packages/endpoint/src/schemas/** +alwaysApply: false +--- + +# Memo invariants + +Before changing memoization, or a schema's `denormalize`, read "Memo invariants" in `packages/normalizr/AGENTS.md`. diff --git a/examples/benchmark/README.md b/examples/benchmark/README.md index 9eb74c466976..91bedfca566f 100644 --- a/examples/benchmark/README.md +++ b/examples/benchmark/README.md @@ -21,6 +21,7 @@ yarn workspace example-benchmark start [suite-name] [filter] ``` Both arguments are optional: + - **No arguments**: runs `normalizr` + `core` suites with all benchmarks - **Suite only**: `yarn start normalizr` runs all benchmarks in that suite - **Suite + filter**: `yarn start normalizr denormalize` runs only benchmarks containing "denormalize" @@ -35,6 +36,7 @@ Both arguments are optional: - `entity` - benchmarks various entity-specific operations - `core` - benchmarks entire operations using [Controller](https://dataclient.io/docs/api/Controller) - `normalizr` - benchmarks just normalize/denormalize + - `denormalizeLong nested 25% missing|null|INVALID` (and `withCache`) - the project list with every 4th nested `BuildTypeDescription` missing from the store, `null`, or `INVALID`; those are still tracked as memo dependencies - `spread` - degenerate-case writes where spread cost scales with store size (single-entity `setResponse` into 1k/10k/100k entity stores, 10k cached endpoint keys, collection push onto 10k items, invalidateAll/expireAll). One case (`setOneEntity in 10k entity store`) is tracked over time in CI via `.github/workflows/benchmark-spread.yml`; the rest are manual-only. - `micro` - isolated microbenchmarks for testing specific operations - `old-normalizr` - runs equivalent benchmarks using the normalizr package diff --git a/examples/benchmark/normalizr.js b/examples/benchmark/normalizr.js index ea5479177034..e283969a27c4 100644 --- a/examples/benchmark/normalizr.js +++ b/examples/benchmark/normalizr.js @@ -6,6 +6,7 @@ import { initialState, MemoCache, WeakDependencyMap, + INVALID, } from './dist/index.js'; import { createAdd } from './filter.js'; import { printStatus } from './printStatus.js'; @@ -39,6 +40,23 @@ const dataValues = { }; const valuesState = normalize(ProjectSchemaValues, dataValues); +/** `entities` with every 4th nested BuildTypeDescription missing, or set to + * `hole`. Those are still dependencies of the projects that nest them. */ +function withNestedHoles(hole) { + const buildTypes = { ...entities.BuildTypeDescription }; + Object.keys(buildTypes).forEach((id, i) => { + if (i % 4) return; + if (hole === 'missing') delete buildTypes[id]; + else buildTypes[id] = hole; + }); + return { ...entities, BuildTypeDescription: buildTypes }; +} +const nestedHoles = [ + ['missing', withNestedHoles('missing')], + ['null', withNestedHoles(null)], + ['INVALID', withNestedHoles(INVALID)], +]; + let githubState = normalize(User, userData); const date = Date.now(); @@ -144,6 +162,11 @@ export default function addNormlizrSuite(suite, filter) { add('denormalizeLong donotcache', () => { return denormalize(ProjectSchema, result, entities); }); + for (const [hole, holeEntities] of nestedHoles) { + add(`denormalizeLong nested 25% ${hole}`, () => { + return new MemoCache().denormalize(ProjectSchema, result, holeEntities); + }); + } add('denormalizeLong Values donotcache', () => { return denormalize( ProjectSchemaValues, @@ -202,6 +225,14 @@ export default function addNormlizrSuite(suite, filter) { }, }, ); + for (const [hole, holeEntities] of nestedHoles) { + // one MemoCache each, so the variants don't evict each other's result + const memoHoles = new MemoCache(); + memoHoles.denormalize(ProjectSchema, result, holeEntities, []); + add(`denormalizeLong nested 25% ${hole} withCache`, () => { + return memoHoles.denormalize(ProjectSchema, result, holeEntities, []); + }); + } add('denormalizeLong Values withCache', () => { return memo.denormalize( ProjectSchemaValues, diff --git a/examples/benchmark/src/index.ts b/examples/benchmark/src/index.ts index 81d21c901479..c0d71be347d1 100644 --- a/examples/benchmark/src/index.ts +++ b/examples/benchmark/src/index.ts @@ -3,6 +3,7 @@ export { denormalize, WeakDependencyMap, MemoCache, + INVALID, } from '@data-client/normalizr'; export * from '@data-client/core'; export { diff --git a/packages/normalizr/AGENTS.md b/packages/normalizr/AGENTS.md index 2c12ea2f8fcf..1bbeacc8e74b 100644 --- a/packages/normalizr/AGENTS.md +++ b/packages/normalizr/AGENTS.md @@ -11,6 +11,22 @@ Non-obvious constraints when changing `src/memo/`, `src/denormalize/`, or `src/i - **Storage shape is API.** `state.entities[key][pk]` is observed by snapshot tests, SSR payloads, and devtools. Shape changes are breaking even when normalize/denormalize round-trip correctly. - **Cache invalidation is reference-driven.** A new object at `entities[key][pk]` busts every chain through that step. Storing multiple logical cells under one `pk` invalidates them all on any single-cell write. +## Memo invariants + +Every change to `src/memo/`, or to a schema's `denormalize`, must keep these. Tests: `src/__tests__/WeakDependencyMap.test.ts`, `src/__tests__/MemoCache.ts`, `packages/react/src/__tests__/concurrent-memo.web.tsx`. + +1. **Sound hits.** A hit returns only a value computed from exactly these entity objects (`===`; non-object values such as `undefined`, `null` and `INVALID` compare by value) and these `argsKey` results. +2. **Complete dependencies.** Every entity and `argsKey` a computation reads is recorded, including deps replayed from a nested hit, and including entities that are missing, `null` or `INVALID`. +3. **Deterministic.** The same reads lead to the same further reads and an equal result. All args-dependence goes through a pure, referentially stable `argsKey` fn. +4. **Complete journey.** The paths returned with a hit cover every entity the value depends on; subscriptions and GC count refs from them. Callers never mutate it. +5. **Weak retention.** A cached value lives only while what it is keyed on is reachable. +6. **Stable identity.** The same reads return the same object while it is cached. + +Accepted limitations (intentional; don't "fix"): + +- The results cache is keyed by the input object, not the schema. Two schemas only see the same result object if two endpoints share a cache key, which is already a bug in the endpoints. +- Entities past `MAX_ENTITY_DEPTH` (`src/denormalize/unvisit.ts`) are read but not tracked, so changes that deep don't invalidate ancestors. + ## Schema-tree traversal pattern When walking the schema *definition* (not data), the canonical recursion is used in many places. Keep it consistent: diff --git a/packages/normalizr/src/__tests__/MemoCache.ts b/packages/normalizr/src/__tests__/MemoCache.ts index 728f394a2d0a..4e1c6f7092e1 100644 --- a/packages/normalizr/src/__tests__/MemoCache.ts +++ b/packages/normalizr/src/__tests__/MemoCache.ts @@ -6,9 +6,11 @@ import { CoolerArticle, IndexedUser, FirstUnion, + Article, } from '__tests__/new'; import { fromJSState } from './immutable.test'; +import { INVALID } from '../denormalize/symbol'; import { IQueryDelegate } from '../interface'; import MemoCache from '../memo/MemoCache'; import { MemoPolicy as POJOPolicy } from '../memo/Policy'; @@ -1063,3 +1065,55 @@ describe('MemoCache', () => { }); }); }); + +describe('MemoCache nested entity dependencies', () => { + const article = { id: '1', title: 't', author: '2' }; + const bob = { id: '2', username: 'bob' }; + + it.each([ + ['missing', {}], + ['INVALID', { '2': INVALID }], + ['null', { '2': null }], + ])( + 'a %s child is a dependency: the parent recomputes once it is set', + (_, users) => { + const memo = new MemoCache(); + const first = memo.denormalize(Article, '1', { + Article: { '1': article }, + User: users, + }); + expect((first.data as any).author?.username).toBeUndefined(); + const second = memo.denormalize(Article, '1', { + Article: { '1': article }, + User: { '2': bob }, + }); + expect((second.data as any).author?.username).toBe('bob'); + }, + ); + + it('a blank child id is not a dependency', () => { + const memo = new MemoCache(); + const entities = { + Article: { '1': { ...article, author: '' } }, + User: {}, + }; + const first = memo.denormalize(Article, '1', entities); + expect((first.data as any).author).toBeUndefined(); + const second = memo.denormalize(Article, '1', { + ...entities, + User: { '2': bob }, + }); + expect(second.data).toBe(first.data); + }); + + it('stays cached while the child stays INVALID', () => { + const memo = new MemoCache(); + const entities = { Article: { '1': article }, User: { '2': INVALID } }; + const first = memo.denormalize(Article, '1', entities); + const second = memo.denormalize(Article, '1', { + ...entities, + User: { ...entities.User }, + }); + expect(second.data).toBe(first.data); + }); +}); diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index 21e945ed469e..966edb61e337 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -1,8 +1,9 @@ import { Temporal } from 'temporal-polyfill'; +import { INVALID } from '../denormalize/symbol'; import { EntityPath } from '../interface'; import { MemoPolicy } from '../memo/Policy'; -import WeakDependencyMap from '../memo/WeakDependencyMap'; +import WeakDependencyMap, { depKey } from '../memo/WeakDependencyMap'; describe('WeakDependencyMap', () => { const a = { hi: '5' }; @@ -91,6 +92,32 @@ describe('WeakDependencyMap', () => { expect(wem.get({}, getEntity)[0]).toBeUndefined(); }); + it.each<[string, any]>([ + ['null', null], + ['INVALID', INVALID], + ])('tracks a %s dependency, as root or after it', (_, value) => { + const wem = new WeakDependencyMap(); + const withA = (entity: any) => + MemoPolicy.getEntities({ ...state, A: { '1': entity } }); + const depValue = { path: depA.path, entity: value }; + wem.set([depValue], 'root'); + wem.set([depB, depValue], 'child'); + expect(wem.get(value, withA(value))[0]).toBe('root'); + expect(wem.get(b, withA(value))[0]).toBe('child'); + expect(wem.get(b, withA(a))[0]).toBeUndefined(); + expect(wem.get(b, withA(undefined))[0]).toBeUndefined(); + }); + + it('keys objects and functions by themselves, so they stay collectable', () => { + // an Entity class is a function, and is the first dependency of buildQueryKey + class Thing {} + const obj = {}; + expect(depKey(Thing)).toBe(Thing); + expect(depKey(obj)).toBe(obj); + expect(depKey(null)).toBe(depKey(null)); + expect(depKey(null)).not.toBe(depKey(INVALID)); + }); + it('considers empty key list invalid', () => { const wem = new WeakDependencyMap(); expect(() => wem.set([], 'whatever')).toThrowErrorMatchingInlineSnapshot( @@ -156,7 +183,7 @@ describe('WeakDependencyMap', () => { expect(wem.get(a, getEntity, [{ userId: 'alice' }])[0]).toBeUndefined(); // removing entity `b` from the store makes the tail entity lookup - // resolve to the UNDEF sentinel in mixed mode, which doesn't match + // resolve to the undefined stand-in in mixed mode, which doesn't match // the originally-recorded reference → miss const getEntityMissingB = MemoPolicy.getEntities({ A: { '1': a } }); expect( diff --git a/packages/normalizr/src/denormalize/UNDEF.ts b/packages/normalizr/src/denormalize/UNDEF.ts deleted file mode 100644 index d40bb931d312..000000000000 --- a/packages/normalizr/src/denormalize/UNDEF.ts +++ /dev/null @@ -1 +0,0 @@ -export const UNDEF = {} as any; diff --git a/packages/normalizr/src/denormalize/cache.ts b/packages/normalizr/src/denormalize/cache.ts index c99cea74dde3..f61300c7f4d0 100644 --- a/packages/normalizr/src/denormalize/cache.ts +++ b/packages/normalizr/src/denormalize/cache.ts @@ -17,6 +17,9 @@ export default interface Cache { data: any; paths: EntityPath[]; }; + /** Records an entity that isn't an object (missing, `null` or `INVALID`) + * as a dependency of the surrounding entity-cache frame. */ + dependOn(pk: string, schema: EntityInterface, entity: unknown): void; /** Records `fn(args)` as a memoization dimension for the surrounding * entity-cache frame and returns the value. */ argsKey(fn: KeyFn): string | undefined; diff --git a/packages/normalizr/src/denormalize/localCache.ts b/packages/normalizr/src/denormalize/localCache.ts index 74e2635cec59..8dfb2c3f58e9 100644 --- a/packages/normalizr/src/denormalize/localCache.ts +++ b/packages/normalizr/src/denormalize/localCache.ts @@ -29,6 +29,8 @@ export default class LocalCache implements Cache { return localCacheKey.get(pk); } + dependOn(): void {} + getResults( input: any, cachable: boolean, diff --git a/packages/normalizr/src/denormalize/unvisit.ts b/packages/normalizr/src/denormalize/unvisit.ts index 9d46fb836773..a3067c24ceef 100644 --- a/packages/normalizr/src/denormalize/unvisit.ts +++ b/packages/normalizr/src/denormalize/unvisit.ts @@ -1,6 +1,5 @@ import type Cache from './cache.js'; import { INVALID } from './symbol.js'; -import { UNDEF } from './UNDEF.js'; import type { EntityInterface, EntityPath, @@ -11,6 +10,18 @@ import type { DenormGetEntity } from '../memo/types.js'; import { denormalize as arrayDenormalize } from '../schemas/Array.js'; import { denormalize as objectDenormalize } from '../schemas/Object.js'; +/** Ids that can't be looked up, so they're never cached or tracked. */ +const isBlankPk = (pk: unknown) => + pk === undefined || pk === '' || pk === 'undefined'; + +/** What a missing, `null` or `INVALID` entity denormalizes to. */ +const nonObjectValue = ( + schema: EntityInterface, + entity: unknown, + delegate: IDenormalizeDelegate, +): any => + typeof entity === 'symbol' ? schema.denormalize(entity, delegate) : entity; + const getUnvisitEntity = ( getEntity: DenormGetEntity, cache: Cache, @@ -23,28 +34,14 @@ const getUnvisitEntity = ( const inputIsId = typeof entityOrId !== 'object'; const entity = inputIsId ? getEntity({ key: schema.key, pk: entityOrId }) : entityOrId; - if (typeof entity === 'symbol') { - return schema.denormalize(entity, delegate); - } - - if ( - entity === undefined && - inputIsId && - // entityOrId cannot be undefined literal as this function wouldn't be called in that case - // however the blank strings can still occur - entityOrId !== '' && - entityOrId !== 'undefined' - ) { - // we cannot perform WeakMap lookups with `undefined`, so we use a special object to represent undefined - // we're actually using this call to ensure we update the cache if a nested schema changes from `undefined` - // this is because cache.getEntity adds this key,pk as a dependency of anything it is nested under - return cache.getEntity(entityOrId, schema, UNDEF, localCacheKey => { - localCacheKey.set(entityOrId, undefined); - }); - } - if (typeof entity !== 'object' || entity === null) { - return entity as any; + // A missing, null or INVALID entity is still a dependency of anything + // nested above it, so those recompute once it changes. Blank ids can't + // be looked up, so they aren't dependencies. + // Only the dependency is needed: there is nothing worth caching. + if (inputIsId && !isBlankPk(entityOrId)) + cache.dependOn(entityOrId, schema, entity); + return nonObjectValue(schema, entity, delegate); } let pk: string | number | undefined = @@ -54,7 +51,7 @@ const getUnvisitEntity = ( // if we can't generate a working pk we cannot do cache lookups properly, // so simply denormalize without caching - if (pk === undefined || pk === '' || pk === 'undefined') { + if (isBlankPk(pk)) { return noCacheGetEntity(localCacheKey => unvisitEntityObject(schema, entity, '', localCacheKey, delegate), ); diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index 2d32c4daf78e..156675843524 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -1,4 +1,4 @@ -import { UNDEF } from '../denormalize/UNDEF.js'; +import { INVALID } from '../denormalize/symbol.js'; /** Function path used by `argsKey` deps. Distinguished from object paths * via `typeof === 'function'`. */ @@ -8,6 +8,28 @@ export type KeyFn = (args: readonly any[]) => string | undefined; * can perform Map lookups consistently. */ const UNDEF_KEY = '\0'; +const UNDEF_DEP = {}; +const NULL_DEP = {}; +const INVALID_DEP = {}; +const otherDeps = new Map(); + +/** `WeakMap` key for an entity value: the object or function itself, or a + * fixed stand-in for `undefined`, `null` or `INVALID`. Checked on every + * dependency, so the common values never need a lookup. */ +export function depKey(entity: unknown): object { + if (typeof entity === 'object') return entity ?? NULL_DEP; + if (entity === undefined) return UNDEF_DEP; + if (typeof entity === 'function') return entity; + if (entity === INVALID) return INVALID_DEP; + // normalize never stores other values; a stand-in for one is never freed + let key = otherDeps.get(entity); + if (!key) { + key = {}; + otherDeps.set(entity, key); + } + return key; +} + /** Maps a (ordered) list of dependencies to a value. * * Useful as a memoization cache for flat/normalized stores. @@ -34,12 +56,11 @@ export default class WeakDependencyMap< getDependency: GetDependency, args: readonly any[] = [], ) { - let curLink = this.next.get(entity); + let curLink = this.next.get(depKey(entity) as K); if (!curLink) return EMPTY; if (this.hasStr) return this._getMixed(curLink, getDependency, args); while (curLink.nextPath) { - // we cannot perform lookups with `undefined`, so we use a special object to represent undefined - const nextDependency = getDependency(curLink.nextPath as Path) ?? UNDEF; + const nextDependency = depKey(getDependency(curLink.nextPath as Path)); const nextLink = curLink.next.get(nextDependency as any); if (!nextLink) return EMPTY; curLink = nextLink; @@ -60,7 +81,7 @@ export default class WeakDependencyMap< const keyValue = (curLink.nextPath as KeyFn)(args) ?? UNDEF_KEY; nextLink = curLink.nextStr?.get(keyValue); } else { - const nextDependency = getDependency(curLink.nextPath as Path) ?? UNDEF; + const nextDependency = depKey(getDependency(curLink.nextPath as Path)); nextLink = curLink.next.get(nextDependency as any); } if (!nextLink) return EMPTY; @@ -95,11 +116,11 @@ export default class WeakDependencyMap< curLink.nextStr.set(k, nextLink); } } else { - nextLink = curLink.next.get(dep.entity as K); + const key = depKey(dep.entity) as K; + nextLink = curLink.next.get(key); if (!nextLink) { nextLink = new Link(); - // void members are represented as a symbol so we can lookup - curLink.next.set((dep.entity ?? UNDEF) as K, nextLink); + curLink.next.set(key, nextLink); } } curLink.nextPath = dep.path as any; diff --git a/packages/normalizr/src/memo/globalCache.ts b/packages/normalizr/src/memo/globalCache.ts index 2f5a34de5387..1c2358abe79e 100644 --- a/packages/normalizr/src/memo/globalCache.ts +++ b/packages/normalizr/src/memo/globalCache.ts @@ -50,6 +50,13 @@ export default class GlobalCache implements Cache { return fn(this._args); } + dependOn(pk: string, schema: EntityInterface, entity: unknown): void { + this.dependencies.push({ + path: { key: schema.key, pk }, + entity: entity as any, + }); + } + getEntity( pk: string, schema: EntityInterface, diff --git a/packages/react/src/__tests__/concurrent-memo.web.tsx b/packages/react/src/__tests__/concurrent-memo.web.tsx new file mode 100644 index 000000000000..9f6caf4c9c90 --- /dev/null +++ b/packages/react/src/__tests__/concurrent-memo.web.tsx @@ -0,0 +1,395 @@ +import type { State } from '@data-client/core'; +import { Endpoint, Entity } from '@data-client/endpoint'; +import { + DataProvider, + getDefaultManagers, + useController, + useSuspense, +} from '@data-client/react'; +import { mockInitialState } from '@data-client/test'; +import { act, render } from '@testing-library/react'; +import { makeGetTodo, Todo } from '__tests__/concurrentFixtures'; +import type { TodoItem } from '__tests__/concurrentFixtures'; +import React, { Suspense, useState, useTransition, version } from 'react'; + +import useCacheState from '../hooks/useCacheState'; + +/** + * Memoization under concurrent rendering. Every read of the list records the + * entity versions it read and the result it got. A read of versions an + * earlier read already saw, that gets a different result, is a miss that + * caching more would have avoided, since those versions' entities are + * still alive. + */ + +const LegacyReact = version.startsWith('16') || version.startsWith('17'); +const describeConcurrent = LegacyReact ? describe.skip : describe; + +class Note extends Entity { + id = ''; + text = ''; +} + +const ROWS = 50; +const rows = (): TodoItem[] => + Array.from({ length: ROWS }, (_, i) => ({ id: `${i}`, title: `${i}` })); + +const getTodoList = new Endpoint(() => Promise.resolve(rows()), { + schema: [Todo], + name: 'getTodoList', +}); +const getTodo = makeGetTodo(({ id }) => Promise.resolve({ id, title: id })); +const getNote = new Endpoint( + ({ id }: { id: string }) => Promise.resolve({ id, text: id }), + { schema: Note, name: 'getNote' }, +); +const pending: Record< + string, + { resolve: (value: TodoItem) => void; reject: (error: Error) => void } +> = {}; +const hold = (key: string) => + new Promise((resolve, reject) => { + pending[key] = { resolve, reject }; + }); +const getDetail = new Endpoint(({ id }: { id: string }) => hold(id), { + schema: Todo, + name: 'getDetail', +}); +const updateKey = (id: string, title: string) => `update ${id} ${title}`; +const updateTodo = new Endpoint( + ({ id, title }: { id: string; title: string }) => hold(updateKey(id, title)), + { + schema: Todo, + sideEffect: true, + name: 'updateTodo', + getOptimisticResponse(_snap, { id, title }) { + return { id, title }; + }, + }, +); +const noDevManagers = getDefaultManagers({ devToolsManager: null }); + +const ids = new WeakMap(); +let nextId = 0; +const idOf = (o: object) => { + let id = ids.get(o); + if (id === undefined) { + id = nextId++; + ids.set(o, id); + } + return id; +}; + +/** Every read of the list: entity versions read, result identity, reader. */ +type Read = { versions: string; data: unknown; who: string }; +let reads: Read[] = []; +const versionsOf = (state: State, todos: { id: string }[]) => { + const table = (state.entities[Todo.key] ?? {}) as Record; + return todos.map(todo => idOf(table[todo.id])).join(','); +}; + +function List() { + const todos = useSuspense(getTodoList); + const state = useCacheState(); + reads.push({ + versions: versionsOf(state, todos), + data: todos, + who: 'render', + }); + return
{todos.length}
; +} + +function Detail({ id }: { id: string }) { + const todo = useSuspense(getDetail, { id }); + return
{todo.title}
; +} + +let controller: ReturnType; +let startTransition: (fn: () => void) => void; +let showDetail: (id: string) => void; +let typeText: (text: string) => void; + +function App({ consumers }: { consumers: number }) { + controller = useController(); + const [detail, setDetail] = useState([]); + const [text, setText] = useState(''); + const [isPending, start] = useTransition(); + startTransition = start; + showDetail = id => setDetail(prev => [...prev, id]); + typeText = setText; + return ( +
+ {String(isPending)} + {text} + loading list

}> + {Array.from({ length: consumers }, (_, i) => ( + + ))} +
+ loading detail

}> + {detail.map(id => ( + + ))} +
+
+ ); +} + +/** A manager or event handler reading the committed store outside render. */ +function readRaw() { + const state = controller.getState(); + const { data } = controller.getResponse(getTodoList, state); + reads.push({ versions: versionsOf(state, data as any), data, who: 'raw' }); +} + +/** Reads of versions already read before, that got a different result. */ +function avoidableMisses(list: Read[]) { + const seen = new Map(); + let misses = 0; + for (const { versions, data } of list) { + if (seen.has(versions) && seen.get(versions) !== data) misses++; + seen.set(versions, data); + } + return misses; +} + +function flipBacks(list: Read[]) { + const seen = new Set(); + let last: string | undefined; + let flips = 0; + for (const { versions } of list) { + if (versions !== last && seen.has(versions)) flips++; + seen.add(versions); + last = versions; + } + return flips; +} + +function trace(name: string) { + if (!process.env.MEMO_TRACE) return; + const label = new Map(); + const seq = reads.map(r => { + if (!label.has(r.versions)) label.set(r.versions, label.size); + return `${r.who === 'raw' ? 'R' : ''}v${label.get(r.versions)}:d${idOf(r.data as object)}`; + }); + console.log( + `${name}: ${reads.length} reads, ${label.size} versions, ${flipBacks(reads)} flip-backs, ${avoidableMisses(reads)} avoidable misses\n${seq.join(' ')}`, + ); +} + +function mount(consumers: number) { + return render( + + + , + ); +} + +const writeTodo = (id: string, title: string) => { + controller.setResponse(getTodo, { id }, { id, title }); +}; + +function beginTransition(row: string, id: string) { + act(() => { + startTransition(() => { + writeTodo(row, `from transition ${id}`); + showDetail(id); + }); + }); +} + +/** Resolves a held request with `value`, or rejects it with an `Error`. */ +async function settle(key: string, value: TodoItem | Error) { + await act(async () => { + if (value instanceof Error) pending[key].reject(value); + else pending[key].resolve(value); + }); +} +const finishTransition = (id: string) => settle(id, { id, title: 'detail' }); + +const writeNote = (i: number) => + act(() => { + controller.setResponse( + getNote, + { id: `${i}` }, + { id: `${i}`, text: `note ${i}` }, + ); + }); + +function beginOptimistic(id: string, title: string) { + act(() => { + controller.fetch(updateTodo, { id, title }).catch(() => {}); + }); +} + +const rollBack = (id: string, title: string) => + settle(updateKey(id, title), new Error('rejected')); +const resolveOptimistic = (id: string, title: string) => + settle(updateKey(id, title), { id, title }); + +describeConcurrent.each([1, 2])( + 'memoization under concurrent rendering, %i consumer(s)', + consumers => { + beforeEach(() => { + reads = []; + for (const key of Object.keys(pending)) delete pending[key]; + }); + + // While a transition is pending, React renders the urgent lane without its + // update and retries the transition with it, so a list alternates between + // the committed rows and the transition's rows. + + it('urgent re-renders during a pending transition', async () => { + const { getByTestId } = mount(consumers); + beginTransition('5', 'X'); + expect(getByTestId('pending').textContent).toBe('true'); + for (let i = 0; i < 5; i++) act(() => typeText(`typed ${i}`)); + await finishTransition('X'); + expect(getByTestId('pending').textContent).toBe('false'); + trace('typing'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('urgent writes to other entities during a pending transition', async () => { + const { getByTestId } = mount(consumers); + beginTransition('5', 'X'); + for (let i = 0; i < 5; i++) writeNote(i); + expect(getByTestId('pending').textContent).toBe('true'); + await finishTransition('X'); + trace('other entity writes'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('urgent writes to a listed row during a pending transition', async () => { + // React reapplies every update queued after the transition's on each + // render, so each render reads new rows and none can hit. + mount(consumers); + beginTransition('5', 'X'); + for (let i = 0; i < 5; i++) { + act(() => writeTodo('7', `urgent ${i}`)); + } + await finishTransition('X'); + trace('listed row writes'); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('two pending transitions with urgent writes between', async () => { + mount(consumers); + beginTransition('5', 'X'); + writeNote(0); + beginTransition('6', 'Y'); + for (let i = 1; i < 5; i++) writeNote(i); + await finishTransition('X'); + writeNote(5); + await finishTransition('Y'); + trace('two transitions'); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('overlapping optimistic updates rolled back out of order', async () => { + mount(consumers); + beginOptimistic('3', 'A'); + writeNote(0); + beginOptimistic('4', 'B'); + writeNote(1); + await rollBack('3', 'A'); + writeNote(2); + await rollBack('4', 'B'); + writeNote(3); + trace('optimistic out of order'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('an optimistic update rolled back during a pending transition', async () => { + mount(consumers); + beginOptimistic('3', 'A'); + writeNote(0); + beginTransition('5', 'X'); + for (let i = 1; i < 4; i++) writeNote(i); + await rollBack('3', 'A'); + for (let i = 4; i < 7; i++) writeNote(i); + await finishTransition('X'); + writeNote(7); + trace('optimistic during transition'); + expect(avoidableMisses(reads)).toBe(0); + }); + + // After a transition commits, the committed rows are a new version. The + // memo only sees it again when a read reaches it: useSuspense serves + // urgent re-renders with an unchanged store from its own useMemo. + + it('urgent re-renders during a second transition, after the first committed', async () => { + const { getByTestId } = mount(consumers); + beginTransition('5', 'X'); + for (let i = 0; i < 3; i++) act(() => typeText(`a${i}`)); + await finishTransition('X'); + expect(getByTestId('pending').textContent).toBe('false'); + beginTransition('6', 'Y'); + for (let i = 0; i < 3; i++) act(() => typeText(`b${i}`)); + await finishTransition('Y'); + trace('second transition'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('reads outside render during optimistic updates, across a resolved mutation', async () => { + mount(consumers); + beginOptimistic('3', 'A'); + readRaw(); + writeNote(0); + readRaw(); + // the mutation resolves: row 3 has a new committed version + await resolveOptimistic('3', 'A'); + beginOptimistic('4', 'B'); + readRaw(); + writeNote(1); + readRaw(); + writeNote(2); + readRaw(); + await rollBack('4', 'B'); + trace('raw reads across a resolved mutation'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('urgent writes to other entities during a second transition, after the first committed', async () => { + // every urgent write changes the state object, so useSuspense re-queries + // the memo; the list's rows are unchanged, so every read should hit + mount(consumers); + beginTransition('5', 'X'); + for (let i = 0; i < 3; i++) writeNote(i); + await finishTransition('X'); + beginTransition('6', 'Y'); + for (let i = 3; i < 8; i++) writeNote(i); + await finishTransition('Y'); + trace('other entity writes during a second transition'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); + + it('urgent re-renders during a pending transition, after two committed writes to a row', async () => { + mount(consumers); + act(() => { + writeTodo('9', 'v2'); + }); + act(() => { + writeTodo('9', 'v3'); + }); + beginTransition('5', 'X'); + for (let i = 0; i < 3; i++) act(() => typeText(`a${i}`)); + await finishTransition('X'); + trace('typing after two committed writes'); + expect(flipBacks(reads)).toBeGreaterThan(0); + expect(avoidableMisses(reads)).toBe(0); + }); + }, +); diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 333ed9e206fd..e49cb2373724 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -56,6 +56,7 @@ Coding agents also get a Claude Code plugin, Vue skills and llms.txt; see - Polling keeps the schema of endpoints given [pollFrequency](/rest/api/RestEndpoint#pollfrequency) with `.extend()`, so a polled [useLive()](/docs/api/useLive) list updates right away from mutations and optimistic updates instead of waiting for the next poll ([#4437](https://github.com/reactive/data-client/pull/4437)) - [DataProvider](/docs/api/DataProvider) only warns that it doesn't update during SSR when something fetches while the server renders, instead of on every server render, so pages that load data after mount (like Expo Router's static web output) stay quiet ([#4449](https://github.com/reactive/data-client/pull/4449)) - Server rendering with React 19 no longer logs "Uncaught suspense" when nothing suspended; React renders and discards fallbacks of large boundaries, and the warning now waits for a fallback that actually shows ([#4450](https://github.com/reactive/data-client/pull/4450)) +- A cached result no longer keeps showing a nested entity as missing after that entity was `INVALID` or `null` and becomes valid again ([#4460](https://github.com/reactive/data-client/pull/4460)) - Vue `createDataClient()` is deprecated, since it's internal to [DataClientPlugin](/vue/api/DataClientPlugin); install the plugin with `app.use(DataClientPlugin, options)` instead of calling it directly ([#4193](https://github.com/reactive/data-client/pull/4193)) - Vue `renderDataCompose()`'s `waitForNextUpdate()` is deprecated, since it gives up silently after 1 second and lets a test pass while still suspended; `await result` instead, or `await allSettled()` after a change, which now also waits for fetches a prop change starts ([unit testing composables](/vue/guides/unit-testing-composables#renderdatacompose-api), [#4221](https://github.com/reactive/data-client/pull/4221))