diff --git a/examples/04_promise/src/components/Counter.tsx b/examples/04_promise/src/components/Counter.tsx index 947c58349..340d1331f 100644 --- a/examples/04_promise/src/components/Counter.tsx +++ b/examples/04_promise/src/components/Counter.tsx @@ -3,6 +3,8 @@ import { Suspense, useState, use } from 'react'; +import { Hello } from './Hello.js'; + export const Counter = ({ delayedMessage, }: { @@ -17,6 +19,7 @@ export const Counter = ({ + ); }; diff --git a/examples/04_promise/src/components/Hello.tsx b/examples/04_promise/src/components/Hello.tsx new file mode 100644 index 000000000..ce943f5cb --- /dev/null +++ b/examples/04_promise/src/components/Hello.tsx @@ -0,0 +1,7 @@ +export const Hello = () => { + return ( +
+ This is a component without {'"use client"'}. +
+ ); +}; diff --git a/packages/waku/src/lib/handlers/dev-worker-api.ts b/packages/waku/src/lib/handlers/dev-worker-api.ts index 0bb5db26b..50dad308b 100644 --- a/packages/waku/src/lib/handlers/dev-worker-api.ts +++ b/packages/waku/src/lib/handlers/dev-worker-api.ts @@ -4,7 +4,7 @@ import type { } from 'node:worker_threads'; import type { ResolvedConfig } from '../config.js'; -import type { ModuleImportResult } from '../plugins/vite-plugin-rsc-hmr.js'; +import type { HotUpdatePayload } from '../plugins/vite-plugin-rsc-hmr.js'; export type RenderRequest = { input: string; @@ -37,9 +37,7 @@ export type MessageReq = }; export type MessageRes = - | { type: 'full-reload' } - | { type: 'hot-import'; source: string } - | { type: 'module-import'; result: ModuleImportResult } + | { type: 'hot-update'; payload: HotUpdatePayload } | { id: number; type: 'start'; context: unknown; stream: ReadableStream } | { id: number; type: 'err'; err: unknown; statusCode?: number } | { id: number; type: 'moduleId'; moduleId: string } @@ -108,37 +106,13 @@ const getWorker = () => { return workerPromise; }; -export async function registerReloadCallback( - fn: (type: 'full-reload') => void, +export async function registerHotUpdateCallback( + fn: (payload: HotUpdatePayload) => void, ) { const worker = await getWorker(); const listener = (mesg: MessageRes) => { - if (mesg.type === 'full-reload') { - fn(mesg.type); - } - }; - worker.on('message', listener); - return () => worker.off('message', listener); -} - -export async function registerImportCallback(fn: (source: string) => void) { - const worker = await getWorker(); - const listener = (mesg: MessageRes) => { - if (mesg.type === 'hot-import') { - fn(mesg.source); - } - }; - worker.on('message', listener); - return () => worker.off('message', listener); -} - -export async function registerModuleCallback( - fn: (result: ModuleImportResult) => void, -) { - const worker = await getWorker(); - const listener = (mesg: MessageRes) => { - if (mesg.type === 'module-import') { - fn(mesg.result); + if (mesg.type === 'hot-update') { + fn(mesg.payload); } }; worker.on('message', listener); diff --git a/packages/waku/src/lib/handlers/dev-worker-impl.ts b/packages/waku/src/lib/handlers/dev-worker-impl.ts index 73b2450b7..ebc479701 100644 --- a/packages/waku/src/lib/handlers/dev-worker-impl.ts +++ b/packages/waku/src/lib/handlers/dev-worker-impl.ts @@ -20,7 +20,6 @@ import { renderRsc, getSsrConfig } from '../renderers/rsc-renderer.js'; import { nonjsResolvePlugin } from '../plugins/vite-plugin-nonjs-resolve.js'; import { rscTransformPlugin } from '../plugins/vite-plugin-rsc-transform.js'; import { rscEnvPlugin } from '../plugins/vite-plugin-rsc-env.js'; -import { rscReloadPlugin } from '../plugins/vite-plugin-rsc-reload.js'; import { rscDelegatePlugin } from '../plugins/vite-plugin-rsc-delegate.js'; import { mergeUserViteConfig } from '../utils/merge-vite-config.js'; @@ -107,8 +106,6 @@ const handleGetSsrConfig = async ( const dummyServer = new Server(); // FIXME we hope to avoid this hack -const moduleImports: Set = new Set(); - const mergedViteConfig = await mergeUserViteConfig({ plugins: [ viteReact(), @@ -117,21 +114,10 @@ const mergedViteConfig = await mergeUserViteConfig({ { name: 'rsc-hmr-plugin', enforce: 'post' }, // dummy to match with handler-dev.ts nonjsResolvePlugin(), rscTransformPlugin({ isBuild: false }), - rscReloadPlugin(moduleImports, (type) => { - const mesg: MessageRes = { type }; + rscDelegatePlugin((payload) => { + const mesg: MessageRes = { type: 'hot-update', payload }; parentPort!.postMessage(mesg); }), - rscDelegatePlugin( - moduleImports, - (source) => { - const mesg: MessageRes = { type: 'hot-import', source }; - parentPort!.postMessage(mesg); - }, - (result) => { - const mesg: MessageRes = { type: 'module-import', result }; - parentPort!.postMessage(mesg); - }, - ), ], optimizeDeps: { include: ['react-server-dom-webpack/client', 'react-dom'], diff --git a/packages/waku/src/lib/handlers/handler-dev.ts b/packages/waku/src/lib/handlers/handler-dev.ts index 8a4406169..a8c9126c7 100644 --- a/packages/waku/src/lib/handlers/handler-dev.ts +++ b/packages/waku/src/lib/handlers/handler-dev.ts @@ -14,19 +14,13 @@ import { renderHtml } from '../renderers/html-renderer.js'; import { decodeInput, hasStatusCode } from '../renderers/utils.js'; import { initializeWorker, - registerReloadCallback, - registerImportCallback, - registerModuleCallback, + registerHotUpdateCallback, renderRscWithWorker, getSsrConfigWithWorker, } from './dev-worker-api.js'; import { patchReactRefresh } from '../plugins/patch-react-refresh.js'; import { rscIndexPlugin } from '../plugins/vite-plugin-rsc-index.js'; -import { - rscHmrPlugin, - hotImport, - moduleImport, -} from '../plugins/vite-plugin-rsc-hmr.js'; +import { rscHmrPlugin, hotUpdate } from '../plugins/vite-plugin-rsc-hmr.js'; import { rscEnvPlugin } from '../plugins/vite-plugin-rsc-env.js'; import type { BaseReq, BaseRes, Handler } from './types.js'; import { mergeUserViteConfig } from '../utils/merge-vite-config.js'; @@ -66,7 +60,6 @@ export function createHandler< rscHmrPlugin(), { name: 'nonjs-resolve-plugin' }, // dummy to match with dev-worker-impl.ts { name: 'rsc-transform-plugin' }, // dummy to match with dev-worker-impl.ts - { name: 'rsc-reload-plugin' }, // dummy to match with dev-worker-impl.ts { name: 'rsc-delegate-plugin' }, // dummy to match with dev-worker-impl.ts ], optimizeDeps: { @@ -89,9 +82,7 @@ export function createHandler< }); const vite = await createViteServer(mergedViteConfig); initializeWorker(config); - registerReloadCallback((type) => vite.ws.send({ type })); - registerImportCallback((source) => hotImport(vite, source)); - registerModuleCallback((result) => moduleImport(vite, result)); + registerHotUpdateCallback((payload) => hotUpdate(vite, payload)); return vite; }); diff --git a/packages/waku/src/lib/plugins/vite-plugin-rsc-delegate.ts b/packages/waku/src/lib/plugins/vite-plugin-rsc-delegate.ts index 2b27fa11a..8557293f2 100644 --- a/packages/waku/src/lib/plugins/vite-plugin-rsc-delegate.ts +++ b/packages/waku/src/lib/plugins/vite-plugin-rsc-delegate.ts @@ -2,17 +2,36 @@ import path from 'node:path'; import type { Plugin, ViteDevServer } from 'vite'; import * as swc from '@swc/core'; -import type { ModuleImportResult } from './vite-plugin-rsc-hmr.js'; +import type { HotUpdatePayload } from './vite-plugin-rsc-hmr.js'; + +const isClientEntry = (id: string, code: string) => { + const ext = path.extname(id); + if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) { + const mod = swc.parseSync(code, { + syntax: ext === '.ts' || ext === '.tsx' ? 'typescript' : 'ecmascript', + tsx: ext === '.tsx', + }); + for (const item of mod.body) { + if ( + item.type === 'ExpressionStatement' && + item.expression.type === 'StringLiteral' && + item.expression.value === 'use client' + ) { + return true; + } + } + } + return false; +}; // import { CSS_LANGS_RE } from "vite/dist/node/constants.js"; const CSS_LANGS_RE = /\.(css|less|sass|scss|styl|stylus|pcss|postcss|sss)(?:$|\?)/; export function rscDelegatePlugin( - moduleImports: Set, - sourceCallback: (source: string) => void, - moduleCallback: (result: ModuleImportResult) => void, + callback: (payload: HotUpdatePayload) => void, ): Plugin { + const moduleImports: Set = new Set(); let mode = 'development'; let base = '/'; let server: ViteDevServer; @@ -25,13 +44,24 @@ export function rscDelegatePlugin( configureServer(serverInstance) { server = serverInstance; }, - async handleHotUpdate({ file }) { - if (moduleImports.has(file)) { - // re-inject - const transformedResult = await server.transformRequest(file); - if (transformedResult) { - const { default: source } = await server.ssrLoadModule(file); - moduleCallback({ ...transformedResult, source, id: file }); + async handleHotUpdate(ctx) { + if (mode === 'development') { + if (moduleImports.has(ctx.file)) { + // re-inject + const transformedResult = await server.transformRequest(ctx.file); + if (transformedResult) { + const { default: source } = await server.ssrLoadModule(ctx.file); + callback({ + type: 'custom', + event: 'module-import', + data: { ...transformedResult, source, id: ctx.file }, + }); + } + } else if ( + ctx.modules.length && + !isClientEntry(ctx.file, await ctx.read()) + ) { + callback({ type: 'custom', event: 'rsc-reload' }); } } }, @@ -50,7 +80,7 @@ export function rscDelegatePlugin( if (item.source.value.startsWith('virtual:')) { // HACK this relies on Vite's internal implementation detail. const source = base + '@id/__x00__' + item.source.value; - sourceCallback(source); + callback({ type: 'custom', event: 'hot-import', data: source }); } else if (CSS_LANGS_RE.test(item.source.value)) { const resolvedSource = await server.pluginContainer.resolveId( item.source.value, @@ -66,11 +96,15 @@ export function rscDelegatePlugin( ); if (transformedResult) { moduleImports.add(resolvedSource.id); - moduleCallback({ - ...transformedResult, - source, - id: resolvedSource.id, - css: true, + callback({ + type: 'custom', + event: 'module-import', + data: { + ...transformedResult, + source, + id: resolvedSource.id, + css: true, + }, }); } } diff --git a/packages/waku/src/lib/plugins/vite-plugin-rsc-hmr.ts b/packages/waku/src/lib/plugins/vite-plugin-rsc-hmr.ts index f2abb5541..cb52c00d3 100644 --- a/packages/waku/src/lib/plugins/vite-plugin-rsc-hmr.ts +++ b/packages/waku/src/lib/plugins/vite-plugin-rsc-hmr.ts @@ -5,19 +5,28 @@ import type { ViteDevServer, } from 'vite'; -export type ModuleImportResult = TransformResult & { +import { + joinPath, + fileURLToFilePath, + decodeFilePathFromAbsolute, +} from '../utils/path.js'; + +type ModuleImportResult = TransformResult & { id: string; // non-transformed result of `TransformResult.code` source: string; css?: boolean; }; -const customCode = ` +const injectingHmrCode = ` import { createHotContext as __vite__createHotContext } from "/@vite/client"; import.meta.hot = __vite__createHotContext(import.meta.url); if (import.meta.hot && !globalThis.__WAKU_HMR_CONFIGURED__) { globalThis.__WAKU_HMR_CONFIGURED__ = true; + import.meta.hot.on('rsc-reload', () => { + globalThis.__WAKU_REFETCH_RSC__?.(); + }); import.meta.hot.on('hot-import', (data) => import(/* @vite-ignore */ data)); import.meta.hot.on('module-import', (data) => { // remove element with the same 'waku-module-id' @@ -39,6 +48,9 @@ if (import.meta.hot && !globalThis.__WAKU_HMR_CONFIGURED__) { `; export function rscHmrPlugin(): Plugin { + const wakuClientDist = decodeFilePathFromAbsolute( + joinPath(fileURLToFilePath(import.meta.url), '../../../client.js'), + ); let viteServer: ViteDevServer; return { name: 'rsc-hmr-plugin', @@ -52,17 +64,36 @@ export function rscHmrPlugin(): Plugin { { tag: 'script', attrs: { type: 'module', async: true }, - children: customCode, + children: injectingHmrCode, injectTo: 'head', }, ]; }, + async transform(code, id) { + if (id === wakuClientDist) { + // FIXME this is fragile. Can we do it better? + const FETCH_RSC_LINE = + 'export const fetchRSC = (input, searchParamsString, setElements, cache = fetchCache)=>{'; + return code.replace( + FETCH_RSC_LINE, + FETCH_RSC_LINE + + ` +globalThis.__WAKU_REFETCH_RSC__ = () => { + cache.splice(0); + const searchParams = new URLSearchParams(searchParamsString); + searchParams.delete('waku_router_skip'); // HACK hard coded, FIXME we need event listeners for 'rsc-reload' + const data = fetchRSC(input, searchParams.toString(), setElements, cache); + setElements((prev) => mergeElements(prev, data)); +};`, + ); + } + }, }; } const pendingMap = new WeakMap>(); -export function hotImport(vite: ViteDevServer, source: string) { +function hotImport(vite: ViteDevServer, source: string) { let sourceSet = pendingMap.get(vite); if (!sourceSet) { sourceSet = new Set(); @@ -79,10 +110,7 @@ export function hotImport(vite: ViteDevServer, source: string) { const modulePendingMap = new WeakMap>(); -export function moduleImport( - viteServer: ViteDevServer, - result: ModuleImportResult, -) { +function moduleImport(viteServer: ViteDevServer, result: ModuleImportResult) { let sourceSet = modulePendingMap.get(viteServer); if (!sourceSet) { sourceSet = new Set(); @@ -137,3 +165,21 @@ async function generateInitialScripts( } return scripts; } + +export type HotUpdatePayload = + | { type: 'full-reload' } + | { type: 'custom'; event: 'rsc-reload' } + | { type: 'custom'; event: 'hot-import'; data: string } + | { type: 'custom'; event: 'module-import'; data: ModuleImportResult }; + +export function hotUpdate(vite: ViteDevServer, payload: HotUpdatePayload) { + if (payload.type === 'full-reload') { + vite.ws.send(payload); + } else if (payload.event === 'rsc-reload') { + vite.ws.send(payload); + } else if (payload.event === 'hot-import') { + hotImport(vite, payload.data); + } else if (payload.event === 'module-import') { + moduleImport(vite, payload.data); + } +} diff --git a/packages/waku/src/lib/plugins/vite-plugin-rsc-reload.ts b/packages/waku/src/lib/plugins/vite-plugin-rsc-reload.ts deleted file mode 100644 index b4da56b37..000000000 --- a/packages/waku/src/lib/plugins/vite-plugin-rsc-reload.ts +++ /dev/null @@ -1,51 +0,0 @@ -import path from 'node:path'; -import type { Plugin } from 'vite'; -import * as swc from '@swc/core'; - -export function rscReloadPlugin( - moduleImports: Set, - fn: (type: 'full-reload') => void, -): Plugin { - let enabled = false; - const isClientEntry = (id: string, code: string) => { - const ext = path.extname(id); - if (['.ts', '.tsx', '.js', '.jsx'].includes(ext)) { - const mod = swc.parseSync(code, { - syntax: ext === '.ts' || ext === '.tsx' ? 'typescript' : 'ecmascript', - tsx: ext === '.tsx', - }); - for (const item of mod.body) { - if ( - item.type === 'ExpressionStatement' && - item.expression.type === 'StringLiteral' && - item.expression.value === 'use client' - ) { - return true; - } - } - } - return false; - }; - return { - name: 'rsc-reload-plugin', - configResolved(config) { - if (config.mode === 'development') { - enabled = true; - } - }, - async handleHotUpdate(ctx) { - if (!enabled) { - return []; - } - if ( - ctx.modules.length && - !isClientEntry(ctx.file, await ctx.read()) && - !moduleImports.has(ctx.file) - ) { - fn('full-reload'); - } else { - return []; - } - }, - }; -}