fix(rsc): handle shadowed local declarations in "use server" closure binding#1151
fix(rsc): handle shadowed local declarations in "use server" closure binding#1151james-elicx wants to merge 9 commits intovitejs:mainfrom
Conversation
hi-ogawa
left a comment
There was a problem hiding this comment.
Great find and thanks for the fix!
|
I think this wasn't |
| it('inner accessing both outer and own names', async () => { | ||
| const input = ` | ||
| function outer() { | ||
| const cookies = 0; | ||
| async function action() { | ||
| "use server"; | ||
| if (condition) { | ||
| const cookies = 1; // block-scoped to the if | ||
| process(cookies); | ||
| } | ||
| return cookies; // refers to OUTER cookies — needs binding | ||
| } | ||
| } | ||
| ` | ||
| expect(await testTransform(input)).toMatchInlineSnapshot(` | ||
| " | ||
| function outer() { | ||
| const cookies = 0; | ||
| const action = /* #__PURE__ */ $$register($$hoist_0_action, "<id>", "$$hoist_0_action").bind(null, cookies); |
There was a problem hiding this comment.
This is the new edge case which your collectLocallyDeclaredNames wasn't able to cover and didn't bind cookies.
|
Thanks for taking the time to look into this further. Sorry, I'm not that familiar with how the plugin works under the hood, so I thought I might have missed something 😓 |
| // TODO: not working | ||
| 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 | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
i was doing some experimenting with this one and managed to get it working by walking through the function's body and checking if there is a reference to the variable that isn't shadows in the current scope.
This is what my bindVars looked like with the changes needed for this to work. Unsure whether this is the kind of approach you're after though, so I haven't pushed a commit for it.
const bindVars = [...scope.references].filter((ref) => {
// own parameters are available in a hoisted function
if (ownParams.has(ref)) {
return false
}
const owner = scope.find_owner(ref)
if (!(owner && owner !== scope && owner !== analyzed.scope)) {
return false
}
let shouldBeBound = false
let shadowDepth = 0
walk(node.body, {
enter(node) {
const innerScope = analyzed.map.get(node)
if (
innerScope?.declarations.has(ref) &&
'body' in node &&
node !== node.body
) {
// if the node declares a variable with the same name and it's not the function
// body itself, it shadows the reference we're looking for
shadowDepth++
}
if (
shadowDepth === 0 &&
node.type === 'Identifier' &&
node.name === ref
) {
shouldBeBound = true
this.skip()
}
},
leave(node) {
const innerScope = analyzed.map.get(node)
if (
innerScope?.declarations.has(ref) &&
'body' in node &&
node !== node.body
) {
shadowDepth--
}
},
})
return shouldBeBound
})There was a problem hiding this comment.
I was thinking maybe we can re-run periscopic analyze against BlockStatement with "use server". The global variables found there (modulo actual global variables of original program) might give the right bind variables.
(EDIT: hmm, no, my approach doesn't work either since such "global variable" cannot tell if it's actual global or the variable outside)
| // 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; | ||
| } | ||
| ` |
There was a problem hiding this comment.
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.
|
Also I'm wondering maybe we should ditch |
|
Considering the periscopic library is actually rather small, that might be a decent path to take - at least, it gives more control over what's going on. I'm happy to spend some time trying to come up with something if you would like me to? |
|
Yes, their library is small, so it looks better to get inspired but write from ground up. I think that's going to be important to tackle more edge cases like in #1155. Please feel free to explore. Thanks for the help! |
Description
This PR fixes a bug where periscopic misclassifies block-scoped declarations inside a
"use server"function body as closure variables when the same name exists in a scope, causing a syntax error in the hoisted output (ran into this when testing Payload CMS with Vinext).It essentially collects the names of local declarations from the function's scope and then stop them from being bound.
There's also a few regression tests that were generated by AI.
This is an attempt to upstream a real fix for cloudflare/vinext#527.