Skip to content

[ppr] RDC for RSCs #79638

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

Draft
wants to merge 4 commits into
base: canary
Choose a base branch
from
Draft
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
5 changes: 5 additions & 0 deletions .changeset/cold-spiders-do.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'next': patch
---

Allow the Dynamic RSC requests to receive the postponed state so it may use the encoded Resume Data Cache.
5 changes: 4 additions & 1 deletion packages/next/errors.json
Original file line number Diff line number Diff line change
Expand Up @@ -692,5 +692,8 @@
"691": "Accessed fallback \\`params\\` during prerendering.",
"692": "Expected clientReferenceManifest to be defined.",
"693": "%s must not be used within a client component. Next.js should be preventing %s from being included in client components statically, but did not in this case.",
"694": "createPrerenderPathname was called inside a client component scope."
"694": "createPrerenderPathname was called inside a client component scope.",
"695": "expected a result to be returned",
"696": "expected a page to be returned for a dynamic RSC request, got %snull",
"697": "expected a page response, got %s"
}
95 changes: 56 additions & 39 deletions packages/next/src/server/base-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2472,7 +2472,11 @@ export default abstract class Server<
!opts.supportsDynamicResponse &&
!isPossibleServerAction &&
!minimalPostponed &&
!isDynamicRSCRequest
// If this route is PPR enabled, we want to get the cached response
// because we need the postponed state to render the dynamic RSC response.
// If this route is not PPR enabled, we can just get the dynamic response
// which will happen when the `ssgCacheKey` is not set.
(isRoutePPREnabled || !isDynamicRSCRequest)
) {
ssgCacheKey = `${locale ? `/${locale}` : ''}${
(pathname === '/' || resolvedUrlPathname === '/') && locale
Expand Down Expand Up @@ -2532,6 +2536,12 @@ export default abstract class Server<
* The unknown route params for this render.
*/
fallbackRouteParams: FallbackRouteParams | null

/**
* Whether this render supports dynamic response. When `undefined` it will
* be determined based on the route configuration.
*/
supportsDynamicResponse: true | undefined
}
type Renderer = (
context: RendererContext
Expand All @@ -2541,22 +2551,16 @@ export default abstract class Server<
postponed,
pagesFallback = false,
fallbackRouteParams,
}) => {
// In development, we always want to generate dynamic HTML.
let supportsDynamicResponse: boolean =
// If we're in development, we always support dynamic HTML, unless it's
// a data request, in which case we only produce static HTML.
(!isNextDataRequest && opts.dev === true) ||
// If we're in development, we always support dynamic HTML, unless it's
// a data request, in which case we only produce static HTML.
supportsDynamicResponse = (!isNextDataRequest && opts.dev === true) ||
// If this is not SSG or does not have static paths, then it supports
// dynamic HTML.
(!isSSG && !hasGetStaticPaths) ||
// If this request has provided postponed data, it supports dynamic
// HTML.
typeof postponed === 'string' ||
// If this is a dynamic RSC request, then this render supports dynamic
// HTML (it's dynamic).
isDynamicRSCRequest

// If this request has provided postponed data, it supports a dynamic
// response.
typeof postponed === 'string',
}) => {
const origQuery = parseUrl(req.url || '', true).query

// clear any dynamic route params so they aren't in
Expand Down Expand Up @@ -3176,6 +3180,7 @@ export default abstract class Server<
// aren't a app path.
pagesFallback: true,
fallbackRouteParams: null,
supportsDynamicResponse: undefined,
})
},
{
Expand Down Expand Up @@ -3211,6 +3216,7 @@ export default abstract class Server<
isProduction || isDebugFallbackShell
? getFallbackRouteParams(pathname)
: null,
supportsDynamicResponse: undefined,
}),
{
routeKind: RouteKind.APP_PAGE,
Expand Down Expand Up @@ -3274,6 +3280,7 @@ export default abstract class Server<
postponed,
pagesFallback: undefined,
fallbackRouteParams,
supportsDynamicResponse: undefined,
})
}

Expand Down Expand Up @@ -3359,15 +3366,9 @@ export default abstract class Server<
cacheControl = { revalidate: 0, expire: undefined }
}

// If this is in minimal mode and this is a flight request that isn't a
// prefetch request while PPR is enabled, it cannot be cached as it contains
// dynamic content.
else if (
this.minimalMode &&
isRSCRequest &&
!isPrefetchRSCRequest &&
isRoutePPREnabled
) {
// If this is a flight request that isn't a pre-fetch request while PPR is
// enabled, it cannot be cached as it contains dynamic content.
else if (isDynamicRSCRequest) {
cacheControl = { revalidate: 0, expire: undefined }
} else if (!this.renderOpts.dev || (hasServerProps && !isNextDataRequest)) {
// If this is a preview mode request, we shouldn't cache it
Expand Down Expand Up @@ -3631,7 +3632,7 @@ export default abstract class Server<
}

// Mark that the request did postpone.
if (didPostpone) {
if (didPostpone && !isDynamicRSCRequest) {
res.setHeader(NEXT_DID_POSTPONE_HEADER, '1')
}

Expand All @@ -3640,23 +3641,38 @@ export default abstract class Server<
// generate both HTML and payloads in the same request so continue to just
// return the generated payload
if (isRSCRequest && !isPreviewMode) {
// If this is a dynamic RSC request, then stream the response.
// If this is a dynamic RSC request, then stream the response. This
// would only be the case when the `html` actually contains the dynamic
// RSC response rather than the actual HTML data.
if (typeof cachedData.rscData === 'undefined') {
if (cachedData.postponed) {
throw new Error('Invariant: Expected postponed to be undefined')
return {
type: 'rsc',
body: cachedData.html,
cacheControl: cacheEntry.cacheControl,
}
}
// If the route is PPR enabled and this is a dynamic RSC request, then
// we need to run the render again with the provided postponed state
// (if any) as it will use that state for it's embedded resume data
// cache.
else if (isDynamicRSCRequest) {
const data = await doRender({
postponed: cachedData.postponed,
pagesFallback: undefined,
fallbackRouteParams: null,
// We want to stream the dynamic RSC response to the client.
supportsDynamicResponse: true,
})
if (data?.value?.kind !== CachedRouteKind.APP_PAGE) {
throw new InvariantError(
`expected a page to be returned for a dynamic RSC request, got ${data?.value?.kind ?? 'null'}`
)
}

return {
type: 'rsc',
body: cachedData.html,
// Dynamic RSC responses cannot be cached, even if they're
// configured with `force-static` because we have no way of
// distinguishing between `force-static` and pages that have no
// postponed state.
// TODO: distinguish `force-static` from pages with no postponed state (static)
cacheControl: isDynamicRSCRequest
? { revalidate: 0, expire: undefined }
: cacheEntry.cacheControl,
body: data.value.html,
cacheControl: cacheEntry.cacheControl,
}
}

Expand Down Expand Up @@ -3721,15 +3737,16 @@ export default abstract class Server<
// This is a resume render, not a fallback render, so we don't need to
// set this.
fallbackRouteParams: null,
supportsDynamicResponse: true,
})
.then(async (result) => {
if (!result) {
throw new Error('Invariant: expected a result to be returned')
throw new InvariantError('expected a result to be returned')
}

if (result.value?.kind !== CachedRouteKind.APP_PAGE) {
throw new Error(
`Invariant: expected a page response, got ${result.value?.kind}`
throw new InvariantError(
`expected a page response, got ${result.value?.kind}`
)
}

Expand Down
56 changes: 29 additions & 27 deletions test/e2e/app-dir/actions/app-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1529,7 +1529,7 @@ describe('app-dir action handling', () => {
const browser = await next.browser('/revalidate')
await browser.refresh()

const thankYouNext = await browser.elementByCss('#thankyounext').text()
const original = await browser.elementByCss('#thankyounext').text()

await browser.elementByCss('#another').click()
await retry(async () => {
Expand All @@ -1538,47 +1538,49 @@ describe('app-dir action handling', () => {
)
})

const newThankYouNext = await browser
.elementByCss('#thankyounext')
.text()

// Should be the same number although in serverless
// it might be eventually consistent
if (!isNextDeploy) {
expect(thankYouNext).toEqual(newThankYouNext)
await retry(async () => {
const another = await browser.elementByCss('#thankyounext').text()
expect(another).toEqual(original)
})
}

await browser.elementByCss('#back').click()

// Should be different
let revalidatedThankYouNext
await retry(async () => {
switch (type) {
case 'tag':
await browser.elementByCss('#revalidate-thankyounext').click()
break
case 'path':
await browser.elementByCss('#revalidate-path').click()
break
default:
throw new Error(`Invalid type: ${type}`)
}
expect(await browser.elementByCss('#title').text()).toBe('revalidate')
})

revalidatedThankYouNext = await browser
.elementByCss('#thankyounext')
.text()
switch (type) {
case 'tag':
await browser.elementByCss('#revalidate-thankyounext').click()
break
case 'path':
await browser.elementByCss('#revalidate-path').click()
break
default:
throw new Error(`Invalid type: ${type}`)
}

expect(thankYouNext).not.toBe(revalidatedThankYouNext)
// Should be different
let revalidated
await retry(async () => {
revalidated = await browser.elementByCss('#thankyounext').text()
expect(revalidated).not.toBe(original)
})

await browser.elementByCss('#another').click()
await retry(async () => {
expect(await browser.elementByCss('#title').text()).toBe(
'another route'
)
})

// The other page should be revalidated too
await retry(async () => {
const newThankYouNext = await browser
.elementByCss('#thankyounext')
.text()
expect(revalidatedThankYouNext).toBe(newThankYouNext)
const another = await browser.elementByCss('#thankyounext').text()
expect(another).toBe(revalidated)
})
}
)
Expand Down
2 changes: 1 addition & 1 deletion test/e2e/app-dir/actions/app/revalidate/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default async function Page() {

return (
<>
<p>/revalidate</p>
<h1 id="title">revalidate</h1>
<p>
{' '}
revalidate (tags: thankyounext): <span id="thankyounext">
Expand Down
Loading