Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
190 changes: 190 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,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, "<id>", "$$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, "<id>", "$$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, "<id>", "$$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, "<id>", "$$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, "<id>", "$$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, "<id>", "$$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, "<id>", "$$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() {
Expand Down
77 changes: 64 additions & 13 deletions 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, Expression, Super } from 'estree'
import { walk } from 'estree-walker'
import MagicString from 'magic-string'
import { analyze } from 'periscopic'
Expand Down Expand Up @@ -82,16 +82,67 @@ 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
// e.g. `config.cookiePrefix` instead of `config`, which may have non-serializable
// properties like a `config.get` function.
const exprs = new Set<string>()
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) {
Expand All @@ -101,7 +152,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`,
)
Expand Down Expand Up @@ -132,8 +183,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) {
Expand Down