Skip to content

Commit be4811d

Browse files
committed
offline navigations: support dynamic route patterns (9/10)
1 parent 9b75037 commit be4811d

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
) {
@@ -1374,6 +1426,131 @@ describe('offlineNavigations build artifacts', () => {
13741426
}
13751427
})
13761428

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

0 commit comments

Comments
 (0)