-
-
Notifications
You must be signed in to change notification settings - Fork 267
feat(rsc): ability to not load server reference module during createFromReadableStream on rsc environment
#1289
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
a976ade
7fdad30
252ac0b
bde0b30
1c85e2a
ee83851
9509b52
1be7726
b082cd4
533c467
af13f9e
117fe44
07a82ea
3e2b533
cdcdfe3
cb1c306
69a4b60
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import fs from 'node:fs' | ||
| import path from 'node:path' | ||
| import { expect, test } from '@playwright/test' | ||
| import { type Fixture, useFixture } from './fixture' | ||
| import { waitForHydration } from './helper' | ||
|
|
||
| for (const mode of ['dev', 'build'] as const) { | ||
| test.describe(mode, () => { | ||
| const f = useFixture({ | ||
| root: 'examples/cache-replay', | ||
| mode, | ||
| }) | ||
|
|
||
| defineTests(f) | ||
| }) | ||
| } | ||
|
|
||
| function defineTests(f: Fixture) { | ||
| const cacheFile = path.join(f.root, '.flight-cache') | ||
|
|
||
| test.beforeEach(() => fs.rmSync(cacheFile, { force: true })) | ||
| test.afterEach(() => fs.rmSync(cacheFile, { force: true })) | ||
|
|
||
| test('replays a server reference without loading its module', async ({ | ||
| page, | ||
| }) => { | ||
| await page.goto(f.url('/cache')) | ||
| await waitForHydration(page) | ||
| await expect(page.getByTestId('cache-exists')).toHaveText('true') | ||
| await expect(page.getByTestId('action-imported')).toHaveText('true') | ||
| await expect(page.getByTestId('action-invoked')).toHaveText('false') | ||
|
|
||
| await f.restart() | ||
|
|
||
| await page.goto(f.url('/read-cache')) | ||
|
Check failure on line 35 in packages/plugin-rsc/e2e/cache-replay.test.ts
|
||
| await waitForHydration(page) | ||
| await expect( | ||
| page.getByRole('heading', { name: 'Cached content' }), | ||
| ).toBeVisible() | ||
| await expect(page.getByTestId('cache-exists')).toHaveText('true') | ||
| await expect(page.getByTestId('action-imported')).toHaveText('false') | ||
| await expect(page.getByTestId('action-invoked')).toHaveText('false') | ||
|
|
||
| await page.getByRole('button', { name: 'Invoke action' }).click() | ||
| await expect(page.getByTestId('action-imported')).toHaveText('true') | ||
| await expect(page.getByTestId('action-invoked')).toHaveText('true') | ||
| }) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| .flight-cache | ||
| dist |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| # Persisted Flight server reference replay | ||
|
|
||
| This example persists a Flight payload containing a server reference, restarts the server, and replays the payload without importing the server action in the RSC environment. The action is imported only when the replayed form invokes it. | ||
|
|
||
| The framework files follow the starter example. The application routes own persistence and replay, while the framework only performs its normal request parsing, action handling, and RSC serialization. | ||
|
|
||
| ## Development manual test | ||
|
|
||
| Start the first process: | ||
|
|
||
| ```bash | ||
| pnpm dev | ||
| ``` | ||
|
|
||
| 1. Visit `http://localhost:5173/cache` and confirm the page displays `true`. | ||
| 2. Stop the development server without changing the source graph. | ||
| 3. Run `pnpm dev` again. | ||
| 4. Visit `http://localhost:5173/` and confirm the page displays `false`. | ||
| 5. Select **Invoke replayed action** and confirm the response displays `true`. | ||
|
|
||
| The source graph must remain unchanged across the restart so its development server-reference IDs remain stable. | ||
|
|
||
| ## Production manual test | ||
|
|
||
| Build the example once: | ||
|
|
||
| ```bash | ||
| pnpm build | ||
| ``` | ||
|
|
||
| Start the first process: | ||
|
|
||
| ```bash | ||
| pnpm preview | ||
| ``` | ||
|
|
||
| 1. Visit `http://localhost:4173/cache`. | ||
| 2. Confirm the page displays `Action imported in the RSC environment: true`. | ||
| 3. Stop the preview server without rebuilding. | ||
| 4. Run `pnpm preview` again. | ||
| 5. Visit `http://localhost:4173/` and confirm the page displays `false`. | ||
| 6. Select **Invoke replayed action** and confirm the response displays `true`. | ||
|
|
||
| The same production build must be used for both processes because the persisted Flight payload contains build-specific server-reference IDs. | ||
|
|
||
| Delete `.flight-cache` to reset the example. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| { | ||
| "name": "@vitejs/plugin-rsc-examples-cache-replay", | ||
| "version": "0.0.0", | ||
| "private": true, | ||
| "license": "MIT", | ||
| "type": "module", | ||
| "scripts": { | ||
| "dev": "vite", | ||
| "build": "vite build", | ||
| "preview": "vite preview" | ||
| }, | ||
| "dependencies": { | ||
| "react": "^19.2.7", | ||
| "react-dom": "^19.2.7" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/react": "^19.2.17", | ||
| "@types/react-dom": "^19.2.3", | ||
| "@vitejs/plugin-react": "latest", | ||
| "@vitejs/plugin-rsc": "latest", | ||
| "rsc-html-stream": "^0.0.7", | ||
| "vite": "^8.1.4" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const actionState = { imported: false, invoked: false } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| 'use server' | ||
|
|
||
| import { actionState } from './action-state' | ||
|
|
||
| actionState.imported = true | ||
|
|
||
| export async function testAction() { | ||
| actionState.invoked = true | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { testAction } from './action' | ||
|
|
||
| export function CachedContent() { | ||
| return ( | ||
| <section> | ||
| <h2>Cached content</h2> | ||
| <form action={testAction}> | ||
| <button type="submit">Invoke action</button> | ||
| </form> | ||
| </section> | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| import { | ||
| createFromReadableStream, | ||
| createFromFetch, | ||
| setServerCallback, | ||
| createTemporaryReferenceSet, | ||
| encodeReply, | ||
| } from '@vitejs/plugin-rsc/browser' | ||
| import React from 'react' | ||
| import { createRoot, hydrateRoot } from 'react-dom/client' | ||
| import { rscStream } from 'rsc-html-stream/client' | ||
| import type { RscPayload } from './entry.rsc' | ||
| import { GlobalErrorBoundary } from './error-boundary' | ||
| import { createRscRenderRequest } from './request' | ||
|
|
||
| async function main() { | ||
| let setPayload: (value: RscPayload) => void | ||
|
|
||
| const initialPayload = await createFromReadableStream<RscPayload>(rscStream) | ||
|
|
||
| function BrowserRoot() { | ||
| const [payload, setPayload_] = React.useState(initialPayload) | ||
|
|
||
| React.useEffect(() => { | ||
| setPayload = (value) => React.startTransition(() => setPayload_(value)) | ||
| }, [setPayload_]) | ||
|
|
||
| React.useEffect(() => { | ||
| return listenNavigation(() => fetchRscPayload()) | ||
| }, []) | ||
|
|
||
| return payload.root | ||
| } | ||
|
|
||
| async function fetchRscPayload() { | ||
| const renderRequest = createRscRenderRequest(window.location.href) | ||
| const payload = await createFromFetch<RscPayload>(fetch(renderRequest)) | ||
| setPayload(payload) | ||
| } | ||
|
|
||
| setServerCallback(async (id, args) => { | ||
| const temporaryReferences = createTemporaryReferenceSet() | ||
| const renderRequest = createRscRenderRequest(window.location.href, { | ||
| id, | ||
| body: await encodeReply(args, { temporaryReferences }), | ||
| }) | ||
| const payload = await createFromFetch<RscPayload>(fetch(renderRequest), { | ||
| temporaryReferences, | ||
| }) | ||
| setPayload(payload) | ||
| const { ok, data } = payload.returnValue! | ||
| if (!ok) throw data | ||
| return data | ||
| }) | ||
|
|
||
| const browserRoot = ( | ||
| <React.StrictMode> | ||
| <GlobalErrorBoundary> | ||
| <BrowserRoot /> | ||
| </GlobalErrorBoundary> | ||
| </React.StrictMode> | ||
| ) | ||
| if ('__NO_HYDRATE' in globalThis) { | ||
| createRoot(document).render(browserRoot) | ||
| } else { | ||
| hydrateRoot(document, browserRoot, { | ||
| formState: initialPayload.formState, | ||
| }) | ||
| } | ||
|
|
||
| if (import.meta.hot) { | ||
| import.meta.hot.on('rsc:update', () => { | ||
| fetchRscPayload() | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| function listenNavigation(onNavigation: () => void) { | ||
| window.addEventListener('popstate', onNavigation) | ||
|
|
||
| const oldPushState = window.history.pushState | ||
| window.history.pushState = function (...args) { | ||
| const result = oldPushState.apply(this, args) | ||
| onNavigation() | ||
| return result | ||
| } | ||
|
|
||
| const oldReplaceState = window.history.replaceState | ||
| window.history.replaceState = function (...args) { | ||
| const result = oldReplaceState.apply(this, args) | ||
| onNavigation() | ||
| return result | ||
| } | ||
|
|
||
| function onClick(event: MouseEvent) { | ||
| const link = (event.target as Element).closest('a') | ||
| if ( | ||
| link && | ||
| link instanceof HTMLAnchorElement && | ||
| link.href && | ||
| (!link.target || link.target === '_self') && | ||
| link.origin === location.origin && | ||
| !link.hasAttribute('download') && | ||
| event.button === 0 && | ||
| !event.metaKey && | ||
| !event.ctrlKey && | ||
| !event.altKey && | ||
| !event.shiftKey && | ||
| !event.defaultPrevented | ||
| ) { | ||
| event.preventDefault() | ||
| history.pushState(null, '', link.href) | ||
| } | ||
| } | ||
| document.addEventListener('click', onClick) | ||
|
|
||
| return () => { | ||
| document.removeEventListener('click', onClick) | ||
| window.removeEventListener('popstate', onNavigation) | ||
| window.history.pushState = oldPushState | ||
| window.history.replaceState = oldReplaceState | ||
| } | ||
| } | ||
|
|
||
| main() |
Uh oh!
There was an error while loading. Please reload this page.