Skip to content
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

feat(rsc): Initial support for RSA rerender #11406

Merged
merged 4 commits into from
Sep 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changesets/11406.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions packages/router/src/rsc/ClientRouter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RscFetcher rscId="__rwjs__Routes" rscProps={rscProps} />
}

Expand Down
132 changes: 93 additions & 39 deletions packages/router/src/rsc/RscFetcher.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -17,27 +17,48 @@ export interface RscProps extends Record<string, unknown> {
}
}

export function rscFetch(
rscId: string,
serializedProps: string,
// setComponent?: (component: Thenable<React.ReactElement>) => void,
let updateCurrentRscCacheKey = (key: string) => {
console.error('updateCurrentRscCacheKey called before it was set')
console.error('updateCurrentRscCacheKey key', key)
}

function onStreamFinished(
fetchPromise: ReturnType<typeof fetch>,
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()
searchParams.set('props', serializedProps)

// 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',
},
Expand All @@ -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)
Expand All @@ -69,31 +97,37 @@ 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: {
'rw-rsc': '1',
},
})

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
Expand All @@ -104,12 +138,14 @@ export function rscFetch(
}

const componentPromise = createFromFetch<never, React.ReactElement>(
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
}

Expand All @@ -120,25 +156,43 @@ interface Props {

export const RscFetcher = ({ rscId, rscProps }: Props) => {
const serializedProps = JSON.stringify(rscProps)
const [component, setComponent] = useState<any>(() => {
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)
}
Loading