Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions packages/plugin-rsc/src/transforms/hoist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Comment thread
hi-ogawa marked this conversation as resolved.
Outdated
})

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
Copy link
Copy Markdown
Contributor Author

@james-elicx james-elicx Mar 21, 2026

Choose a reason for hiding this comment

The 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 const recuse = (...) => $$hoist_0_recurse(...) after the 'use server' directive.


ended up going and playing around with it after i started writing this.

in the bindVars filter callback I added a return false for declName && ref === declName to filter out the function name from the bindings.

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() {
Expand Down
69 changes: 68 additions & 1 deletion packages/plugin-rsc/src/transforms/hoist.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { tinyassert } from '@hiogawa/utils'
import type { Program, Literal } from 'estree'
import type { Program, Literal, Pattern } from 'estree'
import { walk } from 'estree-walker'
import MagicString from 'magic-string'
import { analyze } from 'periscopic'
Expand Down Expand Up @@ -82,11 +82,21 @@ export function transformHoistInlineDirective(
'anonymous_server_function'

// bind variables which are neither global nor in own scope
const localDecls = collectLocallyDeclaredNames(node.body)
const bindVars = [...scope.references].filter((ref) => {
// declared function itself is included as reference
if (ref === declName) {
return false
}

// periscopic misclassifies block-scoped declarations inside a
// "use server" function body as closure references when the same
// name exists in an enclosing scope. Exclude any name that is
// actually declared within this function body.
if (localDecls.has(ref)) {
return false
}

const owner = scope.find_owner(ref)
return owner && owner !== scope && owner !== analyzed.scope
})
Expand Down Expand Up @@ -190,3 +200,60 @@ export function findDirectives(ast: Program, directive: string): Literal[] {
})
return nodes
}

/**
* Collect all names declared within a function body, without crossing into
* nested function boundaries (which have their own scope).
*
* This guards against a periscopic limitation where block-scoped declarations
* (`const`/`let`) inside a `BlockStatement` are misclassified as references
* to outer-scope bindings. Any name returned here must NOT be in `bindVars`.
*/
function collectLocallyDeclaredNames(
body: Program['body'][number],
): Set<string> {
const names = new Set<string>()

function collectPattern(node: Pattern | null): void {
switch (node?.type) {
case 'Identifier':
names.add(node.name)
break
case 'AssignmentPattern':
return collectPattern(node.left)
case 'RestElement':
return collectPattern(node.argument)
case 'ArrayPattern':
for (const el of node.elements) {
collectPattern(el)
}
break
case 'ObjectPattern':
for (const prop of node.properties) {
collectPattern(
prop.type === 'RestElement' ? prop.argument : prop.value,
)
}
}
}

walk(body, {
enter(node) {
switch (node.type) {
case 'FunctionDeclaration':
case 'FunctionExpression':
case 'ClassDeclaration':
if (node.id) names.add(node.id.name)
return this.skip()
case 'ArrowFunctionExpression':
return this.skip()
case 'VariableDeclaration':
for (const decl of node.declarations) {
collectPattern(decl.id)
}
}
},
})

return names
}
Loading