Skip to content

Commit 65e9655

Browse files
committed
offline navigations: support dynamic route patterns (9/10)
1 parent ea85d06 commit 65e9655

9 files changed

Lines changed: 227 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: 7 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,11 @@ 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+
return (
52+
hasBasePath(pathname) ? removeBasePath(pathname) : pathname
53+
) as NormalizedPathname
5054
}
5155

5256
// 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
@@ -331,6 +331,58 @@ describe('offlineNavigations build artifacts', () => {
331331
})
332332
}
333333

334+
async function prefetchDynamicPatternReplayData(
335+
browser: Awaited<ReturnType<typeof next.browser>>
336+
) {
337+
await browser.elementById('prefetch-dynamic-pattern-source').click()
338+
await retry(async () => {
339+
const routeRecords =
340+
await readPersistedOfflineNavigationRouteRecords(browser)
341+
expect(
342+
routeRecords.some((record) =>
343+
record.route.pathname.includes('/dynamic-prefetch/learned')
344+
)
345+
).toBe(true)
346+
})
347+
348+
await browser.elementById('prefetch-dynamic-pattern-target').click()
349+
await retry(async () => {
350+
const routeRecords =
351+
await readPersistedOfflineNavigationRouteRecords(browser)
352+
expect(
353+
routeRecords.some((record) =>
354+
record.route.pathname.includes('/dynamic-prefetch/learned')
355+
)
356+
).toBe(true)
357+
expect(
358+
routeRecords.some((record) =>
359+
record.route.pathname.includes('/dynamic-prefetch/replayed')
360+
)
361+
).toBe(false)
362+
363+
const segmentRecords =
364+
await readPersistedOfflineNavigationSegmentRecords(browser)
365+
const replayedSegmentRecords = segmentRecords.filter((record) =>
366+
record.key.includes('replayed')
367+
)
368+
expect(
369+
segmentRecords.some(
370+
(record) => record.payload.requestKind === 'segment-prefetch'
371+
)
372+
).toBe(true)
373+
expect(
374+
replayedSegmentRecords.some((record) =>
375+
record.segment.requestKey.endsWith('/__PAGE__')
376+
)
377+
).toBe(true)
378+
expect(
379+
replayedSegmentRecords.some(
380+
(record) => record.segment.requestKey === '/_head'
381+
)
382+
).toBe(true)
383+
})
384+
}
385+
334386
async function cleanupOfflineNavigationState(
335387
browser: Awaited<ReturnType<typeof next.browser>>
336388
) {
@@ -1357,6 +1409,131 @@ describe('offlineNavigations build artifacts', () => {
13571409
}
13581410
})
13591411

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

0 commit comments

Comments
 (0)