diff --git a/packages/plugin-rsc/examples/navigation/README.md b/packages/plugin-rsc/examples/navigation/README.md new file mode 100644 index 000000000..63505f803 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/README.md @@ -0,0 +1,148 @@ +# Navigation Example - Coordinating History, Transitions, and Caching + +TODO: review + +This example demonstrates how to properly coordinate Browser URL update with React transitions and implement instant back/forward navigation via caching in a React Server Components application. + +## Problem + +In a typical RSC application with client-side navigation, there's a challenge in coordinating: + +1. Browser history changes (pushState/replaceState/popstate) +2. React transitions for smooth updates +3. Asynchronous data fetching +4. Loading state indicators +5. Back/forward navigation performance + +Without proper coordination, you can encounter: + +- URL bar being out of sync with rendered content +- Slow back/forward navigation (refetching from server) +- Issues with cache invalidation after mutations +- Missing or inconsistent loading indicators + +## Solution + +This example implements a caching pattern that addresses these issues: + +### Key Concepts + +1. **Modern Navigation API**: Uses [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) when available, falls back to History API +2. **Back/Forward Cache by Entry**: Each navigation entry gets a unique key, cache maps `key → Promise` +3. **Instant Navigation**: Cache hits render synchronously (no loading state), cache misses show transitions +4. **Dispatch Pattern**: Uses a dispatch function that coordinates navigation actions with React transitions +5. **Promise-based State**: Navigation state includes a `payloadPromise` that's unwrapped with `React.use()` +6. **Cache Invalidation**: Server actions update cache for current entry + +### Browser Compatibility + +The implementation automatically detects and uses: + +- **Navigation API** (Chrome 102+, Edge 102+): Modern, cleaner API with built-in entry keys +- **History API** (all browsers): Fallback for older browsers, requires manual key management + +No configuration needed - feature detection happens automatically! + +### Implementation + +The core implementation is in `src/framework/navigation.ts`: + +```typescript +// Feature detection +const supportsNavigationAPI = 'navigation' in window + +// Navigation API: Clean, modern +private listenNavigationAPI(): () => void { + const onNavigate = (e: NavigateEvent) => { + if (!e.canIntercept) return + + e.intercept({ + handler: async () => { + this.navigate(url.href) + }, + }) + } + window.navigation.addEventListener('navigate', onNavigate) + return () => window.navigation.removeEventListener('navigate', onNavigate) +} + +// History API fallback: Works everywhere +private listenHistoryAPI(): () => void { + window.history.pushState = (...args) => { + args[0] = this.addStateKey(args[0]) + this.oldPushState.apply(window.history, args) + this.navigate(url.href) + } + // ... popstate, replaceState, link clicks +} + +// Dispatch coordinates navigation with transitions and cache +dispatch = (action: NavigationAction) => { + startTransition(() => { + setState_({ + url: action.url, + push: action.push, + payloadPromise: action.payload + ? Promise.resolve(action.payload) + : bfCache.run(() => createFromFetch(fetch(action.url))), + }) + }) +} + +// Each history entry gets a unique key +function addStateKey(state: any): HistoryState { + const key = Math.random().toString(36).slice(2) + return { ...state, key } +} +``` + +**Why this works:** + +- `React.use()` can unwrap both promises AND resolved values +- Cache hit → returns existing promise → `React.use()` unwraps synchronously → instant render, no transition! +- Cache miss → creates new fetch promise → `React.use()` suspends → shows loading, transition active +- Browser automatically handles scroll restoration via proper history state + +## Running the Example + +```bash +pnpm install +pnpm dev +``` + +Then navigate to http://localhost:5173 + +## What to Try + +1. **Cache Behavior**: + - Visit "Slow Page" (notice the loading indicator) + - Navigate to another page + - Click browser back button + - Notice: No loading indicator! Instant render from cache + +2. **Cache Miss vs Hit**: + - First visit to any page shows "loading..." (cache miss) + - Back/forward to visited pages is instant (cache hit) + - Even slow pages are instant on second visit + +3. **Server Actions**: + - Go to "Counter Page" and increment server counter + - Notice the cache updates for current entry + - Navigate away and back to see updated state + +4. **Scroll Restoration**: Browser handles this automatically via proper history state + +## References + +This pattern is inspired by: + +- [Navigation API](https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API) - Modern navigation standard +- [hi-ogawa/vite-environment-examples](https://github.com/hi-ogawa/vite-environment-examples/blob/main/examples/react-server/src/features/router/browser.ts) - Back/forward cache implementation +- [TanStack Router](https://github.com/TanStack/router/blob/main/packages/history/src/index.ts) - History state key pattern +- [React useTransition](https://react.dev/reference/react/useTransition) +- [React.use](https://react.dev/reference/react/use) + +## Related + +- GitHub Issue: https://github.com/vitejs/vite-plugin-react/issues/860 +- Reproduction: https://github.com/hi-ogawa/reproductions/tree/main/vite-rsc-coordinate-history-and-transition diff --git a/packages/plugin-rsc/examples/navigation/package.json b/packages/plugin-rsc/examples/navigation/package.json new file mode 100644 index 000000000..d2d6b1db0 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/package.json @@ -0,0 +1,25 @@ +{ + "name": "@vitejs/plugin-rsc-examples-navigation", + "version": "0.0.0", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@tanstack/history": "^1.133.28", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "latest", + "@vitejs/plugin-rsc": "latest", + "rsc-html-stream": "^0.0.7", + "vite": "^7.1.10" + } +} diff --git a/packages/plugin-rsc/examples/navigation/public/vite.svg b/packages/plugin-rsc/examples/navigation/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx new file mode 100644 index 000000000..933c08f75 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.browser.tsx @@ -0,0 +1,282 @@ +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 { NavigationManager, type NavigationState } from './navigation' +import { GlobalErrorBoundary } from './error-boundary' +import { createRscRenderRequest } from './request' +// import { RouterContext, type RouterContextType } from './router' +// import { createBrowserHistory } from "@tanstack/history" + +type NavigationEntry = { + url: string + // cached page payload + // TODO: reuse for back/forward navigation. invalidate on server function. + data: Promise + // update browser url on commit + flush: () => void +} + +// https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/app-router.tsx#L96 +// https://github.com/rickhanlonii/async-react/blob/c971b2fa57785dc2014251ec90d3c18cb7a958c6/src/router/index.jsx#L114-L116 +function FlushNavigationEntry({ entry }: { entry: NavigationEntry }) { + React.useInsertionEffect(() => { + // ensure it flushed once for any entry (e.g. strict mode) + entry.flush() + entry.flush = () => {} + }, [entry]) + + return null +} + +function RenderNavigationEntry({ entry }: { entry: NavigationEntry }) { + return React.use(entry.data).root +} + +// class NavigationEntryManager { +// // TODO +// // entries for navigation stack +// // TODO +// // dispatch(url: string) {} +// } + +async function main() { + const initialPayload = await createFromReadableStream(rscStream) + + // const browserHistory = createBrowserHistory(); + // browserHistory.go; + // browserHistory.subscribe + // const manager = new NavigationManager(initialPayload) + + // function Router(props: React.PropsWithChildren) { + // const routerContext: RouterContextType = { + // url: window.location.href, + // navigate: (to: string, options?: { replace?: boolean }) => { + // }, + // } + // const routeState = { + // url: window.location.href, + // commit: () => {}, + // }; + // // React.useOptimistic; + // // optimisticUrl + + // return ( + // + // {props.children} + // + // ) + // } + + const initialEntry: NavigationEntry = { + url: window.location.href, + data: Promise.resolve(initialPayload), + flush: () => {}, + } + + let setCurrentEntry: React.Dispatch> + + function navigate(url: string, options?: { replace?: boolean }) { + setCurrentEntry({ + url, + data: createFromFetch(fetch(createRscRenderRequest(url))), + flush: () => { + if (options?.replace) { + window.history.replaceState({}, '', url) + } else { + window.history.pushState({}, '', url) + } + }, + }) + } + + function BrowserRoot() { + const [currentEntry, setCurrentEntry_] = + React.useState(initialEntry) + // const [isPending, setIsPending] = React.useOptimistic(false); + const [isPending, startTransition] = React.useTransition() + + // const [state, setState] = React.useState(manager.getState()) + // const [isPending, startTransition] = React.useTransition() + + // https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/use-action-queue.ts#L49 + // React.useEffect(() => { + // manager.setReactHandlers(setState, startTransition) + // return manager.listen() + // }, []) + + React.useEffect(() => { + setCurrentEntry = setCurrentEntry_ + + const handleAnchorClick = (e: MouseEvent) => { + const link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + startTransition(() => { + navigate(link.href) + }) + } + } + + document.addEventListener('click', handleAnchorClick) + + // Handle popstate (back/forward) + function handlePopstate(e: PopStateEvent) { + // TODO: use state key from event to look up cache + // e.state.key + e + // this.navigate(window.location.href) + } + + window.addEventListener('popstate', handlePopstate) + + // Cleanup + return () => { + document.removeEventListener('click', handleAnchorClick) + window.removeEventListener('popstate', handlePopstate) + } + }, []) + + return ( + <> + + + + + ) + } + + // function BrowserRoot() { + // const [state, setState] = React.useState(manager.getState()) + // const [isPending, startTransition] = React.useTransition() + + // // https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/use-action-queue.ts#L49 + // // React.useEffect(() => { + // // manager.setReactHandlers(setState, startTransition) + // // return manager.listen() + // // }, []) + + // React.useEffect(() => { + // browserHistory.subscribe; + // }, []) + + // return ( + // <> + // {state.push && } + // {/* */} + // + // + // ) + // } + + // https://github.com/vercel/next.js/blob/08bf0e08f74304afb3a9f79e521e5148b77bf96e/packages/next/src/client/components/app-router.tsx#L96 + // function HistoryUpdater({ url }: { url: string }) { + // React.useInsertionEffect(() => { + // manager.commitHistoryPush(url) + // }, [url]) + // return null + // } + + // TODO: expose `isPending` via store / context + function TransitionStatus(props: { isPending: boolean }) { + React.useEffect(() => { + let el = document.querySelector('#pending') as HTMLDivElement + if (!el) { + el = document.createElement('div') + el.id = 'pending' + el.style.position = 'fixed' + el.style.top = '10px' + el.style.right = '10px' + el.style.padding = '8px 16px' + el.style.backgroundColor = 'rgba(0, 0, 0, 0.8)' + el.style.color = 'white' + el.style.borderRadius = '4px' + el.style.fontSize = '14px' + el.style.fontFamily = 'monospace' + el.style.transition = 'opacity 0.3s ease-in-out' + el.style.pointerEvents = 'none' + el.style.zIndex = '9999' + document.body.appendChild(el) + } + + if (props.isPending) { + el.textContent = 'loading...' + el.style.opacity = '1' + } else { + el.style.opacity = '0' + } + }, [props.isPending]) + return null + } + + // function RenderState({ state }: { state: NavigationState }) { + // const payload = React.use(state.payloadPromise) + // return payload.root + // } + + setServerCallback(async (id, args) => { + const temporaryReferences = createTemporaryReferenceSet() + const renderRequest = createRscRenderRequest(window.location.href, { + id, + body: await encodeReply(args, { temporaryReferences }), + }) + const payload = await createFromFetch(fetch(renderRequest), { + temporaryReferences, + }) + // manager.handleServerAction(payload) + const { ok, data } = payload.returnValue! + if (!ok) throw data + setCurrentEntry({ + url: window.location.href, + data: Promise.resolve(payload), + flush: () => {}, + }) + return data + }) + + const browserRoot = ( + + + + + + ) + + 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', () => { + // manager.invalidateCache() + // manager.navigate(window.location.href) + navigate(window.location.href, { replace: true }) + }) + } +} + +main() diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx new file mode 100644 index 000000000..06512e198 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.rsc.tsx @@ -0,0 +1,109 @@ +import { + renderToReadableStream, + createTemporaryReferenceSet, + decodeReply, + loadServerAction, + decodeAction, + decodeFormState, +} from '@vitejs/plugin-rsc/rsc' +import type { ReactFormState } from 'react-dom/client' +import { Root } from '../root.tsx' +import { parseRenderRequest } from './request.tsx' + +// The schema of payload which is serialized into RSC stream on rsc environment +// and deserialized on ssr/client environments. +export type RscPayload = { + // this demo renders/serializes/deserizlies entire root html element + // but this mechanism can be changed to render/fetch different parts of components + // based on your own route conventions. + root: React.ReactNode + // server action return value of non-progressive enhancement case + returnValue?: { ok: boolean; data: unknown } + // server action form state (e.g. useActionState) of progressive enhancement case + formState?: ReactFormState +} + +export default async function handler(request: Request): Promise { + // differentiate RSC, SSR, action, etc. + const renderRequest = parseRenderRequest(request) + + // handle server function 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) { + // action is called via `ReactClient.setServerCallback`. + 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 (e) { + returnValue = { ok: false, data: e } + actionStatus = 500 + } + } else { + // otherwise server function is called via `
` + // before hydration (e.g. when javascript is disabled). + // aka progressive enhancement. + const formData = await request.formData() + const decodedAction = await decodeAction(formData) + try { + const result = await decodedAction() + formState = await decodeFormState(result, formData) + } catch (e) { + // there's no single general obvious way to surface this error, + // so explicitly return classic 500 response. + return new Response('Internal Server Error: server action failed', { + status: 500, + }) + } + } + } + + const rscPayload: RscPayload = { + root: , + formState, + returnValue, + } + const rscOptions = { temporaryReferences } + const rscStream = renderToReadableStream(rscPayload, rscOptions) + + // Respond RSC stream without HTML rendering as decided by `RenderRequest` + 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.tsx') + >('ssr', 'index') + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { + formState, + // allow quick simulation of javascript disabled browser + debugNojs: renderRequest.url.searchParams.has('__nojs'), + }) + + // respond html + return new Response(ssrResult.stream, { + status: ssrResult.status, + headers: { + 'Content-type': 'text/html', + }, + }) +} + +if (import.meta.hot) { + import.meta.hot.accept() +} diff --git a/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx new file mode 100644 index 000000000..7fc5a9564 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/entry.ssr.tsx @@ -0,0 +1,74 @@ +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, + options: { + formState?: ReactFormState + nonce?: string + debugNojs?: boolean + }, +): Promise<{ stream: ReadableStream; status?: number }> { + // duplicate one RSC stream into two. + // - one for SSR (ReactClient.createFromReadableStream below) + // - another for browser hydration payload by injecting . + const [rscStream1, rscStream2] = rscStream.tee() + + // deserialize RSC stream back to React VDOM + let payload: Promise | undefined + function SsrRoot() { + // deserialization needs to be kicked off inside ReactDOMServer context + // for ReactDomServer preinit/preloading to work + payload ??= createFromReadableStream(rscStream1) + return React.use(payload).root + } + + // render html (traditional SSR) + const bootstrapScriptContent = + await import.meta.viteRsc.loadBootstrapScriptContent('index') + let htmlStream: ReadableStream + let status: number | undefined + try { + htmlStream = await renderToReadableStream(, { + bootstrapScriptContent: options?.debugNojs + ? undefined + : bootstrapScriptContent, + nonce: options?.nonce, + formState: options?.formState, + }) + } catch (e) { + // fallback to render an empty shell and run pure CSR on browser, + // which can replay server component error and trigger error boundary. + status = 500 + htmlStream = await renderToReadableStream( + + + + + , + { + bootstrapScriptContent: + `self.__NO_HYDRATE=1;` + + (options?.debugNojs ? '' : bootstrapScriptContent), + nonce: options?.nonce, + }, + ) + } + + let responseStream: ReadableStream = htmlStream + if (!options?.debugNojs) { + // initial RSC stream is injected in HTML stream as + // using utility made by devongovett https://github.com/devongovett/rsc-html-stream + responseStream = responseStream.pipeThrough( + injectRSCPayload(rscStream2, { + nonce: options?.nonce, + }), + ) + } + + return { stream: responseStream, status } +} diff --git a/packages/plugin-rsc/examples/navigation/src/framework/error-boundary.tsx b/packages/plugin-rsc/examples/navigation/src/framework/error-boundary.tsx new file mode 100644 index 000000000..39d916510 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/error-boundary.tsx @@ -0,0 +1,81 @@ +'use client' + +import React from 'react' + +// Minimal ErrorBoundary example to handle errors globally on browser +export function GlobalErrorBoundary(props: { children?: React.ReactNode }) { + return ( + + {props.children} + + ) +} + +// https://github.com/vercel/next.js/blob/33f8428f7066bf8b2ec61f025427ceb2a54c4bdf/packages/next/src/client/components/error-boundary.tsx +// https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary +class ErrorBoundary extends React.Component<{ + children?: React.ReactNode + errorComponent: React.FC<{ + error: Error + reset: () => void + }> +}> { + state: { error?: Error } = {} + + static getDerivedStateFromError(error: Error) { + return { error } + } + + reset = () => { + this.setState({ error: null }) + } + + render() { + const error = this.state.error + if (error) { + return + } + return this.props.children + } +} + +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/build/webpack/loaders/next-app-loader.ts#L73 +// https://github.com/vercel/next.js/blob/677c9b372faef680d17e9ba224743f44e1107661/packages/next/src/client/components/error-boundary.tsx#L145 +function DefaultGlobalErrorPage(props: { error: Error; reset: () => void }) { + return ( + + + Unexpected Error + + +

Caught an unexpected error

+
+          Error:{' '}
+          {import.meta.env.DEV && 'message' in props.error
+            ? props.error.message
+            : '(Unknown)'}
+        
+ + + + ) +} diff --git a/packages/plugin-rsc/examples/navigation/src/framework/lib/link.tsx b/packages/plugin-rsc/examples/navigation/src/framework/lib/link.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-rsc/examples/navigation/src/framework/lib/router.tsx b/packages/plugin-rsc/examples/navigation/src/framework/lib/router.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts new file mode 100644 index 000000000..c010147f5 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/navigation.ts @@ -0,0 +1,230 @@ +import { createFromFetch } from '@vitejs/plugin-rsc/browser' +import type { RscPayload } from './entry.rsc' +import { createRscRenderRequest } from './request' + +// https://github.com/vercel/next.js/blob/9436dce61f1a3ff9478261dc2eba47e0527acf3d/packages/next/src/client/components/app-router-instance.ts +// https://github.com/vercel/next.js/blob/9436dce61f1a3ff9478261dc2eba47e0527acf3d/packages/next/src/client/components/app-router.tsx +export type NavigationState = { + url: string + push?: boolean + payloadPromise: Promise +} + +/** + * History state with unique key per entry (History API fallback) + */ +type HistoryState = null | { + key?: string +} + +/** + * Navigation manager + * Encapsulates all navigation logic: history interception, caching, transitions + */ +export class NavigationManager { + private state: NavigationState + private cache = new BackForwardCache>() + private setState!: (state: NavigationState) => void + private startTransition?: (fn: () => void) => void + // History API fallback + private oldPushState = window.history.pushState + private oldReplaceState = window.history.replaceState + + constructor(initialPayload: RscPayload) { + this.state = { + url: window.location.href, + push: false, + payloadPromise: Promise.resolve(initialPayload), + } + this.initializeHistoryState() + } + + /** + * Get current state + */ + getState(): NavigationState { + return this.state + } + + /** + * Connect router to React state handlers + */ + setReactHandlers( + setState: (state: NavigationState) => void, + startTransition: (fn: () => void) => void, + ) { + this.setState = setState + this.startTransition = startTransition + } + + /** + * Navigate to URL + */ + navigate(url: string, push = false) { + if (!this.setState || !this.startTransition) { + throw new Error('NavigationManager not connected to React') + } + + this.startTransition(() => { + this.state = { + url, + push, + payloadPromise: this.cache.run(() => + createFromFetch(fetch(createRscRenderRequest(url))), + ), + } + this.setState(this.state) + }) + } + + /** + * Handle server action result + */ + handleServerAction(payload: RscPayload) { + const payloadPromise = Promise.resolve(payload) + this.cache.set(payloadPromise) + if (!this.setState || !this.startTransition) return + + this.startTransition(() => { + this.state = { + url: window.location.href, + push: false, + payloadPromise, + } + this.setState(this.state) + }) + } + + /** + * Invalidate cache for current entry + */ + invalidateCache() { + this.cache.set(undefined) + } + + /** + * Commit history push (called from useInsertionEffect) + * Only needed for History API fallback + */ + commitHistoryPush(url: string) { + this.state.push = false + this.oldPushState.call(window.history, this.addStateKey({}), '', url) + } + + /** + * Setup navigation interception and listeners + */ + listen(): () => void { + // Intercept pushState + window.history.pushState = (...args) => { + args[0] = this.addStateKey(args[0]) + this.oldPushState.apply(window.history, args) // TODO: no. shouldn't commit url yet + const url = new URL(args[2] || window.location.href, window.location.href) + this.navigate(url.href, false) // push flag handled by commitHistoryPush + } + + // Intercept replaceState + window.history.replaceState = (...args) => { + args[0] = this.addStateKey(args[0]) + this.oldReplaceState.apply(window.history, args) // TODO: no. shouldn't commit url yet + const url = new URL(args[2] || window.location.href, window.location.href) + this.navigate(url.href) + } + + // Handle popstate (back/forward) + const onPopstate = (e: PopStateEvent) => { + // TODO: use state key from event to look up cache + e.state.key + this.navigate(window.location.href) + } + window.addEventListener('popstate', onPopstate) + + // Intercept link clicks + const onClick = (e: MouseEvent) => { + const link = (e.target as Element).closest('a') + if ( + link && + link instanceof HTMLAnchorElement && + link.href && + (!link.target || link.target === '_self') && + link.origin === location.origin && + !link.hasAttribute('download') && + e.button === 0 && + !e.metaKey && + !e.ctrlKey && + !e.altKey && + !e.shiftKey && + !e.defaultPrevented + ) { + e.preventDefault() + window.history.pushState({}, '', link.href) + } + } + document.addEventListener('click', onClick) + + // Cleanup + return () => { + document.removeEventListener('click', onClick) + window.removeEventListener('popstate', onPopstate) + window.history.pushState = this.oldPushState + window.history.replaceState = this.oldReplaceState + } + } + + /** + * Initialize history state with key if not present (History API only) + */ + private initializeHistoryState() { + if (!(window.history.state as HistoryState)?.key) { + this.oldReplaceState.call( + window.history, + this.addStateKey(window.history.state), + '', + window.location.href, + ) + } + } + + // https://github.com/TanStack/router/blob/05941e5ef2b7d2776e885cf473fdcc3970548b22/packages/history/src/index.ts + private addStateKey(state: any): HistoryState { + const key = Math.random().toString(36).slice(2) + return { ...state, key } + } +} + +/** + * Back/Forward cache keyed by navigation entry + * + * Uses Navigation API's built-in keys when available, + * falls back to History API state keys + */ +class BackForwardCache { + private cache: Record = {} + + run(fn: () => T): T { + const key = this.getCurrentKey() + if (typeof key === 'string') { + return (this.cache[key] ??= fn()) + } + return fn() + } + + set(value: T | undefined) { + const key = this.getCurrentKey() + if (typeof key === 'string') { + if (value === undefined) { + delete this.cache[key] + } else { + this.cache[key] = value + } + } + } + + /** + * Get current entry key + * Uses Navigation API when available, falls back to History API + */ + private getCurrentKey(): string | undefined { + return (window.history.state as HistoryState)?.key + } +} diff --git a/packages/plugin-rsc/examples/navigation/src/framework/request.tsx b/packages/plugin-rsc/examples/navigation/src/framework/request.tsx new file mode 100644 index 000000000..5f61b8f1a --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/request.tsx @@ -0,0 +1,60 @@ +// TODO + +// Framework conventions (arbitrary choices for this demo): +// - Use `_.rsc` URL suffix to differentiate RSC requests from SSR requests +// - Use `x-rsc-action` header to pass server action ID +const URL_POSTFIX = '_.rsc' +const HEADER_ACTION_ID = 'x-rsc-action' + +// Parsed request information used to route between RSC/SSR rendering and action handling. +// Created by parseRenderRequest() from incoming HTTP requests. +type RenderRequest = { + isRsc: boolean // true if request should return RSC payload (via _.rsc suffix) + isAction: boolean // true if this is a server action call (POST request) + actionId?: string // server action ID from x-rsc-action header + request: Request // normalized Request with _.rsc suffix removed from URL + url: URL // normalized URL with _.rsc suffix removed +} + +export function createRscRenderRequest( + urlString: string, + action?: { id: string; body: BodyInit }, +): Request { + const url = new URL(urlString) + url.pathname += URL_POSTFIX + const headers = new Headers() + if (action) { + headers.set(HEADER_ACTION_ID, action.id) + } + return new Request(url.toString(), { + method: action ? 'POST' : 'GET', + headers, + body: action?.body, + }) +} + +export function parseRenderRequest(request: Request): RenderRequest { + const url = new URL(request.url) + const isAction = request.method === 'POST' + if (url.pathname.endsWith(URL_POSTFIX)) { + url.pathname = url.pathname.slice(0, -URL_POSTFIX.length) + const actionId = request.headers.get(HEADER_ACTION_ID) || undefined + if (request.method === 'POST' && !actionId) { + throw new Error('Missing action id header for RSC action request') + } + return { + isRsc: true, + isAction, + actionId, + request: new Request(url, request), + url, + } + } else { + return { + isRsc: false, + isAction, + request, + url, + } + } +} diff --git a/packages/plugin-rsc/examples/navigation/src/framework/router.tsx b/packages/plugin-rsc/examples/navigation/src/framework/router.tsx new file mode 100644 index 000000000..8e720c2e4 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/framework/router.tsx @@ -0,0 +1,14 @@ +import { createContext, use } from 'react' + +export type RouterContextType = { + url: string + navigate: (to: string, options?: { replace?: boolean }) => void +} + +export const RouterContext = createContext(undefined!) + +export function useRouter() { + return use(RouterContext) +} + +function createRouter() {} diff --git a/packages/plugin-rsc/examples/navigation/src/index.css b/packages/plugin-rsc/examples/navigation/src/index.css new file mode 100644 index 000000000..2542dddb0 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/index.css @@ -0,0 +1,258 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + min-height: 100vh; +} + +.nav { + position: sticky; + top: 0; + z-index: 100; + background: rgba(255, 255, 255, 0.05); + backdrop-filter: blur(10px); + padding: 1rem 2rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} + +.nav h2 { + margin: 0; + font-size: 1.5rem; + color: #646cff; +} + +.nav-links { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.nav-links a { + padding: 0.5rem 1rem; + text-decoration: none; + color: rgba(255, 255, 255, 0.87); + border-radius: 4px; + transition: all 0.2s; + border: 1px solid transparent; +} + +.nav-links a:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); +} + +.nav-links a.active { + background: #646cff; + color: white; +} + +.nav-links a.active:hover { + background: #535bf2; + border-color: transparent; +} + +.main { + display: flex; + justify-content: center; + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +.page { + max-width: 800px; +} + +.page h1 { + font-size: 2.5rem; + margin-top: 0; + margin-bottom: 1rem; + color: #646cff; +} + +.page > p { + font-size: 1.1rem; + line-height: 1.6; + color: rgba(255, 255, 255, 0.7); +} + +.card { + background: rgba(255, 255, 255, 0.05); + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1.5rem; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.card h2 { + margin-top: 0; + font-size: 1.5rem; + color: rgba(255, 255, 255, 0.9); +} + +.card p { + line-height: 1.6; + color: rgba(255, 255, 255, 0.7); +} + +.card ul, +.card ol { + line-height: 1.8; + color: rgba(255, 255, 255, 0.7); +} + +.card li { + margin-bottom: 0.5rem; +} + +.card li strong { + color: rgba(255, 255, 255, 0.9); +} + +.card code { + background: rgba(0, 0, 0, 0.3); + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: 'Courier New', monospace; + font-size: 0.9em; + color: #646cff; +} + +.code-ref { + font-size: 0.9rem; + font-style: italic; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.button-group { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +button, +.button { + padding: 0.6rem 1.2rem; + font-size: 1rem; + font-weight: 500; + font-family: inherit; + background-color: #646cff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + display: inline-block; +} + +button:hover, +.button:hover { + background-color: #535bf2; + transform: translateY(-1px); +} + +button:active, +.button:active { + transform: translateY(0); +} + +.note { + font-size: 0.9rem; + font-style: italic; + color: rgba(255, 255, 255, 0.5); + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +form { + display: inline; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + + .nav { + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(10px); + border-bottom-color: rgba(0, 0, 0, 0.1); + } + + .nav-links a { + color: #213547; + } + + .nav-links a:hover { + background: rgba(0, 0, 0, 0.05); + border-color: rgba(0, 0, 0, 0.1); + } + + .nav-links a.active { + color: white; + } + + .page > p { + color: rgba(0, 0, 0, 0.6); + } + + .card { + background: rgba(0, 0, 0, 0.03); + border-color: rgba(0, 0, 0, 0.1); + } + + .card h2 { + color: #213547; + } + + .card p, + .card ul, + .card ol { + color: rgba(0, 0, 0, 0.7); + } + + .card li strong { + color: #213547; + } + + .card code { + background: rgba(0, 0, 0, 0.1); + } + + .code-ref { + border-top-color: rgba(0, 0, 0, 0.1); + } + + .note { + color: rgba(0, 0, 0, 0.5); + border-top-color: rgba(0, 0, 0, 0.1); + } +} diff --git a/packages/plugin-rsc/examples/navigation/src/root.tsx b/packages/plugin-rsc/examples/navigation/src/root.tsx new file mode 100644 index 000000000..906f0540c --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/root.tsx @@ -0,0 +1,72 @@ +import './index.css' +import { HomePage } from './routes/home' +import { AboutPage } from './routes/about' +import { SlowPage } from './routes/slow' + +export function Root(props: { url: URL }) { + const { pathname, searchParams } = props.url + + let page: React.ReactNode + let title = 'Navigation Example' + + if (pathname === '/about') { + page = + title = 'About - Navigation Example' + } else if (pathname === '/slow') { + page = + title = 'Slow Page - Navigation Example' + } else { + page = + title = 'Home - Navigation Example' + } + + return ( + + + + + + {title} + + +
+ +
{page}
+
+ + + ) +} diff --git a/packages/plugin-rsc/examples/navigation/src/routes/about.tsx b/packages/plugin-rsc/examples/navigation/src/routes/about.tsx new file mode 100644 index 000000000..26dcb76dc --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/routes/about.tsx @@ -0,0 +1,135 @@ +export function AboutPage() { + return ( +
+

About

+

+ This is a React Server Component rendered on the server and streamed to + the client. +

+
+

Navigation Coordination

+

+ When you navigate between pages, the navigation is coordinated with + React transitions to ensure: +

+
    +
  1. The URL updates at the right time
  2. +
  3. Loading states are properly displayed
  4. +
  5. Race conditions are prevented
  6. +
  7. Back/forward navigation works correctly
  8. +
+
+
+

Current Time

+

+ This page was rendered on the server at:{' '} + {new Date().toLocaleTimeString()} +

+

+ Navigate away and back to see the time update, demonstrating that the + page is re-rendered on the server each time. +

+
+
+

Section 1: What are RSCs?

+

+ React Server Components are a new type of component that runs only on + the server. They can directly access server-side resources like + databases, file systems, or APIs without needing to create separate + API endpoints. +

+

+ This architectural pattern reduces the amount of JavaScript shipped to + the client and enables better performance for data-heavy applications. +

+
+
+

Section 2: Key Differences

+

Unlike client components, server components:

+
    +
  • Cannot use React hooks like useState or useEffect
  • +
  • Cannot handle browser events directly
  • +
  • Can be async functions that await data
  • +
  • Don't add to the client JavaScript bundle
  • +
  • Can import and use server-only packages safely
  • +
+
+
+

Section 3: Composition Patterns

+

+ Server and client components can be composed together seamlessly. A + common pattern is to have server components fetch data and pass it as + props to client components that handle interactivity. +

+

+ This separation of concerns creates a clean architecture where data + fetching and rendering logic stay on the server, while interactive + features run on the client. +

+
+
+

Section 4: Streaming Benefits

+

+ RSCs support streaming, meaning the server can start sending UI to the + client before all data is ready. This creates a progressive loading + experience where users see content incrementally rather than waiting + for everything to load. +

+

+ Suspense boundaries can be used to define loading states for different + parts of the page, enabling fine-grained control over the streaming + behavior. +

+
+
+

Section 5: Caching Strategies

+

+ In this navigation example, we implement a simple but effective + caching strategy. Each time you visit a page, the RSC payload is + cached and associated with the browser history entry. +

+

+ This means when you use back/forward navigation, the page loads + instantly from cache. The cache persists for the session, providing a + smooth browsing experience. +

+
+
+

Section 6: Performance Metrics

+

+ By keeping heavy rendering logic on the server, RSCs can significantly + improve metrics like Time to Interactive (TTI) and First Input Delay + (FID). The reduced JavaScript bundle means faster parsing and + execution. +

+

+ Additionally, server-side rendering enables better SEO and faster + First Contentful Paint (FCP) for initial page loads. +

+
+
+

Section 7: Scroll Testing Area

+

+ Scroll to this section and remember its position. Then navigate to + another page and come back using the browser back button. +

+

+ The browser will automatically restore your scroll position to this + exact location, demonstrating native scroll restoration working with + our coordinated navigation. +

+
+
+

Section 8: End of About Page

+

+ This is the end of the About page. Notice the timestamp at the top - + it updates each time you navigate to this page (not from cache), + showing that the server re-renders the component. +

+

+ 📍 Bottom marker - Use this to test scroll restoration! +

+
+
+ ) +} diff --git a/packages/plugin-rsc/examples/navigation/src/routes/home.tsx b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx new file mode 100644 index 000000000..43756d853 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/routes/home.tsx @@ -0,0 +1,147 @@ +export function HomePage() { + return ( +
+

Home Page

+

+ This example demonstrates coordinating browser history navigation with + React transitions and caching RSC payloads by history entry. +

+
+

Try it out

+

+ Click the navigation links above to see the coordinated navigation in + action: +

+
    +
  • + About - A regular page +
  • +
  • + Slow Page - Simulates a slow server response +
  • +
+

+ Notice the cache behavior: +

+
    +
  • + First visit to a page shows "loading..." indicator (cache miss) +
  • +
  • Navigate to another page, then use browser back button
  • +
  • + No loading indicator! The page renders instantly from cache (cache + hit) +
  • +
  • + Even the slow page is instant on back/forward after first visit +
  • +
+
+
+

Section 1: Understanding React Server Components

+

+ React Server Components (RSC) represent a new paradigm in React + applications. They allow you to write components that render on the + server and stream to the client, reducing bundle size and improving + initial load performance. +

+

+ Unlike traditional server-side rendering, RSCs can be refetched + without a full page reload, enabling dynamic updates while maintaining + the benefits of server rendering. +

+
+
+

Section 2: Navigation Benefits

+

+ This example showcases how coordinated navigation works with React + transitions. The key benefits include: +

+
    +
  • Smooth transitions between pages without full page reloads
  • +
  • Intelligent caching of previously visited pages
  • +
  • Proper handling of browser back/forward buttons
  • +
  • Race condition prevention during rapid navigation
  • +
  • Loading state management during async transitions
  • +
+
+
+

Section 3: Performance Characteristics

+

+ When you navigate to a page for the first time, the RSC payload is + fetched from the server. This payload is then cached in memory, + associated with the specific history entry. +

+

+ On subsequent visits via back/forward navigation, the cached payload + is reused instantly, providing a near-instantaneous page transition. + This creates a seamless user experience similar to a traditional SPA + while maintaining the benefits of server rendering. +

+
+
+

Section 4: Testing Scroll Restoration

+

+ Scroll down this page, then navigate to another page using the links + in the header. After that, use your browser's back button to return + here. +

+

+ Notice how the browser automatically restores your scroll position! + This is native browser behavior that works seamlessly with our + navigation coordination. +

+
+
+

Section 5: Implementation Details

+

+ The implementation uses React's startTransition API to + coordinate navigation updates. This ensures that URL changes and + content updates happen in sync, preventing jarring UI jumps or + inconsistent states. +

+

+ The cache is implemented as a simple Map structure, keyed by history + state IDs. Each navigation creates a unique state ID that persists + across back/forward navigation, enabling reliable cache lookups. +

+
+
+

Section 6: Browser History Integration

+

+ Modern browsers provide sophisticated history management APIs. Our + implementation leverages these APIs to create a seamless navigation + experience that feels native while using React Server Components. +

+

+ The popstate event handler ensures that back/forward + navigation is properly detected and handled, coordinating with React's + rendering cycle to provide smooth transitions. +

+
+
+

Section 7: Future Enhancements

+

This example can be extended with additional features such as:

+
    +
  • Prefetching pages on link hover for even faster navigation
  • +
  • Cache size limits and eviction strategies
  • +
  • Stale-while-revalidate patterns for background updates
  • +
  • Optimistic UI updates during navigation
  • +
  • Progress indicators for slow network conditions
  • +
+
+
+

Section 8: Bottom of Page

+

+ You've reached the bottom! Now try navigating to the About page or + Slow Page, then use the browser back button to see scroll restoration + in action. +

+

+ 📍 Scroll position marker - You can use this to verify that your + scroll position is restored when navigating back to this page. +

+
+
+ ) +} diff --git a/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx b/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx new file mode 100644 index 000000000..af6db0558 --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/src/routes/slow.tsx @@ -0,0 +1,172 @@ +/** + * This page simulates a slow server response to demonstrate + * the navigation transition coordination. + */ +export async function SlowPage(props: { url: URL }) { + const delay = Number(props.url.searchParams.get('delay')) || 500 + + // Simulate slow server response + await new Promise((resolve) => setTimeout(resolve, delay)) + + return ( +
+

Slow Page

+

+ This page simulates a slow server response (delay: {delay}ms) to + demonstrate the navigation transition coordination. +

+
+

What to notice:

+
    +
  • The "pending..." indicator appears while the page is loading
  • +
  • The URL updates immediately when the transition starts
  • +
  • The page content doesn't change until the new data is ready
  • +
  • + If you click another link while this is loading, the navigation is + properly coordinated +
  • +
+
+
+

Try different delays:

+ +
+
+

Page loaded at:

+

+ {new Date().toLocaleTimeString()} +

+
+
+

Section 1: Simulating Network Conditions

+

+ This page intentionally delays its response to simulate slow network + conditions or heavy server-side processing. In real applications, you + might encounter similar delays when: +

+
    +
  • Fetching data from slow external APIs
  • +
  • Running complex database queries
  • +
  • Processing large amounts of data on the server
  • +
  • Dealing with high server load
  • +
+
+
+

Section 2: Transition Coordination

+

+ During the loading period, React's transition system keeps the current + page visible while preparing the new one. This prevents showing a + blank screen or jarring layout shifts. +

+

+ The "pending..." indicator in the navigation shows that a transition + is in progress, giving users clear feedback about the application + state. +

+
+
+

Section 3: Race Condition Prevention

+

+ Try clicking rapidly between different delay options. Notice that even + if you click multiple links quickly, the navigation system properly + handles the race conditions. +

+

+ The most recent navigation always wins, and previous pending + navigations are automatically cancelled. This prevents outdated + content from appearing after a newer navigation has started. +

+
+
+

Section 4: Cache Behavior with Slow Pages

+

+ Here's something interesting: Even though this page takes time to load + initially, once it's cached, it loads instantly when you navigate back + using the browser back button. +

+

+ Try it: navigate to another page, then click back. The previously slow + page now appears immediately because it's served from cache! +

+
+
+

Section 5: Loading State Management

+

+ The loading state is managed at the framework level, coordinating + between: +

+
    +
  • The URL state (updates immediately)
  • +
  • The visual loading indicator (shows during fetch)
  • +
  • The content transition (waits for data)
  • +
  • The history entry (created at the right time)
  • +
+

+ This coordination ensures a consistent user experience even with + varying network conditions. +

+
+
+

Section 6: User Experience Patterns

+

+ In production applications, you might want to add additional UX + enhancements for slow loading scenarios: +

+
    +
  • Skeleton screens to show expected layout
  • +
  • Progress bars for long operations
  • +
  • Cancel buttons for user-initiated aborts
  • +
  • Timeout handling with retry mechanisms
  • +
  • Offline detection and appropriate messaging
  • +
+
+
+

Section 7: Scroll Position Testing

+

+ Scroll down to this section and note your position. Then navigate away + and come back using the browser back button. +

+

+ Even though this page initially took time to load, when you return via + back navigation, it not only loads instantly from cache but also + restores your exact scroll position! +

+
+
+

Section 8: Performance Optimization

+

In real applications, you'd want to optimize slow operations by:

+
    +
  • Using database indexes for faster queries
  • +
  • Implementing server-side caching (Redis, Memcached)
  • +
  • Optimizing API calls with batching or GraphQL
  • +
  • Using CDNs for static assets
  • +
  • Implementing request deduplication
  • +
+
+
+

Section 9: Bottom of Slow Page

+

+ You've scrolled to the bottom! The timestamp above shows when this + page was initially loaded. Try different delay values to see how the + system handles various loading times. +

+

+ 📍 End marker - Perfect spot to test scroll restoration! +

+
+
+ ) +} diff --git a/packages/plugin-rsc/examples/navigation/tsconfig.json b/packages/plugin-rsc/examples/navigation/tsconfig.json new file mode 100644 index 000000000..4c355ed3c --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "erasableSyntaxOnly": true, + "allowImportingTsExtensions": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "moduleResolution": "Bundler", + "module": "ESNext", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": ["vite/client", "@vitejs/plugin-rsc/types"], + "jsx": "react-jsx" + } +} diff --git a/packages/plugin-rsc/examples/navigation/vite.config.ts b/packages/plugin-rsc/examples/navigation/vite.config.ts new file mode 100644 index 000000000..a8ab7440f --- /dev/null +++ b/packages/plugin-rsc/examples/navigation/vite.config.ts @@ -0,0 +1,20 @@ +import rsc from '@vitejs/plugin-rsc' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + clearScreen: false, + plugins: [ + react(), + rsc({ + entries: { + client: './src/framework/entry.browser.tsx', + ssr: './src/framework/entry.ssr.tsx', + rsc: './src/framework/entry.rsc.tsx', + }, + }), + ], + build: { + minify: false, + }, +}) as any diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d418faafd..044bd8fc2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -642,6 +642,37 @@ importers: specifier: ^3.0.2 version: 3.0.2 + packages/plugin-rsc/examples/navigation: + dependencies: + react: + specifier: ^19.2.0 + version: 19.2.0 + react-dom: + specifier: ^19.2.0 + version: 19.2.0(react@19.2.0) + devDependencies: + '@tanstack/history': + specifier: ^1.133.28 + version: 1.133.28 + '@types/react': + specifier: ^19.2.2 + version: 19.2.5 + '@types/react-dom': + specifier: ^19.2.2 + version: 19.2.3(@types/react@19.2.5) + '@vitejs/plugin-react': + specifier: latest + version: link:../../../plugin-react + '@vitejs/plugin-rsc': + specifier: latest + version: link:../.. + rsc-html-stream: + specifier: ^0.0.7 + version: 0.0.7 + vite: + specifier: ^7.1.10 + version: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + packages/plugin-rsc/examples/no-ssr: dependencies: react: @@ -2190,6 +2221,10 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/history@1.133.28': + resolution: {integrity: sha512-B7+x7eP2FFvi3fgd3rNH9o/Eixt+pp0zCIdGhnQbAJjFrlwIKGjGnwyJjhWJ5fMQlGks/E2LdDTqEV4W9Plx7g==} + engines: {node: '>=12'} + '@tsconfig/strictest@2.0.8': resolution: {integrity: sha512-XnQ7vNz5HRN0r88GYf1J9JJjqtZPiHt2woGJOo2dYqyHGGcd6OLGqSlBB6p1j9mpzja6Oe5BoPqWmeDx6X9rLw==} @@ -5588,6 +5623,8 @@ snapshots: tailwindcss: 4.1.17 vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(yaml@2.8.1) + '@tanstack/history@1.133.28': {} + '@tsconfig/strictest@2.0.8': {} '@tybys/wasm-util@0.10.1': @@ -5647,7 +5684,7 @@ snapshots: '@types/hoist-non-react-statics@3.3.6': dependencies: - '@types/react': 18.3.20 + '@types/react': 19.2.5 hoist-non-react-statics: 3.3.2 '@types/json-schema@7.0.15': {}