-
-
Notifications
You must be signed in to change notification settings - Fork 246
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
Changes from 1 commit
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 |
|---|---|---|
|
|
@@ -448,6 +448,164 @@ 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" }); | ||
| " | ||
| `) | ||
| }) | ||
|
|
||
| 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('non-colliding closure variable is still bound', async () => { | ||
| // Regression guard: `config` is a genuine closure (not redeclared inside | ||
| // the action) and must still appear in bindVars after the fix. | ||
| // Covered more precisely by 'const shadows outer variable' above, but | ||
| // kept as an explicit intent marker. | ||
| 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; | ||
| } | ||
| ` | ||
| const result = await testTransform(input) | ||
| expect(result).toContain('.bind(null, config)') | ||
| expect(result).not.toContain('bind(null, cookies') | ||
| }) | ||
|
|
||
| it('encode: local declaration not included in bound args', async () => { | ||
| // Same as above but with encode/decode — the encrypted bound-args payload | ||
| // must only include genuine closure vars, not locally-redeclared names. | ||
| 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, { 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('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; | ||
| } | ||
| ` | ||
|
Comment on lines
+652
to
+666
Contributor
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. |
||
| const result = await testTransform(input) | ||
| expect(result).toContain('.bind(null, config)') | ||
| expect(result).not.toContain('bind(null, cookies') | ||
| }) | ||
| }) | ||
|
|
||
| it('no ending new line', async () => { | ||
| const input = `\ | ||
| export async function test() { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.