Skip to content

Commit

Permalink
re-use auto prefetch cache entries for different searchParams
Browse files Browse the repository at this point in the history
  • Loading branch information
ztanner committed Jul 31, 2024
1 parent 916306e commit 5d60dcb
Show file tree
Hide file tree
Showing 13 changed files with 316 additions and 25 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createHrefFromUrl } from './create-href-from-url'
import { fillLazyItemsTillLeafWithHead } from './fill-lazy-items-till-leaf-with-head'
import { extractPathFromFlightRouterState } from './compute-changed-path'
import { createPrefetchCacheEntryForInitialLoad } from './prefetch-cache-utils'
import { PrefetchKind, type PrefetchCacheEntry } from './router-reducer-types'
import type { PrefetchCacheEntry } from './router-reducer-types'
import { addRefreshMarkerToActiveParallelSegments } from './refetch-inactive-parallel-segments'

export interface InitialRouterStateParameters {
Expand Down Expand Up @@ -101,7 +101,6 @@ export function createInitialRouterState({

createPrefetchCacheEntryForInitialLoad({
url,
kind: PrefetchKind.AUTO,
data: {
f: initialFlightData,
c: undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { createHrefFromUrl } from './create-href-from-url'
import { fetchServerResponse } from './fetch-server-response'
import {
PrefetchCacheEntryStatus,
Expand All @@ -16,12 +15,27 @@ import type { FetchServerResponseResult } from '../../../server/app-render/types
* @param nextUrl - an internal URL, primarily used for handling rewrites. Defaults to '/'.
* @return The generated prefetch cache key.
*/
function createPrefetchCacheKey(url: URL, nextUrl?: string | null) {
const pathnameFromUrl = createHrefFromUrl(
url,
// Ensures the hash is not part of the cache key as it does not impact the server fetch
false
)
function createPrefetchCacheKey(
url: URL,
prefetchKind: PrefetchKind = PrefetchKind.TEMPORARY,
nextUrl?: string | null
) {
// Initially we only use the pathname as the cache key. We don't want to include
// search params so that multiple URLs with the same search parameter can re-use
// loading states.
let pathnameFromUrl = url.pathname

// RSC responses can differ based on search params, specifically in the case where we aren't
// returning a partial response (ie with `PrefetchKind.AUTO`).
// In the auto case, since loading.js & layout.js won't have access to search params,
// we can safely re-use that cache entry. But for full prefetches, we should not
// re-use the cache entry as the response may differ.
if (prefetchKind === PrefetchKind.FULL) {
// if we have a full prefetch, we can include the search param in the key,
// as we'll be getting back a full response. The server might have read the search
// params when generating the full response.
pathnameFromUrl += url.search
}

// nextUrl is used as a cache key delimiter since entries can vary based on the Next-URL header
if (nextUrl) {
Expand Down Expand Up @@ -50,20 +64,31 @@ export function getOrCreatePrefetchCacheEntry({
kind?: PrefetchKind
}): PrefetchCacheEntry {
let existingCacheEntry: PrefetchCacheEntry | undefined = undefined
const interceptionCacheKey = createPrefetchCacheKey(url, kind, nextUrl)
const interceptionData = prefetchCache.get(interceptionCacheKey)
const fullDataCacheKey = createPrefetchCacheKey(url, PrefetchKind.FULL)

// We first check if there's a more specific interception route prefetch entry
// This is because when we detect a prefetch that corresponds with an interception route, we prefix it with nextUrl (see `createPrefetchCacheKey`)
// to avoid conflicts with other pages that may have the same URL but render different things depending on the `Next-URL` header.
const interceptionCacheKey = createPrefetchCacheKey(url, nextUrl)
const interceptionData = prefetchCache.get(interceptionCacheKey)

if (interceptionData) {
existingCacheEntry = interceptionData
}
// Next we check to see if search params are present in the URL: if they are, we want to see if the existing prefetch entry
// is "full" and if so, we use that entry, because those are keyed by the full URL (including search params). This lets
// us re-use the loading state from an "auto" prefetch (if it exists) even for routes that have different search params.
else if (url.search && prefetchCache.has(fullDataCacheKey)) {
console.log('using', { fullDataCacheKey })
existingCacheEntry = prefetchCache.get(fullDataCacheKey)
} else {
// If we dont find a more specific interception route prefetch entry, we check for a regular prefetch entry
const prefetchCacheKey = createPrefetchCacheKey(url)
// Otherwise, we check for a regular prefetch entry. These will be keyed by the pathname only.
const prefetchCacheKey = createPrefetchCacheKey(url, kind)
const prefetchData = prefetchCache.get(prefetchCacheKey)
if (prefetchData) {
console.log('using regular', { prefetchCacheKey, kind })
existingCacheEntry = prefetchData
} else {
console.log('not found', { prefetchCacheKey, kind })
}
}

Expand Down Expand Up @@ -130,7 +155,11 @@ function prefixExistingPrefetchCacheEntry({
return
}

const newCacheKey = createPrefetchCacheKey(url, nextUrl)
const newCacheKey = createPrefetchCacheKey(
url,
existingCacheEntry.kind,
nextUrl
)
prefetchCache.set(newCacheKey, existingCacheEntry)
prefetchCache.delete(existingCacheKey)

Expand All @@ -145,27 +174,31 @@ export function createPrefetchCacheEntryForInitialLoad({
tree,
prefetchCache,
url,
kind,
data,
}: Pick<ReadonlyReducerState, 'nextUrl' | 'tree' | 'prefetchCache'> & {
url: URL
kind: PrefetchKind
data: FetchServerResponseResult
}) {
// The initial cache entry technically includes full data, but it isn't explicitly prefetched -- we just seed the
// prefetch cache so that we can skip an extra prefetch request later, since we already have the data.
// We use the `full` kind here with a `null` prefetchTime to signal that this entry has the full RSC data,
// but we should only re-use the `loading` portion for up to the `static` staleTime, because keeping the full data
// would be unexpected without explicitly opting into a full prefetch.
const kind = PrefetchKind.FULL
// if the prefetch corresponds with an interception route, we use the nextUrl to prefix the cache key
const prefetchCacheKey = data.i
? createPrefetchCacheKey(url, nextUrl)
: createPrefetchCacheKey(url)
? createPrefetchCacheKey(url, kind, nextUrl)
: createPrefetchCacheKey(url, kind)

const prefetchEntry = {
treeAtTimeOfPrefetch: tree,
data: Promise.resolve(data),
kind,
prefetchTime: Date.now(),
prefetchTime: null,
lastUsedTime: Date.now(),
key: prefetchCacheKey,
status: PrefetchCacheEntryStatus.fresh,
}
} satisfies PrefetchCacheEntry

prefetchCache.set(prefetchCacheKey, prefetchEntry)

Expand All @@ -189,7 +222,7 @@ function createLazyPrefetchEntry({
url: URL
kind: PrefetchKind
}): PrefetchCacheEntry {
const prefetchCacheKey = createPrefetchCacheKey(url)
const prefetchCacheKey = createPrefetchCacheKey(url, kind)

// initiates the fetch request for the prefetch and attaches a listener
// to the promise to update the prefetch cache entry when the promise resolves (if necessary)
Expand Down Expand Up @@ -272,6 +305,24 @@ function getPrefetchEntryCacheStatus({
prefetchTime,
lastUsedTime,
}: PrefetchCacheEntry): PrefetchCacheEntryStatus {
// TODO: Need to think of a better way to do this.
// Currently the only case of a valid null prefetchTime is when we seed
// the prefetch cache on initial load.
if (prefetchTime === null) {
// We only want to keep around the `loading` portion of these entries as they weren't
// explicit prefetches, and we don't know if they're static.
if (
kind === PrefetchKind.FULL &&
lastUsedTime &&
Date.now() < lastUsedTime + STATIC_STALETIME_MS
) {
return PrefetchCacheEntryStatus.stale
}

// This is a typeguard -- any other case of `prefetchTime` being null is invalid.
return PrefetchCacheEntryStatus.expired
}

// We will re-use the cache entry data for up to the `dynamic` staletime window.
if (Date.now() < (lastUsedTime ?? prefetchTime) + DYNAMIC_STALETIME_MS) {
return lastUsedTime
Expand All @@ -282,14 +333,14 @@ function getPrefetchEntryCacheStatus({
// For "auto" prefetching, we'll re-use only the loading boundary for up to `static` staletime window.
// A stale entry will only re-use the `loading` boundary, not the full data.
// This will trigger a "lazy fetch" for the full data.
if (kind === 'auto') {
if (kind === PrefetchKind.AUTO) {
if (Date.now() < prefetchTime + STATIC_STALETIME_MS) {
return PrefetchCacheEntryStatus.stale
}
}

// for "full" prefetching, we'll re-use the cache entry data for up to `static` staletime window.
if (kind === 'full') {
if (kind === PrefetchKind.FULL) {
if (Date.now() < prefetchTime + STATIC_STALETIME_MS) {
return PrefetchCacheEntryStatus.reusable
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ export type PrefetchCacheEntry = {
treeAtTimeOfPrefetch: FlightRouterState
data: Promise<FetchServerResponseResult>
kind: PrefetchKind
prefetchTime: number
prefetchTime: number | null
lastUsedTime: number | null
key: string
status: PrefetchCacheEntryStatus
Expand Down
10 changes: 10 additions & 0 deletions test/e2e/app-dir/searchparams-reuse-loading/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { ReactNode, Suspense } from 'react'
export default function Root({ children }: { children: ReactNode }) {
return (
<html>
<body>
<Suspense>{children}</Suspense>
</body>
</html>
)
}
24 changes: 24 additions & 0 deletions test/e2e/app-dir/searchparams-reuse-loading/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Link from 'next/link'

export default function Page() {
return (
<>
<Link href="/search">Go to search</Link>
<hr />

<ul>
<li>
<Link href="/search-params?id=1">/search-params?id=1</Link>
</li>
<li>
<Link href="/search-params?id=2">/search-params?id=2</Link>
</li>
<li>
<Link href="/search-params?id=3" prefetch>
/search-params?id=3 (prefetch: true)
</Link>
</li>
</ul>
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Loading() {
return <h1 id="loading">Loading...</h1>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Link from 'next/link'

export default async function Page({
searchParams,
}: {
searchParams: Record<string, string>
}) {
// sleep for 500ms
await new Promise((resolve) => setTimeout(resolve, 500))
return (
<>
<h1 id="params">{JSON.stringify(searchParams)}</h1>
<Link href="/">Back</Link>
</>
)
}
13 changes: 13 additions & 0 deletions test/e2e/app-dir/searchparams-reuse-loading/app/search/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

import { Fragment } from 'react'
import { useSearchParams } from 'next/navigation'

export default function SearchLayout({
children,
}: {
children: React.ReactNode
}) {
let searchParams = useSearchParams()
return <Fragment key={searchParams.get('q')}>{children}</Fragment>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function Loading() {
return <p id="loading">Loading...</p>
}
12 changes: 12 additions & 0 deletions test/e2e/app-dir/searchparams-reuse-loading/app/search/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Search } from './search'

export default async function Page({ searchParams }: { searchParams: any }) {
await new Promise((resolve) => setTimeout(resolve, 3000))

return (
<main id="page-content">
<Search />
<p id="search-value">Search Value: {searchParams.q ?? 'None'}</p>
</main>
)
}
22 changes: 22 additions & 0 deletions test/e2e/app-dir/searchparams-reuse-loading/app/search/search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client'

import { useRouter } from 'next/navigation'

export function Search() {
let router = useRouter()

function search(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()

let input = event.currentTarget.q.value
let params = new URLSearchParams([['q', input]])
router.push(`/search?${params}`)
}

return (
<form onSubmit={search}>
<input placeholder="Search..." name="q" className="border" />
<button>Submit</button>
</form>
)
}
6 changes: 6 additions & 0 deletions test/e2e/app-dir/searchparams-reuse-loading/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* @type {import('next').NextConfig}
*/
const nextConfig = {}

module.exports = nextConfig
Loading

0 comments on commit 5d60dcb

Please sign in to comment.