Skip to content

Commit 0fc7fcd

Browse files
jacob-ebeyhi-ogawa
andauthored
fix(rsc): support setups without an SSR environment (#562)
Co-authored-by: Hiroshi Ogawa <[email protected]>
1 parent aa004d4 commit 0fc7fcd

File tree

17 files changed

+568
-4
lines changed

17 files changed

+568
-4
lines changed

packages/plugin-rsc/e2e/helper.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ export const testNoJs = test.extend({
44
javaScriptEnabled: ({}, use) => use(false),
55
})
66

7-
export async function waitForHydration(page: Page) {
7+
export async function waitForHydration(page: Page, locator: string = 'body') {
88
await expect
99
.poll(
1010
() =>
1111
page
12-
.locator('body')
12+
.locator(locator)
1313
.evaluate(
1414
(el) =>
1515
el &&

packages/plugin-rsc/e2e/starter.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { expect, test } from '@playwright/test'
22
import { type Fixture, useFixture } from './fixture'
3-
import { expectNoReload, testNoJs, waitForHydration } from './helper'
3+
import {
4+
expectNoReload,
5+
testNoJs,
6+
waitForHydration as waitForHydration_,
7+
} from './helper'
8+
import path from 'node:path'
9+
import fs from 'node:fs'
410

511
test.describe('dev-default', () => {
612
const f = useFixture({ root: 'examples/starter', mode: 'dev' })
@@ -22,7 +28,24 @@ test.describe('build-cloudflare', () => {
2228
defineTest(f)
2329
})
2430

25-
function defineTest(f: Fixture) {
31+
test.describe('dev-no-ssr', () => {
32+
const f = useFixture({ root: 'examples/no-ssr', mode: 'dev' })
33+
defineTest(f, 'no-ssr')
34+
})
35+
36+
test.describe('build-no-ssr', () => {
37+
const f = useFixture({ root: 'examples/no-ssr', mode: 'build' })
38+
defineTest(f, 'no-ssr')
39+
40+
test('no ssr build', () => {
41+
expect(fs.existsSync(path.join(f.root, 'dist/ssr'))).toBe(false)
42+
})
43+
})
44+
45+
function defineTest(f: Fixture, variant?: 'no-ssr') {
46+
const waitForHydration: typeof waitForHydration_ = (page) =>
47+
waitForHydration_(page, variant === 'no-ssr' ? '#root' : 'body')
48+
2649
test('basic', async ({ page }) => {
2750
await page.goto(f.url())
2851
await waitForHydration(page)
@@ -48,6 +71,8 @@ function defineTest(f: Fixture) {
4871
})
4972

5073
testNoJs('server action @nojs', async ({ page }) => {
74+
test.skip(variant === 'no-ssr')
75+
5176
await page.goto(f.url())
5277
await page.getByRole('button', { name: 'Server Counter: 1' }).click()
5378
await expect(
@@ -71,6 +96,12 @@ function defineTest(f: Fixture) {
7196
page.getByRole('button', { name: 'Client [edit] Counter: 1' }),
7297
).toBeVisible()
7398

99+
if (variant === 'no-ssr') {
100+
editor.reset()
101+
await page.getByRole('button', { name: 'Client Counter: 1' }).click()
102+
return
103+
}
104+
74105
// check next ssr is also updated
75106
const res = await page.goto(f.url())
76107
expect(await res?.text()).toContain('Client [edit] Counter')
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[examples/starter](https://github.com/vitejs/vite-plugin-react/tree/main/packages/plugin-rsc/examples/starter) without SSR environment
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
7+
<script async type="module" src="/src/framework/entry.browser.tsx"></script>
8+
</head>
9+
<body>
10+
<div id="root"></div>
11+
</body>
12+
</html>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@vitejs/plugin-rsc-examples-no-ssr",
3+
"version": "0.0.0",
4+
"private": true,
5+
"license": "MIT",
6+
"type": "module",
7+
"scripts": {
8+
"dev": "vite",
9+
"build": "vite build",
10+
"preview": "vite preview"
11+
},
12+
"dependencies": {
13+
"@vitejs/plugin-rsc": "latest",
14+
"react": "^19.1.0",
15+
"react-dom": "^19.1.0"
16+
},
17+
"devDependencies": {
18+
"@types/react": "^19.1.8",
19+
"@types/react-dom": "^19.1.6",
20+
"@vitejs/plugin-react": "latest",
21+
"vite": "^7.0.2"
22+
}
23+
}
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
'use server'
2+
3+
let serverCounter = 0
4+
5+
export async function getServerCounter() {
6+
return serverCounter
7+
}
8+
9+
export async function updateServerCounter(change: number) {
10+
serverCounter += change
11+
}
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
'use client'
2+
3+
import React from 'react'
4+
5+
export function ClientCounter() {
6+
const [count, setCount] = React.useState(0)
7+
8+
return (
9+
<button onClick={() => setCount((count) => count + 1)}>
10+
Client Counter: {count}
11+
</button>
12+
)
13+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import * as ReactClient from '@vitejs/plugin-rsc/browser'
2+
import React from 'react'
3+
import * as ReactDOMClient from 'react-dom/client'
4+
import type { RscPayload } from './entry.rsc'
5+
6+
async function main() {
7+
// stash `setPayload` function to trigger re-rendering
8+
// from outside of `BrowserRoot` component (e.g. server function call, navigation, hmr)
9+
let setPayload: (v: RscPayload) => void
10+
11+
const initialPayload = await ReactClient.createFromFetch<RscPayload>(
12+
fetch(window.location.href),
13+
)
14+
15+
// browser root component to (re-)render RSC payload as state
16+
function BrowserRoot() {
17+
const [payload, setPayload_] = React.useState(initialPayload)
18+
19+
React.useEffect(() => {
20+
setPayload = (v) => React.startTransition(() => setPayload_(v))
21+
}, [setPayload_])
22+
23+
// re-fetch/render on client side navigation
24+
React.useEffect(() => {
25+
return listenNavigation(() => fetchRscPayload())
26+
}, [])
27+
28+
return payload.root
29+
}
30+
31+
// re-fetch RSC and trigger re-rendering
32+
async function fetchRscPayload() {
33+
const payload = await ReactClient.createFromFetch<RscPayload>(
34+
fetch(window.location.href),
35+
)
36+
setPayload(payload)
37+
}
38+
39+
// register a handler which will be internally called by React
40+
// on server function request after hydration.
41+
ReactClient.setServerCallback(async (id, args) => {
42+
const url = new URL(window.location.href)
43+
const temporaryReferences = ReactClient.createTemporaryReferenceSet()
44+
const payload = await ReactClient.createFromFetch<RscPayload>(
45+
fetch(url, {
46+
method: 'POST',
47+
body: await ReactClient.encodeReply(args, { temporaryReferences }),
48+
headers: {
49+
'x-rsc-action': id,
50+
},
51+
}),
52+
{ temporaryReferences },
53+
)
54+
setPayload(payload)
55+
return payload.returnValue
56+
})
57+
58+
// hydration
59+
const browserRoot = (
60+
<React.StrictMode>
61+
<BrowserRoot />
62+
</React.StrictMode>
63+
)
64+
ReactDOMClient.createRoot(document.body).render(browserRoot)
65+
66+
// implement server HMR by trigering re-fetch/render of RSC upon server code change
67+
if (import.meta.hot) {
68+
import.meta.hot.on('rsc:update', () => {
69+
fetchRscPayload()
70+
})
71+
}
72+
}
73+
74+
// a little helper to setup events interception for client side navigation
75+
function listenNavigation(onNavigation: () => void) {
76+
window.addEventListener('popstate', onNavigation)
77+
78+
const oldPushState = window.history.pushState
79+
window.history.pushState = function (...args) {
80+
const res = oldPushState.apply(this, args)
81+
onNavigation()
82+
return res
83+
}
84+
85+
const oldReplaceState = window.history.replaceState
86+
window.history.replaceState = function (...args) {
87+
const res = oldReplaceState.apply(this, args)
88+
onNavigation()
89+
return res
90+
}
91+
92+
function onClick(e: MouseEvent) {
93+
let link = (e.target as Element).closest('a')
94+
if (
95+
link &&
96+
link instanceof HTMLAnchorElement &&
97+
link.href &&
98+
(!link.target || link.target === '_self') &&
99+
link.origin === location.origin &&
100+
!link.hasAttribute('download') &&
101+
e.button === 0 && // left clicks only
102+
!e.metaKey && // open in new tab (mac)
103+
!e.ctrlKey && // open in new tab (windows)
104+
!e.altKey && // download
105+
!e.shiftKey &&
106+
!e.defaultPrevented
107+
) {
108+
e.preventDefault()
109+
history.pushState(null, '', link.href)
110+
}
111+
}
112+
document.addEventListener('click', onClick)
113+
114+
return () => {
115+
document.removeEventListener('click', onClick)
116+
window.removeEventListener('popstate', onNavigation)
117+
window.history.pushState = oldPushState
118+
window.history.replaceState = oldReplaceState
119+
}
120+
}
121+
122+
main()

0 commit comments

Comments
 (0)