Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
140afcf
fix(normalizr): Keep memo WeakMaps one level deep
ntucker Oct 24, 2026
3c43ac1
docs(blog): Note the Hermes memo GC fix
ntucker Oct 24, 2026
9256ad9
fix(normalizr): Keep re-read memo entries through pending transitions
ntucker Oct 24, 2026
3c831cd
bench: Measure memo retention without keeping the memo reachable
ntucker Oct 24, 2026
89c682a
perf(normalizr): Skip memo node work on the cold path
ntucker Oct 24, 2026
2a10150
docs(bench): Describe the memo worst-case script
ntucker Oct 24, 2026
17a35d5
fix(normalizr): Keep three memo entries and the one the store flips b…
ntucker Oct 24, 2026
0abba14
docs(normalizr): Document the memo invariants for agents and humans
ntucker Oct 24, 2026
ecea0af
docs(normalizr): Mark schema-agnostic result keys and the depth limit…
ntucker Oct 24, 2026
abe418b
Merge fix/memo-dependency-gaps into fix/memo-ephemeron-depth
ntucker Oct 24, 2026
0182c30
Merge fix/memo-dependency-gaps into fix/memo-ephemeron-depth
ntucker Oct 24, 2026
7af91fc
Merge branch 'fix/memo-dependency-gaps' into fix/memo-ephemeron-depth
ntucker Oct 24, 2026
57175c2
Merge branch 'fix/memo-dependency-gaps' into fix/memo-ephemeron-depth
claude Oct 25, 2026
4732cc1
test(normalizr): Cover the argsKey check in WeakDependencyMap matches()
ntucker Oct 25, 2026
4bbe87a
Merge branch 'fix/memo-dependency-gaps' into fix/memo-ephemeron-depth
ntucker Oct 25, 2026
24525cf
fix(normalizr): Keep one argsKey branch per fn in WeakDependencyMap
ntucker Oct 25, 2026
fa5b60d
Merge branch 'fix/memo-dependency-gaps' into fix/memo-ephemeron-depth
ntucker Oct 25, 2026
88c6a18
fix(normalizr): Look up argsKey branches with a loop, not recursion
ntucker Oct 25, 2026
96aa4e8
Merge branch 'fix/memo-dependency-gaps' into fix/memo-ephemeron-depth
ntucker Oct 25, 2026
cb47857
test(normalizr): Cover argsKey branch fallbacks and child-node eviction
ntucker Oct 25, 2026
a8ab453
perf(normalizr): Skip argsKey paths when matching a memo entry
ntucker Oct 25, 2026
9575a5b
Merge branch 'fix/memo-dependency-gaps' into fix/memo-ephemeron-depth
ntucker Oct 25, 2026
2b7340b
bench: Measure memo updates, Scalar reads and deep-chain ephemerons
ntucker Oct 25, 2026
9d057e3
bench: Give memo-tradeoffs an 8 GB heap
ntucker Oct 25, 2026
4daa7b2
Merge branch 'fix/memo-dependency-gaps' into fix/memo-ephemeron-depth
ntucker Oct 25, 2026
58494d1
fix(normalizr): Bound argsKey forks under one memo root
ntucker Oct 25, 2026
0d7efa3
fix(normalizr): Treat a throwing argsKey selector as a memo miss
ntucker Oct 25, 2026
c63b613
Merge branch 'fix/memo-dependency-gaps' into fix/memo-ephemeron-depth
ntucker Oct 26, 2026
e6ef582
Merge branch 'fix/memo-dependency-gaps' into fix/memo-ephemeron-depth
ntucker Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/memo-shallow-weakmaps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@data-client/normalizr': patch
'@data-client/core': patch
'@data-client/react': patch
'@data-client/vue': patch
---

Fix multi-second freezes on React Native (Hermes) when large results read streaming entities

On Hermes, an app that kept reading a large result while its entities updated could freeze for 0.5–3s at a time, and the freezes grew the longer it ran. An example is `useSuspense()` on a few thousand entities that join live prices. Each freeze was a garbage collection pause caused by memoization. Cached reads no longer slow down Hermes's garbage collector, and they are faster on every platform.
46 changes: 46 additions & 0 deletions examples/benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ Both arguments are optional:
- `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
- `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
- `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
Expand Down Expand Up @@ -82,6 +85,49 @@ counts (minor/major/other) and total GC pause during the loop, and retained
heap after a full GC (should be ~0; nonzero indicates actual retention).
Local-only — not tracked in CI.

### WeakMap (ephemeron) pressure

Memoization keys cached values on entities through `WeakMap`s. How much a
collection costs for those depends on the engine, so measure the worst case
with:

```bash
yarn start:ephemerons [filter]
```

Each scenario takes a V8 heap snapshot and replays Hermes's marking loop on
it. It reports the live `WeakMap` entries, how many marking passes Hermes
would need, and their product, which is the number of entry visits in one
Hermes old-generation pause. It also reports V8's own full-GC time. The
scenarios mirror a live market screen: a 3,736-symbol endpoint whose symbols
join streaming tickers, and a 100-row trade tape that prepends prints. A
50-level Department/Building chain whose deepest Building changes every tick
measures passes against nesting depth.
Local-only — not tracked in CI.

How engines resolve `WeakMap` entries during a major collection:

| Engine | Algorithm | Where it runs | Cost for N nested entries |
| --- | --- | --- | --- |
| Hermes (React Native) | Repeats a scan of every `WeakMap` entry slot in the heap until a pass marks nothing new (`HadesGC::markWeakMapEntrySlots`). No key → value index. | Stop-the-world, on the JS thread, at the end of every old-gen collection | passes ≈ N + 1, each visiting every slot: quadratic |
| V8 (Node, Chrome) | The same fixpoint for at most `--ephemeron-fixpoint-iterations` (default 10) passes, then a linear algorithm over a key → values map (`MarkTransitiveClosureLinear` in `mark-compact.cc`) | Major GC; most marking is concurrent | linear |

So a structure that V8 collects in milliseconds can stall Hermes for seconds:
one pass per level of nesting, and every pass rescans every `WeakMap` entry in
the app, not only the memo's own. Keep `WeakMap` nesting shallow.

### Memo cache worst cases

```bash
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.

### Profiling

For opt/deopt investigation:
Expand Down
Loading