From bf0c004d4c284a274851a37e1865200f8c4757cd Mon Sep 17 00:00:00 2001
From: Nathaniel Tucker
Date: Sat, 24 Oct 2026 18:02:48 -0400
Subject: [PATCH 01/11] feat(normalizr): Keep results a committed render read
React serves urgent re-renders of an unchanged store from hook memos, so
the memo never saw the committed result again while a pending
transition kept retrying with fresh entities, and evicted it.
MemoCache.denormalize now remembers which results-cache entry it read
for each store entities object, and MemoCache.commit(entities) pins
those entries. DataStore calls it from an effect, which only runs for
committed renders. Each node pins two entries: the one a committed render
last read, and the one the store last flipped back to, so an optimistic
rollback still finds the version from before it.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
packages/core/src/controller/Controller.ts | 11 ++++-
packages/normalizr/AGENTS.md | 3 +-
packages/normalizr/src/__tests__/MemoCache.ts | 32 +++++++++++++
packages/normalizr/src/memo/MemoCache.ts | 23 +++++++++-
.../normalizr/src/memo/WeakDependencyMap.ts | 45 +++++++++++++++++--
.../src/__tests__/concurrent-memo.web.tsx | 16 +++----
packages/react/src/components/DataStore.tsx | 6 +++
7 files changed, 119 insertions(+), 17 deletions(-)
diff --git a/packages/core/src/controller/Controller.ts b/packages/core/src/controller/Controller.ts
index 5afa12a14a33..113af288fddd 100644
--- a/packages/core/src/controller/Controller.ts
+++ b/packages/core/src/controller/Controller.ts
@@ -49,12 +49,18 @@ import type { ActionTypes, State } from '../types.js';
export type GenericDispatch = (value: any) => Promise;
export type DataClientDispatch = (value: ActionTypes) => Promise;
+/** Optional: lets the memo keep results from committed stores (see `MemoCache.commit`). */
+interface MemoCommit {
+ commit?(entities: object): void;
+}
+
export interface ControllerConstructorProps<
D extends GenericDispatch = DataClientDispatch,
> {
dispatch?: D;
getState?: () => State;
- memo?: Pick;
+ memo?: Pick &
+ MemoCommit;
gcPolicy?: GCInterface;
}
@@ -98,7 +104,8 @@ export default class Controller<
declare readonly memo: Pick<
MemoCache,
'denormalize' | 'query' | 'buildQueryKey'
- >;
+ > &
+ MemoCommit;
/**
* Handles garbage collection
diff --git a/packages/normalizr/AGENTS.md b/packages/normalizr/AGENTS.md
index 864f89fcbdcf..f246cac84c63 100644
--- a/packages/normalizr/AGENTS.md
+++ b/packages/normalizr/AGENTS.md
@@ -32,7 +32,8 @@ Accepted limitations (intentional; don't "fix"):
- A render that applies a pending store update rebuilds every entity that update touches (`useReducer` has no eager state). Those reads are one-offs.
- Within one committed store, a reader re-reads at most one version unchanged: renders read the committed `optimisticState`, managers and callbacks read `controller.getState()`. Every commit starts a new version. Rolling back an optimistic update returns to the older real entities.
-- So `WeakDependencyMap` keeps the most recently used entries plus the one the store last flipped back to. Bound: `useSuspense` serves urgent re-renders of an unchanged store from its own `useMemo`, so during a retrying transition a newly committed version can be recomputed once.
+- So `WeakDependencyMap` keeps the most recently used entries, never evicting two: the one the store last flipped back to, and the one a committed render last read.
+- Commits come from `MemoCache.commit(entities)`, which `DataStore` calls from an effect (effects never run for discarded renders). Recency alone can't find the committed result: `useSuspense` serves urgent re-renders of an unchanged store from its own `useMemo`, so those reads never reach the memo.
- `yarn workspace example-benchmark start:memo-tradeoffs` measures hit rates and retained memory for these choices.
## Schema-tree traversal pattern
diff --git a/packages/normalizr/src/__tests__/MemoCache.ts b/packages/normalizr/src/__tests__/MemoCache.ts
index af4fdfa375a8..ba66a3bedc60 100644
--- a/packages/normalizr/src/__tests__/MemoCache.ts
+++ b/packages/normalizr/src/__tests__/MemoCache.ts
@@ -1113,3 +1113,35 @@ describe('MemoCache nested entity dependencies', () => {
expect(second.data).toBe(first.data);
});
});
+
+describe('MemoCache.commit', () => {
+ class Todo extends Entity {
+ id = '';
+ title = '';
+ }
+ const input = ['1', '2'];
+ const store = (title: string) => ({
+ Todo: { '1': { id: '1', title }, '2': { id: '2', title: 'b' } },
+ });
+
+ it('keeps a committed result through any number of one-off reads', () => {
+ const memo = new MemoCache();
+ const committed = store('committed');
+ const first = memo.denormalize([Todo], input, committed).data;
+ memo.commit(committed);
+ // a pending transition: each retry builds new entities, read once
+ for (let i = 0; i < 5; i++)
+ memo.denormalize([Todo], input, store(`retry ${i}`));
+ expect(memo.denormalize([Todo], input, committed).data).toBe(first);
+ });
+
+ it('without commit, one-off reads push out a result read only once', () => {
+ const memo = new MemoCache();
+ memo.denormalize([Todo], input, store('first'));
+ const committed = store('committed');
+ const first = memo.denormalize([Todo], input, committed).data;
+ for (let i = 0; i < 5; i++)
+ memo.denormalize([Todo], input, store(`retry ${i}`));
+ expect(memo.denormalize([Todo], input, committed).data).not.toBe(first);
+ });
+});
diff --git a/packages/normalizr/src/memo/MemoCache.ts b/packages/normalizr/src/memo/MemoCache.ts
index 9f5c03917027..8f6c2416e18a 100644
--- a/packages/normalizr/src/memo/MemoCache.ts
+++ b/packages/normalizr/src/memo/MemoCache.ts
@@ -1,5 +1,6 @@
import GlobalCache from './globalCache.js';
import WeakDependencyMap from './WeakDependencyMap.js';
+import type { KeepToken } from './WeakDependencyMap.js';
import buildQueryKey from '../buildQueryKey.js';
import { GetEntityCache, getEntityCaches } from './entitiesCache.js';
import { MemoPolicy } from './Policy.js';
@@ -23,6 +24,8 @@ export default class MemoCache {
declare protected _getCache: GetEntityCache;
/** Caches the final denormalized form based on input, entities */
protected endpoints: EndpointsCache = new WeakDependencyMap();
+ /** Results read per store `entities`, until that store commits. */
+ private reads = new WeakMap
}>
{detail.map(id => (
@@ -177,16 +217,21 @@ function trace(name: string) {
);
}
-function mount(consumers: number) {
+function mount(consumers: number, project = false) {
return render(
-
+ ,
);
}
@@ -396,6 +441,29 @@ describeConcurrent.each([1, 2])(
expect(avoidableMisses(reads)).toBe(0);
});
+ it('a single-entity read whose nested child each transition retry rewrites', async () => {
+ mount(consumers, true);
+ const writeOwner = (name: string) => {
+ controller.setResponse(getOwner, { id: 'o' }, { id: 'o', name });
+ };
+ act(() => writeOwner('v2'));
+ act(() => writeOwner('v3'));
+ act(() => {
+ startTransition(() => {
+ writeOwner('from transition');
+ showDetail('X');
+ });
+ });
+ for (let i = 0; i < 3; i++) act(() => typeText(`a${i}`));
+ await finishTransition('X');
+ const projectReads = reads.filter(r => r.who === 'project');
+ trace('single-entity read');
+ // A pk input never reaches the endpoints tier, so commit pins don't
+ // cover it: recency alone keeps it, with at most one recompute.
+ expect(avoidableMisses(projectReads)).toBeLessThanOrEqual(1);
+ expect(avoidableMisses(reads.filter(r => r.who !== 'project'))).toBe(0);
+ });
+
// Optimistic updates stack: each one rebuilds the entities it touches on
// every store change, so only the store from before all of them can be
// read again. However many are in flight, settling the last one must
diff --git a/packages/react/src/components/__tests__/provider.tsx b/packages/react/src/components/__tests__/provider.tsx
index b08a9aabeb32..15b50b197d54 100644
--- a/packages/react/src/components/__tests__/provider.tsx
+++ b/packages/react/src/components/__tests__/provider.tsx
@@ -5,6 +5,8 @@ import {
Middleware,
Controller,
} from '@data-client/core';
+import { Endpoint, Entity } from '@data-client/endpoint';
+import { MemoCache } from '@data-client/normalizr';
import { act, render } from '@testing-library/react';
import { CoolerArticleResource } from '__tests__/new';
import nock from 'nock';
@@ -149,6 +151,96 @@ describe('', () => {
expect((ctrl as MyController).doSomething()).toBe('hi');
});
+ describe('memo commit', () => {
+ class Item extends Entity {
+ id = '';
+ title = '';
+ }
+ let release: (() => void) | undefined;
+ const updateItem = new Endpoint(
+ ({ id, title }: { id: string; title: string }) =>
+ new Promise<{ id: string; title: string }>(resolve => {
+ release = () => resolve({ id, title });
+ }),
+ {
+ schema: Item,
+ sideEffect: true,
+ name: 'updateItem',
+ getOptimisticResponse(_snap, { id, title }) {
+ return { id, title };
+ },
+ },
+ );
+
+ it('commits each committed store, unsettled while optimistic updates are in flight', async () => {
+ const memo = new MemoCache();
+ const commit = jest.spyOn(memo, 'commit');
+ class MemoController extends Controller {
+ constructor(options: ConstructorParameters[0]) {
+ super({ ...options, memo });
+ }
+ }
+ let ctrl: Controller | undefined;
+ let state: any;
+ function ContextTester() {
+ ctrl = useController();
+ state = useContext(StateContext);
+ return null;
+ }
+ render(
+
+
+ ,
+ );
+ expect(commit).toHaveBeenLastCalledWith(state.entities, true);
+ let done: Promise | undefined;
+ act(() => {
+ done = ctrl?.fetch(updateItem, { id: '1', title: 'optimistic' });
+ });
+ // the rendered store holds the optimistic update
+ expect(state.entities.Item['1'].title).toBe('optimistic');
+ expect(commit).toHaveBeenLastCalledWith(state.entities, false);
+ await act(async () => {
+ release?.();
+ await done;
+ });
+ expect(commit).toHaveBeenLastCalledWith(state.entities, true);
+ });
+
+ it('works with a custom memo that has no commit', () => {
+ const inner = new MemoCache();
+ const memo = {
+ denormalize: inner.denormalize.bind(inner),
+ query: inner.query.bind(inner),
+ buildQueryKey: inner.buildQueryKey.bind(inner),
+ };
+ class MemoController extends Controller {
+ constructor(options: ConstructorParameters[0]) {
+ super({ ...options, memo });
+ }
+ }
+ let ctrl: Controller | undefined;
+ function ContextTester() {
+ ctrl = useController();
+ return null;
+ }
+ render(
+
+
+ ,
+ );
+ expect(() =>
+ act(() => {
+ ctrl?.setResponse(
+ CoolerArticleResource.get,
+ { id: 5 },
+ { id: 5, title: 'hi', content: 'more things here' },
+ );
+ }),
+ ).not.toThrow();
+ });
+ });
+
it('should change state', () => {
jest.useFakeTimers({ now: 50 });
let ctrl: Controller | undefined = undefined;
From 4efca99d8a71c8048fbbdce44b8323647c67b8d8 Mon Sep 17 00:00:00 2001
From: Nathaniel Tucker
Date: Sun, 25 Oct 2026 00:01:38 -0400
Subject: [PATCH 08/11] docs: Note that only the endpoints tier is pinned; add
a changeset
Co-Authored-By: Claude Opus 5.5 (1M context)
---
.changeset/memo-commit-pins.md | 9 +++++++++
packages/normalizr/AGENTS.md | 1 +
2 files changed, 10 insertions(+)
create mode 100644 .changeset/memo-commit-pins.md
diff --git a/.changeset/memo-commit-pins.md b/.changeset/memo-commit-pins.md
new file mode 100644
index 000000000000..101c4eab45e9
--- /dev/null
+++ b/.changeset/memo-commit-pins.md
@@ -0,0 +1,9 @@
+---
+'@data-client/normalizr': patch
+'@data-client/core': patch
+'@data-client/react': patch
+---
+
+Keep results that a committed render read while a transition retries
+
+`DataProvider` now tells the memo which store version committed, so results a committed render read stay cached through any number of pending-transition retries and optimistic updates. Custom `Controller` memos can implement the new optional `commit(entities, settled)` method; ones without it keep working.
diff --git a/packages/normalizr/AGENTS.md b/packages/normalizr/AGENTS.md
index 90e643ff7404..9ad857d8d0cf 100644
--- a/packages/normalizr/AGENTS.md
+++ b/packages/normalizr/AGENTS.md
@@ -34,6 +34,7 @@ Accepted limitations (intentional; don't "fix"):
- Within one committed store, a reader re-reads at most one version unchanged: renders read the committed `optimisticState`, managers and callbacks read `controller.getState()`. Every commit starts a new version. Rolling back an optimistic update returns to the older real entities.
- So `WeakDependencyMap` keeps the two most recently used entries, and never evicts three more: the one the store last flipped back to, the one a committed render last read, and the one a committed render of a store with no optimistic updates last read. Each optimistic update rebuilds the entities it touches on every store change, so once the last of any number in flight settles, that last one is the only earlier result the store can return to.
- Commits come from `MemoCache.commit(entities, settled)`, which `DataStore` calls from an effect (effects never run for discarded renders). Reads are buffered per `entities` (one per node) until it commits; reads with the latest committed `entities` are kept right away, so late mounts are covered. Recency alone can't find the committed result: `useSuspense` serves urgent re-renders of an unchanged store from its own `useMemo`, so those reads never reach the memo.
+- Only the endpoints tier is pinned: it is the only `WeakDependencyMap` built with `tracksLast`. A result whose input is a pk (a single-entity endpoint) lives in the entity tier, so recency alone keeps it and a retrying transition can rebuild it once. The per-entity maps must not track: they live as long as the entity cache, so a recorded last entry would never be freed.
- `yarn workspace example-benchmark start:memo-tradeoffs` measures hit rates and retained memory for these choices.
## Schema-tree traversal pattern
From 4ae0a5d2ffe6c730f294c637222eb446c79207a1 Mon Sep 17 00:00:00 2001
From: Nathaniel Tucker
Date: Sun, 25 Oct 2026 00:20:23 -0400
Subject: [PATCH 09/11] feat(normalizr): Pin the top-level entity of pk results
on commit
Commit pins only reached the endpoints tier, so single-entity endpoints,
entity queries and Collection queries, whose input is a pk, kept
#4459's bound: a retrying transition could rebuild them once. Now the
top-level getEntity of a pk input records its entry, and MemoCache pins
it the same way. Tracking is per call (`track`), so nested entity maps
still never hold a recorded entry.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
packages/normalizr/AGENTS.md | 2 +-
packages/normalizr/src/__tests__/MemoCache.ts | 67 +++++++++++++++----
.../src/__tests__/WeakDependencyMap.test.ts | 18 ++---
packages/normalizr/src/memo/MemoCache.ts | 29 +++++---
.../normalizr/src/memo/WeakDependencyMap.ts | 33 +++++----
packages/normalizr/src/memo/globalCache.ts | 30 ++++++++-
.../src/__tests__/concurrent-memo.web.tsx | 5 +-
7 files changed, 127 insertions(+), 57 deletions(-)
diff --git a/packages/normalizr/AGENTS.md b/packages/normalizr/AGENTS.md
index 9ad857d8d0cf..e44bef600c9e 100644
--- a/packages/normalizr/AGENTS.md
+++ b/packages/normalizr/AGENTS.md
@@ -34,7 +34,7 @@ Accepted limitations (intentional; don't "fix"):
- Within one committed store, a reader re-reads at most one version unchanged: renders read the committed `optimisticState`, managers and callbacks read `controller.getState()`. Every commit starts a new version. Rolling back an optimistic update returns to the older real entities.
- So `WeakDependencyMap` keeps the two most recently used entries, and never evicts three more: the one the store last flipped back to, the one a committed render last read, and the one a committed render of a store with no optimistic updates last read. Each optimistic update rebuilds the entities it touches on every store change, so once the last of any number in flight settles, that last one is the only earlier result the store can return to.
- Commits come from `MemoCache.commit(entities, settled)`, which `DataStore` calls from an effect (effects never run for discarded renders). Reads are buffered per `entities` (one per node) until it commits; reads with the latest committed `entities` are kept right away, so late mounts are covered. Recency alone can't find the committed result: `useSuspense` serves urgent re-renders of an unchanged store from its own `useMemo`, so those reads never reach the memo.
-- Only the endpoints tier is pinned: it is the only `WeakDependencyMap` built with `tracksLast`. A result whose input is a pk (a single-entity endpoint) lives in the entity tier, so recency alone keeps it and a retrying transition can rebuild it once. The per-entity maps must not track: they live as long as the entity cache, so a recorded last entry would never be freed.
+- Pins go on the result's own entry: in the endpoints tier, or in the top-level entity's map when the input is a pk (single-entity endpoints, entity and Collection queries). Only those `get`/`set` calls pass `track`, and `MemoCache` reads and clears the record right after. Nested entity maps never track: they live as long as the entity cache, so a recorded entry nobody clears would never be freed.
- `yarn workspace example-benchmark start:memo-tradeoffs` measures hit rates and retained memory for these choices.
## Schema-tree traversal pattern
diff --git a/packages/normalizr/src/__tests__/MemoCache.ts b/packages/normalizr/src/__tests__/MemoCache.ts
index 2297da729f6e..d7e62fec7390 100644
--- a/packages/normalizr/src/__tests__/MemoCache.ts
+++ b/packages/normalizr/src/__tests__/MemoCache.ts
@@ -15,6 +15,7 @@ import { IQueryDelegate } from '../interface';
import MemoCache from '../memo/MemoCache';
import { MemoPolicy as POJOPolicy } from '../memo/Policy';
import { MemoPolicy as ImmPolicy } from '../memo/Policy.imm';
+import { normalize } from '../normalize/normalize';
class IDEntity extends Entity {
id = '';
@@ -1183,8 +1184,9 @@ describe('MemoCache.commit', () => {
);
});
- it('recomputes a committed single-entity result at most once after one-off reads', () => {
- // a detail endpoint stores just the pk; each retry rewrites a nested child
+ describe('results whose input is a pk', () => {
+ // a detail endpoint stores just the pk, and queries build one; each retry
+ // rewrites a nested child while the top-level entity object stays
class Author extends Entity {
id = '';
name = '';
@@ -1199,17 +1201,56 @@ describe('MemoCache.commit', () => {
Post: { '1': post },
Author: { '2': { id: '2', name } },
});
- const memo = new MemoCache();
- memo.denormalize(Post, '1', withAuthor('older'));
- const committed = withAuthor('committed');
- const first = memo.denormalize(Post, '1', committed).data;
- memo.commit(committed);
- for (let i = 0; i < 5; i++)
- memo.denormalize(Post, '1', withAuthor(`retry ${i}`));
- // A pk input never reaches the endpoints tier, so commit() doesn't pin
- // it: recency alone keeps it, so it can be rebuilt once (equal values).
- const again = memo.denormalize(Post, '1', committed).data;
- expect(again).toEqual(first);
+
+ it('keeps a committed single-entity result through one-off reads', () => {
+ const memo = new MemoCache();
+ memo.denormalize(Post, '1', withAuthor('older'));
+ const committed = withAuthor('committed');
+ const first = memo.denormalize(Post, '1', committed).data;
+ memo.commit(committed);
+ for (let i = 0; i < 5; i++)
+ memo.denormalize(Post, '1', withAuthor(`retry ${i}`));
+ expect(memo.denormalize(Post, '1', committed).data).toBe(first);
+ });
+
+ it('keeps a committed query result through one-off reads', () => {
+ const memo = new MemoCache();
+ const state = (name: string) => ({
+ entities: withAuthor(name),
+ indexes: {},
+ });
+ memo.query(Post, [{ id: '1' }], state('older'));
+ const committed = state('committed');
+ const first = memo.query(Post, [{ id: '1' }], committed).data;
+ memo.commit(committed.entities);
+ for (let i = 0; i < 5; i++)
+ memo.query(Post, [{ id: '1' }], state(`retry ${i}`));
+ expect(memo.query(Post, [{ id: '1' }], committed).data).toBe(first);
+ });
+
+ it('keeps a committed Collection result through one-off reads', () => {
+ const memo = new MemoCache();
+ const posts = new schemas.Collection([Post]);
+ const args = [{ userId: 'u' }];
+ const { entities } = normalize(
+ posts,
+ [{ id: '1', author: { id: '2', name: 'older' } }],
+ args,
+ );
+ const state = (name: string) => ({
+ entities: {
+ ...entities,
+ Author: { '2': { id: '2', name } },
+ },
+ indexes: {},
+ });
+ memo.query(posts, args, state('older'));
+ const committed = state('committed');
+ const first = memo.query(posts, args, committed).data;
+ memo.commit(committed.entities);
+ for (let i = 0; i < 5; i++) memo.query(posts, args, state(`retry ${i}`));
+ expect(memo.query(posts, args, committed).data).toBe(first);
+ });
});
it('keeps the settled result after committed and flip-back pins move on', () => {
diff --git a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts
index 3b06acd96997..388c6500c10c 100644
--- a/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts
+++ b/packages/normalizr/src/__tests__/WeakDependencyMap.test.ts
@@ -176,19 +176,19 @@ describe('WeakDependencyMap', () => {
expect(depKey(null)).not.toBe(depKey(INVALID));
});
- it('records hits and writes only when tracking', () => {
- // a map that never calls trackLast() must not hold its last entry: the
- // per-entity maps would keep every entity they ever cached alive
+ it('records hits and writes only when asked to track', () => {
+ // an untracked call must not hold its entry: the per-entity maps live as
+ // long as the entity cache, so it would keep every entity they cached
const getEntity = MemoPolicy.getEntities(state);
- for (const tracks of [false, true]) {
- const wem = new WeakDependencyMap(tracks);
- wem.set([depA, depB], 'value');
+ for (const track of [false, true]) {
+ const wem = new WeakDependencyMap();
+ wem.set([depA, depB], 'value', [], undefined, track);
const reads = new Map();
wem.trackLast(reads);
- expect(reads.size).toBe(tracks ? 1 : 0);
- wem.get(a, getEntity);
+ expect(reads.size).toBe(track ? 1 : 0);
+ wem.get(a, getEntity, [], track);
wem.trackLast(reads);
- expect(reads.size).toBe(tracks ? 1 : 0);
+ expect(reads.size).toBe(track ? 1 : 0);
}
});
diff --git a/packages/normalizr/src/memo/MemoCache.ts b/packages/normalizr/src/memo/MemoCache.ts
index 65e960e25fe6..6d02f02b2c41 100644
--- a/packages/normalizr/src/memo/MemoCache.ts
+++ b/packages/normalizr/src/memo/MemoCache.ts
@@ -23,7 +23,7 @@ export default class MemoCache {
/** Cache for every entity based on its dependencies and its own input */
declare protected _getCache: GetEntityCache;
/** Caches the final denormalized form based on input, entities */
- protected endpoints: EndpointsCache = new WeakDependencyMap(true);
+ protected endpoints: EndpointsCache = new WeakDependencyMap();
/** Results read per store `entities`, until that store commits. */
private reads = new WeakMap();
/** `entities` from the latest `commit`; reads with it are kept right away. */
@@ -63,17 +63,26 @@ export default class MemoCache {
}
const getEntity = this.policy.getEntities(entities);
- const result = getUnvisit(
+ const tracks = typeof entities === 'object' && entities !== null;
+ const cache = new GlobalCache(
getEntity,
- new GlobalCache(getEntity, this._getCache, this.endpoints, args),
+ this._getCache,
+ this.endpoints,
args,
- )(schema, input);
- if (entities === this.committedEntities) {
- this.endpoints.keepLast(this.committedSettled);
- } else if (entities && typeof entities === 'object') {
- let reads = this.reads.get(entities);
- if (!reads) this.reads.set(entities, (reads = new Map()));
- this.endpoints.trackLast(reads);
+ tracks,
+ );
+ const result = getUnvisit(getEntity, cache, args)(schema, input);
+ // the result's own entry: in the endpoints tier, or the top-level
+ // entity's map when the input is a pk (single-entity endpoints, queries)
+ const tracked = cache.tracked;
+ if (tracked) {
+ if (entities === this.committedEntities) {
+ tracked.keepLast(this.committedSettled);
+ } else {
+ let reads = this.reads.get(entities);
+ if (!reads) this.reads.set(entities, (reads = new Map()));
+ tracked.trackLast(reads);
+ }
}
return result;
}
diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts
index f8300620c790..e4009beefa4f 100644
--- a/packages/normalizr/src/memo/WeakDependencyMap.ts
+++ b/packages/normalizr/src/memo/WeakDependencyMap.ts
@@ -53,34 +53,28 @@ export default class WeakDependencyMap<
private readonly next = new WeakMap>();
/** Sticky: true once any function-typed (`argsKey`) dep has been stored. */
private hasStr = false;
- /** The entry the latest `get` hit or `set` wrote, for `trackLast()`/`keepLast()`.
- * Only recorded when `tracksLast`: a map that never calls those would hold
- * its last entry strongly for as long as the map lives. */
+ /** The entry the latest `get` or `set` with `track` hit or wrote, for
+ * `trackLast()`/`keepLast()`. Only tracked calls record it, and their caller
+ * reads it right away: an entry recorded and never cleared would stay alive
+ * for as long as the map does. */
private lastNode: Node | undefined = undefined;
private lastEntry: Entry | undefined = undefined;
- private readonly tracksLast: boolean;
-
- /** @param tracksLast record each hit or write for `trackLast()`/`keepLast()` */
- constructor(tracksLast = false) {
- this.tracksLast = tracksLast;
- }
-
- private record(node: Node, entry: Entry) {
- if (!this.tracksLast) return;
- this.lastNode = node;
- this.lastEntry = entry;
- }
get(
entity: K,
getDependency: GetDependency,
args: readonly any[] = [],
+ /** record the hit for `trackLast()`/`keepLast()` */
+ track = false,
) {
const root = this.next.get(depKey(entity) as K);
const node = root && find(root, getDependency, args);
if (!node) return EMPTY;
const entry = node.entries[0];
- this.record(node, entry);
+ if (track) {
+ this.lastNode = node;
+ this.lastEntry = entry;
+ }
return [entry.value, entry.journey] as readonly [V, Path[]];
}
@@ -95,6 +89,8 @@ export default class WeakDependencyMap<
* payoff. The array becomes a shared reference held by every subsequent
* cache hit; callers MUST NOT mutate it. */
journey?: Path[],
+ /** record the write for `trackLast()`/`keepLast()` */
+ track = false,
) {
if (dependencies.length < 1) throw new KeySize();
const root = depKey(dependencies[0].entity) as K;
@@ -135,13 +131,14 @@ export default class WeakDependencyMap<
// we could recompute this on get, but it would have a cost and we optimize for `get`
journey: journey ?? (dependencies.map(d => d.path) as Path[]),
};
+ if (track) this.lastEntry = entry;
if (!node) {
node = newNode(entry);
this.next.set(root, node);
- this.record(node, entry);
+ if (track) this.lastNode = node;
return;
}
- this.record(node, entry);
+ if (track) this.lastNode = node;
// A store can flip back to a version it already showed: React renders the
// committed store between retries of a pending transition, which build new
// entities each time, and rolling back an optimistic update returns to the
diff --git a/packages/normalizr/src/memo/globalCache.ts b/packages/normalizr/src/memo/globalCache.ts
index 2f5a34de5387..f1e649299328 100644
--- a/packages/normalizr/src/memo/globalCache.ts
+++ b/packages/normalizr/src/memo/globalCache.ts
@@ -26,17 +26,25 @@ export default class GlobalCache implements Cache {
/** Set true once `argsKey` is called for this denormalize frame. Gates
* function-stripping fast paths in `paths()` / `getResults()`. */
private _hasArgsKey = false;
+ /** Whether to record the result's own memo entry, in `tracked`. */
+ declare private _track: boolean;
+ /** True until the top-level `getEntity` of a pk input takes it. */
+ private trackTop = false;
+ /** The map whose last entry is this result's, once `_track` recorded it. */
+ tracked: WeakDependencyMap | undefined = undefined;
constructor(
getEntity: DenormGetEntity,
getCache: GetEntityCache,
resultCache: EndpointsCache,
args: readonly any[] = [],
+ track = false,
) {
this._getEntity = getEntity;
this._getCache = getCache;
this._resultCache = resultCache;
this._args = args;
+ this._track = track;
}
/** Records `fn(args)` as a string-keyed dependency for the surrounding
@@ -57,6 +65,8 @@ export default class GlobalCache implements Cache {
computeValue: (localCacheKey: Map) => void,
): object | undefined | typeof INVALID {
const key = schema.key;
+ const track = this.trackTop;
+ this.trackTop = false;
// cycleCache is deferred to the branch that actually needs it
// to avoid unnecessary allocations.
const localCacheKey = this.getOrCreateLocalCache(key);
@@ -71,7 +81,9 @@ export default class GlobalCache implements Cache {
entity,
this._getEntity,
this._args,
+ track,
);
+ if (track) this.tracked = globalCache;
// TODO: what if this just returned the deps - then we don't need to store them
if (cachePath) {
@@ -112,7 +124,7 @@ export default class GlobalCache implements Cache {
dependencies: localKey,
value: localCacheKey.get(pk),
};
- globalCache.set(localKey, cacheValue, this._args);
+ globalCache.set(localKey, cacheValue, this._args, undefined, track);
// start of cycle - reset cycle detection
if (this.cycleIndex === trackingIndex) {
@@ -160,14 +172,20 @@ export default class GlobalCache implements Cache {
paths: EntityPath[];
} {
if (!cachable) {
- return { data: computeValue(), paths: this.paths() };
+ // a pk input: the top-level entity's own entry is the result's
+ this.trackTop = this._track;
+ const data = computeValue();
+ this.trackTop = false;
+ return { data, paths: this.paths() };
}
let [data, paths] = this._resultCache.get(
input,
this._getEntity,
this._args,
+ this._track,
);
+ if (this._track) this.tracked = this._resultCache;
if (paths === undefined) {
data = computeValue();
@@ -180,7 +198,13 @@ export default class GlobalCache implements Cache {
paths = this.paths();
// fill pre-allocated slot 0 with the input reference (chain key only)
this.dependencies[0] = { path: { key: '', pk: '' }, entity: input };
- this._resultCache.set(this.dependencies, data, this._args, paths);
+ this._resultCache.set(
+ this.dependencies,
+ data,
+ this._args,
+ paths,
+ this._track,
+ );
}
// hit branch: `paths` aliases the stored Link.journey — return as-is.
// Callers must not mutate it (entityExpiresAt and createCountRef both
diff --git a/packages/react/src/__tests__/concurrent-memo.web.tsx b/packages/react/src/__tests__/concurrent-memo.web.tsx
index 6233007e1884..c56f6c2a71d8 100644
--- a/packages/react/src/__tests__/concurrent-memo.web.tsx
+++ b/packages/react/src/__tests__/concurrent-memo.web.tsx
@@ -458,9 +458,8 @@ describeConcurrent.each([1, 2])(
await finishTransition('X');
const projectReads = reads.filter(r => r.who === 'project');
trace('single-entity read');
- // A pk input never reaches the endpoints tier, so commit pins don't
- // cover it: recency alone keeps it, with at most one recompute.
- expect(avoidableMisses(projectReads)).toBeLessThanOrEqual(1);
+ // a pk input: commit pins the top-level entity's own entry
+ expect(avoidableMisses(projectReads)).toBe(0);
expect(avoidableMisses(reads.filter(r => r.who !== 'project'))).toBe(0);
});
From 1d6e2b34520b273af54066d79885d3a1fca8f5cd Mon Sep 17 00:00:00 2001
From: Nathaniel Tucker
Date: Sun, 25 Oct 2026 00:45:16 -0400
Subject: [PATCH 10/11] bench: Measure committed reads, commit() cost and
optimistic streaks
normalizr: the denormalizeLong, Values, Scalar and All withCache cases on
a MemoCache that committed the entities it reads; 500 small endpoints
read with a new entities each op, with and without commit().
memo-tradeoffs: an optimistic streak. The settled store commits, then 1, 3
or 8 unsettled commits, then the settled store is read again: hit rate and
heap held. A flip back to a pending store mid-streak moves the flip-back
pin off the settled result, so only the settled pin can keep it.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
examples/benchmark/README.md | 9 ++-
examples/benchmark/memo-tradeoffs.js | 43 ++++++++++++++
examples/benchmark/normalizr.js | 83 ++++++++++++++++++++++++++++
3 files changed, 132 insertions(+), 3 deletions(-)
diff --git a/examples/benchmark/README.md b/examples/benchmark/README.md
index d10b1067183d..d7139a1f5728 100644
--- a/examples/benchmark/README.md
+++ b/examples/benchmark/README.md
@@ -40,6 +40,8 @@ Both arguments are optional:
- `denormalizeLong withCache update first|last row` - steady state: each op builds a new `entities` with one project replaced, then reads the cached list; the changed row is first or last in the list
- `denormalizeLong Scalar` (cold) and `denormalizeLong Scalar withCache update one cell` - the 700-row `Scalar` list in a new `MemoCache`, and read again after each op replaces one `Scalar` cell
- `denormalizeLong two Scalars, 25% Stocks missing` (and `withCache`) - a `Holding` list with two `Scalar` lenses (one through its `Stock`); two stores missing different Stocks are read in turn, so list nodes keep more than one `argsKey` branch
+ - `... withCache committed` - the `denormalizeLong`, `Values`, `Scalar` and `All` withCache cases on a `MemoCache` that has `commit()`ed the `entities` it reads, so reads take the committed path
+ - `denormalizeShort 500 endpoints withCache` (and `then commit`) - each op reads 500 small cached endpoints with a new `entities`, then commits it; the difference is the cost of `commit()`
- `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
@@ -124,9 +126,10 @@ yarn start:memo-tradeoffs [filter]
Measures where caching fewer versions could cost reads or memory: one list
read against several store snapshots in turn (hit rate and time per read),
-heap the memo keeps beyond the store after a run of updates, and one list read
-under many `argsKey` values (hit rate and heap held). Local-only — not tracked
-in CI.
+heap the memo keeps beyond the store after a run of updates, an optimistic
+streak (the settled store committed, then 1, 3 or 8 unsettled commits, then
+the settled store read again: hit rate and heap held), and one list read under
+many `argsKey` values (hit rate and heap held). Local-only — not tracked in CI.
### Profiling
diff --git a/examples/benchmark/memo-tradeoffs.js b/examples/benchmark/memo-tradeoffs.js
index d0b97440dbc9..b6f39815a6ea 100644
--- a/examples/benchmark/memo-tradeoffs.js
+++ b/examples/benchmark/memo-tradeoffs.js
@@ -6,6 +6,9 @@
* a different snapshot. Reports how many reads hit and time per read.
* - retained memory: heap the memo holds beyond the store itself, after a run
* of updates. Entity churn (every row replaced) is the worst case.
+ * - optimistic streak: the settled store is committed, then M optimistic
+ * updates each render and commit unsettled, then the store settles back.
+ * Reports how often the settled result is still cached, and heap held.
* - argsKey buckets: one list read under N different `argsKey` values (a
* portfolio app switching between N portfolios), all cached, and the heap
* the memo holds for them.
@@ -246,6 +249,46 @@ for (const { name, run } of memoryCases) {
});
}
+// --- optimistic streak ---------------------------------------------------------
+
+const STREAKS = 20;
+for (const flipBack of [false, true]) {
+ for (const m of [1, 3, 8]) {
+ const name = `optimistic streak: ${ROWS}-row list, ${m} unsettled commits${flipBack ? ' and a flip back' : ''}, then the settled store x ${STREAKS}`;
+ if (!want(name)) continue;
+ let hits = 0;
+ const bytes = retained(memo => {
+ const read = state =>
+ memo.denormalize(StockSchema, base.result, state.entities, stockArgs)
+ .data;
+ const settled = read(base);
+ memo.commit(base.entities, true);
+ for (let streak = 0; streak < STREAKS; streak++) {
+ // each optimistic update renders and commits a store with one more
+ // row changed; then they all settle and the store is `base` again
+ const states = snapshots(m + 1).slice(1);
+ // a pending transition rendered between the first two commits; reading
+ // it back moves the flip-back pin off `base`, leaving only the settled pin
+ const pending = flipBack && snapshots(2)[1];
+ if (pending) read(pending);
+ states.forEach((state, i) => {
+ read(state);
+ memo.commit(state.entities, false);
+ if (pending && i === 0) read(pending);
+ });
+ if (read(base) === settled) hits++;
+ memo.commit(base.entities, true);
+ }
+ return base;
+ });
+ rows.push({
+ scenario: name,
+ 'hit rate': `${((hits / STREAKS) * 100).toFixed(0)}%`,
+ 'memo retains': `${(bytes / 1024 / 1024).toFixed(2)} MB`,
+ });
+ }
+}
+
// --- argsKey buckets ------------------------------------------------------------
for (const n of [1, 10, 100, 1000]) {
diff --git a/examples/benchmark/normalizr.js b/examples/benchmark/normalizr.js
index 40c638456933..a469a7fe2ff9 100644
--- a/examples/benchmark/normalizr.js
+++ b/examples/benchmark/normalizr.js
@@ -26,6 +26,7 @@ import {
buildStockData,
buildStockScalarUpdate,
PortfolioScalar,
+ BuildTypeDescription,
HoldingSchema,
buildHoldingData,
} from './schemas.js';
@@ -122,6 +123,16 @@ const withMissingStocks = offset => {
};
const holdingStores = [withMissingStocks(0), withMissingStocks(2)];
+const BuildTypeSchema = { buildType: BuildTypeDescription };
+
+/** A MemoCache that has read `input` with `ents`, then committed `ents`. */
+function committedMemo(schema, input, ents, args) {
+ const memo = new MemoCache();
+ memo.denormalize(schema, input, ents, args);
+ memo.commit(ents);
+ return memo;
+}
+
export default function addNormlizrSuite(suite, filter) {
const memo = new MemoCache();
// prime the cache
@@ -372,6 +383,78 @@ export default function addNormlizrSuite(suite, filter) {
[],
);
});
+ // committed: each MemoCache has committed the `entities` it reads, so reads
+ // take the path a committed render's re-reads take
+ const memoCommitted = committedMemo(ProjectSchema, result, entities, []);
+ add('denormalizeLong withCache committed', () => {
+ return memoCommitted.denormalize(ProjectSchema, result, entities, []);
+ });
+ const memoValuesCommitted = committedMemo(
+ ProjectSchemaValues,
+ valuesState.result,
+ valuesState.entities,
+ [],
+ );
+ add('denormalizeLong Values withCache committed', () => {
+ return memoValuesCommitted.denormalize(
+ ProjectSchemaValues,
+ valuesState.result,
+ valuesState.entities,
+ [],
+ );
+ });
+ const memoStockCommitted = committedMemo(
+ StockSchema,
+ stockState.result,
+ stockState.entities,
+ stockArgs,
+ );
+ add('denormalizeLong Scalar withCache committed', () => {
+ return memoStockCommitted.denormalize(
+ StockSchema,
+ stockState.result,
+ stockState.entities,
+ stockArgs,
+ );
+ });
+ const memoAllCommitted = committedMemo(
+ AllProjects,
+ queryState.result,
+ queryState.entities,
+ [],
+ );
+ add('denormalizeLong All withCache committed', () => {
+ return memoAllCommitted.denormalize(
+ AllProjects,
+ queryState.result,
+ queryState.entities,
+ [],
+ );
+ });
+ // commit() cost: each op reads 500 small endpoints with a new `entities`,
+ // then (in the second case) commits it
+ const buildTypeInputs = Object.keys(entities.BuildTypeDescription)
+ .slice(0, 500)
+ .map(id => ({ buildType: id }));
+ const memoReads = new MemoCache();
+ const memoCommits = new MemoCache();
+ for (const input of buildTypeInputs) {
+ memoReads.denormalize(BuildTypeSchema, input, entities, []);
+ memoCommits.denormalize(BuildTypeSchema, input, entities, []);
+ }
+ add('denormalizeShort 500 endpoints withCache', () => {
+ const next = { ...entities };
+ for (let i = 0; i < 500; ++i) {
+ memoReads.denormalize(BuildTypeSchema, buildTypeInputs[i], next, []);
+ }
+ });
+ add('denormalizeShort 500 endpoints withCache then commit', () => {
+ const next = { ...entities };
+ for (let i = 0; i < 500; ++i) {
+ memoCommits.denormalize(BuildTypeSchema, buildTypeInputs[i], next, []);
+ }
+ memoCommits.commit(next);
+ });
add('denormalizeLongAndShort withEntityCacheOnly', () => {
memo.endpoints = new WeakDependencyMap();
memo.denormalize(ProjectSchema, result, entities);
From 30caeb7fcab985760f824208f38f29f598a75084 Mon Sep 17 00:00:00 2001
From: Nathaniel Tucker
Date: Sun, 25 Oct 2026 01:57:48 -0400
Subject: [PATCH 11/11] fix(normalizr): Keep tracking when a memo root starts
over
Co-Authored-By: Claude Opus 5.5 (1M context)
---
packages/normalizr/src/memo/WeakDependencyMap.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/normalizr/src/memo/WeakDependencyMap.ts b/packages/normalizr/src/memo/WeakDependencyMap.ts
index 399ace5998cf..5502ba0cddbd 100644
--- a/packages/normalizr/src/memo/WeakDependencyMap.ts
+++ b/packages/normalizr/src/memo/WeakDependencyMap.ts
@@ -122,7 +122,7 @@ export default class WeakDependencyMap<
// Past MAX_FORKS, start this root over instead.
if (node.branches.length && ++top!.forks > MAX_FORKS) {
this.next.delete(root);
- return this.set(dependencies, value, args, journey);
+ return this.set(dependencies, value, args, journey, track);
}
branch = { fn: dep.path as KeyFn, children: new Map() };
node.branches.push(branch);