Skip to content

Commit

Permalink
RSC: Inline transform plugin code and add tests (#10181)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tobbe committed Mar 10, 2024
1 parent 2ecd37b commit a8dd21d
Show file tree
Hide file tree
Showing 3 changed files with 139 additions and 59 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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.js'
)
).replaceAll('\\', '\\\\')}#Counter"}});
`
)
})
})
113 changes: 54 additions & 59 deletions packages/vite/src/plugins/vite-plugin-rsc-transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,66 @@ export function rscTransformPlugin(
): Plugin {
return {
name: 'rsc-transform-plugin',
async transform(code, id) {
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')) {
return code
}

const transformedCode = await transformModuleIfNeeded(
code,
id,
clientEntryFiles
)
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 (!useClient && !useServer) {
return code
}

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

let transformedCode: string

if (useClient) {
transformedCode = await transformClientModule(
code,
body,
id,
clientEntryFiles
)
} else {
transformedCode = transformServerModule(code, body, id)
}

return transformedCode
},
Expand Down Expand Up @@ -314,56 +362,3 @@ async function transformClientModule(

return newSrc
}

async function transformModuleIfNeeded(
source: string,
url: string,
clientEntryFile?: Record<string, string>
): Promise<string> {
let body

try {
body = acorn.parse(source, {
ecmaVersion: 2024,
sourceType: 'module',
}).body
} catch (x: any) {
console.error('Error parsing %s %s', url, x.message)
return source
}

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 (!useClient && !useServer) {
return source
}

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

if (useClient) {
return transformClientModule(source, body, url, clientEntryFile)
}

return transformServerModule(source, body, url)
}
2 changes: 2 additions & 0 deletions packages/vite/src/rsc/rscWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ registerFwGlobals()
// to use it like a production server like this?
// TODO (RSC): Do we need to pass `define` here with RWJS_ENV etc? What about
// `envFile: false`?
// TODO (RSC): Do we need to care about index.html as it says in the docs
// https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-react#middleware-mode
const vitePromise = createServer({
plugins: [
rscReloadPlugin((type) => {
Expand Down

0 comments on commit a8dd21d

Please sign in to comment.