Skip to content

Commit

Permalink
Support hot reload for server components (#459)
Browse files Browse the repository at this point in the history
* Support hot reload for server components

* add a shared component in examples/04

* refactor

* recover isClientEntry check
  • Loading branch information
dai-shi committed Feb 10, 2024
1 parent 87f08a2 commit b34c2f9
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 136 deletions.
3 changes: 3 additions & 0 deletions examples/04_promise/src/components/Counter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

import { Suspense, useState, use } from 'react';

import { Hello } from './Hello.js';

export const Counter = ({
delayedMessage,
}: {
Expand All @@ -17,6 +19,7 @@ export const Counter = ({
<Suspense fallback="Pending...">
<Message count={count} delayedMessage={delayedMessage} />
</Suspense>
<Hello />
</div>
);
};
Expand Down
7 changes: 7 additions & 0 deletions examples/04_promise/src/components/Hello.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export const Hello = () => {
return (
<div style={{ border: '3px gray dashed', margin: '1em', padding: '1em' }}>
This is a component without {'"use client"'}.
</div>
);
};
38 changes: 6 additions & 32 deletions packages/waku/src/lib/handlers/dev-worker-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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);
Expand Down
18 changes: 2 additions & 16 deletions packages/waku/src/lib/handlers/dev-worker-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -107,8 +106,6 @@ const handleGetSsrConfig = async (

const dummyServer = new Server(); // FIXME we hope to avoid this hack

const moduleImports: Set<string> = new Set();

const mergedViteConfig = await mergeUserViteConfig({
plugins: [
viteReact(),
Expand All @@ -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'],
Expand Down
15 changes: 3 additions & 12 deletions packages/waku/src/lib/handlers/handler-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: {
Expand All @@ -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;
});

Expand Down
68 changes: 51 additions & 17 deletions packages/waku/src/lib/plugins/vite-plugin-rsc-delegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
sourceCallback: (source: string) => void,
moduleCallback: (result: ModuleImportResult) => void,
callback: (payload: HotUpdatePayload) => void,
): Plugin {
const moduleImports: Set<string> = new Set();
let mode = 'development';
let base = '/';
let server: ViteDevServer;
Expand All @@ -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' });
}
}
},
Expand All @@ -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,
Expand All @@ -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,
},
});
}
}
Expand Down
62 changes: 54 additions & 8 deletions packages/waku/src/lib/plugins/vite-plugin-rsc-hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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',
Expand All @@ -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<ViteDevServer, Set<string>>();

export function hotImport(vite: ViteDevServer, source: string) {
function hotImport(vite: ViteDevServer, source: string) {
let sourceSet = pendingMap.get(vite);
if (!sourceSet) {
sourceSet = new Set();
Expand All @@ -79,10 +110,7 @@ export function hotImport(vite: ViteDevServer, source: string) {

const modulePendingMap = new WeakMap<ViteDevServer, Set<ModuleImportResult>>();

export function moduleImport(
viteServer: ViteDevServer,
result: ModuleImportResult,
) {
function moduleImport(viteServer: ViteDevServer, result: ModuleImportResult) {
let sourceSet = modulePendingMap.get(viteServer);
if (!sourceSet) {
sourceSet = new Set();
Expand Down Expand Up @@ -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);
}
}
Loading

0 comments on commit b34c2f9

Please sign in to comment.