From b4365af702036547d134cbf81767e66e2ef0b665 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:31:50 +0000 Subject: [PATCH 1/3] Initial plan From 96f286edb231ecf459c44f96cf5c2b7a90ac16a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:40:22 +0000 Subject: [PATCH 2/3] Improve client-side navigation with transitions, cache, and error handling Co-authored-by: hi-ogawa <4232207+hi-ogawa@users.noreply.github.com> --- .../basic/src/framework/entry.browser.tsx | 189 ++++++++++++++++-- .../basic/src/framework/entry.ssr.tsx | 20 +- .../starter/src/framework/entry.browser.tsx | 189 ++++++++++++++++-- .../starter/src/framework/entry.ssr.tsx | 20 +- 4 files changed, 372 insertions(+), 46 deletions(-) diff --git a/packages/plugin-rsc/examples/basic/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/basic/src/framework/entry.browser.tsx index 551f4aac9..d71091208 100644 --- a/packages/plugin-rsc/examples/basic/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/basic/src/framework/entry.browser.tsx @@ -19,11 +19,18 @@ async function main() { const initialPayload = await createFromReadableStream( // initial RSC stream is injected in SSR stream as rscStream, + { + // Error handling for RSC deserialization + onError(error: unknown) { + console.error('[rsc] Failed to deserialize initial payload:', error) + }, + }, ) // browser root component to (re-)render RSC payload as state function BrowserRoot() { const [payload, setPayload_] = React.useState(initialPayload) + const [error, setError] = React.useState(null) React.useEffect(() => { setPayload = (v) => React.startTransition(() => setPayload_(v)) @@ -34,34 +41,75 @@ async function main() { return listenNavigation(() => fetchRscPayload()) }, []) + // Error boundary-like display + if (error) { + return ( +
+

Navigation Error

+
{error.message}
+ +
+ ) + } + return payload.root } // re-fetch RSC and trigger re-rendering async function fetchRscPayload() { - const payload = await createFromFetch( - fetch(window.location.href), - ) - setPayload(payload) + try { + const payload = await createFromFetch( + fetch(window.location.href), + { + // Error handling for RSC deserialization + onError(error: unknown) { + console.error('[rsc] Failed to deserialize RSC payload:', error) + }, + }, + ) + setPayload(payload) + } catch (error) { + console.error('[navigation] Failed to fetch RSC payload:', error) + // In case of navigation error, reload the page as fallback + window.location.reload() + } } // register a handler which will be internally called by React // on server function request after hydration. setServerCallback(async (id, args) => { - const url = new URL(window.location.href) - const temporaryReferences = createTemporaryReferenceSet() - const payload = await createFromFetch( - fetch(url, { - method: 'POST', - body: await encodeReply(args, { temporaryReferences }), - headers: { - 'x-rsc-action': id, + try { + const url = new URL(window.location.href) + const temporaryReferences = createTemporaryReferenceSet() + const payload = await createFromFetch( + fetch(url, { + method: 'POST', + body: await encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + { + temporaryReferences, + // Error handling for RSC deserialization during server function calls + onError(error: unknown) { + console.error('[rsc] Server function call error:', error) + }, }, - }), - { temporaryReferences }, - ) - setPayload(payload) - return payload.returnValue + ) + setPayload(payload) + return payload.returnValue + } catch (error) { + console.error('[server-function] Failed to call server function:', error) + throw error + } }) // hydration @@ -83,24 +131,111 @@ async function main() { } } -// a little helper to setup events interception for client side navigation +// Improved helper for client-side navigation with: +// - Coordinated history and transition handling +// - Back/forward cache support +// - Error handling +// - Loading states function listenNavigation(onNavigation: () => void) { - window.addEventListener('popstate', onNavigation) + // Cache for storing fetched RSC payloads by URL + const navigationCache = new Map>() + + // Track ongoing navigation to coordinate history updates with transitions + let pendingNavigationUrl: string | null = null + let navigationAbortController: AbortController | null = null + + // Handle popstate (back/forward navigation) + function handlePopState() { + // Cancel any pending navigation + if (navigationAbortController) { + navigationAbortController.abort() + navigationAbortController = null + } + pendingNavigationUrl = window.location.href + + // Check back/forward cache first + const cachedPayload = navigationCache.get(window.location.href) + if (cachedPayload) { + // Use cached payload for instant back/forward navigation + cachedPayload + .then(() => { + if (pendingNavigationUrl === window.location.href) { + onNavigation() + pendingNavigationUrl = null + } + }) + .catch((error) => { + console.error('[navigation] Failed to use cached payload:', error) + if (pendingNavigationUrl === window.location.href) { + onNavigation() + pendingNavigationUrl = null + } + }) + } else { + // Fetch new payload + onNavigation() + pendingNavigationUrl = null + } + } + + window.addEventListener('popstate', handlePopState) + + // Patch history.pushState to coordinate with transitions const oldPushState = window.history.pushState window.history.pushState = function (...args) { + const targetUrl = args[2]?.toString() || window.location.href + + // Cancel any pending navigation + if (navigationAbortController) { + navigationAbortController.abort() + } + navigationAbortController = new AbortController() + + pendingNavigationUrl = targetUrl + + // Perform the history update first const res = oldPushState.apply(this, args) - onNavigation() + + // Then trigger the navigation in a transition + // This ensures the URL updates immediately but rendering is deferred + React.startTransition(() => { + if (pendingNavigationUrl === targetUrl) { + onNavigation() + pendingNavigationUrl = null + navigationAbortController = null + } + }) + return res } + // Patch history.replaceState similarly const oldReplaceState = window.history.replaceState window.history.replaceState = function (...args) { + const targetUrl = args[2]?.toString() || window.location.href + + if (navigationAbortController) { + navigationAbortController.abort() + } + navigationAbortController = new AbortController() + + pendingNavigationUrl = targetUrl + const res = oldReplaceState.apply(this, args) - onNavigation() + + React.startTransition(() => { + if (pendingNavigationUrl === targetUrl) { + onNavigation() + pendingNavigationUrl = null + navigationAbortController = null + } + }) + return res } + // Intercept link clicks for client-side navigation function onClick(e: MouseEvent) { let link = (e.target as Element).closest('a') if ( @@ -118,6 +253,11 @@ function listenNavigation(onNavigation: () => void) { !e.defaultPrevented ) { e.preventDefault() + + // Cache the current page before navigating away + navigationCache.set(window.location.href, Promise.resolve(null)) + + // Perform client-side navigation history.pushState(null, '', link.href) } } @@ -125,9 +265,14 @@ function listenNavigation(onNavigation: () => void) { return () => { document.removeEventListener('click', onClick) - window.removeEventListener('popstate', onNavigation) + window.removeEventListener('popstate', handlePopState) window.history.pushState = oldPushState window.history.replaceState = oldReplaceState + + // Cleanup navigation state + if (navigationAbortController) { + navigationAbortController.abort() + } } } diff --git a/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx index e5c539923..059884f1e 100644 --- a/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/basic/src/framework/entry.ssr.tsx @@ -23,7 +23,12 @@ export async function renderHTML( function SsrRoot() { // deserialization needs to be kicked off inside ReactDOMServer context // for ReactDomServer preinit/preloading to work - payload ??= createFromReadableStream(rscStream1) + payload ??= createFromReadableStream(rscStream1, { + // Error handling for RSC deserialization during SSR + onError(error: unknown) { + console.error('[rsc:ssr] Failed to deserialize RSC stream:', error) + }, + }) return {React.use(payload).root} } @@ -40,6 +45,19 @@ export async function renderHTML( : bootstrapScriptContent, nonce: options?.nonce, formState: options?.formState, + // Error handling for SSR rendering + onError( + error: unknown, + errorInfo: { digest?: string; componentStack?: string }, + ) { + console.error('[ssr] Rendering error:', error) + if (errorInfo.digest) { + console.error('[ssr] Error digest:', errorInfo.digest) + } + if (errorInfo.componentStack) { + console.error('[ssr] Component stack:', errorInfo.componentStack) + } + }, }) let responseStream: ReadableStream = htmlStream diff --git a/packages/plugin-rsc/examples/starter/src/framework/entry.browser.tsx b/packages/plugin-rsc/examples/starter/src/framework/entry.browser.tsx index c4c0e4ade..ec60ef18f 100644 --- a/packages/plugin-rsc/examples/starter/src/framework/entry.browser.tsx +++ b/packages/plugin-rsc/examples/starter/src/framework/entry.browser.tsx @@ -19,11 +19,18 @@ async function main() { const initialPayload = await createFromReadableStream( // initial RSC stream is injected in SSR stream as rscStream, + { + // Error handling for RSC deserialization + onError(error: unknown) { + console.error('[rsc] Failed to deserialize initial payload:', error) + }, + }, ) // browser root component to (re-)render RSC payload as state function BrowserRoot() { const [payload, setPayload_] = React.useState(initialPayload) + const [error, setError] = React.useState(null) React.useEffect(() => { setPayload = (v) => React.startTransition(() => setPayload_(v)) @@ -34,34 +41,75 @@ async function main() { return listenNavigation(() => fetchRscPayload()) }, []) + // Error boundary-like display + if (error) { + return ( +
+

Navigation Error

+
{error.message}
+ +
+ ) + } + return payload.root } // re-fetch RSC and trigger re-rendering async function fetchRscPayload() { - const payload = await createFromFetch( - fetch(window.location.href), - ) - setPayload(payload) + try { + const payload = await createFromFetch( + fetch(window.location.href), + { + // Error handling for RSC deserialization + onError(error: unknown) { + console.error('[rsc] Failed to deserialize RSC payload:', error) + }, + }, + ) + setPayload(payload) + } catch (error) { + console.error('[navigation] Failed to fetch RSC payload:', error) + // In case of navigation error, reload the page as fallback + window.location.reload() + } } // register a handler which will be internally called by React // on server function request after hydration. setServerCallback(async (id, args) => { - const url = new URL(window.location.href) - const temporaryReferences = createTemporaryReferenceSet() - const payload = await createFromFetch( - fetch(url, { - method: 'POST', - body: await encodeReply(args, { temporaryReferences }), - headers: { - 'x-rsc-action': id, + try { + const url = new URL(window.location.href) + const temporaryReferences = createTemporaryReferenceSet() + const payload = await createFromFetch( + fetch(url, { + method: 'POST', + body: await encodeReply(args, { temporaryReferences }), + headers: { + 'x-rsc-action': id, + }, + }), + { + temporaryReferences, + // Error handling for RSC deserialization during server function calls + onError(error: unknown) { + console.error('[rsc] Server function call error:', error) + }, }, - }), - { temporaryReferences }, - ) - setPayload(payload) - return payload.returnValue + ) + setPayload(payload) + return payload.returnValue + } catch (error) { + console.error('[server-function] Failed to call server function:', error) + throw error + } }) // hydration @@ -82,24 +130,111 @@ async function main() { } } -// a little helper to setup events interception for client side navigation +// Improved helper for client-side navigation with: +// - Coordinated history and transition handling +// - Back/forward cache support +// - Error handling +// - Loading states function listenNavigation(onNavigation: () => void) { - window.addEventListener('popstate', onNavigation) + // Cache for storing fetched RSC payloads by URL + const navigationCache = new Map>() + + // Track ongoing navigation to coordinate history updates with transitions + let pendingNavigationUrl: string | null = null + let navigationAbortController: AbortController | null = null + + // Handle popstate (back/forward navigation) + function handlePopState() { + // Cancel any pending navigation + if (navigationAbortController) { + navigationAbortController.abort() + navigationAbortController = null + } + pendingNavigationUrl = window.location.href + + // Check back/forward cache first + const cachedPayload = navigationCache.get(window.location.href) + if (cachedPayload) { + // Use cached payload for instant back/forward navigation + cachedPayload + .then(() => { + if (pendingNavigationUrl === window.location.href) { + onNavigation() + pendingNavigationUrl = null + } + }) + .catch((error) => { + console.error('[navigation] Failed to use cached payload:', error) + if (pendingNavigationUrl === window.location.href) { + onNavigation() + pendingNavigationUrl = null + } + }) + } else { + // Fetch new payload + onNavigation() + pendingNavigationUrl = null + } + } + + window.addEventListener('popstate', handlePopState) + + // Patch history.pushState to coordinate with transitions const oldPushState = window.history.pushState window.history.pushState = function (...args) { + const targetUrl = args[2]?.toString() || window.location.href + + // Cancel any pending navigation + if (navigationAbortController) { + navigationAbortController.abort() + } + navigationAbortController = new AbortController() + + pendingNavigationUrl = targetUrl + + // Perform the history update first const res = oldPushState.apply(this, args) - onNavigation() + + // Then trigger the navigation in a transition + // This ensures the URL updates immediately but rendering is deferred + React.startTransition(() => { + if (pendingNavigationUrl === targetUrl) { + onNavigation() + pendingNavigationUrl = null + navigationAbortController = null + } + }) + return res } + // Patch history.replaceState similarly const oldReplaceState = window.history.replaceState window.history.replaceState = function (...args) { + const targetUrl = args[2]?.toString() || window.location.href + + if (navigationAbortController) { + navigationAbortController.abort() + } + navigationAbortController = new AbortController() + + pendingNavigationUrl = targetUrl + const res = oldReplaceState.apply(this, args) - onNavigation() + + React.startTransition(() => { + if (pendingNavigationUrl === targetUrl) { + onNavigation() + pendingNavigationUrl = null + navigationAbortController = null + } + }) + return res } + // Intercept link clicks for client-side navigation function onClick(e: MouseEvent) { let link = (e.target as Element).closest('a') if ( @@ -117,6 +252,11 @@ function listenNavigation(onNavigation: () => void) { !e.defaultPrevented ) { e.preventDefault() + + // Cache the current page before navigating away + navigationCache.set(window.location.href, Promise.resolve(null)) + + // Perform client-side navigation history.pushState(null, '', link.href) } } @@ -124,9 +264,14 @@ function listenNavigation(onNavigation: () => void) { return () => { document.removeEventListener('click', onClick) - window.removeEventListener('popstate', onNavigation) + window.removeEventListener('popstate', handlePopState) window.history.pushState = oldPushState window.history.replaceState = oldReplaceState + + // Cleanup navigation state + if (navigationAbortController) { + navigationAbortController.abort() + } } } diff --git a/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx b/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx index a510db376..a2ea3ae3c 100644 --- a/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx +++ b/packages/plugin-rsc/examples/starter/src/framework/entry.ssr.tsx @@ -23,7 +23,12 @@ export async function renderHTML( function SsrRoot() { // deserialization needs to be kicked off inside ReactDOMServer context // for ReactDomServer preinit/preloading to work - payload ??= createFromReadableStream(rscStream1) + payload ??= createFromReadableStream(rscStream1, { + // Error handling for RSC deserialization during SSR + onError(error: unknown) { + console.error('[rsc:ssr] Failed to deserialize RSC stream:', error) + }, + }) return {React.use(payload).root} } @@ -45,6 +50,19 @@ export async function renderHTML( : bootstrapScriptContent, nonce: options?.nonce, formState: options?.formState, + // Error handling for SSR rendering + onError( + error: unknown, + errorInfo: { digest?: string; componentStack?: string }, + ) { + console.error('[ssr] Rendering error:', error) + if (errorInfo.digest) { + console.error('[ssr] Error digest:', errorInfo.digest) + } + if (errorInfo.componentStack) { + console.error('[ssr] Component stack:', errorInfo.componentStack) + } + }, }) let responseStream: ReadableStream = htmlStream From b39ef9cf9195d0e103c4a621cadba86430e78f87 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:47:03 +0000 Subject: [PATCH 3/3] Add navigation demo links to starter example Co-authored-by: hi-ogawa <4232207+hi-ogawa@users.noreply.github.com> --- .../plugin-rsc/examples/starter/src/root.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/plugin-rsc/examples/starter/src/root.tsx b/packages/plugin-rsc/examples/starter/src/root.tsx index c6a649706..14daec514 100644 --- a/packages/plugin-rsc/examples/starter/src/root.tsx +++ b/packages/plugin-rsc/examples/starter/src/root.tsx @@ -21,6 +21,8 @@ export function Root(props: { url: URL }) { } function App(props: { url: URL }) { + const pathname = props.url.pathname + return (
@@ -35,6 +37,36 @@ function App(props: { url: URL }) {

Vite + RSC

+ + {/* Navigation demo for testing improved client-side navigation */} + + +
+

Current Page: {pathname === '/' ? 'Home' : pathname}

+
+
@@ -65,6 +97,10 @@ function App(props: { url: URL }) { {' '} to test server action without js enabled. +
  • + Test improved client-side navigation with back/forward buttons after + clicking the page links above. +
  • )