diff --git a/packages/plugin-rsc/e2e/syntax-error.test.ts b/packages/plugin-rsc/e2e/syntax-error.test.ts new file mode 100644 index 000000000..75425713c --- /dev/null +++ b/packages/plugin-rsc/e2e/syntax-error.test.ts @@ -0,0 +1,207 @@ +import { test, expect } from '@playwright/test' +import { setupInlineFixture, useFixture } from './fixture' +import { waitForHydration, expectNoReload } from './helper' + +test.describe(() => { + const root = 'examples/e2e/temp/syntax-error' + + test.beforeAll(async () => { + await setupInlineFixture({ + src: 'examples/starter', + dest: root, + files: { + 'src/root.tsx': /* tsx */ ` + import { TestSyntaxErrorClient } from './client.tsx' + + export function Root() { + return ( + + + + + + +
server:ok
+ + + ) + } + `, + 'src/client.tsx': /* tsx */ ` + "use client"; + import { useState } from 'react' + + export function TestSyntaxErrorClient() { + const [count, setCount] = useState(0) + + return ( +
+ +
client:ok
+
+ ) + } + `, + }, + }) + }) + + test.describe(() => { + const f = useFixture({ root, mode: 'dev' }) + + test('client hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + await expect(page.getByTestId('client-content')).toHaveText('client:ok') + + // Set client state to verify preservation after HMR + await page.getByTestId('client-counter').click() + await expect(page.getByTestId('client-counter')).toHaveText( + 'Client Count: 1', + ) + + // add syntax error + const editor = f.createEditor('src/client.tsx') + editor.edit((s) => + s.replace( + '
client:ok
', + '
client:broken<
', + ), + ) + await expect(page.locator('vite-error-overlay')).toBeVisible() + + // fix syntax error + await page.waitForTimeout(200) + editor.edit((s) => + s.replace( + '
client:broken<
', + '
client:fixed
', + ), + ) + await expect(page.locator('vite-error-overlay')).not.toBeVisible() + await expect(page.getByTestId('client-syntax-ready')).toBeVisible() + await expect(page.getByTestId('client-content')).toHaveText( + 'client:fixed', + ) + await expect(page.getByTestId('client-counter')).toHaveText( + 'Client Count: 1', + ) + }) + }) + + test.describe(() => { + const f = useFixture({ root, mode: 'dev' }) + + test('server hmr', async ({ page }) => { + await page.goto(f.url()) + await waitForHydration(page) + await using _ = await expectNoReload(page) + + await expect(page.getByTestId('server-content')).toHaveText('server:ok') + + // Set client state to verify preservation during server HMR + await page.getByTestId('client-counter').click() + await expect(page.getByTestId('client-counter')).toHaveText( + 'Client Count: 1', + ) + + // add syntax error + const editor = f.createEditor('src/root.tsx') + editor.edit((s) => + s.replace( + '
server:ok
', + '
server:broken<
', + ), + ) + await expect(page.locator('vite-error-overlay')).toBeVisible() + + // fix syntax error + await page.waitForTimeout(200) + editor.edit((s) => + s.replace( + '
server:broken<
', + '
server:fixed
', + ), + ) + await expect(page.locator('vite-error-overlay')).not.toBeVisible() + await expect(page.getByTestId('server-content')).toHaveText( + 'server:fixed', + ) + await expect(page.getByTestId('client-counter')).toHaveText( + 'Client Count: 1', + ) + }) + }) + + test.describe(() => { + const f = useFixture({ root, mode: 'dev' }) + + test('client ssr', async ({ page }) => { + // add syntax error + const editor = f.createEditor('src/client.tsx') + editor.edit((s) => + s.replace( + '
client:ok
', + '
client:broken<
', + ), + ) + await page.goto(f.url()) + await expect(page.locator('body')).toContainText('src/client.tsx:15') + + // fix syntax error + await page.waitForTimeout(200) + editor.edit((s) => + s.replace( + '
client:broken<
', + '
client:fixed
', + ), + ) + await expect(async () => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('client-content')).toHaveText( + 'client:fixed', + ) + }).toPass() + }) + }) + + test.describe(() => { + const f = useFixture({ root, mode: 'dev' }) + + test('server ssr', async ({ page }) => { + // add syntax error + const editor = f.createEditor('src/root.tsx') + editor.edit((s) => + s.replace( + '
server:ok
', + '
server:broken<
', + ), + ) + await page.goto(f.url()) + await expect(page.locator('body')).toContainText('src/root.tsx:11') + + // fix syntax error + await page.waitForTimeout(200) + editor.edit((s) => + s.replace( + '
server:broken<
', + '
server:fixed
', + ), + ) + await expect(async () => { + await page.goto(f.url()) + await waitForHydration(page) + await expect(page.getByTestId('server-content')).toHaveText( + 'server:fixed', + ) + }).toPass() + }) + }) +}) diff --git a/packages/plugin-rsc/src/plugin.ts b/packages/plugin-rsc/src/plugin.ts index 8c8009c20..304d7c514 100644 --- a/packages/plugin-rsc/src/plugin.ts +++ b/packages/plugin-rsc/src/plugin.ts @@ -31,7 +31,7 @@ import { } from './transforms' import { generateEncryptionKey, toBase64 } from './utils/encryption-utils' import { createRpcServer } from './utils/rpc' -import { normalizeViteImportAnalysisUrl } from './vite-utils' +import { normalizeViteImportAnalysisUrl, prepareError } from './vite-utils' // state for build orchestration let serverReferences: Record = {} @@ -382,6 +382,20 @@ export default function vitePluginRsc( if (!isInsideClientBoundary(ctx.modules)) { if (this.environment.name === 'rsc') { + // transform js to surface syntax errors + for (const mod of ctx.modules) { + if (mod.type === 'js') { + try { + await this.environment.transformRequest(mod.url) + } catch (e) { + server.environments.client.hot.send({ + type: 'error', + err: prepareError(e as any), + }) + throw e + } + } + } // server hmr ctx.server.environments.client.hot.send({ type: 'custom', @@ -773,6 +787,13 @@ window.__vite_plugin_react_preamble_installed__ = true; code += ` const ssrCss = document.querySelectorAll("link[rel='stylesheet']"); import.meta.hot.on("vite:beforeUpdate", () => ssrCss.forEach(node => node.remove())); +` + // close error overlay after syntax error is fixed and hmr is triggered. + // https://github.com/vitejs/vite/blob/8033e5bf8d3ff43995d0620490ed8739c59171dd/packages/vite/src/client/client.ts#L318-L320 + code += ` +import.meta.hot.on("rsc:update", () => { + document.querySelectorAll("vite-error-overlay").forEach((n) => n.close()) +}); ` return code }, diff --git a/packages/plugin-rsc/src/vite-utils.ts b/packages/plugin-rsc/src/vite-utils.ts index b23e58444..d65a5f008 100644 --- a/packages/plugin-rsc/src/vite-utils.ts +++ b/packages/plugin-rsc/src/vite-utils.ts @@ -2,7 +2,8 @@ import fs from 'node:fs' import path from 'node:path' -import type { DevEnvironment, Rollup } from 'vite' +import type { DevEnvironment, ErrorPayload, Rollup } from 'vite' +import { stripVTControlCharacters as strip } from 'node:util' export const VALID_ID_PREFIX = `/@id/` @@ -125,3 +126,29 @@ export function normalizeViteImportAnalysisUrl( return url } + +// error formatting +// https://github.com/vitejs/vite/blob/8033e5bf8d3ff43995d0620490ed8739c59171dd/packages/vite/src/node/server/middlewares/error.ts#L11 + +type RollupError = Rollup.RollupError + +export function prepareError(err: Error | RollupError): ErrorPayload['err'] { + // only copy the information we need and avoid serializing unnecessary + // properties, since some errors may attach full objects (e.g. PostCSS) + return { + message: strip(err.message), + stack: strip(cleanStack(err.stack || '')), + id: (err as RollupError).id, + frame: strip((err as RollupError).frame || ''), + plugin: (err as RollupError).plugin, + pluginCode: (err as RollupError).pluginCode?.toString(), + loc: (err as RollupError).loc, + } +} + +function cleanStack(stack: string) { + return stack + .split(/\n/) + .filter((l) => /^\s*at/.test(l)) + .join('\n') +}