From 187bc4bc7c868de852dd53df1d1b249967be354c Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 17:54:15 -0400 Subject: [PATCH 01/11] fix(normalizr): Track INVALID and null nested entities as memo dependencies unvisitEntity returned early for a nested entity whose stored value was INVALID or null, without recording it as a dependency. A cached parent kept returning its old result after that entity became valid again. Both now go through cache.getEntity like a missing entity does, keyed by depKey(), which gives each non-object value a stable object stand-in. Documents the memo invariants in packages/normalizr/AGENTS.md, with the two accepted limitations, and adds a scoped agent rule pointing at them. Adds a ReactDOM test that counts memo misses under concurrent rendering. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/memo-invalid-null-deps.md | 10 + .claude/rules/memo-invariants.md | 12 + .cursor/rules/memo-invariants.mdc | 9 + packages/normalizr/AGENTS.md | 16 + packages/normalizr/src/__tests__/MemoCache.ts | 50 ++ packages/normalizr/src/denormalize/UNDEF.ts | 16 + packages/normalizr/src/denormalize/unvisit.ts | 25 +- .../normalizr/src/memo/WeakDependencyMap.ts | 14 +- .../src/__tests__/concurrent-memo.web.tsx | 426 ++++++++++++++++++ 9 files changed, 569 insertions(+), 9 deletions(-) create mode 100644 .changeset/memo-invalid-null-deps.md create mode 100644 .claude/rules/memo-invariants.md create mode 100644 .cursor/rules/memo-invariants.mdc create mode 100644 packages/react/src/__tests__/concurrent-memo.web.tsx 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..a550701ac604 --- /dev/null +++ b/.claude/rules/memo-invariants.md @@ -0,0 +1,12 @@ +--- +paths: + - "packages/normalizr/src/memo/**" + - "packages/normalizr/src/denormalize/**" + - "packages/endpoint/src/schemas/**" +--- + + + +# Memo invariants + +Before changing memoization, or a schema's `denormalize`, read "Memo invariants" in `packages/normalizr/AGENTS.md`. Every change must keep all of them. Cover any new edge case with a test in one of the files listed there. diff --git a/.cursor/rules/memo-invariants.mdc b/.cursor/rules/memo-invariants.mdc new file mode 100644 index 000000000000..22a98fe6f9d7 --- /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/endpoint/src/schemas/** +alwaysApply: false +--- + +# Memo invariants + +Before changing memoization, or a schema's `denormalize`, read "Memo invariants" in `packages/normalizr/AGENTS.md`. Every change must keep all of them. Cover any new edge case with a test in one of the files listed there. diff --git a/packages/normalizr/AGENTS.md b/packages/normalizr/AGENTS.md index 2c12ea2f8fcf..b25713fc371c 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 (`===`; `depKey()` stands in for `undefined`, `null` and `INVALID`) 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 the max entity depth (64) are read but not tracked. The depth limit exists to bound recursion; 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..af4fdfa375a8 100644 --- a/packages/normalizr/src/__tests__/MemoCache.ts +++ b/packages/normalizr/src/__tests__/MemoCache.ts @@ -9,6 +9,7 @@ import { } 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 +1064,52 @@ describe('MemoCache', () => { }); }); }); + +describe('MemoCache nested entity dependencies', () => { + class User extends Entity { + id = ''; + name = ''; + } + class Article extends Entity { + id = ''; + title = ''; + author: User | undefined = undefined; + static schema = { author: User }; + } + const article = { id: '1', title: 't', author: '2' }; + const bob = { id: '2', name: 'bob' }; + + it.each([ + ['missing', undefined], + ['INVALID', INVALID], + ['null', null], + ])( + 'a %s child is a dependency: the parent recomputes once it is set', + (_, child) => { + const memo = new MemoCache(); + const users: Record = + child === undefined ? {} : { '2': child }; + const first = memo.denormalize(Article, '1', { + Article: { '1': article }, + User: users, + }); + expect((first.data as any).author?.name).toBeUndefined(); + const second = memo.denormalize(Article, '1', { + Article: { '1': article }, + User: { '2': bob }, + }); + expect((second.data as any).author?.name).toBe('bob'); + }, + ); + + 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/denormalize/UNDEF.ts b/packages/normalizr/src/denormalize/UNDEF.ts index d40bb931d312..55594cd7a5f1 100644 --- a/packages/normalizr/src/denormalize/UNDEF.ts +++ b/packages/normalizr/src/denormalize/UNDEF.ts @@ -1 +1,17 @@ export const UNDEF = {} as any; + +const valueKeys = new Map(); + +/** The memo dependency key for an entity value. Objects key themselves; + * `undefined` uses `UNDEF`; any other value (`null`, `INVALID`) gets its own + * stable object, since `WeakMap` keys must be objects. */ +export function depKey(entity: unknown): object { + if (typeof entity === 'object' && entity !== null) return entity; + if (entity === undefined) return UNDEF; + let key = valueKeys.get(entity); + if (!key) { + key = {}; + valueKeys.set(entity, key); + } + return key; +} diff --git a/packages/normalizr/src/denormalize/unvisit.ts b/packages/normalizr/src/denormalize/unvisit.ts index 9d46fb836773..6c511b3d86b1 100644 --- a/packages/normalizr/src/denormalize/unvisit.ts +++ b/packages/normalizr/src/denormalize/unvisit.ts @@ -1,6 +1,6 @@ import type Cache from './cache.js'; import { INVALID } from './symbol.js'; -import { UNDEF } from './UNDEF.js'; +import { depKey, UNDEF } from './UNDEF.js'; import type { EntityInterface, EntityPath, @@ -24,7 +24,16 @@ const getUnvisitEntity = ( const entity = inputIsId ? getEntity({ key: schema.key, pk: entityOrId }) : entityOrId; if (typeof entity === 'symbol') { - return schema.denormalize(entity, delegate); + if (!inputIsId) return schema.denormalize(entity, delegate); + // record it as a dependency, so parents recompute once it is valid again + return cache.getEntity( + entityOrId, + schema, + depKey(entity), + localCacheKey => { + localCacheKey.set(entityOrId, schema.denormalize(entity, delegate)); + }, + ); } if ( @@ -43,6 +52,18 @@ const getUnvisitEntity = ( }); } + if (entity === null && inputIsId) { + // record it as a dependency, so parents recompute once it is set + return cache.getEntity( + entityOrId, + schema, + depKey(null), + localCacheKey => { + localCacheKey.set(entityOrId, null); + }, + ); + } + if (typeof entity !== 'object' || entity === null) { return entity as any; } diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index 2d32c4daf78e..0ac77e401d72 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 { depKey } from '../denormalize/UNDEF.js'; /** Function path used by `argsKey` deps. Distinguished from object paths * via `typeof === 'function'`. */ @@ -38,8 +38,8 @@ export default class WeakDependencyMap< 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; + // WeakMap keys must be objects, so other values use a stand-in + const nextDependency = depKey(getDependency(curLink.nextPath as Path)); const nextLink = curLink.next.get(nextDependency as any); if (!nextLink) return EMPTY; curLink = nextLink; @@ -60,7 +60,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 +95,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/react/src/__tests__/concurrent-memo.web.tsx b/packages/react/src/__tests__/concurrent-memo.web.tsx new file mode 100644 index 000000000000..0f213791f4ff --- /dev/null +++ b/packages/react/src/__tests__/concurrent-memo.web.tsx @@ -0,0 +1,426 @@ +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 { 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 (the old dependency chain kept every + * version whose entities were 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 = new Endpoint( + ({ id }: { id: string }) => Promise.resolve({ id, title: id }), + { schema: Todo, name: 'getTodo' }, +); +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 updateTodo = new Endpoint( + ({ id, title }: { id: string; title: string }) => + hold(`update ${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 | undefined) => { + if (!o) return -1; + 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 = (table: Record, todos: { id: string }[]) => + todos.map(todo => idOf(table[todo.id])).join(','); + +function List() { + const todos = useSuspense(getTodoList); + const state = useCacheState(); + const table = (state.entities[Todo.key] ?? {}) as Record; + reads.push({ + versions: versionsOf(table, 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(ids => [...ids, 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); + const table = (state.entities[Todo.key] ?? {}) as Record; + reads.push({ versions: versionsOf(table, 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( + + + , + ); +} + +function beginTransition(row: string, id: string) { + act(() => { + startTransition(() => { + controller.setResponse( + getTodo, + { id: row }, + { + id: row, + title: `from transition ${id}`, + }, + ); + showDetail(id); + }); + }); +} + +async function finishTransition(id: string) { + await act(async () => { + pending[id].resolve({ 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(() => {}); + }); +} + +async function rollBack(id: string, title: string) { + await act(async () => { + pending[`update ${id} ${title}`].reject(new Error('rejected')); + }); +} + +async function resolveOptimistic(id: string, title: string) { + await act(async () => { + pending[`update ${id} ${title}`].resolve({ 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(() => { + controller.setResponse( + getTodo, + { id: '7' }, + { + id: '7', + title: `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'); + // the store moved on: the committed rows now include row 5's new version + 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); + // The committed rows changed when the first write committed, and typing + // re-renders are served by useSuspense's useMemo, so the memo never + // sees them re-read before the transition's retries push them out. + // That costs one recompute, after which they are kept. + expect(avoidableMisses(reads)).toBeLessThanOrEqual(1); + }); + + 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 and the old chain would 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('the same pattern as the existing typing test, after two committed writes to a row', async () => { + mount(consumers); + act(() => { + controller.setResponse(getTodo, { id: '9' }, { id: '9', title: 'v2' }); + }); + act(() => { + controller.setResponse(getTodo, { id: '9' }, { id: '9', title: '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); + // The committed rows changed when the first write committed, and typing + // re-renders are served by useSuspense's useMemo, so the memo never + // sees them re-read before the transition's retries push them out. + // That costs one recompute, after which they are kept. + expect(avoidableMisses(reads)).toBeLessThanOrEqual(1); + }); + }, +); From 6d2dbfb10dc54abb000376f26cfb7e8550b30f06 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 17:54:49 -0400 Subject: [PATCH 02/11] docs(blog): Note the INVALID/null memo dependency fix Co-Authored-By: Claude Opus 5.5 (1M context) --- website/blog/2026-10-03-v0.19-batch-set.md | 1 + 1 file changed, 1 insertion(+) 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)) From 1be7879abb690fa82e9e58f1c1b233ccc3360d9c Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 18:13:42 -0400 Subject: [PATCH 03/11] refactor(normalizr): Record non-object nested entities in one branch unvisitEntity handled missing, null and INVALID nested entities in three near-identical branches. One branch now records all three, and WeakDependencyMap maps its root key through depKey(), so callers pass the raw value. Tests reuse the shared Article and makeGetTodo fixtures. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/normalizr/src/__tests__/MemoCache.ts | 17 ++----- packages/normalizr/src/denormalize/UNDEF.ts | 5 +- packages/normalizr/src/denormalize/unvisit.ts | 46 +++++------------ .../normalizr/src/memo/WeakDependencyMap.ts | 2 +- .../src/__tests__/concurrent-memo.web.tsx | 51 +++++-------------- 5 files changed, 34 insertions(+), 87 deletions(-) diff --git a/packages/normalizr/src/__tests__/MemoCache.ts b/packages/normalizr/src/__tests__/MemoCache.ts index af4fdfa375a8..07f52a448c06 100644 --- a/packages/normalizr/src/__tests__/MemoCache.ts +++ b/packages/normalizr/src/__tests__/MemoCache.ts @@ -6,6 +6,7 @@ import { CoolerArticle, IndexedUser, FirstUnion, + Article, } from '__tests__/new'; import { fromJSState } from './immutable.test'; @@ -1066,18 +1067,8 @@ describe('MemoCache', () => { }); describe('MemoCache nested entity dependencies', () => { - class User extends Entity { - id = ''; - name = ''; - } - class Article extends Entity { - id = ''; - title = ''; - author: User | undefined = undefined; - static schema = { author: User }; - } const article = { id: '1', title: 't', author: '2' }; - const bob = { id: '2', name: 'bob' }; + const bob = { id: '2', username: 'bob' }; it.each([ ['missing', undefined], @@ -1093,12 +1084,12 @@ describe('MemoCache nested entity dependencies', () => { Article: { '1': article }, User: users, }); - expect((first.data as any).author?.name).toBeUndefined(); + 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?.name).toBe('bob'); + expect((second.data as any).author?.username).toBe('bob'); }, ); diff --git a/packages/normalizr/src/denormalize/UNDEF.ts b/packages/normalizr/src/denormalize/UNDEF.ts index 55594cd7a5f1..c4c1614ceb70 100644 --- a/packages/normalizr/src/denormalize/UNDEF.ts +++ b/packages/normalizr/src/denormalize/UNDEF.ts @@ -2,9 +2,8 @@ export const UNDEF = {} as any; const valueKeys = new Map(); -/** The memo dependency key for an entity value. Objects key themselves; - * `undefined` uses `UNDEF`; any other value (`null`, `INVALID`) gets its own - * stable object, since `WeakMap` keys must be objects. */ +/** `WeakMap` key for an entity value: the object itself, `UNDEF` for + * `undefined`, or a stable stand-in for any other value (`null`, `INVALID`). */ export function depKey(entity: unknown): object { if (typeof entity === 'object' && entity !== null) return entity; if (entity === undefined) return UNDEF; diff --git a/packages/normalizr/src/denormalize/unvisit.ts b/packages/normalizr/src/denormalize/unvisit.ts index 6c511b3d86b1..a1496e0bee26 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 { depKey, UNDEF } from './UNDEF.js'; import type { EntityInterface, EntityPath, @@ -23,45 +22,26 @@ const getUnvisitEntity = ( const inputIsId = typeof entityOrId !== 'object'; const entity = inputIsId ? getEntity({ key: schema.key, pk: entityOrId }) : entityOrId; - if (typeof entity === 'symbol') { - if (!inputIsId) return schema.denormalize(entity, delegate); - // record it as a dependency, so parents recompute once it is valid again - return cache.getEntity( - entityOrId, - schema, - depKey(entity), - localCacheKey => { - localCacheKey.set(entityOrId, 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 + (typeof entity !== 'object' || entity === null) && + // blank ids can't be looked up, so they aren't dependencies 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); + // A missing, null or INVALID entity is still a dependency of anything + // nested above it, so those recompute once it changes. + return cache.getEntity(entityOrId, schema, entity, localCacheKey => { + localCacheKey.set( + entityOrId, + typeof entity === 'symbol' ? + schema.denormalize(entity, delegate) + : entity, + ); }); } - - if (entity === null && inputIsId) { - // record it as a dependency, so parents recompute once it is set - return cache.getEntity( - entityOrId, - schema, - depKey(null), - localCacheKey => { - localCacheKey.set(entityOrId, null); - }, - ); + if (typeof entity === 'symbol') { + return schema.denormalize(entity, delegate); } if (typeof entity !== 'object' || entity === null) { diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index 0ac77e401d72..cacf5f8b0560 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -34,7 +34,7 @@ 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) { diff --git a/packages/react/src/__tests__/concurrent-memo.web.tsx b/packages/react/src/__tests__/concurrent-memo.web.tsx index 0f213791f4ff..ad927c6c90a9 100644 --- a/packages/react/src/__tests__/concurrent-memo.web.tsx +++ b/packages/react/src/__tests__/concurrent-memo.web.tsx @@ -7,7 +7,7 @@ import { } from '@data-client/react'; import { mockInitialState } from '@data-client/test'; import { act, render } from '@testing-library/react'; -import { Todo } from '__tests__/concurrentFixtures'; +import { makeGetTodo, Todo } from '__tests__/concurrentFixtures'; import type { TodoItem } from '__tests__/concurrentFixtures'; import React, { Suspense, useState, useTransition, version } from 'react'; @@ -37,10 +37,7 @@ const getTodoList = new Endpoint(() => Promise.resolve(rows()), { schema: [Todo], name: 'getTodoList', }); -const getTodo = new Endpoint( - ({ id }: { id: string }) => Promise.resolve({ id, title: id }), - { schema: Todo, name: 'getTodo' }, -); +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' }, @@ -98,12 +95,12 @@ function List() { data: todos, who: 'render', }); - return
{todos.length}
; + return
{todos.length}
; } function Detail({ id }: { id: string }) { const todo = useSuspense(getDetail, { id }); - return
{todo.title}
; + return
{todo.title}
; } let controller: ReturnType; @@ -194,17 +191,14 @@ function mount(consumers: number) { ); } +const writeTodo = (id: string, title: string) => { + controller.setResponse(getTodo, { id }, { id, title }); +}; + function beginTransition(row: string, id: string) { act(() => { startTransition(() => { - controller.setResponse( - getTodo, - { id: row }, - { - id: row, - title: `from transition ${id}`, - }, - ); + writeTodo(row, `from transition ${id}`); showDetail(id); }); }); @@ -287,16 +281,7 @@ describeConcurrent.each([1, 2])( mount(consumers); beginTransition('5', 'X'); for (let i = 0; i < 5; i++) { - act(() => { - controller.setResponse( - getTodo, - { id: '7' }, - { - id: '7', - title: `urgent ${i}`, - }, - ); - }); + act(() => writeTodo('7', `urgent ${i}`)); } await finishTransition('X'); trace('listed row writes'); @@ -361,11 +346,7 @@ describeConcurrent.each([1, 2])( await finishTransition('Y'); trace('second transition'); expect(flipBacks(reads)).toBeGreaterThan(0); - // The committed rows changed when the first write committed, and typing - // re-renders are served by useSuspense's useMemo, so the memo never - // sees them re-read before the transition's retries push them out. - // That costs one recompute, after which they are kept. - expect(avoidableMisses(reads)).toBeLessThanOrEqual(1); + expect(avoidableMisses(reads)).toBe(0); }); it('reads outside render during optimistic updates, across a resolved mutation', async () => { @@ -406,21 +387,17 @@ describeConcurrent.each([1, 2])( it('the same pattern as the existing typing test, after two committed writes to a row', async () => { mount(consumers); act(() => { - controller.setResponse(getTodo, { id: '9' }, { id: '9', title: 'v2' }); + writeTodo('9', 'v2'); }); act(() => { - controller.setResponse(getTodo, { id: '9' }, { id: '9', title: 'v3' }); + 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); - // The committed rows changed when the first write committed, and typing - // re-renders are served by useSuspense's useMemo, so the memo never - // sees them re-read before the transition's retries push them out. - // That costs one recompute, after which they are kept. - expect(avoidableMisses(reads)).toBeLessThanOrEqual(1); + expect(avoidableMisses(reads)).toBe(0); }); }, ); From 3f55259d7f5897d7d40a69e3dbcfd2665cded1bc Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 18:59:01 -0400 Subject: [PATCH 04/11] refactor(normalizr): Keep depKey with WeakDependencyMap; tidy memo tests - depKey lives in WeakDependencyMap, its only user - unvisitEntity handles all non-object entities in one block - WeakDependencyMap tests null and INVALID dependencies directly - concurrent-memo test helpers: updateKey, versionsOf(state) - AGENTS.md: invariant 1 without implementation names; MAX_ENTITY_DEPTH - memo-invariants rule also applies to normalizr schemas Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/rules/memo-invariants.md | 1 + .cursor/rules/memo-invariants.mdc | 2 +- packages/normalizr/AGENTS.md | 4 +-- .../src/__tests__/WeakDependencyMap.test.ts | 17 +++++++++ packages/normalizr/src/denormalize/UNDEF.ts | 15 -------- packages/normalizr/src/denormalize/unvisit.ts | 36 +++++++------------ .../normalizr/src/memo/WeakDependencyMap.ts | 17 ++++++++- .../src/__tests__/concurrent-memo.web.tsx | 35 +++++++++--------- 8 files changed, 67 insertions(+), 60 deletions(-) diff --git a/.claude/rules/memo-invariants.md b/.claude/rules/memo-invariants.md index a550701ac604..ff20941c3ee7 100644 --- a/.claude/rules/memo-invariants.md +++ b/.claude/rules/memo-invariants.md @@ -2,6 +2,7 @@ paths: - "packages/normalizr/src/memo/**" - "packages/normalizr/src/denormalize/**" + - "packages/normalizr/src/schemas/**" - "packages/endpoint/src/schemas/**" --- diff --git a/.cursor/rules/memo-invariants.mdc b/.cursor/rules/memo-invariants.mdc index 22a98fe6f9d7..4cf79a7b6546 100644 --- a/.cursor/rules/memo-invariants.mdc +++ b/.cursor/rules/memo-invariants.mdc @@ -1,6 +1,6 @@ --- description: Memoization invariants for normalizr's MemoCache and WeakDependencyMap -globs: packages/normalizr/src/memo/**, packages/normalizr/src/denormalize/**, packages/endpoint/src/schemas/** +globs: packages/normalizr/src/memo/**, packages/normalizr/src/denormalize/**, packages/normalizr/src/schemas/**, packages/endpoint/src/schemas/** alwaysApply: false --- diff --git a/packages/normalizr/AGENTS.md b/packages/normalizr/AGENTS.md index b25713fc371c..1bbeacc8e74b 100644 --- a/packages/normalizr/AGENTS.md +++ b/packages/normalizr/AGENTS.md @@ -15,7 +15,7 @@ Non-obvious constraints when changing `src/memo/`, `src/denormalize/`, or `src/i 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 (`===`; `depKey()` stands in for `undefined`, `null` and `INVALID`) and these `argsKey` results. +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. @@ -25,7 +25,7 @@ Every change to `src/memo/`, or to a schema's `denormalize`, must keep these. Te 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 the max entity depth (64) are read but not tracked. The depth limit exists to bound recursion; changes that deep don't invalidate ancestors. +- 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 diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index 21e945ed469e..78973bb7a25c 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -1,5 +1,6 @@ import { Temporal } from 'temporal-polyfill'; +import { INVALID } from '../denormalize/symbol'; import { EntityPath } from '../interface'; import { MemoPolicy } from '../memo/Policy'; import WeakDependencyMap from '../memo/WeakDependencyMap'; @@ -91,6 +92,22 @@ describe('WeakDependencyMap', () => { expect(wem.get({}, getEntity)[0]).toBeUndefined(); }); + it.each([ + ['null', null], + ['INVALID', INVALID], + ])('tracks a %s dependency, as root or after it', (_, value) => { + const wem = new WeakDependencyMap(); + const withA = (entity: unknown) => + MemoPolicy.getEntities({ ...state, A: { '1': entity as any } }); + const depNull = { path: depA.path, entity: value as any }; + wem.set([depNull], 'root'); + wem.set([depB, depNull], 'child'); + expect(wem.get(value as any, 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('considers empty key list invalid', () => { const wem = new WeakDependencyMap(); expect(() => wem.set([], 'whatever')).toThrowErrorMatchingInlineSnapshot( diff --git a/packages/normalizr/src/denormalize/UNDEF.ts b/packages/normalizr/src/denormalize/UNDEF.ts index c4c1614ceb70..d40bb931d312 100644 --- a/packages/normalizr/src/denormalize/UNDEF.ts +++ b/packages/normalizr/src/denormalize/UNDEF.ts @@ -1,16 +1 @@ export const UNDEF = {} as any; - -const valueKeys = new Map(); - -/** `WeakMap` key for an entity value: the object itself, `UNDEF` for - * `undefined`, or a stable stand-in for any other value (`null`, `INVALID`). */ -export function depKey(entity: unknown): object { - if (typeof entity === 'object' && entity !== null) return entity; - if (entity === undefined) return UNDEF; - let key = valueKeys.get(entity); - if (!key) { - key = {}; - valueKeys.set(entity, key); - } - return key; -} diff --git a/packages/normalizr/src/denormalize/unvisit.ts b/packages/normalizr/src/denormalize/unvisit.ts index a1496e0bee26..7f673028b78a 100644 --- a/packages/normalizr/src/denormalize/unvisit.ts +++ b/packages/normalizr/src/denormalize/unvisit.ts @@ -22,30 +22,20 @@ const getUnvisitEntity = ( const inputIsId = typeof entityOrId !== 'object'; const entity = inputIsId ? getEntity({ key: schema.key, pk: entityOrId }) : entityOrId; - if ( - inputIsId && - (typeof entity !== 'object' || entity === null) && - // blank ids can't be looked up, so they aren't dependencies - entityOrId !== '' && - entityOrId !== 'undefined' - ) { - // A missing, null or INVALID entity is still a dependency of anything - // nested above it, so those recompute once it changes. - return cache.getEntity(entityOrId, schema, entity, localCacheKey => { - localCacheKey.set( - entityOrId, - typeof entity === 'symbol' ? - schema.denormalize(entity, delegate) - : entity, - ); - }); - } - if (typeof entity === 'symbol') { - return schema.denormalize(entity, delegate); - } - if (typeof entity !== 'object' || entity === null) { - return entity as any; + const value = () => + typeof entity === 'symbol' ? + schema.denormalize(entity, delegate) + : entity; + // 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. + if (inputIsId && entityOrId !== '' && entityOrId !== 'undefined') { + return cache.getEntity(entityOrId, schema, entity, localCacheKey => { + localCacheKey.set(entityOrId, value()); + }); + } + return value() as any; } let pk: string | number | undefined = diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index cacf5f8b0560..6f846767d426 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -1,4 +1,4 @@ -import { depKey } from '../denormalize/UNDEF.js'; +import { UNDEF } from '../denormalize/UNDEF.js'; /** Function path used by `argsKey` deps. Distinguished from object paths * via `typeof === 'function'`. */ @@ -8,6 +8,21 @@ export type KeyFn = (args: readonly any[]) => string | undefined; * can perform Map lookups consistently. */ const UNDEF_KEY = '\0'; +const valueKeys = new Map(); + +/** `WeakMap` key for an entity value: the object itself, `UNDEF` for + * `undefined`, or a stable stand-in for any other value (`null`, `INVALID`). */ +function depKey(entity: unknown): object { + if (typeof entity === 'object' && entity !== null) return entity; + if (entity === undefined) return UNDEF; + let key = valueKeys.get(entity); + if (!key) { + key = {}; + valueKeys.set(entity, key); + } + return key; +} + /** Maps a (ordered) list of dependencies to a value. * * Useful as a memoization cache for flat/normalized stores. diff --git a/packages/react/src/__tests__/concurrent-memo.web.tsx b/packages/react/src/__tests__/concurrent-memo.web.tsx index ad927c6c90a9..c83717b4fde9 100644 --- a/packages/react/src/__tests__/concurrent-memo.web.tsx +++ b/packages/react/src/__tests__/concurrent-memo.web.tsx @@ -1,3 +1,4 @@ +import type { State } from '@data-client/core'; import { Endpoint, Entity } from '@data-client/endpoint'; import { DataProvider, @@ -17,8 +18,8 @@ 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 (the old dependency chain kept every - * version whose entities were alive). + * caching more would have avoided, since those versions' entities are + * still alive. */ const LegacyReact = version.startsWith('16') || version.startsWith('17'); @@ -54,9 +55,9 @@ 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(`update ${id} ${title}`), + ({ id, title }: { id: string; title: string }) => hold(updateKey(id, title)), { schema: Todo, sideEffect: true, @@ -70,8 +71,7 @@ const noDevManagers = getDefaultManagers({ devToolsManager: null }); const ids = new WeakMap(); let nextId = 0; -const idOf = (o: object | undefined) => { - if (!o) return -1; +const idOf = (o: object) => { let id = ids.get(o); if (id === undefined) { id = nextId++; @@ -83,15 +83,16 @@ const idOf = (o: object | undefined) => { /** Every read of the list: entity versions read, result identity, reader. */ type Read = { versions: string; data: unknown; who: string }; let reads: Read[] = []; -const versionsOf = (table: Record, todos: { id: string }[]) => - todos.map(todo => idOf(table[todo.id])).join(','); +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(); - const table = (state.entities[Todo.key] ?? {}) as Record; reads.push({ - versions: versionsOf(table, todos), + versions: versionsOf(state, todos), data: todos, who: 'render', }); @@ -114,7 +115,7 @@ function App({ consumers }: { consumers: number }) { const [text, setText] = useState(''); const [isPending, start] = useTransition(); startTransition = start; - showDetail = id => setDetail(ids => [...ids, id]); + showDetail = id => setDetail(prev => [...prev, id]); typeText = setText; return (
@@ -138,8 +139,7 @@ function App({ consumers }: { consumers: number }) { function readRaw() { const state = controller.getState(); const { data } = controller.getResponse(getTodoList, state); - const table = (state.entities[Todo.key] ?? {}) as Record; - reads.push({ versions: versionsOf(table, data as any), data, who: 'raw' }); + reads.push({ versions: versionsOf(state, data as any), data, who: 'raw' }); } /** Reads of versions already read before, that got a different result. */ @@ -230,13 +230,13 @@ function beginOptimistic(id: string, title: string) { async function rollBack(id: string, title: string) { await act(async () => { - pending[`update ${id} ${title}`].reject(new Error('rejected')); + pending[updateKey(id, title)].reject(new Error('rejected')); }); } async function resolveOptimistic(id: string, title: string) { await act(async () => { - pending[`update ${id} ${title}`].resolve({ id, title }); + pending[updateKey(id, title)].resolve({ id, title }); }); } @@ -340,7 +340,6 @@ describeConcurrent.each([1, 2])( for (let i = 0; i < 3; i++) act(() => typeText(`a${i}`)); await finishTransition('X'); expect(getByTestId('pending').textContent).toBe('false'); - // the store moved on: the committed rows now include row 5's new version beginTransition('6', 'Y'); for (let i = 0; i < 3; i++) act(() => typeText(`b${i}`)); await finishTransition('Y'); @@ -371,7 +370,7 @@ describeConcurrent.each([1, 2])( 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 and the old chain would hit + // 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); @@ -384,7 +383,7 @@ describeConcurrent.each([1, 2])( expect(avoidableMisses(reads)).toBe(0); }); - it('the same pattern as the existing typing test, after two committed writes to a row', async () => { + it('urgent re-renders during a pending transition, after two committed writes to a row', async () => { mount(consumers); act(() => { writeTodo('9', 'v2'); From acbfb64bf2c8234b5fcfde62d903b2c2c231fe26 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 20:54:38 -0400 Subject: [PATCH 05/11] test(normalizr): Cover blank nested ids in unvisitEntity Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/normalizr/src/__tests__/MemoCache.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/normalizr/src/__tests__/MemoCache.ts b/packages/normalizr/src/__tests__/MemoCache.ts index 07f52a448c06..ffa860c9d1c0 100644 --- a/packages/normalizr/src/__tests__/MemoCache.ts +++ b/packages/normalizr/src/__tests__/MemoCache.ts @@ -1093,6 +1093,21 @@ describe('MemoCache nested entity dependencies', () => { }, ); + 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 } }; From 8d60311c748826d87fadec0fef93e7518fbe6970 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 22:38:54 -0400 Subject: [PATCH 06/11] fix(normalizr): Key function dependencies by themselves in depKey Entity classes are functions, and buildQueryKey records them as the first dependency. depKey gave them a stand-in in a module-level Map, which kept every queried schema alive. Functions are valid WeakMap keys. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/__tests__/WeakDependencyMap.test.ts | 12 +++++++++++- packages/normalizr/src/memo/WeakDependencyMap.ts | 13 +++++++++---- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index 78973bb7a25c..34cf13ab05a7 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -3,7 +3,7 @@ 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' }; @@ -108,6 +108,16 @@ describe('WeakDependencyMap', () => { 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( diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index 6f846767d426..b9f0a59ef042 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -10,10 +10,15 @@ const UNDEF_KEY = '\0'; const valueKeys = new Map(); -/** `WeakMap` key for an entity value: the object itself, `UNDEF` for - * `undefined`, or a stable stand-in for any other value (`null`, `INVALID`). */ -function depKey(entity: unknown): object { - if (typeof entity === 'object' && entity !== null) return entity; +/** `WeakMap` key for an entity value: the object or function itself, `UNDEF` + * for `undefined`, or a stable stand-in for any other value (`null`, + * `INVALID`). Only those few values get stand-ins, since they're never freed. */ +export function depKey(entity: unknown): object { + if ( + (typeof entity === 'object' && entity !== null) || + typeof entity === 'function' + ) + return entity; if (entity === undefined) return UNDEF; let key = valueKeys.get(entity); if (!key) { From 0ce7d8aec2c7e8fcc251e61fb5d106824d64dece Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sat, 24 Oct 2026 23:26:42 -0400 Subject: [PATCH 07/11] refactor(normalizr): Drop UNDEF; one blank-id check in unvisitEntity - depKey's stand-in Map already covers undefined, so UNDEF.ts goes - unvisitEntity: no value closure; isBlankPk shared by both branches - tests: rows hold the store table; one settle() for held requests - memo-invariants rule just points at AGENTS.md Co-Authored-By: Claude Opus 5.5 (1M context) --- .claude/rules/memo-invariants.md | 2 +- .cursor/rules/memo-invariants.mdc | 2 +- packages/normalizr/src/__tests__/MemoCache.ts | 10 +++---- .../src/__tests__/WeakDependencyMap.test.ts | 16 +++++------ packages/normalizr/src/denormalize/UNDEF.ts | 1 - packages/normalizr/src/denormalize/unvisit.ts | 27 +++++++++++++------ .../normalizr/src/memo/WeakDependencyMap.ts | 10 +++---- .../src/__tests__/concurrent-memo.web.tsx | 27 +++++++------------ 8 files changed, 46 insertions(+), 49 deletions(-) delete mode 100644 packages/normalizr/src/denormalize/UNDEF.ts diff --git a/.claude/rules/memo-invariants.md b/.claude/rules/memo-invariants.md index ff20941c3ee7..b7528e91ae58 100644 --- a/.claude/rules/memo-invariants.md +++ b/.claude/rules/memo-invariants.md @@ -10,4 +10,4 @@ paths: # Memo invariants -Before changing memoization, or a schema's `denormalize`, read "Memo invariants" in `packages/normalizr/AGENTS.md`. Every change must keep all of them. Cover any new edge case with a test in one of the files listed there. +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 index 4cf79a7b6546..fc54576f697b 100644 --- a/.cursor/rules/memo-invariants.mdc +++ b/.cursor/rules/memo-invariants.mdc @@ -6,4 +6,4 @@ alwaysApply: false # Memo invariants -Before changing memoization, or a schema's `denormalize`, read "Memo invariants" in `packages/normalizr/AGENTS.md`. Every change must keep all of them. Cover any new edge case with a test in one of the files listed there. +Before changing memoization, or a schema's `denormalize`, read "Memo invariants" in `packages/normalizr/AGENTS.md`. diff --git a/packages/normalizr/src/__tests__/MemoCache.ts b/packages/normalizr/src/__tests__/MemoCache.ts index ffa860c9d1c0..4e1c6f7092e1 100644 --- a/packages/normalizr/src/__tests__/MemoCache.ts +++ b/packages/normalizr/src/__tests__/MemoCache.ts @@ -1071,15 +1071,13 @@ describe('MemoCache nested entity dependencies', () => { const bob = { id: '2', username: 'bob' }; it.each([ - ['missing', undefined], - ['INVALID', INVALID], - ['null', null], + ['missing', {}], + ['INVALID', { '2': INVALID }], + ['null', { '2': null }], ])( 'a %s child is a dependency: the parent recomputes once it is set', - (_, child) => { + (_, users) => { const memo = new MemoCache(); - const users: Record = - child === undefined ? {} : { '2': child }; const first = memo.denormalize(Article, '1', { Article: { '1': article }, User: users, diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts index 34cf13ab05a7..966edb61e337 100644 --- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts +++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts @@ -92,17 +92,17 @@ describe('WeakDependencyMap', () => { expect(wem.get({}, getEntity)[0]).toBeUndefined(); }); - it.each([ + 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: unknown) => - MemoPolicy.getEntities({ ...state, A: { '1': entity as any } }); - const depNull = { path: depA.path, entity: value as any }; - wem.set([depNull], 'root'); - wem.set([depB, depNull], 'child'); - expect(wem.get(value as any, withA(value))[0]).toBe('root'); + 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(); @@ -183,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/unvisit.ts b/packages/normalizr/src/denormalize/unvisit.ts index 7f673028b78a..f477aa3ac203 100644 --- a/packages/normalizr/src/denormalize/unvisit.ts +++ b/packages/normalizr/src/denormalize/unvisit.ts @@ -10,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,19 +35,18 @@ const getUnvisitEntity = ( const entity = inputIsId ? getEntity({ key: schema.key, pk: entityOrId }) : entityOrId; if (typeof entity !== 'object' || entity === null) { - const value = () => - typeof entity === 'symbol' ? - schema.denormalize(entity, delegate) - : entity; // 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. - if (inputIsId && entityOrId !== '' && entityOrId !== 'undefined') { + if (inputIsId && !isBlankPk(entityOrId)) { return cache.getEntity(entityOrId, schema, entity, localCacheKey => { - localCacheKey.set(entityOrId, value()); + localCacheKey.set( + entityOrId, + nonObjectValue(schema, entity, delegate), + ); }); } - return value() as any; + return nonObjectValue(schema, entity, delegate); } let pk: string | number | undefined = @@ -45,7 +56,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 b9f0a59ef042..57ebbcff7335 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -1,5 +1,3 @@ -import { UNDEF } from '../denormalize/UNDEF.js'; - /** Function path used by `argsKey` deps. Distinguished from object paths * via `typeof === 'function'`. */ export type KeyFn = (args: readonly any[]) => string | undefined; @@ -10,16 +8,15 @@ const UNDEF_KEY = '\0'; const valueKeys = new Map(); -/** `WeakMap` key for an entity value: the object or function itself, `UNDEF` - * for `undefined`, or a stable stand-in for any other value (`null`, - * `INVALID`). Only those few values get stand-ins, since they're never freed. */ +/** `WeakMap` key for an entity value: the object or function itself, or a + * stable stand-in for any other value (`undefined`, `null`, `INVALID`). Only + * those few values get stand-ins, since they're never freed. */ export function depKey(entity: unknown): object { if ( (typeof entity === 'object' && entity !== null) || typeof entity === 'function' ) return entity; - if (entity === undefined) return UNDEF; let key = valueKeys.get(entity); if (!key) { key = {}; @@ -58,7 +55,6 @@ export default class WeakDependencyMap< if (!curLink) return EMPTY; if (this.hasStr) return this._getMixed(curLink, getDependency, args); while (curLink.nextPath) { - // WeakMap keys must be objects, so other values use a stand-in const nextDependency = depKey(getDependency(curLink.nextPath as Path)); const nextLink = curLink.next.get(nextDependency as any); if (!nextLink) return EMPTY; diff --git a/packages/react/src/__tests__/concurrent-memo.web.tsx b/packages/react/src/__tests__/concurrent-memo.web.tsx index c83717b4fde9..9f6caf4c9c90 100644 --- a/packages/react/src/__tests__/concurrent-memo.web.tsx +++ b/packages/react/src/__tests__/concurrent-memo.web.tsx @@ -204,21 +204,21 @@ function beginTransition(row: string, id: string) { }); } -async function finishTransition(id: string) { +/** Resolves a held request with `value`, or rejects it with an `Error`. */ +async function settle(key: string, value: TodoItem | Error) { await act(async () => { - pending[id].resolve({ id, title: 'detail' }); + 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}`, - }, + { id: `${i}`, text: `note ${i}` }, ); }); @@ -228,17 +228,10 @@ function beginOptimistic(id: string, title: string) { }); } -async function rollBack(id: string, title: string) { - await act(async () => { - pending[updateKey(id, title)].reject(new Error('rejected')); - }); -} - -async function resolveOptimistic(id: string, title: string) { - await act(async () => { - pending[updateKey(id, title)].resolve({ id, title }); - }); -} +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)', From dc95623b6886886287eb96dee00de21fd61d3381 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 25 Oct 2026 00:12:21 -0400 Subject: [PATCH 08/11] bench: Measure nested missing, null and INVALID entities Every 4th nested BuildTypeDescription in the project list is missing from the store, null, or INVALID, cold and withCache. Each is now a memo dependency of the project that nests it. Export INVALID from the bundle. Co-Authored-By: Claude Opus 5.5 (1M context) --- examples/benchmark/README.md | 2 ++ examples/benchmark/normalizr.js | 31 +++++++++++++++++++++++++++++++ examples/benchmark/src/index.ts | 1 + 3 files changed, 34 insertions(+) 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 { From de8c3356aa87a24bc9f49b0cc75d23034242f2fb Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Sun, 25 Oct 2026 01:15:13 -0400 Subject: [PATCH 09/11] perf(normalizr): Fixed depKey stand-ins for undefined, null and INVALID depKey runs on every memo dependency. Looking up undefined, null and INVALID in a Map made cached reads of lists with missing, null or INVALID children up to 33% slower than master; fixed objects need no lookup. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../normalizr/src/memo/WeakDependencyMap.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts index 57ebbcff7335..156675843524 100644 --- a/packages/normalizr/src/memo/WeakDependencyMap.ts +++ b/packages/normalizr/src/memo/WeakDependencyMap.ts @@ -1,3 +1,5 @@ +import { INVALID } from '../denormalize/symbol.js'; + /** Function path used by `argsKey` deps. Distinguished from object paths * via `typeof === 'function'`. */ export type KeyFn = (args: readonly any[]) => string | undefined; @@ -6,21 +8,24 @@ export type KeyFn = (args: readonly any[]) => string | undefined; * can perform Map lookups consistently. */ const UNDEF_KEY = '\0'; -const valueKeys = new Map(); +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 - * stable stand-in for any other value (`undefined`, `null`, `INVALID`). Only - * those few values get stand-ins, since they're never freed. */ + * 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' && entity !== null) || - typeof entity === 'function' - ) - return entity; - let key = valueKeys.get(entity); + 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 = {}; - valueKeys.set(entity, key); + otherDeps.set(entity, key); } return key; } From d5f777e3370e2ea1af900f8f0bf0f8f4e376f3f8 Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Mon, 26 Oct 2026 10:38:57 -0400 Subject: [PATCH 10/11] perf(normalizr): Record non-object children as plain dependencies A missing, null or INVALID child only needs its dependency recorded; its value is not worth caching. Skip the entity cache (closure, cache entry, arrays) and push the dependency directly. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/normalizr/src/denormalize/cache.ts | 3 +++ packages/normalizr/src/denormalize/localCache.ts | 2 ++ packages/normalizr/src/denormalize/unvisit.ts | 11 +++-------- packages/normalizr/src/memo/globalCache.ts | 4 ++++ 4 files changed, 12 insertions(+), 8 deletions(-) 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 f477aa3ac203..a3067c24ceef 100644 --- a/packages/normalizr/src/denormalize/unvisit.ts +++ b/packages/normalizr/src/denormalize/unvisit.ts @@ -38,14 +38,9 @@ const getUnvisitEntity = ( // 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. - if (inputIsId && !isBlankPk(entityOrId)) { - return cache.getEntity(entityOrId, schema, entity, localCacheKey => { - localCacheKey.set( - entityOrId, - nonObjectValue(schema, entity, delegate), - ); - }); - } + // Only the dependency is needed: there is nothing worth caching. + if (inputIsId && !isBlankPk(entityOrId)) + cache.dependOn(entityOrId, schema, entity); return nonObjectValue(schema, entity, delegate); } diff --git a/packages/normalizr/src/memo/globalCache.ts b/packages/normalizr/src/memo/globalCache.ts index 2f5a34de5387..f9f733f517d9 100644 --- a/packages/normalizr/src/memo/globalCache.ts +++ b/packages/normalizr/src/memo/globalCache.ts @@ -50,6 +50,10 @@ 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, From 595ead97a0d31f64e5b7a7ba93c14d6a09f6d66b Mon Sep 17 00:00:00 2001 From: Nathaniel Tucker Date: Mon, 26 Oct 2026 11:31:10 -0400 Subject: [PATCH 11/11] chore: Format dependOn --- packages/normalizr/src/memo/globalCache.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/normalizr/src/memo/globalCache.ts b/packages/normalizr/src/memo/globalCache.ts index f9f733f517d9..1c2358abe79e 100644 --- a/packages/normalizr/src/memo/globalCache.ts +++ b/packages/normalizr/src/memo/globalCache.ts @@ -51,7 +51,10 @@ export default class GlobalCache implements Cache { } dependOn(pk: string, schema: EntityInterface, entity: unknown): void { - this.dependencies.push({ path: { key: schema.key, pk }, entity: entity as any }); + this.dependencies.push({ + path: { key: schema.key, pk }, + entity: entity as any, + }); } getEntity(