Skip to content
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
22 changes: 14 additions & 8 deletions packages/next/src/client/app-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ nextServerDataLoadingGlobal.length = 0
// Patch its push method so subsequent chunks are handled (but not actually pushed to the array).
nextServerDataLoadingGlobal.push = nextServerDataCallback

const readable = new ReadableStream({
let readable: ReadableStream<Uint8Array> = new ReadableStream({
start(controller) {
nextServerDataRegisterWriter(controller)
},
Expand All @@ -175,6 +175,17 @@ if (process.env.NODE_ENV !== 'production') {
readable.name = 'hydration'
}

// When Cache Components is enabled, tee the inlined Flight stream so we can
// truncate a clone at the static stage byte boundary and cache it. We don't
// know if `l` is present until React decodes the payload, so always tee and
// cancel the clone if not needed.
let initialFlightStreamForCache: ReadableStream<Uint8Array> | null = null
if (process.env.__NEXT_CACHE_COMPONENTS) {
const [forReact, forCache] = readable.tee()
readable = forReact
initialFlightStreamForCache = forCache
}

let debugChannel:
| { readable?: ReadableStream; writable?: WritableStream }
| undefined
Expand Down Expand Up @@ -321,13 +332,8 @@ export async function hydrate(
const actionQueue: AppRouterActionQueue = createMutableActionQueue(
createInitialRouterState({
navigatedAt: initialTimestamp,
initialFlightData: initialRSCPayload.f,
initialCanonicalUrlParts: initialRSCPayload.c,
initialRenderedSearch: initialRSCPayload.q,
initialCouldBeIntercepted: initialRSCPayload.i,
initialPrerendered: initialRSCPayload.S,
initialStaleTime: initialRSCPayload.s,
initialHeadVaryParams: initialRSCPayload.h,
initialRSCPayload,
initialFlightStreamForCache,
location: window.location,
}),
instrumentationHooks
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { FlightDataPath } from '../../../shared/lib/app-router-types'
import type { VaryParamsThenable } from '../../../shared/lib/segment-cache/vary-params-decoding'
import type { InitialRSCPayload } from '../../../shared/lib/app-router-types'

import { createHrefFromUrl } from './create-href-from-url'
import { extractPathFromFlightRouterState } from './compute-changed-path'
Expand All @@ -12,32 +11,34 @@ import {
getStaleAt,
writeStaticStageResponseIntoCache,
} from '../segment-cache/cache'
import { decodeStaticStage } from './fetch-server-response'
import { discoverKnownRoute } from '../segment-cache/optimistic-routes'
import type { NormalizedSearch } from '../segment-cache/cache-key'

export interface InitialRouterStateParameters {
navigatedAt: number
initialCanonicalUrlParts: string[]
initialRenderedSearch: string
initialFlightData: FlightDataPath[]
initialCouldBeIntercepted: boolean
initialPrerendered: boolean
initialStaleTime: AsyncIterable<number> | undefined
initialHeadVaryParams: VaryParamsThenable | null
initialRSCPayload: InitialRSCPayload
initialFlightStreamForCache?: ReadableStream<Uint8Array> | null
location: Location | null
}

export function createInitialRouterState({
navigatedAt,
initialFlightData,
initialCanonicalUrlParts,
initialRenderedSearch,
initialCouldBeIntercepted,
initialPrerendered,
initialStaleTime,
initialHeadVaryParams,
initialRSCPayload,
initialFlightStreamForCache,
location,
}: InitialRouterStateParameters): AppRouterState {
const {
c: initialCanonicalUrlParts,
f: initialFlightData,
q: initialRenderedSearch,
i: initialCouldBeIntercepted,
S: initialPrerendered,
s: initialStaleTime,
l: initialStaticStageByteLength,
h: initialHeadVaryParams,
} = initialRSCPayload

// When initialized on the server, the canonical URL is provided as an array of parts.
// This is to ensure that when the RSC payload streamed to the client, crawlers don't interpret it
// as a URL that should be crawled.
Expand Down Expand Up @@ -98,26 +99,62 @@ export function createInitialRouterState({
// Write the initial seed data into the segment cache so subsequent
// navigations to the initial page can serve cached segments instantly.
if (initialSeedData !== null && initialStaleTime !== undefined) {
// Currently only fully static pages include initialStaleTime.
route.isFullyStatic = true

const now = Date.now()

getStaleAt(now, initialStaleTime)
.then((staleAt) => {
writeStaticStageResponseIntoCache(
now,
initialFlightData,
undefined, // buildId - used to check mismatch during navigation
initialHeadVaryParams,
staleAt,
route
)
})
.catch(() => {
// The static stage processing failed. Not fatal — the page
// rendered normally, we just won't write into the cache.
})
if (
initialStaticStageByteLength !== undefined &&
initialFlightStreamForCache != null
) {
// Partially static page — truncate the cloned Flight stream at the
// static stage byte boundary, decode, and cache the static subset.
decodeStaticStage<InitialRSCPayload>(
initialFlightStreamForCache,
initialStaticStageByteLength,
undefined
)
.then(async (staticStageResponse) => {
const now = Date.now()
const staleAt = await getStaleAt(now, staticStageResponse.s)

writeStaticStageResponseIntoCache(
now,
staticStageResponse.f,
undefined, // no build ID mismatch check for initial HTML
staticStageResponse.h,
staleAt,
route
)
})
.catch(() => {
// The static stage processing failed. Not fatal — the page
// rendered normally, we just won't write into the cache.
})
} else {
// Fully static page — cache the entire decoded seed data as-is.
route.isFullyStatic = true

const now = Date.now()

getStaleAt(now, initialStaleTime)
.then((staleAt) => {
writeStaticStageResponseIntoCache(
now,
initialFlightData,
undefined, // no build ID mismatch check for initial HTML
initialHeadVaryParams,
staleAt,
route
)
})
.catch(() => {
// The static stage processing failed. Not fatal — the page
// rendered normally, we just won't write into the cache.
})

// Cancel the stream clone — fully static path doesn't need it.
initialFlightStreamForCache?.cancel()
}
} else {
// No caching — cancel the unused stream clone.
initialFlightStreamForCache?.cancel()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { InvariantError } from '../../../shared/lib/invariant-error'
import type {
FlightRouterState,
InitialRSCPayload,
NavigationFlightResponse,
} from '../../../shared/lib/app-router-types'

Expand Down Expand Up @@ -65,8 +66,12 @@ export interface FetchServerResponseOptions {
readonly isHmrRefresh?: boolean
}

export type StaticStageData = {
readonly response: NavigationFlightResponse
export type StaticStageData<
T extends
| NavigationFlightResponse
| InitialRSCPayload = NavigationFlightResponse,
> = {
readonly response: T
readonly isFullyStatic: boolean
}

Expand Down Expand Up @@ -385,11 +390,13 @@ export async function processFetch(response: Response): Promise<{
* byte boundary and decode.
* - Otherwise: no cache-worthy data.
*/
async function resolveStaticStageData(
export async function resolveStaticStageData<
T extends NavigationFlightResponse | InitialRSCPayload,
>(
cacheData: FetchResponseCacheData,
flightResponse: NavigationFlightResponse,
headers: RequestHeaders
): Promise<StaticStageData | null> {
flightResponse: T,
headers: RequestHeaders | undefined
): Promise<StaticStageData<T> | null> {
const { completenessMarker, responseBodyClone } = cacheData
const isFullyStatic = completenessMarker === ResponseCompletenessMarker.Static

Expand All @@ -401,21 +408,14 @@ async function resolveStaticStageData(
}

if (flightResponse.l !== undefined) {
// Partially static — truncate the body clone at the byte boundary.
const staticStageByteLength = await flightResponse.l

const truncatedStream = truncateStream(
// Partially static - truncate the body clone at the byte boundary and
// decode it.
const response = await decodeStaticStage<T>(
responseBodyClone,
staticStageByteLength
flightResponse.l,
headers
)

const response =
await createFromNextReadableStream<NavigationFlightResponse>(
truncatedStream,
headers,
{ allowPartialStream: true }
)

return { response, isFullyStatic }
}

Expand All @@ -425,6 +425,28 @@ async function resolveStaticStageData(
return null
}

/**
* Truncates a Flight stream clone at the given byte boundary and decodes the
* static stage prefix. Used by both the navigation path and the initial HTML
* hydration path.
*/
export async function decodeStaticStage<T>(
responseBodyClone: ReadableStream<Uint8Array>,
staticStageByteLengthPromise: Promise<number>,
headers: RequestHeaders | undefined
): Promise<T> {
const staticStageByteLength = await staticStageByteLengthPromise

const truncatedStream = truncateStream(
responseBodyClone,
staticStageByteLength
)

return createFromNextReadableStream<T>(truncatedStream, headers, {
allowPartialStream: true,
})
}

export async function createFetch<T>(
url: URL,
headers: RequestHeaders,
Expand Down Expand Up @@ -586,7 +608,7 @@ export async function createFetch<T>(

export function createFromNextReadableStream<T>(
flightStream: ReadableStream<Uint8Array>,
requestHeaders: RequestHeaders,
requestHeaders: RequestHeaders | undefined,
options?: { allowPartialStream?: boolean }
): Promise<T> {
return createFromReadableStream(flightStream, {
Expand Down
Loading
Loading