feat: keep provenance across destructured primitives - #60
Conversation
A value reached two ways produced two different answers. `order.status`
read directly in JSX resolved to a confirmed network origin, because the
plugin emits the root object plus an access path and the runtime walks
it. The same value read as `const { status } = order` resolved to almost
nothing: `wrapCapturedIdentifier` consulted only the state, prop,
storage, and derived tables, so a destructured binding carried no hint,
and primitives have no identity to recover one from. It fell back to the
50ms recent-primitive heuristic, which by design refuses to answer when
more than one recent source matches.
Destructuring is the common way to write this, so the strongest part of
the evidence chain — reaching the response that produced the state —
degraded exactly where real code lives.
Records a local alias table at compile time: a binding that reads a
fixed path out of another binding now emits the same originValue and
accessPath a direct member read would have emitted. Chains compose, so
three levels of destructuring still resolve to the response root.
Renamed, defaulted, and array patterns carry the path too.
Two refusals, consistent with reporting nothing over guessing:
- A reassignable root emits no path. It would be re-read at capture
time, so the recorded path could describe a different object.
- The root name is re-resolved in the use-site scope. A shadowing
declaration would otherwise point originValue at another object.
The runtime is unchanged; this produces a hint shape it already
resolves. Verified the new tests fail with the collector disabled.
Co-Authored-By: Claude Opus 5 <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 183a51c90c
ℹ️ 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".
| if (patternPath.isAssignmentPattern()) { | ||
| recordAliasPattern(patternPath.get("left") as NodePath, source, state); |
There was a problem hiding this comment.
Skip alias hints when a destructuring default wins
When the source property is undefined, as in const { status = "pending" } = order, this records status as order.status even though the captured value came from the local fallback. The runtime follows originValue and accessPath without comparing the resolved property to the captured value, so a network-originated order produces a confirmed but false causal chain for the literal "pending"; either omit the hint when the fallback wins or instrument which branch supplied the value.
AGENTS.md reference: AGENTS.md:L9-L9
Useful? React with 👍 / 👎.
|
|
||
| function propertyKeySegment(key: t.Node, computed: boolean): string | null { | ||
| if (!computed && t.isIdentifier(key)) return key.name; | ||
| if (t.isStringLiteral(key)) return computed ? `[${JSON.stringify(key.value)}]` : key.value; |
There was a problem hiding this comment.
Encode non-identifier string keys in bracket notation
For an uncomputed string key such as const { "status.current": current } = order, returning the raw key emits accessPath: "status.current", which the runtime parses as two nested properties rather than the single literal property read by destructuring; keys such as "status-current" instead make the path unparsable and lose the hint. Emit bracket-quoted notation for string keys that are not plain identifiers so the evidence path identifies the property actually consumed.
AGENTS.md reference: AGENTS.md:L9-L9
Useful? React with 👍 / 👎.
| recordAliasPattern(element as NodePath, { | ||
| ...source, | ||
| accessPath: joinAccessPath(source.accessPath, `[${index}]`), | ||
| }, state); |
There was a problem hiding this comment.
Do not model every array pattern as numeric property reads
An array destructuring pattern consumes the source's iterator; it does not necessarily read numeric properties. When payload.items is a Set, generator result, or custom iterable, const [first] = payload.items still records items[0], and the runtime can append that nonexistent path to the root's confirmed network origin. Only emit indexed paths when array-style numeric access is known to match iteration, or otherwise decline the hint.
AGENTS.md reference: AGENTS.md:L9-L9
Useful? React with 👍 / 👎.
| ...(aliasMetadata | ||
| ? { originValue: t.identifier(aliasMetadata.rootName), accessPath: aliasMetadata.accessPath } | ||
| : {}), |
There was a problem hiding this comment.
Carry root binding metadata through aliases
The new hint preserves only the root value and path, while state, prop, storage, and derived metadata are still looked up on the destructured binding itself. For example, function Price(props) { const { amount } = props; return <strong>{amount}</strong>; } loses the explicit Price.props.amount origin that direct props.amount emits because traceProp does not register the props object as a value origin; destructuring an object-valued state similarly drops its state ID and update correlation. Resolve these metadata tables against the alias root and compose the alias path when building the capture arguments.
AGENTS.md reference: AGENTS.md:L9-L9
Useful? React with 👍 / 👎.
| const binding = scope.getBinding(rootNode.name); | ||
| // A reassignable root would be re-read at capture time, so the recorded path | ||
| // could describe a different object than the one the alias was taken from. | ||
| if (!binding || !binding.constant) return null; |
There was a problem hiding this comment.
Snapshot the source instead of rewalking a mutable path
Checking binding.constant only guarantees that the root identifier is not reassigned; it does not freeze the object graph used by a folded alias. In const { data } = response; response.data = replacement; const { status } = data; return <span>{status}</span>, the emitted hint re-reads response.data.status at capture time even though status came from the detached original data; if replacement has its own registered origin, the runtime selects that unrelated deeper origin and reports a false confirmed chain. Preserve the declaration-time source identity or decline folding when the path can no longer be proven stable.
AGENTS.md reference: AGENTS.md:L9-L9
Useful? React with 👍 / 👎.
Prompted by the first comment on the r/reactjs launch post, which singled out one capability out of everything in the post:
That chain had a gap exactly where real code lives.
The gap
The same value, reached two ways, gave two different answers:
wrapCapturedMemberemits the root object plus an access path, and the runtime walks it to the deepest recorded origin.wrapCapturedIdentifierconsulted only the state, prop, storage, and derived tables — there was no table for "this binding reads a fixed path out of that object". So a destructured binding carried no hint, and since primitives have no identity, there was nothing to recover one from. It fell back to the 50ms recent-primitive heuristic, which by design refuses to answer when more than one recent source matches the value.Destructuring is how most React code is written, so the strongest link in the evidence chain degraded in the most common case.
Change
A compile-time local alias table. A binding that reads a fixed path out of another binding now emits the same
originValue+accessPatha direct member read would have.const { status } = orderorder+statusconst { status: current } = orderorder+statusconst { status = "pending" } = orderorder+statusconst [first] = payload.itemspayload+items[0]const {data}=res; const {order}=data; const {status}=orderres+data.order.statusThe last row is the point: chained destructuring folds back to the response root.
Two refusals
Consistent with reporting nothing rather than guessing:
originValueis re-read at capture time, so a rebound root could make the recorded path describe a different object. Guarded by a test that asserts the path is absent.originValueat an unrelated object.Scope
The runtime is unchanged — this emits a hint shape it already resolves (
runtime.test.ts:1341covers that path). SWC users get this too:vite-pluginalways runs its own Babel transform, and@vitejs/plugin-react-swconly handles JSX and Fast Refresh.Verification
pnpm typecheckandpnpm test: 24/24 tasks.verify:performance: budgets pass.Not in this PR
Two further gaps found while reading the chain, left for separate work:
staticMemberInforeturns null for non-literal computed access (data[key].status,row[columnId]), which is common in table code.#registerOriginbounds recording at depth 5 and 100 properties per object. Reasonable defaults, but when the budget is why evidence is missing, that is currently indistinguishable from "no evidence exists". Saying which one it is would fit the project's honesty rule and is cheap.🤖 Generated with Claude Code