Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore(rsc): Add RSA unit test for module scoped 'use server' #11169

Merged
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import { vol } from 'memfs'
import type { TransformPluginContext } from 'rollup'
import {
afterAll,
beforeAll,
describe,
it,
expect,
vi,
afterEach,
} from 'vitest'

import { rscTransformUseServerPlugin } from '../vite-plugin-rsc-transform-server.js'

vi.mock('fs', async () => ({ default: (await import('memfs')).fs }))

const RWJS_CWD = process.env.RWJS_CWD

beforeAll(() => {
process.env.RWJS_CWD = '/Users/tobbe/rw-app/'

// Add a toml entry for getPaths et al.
vol.fromJSON({ 'redwood.toml': '' }, process.env.RWJS_CWD)
})

afterAll(() => {
process.env.RWJS_CWD = RWJS_CWD
})

function getPluginTransform() {
const plugin = rscTransformUseServerPlugin()

if (typeof plugin.transform !== 'function') {
throw new Error('Plugin does not have a transform function')
}

// Calling `bind` to please TS
// See https://stackoverflow.com/a/70463512/88106
// Typecasting because we're only going to call transform, and we don't need
// anything provided by the context.
return plugin.transform.bind({} as TransformPluginContext)
}

const pluginTransform = getPluginTransform()

describe('rscTransformUseServerPlugin module scoped "use server"', () => {
afterEach(() => {
vi.resetAllMocks()
})

it('should handle one function', async () => {
const id = 'some/path/to/actions.ts'
const input = `
'use server'

import fs from 'node:fs'

export async function formAction(formData: FormData) {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}`.trim()

const output = await pluginTransform(input, id)

expect(output).toMatchInlineSnapshot(`
"'use server'

import fs from 'node:fs'

export async function formAction(formData: FormData) {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}

import {registerServerReference} from "react-server-dom-webpack/server";
registerServerReference(formAction,"some/path/to/actions.ts","formAction");
"
`)
})

it('should handle two functions', async () => {
const id = 'some/path/to/actions.ts'
const input = `
'use server'

import fs from 'node:fs'

export async function formAction1(formData: FormData) {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}

export async function formAction2(formData: FormData) {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}`.trim()

const output = await pluginTransform(input, id)

expect(output).toMatchInlineSnapshot(`
"'use server'

import fs from 'node:fs'

export async function formAction1(formData: FormData) {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}

export async function formAction2(formData: FormData) {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}

import {registerServerReference} from "react-server-dom-webpack/server";
registerServerReference(formAction1,"some/path/to/actions.ts","formAction1");
registerServerReference(formAction2,"some/path/to/actions.ts","formAction2");
"
`)
})

it('should handle arrow function', async () => {
const id = 'some/path/to/actions.ts'
const input = `
'use server'

import fs from 'node:fs'

export const formAction = async (formData: FormData) => {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}`.trim()

const output = await pluginTransform(input, id)

expect(output).toMatchInlineSnapshot(`
"'use server'

import fs from 'node:fs'

export const formAction = async (formData: FormData) => {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}

import {registerServerReference} from "react-server-dom-webpack/server";
if (typeof formAction === "function") registerServerReference(formAction,"some/path/to/actions.ts","formAction");
"
`)
})

it.todo('should handle default exported arrow function', async () => {
const id = 'some/path/to/actions.ts'
const input = `
'use server'

import fs from 'node:fs'

export default async (formData: FormData) => {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}`.trim()

const output = await pluginTransform(input, id)

expect(output).toMatchInlineSnapshot(`
"'use server'

import fs from 'node:fs'

export default async (formData: FormData) => {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}

import {registerServerReference} from "react-server-dom-webpack/server";
registerServerReference(default,"some/path/to/actions.ts","default");
"
`)
})

it('should handle default exported named function', async () => {
const id = 'some/path/to/actions.ts'
const input = `
"use server"

import fs from 'node:fs'

export default async function formAction(formData: FormData) {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}`.trim()

const output = await pluginTransform(input, id)

expect(output).toMatchInlineSnapshot(`
""use server"

import fs from 'node:fs'

export default async function formAction(formData: FormData) {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\`
)
}

import {registerServerReference} from "react-server-dom-webpack/server";
registerServerReference(formAction,"some/path/to/actions.ts","default");
"
`)
})

it.todo('should handle default exported anonymous function', async () => {
const id = 'some/path/to/actions.ts'
const input = `
'use server'

import fs from 'node:fs'

export default async function (formData: FormData) {
await fs.promises.writeFile(
'settings.json',
\`{ "delay": \${formData.get('delay')} }\n\`
)
}`

const output = await pluginTransform(input, id)

if (typeof output !== 'string') {
throw new Error('Expected output to be a string')
}

// Check that the file has a "use server" directive at the top
// Comments and other directives are allowed before it.
// Maybe also imports, I'm not sure, but am going to allow it for now. If
// someone finds a problem with that, we can revisit.
const outputLines = output.split('\n')
const firstCodeLineIndex = outputLines.findIndex(
(line) =>
line.startsWith('export ') ||
line.startsWith('async ') ||
line.startsWith('function ') ||
line.startsWith('const ') ||
line.startsWith('let ') ||
line.startsWith('var '),
)
expect(
outputLines
.slice(0, firstCodeLineIndex)
.some((line) => line.startsWith('"use server"')),
).toBeTruthy()
expect(output).toContain(
'import {registerServerReference} from "react-server-dom-webpack/server";',
)
expect(output).toContain(
`registerServerReference(formAction,"${id}","default");`,
)
// One import and (exactly) one call to registerServerReference, so two
// matches
expect(output.match(/registerServerReference/g)).toHaveLength(2)
})
})
25 changes: 10 additions & 15 deletions packages/vite/src/plugins/vite-plugin-rsc-transform-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,9 @@ export function rscTransformUseServerPlugin(): Plugin {
let useClient = false
let useServer = false

for (let i = 0; i < body.length; i++) {
const node = body[i]

for (const node of body) {
if (node.type !== 'ExpressionStatement' || !node.directive) {
break
continue
}

if (node.directive === 'use client') {
Expand All @@ -46,17 +44,17 @@ export function rscTransformUseServerPlugin(): Plugin {
}
}

if (!useServer) {
return code
}

if (useClient && useServer) {
throw new Error(
'Cannot have both "use client" and "use server" directives in the same file.',
)
}

const transformedCode = transformServerModule(body, id, code)
let transformedCode = code

if (useServer) {
transformedCode = transformServerModule(body, id, code)
}

return transformedCode
},
Expand Down Expand Up @@ -119,9 +117,7 @@ function transformServerModule(
const localNames = new Map<string, string>()
const localTypes = new Map<string, string>()

for (let i = 0; i < body.length; i++) {
const node = body[i]

for (const node of body) {
switch (node.type) {
case 'ExportAllDeclaration':
// If export * is used, the other file needs to explicitly opt into "use server" too.
Expand All @@ -137,7 +133,7 @@ function transformServerModule(
}
}

continue
break

case 'ExportNamedDeclaration':
if (node.declaration) {
Expand Down Expand Up @@ -173,12 +169,11 @@ function transformServerModule(
}
}

continue
break
}
}

let newSrc =
'"use server"\n' +
code +
'\n\n' +
'import {registerServerReference} from ' +
Expand Down
Loading