Skip to content

feat: recover provenance past the per-object recording budget - #62

Merged
stackloomdev merged 1 commit into
mainfrom
feat/breadth-budget-recovery
Jul 28, 2026
Merged

stackloomdev merged 1 commit into
mainfrom
feat/breadth-budget-recovery

Conversation

@stackloomdev

Copy link
Copy Markdown
Owner

Third and last gap from the chain audit that started with the r/reactjs comment. Found while probing the previous PR's claim — and unlike the item I dropped in #61, this one is real.

The gap

#registerOrigin walks a response into #valueOrigins but stops after 100 entries per object. A .map() callback hands the element itself to JSX, so there is no path back to the response — only the object. If that object was never registered, the lookup finds nothing.

Measured, not assumed:

items[5]   → confirmed, "response.items[5].name"
items[150] → []

A 200-row list explains its first 100 rows and silently refuses the rest. That is the worst shape a failure can take in a tool whose premise is never misleading: silent and position-dependent. Two rows that look identical differ in whether they can be explained, and nothing says why.

Change

Raising the constant only moves the cliff — a 10k-row table breaks just the same — so registration stays bounded and the recovery moves to lookup time.

  • A container cut off by the budget is remembered by WeakRef, bounded to the last 32.
  • A lookup that misses scans those containers for the value by identity and builds the path from the container's own origin.
  • Registration stays O(1) for the tail. The scan runs only on a miss, which is when a person clicks an element — affordable in a way that eagerly registering every child is not.
  • Reads enumerable data properties only, through a helper now shared with registration. Resolving a lookup must never invoke an application getter; a test fails if it ever does.

Budget

This raises the npm tarball budget from 128 KiB to 130 KiB, and that deserves to be called out rather than absorbed quietly.

tarball
main 127.3 KiB (0.7 KiB headroom)
this change 128.9 KiB

The budget has held since it was introduced in #23 and should keep holding — the point is to catch growth nobody decided on. I tried trimming first (extracting the shared helper) and it came out 0.1 KiB worse, since the tarball carries source maps and declarations. So the trade is stated instead: 1.6 KiB for removing a silent, position-dependent failure. The constant now carries a comment explaining when it moved and why.

If you would rather not spend it, the alternative is dropping the non-array branch — cheaper, but it leaves keyed records over 100 keys broken, which is a known gap rather than a fixed one.

Verification

  • 4 new runtime tests: position independence, bracketed awkward keys, no getter invocation, and the 32-container bound.
  • Confirmed 3 of them fail with the recovery disabled — they test the change rather than passing vacuously.
  • pnpm typecheck, pnpm test: 24/24 tasks.
  • verify:release, verify:public-api (5 entrypoints, snapshot unchanged), verify:trace-contract: pass.
  • React 19 Playwright e2e: 12/12.

🤖 Generated with Claude Code

#registerOrigin walks a response into #valueOrigins but stops after 100
entries per object. A list element past the hundredth was therefore
never registered, and since a map callback hands the element itself to
JSX with no path back to the response, the lookup found nothing at all.

That is the worst shape a failure can take here: silent and dependent on
position. Two rows that look identical differed in whether they could be
explained, with nothing said about why.

Raising the constant only moves the cliff, so registration stays bounded
and the recovery moves to lookup time. A container that was cut off is
remembered by WeakRef, and a lookup that misses scans those containers
for the value by identity, building the path from the container's own
origin. Registration stays O(1) for the tail; the scan runs only when
someone inspects an element, where it is affordable.

Reads enumerable data properties only, through a helper now shared with
registration: resolving a lookup must never invoke an application
getter. Covered by a test that fails if it ever does.

Raises the npm tarball budget from 128 KiB to 130 KiB. It has held since
it was introduced and should keep holding, so the raise is deliberate
and stated rather than quietly absorbed: 127.3 KiB left 0.7 KiB of
headroom and this costs 1.6 KiB.

Verified the three positive tests fail with the recovery disabled.

Co-Authored-By: Claude Opus 5 <[email protected]>
@stackloomdev
stackloomdev merged commit 8e522ae into main Jul 28, 2026
11 checks passed
@stackloomdev
stackloomdev deleted the feat/breadth-budget-recovery branch July 28, 2026 08:55

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1be8d8908e

ℹ️ 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".

Comment on lines +1348 to +1350
const found = Array.isArray(container)
? container.indexOf(value)
: dataEntries(container).find(([, child]) => child === value)?.[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not confirm values added after registration

When a remembered array or record is mutated after registerValueOrigin—for example, by pushing a locally created row—this lookup scans the container's current contents and labels that new object as a confirmed child of the earlier network/store origin. Because membership at lookup time is not evidence that the value existed when the origin was registered, recovery must either preserve registration-time membership or report the result as possible/unavailable rather than manufacturing a confirmed path.

AGENTS.md reference: AGENTS.md:L9-L9

Useful? React with 👍 / 👎.

Comment on lines +1357 to +1361
return [{
...entry.origin,
id: createId("cs_origin", ++this.#sequence),
path: `${entry.origin.path ?? "value"}${this.#containerSegment(container, key)}`,
}];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve ambiguity across matching containers

When the same tail object is present in two remembered containers with different origins, this immediate return chooses only the newest match and reports it as confirmed, even though object identity cannot establish which container supplied the rendered value. The recovery should collect all matching origins or explicitly downgrade/withhold the attribution instead of silently selecting one causal chain.

AGENTS.md reference: AGENTS.md:L9-L9

Useful? React with 👍 / 👎.

Comment on lines +1348 to +1350
const found = Array.isArray(container)
? container.indexOf(value)
: dataEntries(container).find(([, child]) => child === value)?.[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid invoking array accessors during recovery

For a truncated array containing an accessor-backed index, indexOf performs ordinary property reads and invokes that accessor while searching for a later item or a missing value. This defeats the new data-property-only safety guarantee and can run or mutate application code merely because the user inspected an element; arrays should be searched through descriptors just like non-array containers.

Useful? React with 👍 / 👎.

: [];
if ((typeof value === "object" && value !== null) || typeof value === "function") {
origins.push(...(this.#valueOrigins.get(value as object) ?? []));
origins.push(...this.#originsForObject(value as object));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep recovery off the render-time capture path

When an instrumented render maps a large registered list, every row past the first 100 reaches this fallback during traceDerived capture, rather than when a person later inspects the row. Each tail object is found with an indexOf scan from the beginning of the container, so rendering an n-row list performs quadratic recovery work; the cited 10,000-row case can therefore block the application during rendering. Defer this scan until actual inspection or use a lookup strategy whose render-time work remains bounded.

AGENTS.md reference: AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

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.

1 participant