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')
+}