Skip to content

fix(rsc): handle shadowed local declarations in "use server" closure binding#1151

Open
james-elicx wants to merge 9 commits intovitejs:mainfrom
james-elicx:james/fix-use-server-scope-collision
Open

fix(rsc): handle shadowed local declarations in "use server" closure binding#1151
james-elicx wants to merge 9 commits intovitejs:mainfrom
james-elicx:james/fix-use-server-scope-collision

Conversation

@james-elicx
Copy link

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.

@hi-ogawa hi-ogawa self-requested a review March 20, 2026 06:04
Copy link
Contributor

@hi-ogawa hi-ogawa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great find and thanks for the fix!

@hi-ogawa hi-ogawa self-assigned this Mar 20, 2026
@james-elicx james-elicx requested a review from hi-ogawa March 20, 2026 07:18
@hi-ogawa
Copy link
Contributor

I think this wasn't periscopic bug, but it's our misusage or algorithmic issue. I'm trying to fix the root cause and might need to take a time a bit more. I have added a test case which works with your approach but breaks my approach, and another one which breaks yours and works with mine.

Comment on lines +584 to +602
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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the new edge case which your collectLocallyDeclaredNames wasn't able to cover and didn't bind cookies.

@james-elicx
Copy link
Author

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 😓

Comment on lines +618 to +630
// 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
}
}
}
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
})

Copy link
Contributor

@hi-ogawa hi-ogawa Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +652 to +666
// 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;
}
`
Copy link
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.

@hi-ogawa
Copy link
Contributor

Also I'm wondering maybe we should ditch periscopic and design estree scope management utility from scratch. I think that might be actually easier than working around what periscopic doesn't provide. Such full rewrite might be easier for human to review.

@james-elicx
Copy link
Author

james-elicx commented Mar 22, 2026

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?

@hi-ogawa
Copy link
Contributor

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!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants