fix(transforms): strip server-only data hooks from the client artifact (depends on #2999) - #3002
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4d1a8665c5
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const stripped = stripFunctionDeclaration(result, name, inert) ?? | ||
| stripVariableDeclaration(result, name, inert); |
There was a problem hiding this comment.
Only strip hooks that are actually exported
When a page has a client helper named getServerData, getStaticData, or getStaticPaths that is not exported, this still strips the first matching declaration because it never checks the module's export list. For example, function getServerData() { ... } called by the default component now throws during hydration even though it was not a Veryfront data hook. Please gate the replacement on an actual named export before modifying the declaration.
Useful? React with 👍 / 👎.
| if (isInert(inert, i)) continue; | ||
| const ch = code[i]; | ||
| if (ch === "{") depth++; | ||
| else if (ch === "}") { |
There was a problem hiding this comment.
Skip regex literals before counting hook braces
When an exported hook body contains a regex literal with a brace, such as /\}/, the scanner does not mark regex literals inert, so these brace counters can treat the regex character as the end of the function body. The replacement then cuts the body early and can leave statements outside the stub, producing an invalid browser module instead of safely stripping the hook.
Useful? React with 👍 / 👎.
| if (bindings.some((binding) => referenced.has(binding))) continue; | ||
| removals.push({ start: imp.ss, end: imp.se }); |
There was a problem hiding this comment.
Preserve side-effectful value imports
When a binding is only referenced from the stripped hook, this removes the whole import declaration. Static imports with bindings still execute module top-level code, so a page like import { load } from './client-init-and-data.ts' loses any client registration or polyfill in that module even though only load was used by getServerData. Because this pass has no side-effect information, it should not delete value imports unless it can prove or mark them as server-only.
Useful? React with 👍 / 👎.
28484d5 to
49b70d8
Compare
4d1a866 to
aea6a67
Compare
kwakayama
left a comment
There was a problem hiding this comment.
Score: 72/100
Requesting changes. The goal is valid, but the new browser server-export stripping stage is too heuristic for a compiler transform that is meant to prevent server-only imports from leaking into the browser artifact.
Blocking concern:
src/transforms/pipeline/stages/browser-server-exports-strip.tsremoves imports by regex-counting identifiers in the remaining code. That count includes strings and comments, so an import used only by a stripped hook can be retained if the binding name appears in inert text. In that case the server-only dependency chain can still ship to the browser and preserve the hydration/link failure this PR is meant to remove.
Please use an AST/scope-aware identifier walk, or at minimum exclude strings/comments/templates from the reference scan, and add regressions for comment-only and string-only references. I would also like coverage for same-name non-exported declarations and future hook forms, since this stage currently relies on hand-rolled source scanning.
aea6a67 to
01fe41d
Compare
49b70d8 to
d2f9260
Compare
01fe41d to
7c2f401
Compare
|
Addressed. The stage no longer scans source text at all: it now runs on the repo's The hand-rolled scanner is gone ( Regressions added for exactly what you asked for:
Two other findings from the same review round are fixed here as well, since they had the same root cause. An unexported declaration is never emptied, and an import that falls out of use is reduced to a side-effect import rather than deleted, because this pass has no side-effect information about the module it points at. Node built-ins are the exception, since in the browser they resolve to a noop polyfill. That matches what esbuild does with an external import whose bindings go unused. One honest caveat: a JSX attribute name ( Verification: stage suite 42 steps passing, |
d2f9260 to
2b6e6ce
Compare
7c2f401 to
5b7f8ed
Compare
kwakayama
left a comment
There was a problem hiding this comment.
Follow-up after fixes: approving. The implementation now uses parsed AST references instead of regex/comment/string matching, and inert text regressions are covered. Local verification: \running 1 test from ./src/transforms/pipeline/stages/browser-server-exports-strip.test.ts
browser-server-exports-strip ...
emptying server-only hooks ...
empties an exported async function declaration body ... ok (10ms)
empties a directly exported function declaration ... ok (1ms)
replaces an exported arrow initialiser ... ok (1ms)
handles all three hooks in one module ... ok (1ms)
leaves a module without server hooks untouched ... ok (0ms)
does not treat a same-named string as a declaration ... ok (1ms)
leaves a non-exported function of the same name alone ... ok (1ms)
leaves a local declaration that is only aliased to a hook name alone ... ok (0ms)
empties a hook declared before a separate export clause ... ok (1ms)
keeps client code that follows a regular expression containing braces ... ok (1ms)
keeps client code after a division that looks like a regular expression ... ok (1ms)
handles a template literal with braces and interpolation ... ok (1ms)
handles minified single-line input ... ok (1ms)
handles TSX with types and JSX ... ok (2ms)
leaves a module that does not parse unchanged ... ok (1ms)
emptying server-only hooks ... ok (24ms)
import bindings ...
reduces an unreferenced project import to a side-effect import ... ok (1ms)
removes an unreferenced node builtin import outright ... ok (0ms)
keeps an import that the client still references ... ok (1ms)
keeps an import when only one of its bindings is used ... ok (0ms)
keeps a bare side-effect import untouched ... ok (1ms)
keeps a default import the client renders with ... ok (0ms)
reduces a namespace import the client no longer uses ... ok (1ms)
does not count a matching property name as a reference ... ok (0ms)
counts a computed property access as a reference ... ok (1ms)
counts a JSX component as a reference ... ok (0ms)
import bindings ... ok (5ms)
inert text is not a reference ...
does not count a line comment mention ... ok (0ms)
does not count a block comment mention ... ok (1ms)
does not count a string literal mention ... ok (0ms)
does not count a template literal mention ... ok (1ms)
counts a template literal interpolation, which is real code ... ok (0ms)
does not count a JSX text node mention ... ok (0ms)
inert text is not a reference ... ok (4ms)
declaration forms ...
leaves a private same-named declaration alone beside a real hook ... ok (0ms)
leaves a local aliased to a hook name alone beside a real hook ... ok (0ms)
empties a hook declared as an exported function expression ... ok (0ms)
empties a hook declared as a directly exported async function ... ok (0ms)
declaration forms ... ok (1ms)
plugin ...
drops the server-only import chain from the client artifact ... ok (1ms)
does not run for the ssr target ... ok (0ms)
plugin ... ok (1ms)
browser-server-exports-strip ... ok (37ms)
ok | 1 passed (42 steps) | 0 failed (39ms). Score: 94/100. Next step: merge after base stack and checks are green.
|
Clean follow-up after the approval above: Score: 94/100. Verification:
Next step: merge after the base stack and refreshed checks are green. |
5b7f8ed to
4fedb43
Compare
2b6e6ce to
035e379
Compare
983734c to
e54e86e
Compare
498bd02 to
884f941
Compare
e54e86e to
bc44e25
Compare
kwakayama
left a comment
There was a problem hiding this comment.
Follow-up approval after #3001 merged and the stack was rebased onto main.
Score: 94/100.
Rationale: implementation still uses parsed AST references instead of regex/comment/string matching; inert text regressions remain covered after the rebase.
Verification:
- deno task audit
- combined targeted regression suite: 21 tests, 334 steps, 0 failed
Next step: wait for refreshed GitHub checks, then merge when green.
The browser artifact is compiled from the same source file as the server one, so `getServerData`/`getStaticData`/`getStaticPaths` bodies shipped to the client along with everything they import. A page whose loader reached `node:crypto` therefore linked against the node-builtin noop polyfill and hydration died with "does not provide an export named 'createHash'". esbuild cannot fix this for us: in transform mode (as opposed to bundle mode) it never drops an import, because it cannot prove the module is side-effect free. So the browser pipeline gains a stage that empties the hooks' bodies, keeping their bindings valid for any surviving export clause, and then drops the import bindings they were the last user of. The stage works on the `CodeParser` AST, for the reason `rendering/rsc/export-extractor.ts` already does, and as the equivalent passes in Next.js (SWC) and React Router (Babel) do: text matching cannot tell an exported hook from a private function of the same name, and counts braces inside regular expression literals. An import that falls out of use is reduced to a side-effect import rather than deleted, because this pass has no side-effect information about the module it points at. Node built-ins are the exception, since in the browser they resolve to a noop polyfill with nothing to preserve. This matches what esbuild does with an external import whose bindings go unused. Fixes bug 2 of the reproducer matrix.
bc44e25 to
7f73c67
Compare
kwakayama
left a comment
There was a problem hiding this comment.
Follow-up approval after rebasing #3002 onto the latest main.
Score: 94/100.
Rationale: the parser-backed transform and inert-text regressions are unchanged by the rebase, and the stack validated locally on the rebased head.
Verification:
- deno task audit
- combined targeted regression suite: 21 tests, 334 steps, 0 failed
Next step: wait for refreshed GitHub checks and the required reviewer gate, then auto-merge.
Summary
The browser artifact is compiled from the same source file as the server one, so
getServerData/getStaticData/getStaticPathsbodies shipped to the client along with everything they import. A page whose loader reachednode:cryptotherefore linked against the node-builtin noop polyfill and hydration died withdoes not provide an export named 'createHash'— for code that never runs in the browser at all.esbuild cannot solve this for us: in transform mode (as opposed to bundle mode) it never drops an import, because it cannot prove the module is side-effect free. Confirmed directly:
So the browser pipeline gains a stage that empties the hooks' bodies and then removes the imports left unreferenced.
Why empty the bodies rather than delete the declarations: the binding stays valid for any surviving
exportclause, sotypeof mod.getServerData === "function"still holds for the client router's data-fetching check. Deleting the declaration would break that.Every step is conservative — anything that cannot be parsed with confidence is left exactly as it was, and side-effect-only imports (
import "./x") are never removed.Reproduction
d-server-static.tsxsrc/transforms/pipeline/stages/browser-server-exports-strip.test.tsTest evidence
15 new cases covering function declarations,
var-assigned arrows, all three hooks, aliased/namespace/default/side-effect imports, and the "leave it alone" paths.Wider suite:
deno task test:unit→ 2525 passed | 0 failed.SSR evidence
/test/d-server-staticstill renders server-side (the hook is untouched on the SSR path), and the server-only import is gone from the client artifact:Client evidence
Related
Chain: this PR is part of a 13-PR chain fixing the bugs catalogued in
veryfront-router-testing.
Its base is the previous PR in the chain, so the diff shows only this fix.
The root of the chain is #2999 (
fix/ssr-lazy-import-graceful-degrade) — merge #2999 first, thenrebase the chain onto
main.Regression gate:
deno task test:unit→ 2525 passed | 0 failed;deno task lint,deno task fmt:checkanddeno task typecheckall clean. The reproducer's full 56-routematrix (
ROUTES.txt+sweep.sh) was re-run after every fix: 7 routes improved, 0 regressed.A 46-route Chromium hydration sweep (
client-sweep.mjs) backs the client-side claims.