fix(transforms): prune destructured server values from the client artifact - #3862
fix(transforms): prune destructured server values from the client artifact#3862kojiwakayama wants to merge 2 commits into
Conversation
…ifact
`moduleScopeDeclarations` collected binding names only from plain-identifier
declarators. Any `VariableDeclaration` containing a destructuring pattern hit
the bail-out branch and was discarded from the collector entirely, so it could
never be pruned — a hook-only `const { a } = getEnv("X")` shipped to the
browser along with its import. The simple-identifier form was already pruned
correctly, so the leak was purely a function of how the value was bound.
Add `patternBindings`, which walks object/array patterns and returns the
identifiers in *binding* positions. Value positions are deliberately excluded:
`AssignmentPattern.right` (a default) and a computed `ObjectProperty.key` are
reads, and must stay visible to `referencedIdentifiers`. Adding them to the
`excluded` set in `dropUnusedModuleScopeBindings` would hide a live client read
and let the pass delete code the browser still needs — over-pruning is a worse
failure than the leak this closes. An unmodelled shape returns null and the
caller keeps the whole statement, preserving the existing fail-safe.
The consumer needed no change: `ModuleScopeDecl` already carried plural
`names`/`bindingIds`, and the liveness check already used `names.some(...)` /
`names.every(...)`. Only the collector never populated them.
`patternBindings` is exported for direct testing. The binding/value split is
not observable through the stage end to end today — a naive walk that also
collected default and computed-key identifiers produced byte-identical output
on every module shape probed, and left the end-to-end guards passing. Testing
the split at the seam is what actually pins it: both naive variants fail the
new `patternBindings` cases.
Replaces the pinned "documented limitation" test with the behaviour, and adds
object/nested/array/hole/rest coverage plus end-to-end guards for a sibling
binding the client reads, a default that references a client binding, and a
computed key that references one.
Refs veryfront/veryfront-issue-inbox#607
|
Warning Review limit reached
Next review available in: 22 minutes Limit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
📦 Client bundle boundary
A server module in a client graph aborts hydration in the browser. New leaks fail CI; known leaks are tracked in |
|
@coderabbitai review Context for the review, since the earlier run hit the rate limit before it started. What this changes. The property most worth attacking: value positions inside a pattern must stay visible to Known gap I would like a second opinion on. The binding/value split is not observable through this stage end to end today. I mutated the fix to the naive variant (collecting defaults and computed keys as bindings) and it produced byte-identical stage output on every shape I probed — 8 targeted differential shapes plus a 12-case end-to-end probe — with the end-to-end over-prune guards still passing. That is why Shapes already probed (all strip correctly, hook consuming the bound name): nested pattern as a default ( Areas I consider most likely to hide a defect: interaction with the scope-aware import-liveness walkers and the shadowing cases, and whether adding module-scope pattern bindings to |
🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- changed files ---'
git diff --name-status HEAD^ HEAD
printf '%s\n' '--- diff ---'
git diff --find-renames --unified=80 HEAD^ HEAD -- \
src/transforms/pipeline/stages/browser-server-exports-strip.ts \
src/transforms/pipeline/stages/browser-server-exports-strip_test.ts
printf '%s\n' '--- source outlines ---'
ast-grep outline src/transforms/pipeline/stages/browser-server-exports-strip.ts --items all --view expanded || true
printf '%s\n' '--- candidate test files ---'
fd -t f 'browser-server-exports-strip.*test|browser-server-exports-strip_test' src || trueLength of output: 15728 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- pattern and reference helpers ---'
sed -n '210,625p' src/transforms/pipeline/stages/browser-server-exports-strip.ts
printf '%s\n' '--- lexical reference walker ---'
sed -n '625,1090p' src/transforms/pipeline/stages/browser-server-exports-strip.ts
printf '%s\n' '--- module and import pruning ---'
sed -n '1180,1380p' src/transforms/pipeline/stages/browser-server-exports-strip.ts
printf '%s\n' '--- complete test diff ---'
git diff --find-renames --unified=60 HEAD^ HEAD -- \
src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
printf '%s\n' '--- test structure ---'
ast-grep outline src/transforms/pipeline/stages/browser-server-exports-strip.test.ts \
--items all --view expanded || true
printf '%s\n' '--- direct patternBindings tests ---'
rg -n -C 12 'patternBindings|computed|AssignmentPattern|destructur|default' \
src/transforms/pipeline/stages/browser-server-exports-strip.test.tsLength of output: 138300 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- strip pipeline order ---'
nl -ba src/transforms/pipeline/stages/browser-server-exports-strip.ts | sed -n '1225,1465p'
printf '%s\n' '--- relevant tests with line numbers ---'
nl -ba src/transforms/pipeline/stages/browser-server-exports-strip.test.ts | sed -n '810,940p'
printf '%s\n' '--- references to pruning calls ---'
rg -n -C 8 'dropUnusedModuleScopeBindings\(|dropUnusedImportBindings\(|hookReferencedIdentifiers\(' \
src/transforms/pipeline/stages/browser-server-exports-strip.tsLength of output: 915 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- strip pipeline order ---'
sed -n '1225,1465p' src/transforms/pipeline/stages/browser-server-exports-strip.ts
printf '%s\n' '--- relevant tests ---'
sed -n '810,940p' src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
printf '%s\n' '--- references to pruning calls ---'
rg -n -C 8 'dropUnusedModuleScopeBindings\(|dropUnusedImportBindings\(|hookReferencedIdentifiers\(' \
src/transforms/pipeline/stages/browser-server-exports-strip.tsLength of output: 20671
Test gap — Line 889 and Line 904: Both over-prune guards read Use this module shape instead: import { getEnv } from "veryfront";
import { fallback } from "../lib/client.js";
const { a = fallback } = getEnv("X");
export async function getServerData() { return { props: { a } }; }
export default function Page() { return null; }The correct collector keeps the destructuring declaration because its default expression reads const { [keyName]: a } = getEnv("X");Replace the 🧠 Learnings used
|
`lint:test-typecheck` rejected the new seam tests: indexing `program.body[0]`
and `declarations[0]` yields `T | undefined` under the strict baseline, and the
suite had only been run with `--no-check`.
Guard both with explicit throws that name the offending source, rather than
non-null assertions — a malformed fixture should fail with the input in the
message, not with a TypeError three lines later.
Also correct the over-prune block's comment. It read as though those three
end-to-end cases guard the binding/value split; they do not. A collector that
wrongly treats defaults and computed keys as bindings passes all three. Two
independent attempts to build a discriminating end-to-end case failed — a
differential run over both collectors produced byte-identical stage output on
every shape tried, including `const { a = fallback } = getEnv("X")` with no
client read of `fallback`, where the declaration is dropped either way because
`a` is unreferenced once the hook body is emptied, taking `fallback` with it.
The property is pinned in the `patternBindings` block, at the seam where it is
decidable; the comment now says so.
Refs veryfront/veryfront-issue-inbox#607
|
@coderabbitai Thanks — and agreed on the finding: the two over-prune guards do not discriminate the mutation. That is exactly the gap, and I had reached the same conclusion independently before opening this PR. But the proposed replacement does not discriminate it either. I ran your exact shapes against both collectors before acting on the suggestion, and the outputs are byte-identical. Your suggested module, verbatim: import { getEnv } from "veryfront";
import { fallback } from "../lib/client.js";
const { a = fallback } = getEnv("X");
export async function getServerData() { return { props: { a } }; }
export default function Page() { return null; }Correct collector, actual stage output: export async function getServerData() {
throw new Error("server-only");
}
export default function Page() {
return null;
}Both imports are gone, and the destructuring declaration with them — not kept, as the prediction expected. Same output from the naive collector, and the same for the The prediction was that "the correct collector keeps the destructuring declaration because its default expression reads
The default's read only matters while the declaration survives, and the declaration cannot survive in a shape where the hook is its only consumer — which is precisely the shape needed for the leak. That is the asymmetry that makes this property undecidable end to end here, and it is why I moved the pin to the seam rather than writing a better module fixture. I have pushed
Your review is now one of those two attempts, recorded in the comment. That is more useful than a fixture that looks like a guard and is not. The discriminating tests remain the If you can construct a shape where the declaration survives (a sibling binding the client reads) and the default's read is load-bearing for a third binding's liveness, that would discriminate and I will add it. I probed that direction — including |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- requested commit ---'
git show --stat --oneline --decorate --no-renames dbf3db42e
printf '%s\n' '--- requested commit test diff ---'
git show --format=fuller --find-renames --unified=30 dbf3db42e -- \
src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
printf '%s\n' '--- pruning helpers outline ---'
ast-grep outline src/transforms/pipeline/stages/browser-server-exports-strip.ts \
--items all --view expanded | sed -n '1,240p'
printf '%s\n' '--- pruning and hook-removal order ---'
rg -n -C 12 \
'function dropUnusedModuleScopeBindings|dropUnusedModuleScopeBindings\(|hookClosure|remove.*hook|replace.*hook|remove.*server' \
src/transforms/pipeline/stages/browser-server-exports-strip.tsLength of output: 25355
I verified
The updated comment correctly states that the end-to-end cases do not discriminate the binding/value classification. The direct I withdraw the replacement-fixture recommendation. The original finding about the misleading guard description is addressed by the verified comment update. 🐇 ✏️ Learnings added
If you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! You are interacting with an AI system. |
Superseded by #3861 — closing#3861 merged as Its implementation is the one to keep. It reuses the existing I measured
That residual is recorded on veryfront/veryfront-issue-inbox#607, which I have reopened — it had been closed as completed. I also tried to land the One thing worth taking from this branch
No hard feelings toward the duplicate — the residual measurement and the withdrawn follow-up are the useful output. |
Description
A hook-only destructured server value shipped to the browser.
moduleScopeDeclarationscollected binding names only from plain-identifier declarators; anyVariableDeclarationcontaining a destructuring pattern hit the bail-out branch and was discarded from the collector entirely, so it could never be pruned:The simple-identifier form (
const a = getEnv("X")) was already pruned correctly, so the leak was purely a function of how the value was bound. It was pinned in the suite as a documented limitation; this replaces that pin with the behaviour.The fix
patternBindings()walks object/array patterns and returns identifiers in binding positions. Value positions are deliberately excluded, because they are reads:{ a },{ k: a }a{ [expr]: a }aexpr{ a = def }adef{ ...rest },[...rest]rest[a, , b]a,b{ a: { b } }bAdding a value-position identifier to the
excludedset indropUnusedModuleScopeBindingswould hide a live client read and let the pass delete code the browser still needs. Over-pruning is a worse failure than the leak this closes, so an unmodelled shape returnsnulland the caller keeps the whole statement — preserving the existing fail-safe.The consumer needed no change.
ModuleScopeDeclalready carried pluralnames/bindingIds, and the liveness check already usednames.some(...)/names.every(...). Only the collector never populated them.Related Issue(s)
Refs veryfront/veryfront-issue-inbox#607
Scope note: this is deliberately only the destructured-value leak. It does not touch the deferred-execution classifier, the intrinsic-tampering analysis, or the strip/compile ordering — those live in #3846 and #3855 and are tracked on veryfront/veryfront-issue-inbox#605.
Type of Change
Checklist
deno fmt --check,deno lint,deno checkcleanWhy
patternBindingsis exportedBecause the binding/value split is not observable through this stage end to end today, and I would rather say so than imply coverage that does not exist.
I mutated the fix to the naive version — collecting default and computed-key identifiers as bindings — and it produced byte-identical stage output on every module shape I probed (8 targeted differential shapes, plus a 12-case end-to-end probe). The end-to-end over-prune guards in this PR passed against the naive variant too. As written, they do not discriminate it.
So the invariant is pinned at the seam where it lives. Both naive variants now fail:
patternBindingstestsAssignmentPattern.righttreated as a bindingObjectProperty.keytreated as a bindingThe end-to-end guards are still worth keeping — they pin real behaviour — but the seam tests are what actually protect the property if
hookClosureconstruction changes later.Verification
Independent probe against the real stage (not the test helper), asserting the secret and the server import are absent from the emitted artifact — 12/12, including the three over-prune guards. Notably
{ a, b }withblive on the client correctly keeps the server value: under-pruning, never over-pruning.Size
+258 / -12across two files.