From 3417f6562bbfa734de0e0ce40acfc8d71396d7ee Mon Sep 17 00:00:00 2001 From: James Date: Thu, 19 Mar 2026 12:14:41 +0000 Subject: [PATCH 1/9] fix(rsc): handle shadowed local declarations in "use server" closure binding --- .../plugin-rsc/src/transforms/hoist.test.ts | 158 ++++++++++++++++++ packages/plugin-rsc/src/transforms/hoist.ts | 69 +++++++- 2 files changed, 226 insertions(+), 1 deletion(-) diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 22742a470..682ebc869 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -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, "", "$$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, "", "$$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, "", "$$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; +} +` + 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() { diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index eb5bf15ba..970416288 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -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' @@ -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 }) @@ -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 { + const names = new Set() + + 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 +} From ae001b05cdfc6855a2c64e6d2c837736a4829c31 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 20 Mar 2026 07:14:18 +0000 Subject: [PATCH 2/9] switch to inline snapshot --- .../plugin-rsc/src/transforms/hoist.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 682ebc869..4fd4a9305 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -600,9 +600,24 @@ function buildAction(config) { return submitAction; } ` - const result = await testTransform(input) - expect(result).toContain('.bind(null, config)') - expect(result).not.toContain('bind(null, cookies') + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function buildAction(config) { + const cookies = getCookies(); + + const submitAction = /* #__PURE__ */ $$register($$hoist_0_submitAction, "", "$$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" }); + " + `) }) }) From 64ea63adcac0482186ec086ca67fc8fd11bfcb51 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 20 Mar 2026 07:15:31 +0000 Subject: [PATCH 3/9] and another --- .../plugin-rsc/src/transforms/hoist.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 4fd4a9305..f442ecfe4 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -541,9 +541,24 @@ function buildAction(config) { return submitAction; } ` - const result = await testTransform(input) - expect(result).toContain('.bind(null, config)') - expect(result).not.toContain('bind(null, cookies') + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function buildAction(config) { + const cookies = getCookies(); + + const submitAction = /* #__PURE__ */ $$register($$hoist_0_submitAction, "", "$$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('encode: local declaration not included in bound args', async () => { From 3c281dbf1ec3582d9da79b683998791fea143610 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 21 Mar 2026 18:33:20 +0900 Subject: [PATCH 4/9] refactor: use extract_names --- .../plugin-rsc/src/transforms/hoist.test.ts | 7 +++++ packages/plugin-rsc/src/transforms/hoist.ts | 31 +++---------------- 2 files changed, 12 insertions(+), 26 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index f442ecfe4..d69f7bbce 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -652,3 +652,10 @@ export async function test() { `) }) }) + +// TODO: should report to upstream or looks for alternative +// https://github.com/Rich-Harris/periscopic/ +describe.skip('periscopic issues', () => { + // TODO: re-export + // TODO: shadowed variable in nested functions +}) diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index 970416288..4eeb9ee19 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -1,8 +1,8 @@ import { tinyassert } from '@hiogawa/utils' -import type { Program, Literal, Pattern } from 'estree' +import type { Program, Literal } from 'estree' import { walk } from 'estree-walker' import MagicString from 'magic-string' -import { analyze } from 'periscopic' +import { analyze, extract_names } from 'periscopic' export function transformHoistInlineDirective( input: string, @@ -214,29 +214,6 @@ function collectLocallyDeclaredNames( ): Set { const names = new Set() - 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) { @@ -249,7 +226,9 @@ function collectLocallyDeclaredNames( return this.skip() case 'VariableDeclaration': for (const decl of node.declarations) { - collectPattern(decl.id) + for (const name of extract_names(decl.id)) { + names.add(name) + } } } }, From 049c4de43ed9cf289673f771ac5809416a048099 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 21 Mar 2026 18:41:57 +0900 Subject: [PATCH 5/9] test: remove duplicate --- .../plugin-rsc/src/transforms/hoist.test.ts | 90 ++++--------------- 1 file changed, 17 insertions(+), 73 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index d69f7bbce..91114d036 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -489,70 +489,20 @@ function buildAction(config) { /* #__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, "", "$$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; -} -` - expect(await testTransform(input)).toMatchInlineSnapshot(` + expect(await testTransform(input, { encode: true })) + .toMatchInlineSnapshot(` " function buildAction(config) { const cookies = getCookies(); - const submitAction = /* #__PURE__ */ $$register($$hoist_0_submitAction, "", "$$hoist_0_submitAction").bind(null, config); + const submitAction = /* #__PURE__ */ $$register($$hoist_0_submitAction, "", "$$hoist_0_submitAction").bind(null, __enc([config])); return submitAction; } - ;export async function $$hoist_0_submitAction(config, formData) { - "use server"; + ;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); }; @@ -561,38 +511,32 @@ function buildAction(config) { `) }) - 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. + 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(config) { - const cookies = getCookies(); - +function buildAction(cookies) { async function submitAction(formData) { "use server"; const cookies = formData.get("value"); - return doSomething(config, cookies); + return cookies; } return submitAction; } ` - expect(await testTransform(input, { encode: true })) - .toMatchInlineSnapshot(` + expect(await testTransform(input)).toMatchInlineSnapshot(` " - function buildAction(config) { - const cookies = getCookies(); - - const submitAction = /* #__PURE__ */ $$register($$hoist_0_submitAction, "", "$$hoist_0_submitAction").bind(null, __enc([config])); + function buildAction(cookies) { + const submitAction = /* #__PURE__ */ $$register($$hoist_0_submitAction, "", "$$hoist_0_submitAction"); return submitAction; } - ;export async function $$hoist_0_submitAction($$hoist_encoded, formData) { - const [config] = __dec($$hoist_encoded); - "use server"; + ;export async function $$hoist_0_submitAction(formData) { + "use server"; const cookies = formData.get("value"); - return doSomething(config, cookies); + return cookies; }; /* #__PURE__ */ Object.defineProperty($$hoist_0_submitAction, "name", { value: "submitAction" }); " From 9741514773c97e3b55427b8e41bf51fea720db03 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 21 Mar 2026 19:12:50 +0900 Subject: [PATCH 6/9] fix: fix actual bug on ourside --- .../plugin-rsc/src/transforms/hoist.test.ts | 122 +++++++++++++++++- packages/plugin-rsc/src/transforms/hoist.ts | 52 +------- 2 files changed, 122 insertions(+), 52 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 91114d036..2512dc129 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -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' @@ -597,9 +599,121 @@ export async function test() { }) }) -// TODO: should report to upstream or looks for alternative +// TODO: report upstream // https://github.com/Rich-Harris/periscopic/ -describe.skip('periscopic issues', () => { - // TODO: re-export - // TODO: shadowed variable in nested functions +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['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['scope'] | undefined + let innerBlockScope: ReturnType['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) + }) }) diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index 4eeb9ee19..d2f6aa78d 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -71,7 +71,7 @@ export function transformHoistInlineDirective( ) } - const scope = analyzed.map.get(node) + const scope = analyzed.map.get(node.body) tinyassert(scope) const declName = node.type === 'FunctionDeclaration' && node.id.name const originalName = @@ -82,18 +82,10 @@ export function transformHoistInlineDirective( 'anonymous_server_function' // bind variables which are neither global nor in own scope - const localDecls = collectLocallyDeclaredNames(node.body) + const ownParams = new Set(node.params.flatMap((p) => extract_names(p))) 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)) { + // own parameters are available in a hoisted function + if (ownParams.has(ref)) { return false } @@ -200,39 +192,3 @@ 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 { - const names = new Set() - - 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) { - for (const name of extract_names(decl.id)) { - names.add(name) - } - } - } - }, - }) - - return names -} From 6ec6480c4da5313328682d6b14b72009ddcbfefe Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 21 Mar 2026 19:21:54 +0900 Subject: [PATCH 7/9] test: more --- .../plugin-rsc/src/transforms/hoist.test.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 2512dc129..1bf68f322 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -580,6 +580,75 @@ function buildAction(config) { " `) }) + + it('inner accessing both outer and own names', async () => { + const input = ` +function buildAction() { + const cookies = getCookies(); + async function action() { + "use server"; + if (condition) { + const cookies = localValue; // block-scoped to the if + process(cookies); + } + return cookies; // refers to OUTER cookies — needs binding + } +} +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function buildAction() { + const cookies = getCookies(); + const action = /* #__PURE__ */ $$register($$hoist_0_action, "", "$$hoist_0_action").bind(null, cookies); + } + + ;export async function $$hoist_0_action(cookies) { + "use server"; + if (condition) { + const cookies = localValue; // 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: 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; +} +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function Parent() { + const count = 0; + + const recurse = /* #__PURE__ */ $$register($$hoist_0_recurse, "", "$$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 () => { From 44f3a5944d6b8bf9698a327246f94bcbc1eb164d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 21 Mar 2026 19:36:56 +0900 Subject: [PATCH 8/9] test: more case --- .../plugin-rsc/src/transforms/hoist.test.ts | 44 ++++++++++++++++--- 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 1bf68f322..64a09c5ca 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -583,12 +583,12 @@ function buildAction(config) { it('inner accessing both outer and own names', async () => { const input = ` -function buildAction() { - const cookies = getCookies(); +function outer() { + const cookies = 0; async function action() { "use server"; if (condition) { - const cookies = localValue; // block-scoped to the if + const cookies = 1; // block-scoped to the if process(cookies); } return cookies; // refers to OUTER cookies — needs binding @@ -597,15 +597,15 @@ function buildAction() { ` expect(await testTransform(input)).toMatchInlineSnapshot(` " - function buildAction() { - const cookies = getCookies(); + function outer() { + const cookies = 0; const action = /* #__PURE__ */ $$register($$hoist_0_action, "", "$$hoist_0_action").bind(null, cookies); } ;export async function $$hoist_0_action(cookies) { "use server"; if (condition) { - const cookies = localValue; // block-scoped to the if + const cookies = 1; // block-scoped to the if process(cookies); } return cookies; // refers to OUTER cookies — needs binding @@ -614,6 +614,38 @@ function buildAction() { " `) }) + + 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 + } + } +} +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function outer() { + const cookie = 0; + const action = /* #__PURE__ */ $$register($$hoist_0_action, "", "$$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 From 44d3fc90ab8438d5dd140deda8c9877ea0578028 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sat, 21 Mar 2026 19:39:32 +0900 Subject: [PATCH 9/9] test: todo --- packages/plugin-rsc/src/transforms/hoist.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 64a09c5ca..68a9dded1 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -615,6 +615,7 @@ function outer() { `) }) + // TODO: not working it('inner has own block then shadows', async () => { const input = ` function outer() {