-
-
Notifications
You must be signed in to change notification settings - Fork 262
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
Merged
Merged
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
a976ade
fix(plugin-rsc): preserve server references during SSR
hi-ogawa 7fdad30
fix(plugin-rsc): make server reference preservation opt-in
hi-ogawa 252ac0b
Merge branch 'main' into rsc-preserve-server-references
hi-ogawa bde0b30
fix(plugin-rsc): support replayed references in dev
hi-ogawa 1c85e2a
refactor(plugin-rsc): align replay example with starter
hi-ogawa ee83851
refactor(plugin-rsc): simplify replay example
hi-ogawa 9509b52
refactor(plugin-rsc): track replay action state
hi-ogawa 1be7726
refactor(plugin-rsc): clarify replay example flow
hi-ogawa b082cd4
test(plugin-rsc): cover cache replay across restarts
hi-ogawa 533c467
test(plugin-rsc): compare cache replay modes
hi-ogawa af13f9e
test(plugin-rsc): stabilize cache replay assertions
hi-ogawa 117fe44
docs(plugin-rsc): unify cache replay instructions
hi-ogawa 07a82ea
docs(plugin-rsc): explain preserved server references
hi-ogawa 3e2b533
test(plugin-rsc): explain restart navigation
hi-ogawa cdcdfe3
refactor(plugin-rsc): rename preserved reference tag
hi-ogawa cb1c306
fix(plugin-rsc): respect fs restrictions during reference validation
hi-ogawa 69a4b60
nit
hi-ogawa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| 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' | ||
|
|
||
| test.describe('dev', () => { | ||
| const f = useFixture({ | ||
| root: 'examples/cache-replay', | ||
| mode: 'dev', | ||
| }) | ||
| defineTests(f) | ||
| }) | ||
|
|
||
| test.describe('build', () => { | ||
| const f = useFixture({ | ||
| root: 'examples/cache-replay', | ||
| mode: 'build', | ||
| }) | ||
| 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('/')) | ||
| await expect(page.getByTestId('cache-exists')).toHaveText('false') | ||
|
|
||
| // Serialize content after importing its action. | ||
| 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') | ||
|
|
||
| // Avoid a dev client reload racing navigation after restart. | ||
| await page.goto('about:blank') | ||
| await f.restart() | ||
|
|
||
| // Default replay imports the referenced action. | ||
| await page.goto(f.url('/read-cache')) | ||
| await waitForHydration(page) | ||
| await expect(page.getByTestId('cached-content')).toBeVisible() | ||
| await page.goto(f.url('/')) | ||
| await expect(page.getByTestId('cache-exists')).toHaveText('true') | ||
| await expect(page.getByTestId('action-imported')).toHaveText('true') | ||
| await expect(page.getByTestId('action-invoked')).toHaveText('false') | ||
|
|
||
| // Avoid a dev client reload racing navigation after restart. | ||
| await page.goto('about:blank') | ||
| await f.restart() | ||
|
|
||
| // Preserved replay leaves the referenced action unloaded. | ||
| await page.goto(f.url('/read-cache-preserve')) | ||
| await waitForHydration(page) | ||
| await expect(page.getByTestId('cached-content')).toBeVisible() | ||
| await page.goto(f.url('/')) | ||
| await expect(page.getByTestId('cache-exists')).toHaveText('true') | ||
| await expect(page.getByTestId('action-imported')).toHaveText('false') | ||
| await expect(page.getByTestId('action-invoked')).toHaveText('false') | ||
|
|
||
| // Invoking the preserved reference imports and runs the action. | ||
| await page.goto(f.url('/read-cache-preserve')) | ||
| await waitForHydration(page) | ||
| await page.getByTestId('invoke-action').click() | ||
| await expect(page.getByTestId('action-imported')).toHaveText('true') | ||
| await expect(page.getByTestId('action-invoked')).toHaveText('true') | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| .flight-cache | ||
| dist |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| # Persisted Flight server reference replay | ||
|
|
||
| This example persists a Flight payload containing a server reference and compares the default replay, which imports the server action in the RSC environment, with a replay that preserves the reference. With preservation enabled, 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. | ||
|
|
||
| ## Manual test | ||
|
|
||
| Start the development server: | ||
|
|
||
| ```bash | ||
| pnpm dev | ||
| ``` | ||
|
|
||
| Alternatively, build once and start the production server: | ||
|
|
||
| ```bash | ||
| pnpm build | ||
| pnpm preview | ||
| ``` | ||
|
|
||
| 1. Visit `/cache` and confirm the action is imported. | ||
| 2. Stop and restart the server with `pnpm dev` or `pnpm preview`. | ||
| 3. Visit `/read-cache`, then visit `/` and confirm the action is imported. | ||
| 4. Restart the server again. | ||
| 5. Visit `/read-cache-preserve`, then visit `/` and confirm the action is not imported. | ||
| 6. Return to `/read-cache-preserve`. | ||
| 7. Select **Invoke action** and confirm the action is imported and invoked. | ||
|
|
||
| Do not change the source graph between development server restarts or rebuild between production server restarts. The persisted Flight payload contains server-reference IDs that must remain stable. | ||
|
|
||
| Delete `.flight-cache` to reset the example. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const actionState = { imported: false, invoked: false } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
14 changes: 14 additions & 0 deletions
14
packages/plugin-rsc/examples/cache-replay/src/cached-content.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { testAction } from './action' | ||
|
|
||
| export function CachedContent() { | ||
| return ( | ||
| <section data-testid="cached-content"> | ||
| <h2>Cached content</h2> | ||
| <form action={testAction}> | ||
| <button type="submit" data-testid="invoke-action"> | ||
| Invoke action | ||
| </button> | ||
| </form> | ||
| </section> | ||
| ) | ||
| } |
124 changes: 124 additions & 0 deletions
124
packages/plugin-rsc/examples/cache-replay/src/framework/entry.browser.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.