Skip to content
Merged
207 changes: 207 additions & 0 deletions packages/plugin-rsc/e2e/syntax-error.test.ts
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<head>
<meta charSet="UTF-8" />
</head>
<body>
<TestSyntaxErrorClient />
<div data-testid="server-content">server:ok</div>
</body>
</html>
)
}
`,
'src/client.tsx': /* tsx */ `
"use client";
import { useState } from 'react'

export function TestSyntaxErrorClient() {
const [count, setCount] = useState(0)

return (
<div data-testid="client-syntax-ready">
<button
onClick={() => setCount(count + 1)}
data-testid="client-counter"
>
Client Count: {count}
</button>
<div data-testid="client-content">client:ok</div>
</div>
)
}
`,
},
})
})

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(
'<div data-testid="client-content">client:ok</div>',
'<div data-testid="client-content">client:broken<</div>',
),
)
await expect(page.locator('vite-error-overlay')).toBeVisible()

// fix syntax error
await page.waitForTimeout(200)
editor.edit((s) =>
s.replace(
'<div data-testid="client-content">client:broken<</div>',
'<div data-testid="client-content">client:fixed</div>',
),
)
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(
'<div data-testid="server-content">server:ok</div>',
'<div data-testid="server-content">server:broken<</div>',
),
)
await expect(page.locator('vite-error-overlay')).toBeVisible()

// fix syntax error
await page.waitForTimeout(200)
editor.edit((s) =>
s.replace(
'<div data-testid="server-content">server:broken<</div>',
'<div data-testid="server-content">server:fixed</div>',
),
)
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(
'<div data-testid="client-content">client:ok</div>',
'<div data-testid="client-content">client:broken<</div>',
),
)
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(
'<div data-testid="client-content">client:broken<</div>',
'<div data-testid="client-content">client:fixed</div>',
),
)
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(
'<div data-testid="server-content">server:ok</div>',
'<div data-testid="server-content">server:broken<</div>',
),
)
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(
'<div data-testid="server-content">server:broken<</div>',
'<div data-testid="server-content">server:fixed</div>',
),
)
await expect(async () => {
await page.goto(f.url())
await waitForHydration(page)
await expect(page.getByTestId('server-content')).toHaveText(
'server:fixed',
)
}).toPass()
})
})
})
23 changes: 22 additions & 1 deletion packages/plugin-rsc/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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
},
Expand Down
29 changes: 28 additions & 1 deletion packages/plugin-rsc/src/vite-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/`

Expand Down Expand Up @@ -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')
}
Loading