diff --git a/packages/plugin-rsc/src/transforms/hoist.test.ts b/packages/plugin-rsc/src/transforms/hoist.test.ts index 265f84062..3950f8419 100644 --- a/packages/plugin-rsc/src/transforms/hoist.test.ts +++ b/packages/plugin-rsc/src/transforms/hoist.test.ts @@ -1,7 +1,7 @@ import path from 'node:path' import { parseAstAsync } from 'vite' import { describe, expect, it } from 'vitest' -import { transformHoistInlineDirective } from './hoist' +import { findDirectives, transformHoistInlineDirective } from './hoist' describe('fixtures', () => { const fixtures = import.meta.glob( @@ -128,6 +128,43 @@ async function f() { expect(await testTransform(input)).toMatchInlineSnapshot(`undefined`) }) + it('ignores strings outside a function directive prologue', async () => { + const input = ` +async function initialized() { + initialize(); + "use server"; +} + +async function parenthesized() { + ("use server"); +} +` + expect(await testTransform(input)).toBeUndefined() + }) + + it('recognizes a directive after another prologue directive', async () => { + const input = ` +async function action() { + "use strict"; + "use server"; +} +` + expect(await testTransformNames(input)).toEqual(['$$hoist_0_action']) + }) + + it('finds directives only in directive-capable bodies', async () => { + const input = ` +{ + "use server"; +} +async function action() { + "use server"; +} +` + const ast = await parseAstAsync(input) + expect(findDirectives(ast, 'use server')).toHaveLength(1) + }) + it('top level', async () => { const input = ` const x = "x"; diff --git a/packages/plugin-rsc/src/transforms/hoist.ts b/packages/plugin-rsc/src/transforms/hoist.ts index 8f72cfad1..63291351c 100644 --- a/packages/plugin-rsc/src/transforms/hoist.ts +++ b/packages/plugin-rsc/src/transforms/hoist.ts @@ -10,6 +10,7 @@ import { walk } from 'estree-walker' import MagicString from 'magic-string' import type { ESTree } from 'vite' import { buildScopeTree, type ScopeTree } from './scope' +import { isDirective } from './utils' /** * Turns an inline directive function into a module-level registered function. @@ -263,11 +264,7 @@ function getRuntimeHoistPosition(ast: Program): number { // Preserve leading directives so directive-based transforms can // still compose just in case. for (const statement of ast.body) { - const isDirective = - statement.type === 'ExpressionStatement' && - statement.expression.type === 'Literal' && - typeof statement.expression.value === 'string' - if (!isDirective) { + if (!isDirective(statement)) { return statement.start } } @@ -282,15 +279,12 @@ function matchDirective( directive: RegExp, ): { match: RegExpMatchArray; node: Literal } | undefined { for (const stmt of body) { - if ( - stmt.type === 'ExpressionStatement' && - stmt.expression.type === 'Literal' && - typeof stmt.expression.value === 'string' - ) { - const match = stmt.expression.value.match(directive) - if (match) { - return { match, node: stmt.expression } - } + if (!isDirective(stmt)) { + return + } + const match = stmt.directive.match(directive) + if (match) { + return { match, node: stmt.expression } } } } diff --git a/packages/plugin-rsc/src/transforms/utils.test.ts b/packages/plugin-rsc/src/transforms/utils.test.ts index 0968e551c..a5d45b22c 100644 --- a/packages/plugin-rsc/src/transforms/utils.test.ts +++ b/packages/plugin-rsc/src/transforms/utils.test.ts @@ -1,9 +1,24 @@ import { parseAstAsync } from 'vite' import { describe, expect, test } from 'vitest' import { transformProxyExport } from './proxy-export' -import { validateNonAsyncFunction } from './utils' +import { hasDirective, validateNonAsyncFunction } from './utils' import { transformWrapExport } from './wrap-export' +describe(hasDirective, () => { + test.each([ + [`'use server'; export {};`, true], + [`'use strict'; 'use server'; export {};`, true], + [`import './setup.js'; 'use server'; export {};`, false], + [`('use server'); export {};`, false], + [`; 'use server'; export {};`, false], + [`'use client'; export {};`, false], + [`'use strict'; 'use client'; export {};`, false], + ])('recognizes directive prologues', async (input, expected) => { + const ast = await parseAstAsync(input) + expect(hasDirective(ast.body, 'use server')).toBe(expected) + }) +}) + describe(validateNonAsyncFunction, () => { // next.js's validation isn't entirely consistent. // for now we aim to make it at least as forgiving as next.js. diff --git a/packages/plugin-rsc/src/transforms/utils.ts b/packages/plugin-rsc/src/transforms/utils.ts index 3affcbdfc..030184dc3 100644 --- a/packages/plugin-rsc/src/transforms/utils.ts +++ b/packages/plugin-rsc/src/transforms/utils.ts @@ -1,19 +1,19 @@ -import type { ExportDefaultDeclaration } from 'estree' +import type { Directive, ExportDefaultDeclaration } from 'estree' import type { Identifier, Node, Pattern, Program } from 'estree' import type { ESTree } from 'vite' +export function isDirective(node: Node): node is Directive { + // Directive is not its own `type` + // https://github.com/estree/estree/blob/master/es5.md#directive + return node.type === 'ExpressionStatement' && 'directive' in node +} + export function hasDirective( viteBody: ESTree.Program['body'], directive: string, ): boolean { const body = viteBody as unknown as Program['body'] - return !!body.find( - (stmt) => - stmt.type === 'ExpressionStatement' && - stmt.expression.type === 'Literal' && - typeof stmt.expression.value === 'string' && - stmt.expression.value === directive, - ) + return body.some((stmt) => isDirective(stmt) && stmt.directive === directive) } // Copied from periscopic `extract_names` / `extract_identifiers`