-
-
Notifications
You must be signed in to change notification settings - Fork 266
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 5 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
2 changes: 2 additions & 0 deletions
2
packages/plugin-rsc/examples/rsc-server-reference-replay/.gitignore
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 |
46 changes: 46 additions & 0 deletions
46
packages/plugin-rsc/examples/rsc-server-reference-replay/README.md
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,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. |
24 changes: 24 additions & 0 deletions
24
packages/plugin-rsc/examples/rsc-server-reference-replay/package.json
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-rsc-server-reference-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" | ||
| } | ||
| } |
9 changes: 9 additions & 0 deletions
9
packages/plugin-rsc/examples/rsc-server-reference-replay/src/action-import-state.ts
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 @@ | ||
| let actionImported = false | ||
|
|
||
| export function markActionImported() { | ||
| actionImported = true | ||
| } | ||
|
|
||
| export function wasActionImported() { | ||
| return actionImported | ||
| } | ||
7 changes: 7 additions & 0 deletions
7
packages/plugin-rsc/examples/rsc-server-reference-replay/src/action.ts
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,7 @@ | ||
| 'use server' | ||
|
|
||
| import { markActionImported } from './action-import-state' | ||
|
|
||
| markActionImported() | ||
|
|
||
| export async function replayedAction() {} | ||
|
hi-ogawa marked this conversation as resolved.
Outdated
|
||
9 changes: 9 additions & 0 deletions
9
packages/plugin-rsc/examples/rsc-server-reference-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,9 @@ | ||
| import { replayedAction } from './action' | ||
|
|
||
| export function CachedContent() { | ||
| return ( | ||
| <form action={replayedAction}> | ||
| <button type="submit">Invoke replayed action</button> | ||
| </form> | ||
| ) | ||
| } |
124 changes: 124 additions & 0 deletions
124
packages/plugin-rsc/examples/rsc-server-reference-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() |
94 changes: 94 additions & 0 deletions
94
packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.rsc.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,94 @@ | ||
| import { | ||
| renderToReadableStream, | ||
| createTemporaryReferenceSet, | ||
| decodeReply, | ||
| loadServerAction, | ||
| decodeAction, | ||
| decodeFormState, | ||
| } from '@vitejs/plugin-rsc/rsc' | ||
| import type { ReactFormState } from 'react-dom/client' | ||
| import { Root } from '../root' | ||
| import { parseRenderRequest } from './request' | ||
|
|
||
| export type RscPayload = { | ||
| root: React.ReactNode | ||
| returnValue?: { ok: boolean; data: unknown } | ||
| formState?: ReactFormState | ||
| } | ||
|
|
||
| export default { fetch: handler } | ||
|
|
||
| async function handler(request: Request): Promise<Response> { | ||
| const renderRequest = parseRenderRequest(request) | ||
| request = renderRequest.request | ||
|
|
||
| let returnValue: RscPayload['returnValue'] | undefined | ||
| let formState: ReactFormState | undefined | ||
| let temporaryReferences: unknown | undefined | ||
| let actionStatus: number | undefined | ||
| if (renderRequest.isAction === true) { | ||
| if (renderRequest.actionId) { | ||
| const contentType = request.headers.get('content-type') | ||
| const body = contentType?.startsWith('multipart/form-data') | ||
| ? await request.formData() | ||
| : await request.text() | ||
| temporaryReferences = createTemporaryReferenceSet() | ||
| const args = await decodeReply(body, { temporaryReferences }) | ||
| const action = await loadServerAction(renderRequest.actionId) | ||
| try { | ||
| const data = await action.apply(null, args) | ||
| returnValue = { ok: true, data } | ||
| } catch (error) { | ||
| returnValue = { ok: false, data: error } | ||
| actionStatus = 500 | ||
| } | ||
| } else { | ||
| const formData = await request.formData() | ||
| const decodedAction = await decodeAction(formData) | ||
| try { | ||
| const result = await decodedAction() | ||
| formState = await decodeFormState(result, formData) | ||
| } catch { | ||
| return new Response('Internal Server Error: server action failed', { | ||
| status: 500, | ||
| }) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| const rscPayload: RscPayload = { | ||
| root: <Root url={renderRequest.url} />, | ||
| formState, | ||
| returnValue, | ||
| } | ||
| const rscOptions = { temporaryReferences } | ||
| const rscStream = renderToReadableStream<RscPayload>(rscPayload, rscOptions) | ||
|
|
||
| if (renderRequest.isRsc) { | ||
| return new Response(rscStream, { | ||
| status: actionStatus, | ||
| headers: { | ||
| 'content-type': 'text/x-component;charset=utf-8', | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| const ssrEntryModule = await import.meta.viteRsc.loadModule< | ||
| typeof import('./entry.ssr') | ||
| >('ssr', 'index') | ||
| const ssrResult = await ssrEntryModule.renderHTML(rscStream, { | ||
| formState, | ||
| debugNojs: renderRequest.url.searchParams.has('__nojs'), | ||
| }) | ||
|
|
||
| return new Response(ssrResult.stream, { | ||
| status: ssrResult.status, | ||
| headers: { | ||
| 'Content-type': 'text/html', | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| if (import.meta.hot) { | ||
| import.meta.hot.accept() | ||
| } |
63 changes: 63 additions & 0 deletions
63
packages/plugin-rsc/examples/rsc-server-reference-replay/src/framework/entry.ssr.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,63 @@ | ||
| import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr' | ||
| import React from 'react' | ||
| import type { ReactFormState } from 'react-dom/client' | ||
| import { renderToReadableStream } from 'react-dom/server.edge' | ||
| import { injectRSCPayload } from 'rsc-html-stream/server' | ||
| import type { RscPayload } from './entry.rsc' | ||
|
|
||
| export async function renderHTML( | ||
| rscStream: ReadableStream<Uint8Array>, | ||
| options: { | ||
| formState?: ReactFormState | ||
| nonce?: string | ||
| debugNojs?: boolean | ||
| }, | ||
| ): Promise<{ stream: ReadableStream<Uint8Array>; status?: number }> { | ||
| const [rscStream1, rscStream2] = rscStream.tee() | ||
|
|
||
| let payload: Promise<RscPayload> | undefined | ||
| function SsrRoot() { | ||
| payload ??= createFromReadableStream<RscPayload>(rscStream1) | ||
| return React.use(payload).root | ||
| } | ||
|
|
||
| const bootstrapScriptContent = | ||
| await import.meta.viteRsc.loadBootstrapScriptContent('index') | ||
| let htmlStream: ReadableStream<Uint8Array> | ||
| let status: number | undefined | ||
| try { | ||
| htmlStream = await renderToReadableStream(<SsrRoot />, { | ||
| bootstrapScriptContent: options.debugNojs | ||
| ? undefined | ||
| : bootstrapScriptContent, | ||
| nonce: options.nonce, | ||
| formState: options.formState, | ||
| }) | ||
| } catch { | ||
| status = 500 | ||
| htmlStream = await renderToReadableStream( | ||
| <html> | ||
| <body> | ||
| <noscript>Internal Server Error: SSR failed</noscript> | ||
| </body> | ||
| </html>, | ||
| { | ||
| bootstrapScriptContent: | ||
| `self.__NO_HYDRATE=1;` + | ||
| (options.debugNojs ? '' : bootstrapScriptContent), | ||
| nonce: options.nonce, | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| let responseStream: ReadableStream<Uint8Array> = htmlStream | ||
| if (!options.debugNojs) { | ||
| responseStream = responseStream.pipeThrough( | ||
| injectRSCPayload(rscStream2, { | ||
| nonce: options.nonce, | ||
| }), | ||
| ) | ||
| } | ||
|
|
||
| return { stream: responseStream, status } | ||
| } |
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.