diff --git a/.changesets/11406.md b/.changesets/11406.md new file mode 100644 index 000000000000..44ca9b1f8a9b --- /dev/null +++ b/.changesets/11406.md @@ -0,0 +1,7 @@ +- feat(rsc): Initial support for RSA rerender (#11406) by @Tobbe + +This PR makes it so that the entire page is re-rendered when a React Server Action returns. +Previously when calling an RSA you'd only get the result of the action back. +Now, when calling an RSA you'll still get the result back, and in addition to that the page will update. +What this means is that if you for example update a counter on the server that a server component is displaying that counter will now immediately update. +Also, if some data has been updated by something external to the app the new data will be displayed (like if someone used an external CMS to update some .md file you're rendering) diff --git a/packages/router/src/rsc/ClientRouter.tsx b/packages/router/src/rsc/ClientRouter.tsx index e44274a4db91..d262c22b2e3a 100644 --- a/packages/router/src/rsc/ClientRouter.tsx +++ b/packages/router/src/rsc/ClientRouter.tsx @@ -87,6 +87,10 @@ const LocationAwareRouter = ({ } const rscProps = { location: { pathname, search } } + // TODO (RSC): I think that moving between private and public routes + // re-initializes RscFetcher. I wonder if there's an optimization to be made + // here. Maybe we can lift RscFetcher up so we can keep the same instance + // around and reuse it everywhere return } diff --git a/packages/router/src/rsc/RscFetcher.tsx b/packages/router/src/rsc/RscFetcher.tsx index a68b12bb0b5d..0c5e19abf6eb 100644 --- a/packages/router/src/rsc/RscFetcher.tsx +++ b/packages/router/src/rsc/RscFetcher.tsx @@ -1,5 +1,5 @@ import type React from 'react' -import { useState, useEffect } from 'react' +import { use, useState, useEffect } from 'react' import type { Options } from 'react-server-dom-webpack/client' import { createFromFetch, encodeReply } from 'react-server-dom-webpack/client' @@ -17,19 +17,40 @@ export interface RscProps extends Record { } } -export function rscFetch( - rscId: string, - serializedProps: string, - // setComponent?: (component: Thenable) => void, +let updateCurrentRscCacheKey = (key: string) => { + console.error('updateCurrentRscCacheKey called before it was set') + console.error('updateCurrentRscCacheKey key', key) +} + +function onStreamFinished( + fetchPromise: ReturnType, + onFinished: (text: string) => void, ) { - console.log('rscFetch :: rscId', rscId) - console.log('rscFetch :: props', serializedProps) + return ( + fetchPromise + // clone the response so createFromFetch can use it (otherwise we lock the + // reader) and wait for the text to be consumed so we know the stream is + // finished + .then((response) => response.clone().text()) + .then(onFinished) + ) +} + +function rscFetch(rscId: string, serializedProps: string) { + console.log( + 'rscFetch :: args:\n rscId: ' + + rscId + + '\n serializedProps: ' + + serializedProps, + ) + const rscCacheKey = `${rscId}::${serializedProps}` - // TODO (RSC): The cache key should be rscId + serializedProps - const cached = rscCache.get(serializedProps) + const cached = rscCache.get(rscCacheKey) if (cached) { - console.log('rscFetch :: cache hit for', serializedProps) + console.log('rscFetch :: cache hit for', rscCacheKey) return cached + } else { + console.log('rscFetch :: cache miss for', rscCacheKey) } const searchParams = new URLSearchParams() @@ -37,7 +58,7 @@ export function rscFetch( // TODO (RSC): During SSR we should not fetch (Is this function really // called during SSR?) - const response = fetch(BASE_PATH + rscId + '?' + searchParams, { + const responsePromise = fetch(BASE_PATH + rscId + '?' + searchParams, { headers: { 'rw-rsc': '1', }, @@ -52,9 +73,16 @@ export function rscFetch( callServer: async function (rsaId: string, args: unknown[]) { // `args` is often going to be an array with just a single element, // and that element will be FormData - console.log('rscFetchForClientRouter.ts :: callServer') - console.log(' rsaId', rsaId) - console.log(' args', args) + console.log('RscFetcher :: callServer rsaId', rsaId, 'args', args) + + // Including rsaId here to make sure the page rerenders when calling RSAs + // Calling a RSA doesn't change the url (i.e. `serializedProps`), and it + // also doesn't change the rscId, so React would not detect a state change + // that would trigger a rerender. So we include the rsaId here to make + // a new cache key that will trigger a rerender. + // TODO (RSC): What happens if you call the same RSA twice in a row? + // Like `increment()` + const rscCacheKey = `${rscId}::${serializedProps}::${rsaId}::${new Date()}` const searchParams = new URLSearchParams() searchParams.set('action_id', rsaId) @@ -69,7 +97,7 @@ export function rscFetch( console.error('Error encoding Server Action arguments', e) } - const response = fetch(BASE_PATH + id + '?' + searchParams, { + const responsePromise = fetch(BASE_PATH + id + '?' + searchParams, { method: 'POST', body, headers: { @@ -77,23 +105,29 @@ export function rscFetch( }, }) + onStreamFinished(responsePromise, () => { + updateCurrentRscCacheKey(rscCacheKey) + }) + // I'm not sure this recursive use of `options` is needed. I briefly // tried without it, and things seemed to work. But keeping it for // now, until we learn more. - const data = createFromFetch(response, options) + const dataPromise = createFromFetch(responsePromise, options) - const dataValue = await data - console.log( - 'rscFetchForClientRuoter.ts :: callServer dataValue', - dataValue, - ) + // TODO (RSC): This is where we want to update the RSA cache, but first we + // need to normalize the data that comes back from the server. We need to + // always send an object with a `__rwjs__rsa_data` key and some key + // for the flight data + // rscCache.set(rscCacheKey, dataPromise) + + const dataValue = await dataPromise + console.log('RscFetcher :: callServer dataValue', dataValue) // TODO (RSC): Fix the types for `createFromFetch` // @ts-expect-error The type is wrong for createFromFetch const Routes = dataValue.Routes?.[0] console.log('Routes', Routes) - // TODO (RSC): Figure out how to trigger a rerender of the page with the - // new Routes + rscCache.set(rscCacheKey, Promise.resolve(Routes)) // TODO (RSC): Fix the types for `createFromFetch` // @ts-expect-error The type is wrong for createFromFetch. It can really @@ -104,12 +138,14 @@ export function rscFetch( } const componentPromise = createFromFetch( - response, + responsePromise, options, ) - rscCache.set(serializedProps, componentPromise) + rscCache.set(rscCacheKey, componentPromise) + // TODO (RSC): Figure out if this is ever used, or if it's better to return + // the cache key return componentPromise } @@ -120,25 +156,43 @@ interface Props { export const RscFetcher = ({ rscId, rscProps }: Props) => { const serializedProps = JSON.stringify(rscProps) - const [component, setComponent] = useState(() => { - console.log('RscFetcher :: useState callback') - - return rscFetch(rscId, serializedProps) + const [currentRscCacheKey, setCurrentRscCacheKey] = useState(() => { + console.log('RscFetcher :: useState initial value') + // Calling rscFetch here to prime the cache + rscFetch(rscId, serializedProps) + return `${rscId}::${serializedProps}` }) - console.log('RscFetcher rerender rscId', rscId) - console.log('RscFetcher rerender rscProps', rscProps) + useEffect(() => { + console.log('RscFetcher :: useEffect set updateCurrentRscCacheKey') + updateCurrentRscCacheKey = (key: string) => { + console.log('RscFetcher inside updateCurrentRscCacheKey', key) - if (!rscCache.get(serializedProps)) { - rscFetch(rscId, serializedProps) - } + setCurrentRscCacheKey(key) + } + }, []) useEffect(() => { - console.log('RscFetcher :: useEffect rscProps') - const componentPromise = rscFetch(rscId, serializedProps) - console.log('componentPromise', componentPromise) - setComponent(componentPromise) + console.log('RscFetcher :: useEffect about to call rscFetch') + // rscFetch will update rscCache with the fetched component + rscFetch(rscId, serializedProps) + setCurrentRscCacheKey(`${rscId}::${serializedProps}`) }, [rscId, serializedProps]) - return component + console.log( + 'RscFetcher :: current props\n' + + ' rscId: ' + + rscId + + '\n rscProps: ' + + serializedProps, + ) + console.log('RscFetcher :: rendering cache entry for\n' + currentRscCacheKey) + + const component = rscCache.get(currentRscCacheKey) + + if (!component) { + throw new Error('Missing RSC cache entry for ' + currentRscCacheKey) + } + + return use(component) }