-
-
Notifications
You must be signed in to change notification settings - Fork 238
fix(rsc): handle shadowed local declarations in "use server" closure binding #1151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
3417f65
ae001b0
64ea63a
3c281db
049c4de
9741514
6ec6480
44f3a59
44d3fc9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,5 @@ | ||
| import { walk } from 'estree-walker' | ||
| import { analyze } from 'periscopic' | ||
| import { parseAstAsync } from 'vite' | ||
| import { describe, expect, it } from 'vitest' | ||
| import { transformHoistInlineDirective } from './hoist' | ||
|
|
@@ -448,6 +450,240 @@ export async function kv() { | |
| `) | ||
| }) | ||
|
|
||
| // periscopic misclassifies block-scoped declarations inside a "use server" | ||
| // function body as outer-scope closure variables when the same name exists in | ||
| // an enclosing scope. The hoist transform then injects a duplicate `const` | ||
| // declaration (from decryptActionBoundArgs) which causes a SyntaxError at | ||
| // runtime. | ||
| describe('local declaration shadows outer binding', () => { | ||
| it('const shadows outer variable', async () => { | ||
| // `cookies` is declared in the outer scope AND re-declared with const | ||
| // inside the server action. periscopic sees it as a closure ref, but it | ||
| // is NOT — the server action owns its own `cookies`. | ||
| const input = ` | ||
| function buildAction(config) { | ||
| const cookies = getCookies(); | ||
|
|
||
| async function submitAction(formData) { | ||
| "use server"; | ||
| const cookies = formData.get("value"); | ||
| return doSomething(config, cookies); | ||
| } | ||
|
|
||
| return submitAction; | ||
| } | ||
| ` | ||
| expect(await testTransform(input)).toMatchInlineSnapshot(` | ||
| " | ||
| function buildAction(config) { | ||
| const cookies = getCookies(); | ||
|
|
||
| const submitAction = /* #__PURE__ */ $$register($$hoist_0_submitAction, "<id>", "$$hoist_0_submitAction").bind(null, config); | ||
|
|
||
| return submitAction; | ||
| } | ||
|
|
||
| ;export async function $$hoist_0_submitAction(config, formData) { | ||
| "use server"; | ||
| const cookies = formData.get("value"); | ||
| return doSomething(config, cookies); | ||
| }; | ||
| /* #__PURE__ */ Object.defineProperty($$hoist_0_submitAction, "name", { value: "submitAction" }); | ||
| " | ||
| `) | ||
| expect(await testTransform(input, { encode: true })) | ||
| .toMatchInlineSnapshot(` | ||
| " | ||
| function buildAction(config) { | ||
| const cookies = getCookies(); | ||
|
|
||
| const submitAction = /* #__PURE__ */ $$register($$hoist_0_submitAction, "<id>", "$$hoist_0_submitAction").bind(null, __enc([config])); | ||
|
|
||
| return submitAction; | ||
| } | ||
|
|
||
| ;export async function $$hoist_0_submitAction($$hoist_encoded, formData) { | ||
| const [config] = __dec($$hoist_encoded); | ||
| "use server"; | ||
| const cookies = formData.get("value"); | ||
| return doSomething(config, cookies); | ||
| }; | ||
| /* #__PURE__ */ Object.defineProperty($$hoist_0_submitAction, "name", { value: "submitAction" }); | ||
| " | ||
| `) | ||
| }) | ||
|
|
||
| it('const shadows function parameter', async () => { | ||
| // the outer `cookies` binding comes from a function parameter, not a | ||
| // variable declaration — collectOuterNames must handle params too. | ||
| const input = ` | ||
| function buildAction(cookies) { | ||
| async function submitAction(formData) { | ||
| "use server"; | ||
| const cookies = formData.get("value"); | ||
| return cookies; | ||
| } | ||
|
|
||
| return submitAction; | ||
| } | ||
| ` | ||
| expect(await testTransform(input)).toMatchInlineSnapshot(` | ||
| " | ||
| function buildAction(cookies) { | ||
| const submitAction = /* #__PURE__ */ $$register($$hoist_0_submitAction, "<id>", "$$hoist_0_submitAction"); | ||
|
|
||
| return submitAction; | ||
| } | ||
|
|
||
| ;export async function $$hoist_0_submitAction(formData) { | ||
| "use server"; | ||
| const cookies = formData.get("value"); | ||
| return cookies; | ||
| }; | ||
| /* #__PURE__ */ Object.defineProperty($$hoist_0_submitAction, "name", { value: "submitAction" }); | ||
| " | ||
| `) | ||
| }) | ||
|
|
||
| it('destructured local declaration not included in bound args', async () => { | ||
| // `const { cookies } = ...` — the name comes from a destructuring pattern, | ||
| // not a plain Identifier declarator. Must still be excluded from bindVars. | ||
| const input = ` | ||
| function buildAction(config) { | ||
| const cookies = getCookies(); | ||
|
|
||
| async function submitAction(formData) { | ||
| "use server"; | ||
| const { cookies } = parseForm(formData); | ||
| return doSomething(config, cookies); | ||
| } | ||
|
|
||
| return submitAction; | ||
| } | ||
| ` | ||
| expect(await testTransform(input)).toMatchInlineSnapshot(` | ||
| " | ||
| function buildAction(config) { | ||
| const cookies = getCookies(); | ||
|
|
||
| const submitAction = /* #__PURE__ */ $$register($$hoist_0_submitAction, "<id>", "$$hoist_0_submitAction").bind(null, config); | ||
|
|
||
| return submitAction; | ||
| } | ||
|
|
||
| ;export async function $$hoist_0_submitAction(config, formData) { | ||
| "use server"; | ||
| const { cookies } = parseForm(formData); | ||
| return doSomething(config, cookies); | ||
| }; | ||
| /* #__PURE__ */ Object.defineProperty($$hoist_0_submitAction, "name", { value: "submitAction" }); | ||
| " | ||
| `) | ||
| }) | ||
|
|
||
| it('inner accessing both outer and own names', async () => { | ||
| const input = ` | ||
| function outer() { | ||
| const cookies = 0; | ||
| async function action() { | ||
| "use server"; | ||
| if (condition) { | ||
| const cookies = 1; // block-scoped to the if | ||
| process(cookies); | ||
| } | ||
| return cookies; // refers to OUTER cookies — needs binding | ||
| } | ||
| } | ||
| ` | ||
| expect(await testTransform(input)).toMatchInlineSnapshot(` | ||
| " | ||
| function outer() { | ||
| const cookies = 0; | ||
| const action = /* #__PURE__ */ $$register($$hoist_0_action, "<id>", "$$hoist_0_action").bind(null, cookies); | ||
| } | ||
|
|
||
| ;export async function $$hoist_0_action(cookies) { | ||
| "use server"; | ||
| if (condition) { | ||
| const cookies = 1; // block-scoped to the if | ||
| process(cookies); | ||
| } | ||
| return cookies; // refers to OUTER cookies — needs binding | ||
| }; | ||
| /* #__PURE__ */ Object.defineProperty($$hoist_0_action, "name", { value: "action" }); | ||
| " | ||
| `) | ||
| }) | ||
|
|
||
| // TODO: not working | ||
| it('inner has own block then shadows', async () => { | ||
| const input = ` | ||
| function outer() { | ||
| const cookie = 0; | ||
| async function action() { | ||
| "use server"; | ||
| if (cond) { | ||
| const cookie = 1; | ||
| return cookie; // refers to if-block's cookie | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+618
to
+630
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i was doing some experimenting with this one and managed to get it working by walking through the function's body and checking if there is a reference to the variable that isn't shadows in the current scope. This is what my const bindVars = [...scope.references].filter((ref) => {
// own parameters are available in a hoisted function
if (ownParams.has(ref)) {
return false
}
const owner = scope.find_owner(ref)
if (!(owner && owner !== scope && owner !== analyzed.scope)) {
return false
}
let shouldBeBound = false
let shadowDepth = 0
walk(node.body, {
enter(node) {
const innerScope = analyzed.map.get(node)
if (
innerScope?.declarations.has(ref) &&
'body' in node &&
node !== node.body
) {
// if the node declares a variable with the same name and it's not the function
// body itself, it shadows the reference we're looking for
shadowDepth++
}
if (
shadowDepth === 0 &&
node.type === 'Identifier' &&
node.name === ref
) {
shouldBeBound = true
this.skip()
}
},
leave(node) {
const innerScope = analyzed.map.get(node)
if (
innerScope?.declarations.has(ref) &&
'body' in node &&
node !== node.body
) {
shadowDepth--
}
},
})
return shouldBeBound
})
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was thinking maybe we can re-run periscopic |
||
| ` | ||
| expect(await testTransform(input)).toMatchInlineSnapshot(` | ||
| " | ||
| function outer() { | ||
| const cookie = 0; | ||
| const action = /* #__PURE__ */ $$register($$hoist_0_action, "<id>", "$$hoist_0_action").bind(null, cookie); | ||
| } | ||
|
|
||
| ;export async function $$hoist_0_action(cookie) { | ||
| "use server"; | ||
| if (cond) { | ||
| const cookie = 1; | ||
| return cookie; // refers to if-block's cookie | ||
| } | ||
| }; | ||
| /* #__PURE__ */ Object.defineProperty($$hoist_0_action, "name", { value: "action" }); | ||
| " | ||
| `) | ||
| }) | ||
| }) | ||
|
|
||
| // TODO: is this supposed to work? probably yes | ||
| it('self-referencing function', async () => { | ||
| const input = ` | ||
| function Parent() { | ||
| const count = 0; | ||
|
|
||
| async function recurse(n) { | ||
| "use server"; | ||
| if (n > 0) return recurse(n - 1); | ||
| return count; | ||
| } | ||
|
|
||
| return recurse; | ||
| } | ||
| ` | ||
|
Comment on lines
+652
to
+666
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this one probably needs to filter the declaration name out of the bind variables, and then inject a line inside the hoisted function the just re-declares the original function name with the value being the hoisted function. Possibly something like ended up going and playing around with it after i started writing this. in the then further down below where the function declaration is appended, i did the following: if (declName && scope.references.has(declName)) {
const boundArgs =
bindVars.length > 0 ? `${bindVars.join(', ')}, ` : ''
const directiveNode = node.body.body[0]!
output.appendLeft(
directiveNode.end,
`\nconst ${declName} = (...$$args) => ${newName}(${boundArgs}...$$args);`,
)
}basically just creates a new function declaration that calls the hoisted function with the bound arguments and optionally any other arguments the user calls the function with. i would caveat that with that i don't think it would work like that if the server action was encrypted though - would probably need doing differently in that case. also didn't try it out with extra levels of nested scopes or shadowing. |
||
| expect(await testTransform(input)).toMatchInlineSnapshot(` | ||
| " | ||
| function Parent() { | ||
| const count = 0; | ||
|
|
||
| const recurse = /* #__PURE__ */ $$register($$hoist_0_recurse, "<id>", "$$hoist_0_recurse").bind(null, count, recurse); | ||
|
|
||
| return recurse; | ||
| } | ||
|
|
||
| ;export async function $$hoist_0_recurse(count, recurse, n) { | ||
| "use server"; | ||
| if (n > 0) return recurse(n - 1); | ||
| return count; | ||
| }; | ||
| /* #__PURE__ */ Object.defineProperty($$hoist_0_recurse, "name", { value: "recurse" }); | ||
| " | ||
| `) | ||
| }) | ||
|
|
||
| it('no ending new line', async () => { | ||
| const input = `\ | ||
| export async function test() { | ||
|
|
@@ -464,3 +700,122 @@ export async function test() { | |
| `) | ||
| }) | ||
| }) | ||
|
|
||
| // TODO: report upstream | ||
| // https://github.com/Rich-Harris/periscopic/ | ||
| describe('periscopic behavior', () => { | ||
| it('re-export confuses scopes', async () => { | ||
| // periscopic bug: `export { x } from "y"` creates a block scope with `x` | ||
| // as a declaration, which shadows the real module-level import. | ||
| // `find_owner` then returns that intermediate scope instead of | ||
| // `analyzed.scope`, causing the hoist algorithm to misidentify `redirect` | ||
| // as a closure variable. The workaround in hoist.ts strips re-exports | ||
| // before calling analyze. | ||
| const ast = await parseAstAsync(` | ||
| export { redirect } from "react-router/rsc"; | ||
| import { redirect } from "react-router/rsc"; | ||
|
|
||
| export default () => { | ||
| const f = async () => { | ||
| "use server"; | ||
| throw redirect(); | ||
| }; | ||
| } | ||
| `) | ||
| const { map, scope: root } = analyze(ast) | ||
| // find the arrow with "use server" | ||
| let serverScope: ReturnType<typeof analyze>['scope'] | undefined | ||
| walk(ast, { | ||
| enter(node) { | ||
| if ( | ||
| node.type === 'ArrowFunctionExpression' && | ||
| node.body.type === 'BlockStatement' && | ||
| node.body.body.some( | ||
| (s: any) => | ||
| s.type === 'ExpressionStatement' && | ||
| s.expression.type === 'Literal' && | ||
| s.expression.value === 'use server', | ||
| ) | ||
| ) { | ||
| serverScope = map.get(node) | ||
| } | ||
| }, | ||
| }) | ||
| expect(serverScope).toBeDefined() | ||
| expect(serverScope!.references.has('redirect')).toBe(true) | ||
| // find_owner should return the root scope (where the import lives), but | ||
| // instead returns the synthetic block scope periscopic creates for the | ||
| // re-export — this is a periscopic bug. | ||
| const owner = serverScope!.find_owner('redirect') | ||
| expect(owner).not.toBe(root) | ||
| expect(owner).not.toBe(serverScope) | ||
| }) | ||
|
|
||
| it('shadowed variable: find_owner misses child block scope', async () => { | ||
| // When a `const` inside a function body shadows an outer name, periscopic | ||
| // puts the declaration in the BlockStatement's scope (a child of the | ||
| // function scope). `find_owner` walks *up* from the function scope, so it | ||
| // finds the outer declaration instead of the inner one. | ||
| // | ||
| // This is not a periscopic bug — it is correct scope modeling. The hoist | ||
| // algorithm was using find_owner from the function scope, which cannot see | ||
| // declarations in child (block) scopes. | ||
| const ast = await parseAstAsync(` | ||
| function outer() { | ||
| const cookies = getCookies(); | ||
| async function inner(formData) { | ||
| "use server"; | ||
| const cookies = formData.get("value"); | ||
| return cookies; | ||
| } | ||
| } | ||
| `) | ||
| const { map, scope: root } = analyze(ast) | ||
| // find the inner function and its body block scope | ||
| let innerFnScope: ReturnType<typeof analyze>['scope'] | undefined | ||
| let innerBlockScope: ReturnType<typeof analyze>['scope'] | undefined | ||
| walk(ast, { | ||
| enter(node) { | ||
| if ( | ||
| node.type === 'FunctionDeclaration' && | ||
| (node as any).id?.name === 'inner' | ||
| ) { | ||
| innerFnScope = map.get(node) | ||
| } | ||
| if ( | ||
| node.type === 'BlockStatement' && | ||
| node.body.some( | ||
| (s: any) => | ||
| s.type === 'ExpressionStatement' && | ||
| s.expression.type === 'Literal' && | ||
| s.expression.value === 'use server', | ||
| ) | ||
| ) { | ||
| innerBlockScope = map.get(node) | ||
| } | ||
| }, | ||
| }) | ||
| expect(innerFnScope).toBeDefined() | ||
| expect(innerBlockScope).toBeDefined() | ||
|
|
||
| // periscopic correctly declares `cookies` in the block scope (child of function scope) | ||
| expect(innerBlockScope!.declarations.has('cookies')).toBe(true) | ||
| // the function scope does NOT have `cookies` — only `formData` (param) | ||
| expect(innerFnScope!.declarations.has('cookies')).toBe(false) | ||
| expect(innerFnScope!.declarations.has('formData')).toBe(true) | ||
|
|
||
| // `cookies` propagates up as a reference through all ancestor scopes | ||
| expect(innerFnScope!.references.has('cookies')).toBe(true) | ||
|
|
||
| // find_owner from function scope walks UP and finds the OUTER `cookies`, | ||
| // not the inner one (which is in a child block scope, unreachable by walking up) | ||
| const ownerFromFnScope = innerFnScope!.find_owner('cookies') | ||
| expect(ownerFromFnScope).not.toBe(innerFnScope) | ||
| expect(ownerFromFnScope).not.toBe(innerBlockScope) | ||
| expect(ownerFromFnScope).not.toBe(root) | ||
|
|
||
| // find_owner from block scope correctly finds the INNER `cookies` | ||
| const ownerFromBlockScope = innerBlockScope!.find_owner('cookies') | ||
| expect(ownerFromBlockScope).toBe(innerBlockScope) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the new edge case which your
collectLocallyDeclaredNameswasn't able to cover and didn't bindcookies.