Skip to content

Commit f0d70ed

Browse files
committed
offline navigations: support dynamic route patterns (9/10)
1 parent 00b5b5b commit f0d70ed

9 files changed

Lines changed: 230 additions & 8 deletions

File tree

packages/next/src/build/define-env.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -384,7 +384,9 @@ export function getDefineEnv({
384384
'process.env.__NEXT_GESTURE_TRANSITION':
385385
config.experimental.gestureTransition ?? false,
386386
'process.env.__NEXT_OPTIMISTIC_ROUTING':
387-
config.experimental.optimisticRouting ?? false,
387+
config.experimental.optimisticRouting ||
388+
config.experimental.offlineNavigations ||
389+
false,
388390
'process.env.__NEXT_VARY_PARAMS': config.experimental.varyParams ?? false,
389391
'process.env.__NEXT_EXPOSE_TESTING_API':
390392
dev || config.experimental.exposeTestingApiInProductionBuild === true,

packages/next/src/build/templates/app-page.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,8 @@ export async function handler(
890890
staleTimes: nextConfig.experimental.staleTimes,
891891
dynamicOnHover: Boolean(nextConfig.experimental.dynamicOnHover),
892892
optimisticRouting: Boolean(
893-
nextConfig.experimental.optimisticRouting
893+
nextConfig.experimental.optimisticRouting ||
894+
nextConfig.experimental.offlineNavigations
894895
),
895896
inlineCss: Boolean(nextConfig.experimental.inlineCss),
896897
prefetchInlining: nextConfig.experimental.prefetchInlining ?? false,

packages/next/src/build/templates/edge-ssr-app.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,10 @@ async function requestHandler(
165165
expireTime: nextConfig.expireTime,
166166
staleTimes: nextConfig.experimental.staleTimes,
167167
dynamicOnHover: Boolean(nextConfig.experimental.dynamicOnHover),
168-
optimisticRouting: Boolean(nextConfig.experimental.optimisticRouting),
168+
optimisticRouting: Boolean(
169+
nextConfig.experimental.optimisticRouting ||
170+
nextConfig.experimental.offlineNavigations
171+
),
169172
inlineCss: Boolean(nextConfig.experimental.inlineCss),
170173
prefetchInlining: nextConfig.experimental.prefetchInlining ?? false,
171174
authInterrupts: Boolean(nextConfig.experimental.authInterrupts),

packages/next/src/client/route-params.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
NEXT_REWRITTEN_QUERY_HEADER,
1111
NEXT_RSC_UNION_QUERY,
1212
} from './components/app-router-headers'
13+
import { hasBasePath } from './has-base-path'
14+
import { removeBasePath } from './remove-base-path'
1315
import type {
1416
NormalizedPathname,
1517
NormalizedSearch,
@@ -44,9 +46,14 @@ export function getRenderedPathname(
4446
// page will be different from the pathname in the request URL. In this case,
4547
// the response will include a header that gives the rewritten pathname.
4648
const rewrittenPath = response.headers.get(NEXT_REWRITTEN_PATH_HEADER)
47-
return (rewrittenPath ??
48-
urlToUrlWithoutFlightMarker(new URL(response.url))
49-
.pathname) as NormalizedPathname
49+
const pathname =
50+
rewrittenPath ?? urlToUrlWithoutFlightMarker(new URL(response.url)).pathname
51+
// `response.url` is browser-visible, so it may still include the configured
52+
// basePath. Route trees and dynamic param matching use app pathnames without
53+
// basePath, matching the server-side params contract.
54+
return (
55+
hasBasePath(pathname) ? removeBasePath(pathname) : pathname
56+
) as NormalizedPathname
5057
}
5158

5259
// Pathname parts come from `URL.pathname.split('/')`, so they are already

packages/next/src/export/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,10 @@ async function exportAppImpl(
510510
clientParamParsingOrigins:
511511
nextConfig.experimental.clientParamParsingOrigins,
512512
dynamicOnHover: nextConfig.experimental.dynamicOnHover ?? false,
513-
optimisticRouting: nextConfig.experimental.optimisticRouting ?? false,
513+
optimisticRouting:
514+
nextConfig.experimental.optimisticRouting ||
515+
nextConfig.experimental.offlineNavigations ||
516+
false,
514517
inlineCss: nextConfig.experimental.inlineCss ?? false,
515518
prefetchInlining: nextConfig.experimental.prefetchInlining ?? false,
516519
authInterrupts: !!nextConfig.experimental.authInterrupts,

packages/next/src/server/base-server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -578,7 +578,9 @@ export default abstract class Server<
578578
this.nextConfig.experimental.clientParamParsingOrigins,
579579
dynamicOnHover: this.nextConfig.experimental.dynamicOnHover ?? false,
580580
optimisticRouting:
581-
this.nextConfig.experimental.optimisticRouting ?? false,
581+
this.nextConfig.experimental.optimisticRouting ||
582+
this.nextConfig.experimental.offlineNavigations ||
583+
false,
582584
inlineCss: this.nextConfig.experimental.inlineCss ?? false,
583585
prefetchInlining:
584586
this.nextConfig.experimental.prefetchInlining ?? false,
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
'use client'
2+
3+
import { useParams } from 'next/navigation'
4+
5+
export function DynamicPrefetchValue() {
6+
const params = useParams<{ value: string }>()
7+
8+
return <p id="dynamic-prefetch-page">dynamic prefetch path: {params.value}</p>
9+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Suspense } from 'react'
2+
import { OfflineStatus } from '../../offline-status'
3+
import { DynamicPrefetchValue } from './dynamic-prefetch-value'
4+
5+
export function generateStaticParams() {
6+
return [{ value: '__TEST__' }]
7+
}
8+
9+
export default function DynamicPrefetchPage() {
10+
return (
11+
<>
12+
<Suspense fallback={<p>loading dynamic prefetch value</p>}>
13+
<DynamicPrefetchValue />
14+
</Suspense>
15+
<OfflineStatus />
16+
</>
17+
)
18+
}

test/production/app-dir/offline-navigations/offline-navigations.test.ts

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,58 @@ describe('offlineNavigations build artifacts', () => {
353353
})
354354
}
355355

356+
async function prefetchDynamicPatternReplayData(
357+
browser: Awaited<ReturnType<typeof next.browser>>
358+
) {
359+
await browser.elementById('prefetch-dynamic-pattern-source').click()
360+
await retry(async () => {
361+
const routeRecords =
362+
await readPersistedOfflineNavigationRouteRecords(browser)
363+
expect(
364+
routeRecords.some((record) =>
365+
record.route.pathname.includes('/dynamic-prefetch/learned')
366+
)
367+
).toBe(true)
368+
})
369+
370+
await browser.elementById('prefetch-dynamic-pattern-target').click()
371+
await retry(async () => {
372+
const routeRecords =
373+
await readPersistedOfflineNavigationRouteRecords(browser)
374+
expect(
375+
routeRecords.some((record) =>
376+
record.route.pathname.includes('/dynamic-prefetch/learned')
377+
)
378+
).toBe(true)
379+
expect(
380+
routeRecords.some((record) =>
381+
record.route.pathname.includes('/dynamic-prefetch/replayed')
382+
)
383+
).toBe(false)
384+
385+
const segmentRecords =
386+
await readPersistedOfflineNavigationSegmentRecords(browser)
387+
const replayedSegmentRecords = segmentRecords.filter((record) =>
388+
record.key.includes('replayed')
389+
)
390+
expect(
391+
segmentRecords.some(
392+
(record) => record.payload.requestKind === 'segment-prefetch'
393+
)
394+
).toBe(true)
395+
expect(
396+
replayedSegmentRecords.some((record) =>
397+
getPersistedSegmentRequestKey(record)?.endsWith('/__PAGE__')
398+
)
399+
).toBe(true)
400+
expect(
401+
replayedSegmentRecords.some((record) =>
402+
isPersistedHeadSegmentRecord(record)
403+
)
404+
).toBe(true)
405+
})
406+
}
407+
356408
async function cleanupOfflineNavigationState(
357409
browser: Awaited<ReturnType<typeof next.browser>>
358410
) {
@@ -1368,6 +1420,131 @@ describe('offlineNavigations build artifacts', () => {
13681420
}
13691421
})
13701422

1423+
it('replays a dynamic route from persisted known route patterns', async () => {
1424+
const buildResult = await next.build()
1425+
expect(buildResult.exitCode).toBe(0)
1426+
1427+
await next.start({ skipBuild: true })
1428+
1429+
let page: Playwright.Page | undefined
1430+
try {
1431+
const browser = await next.browser('/docs', {
1432+
beforePageLoad(p: Playwright.Page) {
1433+
page = p
1434+
},
1435+
})
1436+
await waitForOfflineNavigationServiceWorker(browser, page!)
1437+
1438+
await prefetchDynamicPatternReplayData(browser)
1439+
await retry(async () => {
1440+
const routeRecords =
1441+
await readPersistedOfflineNavigationRouteRecords(browser)
1442+
expect(
1443+
routeRecords.some((record) =>
1444+
record.route.pathname.includes('/dynamic-prefetch/replayed')
1445+
)
1446+
).toBe(false)
1447+
})
1448+
await retry(async () => {
1449+
const cacheState = await readOfflineNavigationCacheState(browser)
1450+
expect(cacheState.entries).toEqual(
1451+
expect.arrayContaining([
1452+
{
1453+
cacheName: expect.stringMatching(/^next-offline-navigation-v1:/),
1454+
pathname: expect.stringMatching(
1455+
/^\/app-assets\/_next\/static\/(?:immutable\/)?chunks\/.+\.js$/
1456+
),
1457+
},
1458+
])
1459+
)
1460+
})
1461+
1462+
const session = await (
1463+
page!.context() as Playwright.BrowserContext & {
1464+
newCDPSession: (page: Playwright.Page) => Promise<{
1465+
send: (method: string) => Promise<void>
1466+
detach: () => Promise<void>
1467+
}>
1468+
}
1469+
).newCDPSession(page!)
1470+
1471+
try {
1472+
await session.send('Network.clearBrowserCache')
1473+
} finally {
1474+
await session.detach()
1475+
}
1476+
1477+
await next.stop()
1478+
await page!.context().setOffline(true)
1479+
const dynamicReplayResponse = await page!.goto(
1480+
`${next.url}/docs/dynamic-prefetch/replayed#restored`,
1481+
{ waitUntil: 'domcontentloaded' }
1482+
)
1483+
expect(dynamicReplayResponse?.status()).toBe(200)
1484+
await retry(async () => {
1485+
expect(await browser.elementById('dynamic-prefetch-page').text()).toBe(
1486+
'dynamic prefetch path: replayed'
1487+
)
1488+
})
1489+
} finally {
1490+
if (page) {
1491+
await page.context().setOffline(false)
1492+
}
1493+
await next.stop()
1494+
}
1495+
})
1496+
1497+
it('misses dynamic route pattern replay when a required segment record is missing', async () => {
1498+
const buildResult = await next.build()
1499+
expect(buildResult.exitCode).toBe(0)
1500+
1501+
await next.start({ skipBuild: true })
1502+
1503+
let page: Playwright.Page | undefined
1504+
try {
1505+
const browser = await next.browser('/docs', {
1506+
beforePageLoad(p: Playwright.Page) {
1507+
page = p
1508+
},
1509+
})
1510+
await waitForOfflineNavigationServiceWorker(browser, page!)
1511+
1512+
await prefetchDynamicPatternReplayData(browser)
1513+
1514+
const deletedSegments =
1515+
await deletePersistedOfflineNavigationSegmentRecords(browser, {
1516+
keySubstring: 'replayed',
1517+
requestKeySuffix: '/__PAGE__',
1518+
})
1519+
expect(deletedSegments).toBeGreaterThan(0)
1520+
await retry(async () => {
1521+
const segmentRecords =
1522+
await readPersistedOfflineNavigationSegmentRecords(browser)
1523+
expect(
1524+
segmentRecords.some(
1525+
(record) =>
1526+
record.key.includes('replayed') &&
1527+
getPersistedSegmentRequestKey(record)?.endsWith('/__PAGE__')
1528+
)
1529+
).toBe(false)
1530+
})
1531+
1532+
await next.stop()
1533+
await page!.context().setOffline(true)
1534+
const missingSegmentResponse = await page!.goto(
1535+
`${next.url}/docs/dynamic-prefetch/replayed#missing-segment`,
1536+
{ waitUntil: 'domcontentloaded' }
1537+
)
1538+
expect(missingSegmentResponse?.status()).toBe(200)
1539+
await expectOfflineNavigationCacheMiss(browser, 'missing-segment')
1540+
} finally {
1541+
if (page) {
1542+
await page.context().setOffline(false)
1543+
}
1544+
await next.stop()
1545+
}
1546+
})
1547+
13711548
it('does not emit offline navigation artifacts when disabled', async () => {
13721549
await next.patchFile('next.config.js', (content) =>
13731550
content.replace('offlineNavigations: true', 'offlineNavigations: false')

0 commit comments

Comments
 (0)