Skip to content

feat: keep provenance across destructured primitives - #60

Merged
stackloomdev merged 1 commit into
mainfrom
feat/destructured-alias-provenance
Jul 28, 2026
Merged

stackloomdev merged 1 commit into
mainfrom
feat/destructured-alias-provenance

Conversation

@stackloomdev

Copy link
Copy Markdown
Owner

Prompted by the first comment on the r/reactjs launch post, which singled out one capability out of everything in the post:

Tracing a value back to the API response, i feel could save a lot of debugging time

That chain had a gap exactly where real code lives.

The gap

The same value, reached two ways, gave two different answers:

<button disabled={order.status !== "paid"}>   // confirmed origin: GET /api/orders/4821
const { status } = order;
<button disabled={status !== "paid"}>          // no hint at all

wrapCapturedMember emits the root object plus an access path, and the runtime walks it to the deepest recorded origin. wrapCapturedIdentifier consulted 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 + accessPath a direct member read would have.

Source Resolves to
const { status } = order order + status
const { status: current } = order order + status
const { status = "pending" } = order order + status
const [first] = payload.items payload + items[0]
const {data}=res; const {order}=data; const {status}=order res + data.order.status

The last row is the point: chained destructuring folds back to the response root.

Two refusals

Consistent with reporting nothing rather than guessing:

  • Reassignable root emits no path. originValue is 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.
  • Root name is re-resolved at the use site. A shadowing declaration between the alias and the read would otherwise point originValue at an unrelated object.

Scope

The runtime is unchanged — this emits a hint shape it already resolves (runtime.test.ts:1341 covers that path). SWC users get this too: vite-plugin always runs its own Babel transform, and @vitejs/plugin-react-swc only handles JSX and Fast Refresh.

Verification

  • 4 new plugin tests. Confirmed they fail with the collector disabled — 3 positive tests go red, so they test the change rather than passing vacuously.
  • pnpm typecheck and pnpm test: 24/24 tasks.
  • Forced (uncached) rebuild, then verify:performance: budgets pass.
  • React 19 Playwright e2e: 12/12.

Not in this PR

Two further gaps found while reading the chain, left for separate work:

  1. staticMemberInfo returns null for non-literal computed access (data[key].status, row[columnId]), which is common in table code.
  2. #registerOrigin bounds 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

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]>
@stackloomdev
stackloomdev merged commit 3af5636 into main Jul 28, 2026
11 checks passed
@stackloomdev
stackloomdev deleted the feat/destructured-alias-provenance branch July 28, 2026 07:10

@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: 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".

Comment on lines +424 to +425
if (patternPath.isAssignmentPattern()) {
recordAliasPattern(patternPath.get("left") as NodePath, source, state);

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 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;

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 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 👍 / 👎.

Comment on lines +413 to +416
recordAliasPattern(element as NodePath, {
...source,
accessPath: joinAccessPath(source.accessPath, `[${index}]`),
}, state);

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 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 👍 / 👎.

Comment on lines +786 to +788
...(aliasMetadata
? { originValue: t.identifier(aliasMetadata.rootName), accessPath: aliasMetadata.accessPath }
: {}),

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 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 👍 / 👎.

Comment on lines +369 to +372
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;

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 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 👍 / 👎.

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