From 83e60fcbbd4196533e4594163c6d6383b1c7bdd9 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 21 Mar 2026 21:44:51 +0000 Subject: [PATCH 1/2] fix: member expression binding unserializable root variable --- .../plugin-rsc/src/transforms/hoist.test.ts | 190 ++++++++++++++++++ packages/plugin-rsc/src/transforms/hoist.ts | 60 +++++- 2 files changed, 245 insertions(+), 5 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 22742a470..ad132fd31 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -448,6 +448,196 @@ export async function kv() { `) }) + describe('binding member expressions', () => { + it('member access only binds the member expression, not the root variable', async () => { + const input = ` +function MyForm({ config }) { + async function submitAction(formData) { + "use server"; + + const prefix = config.cookiePrefix; // ONLY member access, never bare config + console.log(config.cookiePrefix); + return config.cookiePrefix; + } + + return "test"; +} +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function MyForm({ config }) { + const submitAction = /* #__PURE__ */ $$register($$hoist_0_submitAction, "", "$$hoist_0_submitAction").bind(null, config.cookiePrefix); + + return "test"; + } + + ;export async function $$hoist_0_submitAction($$bind_0_config_cookiePrefix, formData) { + "use server"; + + const prefix = $$bind_0_config_cookiePrefix; // ONLY member access, never bare config + console.log($$bind_0_config_cookiePrefix); + return $$bind_0_config_cookiePrefix; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_0_submitAction, "name", { value: "submitAction" }); + " + `) + }) + + it('multiple different props from same object are each bound separately', async () => { + const input = ` +function outer(config) { + async function action(formData) { + "use server"; + return config.host + config.port; + } +} +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function outer(config) { + const action = /* #__PURE__ */ $$register($$hoist_0_action, "", "$$hoist_0_action").bind(null, config.host, config.port); + } + + ;export async function $$hoist_0_action($$bind_0_config_host, $$bind_1_config_port, formData) { + "use server"; + return $$bind_0_config_host + $$bind_1_config_port; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_0_action, "name", { value: "action" }); + " + `) + }) + + it('bare use of var falls back to binding the whole variable', async () => { + const input = ` +function outer(config) { + async function action(formData) { + "use server"; + const prefix = config.cookiePrefix; + return doSomething(config); + } +} +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function outer(config) { + const action = /* #__PURE__ */ $$register($$hoist_0_action, "", "$$hoist_0_action").bind(null, config); + } + + ;export async function $$hoist_0_action(config, formData) { + "use server"; + const prefix = config.cookiePrefix; + return doSomething(config); + }; + /* #__PURE__ */ Object.defineProperty($$hoist_0_action, "name", { value: "action" }); + " + `) + }) + + it('computed member access falls back to binding the whole variable', async () => { + const input = ` +function outer(config, key) { + async function action(formData) { + "use server"; + return config[key]; + } +} +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function outer(config, key) { + const action = /* #__PURE__ */ $$register($$hoist_0_action, "", "$$hoist_0_action").bind(null, key, config); + } + + ;export async function $$hoist_0_action(key, config, formData) { + "use server"; + return config[key]; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_0_action, "name", { value: "action" }); + " + `) + }) + + it('mixed vars: one member-only, one bare', async () => { + const input = ` +function outer(config, user) { + async function action(formData) { + "use server"; + return config.cookiePrefix + user; + } +} +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function outer(config, user) { + const action = /* #__PURE__ */ $$register($$hoist_0_action, "", "$$hoist_0_action").bind(null, user, config.cookiePrefix); + } + + ;export async function $$hoist_0_action(user, $$bind_0_config_cookiePrefix, formData) { + "use server"; + return $$bind_0_config_cookiePrefix + user; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_0_action, "name", { value: "action" }); + " + `) + }) + + it('multi-level member access binds the full chain', async () => { + const input = ` +function outer(config) { + async function action(formData) { + "use server"; + return config.db.host; + } +} +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function outer(config) { + const action = /* #__PURE__ */ $$register($$hoist_0_action, "", "$$hoist_0_action").bind(null, config.db.host); + } + + ;export async function $$hoist_0_action($$bind_0_config_db_host, formData) { + "use server"; + return $$bind_0_config_db_host; + }; + /* #__PURE__ */ Object.defineProperty($$hoist_0_action, "name", { value: "action" }); + " + `) + }) + + it('shadowed object with member access falls back to binding the whole variable', async () => { + const input = ` + function outer(config) { + async function action(formData) { + "use server"; + const oldHost = config.db.host; + if (condition) { + const config = { db: { host: "test" } }; // shadows outer config + return config.db.host; // should refer to inner config, not outer + } + } + } +` + expect(await testTransform(input)).toMatchInlineSnapshot(` + " + function outer(config) { + const action = /* #__PURE__ */ $$register($$hoist_0_action, "", "$$hoist_0_action").bind(null, config); + } + + ;export async function $$hoist_0_action(config, formData) { + "use server"; + const oldHost = config.db.host; + if (condition) { + const config = { db: { host: "test" } }; // shadows outer config + return config.db.host; // should refer to inner config, not outer + } + }; + /* #__PURE__ */ Object.defineProperty($$hoist_0_action, "name", { value: "action" }); + " + `) + }) + }) + 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..f2f3b4bb5 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, Expression, Super } from 'estree' import { walk } from 'estree-walker' import MagicString from 'magic-string' import { analyze } from 'periscopic' @@ -90,8 +90,58 @@ export function transformHoistInlineDirective( const owner = scope.find_owner(ref) return owner && owner !== scope && owner !== analyzed.scope }) + .flatMap((ref) => { + // extract the full expression used for a variable so that we can bind the whole + // expression accessor instead of the bare variable, which may not be serializable + // e.g. `config.cookiePrefix` instead of `config`, which may have non-serializable + // properties like a `config.get` function. + const exprs = new Set() + let isBareVarUsed = false + + walk(node.body, { + enter(inner, innerParent) { + const isLHSOfMemberExpr = + innerParent?.type === 'MemberExpression' && + innerParent.object === inner && + !innerParent.computed + + if (inner.type === 'Identifier' && inner.name === ref) { + if (isLHSOfMemberExpr) return + isBareVarUsed = true + } else if ( + inner.type === 'MemberExpression' && + !inner.computed && + !isLHSOfMemberExpr + ) { + // walk down the object chain until we find the leaf identifier and check if it's the ref + let root: Expression | Super = inner + while (root.type === 'MemberExpression') root = root.object + + if (root.type === 'Identifier' && root.name === ref) { + exprs.add(input.slice(inner.start, inner.end)) + } + } + }, + }) + + if (isBareVarUsed || exprs.size === 0) + return [{ param: ref, arg: ref }] + + return [...exprs.values()].map((expr, idx) => { + const param = `$$bind_${idx}_${expr.replace(/\./g, '_')}` + walk(node.body, { + enter(inner) { + if (input.slice(inner.start, inner.end) === expr) { + output.update(inner.start, inner.end, param) + } + }, + }) + return { param, arg: expr } + }) + }) + let newParams = [ - ...bindVars, + ...bindVars.map((b) => b.param), ...node.params.map((n) => input.slice(n.start, n.end)), ].join(', ') if (bindVars.length > 0 && options.decode) { @@ -101,7 +151,7 @@ export function transformHoistInlineDirective( ].join(', ') output.appendLeft( node.body.body[0]!.start, - `const [${bindVars.join(',')}] = ${options.decode( + `const [${bindVars.map((b) => b.param).join(',')}] = ${options.decode( '$$hoist_encoded', )};\n`, ) @@ -132,8 +182,8 @@ export function transformHoistInlineDirective( })}` if (bindVars.length > 0) { const bindArgs = options.encode - ? options.encode('[' + bindVars.join(', ') + ']') - : bindVars.join(', ') + ? options.encode('[' + bindVars.map((b) => b.arg).join(', ') + ']') + : bindVars.map((b) => b.arg).join(', ') newCode = `${newCode}.bind(null, ${bindArgs})` } if (declName) { From 123e92ee9ab399c6c71a6915bb479d2b6e588656 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 21 Mar 2026 21:57:47 +0000 Subject: [PATCH 2/2] format again oops --- packages/plugin-rsc/src/transforms/hoist.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index f2f3b4bb5..39b4ee192 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -82,14 +82,15 @@ export function transformHoistInlineDirective( 'anonymous_server_function' // bind variables which are neither global nor in own scope - const bindVars = [...scope.references].filter((ref) => { - // declared function itself is included as reference - if (ref === declName) { - return false - } - const owner = scope.find_owner(ref) - return owner && owner !== scope && owner !== analyzed.scope - }) + const bindVars = [...scope.references] + .filter((ref) => { + // declared function itself is included as reference + if (ref === declName) { + return false + } + const owner = scope.find_owner(ref) + return owner && owner !== scope && owner !== analyzed.scope + }) .flatMap((ref) => { // extract the full expression used for a variable so that we can bind the whole // expression accessor instead of the bare variable, which may not be serializable