diff --git a/packages/vite/src/plugins/__tests__/vite-plugin-rsc-transform.test.mts b/packages/vite/src/plugins/__tests__/vite-plugin-rsc-transform.test.mts deleted file mode 100644 index c5b83872f09a..000000000000 --- a/packages/vite/src/plugins/__tests__/vite-plugin-rsc-transform.test.mts +++ /dev/null @@ -1,83 +0,0 @@ -import * as path from 'node:path' - -import { vol } from 'memfs' - -import { rscTransformPlugin } from '../vite-plugin-rsc-transform.js' -import { afterAll, beforeAll, describe, it, expect, vi } from 'vitest' - -const clientEntryFiles = { - 'rsc-AboutCounter.tsx-0': - '/Users/tobbe/rw-app/web/src/components/Counter/AboutCounter.tsx', - 'rsc-Counter.tsx-1': - '/Users/tobbe/rw-app/web/src/components/Counter/Counter.tsx', - 'rsc-NewUserExample.tsx-2': - '/Users/tobbe/rw-app/web/src/components/UserExample/NewUserExample/NewUserExample.tsx', -} - -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/' - vol.fromJSON({ 'redwood.toml': '' }, process.env.RWJS_CWD) -}) - -afterAll(() => { - process.env.RWJS_CWD = RWJS_CWD -}) - -describe('rscTransformPlugin', () => { - it('should insert Symbol.for("react.client.reference")', async () => { - const plugin = rscTransformPlugin(clientEntryFiles) - - if (typeof plugin.transform !== 'function') { - return - } - - // Calling `bind` to please TS - // See https://stackoverflow.com/a/70463512/88106 - const output = await plugin.transform.bind({})( - `"use client"; -import { jsx, jsxs } from "react/jsx-runtime"; -import React from "react"; -import "client-only"; -import styles from "./Counter.module.css"; -import "./Counter.css"; -export const Counter = () => { - const [count, setCount] = React.useState(0); - return /* @__PURE__ */ jsxs("div", { style: { - border: "3px blue dashed", - margin: "1em", - padding: "1em" - }, children: [ - /* @__PURE__ */ jsxs("p", { children: [ - "Count: ", - count - ] }), - /* @__PURE__ */ jsx("button", { onClick: () => setCount((c) => c + 1), children: "Increment" }), - /* @__PURE__ */ jsx("h3", { className: styles.header, children: "This is a client component." }) - ] }); -};`, - '/Users/tobbe/rw-app/web/src/components/Counter/Counter.tsx' - ) - - expect(output).toEqual( - `const CLIENT_REFERENCE = Symbol.for('react.client.reference'); -export const Counter = Object.defineProperties(function() {throw new Error("Attempted to call Counter() from the server but Counter is on the client. It's not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.");},{$$typeof: {value: CLIENT_REFERENCE},$$id: {value: "${( - path.sep + - path.join( - 'Users', - 'tobbe', - 'rw-app', - 'web', - 'dist', - 'rsc', - 'assets', - 'rsc-Counter.tsx-1.mjs' - ) - ).replaceAll('\\', '\\\\')}#Counter"}}); -` - ) - }) -}) diff --git a/packages/vite/src/plugins/vite-plugin-rsc-transform.ts b/packages/vite/src/plugins/vite-plugin-rsc-transform-client.ts similarity index 62% rename from packages/vite/src/plugins/vite-plugin-rsc-transform.ts rename to packages/vite/src/plugins/vite-plugin-rsc-transform-client.ts index 996713506d01..8ec9ce1fd885 100644 --- a/packages/vite/src/plugins/vite-plugin-rsc-transform.ts +++ b/packages/vite/src/plugins/vite-plugin-rsc-transform-client.ts @@ -5,15 +5,15 @@ import type { Plugin } from 'vite' import { getPaths } from '@redwoodjs/project-config' -export function rscTransformPlugin( +export function rscTransformUseClientPlugin( clientEntryFiles: Record, ): Plugin { return { - name: 'rsc-transform-plugin', + name: 'rsc-transform-use-client-plugin', transform: async function (code, id) { // Do a quick check for the exact string. If it doesn't exist, don't // bother parsing. - if (!code.includes('use client') && !code.includes('use server')) { + if (!code.includes('use client')) { return code } @@ -57,7 +57,7 @@ export function rscTransformPlugin( } } - if (!useClient && !useServer) { + if (!useClient) { return code } @@ -67,138 +67,18 @@ export function rscTransformPlugin( ) } - let transformedCode: string - - if (useClient) { - transformedCode = await transformClientModule( - code, - body, - id, - clientEntryFiles, - ) - } else { - transformedCode = transformServerModule(code, body, id) - } + const transformedCode = await transformClientModule( + code, + body, + id, + clientEntryFiles, + ) return transformedCode }, } } -function addLocalExportedNames(names: Map, node: any) { - switch (node.type) { - case 'Identifier': - names.set(node.name, node.name) - return - - case 'ObjectPattern': - for (let i = 0; i < node.properties.length; i++) { - addLocalExportedNames(names, node.properties[i]) - } - - return - - case 'ArrayPattern': - for (let i = 0; i < node.elements.length; i++) { - const element = node.elements[i] - if (element) { - addLocalExportedNames(names, element) - } - } - - return - - case 'Property': - addLocalExportedNames(names, node.value) - return - - case 'AssignmentPattern': - addLocalExportedNames(names, node.left) - return - - case 'RestElement': - addLocalExportedNames(names, node.argument) - return - - case 'ParenthesizedExpression': - addLocalExportedNames(names, node.expression) - return - } -} - -function transformServerModule(source: string, body: any, url: string): string { - // If the same local name is exported more than once, we only need one of the names. - const localNames = new Map() - const localTypes = new Map() - - for (let i = 0; i < body.length; i++) { - const node = body[i] - - switch (node.type) { - case 'ExportAllDeclaration': - // If export * is used, the other file needs to explicitly opt into "use server" too. - break - - case 'ExportDefaultDeclaration': - if (node.declaration.type === 'Identifier') { - localNames.set(node.declaration.name, 'default') - } else if (node.declaration.type === 'FunctionDeclaration') { - if (node.declaration.id) { - localNames.set(node.declaration.id.name, 'default') - localTypes.set(node.declaration.id.name, 'function') - } - } - - continue - - case 'ExportNamedDeclaration': - if (node.declaration) { - if (node.declaration.type === 'VariableDeclaration') { - const declarations = node.declaration.declarations - - for (let j = 0; j < declarations.length; j++) { - addLocalExportedNames(localNames, declarations[j].id) - } - } else { - const name = node.declaration.id.name - localNames.set(name, name) - - if (node.declaration.type === 'FunctionDeclaration') { - localTypes.set(name, 'function') - } - } - } - - if (node.specifiers) { - const specifiers = node.specifiers - - for (let j = 0; j < specifiers.length; j++) { - const specifier = specifiers[j] - localNames.set(specifier.local.name, specifier.exported.name) - } - } - - continue - } - } - - let newSrc = source + '\n\n;' - localNames.forEach(function (exported, local) { - if (localTypes.get(local) !== 'function') { - // We first check if the export is a function and if so annotate it. - newSrc += 'if (typeof ' + local + ' === "function") ' - } - - newSrc += 'Object.defineProperties(' + local + ',{' - newSrc += '$$typeof: {value: Symbol.for("react.server.reference")},' - newSrc += '$$id: {value: ' + JSON.stringify(url + '#' + exported) + '},' - newSrc += '$$bound: { value: null }' - newSrc += '});\n' - }) - - return newSrc -} - function addExportNames(names: Array, node: any) { switch (node.type) { case 'Identifier': diff --git a/packages/vite/src/plugins/vite-plugin-rsc-transform-server.ts b/packages/vite/src/plugins/vite-plugin-rsc-transform-server.ts new file mode 100644 index 000000000000..92e683bdb520 --- /dev/null +++ b/packages/vite/src/plugins/vite-plugin-rsc-transform-server.ts @@ -0,0 +1,183 @@ +import * as acorn from 'acorn-loose' +import type { Plugin } from 'vite' + +export function rscTransformUseServerPlugin(): Plugin { + return { + name: 'rsc-transform-use-server-plugin', + transform: async function (code, id) { + // Do a quick check for the exact string. If it doesn't exist, don't + // bother parsing. + if (!code.includes('use server')) { + return code + } + + // TODO (RSC): Bad bad hack. Don't do this. + // At least look for something that's guaranteed to be only present in + // transformed modules + // Ideally don't even try to transform twice + if (code.includes('$$id')) { + // Already transformed + return code + } + + let body + + try { + body = acorn.parse(code, { + ecmaVersion: 2024, + sourceType: 'module', + }).body + } catch (x: any) { + console.error('Error parsing %s %s', id, x.message) + return code + } + + let useClient = false + let useServer = false + + for (let i = 0; i < body.length; i++) { + const node = body[i] + + if (node.type !== 'ExpressionStatement' || !node.directive) { + break + } + + if (node.directive === 'use client') { + useClient = true + } + + if (node.directive === 'use server') { + useServer = true + } + } + + 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) + + return transformedCode + }, + } +} + +function addLocalExportedNames(names: Map, node: any) { + switch (node.type) { + case 'Identifier': + names.set(node.name, node.name) + return + + case 'ObjectPattern': + for (let i = 0; i < node.properties.length; i++) { + addLocalExportedNames(names, node.properties[i]) + } + + return + + case 'ArrayPattern': + for (let i = 0; i < node.elements.length; i++) { + const element = node.elements[i] + if (element) { + addLocalExportedNames(names, element) + } + } + + return + + case 'Property': + addLocalExportedNames(names, node.value) + return + + case 'AssignmentPattern': + addLocalExportedNames(names, node.left) + return + + case 'RestElement': + addLocalExportedNames(names, node.argument) + return + + case 'ParenthesizedExpression': + addLocalExportedNames(names, node.expression) + return + } +} + +function transformServerModule(body: any, url: string, code: string): string { + // If the same local name is exported more than once, we only need one of the names. + const localNames = new Map() + const localTypes = new Map() + + for (let i = 0; i < body.length; i++) { + const node = body[i] + + switch (node.type) { + case 'ExportAllDeclaration': + // If export * is used, the other file needs to explicitly opt into "use server" too. + break + + case 'ExportDefaultDeclaration': + if (node.declaration.type === 'Identifier') { + localNames.set(node.declaration.name, 'default') + } else if (node.declaration.type === 'FunctionDeclaration') { + if (node.declaration.id) { + localNames.set(node.declaration.id.name, 'default') + localTypes.set(node.declaration.id.name, 'function') + } + } + + continue + + case 'ExportNamedDeclaration': + if (node.declaration) { + if (node.declaration.type === 'VariableDeclaration') { + const declarations = node.declaration.declarations + + for (let j = 0; j < declarations.length; j++) { + addLocalExportedNames(localNames, declarations[j].id) + } + } else { + const name = node.declaration.id.name + localNames.set(name, name) + + if (node.declaration.type === 'FunctionDeclaration') { + localTypes.set(name, 'function') + } + } + } + + if (node.specifiers) { + const specifiers = node.specifiers + + for (let j = 0; j < specifiers.length; j++) { + const specifier = specifiers[j] + localNames.set(specifier.local.name, specifier.exported.name) + } + } + + continue + } + } + + let newSrc = '"use server"\n' + code + '\n\n' + localNames.forEach(function (exported, local) { + if (localTypes.get(local) !== 'function') { + // We first check if the export is a function and if so annotate it. + newSrc += 'if (typeof ' + local + ' === "function") ' + } + + newSrc += 'Object.defineProperties(' + local + ',{' + newSrc += '$$typeof: {value: Symbol.for("react.server.reference")},' + newSrc += '$$id: {value: ' + JSON.stringify(url + '#' + exported) + '},' + newSrc += '$$bound: { value: null }' + newSrc += '});\n\n' + }) + + return newSrc +} diff --git a/packages/vite/src/rsc/rscBuildForServer.ts b/packages/vite/src/rsc/rscBuildForServer.ts index 3e6c0a633036..053af9819cb7 100644 --- a/packages/vite/src/rsc/rscBuildForServer.ts +++ b/packages/vite/src/rsc/rscBuildForServer.ts @@ -5,7 +5,8 @@ import { build as viteBuild } from 'vite' import { getPaths } from '@redwoodjs/project-config' import { onWarn } from '../lib/onWarn.js' -import { rscTransformPlugin } from '../plugins/vite-plugin-rsc-transform.js' +import { rscTransformUseClientPlugin } from '../plugins/vite-plugin-rsc-transform-client.js' +import { rscTransformUseServerPlugin } from '../plugins/vite-plugin-rsc-transform-server.js' /** * RSC build. Step 3. @@ -83,7 +84,8 @@ export async function rscBuildForServer( // /Users/tobbe/.../rw-app/web/dist/server/assets/rsc0.js // That's why it needs the `clientEntryFiles` data // (It does other things as well, but that's why it needs clientEntryFiles) - rscTransformPlugin(clientEntryFiles), + rscTransformUseClientPlugin(clientEntryFiles), + rscTransformUseServerPlugin(), ], build: { ssr: true, diff --git a/packages/vite/src/rsc/rscWorker.ts b/packages/vite/src/rsc/rscWorker.ts index 95900c2eab74..e7d94490f039 100644 --- a/packages/vite/src/rsc/rscWorker.ts +++ b/packages/vite/src/rsc/rscWorker.ts @@ -22,7 +22,8 @@ import type { defineEntries, GetEntry } from '../entries.js' import { registerFwGlobals } from '../lib/registerGlobals.js' import { StatusError } from '../lib/StatusError.js' import { rscReloadPlugin } from '../plugins/vite-plugin-rsc-reload.js' -import { rscTransformPlugin } from '../plugins/vite-plugin-rsc-transform.js' +import { rscTransformUseClientPlugin } from '../plugins/vite-plugin-rsc-transform-client.js' +import { rscTransformUseServerPlugin } from '../plugins/vite-plugin-rsc-transform-server.js' import type { RenderInput, @@ -115,8 +116,9 @@ const handleRender = async ({ id, input }: MessageReq & { type: 'render' }) => { // server. So we have to register them here again. registerFwGlobals() -// TODO: this was copied from waku; they have a todo to remove it. -// We need this to fix a WebSocket error in dev, `WebSocket server error: Port is already in use`. +// TODO (RSC): this was copied from waku; they have a todo to remove it. +// We need this to fix a WebSocket error in dev, `WebSocket server error: Port +// is already in use`. const dummyServer = new Server() // TODO (RSC): `createServer` is mostly used to create a dev server. Is it OK @@ -135,7 +137,8 @@ const vitePromise = createServer({ const message: MessageRes = { type } parentPort.postMessage(message) }), - rscTransformPlugin({}), + rscTransformUseClientPlugin({}), + rscTransformUseServerPlugin(), ], ssr: { resolve: {