diff --git a/examples/with-rsc/src/components/Container.tsx b/examples/with-rsc/src/components/Container.tsx new file mode 100644 index 0000000000..842adceeaf --- /dev/null +++ b/examples/with-rsc/src/components/Container.tsx @@ -0,0 +1,23 @@ +'use server'; + +import EditButton from '@/components/EditButton'; +import Counter from '@/components/Counter'; +import InnerServer from '@/components/Content'; + +export default function Container() { + return ( + <> + + + + + hello world + +
{serverPrint('serverPrint call')}
+ + ); +} + +export function serverPrint(sentence) { + return sentence; +} \ No newline at end of file diff --git a/examples/with-rsc/src/components/Content.tsx b/examples/with-rsc/src/components/Content.tsx new file mode 100644 index 0000000000..8cf1e5c400 --- /dev/null +++ b/examples/with-rsc/src/components/Content.tsx @@ -0,0 +1,9 @@ +'use server'; + +export default function InnerServer() { + return ( +
+ inner server +
+ ); +} \ No newline at end of file diff --git a/examples/with-rsc/src/components/Counter.client.tsx b/examples/with-rsc/src/components/Counter.tsx similarity index 72% rename from examples/with-rsc/src/components/Counter.client.tsx rename to examples/with-rsc/src/components/Counter.tsx index c61fbaa994..3ecb96a67e 100644 --- a/examples/with-rsc/src/components/Counter.client.tsx +++ b/examples/with-rsc/src/components/Counter.tsx @@ -2,8 +2,9 @@ import { useState } from 'react'; import { useAppContext } from 'ice'; import styles from './index.module.css'; +import { clientPrint } from './EditButton'; -export default function Counter() { +export default function Counter({ children }) { const [count, setCount] = useState(0); function updateCount() { @@ -16,6 +17,8 @@ export default function Counter() { return ( ); } \ No newline at end of file diff --git a/examples/with-rsc/src/components/EditButton.client.tsx b/examples/with-rsc/src/components/EditButton.tsx similarity index 90% rename from examples/with-rsc/src/components/EditButton.client.tsx rename to examples/with-rsc/src/components/EditButton.tsx index 4a9fd16add..559c29b2c8 100644 --- a/examples/with-rsc/src/components/EditButton.client.tsx +++ b/examples/with-rsc/src/components/EditButton.tsx @@ -22,3 +22,7 @@ export default function EditButton({ noteId, children }) { ); } + +export function clientPrint(sentence) { + return sentence; +} \ No newline at end of file diff --git a/examples/with-rsc/src/components/RefreshButton.client.tsx b/examples/with-rsc/src/components/RefreshButton.client.tsx new file mode 100644 index 0000000000..643ec10bbf --- /dev/null +++ b/examples/with-rsc/src/components/RefreshButton.client.tsx @@ -0,0 +1,19 @@ +'use client'; +import { useRefresh } from '@ice/runtime'; + +export default function Button({ children }) { + const refresh = useRefresh(); + + return ( + + ); +} diff --git a/examples/with-rsc/src/pages/about.tsx b/examples/with-rsc/src/pages/about.tsx new file mode 100644 index 0000000000..b9a1859c37 --- /dev/null +++ b/examples/with-rsc/src/pages/about.tsx @@ -0,0 +1,26 @@ +import { useAppContext } from 'ice'; +import styles from './index.module.css'; +import RefreshButton from '@/components/RefreshButton.client'; +import Counter from '@/components/Counter.client'; + +if (!global.requestCount) { + global.requestCount = 0; +} + +export default function Home() { + console.log('Render: Index'); + + const appContext = useAppContext(); + console.log(appContext); + + return ( +
+

About Page

+
server request count: { global.requestCount++ }
+ + + Refresh Button + +
+ ); +} diff --git a/examples/with-rsc/src/pages/index.tsx b/examples/with-rsc/src/pages/index.tsx index 0305d82e97..56f3a70450 100644 --- a/examples/with-rsc/src/pages/index.tsx +++ b/examples/with-rsc/src/pages/index.tsx @@ -1,7 +1,6 @@ import { useAppContext } from 'ice'; import styles from './index.module.css'; -import EditButton from '@/components/EditButton.client'; -import Counter from '@/components/Counter.client'; +import Container from '@/components/Container'; export default function Home() { console.log('Render: Index'); @@ -12,10 +11,7 @@ export default function Home() { return (

Home Page

- - - hello world - +
); } diff --git a/packages/ice/package.json b/packages/ice/package.json index b876a13868..bbedfac648 100644 --- a/packages/ice/package.json +++ b/packages/ice/package.json @@ -46,6 +46,9 @@ "@swc/helpers": "0.5.1", "@types/express": "^4.17.14", "address": "^1.1.2", + "acorn": "^8.10.0", + "acorn-jsx": "^5.3.2", + "recast": "^0.23.4", "build-scripts": "^2.1.2-0", "chalk": "^4.0.0", "commander": "^9.0.0", diff --git a/packages/ice/src/esbuild/rscServerRegister.ts b/packages/ice/src/esbuild/rscServerRegister.ts index ff90045b6d..39e41c4b05 100644 --- a/packages/ice/src/esbuild/rscServerRegister.ts +++ b/packages/ice/src/esbuild/rscServerRegister.ts @@ -1,16 +1,128 @@ +import fs from 'fs'; import url from 'url'; import type { Plugin, PluginBuild } from 'esbuild'; +import { Parser } from 'acorn'; +import jsx from 'acorn-jsx'; +import recast from 'recast'; +import ts from 'typescript'; + +export function rscCodeTransform({ content, path }: { content: string; path: string }): string { + if (content.indexOf('use client') === -1 && content.indexOf('use server') === -1) { + return content; + } + + // transform tsx/ts code to es6 jsx code + if (path.endsWith('tsx') || path.endsWith('ts')) { + const result = ts.transpileModule(content, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ES2022, + jsx: ts.JsxEmit.Preserve, + moduleResolution: ts.ModuleResolutionKind.NodeJs, + }, + }); + content = result.outputText; + } + + let body; + try { + // get AST of source code. + body = (Parser.extend(jsx()).parse(content, { + ecmaVersion: 2024, + sourceType: 'module', + }) as any).body; + } catch (x) { + console.error('Error parsing %s %s %s', url, x.message, path); + return content; + } + + 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 content; + } + + if (useClient && useServer) { + throw new Error( + 'Cannot have both "use client" and "use server" directives in the same file.', + ); + } + + let source: string = content; + const moduleId: string = url.pathToFileURL(path).href; + + if (useClient) { + source = 'const Server = require(\'react-server-dom-webpack/server.node\');\n'; + source += 'const createClientModuleProxy = Server.createClientModuleProxy;\n'; + source += transformContent(moduleId); + } else if (useServer) { + source = 'const Server = require(\'react-server-dom-webpack/server.node\');\n'; + source += 'const registerServerReference = Server.registerServerReference;\n'; + for (let i = 0; i < body.length; i++) { + const node = body[i]; + if (['ExpressionStatement', 'VariableDeclaration', 'AssignmentExpression', 'ForStatement', 'IfStatement'].includes(node.type)) { + // concat top level statements. + const { start, end } = node; + const statement = content.substring(start, end); + if (statement.indexOf('use client') === -1 && statement.indexOf('use server') === -1) { + source += `${statement}\n`; + } + } else if (node.type === 'ImportDeclaration') { + // concat the 'import' statements. + const { start, end } = node; + source += `${content.substring(start, end)}\n`; + } else if (node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration') { + const { declaration } = node; + // Handling the case where the export is a function. + if (declaration.type === 'FunctionDeclaration') { + const functionName = declaration.id.name; + source += `${recast.print(declaration).code};\n`; + if (node.type === 'ExportNamedDeclaration') { + source += `registerServerReference(${functionName}, '${moduleId}', '${functionName}');\n`; + source += `module.exports.${functionName} = ${functionName};\n`; + } else { + source += `registerServerReference(${functionName}, '${moduleId}', null);\n`; + source += `module.exports = ${functionName};\n`; + } + } else { + // concat export variables + const exportNames = declaration.declarations.map(item => item.id.name); + source += `${recast.print(declaration).code}\n`; + for (const exportName of exportNames) { + source += `module.exports.${exportName} = ${exportName};\n`; + } + } + } else if (node.type === 'FunctionDeclaration') { + source += `${recast.print(node).code};\n`; + } + } + } + + return source; +} const rscServerRegister = (): Plugin => { return { name: 'rsc-server-register', setup: async (build: PluginBuild) => { - build.onLoad({ filter: /\/src\/.*\.client\.(js|ts|jsx|tsx)$/ }, async (args) => { // /src\/.*\ + build.onLoad({ filter: /\/src\/.*\.(js|ts|jsx|tsx)$/ }, async (args) => { const { path } = args; const loader = path.endsWith('.tsx') || path.endsWith('.ts') ? 'tsx' : 'jsx'; - const moduleId: string = url.pathToFileURL(path).href; - let source = 'const Server: any = require(\'react-server-dom-webpack/server.node\');const createClientModuleProxy = Server.createClientModuleProxy;'; - source += transformContent(moduleId); + let content: string = await fs.promises.readFile(path, 'utf-8'); + const source = rscCodeTransform({ content, path }); return { contents: source, loader }; }); }, @@ -18,9 +130,8 @@ const rscServerRegister = (): Plugin => { }; function transformContent(moduleId: string) { - const content = `\ - const comp = createClientModuleProxy('${moduleId}'); - module.exports = comp`; + let content = `const comp = createClientModuleProxy('${moduleId}');\n`; + content += 'module.exports = comp;\n'; return content; } diff --git a/packages/ice/tests/fixtures/rscTransform/client/clientInput1.tsx b/packages/ice/tests/fixtures/rscTransform/client/clientInput1.tsx new file mode 100644 index 0000000000..86c12f3cb8 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/client/clientInput1.tsx @@ -0,0 +1,28 @@ +'use client'; +import { useState } from 'react'; +import styles from './index.module.css'; +import { clientPrint } from './EditButton'; + +type Name = string; +interface Obj { + name: Name; + age: number; +} + +export default function Counter({ children }) { + const [count, setCount] = useState(0); + const obj: Obj = { name: 'name', age: 100 }; + + function updateCount() { + setCount(count + 1); + } + + return ( + + ); +} \ No newline at end of file diff --git a/packages/ice/tests/fixtures/rscTransform/client/clientInput2.jsx b/packages/ice/tests/fixtures/rscTransform/client/clientInput2.jsx new file mode 100644 index 0000000000..a03fa52ef3 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/client/clientInput2.jsx @@ -0,0 +1,20 @@ +'use client'; +import { useState } from 'react'; +import styles from './index.module.css'; +import { clientPrint } from './EditButton'; + +export default function Counter({ children }) { + const [count, setCount] = useState(0); + + function updateCount() { + setCount(count + 1); + } + + return ( + + ); +} \ No newline at end of file diff --git a/packages/ice/tests/fixtures/rscTransform/client/clientInput3.js b/packages/ice/tests/fixtures/rscTransform/client/clientInput3.js new file mode 100644 index 0000000000..a03fa52ef3 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/client/clientInput3.js @@ -0,0 +1,20 @@ +'use client'; +import { useState } from 'react'; +import styles from './index.module.css'; +import { clientPrint } from './EditButton'; + +export default function Counter({ children }) { + const [count, setCount] = useState(0); + + function updateCount() { + setCount(count + 1); + } + + return ( + + ); +} \ No newline at end of file diff --git a/packages/ice/tests/fixtures/rscTransform/client/clientOutput1.tsx b/packages/ice/tests/fixtures/rscTransform/client/clientOutput1.tsx new file mode 100644 index 0000000000..b798d08bba --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/client/clientOutput1.tsx @@ -0,0 +1,4 @@ +const Server = require('react-server-dom-webpack/server.node'); +const createClientModuleProxy = Server.createClientModuleProxy; +const comp = createClientModuleProxy('file:///home/runner/work/ice/ice/packages/ice/tests/fixtures/rscTransform/client/clientInput1.tsx'); +module.exports = comp; diff --git a/packages/ice/tests/fixtures/rscTransform/client/clientOutput2.jsx b/packages/ice/tests/fixtures/rscTransform/client/clientOutput2.jsx new file mode 100644 index 0000000000..f8d946183d --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/client/clientOutput2.jsx @@ -0,0 +1,4 @@ +const Server = require('react-server-dom-webpack/server.node'); +const createClientModuleProxy = Server.createClientModuleProxy; +const comp = createClientModuleProxy('file:///home/runner/work/ice/ice/packages/ice/tests/fixtures/rscTransform/client/clientInput2.jsx'); +module.exports = comp; diff --git a/packages/ice/tests/fixtures/rscTransform/client/clientOutput3.js b/packages/ice/tests/fixtures/rscTransform/client/clientOutput3.js new file mode 100644 index 0000000000..f7c6c335f9 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/client/clientOutput3.js @@ -0,0 +1,4 @@ +const Server = require('react-server-dom-webpack/server.node'); +const createClientModuleProxy = Server.createClientModuleProxy; +const comp = createClientModuleProxy('file:///home/runner/work/ice/ice/packages/ice/tests/fixtures/rscTransform/client/clientInput3.js'); +module.exports = comp; diff --git a/packages/ice/tests/fixtures/rscTransform/others/otherInput1.tsx b/packages/ice/tests/fixtures/rscTransform/others/otherInput1.tsx new file mode 100644 index 0000000000..9863b9fe70 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/others/otherInput1.tsx @@ -0,0 +1,18 @@ +import { useState } from 'react'; + +'use client' +export default function Counter({ children }) { + const [count, setCount] = useState(0); + + function updateCount() { + setCount(count + 1); + } + + return ( + + ); +} +'use client'; \ No newline at end of file diff --git a/packages/ice/tests/fixtures/rscTransform/others/otherInput2.tsx b/packages/ice/tests/fixtures/rscTransform/others/otherInput2.tsx new file mode 100644 index 0000000000..2faeb5dcd7 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/others/otherInput2.tsx @@ -0,0 +1,16 @@ +// 'use client'; +import { useState } from 'react'; +export default function Counter({ children }) { + const [count, setCount] = useState(0); + + function updateCount() { + setCount(count + 1); + } + + return ( + + ); +} \ No newline at end of file diff --git a/packages/ice/tests/fixtures/rscTransform/others/otherInput3.tsx b/packages/ice/tests/fixtures/rscTransform/others/otherInput3.tsx new file mode 100644 index 0000000000..89bce3360a --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/others/otherInput3.tsx @@ -0,0 +1,17 @@ +'use client'; +'use server'; +import { useState } from 'react'; +export default function Counter({ children }) { + const [count, setCount] = useState(0); + + function updateCount() { + setCount(count + 1); + } + + return ( + + ); +} \ No newline at end of file diff --git a/packages/ice/tests/fixtures/rscTransform/server/serverInput1.tsx b/packages/ice/tests/fixtures/rscTransform/server/serverInput1.tsx new file mode 100644 index 0000000000..8cf1e5c400 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/server/serverInput1.tsx @@ -0,0 +1,9 @@ +'use server'; + +export default function InnerServer() { + return ( +
+ inner server +
+ ); +} \ No newline at end of file diff --git a/packages/ice/tests/fixtures/rscTransform/server/serverInput2.tsx b/packages/ice/tests/fixtures/rscTransform/server/serverInput2.tsx new file mode 100644 index 0000000000..d9ddc827c5 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/server/serverInput2.tsx @@ -0,0 +1,23 @@ +'use server'; + +import EditButton from '@/components/EditButton'; +import Counter from '@/components/Counter'; +import InnerServer from '@/components/Content'; + +export default function Container() { + return ( + <> + + + + + hello world + +
{serverPrint('serverPrint call')}
+ + ); +} + +export function serverPrint(sentence: string): string { + return sentence; +} diff --git a/packages/ice/tests/fixtures/rscTransform/server/serverInput3.tsx b/packages/ice/tests/fixtures/rscTransform/server/serverInput3.tsx new file mode 100644 index 0000000000..804003c5ce --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/server/serverInput3.tsx @@ -0,0 +1,28 @@ +'use server'; + +import EditButton from '@/components/EditButton'; +import Counter from '@/components/Counter'; +import InnerServer from '@/components/Content'; + +export default function Container() { + return ( + <> + + + + + hello world + +
{serverPrint('serverPrint call')}
+
{privateFunc()}
+ + ); +} + +export function serverPrint(sentence: string): string { + return sentence; +} + +function privateFunc(): string { + return 'privateFunc'; +} \ No newline at end of file diff --git a/packages/ice/tests/fixtures/rscTransform/server/serverInput4.tsx b/packages/ice/tests/fixtures/rscTransform/server/serverInput4.tsx new file mode 100644 index 0000000000..c278b5b3cb --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/server/serverInput4.tsx @@ -0,0 +1,27 @@ +'use server'; + +import Counter from '@/components/Counter'; +import InnerServer from '@/components/Content'; + +const word = 'global variable'; +let x; +x++; +x = 10; +var a; +a = {name: 'name'} +console.log(a); +for(let i = 0; i < 10; ++i) { + if(i % 2 === 0) + console.log(i); +} + +export default function Container() { + return ( + <> + + + +
{word}
+ + ); +} diff --git a/packages/ice/tests/fixtures/rscTransform/server/serverInput5.tsx b/packages/ice/tests/fixtures/rscTransform/server/serverInput5.tsx new file mode 100644 index 0000000000..b1a228696f --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/server/serverInput5.tsx @@ -0,0 +1,13 @@ +'use server'; + +const word = 'global variable'; + +export default function Container() { + return ( + <> +
{word}
+ + ); +} + +export var tmp = 1, tmp2 = 2; \ No newline at end of file diff --git a/packages/ice/tests/fixtures/rscTransform/server/serverOutput1.tsx b/packages/ice/tests/fixtures/rscTransform/server/serverOutput1.tsx new file mode 100644 index 0000000000..a716ded984 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/server/serverOutput1.tsx @@ -0,0 +1,7 @@ +const Server = require('react-server-dom-webpack/server.node'); +const registerServerReference = Server.registerServerReference; +function InnerServer() { + return
inner server
; +}; +registerServerReference(InnerServer, 'file:///home/runner/work/ice/ice/packages/ice/tests/fixtures/rscTransform/server/serverInput1.tsx', null); +module.exports = InnerServer; diff --git a/packages/ice/tests/fixtures/rscTransform/server/serverOutput2.tsx b/packages/ice/tests/fixtures/rscTransform/server/serverOutput2.tsx new file mode 100644 index 0000000000..820b708549 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/server/serverOutput2.tsx @@ -0,0 +1,21 @@ +const Server = require('react-server-dom-webpack/server.node'); +const registerServerReference = Server.registerServerReference; +import EditButton from '@/components/EditButton'; +import Counter from '@/components/Counter'; +import InnerServer from '@/components/Content'; +function Container() { + return <> + + + + hello world +
{serverPrint("serverPrint call")}
+ ; +}; +registerServerReference(Container, 'file:///home/runner/work/ice/ice/packages/ice/tests/fixtures/rscTransform/server/serverInput2.tsx', null); +module.exports = Container; +function serverPrint(sentence) { + return sentence; +}; +registerServerReference(serverPrint, 'file:///home/runner/work/ice/ice/packages/ice/tests/fixtures/rscTransform/server/serverInput2.tsx', 'serverPrint'); +module.exports.serverPrint = serverPrint; diff --git a/packages/ice/tests/fixtures/rscTransform/server/serverOutput3.tsx b/packages/ice/tests/fixtures/rscTransform/server/serverOutput3.tsx new file mode 100644 index 0000000000..cd26878ebb --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/server/serverOutput3.tsx @@ -0,0 +1,25 @@ +const Server = require('react-server-dom-webpack/server.node'); +const registerServerReference = Server.registerServerReference; +import EditButton from '@/components/EditButton'; +import Counter from '@/components/Counter'; +import InnerServer from '@/components/Content'; +function Container() { + return <> + + + + hello world +
{serverPrint("serverPrint call")}
+
{privateFunc()}
+ ; +}; +registerServerReference(Container, 'file:///Users/lzx/Documents/project/ice/packages/ice/tests/fixtures/rscTransform/server/serverInput3.tsx', null); +module.exports = Container; +function serverPrint(sentence) { + return sentence; +}; +registerServerReference(serverPrint, 'file:///home/runner/work/ice/ice/packages/ice/tests/fixtures/rscTransform/server/serverInput3.tsx', 'serverPrint'); +module.exports.serverPrint = serverPrint; +function privateFunc() { + return "privateFunc"; +}; diff --git a/packages/ice/tests/fixtures/rscTransform/server/serveroutput4.tsx b/packages/ice/tests/fixtures/rscTransform/server/serveroutput4.tsx new file mode 100644 index 0000000000..823ea30399 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/server/serveroutput4.tsx @@ -0,0 +1,25 @@ +const Server = require('react-server-dom-webpack/server.node'); +const registerServerReference = Server.registerServerReference; +import Counter from '@/components/Counter'; +import InnerServer from '@/components/Content'; +const word = 'global variable'; +let x; +x++; +x = 10; +var a; +a = { name: 'name' }; +console.log(a); +for (let i = 0; i < 10; ++i) { + if (i % 2 === 0) + console.log(i); +} +function Container() { + return <> + + + +
{word}
+ ; +}; +registerServerReference(Container, 'file:///home/runner/work/ice/ice/packages/ice/tests/fixtures/rscTransform/server/serverInput4.tsx', null); +module.exports = Container; diff --git a/packages/ice/tests/fixtures/rscTransform/server/serveroutput5.tsx b/packages/ice/tests/fixtures/rscTransform/server/serveroutput5.tsx new file mode 100644 index 0000000000..bd4dbc11e8 --- /dev/null +++ b/packages/ice/tests/fixtures/rscTransform/server/serveroutput5.tsx @@ -0,0 +1,13 @@ +const Server = require('react-server-dom-webpack/server.node'); +const registerServerReference = Server.registerServerReference; +const word = 'global variable'; +function Container() { + return <> +
{word}
+ ; +}; +registerServerReference(Container, 'file:///home/runner/work/ice/ice/packages/ice/tests/fixtures/rscTransform/server/serverInput5.tsx', null); +module.exports = Container; +var tmp = 1, tmp2 = 2; +module.exports.tmp = tmp; +module.exports.tmp2 = tmp2; diff --git a/packages/ice/tests/rscServerRegister.test.ts b/packages/ice/tests/rscServerRegister.test.ts new file mode 100644 index 0000000000..a5d3939d98 --- /dev/null +++ b/packages/ice/tests/rscServerRegister.test.ts @@ -0,0 +1,95 @@ +import * as path from 'path'; +import { expect, it, describe } from 'vitest'; +import fse from 'fs-extra'; +import { rscCodeTransform } from '../src/esbuild/rscServerRegister'; + +const rootDir = path.join(__dirname, './fixtures/rscTransform'); + +async function getFileInfo(filePath: string) { + filePath = path.join(rootDir, filePath); + const content = await fse.readFile(filePath, 'utf-8'); + return { content, path: filePath }; +} + +describe('rsc client test', () => { + it('basic tsx client component', async () => { + const fileInfo = await getFileInfo('client/clientInput1.tsx'); + const content = rscCodeTransform(fileInfo); + const { content: expectContent } = await getFileInfo('client/clientOutput1.tsx'); + expect(content).toBe(expectContent); + }); + + it('basic jsx client component', async () => { + const fileInfo = await getFileInfo('client/clientInput2.jsx'); + const content = rscCodeTransform(fileInfo); + const { content: expectContent } = await getFileInfo('client/clientOutput2.jsx'); + expect(content).toBe(expectContent); + }); + + it('basic js client component', async () => { + const fileInfo = await getFileInfo('client/clientInput3.js'); + const content = rscCodeTransform(fileInfo); + const { content: expectContent } = await getFileInfo('client/clientOutput3.js'); + expect(content).toBe(expectContent); + }); +}); + +describe('rsc server test', () => { + it('basic server component', async () => { + const fileInfo = await getFileInfo('server/serverInput1.tsx'); + const content = rscCodeTransform(fileInfo); + const { content: expectContent } = await getFileInfo('server/serverOutput1.tsx'); + expect(content).toBe(expectContent); + }); + + it('server component with export function and typescript', async () => { + const fileInfo = await getFileInfo('server/serverInput2.tsx'); + const content = rscCodeTransform(fileInfo); + const { content: expectContent } = await getFileInfo('server/serverOutput2.tsx'); + expect(content).toBe(expectContent); + }); + + it('server component with private function and typescript', async () => { + const fileInfo = await getFileInfo('server/serverInput3.tsx'); + const content = rscCodeTransform(fileInfo); + const { content: expectContent } = await getFileInfo('server/serverOutput3.tsx'); + expect(content).toBe(expectContent); + }); + + it('server component with global statement', async () => { + const fileInfo = await getFileInfo('server/serverInput4.tsx'); + const content = rscCodeTransform(fileInfo); + const { content: expectContent } = await getFileInfo('server/serverOutput4.tsx'); + expect(content).toBe(expectContent); + }); + + it('server component with export variables', async () => { + const fileInfo = await getFileInfo('server/serverInput5.tsx'); + const content = rscCodeTransform(fileInfo); + const { content: expectContent } = await getFileInfo('server/serverOutput5.tsx'); + expect(content).toBe(expectContent); + }); +}); + +describe('rsc boundary conditions', () => { + it('use client not at first line', async () => { + const fileInfo = await getFileInfo('others/otherInput1.tsx'); + const content = rscCodeTransform(fileInfo); + expect(content.indexOf('createClientModuleProxy') === -1).toBeTruthy(); + }); + + it('\'use client\' has been commented out', async () => { + const fileInfo = await getFileInfo('others/otherInput2.tsx'); + const content = rscCodeTransform(fileInfo); + expect(content.indexOf('createClientModuleProxy') === -1).toBeTruthy(); + }); + + it('with both use client and use server', async () => { + const fileInfo = await getFileInfo('others/otherInput3.tsx'); + try { + let content = rscCodeTransform(fileInfo); + } catch (e) { + expect(e.message === 'Cannot have both "use client" and "use server" directives in the same file.').toBeTruthy(); + } + }); +}); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 2a618bfa4f..9b09642ac8 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -26,8 +26,7 @@ import type { RunClientAppOptions, CreateRoutes } from './runClientApp.js'; import { useAppContext as useInternalAppContext, useAppData, AppContextProvider } from './AppContext.js'; import { getAppData } from './appData.js'; import { useData, useConfig } from './RouteContext.js'; -import { runRSCClientApp } from './runRSCClientApp.js'; - +import { runRSCClientApp, useRefresh } from './runRSCClientApp.js'; import { Meta, Title, @@ -146,6 +145,7 @@ export { RouteErrorComponent, runRSCClientApp, + useRefresh, }; export type { diff --git a/packages/runtime/src/runRSCClientApp.tsx b/packages/runtime/src/runRSCClientApp.tsx index 14b0ece3a7..b9fe82d8e5 100644 --- a/packages/runtime/src/runRSCClientApp.tsx +++ b/packages/runtime/src/runRSCClientApp.tsx @@ -3,24 +3,61 @@ import * as ReactDOM from 'react-dom/client'; import pkg from 'react-server-dom-webpack/client'; // @ts-ignore -const { Suspense, use } = React; +const { Suspense, use, useState, createContext, useContext, startTransition } = React; const { createFromFetch } = pkg; export async function runRSCClientApp() { - function App({ response }) { - return ( - Loading...}> - {use(response)} - + const container = document.getElementById('app'); + const root = ReactDOM.createRoot(container); + root.render(); +} + +function Root() { + return ( + + ); +} + +const RouterContext = createContext(null); +const initialCache = new Map(); + +function Router() { + const [cache, setCache] = useState(initialCache); + const [location, setLocation] = useState(window.location.href); + + let content = cache.get(location); + if (!content) { + content = createFromFetch( + getReactTree(location), ); + cache.set(location, content); + } + + function refresh() { + startTransition(() => { + const nextCache = new Map(); + const nextContent = createFromFetch( + getReactTree(location), + ); + nextCache.set(location, nextContent); + setCache(nextCache); + }); } - const rscPath = location.href + (location.href.indexOf('?') ? '?rsc' : '&rsc'); - const response = createFromFetch( - fetch(rscPath), + return ( + + Loading...}> + {use(content)} + + ); +} - const container = document.getElementById('app'); - const root = ReactDOM.createRoot(container); - root.render(); +export function useRefresh() { + const router = useContext(RouterContext); + return router.refresh; +} + +function getReactTree(location) { + return fetch(location + location.indexOf('?') ? '?rsc' : '&rsc'); } \ No newline at end of file diff --git a/packages/runtime/src/runServerApp.tsx b/packages/runtime/src/runServerApp.tsx index 3c028809d1..050f5db652 100644 --- a/packages/runtime/src/runServerApp.tsx +++ b/packages/runtime/src/runServerApp.tsx @@ -256,6 +256,10 @@ async function doRender(serverContext: ServerContext, renderOptions: RenderOptio } } + if (renderOptions.clientManifest && req.url.indexOf('?') === -1) { + return renderDocument({ matches: [], routes, renderOptions, documentData }); + } + // HashRouter loads route modules by the CSR. if (appConfig?.router?.type === 'hash') { return renderDocument({ matches: [], routes, renderOptions, documentData }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 587f72c2f4..55cdc129bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1142,6 +1142,8 @@ importers: '@types/micromatch': ^4.0.2 '@types/multer': ^1.4.7 '@types/temp': ^0.9.1 + acorn: ^8.10.0 + acorn-jsx: ^5.3.2 address: ^1.1.2 build-scripts: ^2.1.2-0 chalk: ^4.0.0 @@ -1165,6 +1167,7 @@ importers: path-to-regexp: ^6.2.0 react: ^18.2.0 react-router: 6.14.2 + recast: ^0.23.4 regenerator-runtime: ^0.13.0 resolve.exports: ^1.1.0 sass: ^1.50.0 @@ -1184,6 +1187,8 @@ importers: '@ice/webpack-config': link:../webpack-config '@swc/helpers': 0.5.1 '@types/express': 4.17.17 + acorn: 8.10.0 + acorn-jsx: 5.3.2_acorn@8.10.0 address: 1.2.2 build-scripts: 2.1.2-0 chalk: 4.1.2 @@ -1202,6 +1207,7 @@ importers: mrmime: 1.0.1 open: 8.4.2 path-to-regexp: 6.2.1 + recast: 0.23.4 regenerator-runtime: 0.13.11 resolve.exports: 1.1.1 semver: 7.4.0 @@ -5565,7 +5571,7 @@ packages: '@rollup/plugin-replace': 5.0.2_rollup@2.79.1 '@rollup/pluginutils': 4.2.1 '@swc/core': 1.3.32 - acorn: 8.8.2 + acorn: 8.10.0 autoprefixer: 10.4.13_postcss@8.4.25 build-scripts: 2.1.0 cac: 6.7.14 @@ -8723,10 +8729,17 @@ packages: /acorn-globals/7.0.1: resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} dependencies: - acorn: 8.8.2 + acorn: 8.10.0 acorn-walk: 8.2.0 dev: true + /acorn-import-assertions/1.9.0_acorn@8.10.0: + resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + peerDependencies: + acorn: ^8 + dependencies: + acorn: 8.10.0 + /acorn-import-assertions/1.9.0_acorn@8.8.2: resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} peerDependencies: @@ -8734,13 +8747,12 @@ packages: dependencies: acorn: 8.8.2 - /acorn-jsx/5.3.2_acorn@8.8.2: + /acorn-jsx/5.3.2_acorn@8.10.0: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: - acorn: 8.8.2 - dev: true + acorn: 8.10.0 /acorn-loose/8.3.0: resolution: {integrity: sha512-75lAs9H19ldmW+fAbyqHdjgdCrz0pWGXKmnqFoh8PyVd1L2RIb4RzYrSjmopeqv3E1G3/Pimu6GgLlrGbrkF7w==} @@ -8771,6 +8783,11 @@ packages: hasBin: true dev: true + /acorn/8.10.0: + resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} + engines: {node: '>=0.4.0'} + hasBin: true + /acorn/8.8.2: resolution: {integrity: sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==} engines: {node: '>=0.4.0'} @@ -9251,6 +9268,16 @@ packages: engines: {node: '>=0.8'} dev: false + /assert/2.1.0: + resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} + dependencies: + call-bind: 1.0.2 + is-nan: 1.3.2 + object-is: 1.1.5 + object.assign: 4.1.4 + util: 0.12.5 + dev: false + /assertion-error/1.1.0: resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} dev: true @@ -9259,6 +9286,13 @@ packages: resolution: {integrity: sha512-eBvWn1lvIApYMhzQMsu9ciLfkBY499mFZlNqG+/9WR7PVlroQw0vG30cOQQbaKz3sCEc44TAOu2ykzqXSNnwag==} dev: true + /ast-types/0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + dependencies: + tslib: 2.5.0 + dev: false + /astral-regex/2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -12595,8 +12629,8 @@ packages: resolution: {integrity: sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: - acorn: 8.8.2 - acorn-jsx: 5.3.2_acorn@8.8.2 + acorn: 8.10.0 + acorn-jsx: 5.3.2_acorn@8.10.0 eslint-visitor-keys: 3.3.0 dev: true @@ -14393,6 +14427,14 @@ packages: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} dev: true + /is-nan/1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + dev: false + /is-negative-zero/2.0.2: resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} engines: {node: '>= 0.4'} @@ -16490,7 +16532,7 @@ packages: /mlly/1.1.1: resolution: {integrity: sha512-Jnlh4W/aI4GySPo6+DyTN17Q75KKbLTyFK8BrGhjNP4rxuUjbRWhE6gHg3bs33URWAF44FRm7gdQA348i3XxRw==} dependencies: - acorn: 8.8.2 + acorn: 8.10.0 pathe: 1.1.0 pkg-types: 1.0.2 ufo: 1.1.1 @@ -16715,7 +16757,6 @@ packages: dependencies: call-bind: 1.0.2 define-properties: 1.2.0 - dev: true /object-keys/1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} @@ -20370,6 +20411,17 @@ packages: /reading-time/1.5.0: resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} + /recast/0.23.4: + resolution: {integrity: sha512-qtEDqIZGVcSZCHniWwZWbRy79Dc6Wp3kT/UmDA2RJKBPg7+7k51aQBZirHmUGn5uvHf2rg8DkjizrN26k61ATw==} + engines: {node: '>= 4'} + dependencies: + assert: 2.1.0 + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tslib: 2.5.0 + dev: false + /rechoir/0.6.2: resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} engines: {node: '>= 0.10'} @@ -21632,7 +21684,7 @@ packages: /strip-literal/0.4.2: resolution: {integrity: sha512-pv48ybn4iE1O9RLgCAN0iU4Xv7RlBTiit6DKmMiErbs9x1wH6vXBs45tWc0H5wUIF6TLTrKweqkmYF/iraQKNw==} dependencies: - acorn: 8.8.2 + acorn: 8.10.0 dev: true /style-equal/1.0.0: @@ -22168,7 +22220,7 @@ packages: hasBin: true dependencies: '@jridgewell/source-map': 0.3.2 - acorn: 8.8.2 + acorn: 8.10.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -22398,7 +22450,7 @@ packages: '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.3 '@types/node': 17.0.45 - acorn: 8.8.2 + acorn: 8.10.0 acorn-walk: 8.2.0 arg: 4.1.3 create-require: 1.1.1 @@ -23295,7 +23347,7 @@ packages: hasBin: true dependencies: '@discoveryjs/json-ext': 0.5.7 - acorn: 8.8.2 + acorn: 8.10.0 acorn-walk: 8.2.0 chalk: 4.1.2 commander: 7.2.0 @@ -23715,8 +23767,8 @@ packages: '@webassemblyjs/ast': 1.11.1 '@webassemblyjs/wasm-edit': 1.11.1 '@webassemblyjs/wasm-parser': 1.11.1 - acorn: 8.8.2 - acorn-import-assertions: 1.9.0_acorn@8.8.2 + acorn: 8.10.0 + acorn-import-assertions: 1.9.0_acorn@8.10.0 browserslist: 4.21.5 chrome-trace-event: 1.0.3 enhanced-resolve: 5.15.0 @@ -23755,8 +23807,8 @@ packages: '@webassemblyjs/ast': 1.11.1 '@webassemblyjs/wasm-edit': 1.11.1 '@webassemblyjs/wasm-parser': 1.11.1 - acorn: 8.8.2 - acorn-import-assertions: 1.9.0_acorn@8.8.2 + acorn: 8.10.0 + acorn-import-assertions: 1.9.0_acorn@8.10.0 browserslist: 4.21.5 chrome-trace-event: 1.0.3 enhanced-resolve: 5.15.0 @@ -23795,8 +23847,8 @@ packages: '@webassemblyjs/ast': 1.11.5 '@webassemblyjs/wasm-edit': 1.11.5 '@webassemblyjs/wasm-parser': 1.11.5 - acorn: 8.8.2 - acorn-import-assertions: 1.9.0_acorn@8.8.2 + acorn: 8.10.0 + acorn-import-assertions: 1.9.0_acorn@8.10.0 browserslist: 4.21.5 chrome-trace-event: 1.0.3 enhanced-resolve: 5.14.1