Repository navigation
Conversation
…encies 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) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 595ead9 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
Staff engineer (Cursor agent): Staff review at The fix is the right size. Recording Things to settle before this lands, in order of importance:
What I checked: I read the full diff, plus |
|
Docs preview: https://docs-site-pr-4460-data-client.vercel.app Built from 595ead9 merged into |
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) <noreply@anthropic.com>
|
Staff engineer (Cursor agent): LGTM + FOLLOW_UP at 1be7879. The refactor is a real entropy win: three near-identical Follow-up (no change needed here): this commit exists only on #4460. #4459 already merged an earlier cut of this branch, so the two now diverge again (this branch is 1 ahead, 10 behind #4459's head). Whichever lands first, the other needs this refactor folded in, or the three-branch version and the caller-side Validated: I read the commit diff plus |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Size Change: +113 B (+0.1%) Total Size: 112 kB 📦 View Changed
ℹ️ View Unchanged
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1be7879abb
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| /** `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; |
There was a problem hiding this comment.
Preserve function references as weak dependency keys
When an Entity class is passed to MemoCache.buildQueryKey(), BaseDelegate.tracked() records that constructor as the first dependency. Functions are valid WeakMap keys, but this guard excludes them, so every queried Entity constructor is now stored strongly in the module-global valueKeys map. Dynamically created schemas and anything their closures retain consequently survive even after their Controller and MemoCache are discarded; a forced-GC check confirmed this retention. Return functions unchanged alongside non-null objects rather than allocating permanent stand-ins for them.
AGENTS.md reference: packages/normalizr/AGENTS.md:L22-L22
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Benchmark React
Details
| Benchmark suite | Current: 595ead9 | Previous: 8817f47 | Ratio |
|---|---|---|---|
data-client: getlist-100 |
215.08 ops/s (± 7.6%) |
222.22 ops/s (± 8.5%) |
1.03 |
data-client: getlist-500 |
80.97 ops/s (± 4.4%) |
73.27 ops/s (± 6.4%) |
0.90 |
data-client: update-entity |
500 ops/s (± 12.1%) |
666.67 ops/s (± 6.1%) |
1.33 |
data-client: update-user |
526.32 ops/s (± 8.0%) |
625 ops/s (± 6.8%) |
1.19 |
data-client: getlist-500-sorted |
67.8 ops/s (± 9.6%) |
64.78 ops/s (± 9.3%) |
0.96 |
data-client: update-entity-sorted |
384.62 ops/s (± 5.6%) |
444.66 ops/s (± 10.7%) |
1.16 |
data-client: update-entity-multi-view |
454.55 ops/s (± 9.5%) |
555.56 ops/s (± 7.0%) |
1.22 |
data-client: list-detail-switch-10 |
22.78 ops/s (± 9.9%) |
20.64 ops/s (± 8.0%) |
0.91 |
data-client: update-user-10000 |
119.06 ops/s (± 7.5%) |
125.79 ops/s (± 11.2%) |
1.06 |
data-client: invalidate-and-resolve |
65.36 ops/s (± 5.4%) |
60.61 ops/s (± 7.1%) |
0.93 |
data-client: unshift-item |
357.14 ops/s (± 4.8%) |
384.62 ops/s (± 0.0%) |
1.08 |
data-client: delete-item |
555.56 ops/s (± 11.2%) |
555.56 ops/s (± 9.1%) |
1 |
data-client: move-item |
289.92 ops/s (± 10.3%) |
307.77 ops/s (± 6.0%) |
1.06 |
This comment was automatically generated by workflow using github-action-benchmark.
There was a problem hiding this comment.
Benchmark
Details
| Benchmark suite | Current: 595ead9 | Previous: 7adefee | Ratio |
|---|---|---|---|
normalizeLong |
694 ops/sec (±6.35%) |
693 ops/sec (±3.56%) |
1.00 |
normalizeLong Values |
609 ops/sec (±1.13%) |
607 ops/sec (±1.11%) |
1.00 |
normalizeLong Scalar |
547 ops/sec (±3.02%) |
544 ops/sec (±2.39%) |
0.99 |
normalizeLong Scalar update |
1587 ops/sec (±1.60%) |
1574 ops/sec (±1.86%) |
0.99 |
denormalizeLong |
346 ops/sec (±6.09%) |
349 ops/sec (±5.61%) |
1.01 |
denormalizeLong Values |
314 ops/sec (±4.52%) |
322 ops/sec (±3.99%) |
1.03 |
denormalizeLong donotcache |
1777 ops/sec (±1.27%) |
1820 ops/sec (±2.03%) |
1.02 |
denormalizeLong nested 25% missing |
356 ops/sec (±5.24%) |
||
denormalizeLong nested 25% null |
354 ops/sec (±5.02%) |
||
denormalizeLong nested 25% INVALID |
380 ops/sec (±4.59%) |
||
denormalizeLong Values donotcache |
1253 ops/sec (±1.74%) |
1151 ops/sec (±5.18%) |
0.92 |
denormalizeLong Scalar donotcache |
1725 ops/sec (±0.72%) |
1909 ops/sec (±0.48%) |
1.11 |
denormalizeShort donotcache 500x |
2481 ops/sec (±1.03%) |
2462 ops/sec (±1.91%) |
0.99 |
denormalizeShort 500x |
905 ops/sec (±6.47%) |
899 ops/sec (±7.30%) |
0.99 |
denormalizeShort 500x withCache |
11934 ops/sec (±2.20%) |
9181 ops/sec (±2.20%) |
0.77 |
queryShort 500x withCache |
4629 ops/sec (±1.15%) |
5359 ops/sec (±0.74%) |
1.16 |
buildQueryKey All |
88743 ops/sec (±2.19%) |
98489 ops/sec (±0.41%) |
1.11 |
query All withCache |
9264 ops/sec (±5.22%) |
10256 ops/sec (±5.77%) |
1.11 |
denormalizeLong with mixin Entity |
276 ops/sec (±5.87%) |
335 ops/sec (±6.11%) |
1.21 |
denormalizeLong withCache |
11912 ops/sec (±3.33%) |
12871 ops/sec (±0.43%) |
1.08 |
denormalizeLong withCache (Scalar churn) |
11921 ops/sec (±2.19%) |
12670 ops/sec (±1.00%) |
1.06 |
denormalizeLong nested 25% missing withCache |
10927 ops/sec (±0.34%) |
||
denormalizeLong nested 25% null withCache |
11668 ops/sec (±1.63%) |
||
denormalizeLong nested 25% INVALID withCache |
11035 ops/sec (±2.65%) |
||
denormalizeLong Values withCache |
9169 ops/sec (±1.87%) |
9804 ops/sec (±1.03%) |
1.07 |
denormalizeLong Scalar withCache |
12965 ops/sec (±1.15%) |
13747 ops/sec (±0.99%) |
1.06 |
denormalizeLong Scalar update withCache |
7234 ops/sec (±1.42%) |
7523 ops/sec (±0.38%) |
1.04 |
denormalizeLong All withCache |
11590 ops/sec (±0.26%) |
13111 ops/sec (±0.23%) |
1.13 |
denormalizeLong Query-sorted withCache |
9966 ops/sec (±5.64%) |
10436 ops/sec (±6.87%) |
1.05 |
denormalizeLongAndShort withEntityCacheOnly |
2708 ops/sec (±1.53%) |
3035 ops/sec (±1.13%) |
1.12 |
denormalize bidirectional 50 |
6030 ops/sec (±10.77%) |
7229 ops/sec (±9.22%) |
1.20 |
denormalize bidirectional 50 donotcache |
66925 ops/sec (±2.59%) |
74415 ops/sec (±0.62%) |
1.11 |
getResponse |
7383 ops/sec (±4.85%) |
6850 ops/sec (±6.61%) |
0.93 |
getResponse (null) |
17967514 ops/sec (±1.88%) |
19028450 ops/sec (±3.52%) |
1.06 |
getResponse (clear cache) |
264 ops/sec (±8.19%) |
333 ops/sec (±7.84%) |
1.26 |
getSmallResponse |
5048 ops/sec (±2.21%) |
6392 ops/sec (±0.50%) |
1.27 |
getSmallInferredResponse |
4271 ops/sec (±1.94%) |
4838 ops/sec (±0.54%) |
1.13 |
getResponse Collection |
7172 ops/sec (±3.78%) |
8037 ops/sec (±2.20%) |
1.12 |
get Collection |
5402 ops/sec (±0.37%) |
6524 ops/sec (±0.49%) |
1.21 |
get Query-sorted |
8124 ops/sec (±3.88%) |
7759 ops/sec (±5.02%) |
0.96 |
setLong |
715 ops/sec (±2.15%) |
737 ops/sec (±0.35%) |
1.03 |
setLongWithMerge |
406 ops/sec (±1.80%) |
415 ops/sec (±1.86%) |
1.02 |
setLongWithSimpleMerge |
435 ops/sec (±0.85%) |
443 ops/sec (±1.35%) |
1.02 |
setSmallResponse 500x |
1549 ops/sec (±0.38%) |
1611 ops/sec (±1.41%) |
1.04 |
setMany 50x one-per-row |
207 ops/sec (±0.50%) |
220 ops/sec (±1.51%) |
1.06 |
setMany 50 batch |
5849 ops/sec (±0.40%) |
6279 ops/sec (±0.55%) |
1.07 |
setMany 500x one-per-row |
20.94 ops/sec (±0.40%) |
22.67 ops/sec (±0.44%) |
1.08 |
setMany 500 batch |
2534 ops/sec (±2.64%) |
2821 ops/sec (±0.35%) |
1.11 |
This comment was automatically generated by workflow using github-action-benchmark.
- 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
- 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
@codex review |
|
Codex Review: Didn't find any major issues. 👍 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
|
@codex review |
|
Codex Review: Didn't find any major issues. Bravo. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Requested by Nathaniel
Motivation
Bug fix. A component reads a parent entity, and a nested entity of that parent is
INVALID(it failed validation) ornullin the store.Example: an
Articlehasauthor: '2', andUser'2'isINVALID. Then a validUser'2'arrives. Expected: the article shows the new author. Actual: the cached article keeps the old, emptyauthor.Solution
INVALIDornullentity changes, as it already did for a missing one.unvisitEntityrecords these entities as dependencies. Blank ids are still not dependencies, because they cannot be looked up.WeakMapkeys must be objects.depKey()gives each non-object value a stable object stand-in.Assumptions and validation
INVALIDandnulltests inMemoCache.tsfail on master and pass here. The unit suite passes.packages/normalizr/AGENTS.md, hold. fix(normalizr): Keep memo WeakMaps one level deep #4459 builds on them.examples/benchmarkand three of benchmark-react (data-client only) on one quiet machine (Ryzen 9 7950X, WSL2, Node 26). Between runs, a case moves by up to about ±5%, so only larger changes are real. Full tables are under "Benchmarks" below.nullorINVALIDchildren cost 16–21%, with 25% of children affected. They are now dependencies, so each cached read checks them, as it already checks missing children on master. This cost is the fix.denormalizeLong All withCache-6.4%,Query-sorted-4.5%,queryShort 500x-4.4%). That is thedepKey()call on each dependency check. A smallerdepKeyfor V8 to inline measured no better, so I kept this one.update-entity-sorted(-10.9%) anddelete-item(-7.7%) are the largest changes, but those two vary the most between runs.GOALS impact
nullorINVALIDchildren are 16–21% slower, because those children are now tracked (the fix); cold reads of them are 6–8% slower, and missing children read 10% faster cold. Other cached reads are 0–6% slower fromdepKey(). Other cold reads and writes are unchanged.@data-client/normalizr(+2.3%), against current master. The non-object path adds 8 B of that.dependOnoptimizes (Maglev) with no deopts, its call site stays monomorphic, andgetEntityhas fewer deopts than before (no "wrong feedback cell").Benchmarks
Non-object children, examples/benchmark, ops/s, median of 3 alternating rounds
examples/benchmark, ops/s, median of 6 alternating rounds
benchmark-react (data-client), ops/s, median of 3 alternating rounds
🤖 Generated with Claude Code
Note
Medium Risk
Changes core denormalize memo dependency tracking and cache lookup keys; correctness is heavily tested but cached-read paths see a small perf cost from
depKey()on each dependency step.Overview
Fixes stale parent denormalization when a nested child was
INVALID,null, or missing and later becomes a valid entity—cached parents now recompute instead of keeping the old nested shape.Memo / denormalize:
unvisitEntitycalls newcache.dependOn()for non-object store values (still skips blank ids). That records them inWeakDependencyMapwithout the oldUNDEF+getEntityshortcut.depKey()mapsundefined,null,INVALID, and other primitives to stable object keys forWeakMapchains.Docs & tests: “Memo invariants” land in
packages/normalizr/AGENTS.md(with agent rule pointers). Coverage addsMemoCachenested-dependency cases,WeakDependencyMap/depKeytests, Reactconcurrent-memo.web.tsx, and benchmark variants for 25% nested missing|null|INVALID.Reviewed by Cursor Bugbot for commit 595ead9. Bugbot is set up for automated code reviews on this repo. Configure here.