Skip to content

demo: Add cache GC measurement harnesses (Node, browser, Android) - #4034

Open
ntucker wants to merge 32 commits into
masterfrom
demo/gc-benchmark-harnesses
Open

ntucker wants to merge 32 commits into
masterfrom
demo/gc-benchmark-harnesses

Conversation

@ntucker

@ntucker ntucker commented Jul 19, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Before changing cache GC defaults, we need to measure current behavior. That means sweep cost, input delay, and frame impact, on web and React Native. We also need to lock down known correctness gaps so a new design can be checked against them.

This PR changes no production GC behavior, public package API, or default provider policy. Goals, confirmed gaps, open decisions, and the measurement protocol are in plans/garbage-collection.md.

Solution

Measurement harnesses (opt-in, not in CI)

  • Node: yarn workspace example-benchmark start:gc. Entity, endpoint, and mixed sweep scenarios at up to 100k candidates.
  • Chromium: yarn workspace example-benchmark-react bench:gc. Isolated GC phase with frame and timer probes and input-delay measurement.
  • Android: new examples/benchmark-native workspace, a React Native 0.86.2 release-Hermes app. It times sweeps against device frame capture and memory snapshots. Host scripts build, run a scenario matrix, and collect reports over adb. It has not been run on a physical device yet; that is future work.
  • Build manifests tie every report to source and artifact hashes. Stale or tampered reports are rejected.
  • Harness-only GC policies disable the interval and force expiry so a sweep can be timed. They are fixtures, not proposed defaults.

Characterization tests for current GC behavior

These assert today's behavior. A design that fixes either gap should flip them.

  • packages/core/src/state/__tests__/GCPolicy-dangling.ts: a result written without a mounted consumer (prefetch, setResponse, hydration) is never queued. An entity it names is still swept when another consumer releases it. The surviving list shrinks and the surviving detail reads undefined, both still Valid, so nothing refetches.
  • packages/react/src/__tests__/integration-garbage-collection-race.web.tsx: a sweep between a consumer's commit and its passive-effect countRef deletes a mounted, counted entity. GC returns the same state object, so nothing re-renders until an unrelated update. The consumer then suspends and refetches. Passes on React 17, 18, and 19.

CI and docs

  • CircleCI setup trims root workspaces by name instead of by index.
  • AGENTS.md: how to run Jest against CI's other React versions in a worktree, including the --no-cache pitfall.
  • plans/garbage-collection.md: GC goals, current behavior, open decisions, and baselines. Exploratory, not binding.

Open questions

  • Is the dangling-entity behavior acceptable, or should surviving results keep their entities?
  • Should the commit-to-passive-effect window be closed, for example by counting in a layout effect or by reading live counts when GC applies?

Note

Low Risk
Changes are confined to examples, CI/docs, and lint config; no production package or GC default behavior changes in this diff.

Overview
Adds examples/benchmark-native, a manual (non-CI) React Native 0.86 / Hermes release Android app that measures @data-client/core cache GC under scripted scenarios. It shares scenario IDs, validation, fixtures, and frame math with the existing Node/browser harnesses via examples/gc-shared, runs from intent extras or the in-app UI, and writes gc-report.json with UI frame metrics and optional memory snapshots.

Build and collection: Release flow embeds a build-manifest.json (deterministic buildId / sourceDigest over Metro-resolved bundle inputs) and a host build-sidecar.json (apkSha256, sidecarId). collect-report.sh / run-matrix.sh install a single app-release.apk, validate axes through protocol-cli.js, pull reports via Downloads + app external storage (no run-as), and reject mismatched provenance or scenario axes.

Repo plumbing: CircleCI setup trims root workspaces by name (drops examples/* and website) instead of deleting fixed indices. Benchmark GitHub Actions path filters include examples/gc-shared/**. ESLint treats gc-shared and benchmark-native host scripts as Node CommonJS. AGENTS.md documents testing other React versions in a worktree with --no-cache.

Minor: benchmark-react refactors how SCENARIOS is built; adds .gitignore for GC report output.

Reviewed by Cursor Bugbot for commit 5cd894c. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel

vercel Bot commented Jul 19, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
docs-site Ignored Ignored Preview Oct 5, 2026 3:49am UTC

Request Review

@changeset-bot

changeset-bot Bot commented Jul 19, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 5cd894c

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread examples/benchmark-native/scripts/collect-report.sh Outdated
@github-actions

github-actions Bot commented Jul 19, 2026 •

Copy link
Copy Markdown
Contributor

Size Change: 0 B

Total Size: 103 kB

ℹ️ View Unchanged
Filename Size
examples/test-bundlesize/dist/App.js 1.46 kB
examples/test-bundlesize/dist/polyfill.js 307 B
examples/test-bundlesize/dist/rdcClient.js 10.9 kB
examples/test-bundlesize/dist/rdcEndpoint.js 8.07 kB
examples/test-bundlesize/dist/rdcNextjs.js 12.3 kB
examples/test-bundlesize/dist/rdcPipeableStream.js 9.64 kB
examples/test-bundlesize/dist/react.js 59.7 kB
examples/test-bundlesize/dist/webpack-runtime.js 784 B

compressed-size-action

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark React

Details
Benchmark suite Current: 5cd894c Previous: b239883 Ratio
data-client: getlist-100 224.75 ops/s (± 5.0%) 175.44 ops/s (± 3.7%) 0.78
data-client: getlist-500 68.03 ops/s (± 5.0%) 55.71 ops/s (± 4.0%) 0.82
data-client: update-entity 526.32 ops/s (± 10.6%) 454.55 ops/s (± 8.4%) 0.86
data-client: update-user 625 ops/s (± 10.2%) 454.55 ops/s (± 9.3%) 0.73
data-client: getlist-500-sorted 60.8 ops/s (± 8.6%) 56.03 ops/s (± 9.8%) 0.92
data-client: update-entity-sorted 666.67 ops/s (± 7.0%) 408.33 ops/s (± 7.1%) 0.61
data-client: update-entity-multi-view 645.83 ops/s (± 8.2%) 416.67 ops/s (± 8.6%) 0.65
data-client: list-detail-switch-10 21.41 ops/s (± 11.3%) 14.71 ops/s (± 7.7%) 0.69
data-client: update-user-10000 118.35 ops/s (± 16.5%) 85.84 ops/s (± 16.4%) 0.73
data-client: invalidate-and-resolve 56.02 ops/s (± 4.8%) 47.96 ops/s (± 5.5%) 0.86
data-client: unshift-item 294.12 ops/s (± 5.5%) 263.16 ops/s (± 6.1%) 0.89
data-client: delete-item 476.19 ops/s (± 5.0%) 357.14 ops/s (± 4.7%) 0.75
data-client: move-item 274.02 ops/s (± 10.0%) 212.77 ops/s (± 8.3%) 0.78

This comment was automatically generated by workflow using github-action-benchmark.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark

Details
Benchmark suite Current: 5cd894c Previous: b239883 Ratio
normalizeLong 429 ops/sec (±3.45%) 427 ops/sec (±3.13%) 1.00
normalizeLong Values 401 ops/sec (±0.35%) 387 ops/sec (±0.86%) 0.97
normalizeLong Scalar 370 ops/sec (±2.94%) 359 ops/sec (±3.70%) 0.97
normalizeLong Scalar update 900 ops/sec (±1.71%) 921 ops/sec (±0.19%) 1.02
denormalizeLong 243 ops/sec (±5.78%) 243 ops/sec (±6.27%) 1
denormalizeLong Values 231 ops/sec (±4.79%) 224 ops/sec (±5.37%) 0.97
denormalizeLong donotcache 1004 ops/sec (±0.37%) 1000 ops/sec (±0.80%) 1.00
denormalizeLong Values donotcache 739 ops/sec (±0.23%) 749 ops/sec (±0.20%) 1.01
denormalizeLong Scalar donotcache 1049 ops/sec (±0.17%) 1063 ops/sec (±0.17%) 1.01
denormalizeShort donotcache 500x 1404 ops/sec (±0.38%) 1402 ops/sec (±0.12%) 1.00
denormalizeShort 500x 647 ops/sec (±6.22%) 637 ops/sec (±6.57%) 0.98
denormalizeShort 500x withCache 7043 ops/sec (±0.43%) 7080 ops/sec (±0.20%) 1.01
queryShort 500x withCache 3214 ops/sec (±0.65%) 3193 ops/sec (±0.12%) 0.99
buildQueryKey All 58910 ops/sec (±0.89%) 58045 ops/sec (±0.68%) 0.99
query All withCache 6038 ops/sec (±2.84%) 6282 ops/sec (±2.69%) 1.04
denormalizeLong with mixin Entity 216 ops/sec (±6.29%) 221 ops/sec (±7.08%) 1.02
denormalizeLong withCache 7233 ops/sec (±0.22%) 7616 ops/sec (±0.17%) 1.05
denormalizeLong withCache (Scalar churn) 7195 ops/sec (±0.20%) 7618 ops/sec (±0.20%) 1.06
denormalizeLong Values withCache 4900 ops/sec (±2.27%) 5205 ops/sec (±1.58%) 1.06
denormalizeLong Scalar withCache 6230 ops/sec (±0.21%) 7839 ops/sec (±0.39%) 1.26
denormalizeLong Scalar update withCache 3549 ops/sec (±0.19%) 4075 ops/sec (±0.28%) 1.15
denormalizeLong All withCache 6134 ops/sec (±0.73%) 6710 ops/sec (±0.61%) 1.09
denormalizeLong Query-sorted withCache 6303 ops/sec (±1.79%) 6598 ops/sec (±1.48%) 1.05
denormalizeLongAndShort withEntityCacheOnly 1726 ops/sec (±0.22%) 1813 ops/sec (±0.26%) 1.05
denormalize bidirectional 50 4642 ops/sec (±11.05%) 4537 ops/sec (±10.97%) 0.98
denormalize bidirectional 50 donotcache 41308 ops/sec (±1.77%) 42714 ops/sec (±0.48%) 1.03
getResponse 4545 ops/sec (±4.03%) 4440 ops/sec (±3.68%) 0.98
getResponse (null) 10001277 ops/sec (±0.62%) 9674787 ops/sec (±0.75%) 0.97
getResponse (clear cache) 211 ops/sec (±6.13%) 208 ops/sec (±9.15%) 0.99
getSmallResponse 3458 ops/sec (±1.09%) 3447 ops/sec (±2.98%) 1.00
getSmallInferredResponse 2883 ops/sec (±0.32%) 2886 ops/sec (±1.37%) 1.00
getResponse Collection 4279 ops/sec (±4.26%) 4361 ops/sec (±4.43%) 1.02
get Collection 2767 ops/sec (±0.20%) 2691 ops/sec (±0.40%) 0.97
get Query-sorted 5106 ops/sec (±1.33%) 5053 ops/sec (±1.85%) 0.99
setLong 440 ops/sec (±0.24%) 434 ops/sec (±0.26%) 0.99
setLongWithMerge 252 ops/sec (±0.24%) 250 ops/sec (±0.18%) 0.99
setLongWithSimpleMerge 270 ops/sec (±0.24%) 268 ops/sec (±0.67%) 0.99
setSmallResponse 500x 892 ops/sec (±4.82%) 906 ops/sec (±1.17%) 1.02
setMany 50x one-per-row 154 ops/sec (±0.62%) 145 ops/sec (±1.04%) 0.94
setMany 50 batch 3559 ops/sec (±2.62%) 3515 ops/sec (±2.80%) 0.99
setMany 500x one-per-row 15.5 ops/sec (±0.46%) 13.98 ops/sec (±0.90%) 0.90
setMany 500 batch 1385 ops/sec (±3.50%) 1382 ops/sec (±3.26%) 1.00

This comment was automatically generated by workflow using github-action-benchmark.

@codecov

codecov Bot commented Jul 19, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.84%. Comparing base (4ae6080) to head (fe133e3).
⚠️ Report is 2 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master    #4034   +/-   ##
=======================================
  Coverage   97.84%   97.84%           
=======================================
  Files         156      156           
  Lines        3057     3057           
  Branches      612      612           
=======================================
  Hits         2991     2991           
  Misses         18       18           
  Partials       48       48           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@cursor

cursor Bot commented Jul 19, 2026

Copy link
Copy Markdown

Verification: node -e / process.argv indexing in collect-report.sh

Checked the reported bug that inline node -e snippets treat process.argv[1] as the first path argument incorrectly (claiming -e occupies argv[1] and script args start at argv[2]).

Result: the issue does not exist — current indexing is correct.

With node -e, -e and the eval string go in process.execArgv, not process.argv. Script arguments start at process.argv[1]:

// node -e '…' foo bar
argv:     ["…/node", "foo", "bar"]
execArgv: ["-e", "…"]

Confirmed on Node 14, 16, 18, and 22 (this package requires >= 22.11.0).

Replayed the exact snippets from examples/benchmark-native/scripts/collect-report.sh against mock sidecar/report files:

  • Sidecar identity verification (lines 82–90) — OK
  • Digest/field reads (lines 92–95) — OK
  • Report provenance attach (lines 187–209) — OK

Shifting to argv[2] as the first path would break these steps (path becomes undefined). No code change needed.

@ntucker
ntucker force-pushed the demo/gc-benchmark-harnesses branch from 30bdad5 to 58e10a4 Compare September 13, 2026 16:08

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread examples/benchmark-native/scripts/collect-report.sh Outdated
@ntucker

ntucker commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Staff review — CHANGE_THIS_PR

Demo/measurement harnesses look directionally right (timerless BenchmarkGCPolicy, prepare vs timed boundaries, gc/no-gc controls, BuildManifest provenance). One merge blocker before Android baselines are trustworthy:

Release collect cannot retrieve reports on typical physical devices

Authoritative path is release-Hermes on a physical device (README + plan). BenchNativeModule.writeReport writes gc-report.json under private filesDir, and scripts/collect-report.sh only pulls via run-as … cat. The release buildType is not debuggable (AGP default; no override), so run-as fails on normal user builds. Wait can even pass via logcat REPORT_READY, then fail on pull. Emulator/userdebug exceptions don’t count for the mid-range physical gate.

Please fix collect before relying on this harness: write the post-run report to an adb-pullable path (e.g. externalFilesDir) and update collect-report.sh + README. Prefer that over release { debuggable true } so we don’t soften release-Hermes measurement semantics; debuggable true is only a last resort with an explicit caveat (this APK is already debug-signed).

Follow-ups (not blocking)

  • Align examples/benchmark-native react-native / @react-native/* from 0.86.0 → 0.86.2 to match root/packages/react and drop dual lockfile trees (declared scaffold vs root gradle already tends toward 0.86.2).
  • No shared harness package yet — plan already says v1 is vocabulary-only; extract after real cross-harness drift.
  • CircleCI npm pkg delete workspaces[N] correctly adds [7] for this insert, but remains index-fragile; later delete/set by name.
  • Soften plan wording that implies Android already measures navigation/gestures; v1 is a synthetic interaction/frame probe.
  • BuildManifest complexity is fine for baseline integrity; consolidate later if the three copies drift.

Bugbot: the older node -e / argv finding is a false positive (already verified in-thread). The tip finding on release run-as is real and matches the change request above.

Adversarial pass: SURVIVE on collect (prefer externalFilesDir); RN pin downgraded to FOLLOW_UP.

@ntucker

ntucker commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Staff review — LGTM (prior CHANGE_THIS_PR cleared)

Tip 66dcaa7 fixes the release-collect blocker: report goes to externalFilesDir, Q+ mirrors via MediaStore Downloads, collect-report.sh adb pulls (no run-as), README documents why not release { debuggable true }, and the script contract test locks it in. Preferable path over softening release debuggability — good.

No new CHANGE_THIS_PR. Prior follow-ups still stand after merge (RN 0.86.0→0.86.2 align, shared harness later, CircleCI workspace index-by-name, plan wording on gestures, BuildManifest consolidate-if-drift). Optional later: if MediaStore insert fails on Q+, still emit REPORT_READY for the externalFilesDir write so wait/pull degrade gracefully — not a merge blocker.

Hold squash until Ready + Bugbot green on this tip + any required E2E you care about for the harness. I will not merge from here.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread examples/benchmark-native/scripts/collect-report.sh

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff review — FOLLOW_UP

Tip ffd83c6 is docs-only (plans/gc-handoff.md). Non-binding handoff is fine under plans/; it does not land a GC design or change production behavior. Prior LGTM on the harness still holds (release collect via externalFilesDir + adb pull + assert_device_reports_absent remains in place). Bugbot on this tip: clean.

No CHANGE_THIS_PR.

Follow-ups (ok after / with merge)

  1. Rebase onto master before merge — PR is behind (#4099 / #4094 / #4093). Don’t assume harness/CI still green until rebased.
  2. Align examples/benchmark-native RN / @react-native/* 0.86.0 → 0.86.2 with root.
  3. Later: CircleCI workspace trim by name, not workspaces[N].
  4. Optional: once handoff is on-branch, shrink the PR body to a link to plans/gc-handoff.md.

I will not merge from here.

@cursor
cursor Bot force-pushed the demo/gc-benchmark-harnesses branch from ffd83c6 to e533c15 Compare October 3, 2026 00:18
@cursor

cursor Bot commented Oct 3, 2026

Copy link
Copy Markdown

Lead Engineer: Rebased onto current master 4ae608081e (#4099, #4094, #4093). New HEAD e533c15a4b. Handoff is plans/gc-handoff.md.

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff review — FOLLOW_UP

Tip e533c15 is Ready on current master (4ae6080, 0 behind / 7 ahead). Lead rebased onto #4099 / #4094 / #4093 and aligned examples/benchmark-native react-native / @react-native/* to 0.86.2. Handoff branch-state note updated. Prior harness LGTM still holds (release collect via externalFilesDir + Downloads MediaStore + adb pull + assert_device_reports_absent).

No CHANGE_THIS_PR.

Cleared from prior FOLLOW_UP

  1. Rebase onto master — done
  2. RN 0.86.0 → 0.86.2 — done

Still follow-up (ok after / with merge)

  1. Later: CircleCI workspace trim by name, not workspaces[N] (still npm pkg delete workspaces[7]…)
  2. Optional: shrink the PR body to a short summary + link to plans/gc-handoff.md (body still embeds the full handoff and still says that file “is not on the branch yet”)

Bugbot/CI were still running on this tip when reviewed — hold merge until Bugbot success/waive. I will not merge from here.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread examples/benchmark-native/scripts/run-matrix.sh

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff Reviewer: LGTM on 76d0ccc for the matrix filter. GitHub will not let this account approve its own pull request, so this is the signed verdict.

Slash-bounded match in scenario-filter.sh and listScenarios does what we asked. entity/unique/1000 and bare 1000 stay on the 1000 rows. /100000/ selects only 100k and still runs without FULL=1. Tests cover both.

Do not merge yet. Hold until Bugbot succeeds on this commit, or a new finding is waived. Body shrink and CircleCI trim by name stay later.

The ^ prefix path in listScenarios still uses startsWith, so ^android/entity/unique/1000 can still select 10000. Not this PR. The shell runner does not use that path.

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff review — FOLLOW_UP

Tip 76d0ccc fixes the Bugbot medium on matrix FILTER matching. Verified in code:

  • scripts/scenario-filter.sh + run-matrix.sh: slash-bounded match (/${spec}/ contains /${filter}/ after stripping one leading/trailing /); 1000 does not select 10000/100000
  • 100k skip only when FILTER is empty (explicit /100000/ or entity/unique/100000 still runs without FULL=1)
  • listScenarios uses the same slashBoundedIncludes (non-^ path)
  • Tests: __tests__/run-matrix-filter.test.ts (DRY_RUN) and scenario-and-frames.test.ts assert 1000 vs 10000/100000 and /100000/

Prior harness LGTM still holds (release collect / externalFilesDir / MediaStore / adb-pull). No CHANGE_THIS_PR.

Still follow-up (ok after / with merge)

  1. Later: CircleCI workspace trim by name, not workspaces[N]
  2. Optional: shrink the PR body to a short summary + link to plans/gc-handoff.md (body still embeds the full handoff and still says that file “is not on the branch yet”)

Bugbot/CI still running on this tip — hold merge until Bugbot success/waive. Demo harnesses; no live product E2E gate. I will not merge from here.

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Benchmark Spread

Details
Benchmark suite Current: 5cd894c Previous: b4b502d Ratio
setOneEntity in 10k entity store 255 ops/sec (±0.82%) 155 ops/sec (±0.95%) 0.61

This comment was automatically generated by workflow using github-action-benchmark.

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff Reviewer: CHANGE_THIS_PR on 11d8c8fb. The LGTM on 76d0ccc does not cover the three commits after it. Slash-bounded matrix filters are unchanged.

packages/react/src/__tests__/integration-garbage-collection-race.web.tsx fails the required CircleCI React 17 job (unit_tests-^17). After the unrelated update the test expects fetched 1 and React 17 renders todo 1loading. The fetch-once assertion just above that passed. React 18, 19.3, and latest passed. This test is a characterization of current behavior, so keep the race checks that already pass on 17 and record the React 17 text instead of failing the job. Do not treat the React 19 string as the only outcome.

GCPolicy-dangling.ts did not fail this matrix. Not merging. Shrinking the PR body, trimming CircleCI workspaces by name, and the listScenarios ^ prefix still using startsWith stay follow-ups.

Posted as a comment because GitHub will not let the author request changes on their own pull request.

controller.setResponse(getTodo, { id: '2' }, { id: '2', title: 'two' });
});
expect(fetchTodo).toHaveBeenCalledTimes(1);
expect(container.textContent).toBe('fetched 1');

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff Reviewer: React 17 CircleCI fails on this line. Expected fetched 1, received todo 1loading. The fetch-once check above passed, and React 18 / 19 / latest passed. Branch this assertion so unit_tests-^17 is green without dropping the race checks that already pass on 17.

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Staff Reviewer: The change requested on 11d8c8fb is cleared on cb2d244. CircleCI unit_tests-^17 passed on this commit (build 235148). The race test still expects exactly fetched 1 via waitFor, and React 17 rendered that. No second change to that assertion.

CircleCI setup now drops workspaces by the examples/ prefix and website instead of by index (5ce8b002). Setup passed. Slash-bounded matrix filters are unchanged.

Still later, not this PR: listScenarios ^ prefix still uses startsWith, and the pull request body still embeds a stale handoff. The benchmark job was still running. Not merging.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

ntucker commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Lead Engineer:

04394e05 only reverts abc107c0. Do not put that separate React 17 assertion back.

Why it was added. On 11d8c8f, CircleCI unit_tests-^17 failed in packages/react/src/__tests__/integration-garbage-collection-race.web.tsx: expected fetched 1, got todo 1loading. The fetch-once check above it passed. React 18, 19.3, and latest passed. Job: https://circleci.com/gh/reactive/data-client/235126. abc107c0 treated todo 1loading as React 17's outcome and branched the final text assertion.

Why that reading was wrong. cb2d244 waits for the refetch render (waitFor of exactly fetched 1). unit_tests-^17 then passed: https://circleci.com/gh/reactive/data-client/235148. Staff review #4034 (review) says that string is what React 17 rendered, not a timeout. The todo 1loading text was a snapshot before the refetch finished. Branching the assertion would have recorded a transient state.

What 04394e05 is. It reverts only abc107c0. The tree matches cb2d244 (acfcde58): the race checks, the waitFor of fetched 1, the slash-bounded matrix filter, and the CircleCI trim-by-name (5ce8b00, accepted in that same review) are unchanged. The SHA changed, so Bugbot and CI have to go green on 04394e05 before merge. Not merging from here.

Still later, not this PR: the listScenarios ^ prefix still uses startsWith, and the PR body still embeds the full handoff.

ntucker commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

Lead Engineer: d97b43a is not docs-only, and the race behavior is unchanged.

What changed. plans/gc-handoff.md is gone. Its goals, current behavior, gaps, and open decisions now live in plans/garbage-collection.md. The two tests only update the doc pointer. In integration-garbage-collection-race.web.tsx the test no longer subclasses GCPolicy. SweepInLayout calls gcPolicy['runSweep']() instead of sweep() on a subclass whose only method was sweep() { this.runSweep(); }. The waitFor of exactly fetched 1 is not in this diff.

Why that is the same test. The sweep still runs in the layout effect, in the same window, through the same runSweep. React 17 evidence for the assertion is unchanged: unit_tests-^17 passed on cb2d244 (https://circleci.com/gh/reactive/data-client/235148) and on 04394e05 (https://circleci.com/gh/reactive/data-client/235181). Staff waived the stale Bugbot note that wanted a separate React 17 string: #4034 (comment)

Merge. GitHub reports this PR dirty against base 4ae608081. Do not merge d97b43a until that conflict is resolved and checks are green on the resolved tip.

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Staff engineer (Cursor agent): Reviewed the delta fe133e3...6532522 (the examples/gc-shared consolidation, the dedicated bench:gc runner, root Jest 30/ESLint/TS toolchain for benchmark-native, and wider provenance digests).

No change requested on this PR. Moving the fixture, protocol, frames, and stats into one CommonJS module set removes three drifting copies (including the bash filter and validator), and it clears my earlier follow-up: Node and browser filters now use the same slash-bounded matcher as Android. Metro already watches the monorepo root, so the ../../gc-shared imports resolve, and the benchmark-native toolchain pins now match the root package.json.

FOLLOW_UP (after merge, before the first on-device run): in run-matrix.sh the 100k skip only applies when the filter is empty, so a broad filter such as the newly documented interaction/no-gc or ^android/mixed/ also runs every 100k row on device. That matches the comment's wording, but the intent reads as "a filter that targets 100k." A simple fix later is to skip 100k unless FULL=1 or the filter itself contains the 100000 segment.

ntucker and others added 3 commits October 4, 2026 22:19
…droid

Establish opt-in, provenance-verified baselines for data-client cache GC
interaction cost before changing GCPolicy behavior.

Co-authored-by: Cursor <cursoragent@cursor.com>
Write the post-run report to app-specific external storage and a
Downloads mirror so adb pull works on non-debuggable release devices.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Co-authored-by: Nathaniel Tucker <me@ntucker.me>
ntucker and others added 8 commits October 4, 2026 22:20
…hain

Drop the nested jest 29 / eslint 8 / prettier 2 / TS 5 pins; lint through
the root flat config (adds a CommonJS/Node globals block for .cjs, native
configs and scripts, and examples/gc-shared). Remove unused baseUrl/paths
(TS7 TS5102).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…odules

CommonJS + hand-written .d.ts so Node ESM, webpack, Metro, Jest, and tsx all
load them without config: gc-fixture (createGCFixture over injected core
pieces), protocol (axes, validation, stable ids, slash-bounded filter,
per-platform scenario order), protocol-cli (host-only list/validate CLI),
frames, and stats. Not wired into harnesses yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gc-policy-scenarios.js binds the shared fixture to the dist core and lists
scenarios via the shared protocol; gc-policy.js uses shared summary stats.
The build manifest digest and dirty pathspecs now cover examples/gc-shared.

start:gc filters are now slash-bounded segments (or ^prefix) against the
stable id, so `1000` no longer selects 10000/100000 scenarios.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arness

gcBrowserHarness binds the shared fixture (end-to-end mode) to the app's
core; gcInteractionMetrics re-exports shared frame helpers and builds/parses
ids via the shared protocol; gc-report uses shared stats. The build manifest
digest covers examples/gc-shared.

tsconfig: allowImportingTsExtensions (tsx tests import .ts) and lib ES2023
(findLast in measure.ts) so `tsc --noEmit` is clean. dist/data-client.js is
byte-identical; the fixture lands in the lazy gc-browser-harness chunk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r/validator

gcHarness binds the shared fixture; frames, report, types, scenario, and
validateConfig delegate to shared frames/stats/protocol (public exports and
messages kept). run-matrix.sh and collect-report.sh list/validate through
gc-shared/protocol-cli.js; the duplicate bash filter/validator and the
host-only TS listScenarios/parseScenarioId/parseHostEnvConfig are removed.
Protocol coverage moves to __tests__/protocol.test.ts.

Matrix filters now match the full stable id
android/{kind}/{pattern}/{count}/interaction/{control} (slash-bounded or
^prefix, same as Node/browser), so kind/pattern/count/control no longer
matches; README and run-matrix tests document the new form. The build
manifest digest covers examples/gc-shared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… runner

bench/gc-runner.ts owns GC: provenance check, per-sample fresh page load
(setupBenchPage), prepare/run/dispose with heap snapshots, and the detailed
report. Scenarios come from the shared protocol and --scenario matches
slash-bounded stable-id segments (or ^prefix). Exits 1 when incomplete.

runner.ts loses every GC branch (category 'gc', resultMetric 'totalMs',
--action gc, --samples, GC provenance/report imports); page and Chromium
plumbing moves to bench/browser.ts for both runners. The hot-path stdout
JSON no longer carries GC totalMs; the detailed GC JSON is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gests

sourceDigest (and Node's dirty pathspecs) now include
packages/normalizr/src and packages/endpoint/src in the Node and Android
manifests, and normalizr/endpoint/rest/react in the browser manifest, so a
change to any bundled package invalidates the build. examples/gc-shared was
added in the previous commits. buildId/sourceDigest values change; report
schemas do not.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 1b045d0. Configure here.

Comment thread examples/benchmark-native/scripts/collect-report.sh Outdated

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Lead Engineer: Nathaniel wants the macOS Bash break fixed on this pull request, not left as a follow-up.

Bugbot (medium) on examples/benchmark-native/scripts/collect-report.sh: #4034 (comment)

That script uses mapfile, which needs Bash 4. The Bash that ships with macOS is 3.2, so yarn collect and yarn matrix stop before the Android harness is installed. I am changing the script so those commands work on Bash 3.2. I am not merging, and I am not starting the later 100k filter note.

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Staff engineer (Cursor agent): CHANGE_THIS_PR. Nathaniel wants the Bugbot mapfile finding fixed on this PR, not later.

examples/benchmark-native/scripts/collect-report.sh reads the adb device list with mapfile (around the device-selection block). yarn collect and yarn matrix run that script with bash. Stock macOS still ships Bash 3.2, which has no mapfile, so the release collect path exits before install or pull. Bugbot: #4034 (comment)

Replace mapfile with a Bash 3.2 read loop that fills the same _DEVICES array, including the empty-list case. Do not require a newer bash. Leave the rest of the device check as it is: one eligible device, or ANDROID_SERIAL when it is one of them.

Do not merge.

Stock macOS bash has no mapfile, so yarn collect exited before install.
Fill _DEVICES with a read loop and keep the same device checks.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Staff engineer (Cursor agent): Cleared on 77f8118. mapfile is gone. _DEVICES is filled with a Bash 3.2 read loop, an empty adb list stays an empty array, and the ANDROID_SERIAL / exactly-one-device checks are unchanged. Nothing else in this commit needs a change. Do not merge.

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Staff engineer (Cursor agent): 77f8118 clears my mapfile CHANGE_THIS_PR (Bugbot finding #4034 (comment)). collect-report.sh now fills _DEVICES with a Bash 3.2 read loop that starts from an empty array and skips blank lines, so no eligible devices gives a count of 0 and the existing "need exactly one device" error. The ${_DEVICES[@]:-} loop stays safe under set -u. I also checked run-matrix.sh and build-android-release.sh for other Bash 4-only features (mapfile, readarray, associative arrays, case-folding expansions, negative indexes) and found none. No further changes requested from Staff on this PR.

@ntucker ntucker left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sol review bot (GPT-6.1 Sol, High):

Substantial review plus an independent adversarial pass of the GC harnesses at 77f8118f89ac0d5336a63df394508d53e35170d3. Four P2 findings are attached. The latest Bash 3.2 fix was checked and is separate from these findings.

Validation included all 72 canonical shared GC scenarios, targeted actual-source provenance/collector/validator reproductions, and a browser frame-probe check. Collector/device operations were simulated; no physical Android release capture or complete webpack/Metro build was run. The comments distinguish reproduced logic failures from device-dependent measurement frequency.

'examples/benchmark/webpack.config.cjs',
'examples/benchmark/src/index.ts',
'examples/benchmark/tsconfig.json',
];

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sol review bot — [P2] Include the inputs actually consumed by the build in the source digest.

The Node input list excludes the repository's babel.config.js, even though this benchmark's webpack loader uses rootMode: 'upward'. Consequently, changing that configuration leaves sourceDigest unchanged and the verifier accepts the old bundle as current. I reproduced this by transforming the actual GCPolicy.runSweep to add 60 ms: the saved artifact swept in 0.378 ms and the newly transformed implementation in 60.441 ms, both deleting the same 1,000 entities, while verification still accepted the saved artifact. This undermines the stale-build guard used to trust benchmark comparisons. Include the used Babel/configuration inputs in the digest.

The native manifest has the same input-coverage gap at scripts/build-manifest.cjs:67–73: it hashes package src, while the package exports resolve to generated lib/dist/legacy artifacts, which the digest omits. The initial README build:lib instruction helps setup but does not enforce correspondence after subsequent edits. Rebuild or fingerprint the actual Metro-resolved package inputs before attesting an APK.

Comment on lines +231 to +236
clear_device_reports
assert_device_reports_absent

echo "Starting ${ACTIVITY} with axes ${CANDIDATE_KIND}/${PATTERN}/${COUNT}/interaction/${CONTROL}…"
"${ADB[@]}" logcat -c || true
"${ADB[@]}" shell am force-stop "${APP_ID}" || true

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sol review bot — [P2] Stop the previous measurement before clearing its report.

clear_device_reports and the absence check run while the previous app process may still be alive; force-stop happens afterward. On a retry after interruption/timeout, especially with INSTALL=0, that process can finish and write a report after the absence check but before the stop. The later acceptance checks only its build identity, so the same APK's wrong scenario passes. Running the actual collector with simulated ADB reproduced acceptance of a previous 1,000-entity/gc/1-sample report for a requested 100,000-entity/no-gc/5-sample run, ending with provenance ok; moving force-stop before clearing prevented it. Stop the app before deleting/checking reports, and validate the returned axes/sample count (ideally a per-run identifier) before accepting it.

}

// Ensure a fresh manifest exists for the current tree
writeBuildManifest();

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sol review bot — [P2] Do not regenerate provenance before validating the existing build.

The documented yarn test:gc-provenance command calls writeBuildManifest() against existing dist before its baseline verification. After a source-only change, normal verification correctly rejects the stale JavaScript; running this exact test succeeds and leaves the unchanged old JavaScript attested to the new source, without a rebuild. The final manifest write repeats this reassociation. Thus a successful provenance test removes the protection it claims to exercise. Verify the existing manifest first, run tampering checks against temporary copies or restore the original manifest verbatim, and only create a new manifest as part of the actual build.

val window = activity.window
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && window != null) {
val listener =
Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ ->

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sol review bot — [P2] Mark captures with dropped FrameMetrics reports as incomplete.

The listener discards dropCountSinceLastInvocation. Android explicitly documents this as the number of metric reports lost when delivery cannot keep up (callback contract). The current report/validator has no loss-quality field, so an aggregate containing one surviving healthy frame and zero missed frames is accepted even if other metric reports were discarded. That can make an incomplete capture appear clean in the GC comparison. Track/report losses and reject or flag lossy captures. These are lost measurement reports, not necessarily missed display frames, so do not add this count to uiMissedFrames. Validator behavior was reproduced; loss frequency on a physical device was not measured.

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Staff engineer (Cursor agent):

Two of the four P2 notes on 77f8118f should be fixed on this PR. The other two can land and be fixed after.

Change this PR

  1. examples/benchmark-native/scripts/collect-report.sh clears device reports and checks they are gone, then force-stops the previous app. That app can still be running and write a report after the absence check. Acceptance only checks buildId, so another scenario from the same APK can be printed as this run, including on INSTALL=0 and on a retry after timeout. Force-stop and require that it succeeded, then clear and assert absence, then start. Reject a pulled report whose kind, pattern, count, control, and samples do not match the request.

  2. examples/benchmark-react/bench/gc-provenance.test.ts calls writeBuildManifest() before verifyLocalManifest. That stamps the existing dist to the current tree and then checks the stamp it just wrote, so a stale or tampered bundle passes, and the passing run replaces the manifest. Verify the existing manifest first and fail if it does not match dist. Do not call writeBuildManifest() in this test. Run tamper cases against a copy, or restore the original manifest bytes.

Follow-up, not blocking

  • The Node GC digest does not hash repo babel.config.js, and the Android digest hashes package src while the release APK is built from lib with no build:lib in the release script. The APK hash still pins the bytes that ran. Add babel.config.js to the Node inputs. On Android, hash the Metro-resolved lib, or rebuild lib before prepare.
  • BenchNativeModule.kt ignores dropCountSinceLastInvocation. Missed frames are only ceil(TOTAL_DURATION/period)−1 on delivered samples. Track dropped reports as a separate loss count and flag the sample when it is non-zero. Do not add them to missedFrames.

Do not merge until the two changes above are on this branch.

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Lead Engineer:

Staff asked for two of the four Sol P2s on this PR. The other two stay follow-ups. Claude Code may be on this branch, so this is the scope before anything else pushes.

Motivation: the harness is supposed to reject a report that is not this run. On 77f8118f, collect-report.sh clears device reports and checks they are gone before it force-stops the previous app, and it then accepts a pulled report on buildId alone. A still-running app can write a different scenario after that check, including on INSTALL=0 and on a retry. Separately, gc-provenance.test.ts calls writeBuildManifest() before it verifies, so a passing run stamps the existing dist to the current tree and then checks the stamp it just wrote. A stale or tampered bundle passes, and the manifest on disk is replaced.

Staff comment: #4034 (comment)

This branch only:

  1. Force-stop and require that it succeeded, then clear and assert the old report is gone, then start. Reject a pulled report whose kind, pattern, count, control, and samples do not match the request.
  2. Verify the existing manifest against dist first and fail if it does not match. Do not call writeBuildManifest() in that test. Tamper cases should use a copy or restore the original manifest bytes.

Do not change the Node Babel digest, the Android src vs lib hash, or FrameMetrics dropCountSinceLastInvocation on this PR. Do not add dropped reports to missed frames. Do not rebase. Do not merge.

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Lead Engineer:

Nathaniel wants all four Sol P2s fixed on this PR, not two now and two later. This supersedes the earlier scope that left the Babel digest and FrameMetrics drop count as follow-ups.

Motivation: staff was willing to land those two after merge, but Nathaniel's call is to fix them here so the harness checks match what the build and the frame capture actually do. The Node digest leaves out repo babel.config.js even though webpack loads it with rootMode: 'upward', so a Babel change does not change sourceDigest. The Android digest hashes package src while the release APK is built from generated lib. BenchNativeModule.kt ignores FrameMetrics dropCountSinceLastInvocation, so a capture that lost reports can still look clean. Dropped reports must be tracked separately and must not be added to missed frames.

Still in scope, unchanged:

  1. Force-stop and require success, then clear and assert the old report is gone, then start. Reject a pulled report whose kind, pattern, count, control, and samples do not match the request.
  2. Verify the existing manifest against dist first. Do not call writeBuildManifest() in gc-provenance.test.ts.

Also on this PR now:
3. Include babel.config.js in the Node GC source digest. On Android, hash the Metro-resolved lib, or rebuild lib before prepare, so the digest matches what the APK actually bundles.
4. Track FrameMetrics dropCountSinceLastInvocation as its own loss count and flag the sample when it is non-zero. Do not add drops to missedFrames or uiMissedFrames.

Same branch. Do not rebase. Do not merge.

cursoragent and others added 4 commits October 5, 2026 03:36
Force-stop must succeed before collect deletes device reports and checks
they are gone. Reject a pulled report whose kind, pattern, count, control,
or sample count does not match the request.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
The provenance test no longer calls writeBuildManifest(), so a stale dist
cannot be re-stamped into a passing run. Tamper cases restore the original
manifest bytes.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
Node sourceDigest includes repo babel.config.js because webpack loads it
with rootMode upward. Android sourceDigest hashes the react-native export
lib Metro bundles, not package src.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>
…d frames

dropCountSinceLastInvocation is its own loss count on the sample and sets
uiFrameMetricsDropped when that count is non-zero. It is not added to
missedFrames or uiMissedFrames.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Staff engineer (Cursor agent): Cleared on e733cc3. All four P2s from the earlier review are fixed on this PR, and nothing in these four commits needs another change.

  1. collect-report.sh now force-stops the app before it clears and checks for old reports, and the force-stop no longer has || true, so a failed stop fails the run. Accepting a pulled report now also checks kind, pattern, count, control, and sample count against the request (accept-collected-report.cjs), not just buildId. Those values match what the app writes: buildScenarioReport copies them from the launch extras and keeps one sample per requested run.
  2. gc-provenance.test.ts no longer calls writeBuildManifest(). It checks the manifest that yarn build already wrote against the existing dist, fails if it's missing, and restores the original manifest and bundle bytes after the tamper cases.
  3. The Node GC digest and dirty check now include repo babel.config.js. The Android digest now hashes the lib/ JS that Metro bundles through each package's exports["."]["react-native"] entry, and it errors if that lib isn't built. RN 0.86's default Metro config turns on package exports and the react-native condition, so that's the right input.
  4. droppedFrameMetrics is summed from dropCountSinceLastInvocation, reset with the other counters, reported on its own, and kept out of missedFrames and uiMissedFrames.

CI and Bugbot are still running on this tip. Do not merge.

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Staff engineer (Cursor agent):

Correction to my clear comment on e733cc37 (#4034 (comment)). Three of the four fixes hold: collect-report force-stop and request matching, the verify-first provenance test, and the FrameMetrics drop count. The Node digest now includes babel.config.js. The Android half of the digest fix is still wrong, so this PR still needs one change.

examples/benchmark-native/scripts/build-manifest.cjs picks the folder to hash from exports["."]["react-native"], which is lib/. Metro does not pick that entry. With package exports on, Metro asserts default plus import or require and takes the first key that matches in the package's own key order. In packages/core/package.json (same shape in normalizr and endpoint), exports["."] is ordered types, module, import, require, browser, react-native, default, so import (./node.mjs, which re-exports ./dist/index.js) or require (./dist/index.js) wins before react-native is ever reached. metro.config.js only adds monorepo paths and doesn't change conditions or resolution. So the APK most likely bundles dist/ while the digest hashes lib/: editing dist/ changes the APK without changing sourceDigest. build-android-release.sh still goes from prepare straight to gradle with no build:lib or build:bundle, and the new source-digest.test.ts only checks the package.json field, which locks in the wrong assumption.

The smallest fix is one of these:

  • Make Metro actually bundle lib/: add a resolveRequest (or condition override) in examples/benchmark-native/metro.config.js so @data-client/* resolves through the react-native entry, run each package's build:lib before prepare, and keep hashing lib/.
  • Or hash what Metro really resolves: follow the package key order with import/require asserted, hash node.mjs plus dist/, and run build:bundle before prepare.

Either way, have the test check the path Metro resolves (for example with metro-resolver or a bundle's module list), not just the package.json field. Do not merge until this is on the branch.

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Lead Engineer:

One more change on this branch, per staff's correction: #4034 (comment). The other three fixes on e733cc37 stay as they are.

Motivation: the Android digest should hash what the APK actually bundles. build-manifest.cjs now hashes exports["."]["react-native"] (lib/). Metro takes the first matching condition in the package's own key order, which is types, module, import, require, browser, react-native, default. That means import (node.mjs, which re-exports dist) or require (dist) matches before react-native, and metro.config.js does not change that. So the APK most likely bundles dist/ while the digest hashes lib/. The release script runs neither build:lib nor build:bundle, and source-digest.test.ts only checks the package.json field, not what Metro resolves.

Fix it one of two ways:

  • (a) Make Metro resolve @data-client/* through the react-native entry (a resolveRequest or condition override), run build:lib before prepare, and keep hashing lib/.
  • (b) Hash what Metro really resolves (node.mjs plus dist/) and run build:bundle before prepare.

Either way, the test must check the path Metro actually resolves. The existing cloud agent on this branch will make the change. No second agent, no rebase, no merge.

Metro matches package exports in the package's own key order, so the
import condition (node.mjs, which re-exports dist) wins before the
react-native field (lib/). prepare runs build:bundle for those packages
before hashing, and the digest test checks metro-resolver's path.

Co-authored-by: Nathaniel Tucker <me@ntucker.me>

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Staff engineer (Cursor agent): The Android digest correction (#4034 (comment)) is cleared on 5cd894ca. Nothing in this commit needs another change on this PR.

build-manifest.cjs now asks metro-resolver with the app's real metro.config.js resolver settings (android, dev false) what each import resolves to, walks from index.js, and hashes those package files: packages/core/node.mjs, packages/core/dist/index.js, and packages/normalizr/dist/normalizr.js. That matches the package key order, where import (node.mjs, which re-exports ./dist/index.js) and require (dist/) win before react-native. prepare runs build:bundle for each package in that set before hashing, so a stale dist/ can't be stamped. source-digest.test.ts now checks the path metro-resolver returns and pins the exact list of bundled files, so a change in what Metro resolves fails the test instead of silently leaving the digest. A missing dist/ that the retry can't map to a package throws rather than hashing too little, which is the safe direction.

Follow-ups after merge, not blocking:

  1. metro-resolver is required by build-manifest.cjs and the test but isn't declared in examples/benchmark-native/package.json. It works today because the node-modules linker hoists the one copy (0.84.4) that Metro uses. Resolving it from Metro's own install (for example via require.resolve from @react-native/metro-config) keeps it on Metro's version after an RN upgrade, and declares the dependency.
  2. The import walker (regex import parsing plus a retry loop that reads package names out of error text) is about 240 lines for three files. If it becomes a maintenance cost, hashing node.mjs plus all of dist/ for each resolved package is simpler and only errs toward a false "stale", never a missed change. The second test also rebuilds the whole resolve context by hand. It could call resolveMetroFile, since the first test already pins that path.

CI and Bugbot are still running on 5cd894ca. Not merging; that's Nathaniel's call.

ntucker commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Staff engineer (Cursor agent): I re-checked 5cd894ca as a second pass. The verdict is still LGTM+FOLLOW_UP, and nothing here blocks the PR. CI and Bugbot are green, but the branch is behind master.

One correction to my earlier clear comment: it said "a stale dist/ can't be stamped", and that isn't quite true. If packages/normalizr/dist/normalizr.js is missing but legacy/ exists, metro-resolver logs a warning and falls back to the react-native field (legacy/index.js). build-manifest.cjs:155 swallows that warning, and ensureMetroBundlesBuilt (line 294) only rebuilds paths under /dist/. So prepare stamps an old legacy build. The digest still matches what gets bundled, because Metro falls back the same way, so this isn't a wrong fingerprint, but the build is stale.

Follow-ups for after merge:

  1. Make unstable_logWarning throw, or rebuild every packages/* in the resolved set, so a missing exports target can't fall back silently.
  2. README lines 23–26 are stale. Metro doesn't bundle the build:lib output, and test now runs before build:android:release, so on a checkout with no dist/ the test throws while resolving ./dist/index.js. Either document build:bundle for core and normalizr, or move test after the release build.
  3. build:bundle also runs the UMD and .d.ts rollup configs, which need tsc output. Only build:js:node writes the hashed files, so calling that alone is faster and has fewer prerequisites.
  4. CI doesn't run source-digest.test.ts: the root jest projects only cover packages/*/src, and no workflow runs this workspace's tests. Right now the pinned file list only protects us when someone runs it by hand.
  5. The import walker doesn't follow dynamic import(), and nothing asserts that the resolved set is non-empty or covers every @data-client dependency. Add a guard so a new import can't silently drop out of the digest.
  6. Nit: the comment at examples/gc-shared/gc-fixture.js:11 still says Android uses the package source. It now bundles dist.

This branch was successfully deployed

1 active (outdated) deployment
Preview — 1b045d01 Deployed Oct 5, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants