@wular/pnext 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/reference/navigation.md +1 -1
- package/src/client/router/runtime.ts +92 -40
package/package.json
CHANGED
package/reference/navigation.md
CHANGED
|
@@ -49,7 +49,7 @@ Requests use low network priority. At most four run at once, though the hover-in
|
|
|
49
49
|
|
|
50
50
|
## Soft navigation
|
|
51
51
|
|
|
52
|
-
Link clicks and router pushes swap the page in place instead of reloading the document, so shared chunks and CSS are never re-downloaded. A new page starts at the top, and back and forward stay soft and restore scroll.
|
|
52
|
+
Link clicks and router pushes swap the page in place instead of reloading the document, so shared chunks and CSS are never re-downloaded. A new page starts at the top, and back and forward stay soft and restore scroll. A dynamic page the router kept from a prefetch or a visit is reused as-is for 5 seconds. After that, the link paints the page's layout and loading fallbacks at once and streams fresh data in, so old data never shows; the frame stays reusable for five minutes. Back and forward restore the page as it was shown. Cross-origin targets, non-HTML responses, and fetch failures fall back to a full page load.
|
|
53
53
|
|
|
54
54
|
## Redirects and not found
|
|
55
55
|
|
|
@@ -129,8 +129,10 @@ export function isRewriteDocument(html: string, pathname: string): boolean {
|
|
|
129
129
|
return !routePatternMatchesPathname(state.route, pathname, state.catchAllOptional)
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
export function routeParamBoundaryChanged(
|
|
133
|
-
|
|
132
|
+
export function routeParamBoundaryChanged(
|
|
133
|
+
doc: Document,
|
|
134
|
+
current = documentRouteState(document),
|
|
135
|
+
): boolean {
|
|
134
136
|
const incoming = documentRouteState(doc)
|
|
135
137
|
if (!current?.route || !incoming?.route) return false
|
|
136
138
|
if (current.route !== incoming.route) return true
|
|
@@ -2616,6 +2618,8 @@ export interface PrefetchEntry {
|
|
|
2616
2618
|
/** Slot-only key of the nav state the entry was rendered against. */
|
|
2617
2619
|
slotsKey: string
|
|
2618
2620
|
onInvalidate: (() => void)[]
|
|
2621
|
+
/** A core prefetch's streamed shell, painted by a click that attaches before the body lands. */
|
|
2622
|
+
shell?: { html?: string; listeners: Set<(html: string) => void> }
|
|
2619
2623
|
}
|
|
2620
2624
|
|
|
2621
2625
|
const prefetchCache = new Map<string, PrefetchEntry>()
|
|
@@ -3045,10 +3049,20 @@ export function prefetchRoute(
|
|
|
3045
3049
|
options.element?.dispatchEvent(new CustomEvent('pnext:prefetchstart'))
|
|
3046
3050
|
const startEpoch = routerCacheEpoch()
|
|
3047
3051
|
const task = createPrefetchTask(cacheKey, options)
|
|
3052
|
+
// A core prefetch is the destination document itself, so its shell can paint an attached click.
|
|
3053
|
+
const shell: PrefetchEntry['shell'] =
|
|
3054
|
+
typeof __PNEXT_NEXT_ROUTER__ === 'undefined' ? { listeners: new Set() } : undefined
|
|
3048
3055
|
const page = fetchPage(url.href, {
|
|
3049
3056
|
prefetch: full ? 'full' : 'auto',
|
|
3050
3057
|
navState: prefetchState,
|
|
3051
3058
|
task,
|
|
3059
|
+
onShell:
|
|
3060
|
+
typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && !full
|
|
3061
|
+
? html => {
|
|
3062
|
+
shell!.html = html
|
|
3063
|
+
shell!.listeners.forEach(listener => listener(html))
|
|
3064
|
+
}
|
|
3065
|
+
: undefined,
|
|
3052
3066
|
// Only when the shell is actually cached: the server strips it from the
|
|
3053
3067
|
// response, so without a local copy there would be nothing to merge into.
|
|
3054
3068
|
resumeFromShell: options.hoverResume === true && takeShellForUrl(url) !== null,
|
|
@@ -3137,6 +3151,7 @@ export function prefetchRoute(
|
|
|
3137
3151
|
slotsKey: slotsStateKey(prefetchState.slots ?? {}),
|
|
3138
3152
|
settled: false,
|
|
3139
3153
|
onInvalidate: options.onInvalidate ? [options.onInvalidate] : [],
|
|
3154
|
+
shell,
|
|
3140
3155
|
})
|
|
3141
3156
|
void page.then(fetched => {
|
|
3142
3157
|
const entry = prefetchCache.get(cacheKey)
|
|
@@ -3291,6 +3306,7 @@ function streamHasPendingHole(buffer: string) {
|
|
|
3291
3306
|
export async function readStreamedBody(
|
|
3292
3307
|
response: Response,
|
|
3293
3308
|
onShell: (shellHtml: string) => void,
|
|
3309
|
+
ready?: (shellHtml: string) => boolean,
|
|
3294
3310
|
): Promise<string> {
|
|
3295
3311
|
const reader = response.body?.getReader()
|
|
3296
3312
|
if (!reader) return response.text()
|
|
@@ -3311,7 +3327,7 @@ export async function readStreamedBody(
|
|
|
3311
3327
|
// the caller swap the complete, materialized document.
|
|
3312
3328
|
!/<\/html>\s*$/.test(shellBuffer)
|
|
3313
3329
|
) {
|
|
3314
|
-
if (streamHasPendingHole(shellBuffer)) {
|
|
3330
|
+
if (streamHasPendingHole(shellBuffer) && (!ready || ready(shellBuffer))) {
|
|
3315
3331
|
shellDelivered = true
|
|
3316
3332
|
// Expose only holes still pending at this commit. Continuations already present in the
|
|
3317
3333
|
// read are included so showLoadingShell can materialize them before choosing a fallback.
|
|
@@ -3438,7 +3454,7 @@ async function fetchPage(
|
|
|
3438
3454
|
// Only the streamed-navigation read path (readStreamedBody) can consume a
|
|
3439
3455
|
// late metadata tail written after `</html>`, so only it asks for one: the
|
|
3440
3456
|
// server keeps blocking on `generateMetadata` for every other consumer.
|
|
3441
|
-
if (options.onShell) headers[LATE_METADATA_HEADER] = '1'
|
|
3457
|
+
if (options.onShell && !options.prefetch) headers[LATE_METADATA_HEADER] = '1'
|
|
3442
3458
|
if (options.fullRender) headers['x-pnext-full-render'] = '1'
|
|
3443
3459
|
if (options.resumeFromShell) headers['x-pnext-resume-shell'] = '1'
|
|
3444
3460
|
|
|
@@ -3707,7 +3723,16 @@ async function fetchPage(
|
|
|
3707
3723
|
segmentPayload && shellOnly && !response.headers.has(SEGMENT_ROUTE_HEADER)
|
|
3708
3724
|
const responseBody =
|
|
3709
3725
|
strippedBody ??
|
|
3710
|
-
(options.onShell
|
|
3726
|
+
(options.onShell
|
|
3727
|
+
? await readStreamedBody(
|
|
3728
|
+
response,
|
|
3729
|
+
options.onShell,
|
|
3730
|
+
// A prefetch read can end mid-shell; its shell counts once the document's tail scripts land.
|
|
3731
|
+
typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && options.prefetch
|
|
3732
|
+
? shell => shell.includes('id="__PNEXT_NAV_STATE__"') && /<\/script>\s*$/.test(shell)
|
|
3733
|
+
: undefined,
|
|
3734
|
+
)
|
|
3735
|
+
: await response.text())
|
|
3711
3736
|
let html = segmentPayload ? segmentDocumentHtml(responseBody, bodySegment) : responseBody
|
|
3712
3737
|
if (html === null) return null
|
|
3713
3738
|
// A LAYOUT-only prefetch carries no page: compose this URL's cached page frame
|
|
@@ -4508,14 +4533,41 @@ function unsettledPrefetchDeadline(): Promise<null> {
|
|
|
4508
4533
|
return new Promise(resolve => setTimeout(() => resolve(null), UNSETTLED_PREFETCH_WAIT_MS))
|
|
4509
4534
|
}
|
|
4510
4535
|
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4536
|
+
/**
|
|
4537
|
+
* The in-flight prefetch's document, or `null` once it has waited long enough. A streamed shell
|
|
4538
|
+
* that paints this navigation first commits it to that prefetch, so it waits for the body.
|
|
4539
|
+
*/
|
|
4540
|
+
function attachToPrefetch(
|
|
4541
|
+
entry: PrefetchEntry,
|
|
4542
|
+
onShell?: (shellHtml: string) => boolean,
|
|
4543
|
+
): Promise<PrefetchedPage | null> {
|
|
4544
|
+
return new Promise(resolve => {
|
|
4545
|
+
const shell = entry.shell
|
|
4546
|
+
const done = (page: PrefetchedPage | Promise<PrefetchedPage | null> | null) => {
|
|
4547
|
+
clearTimeout(timer)
|
|
4548
|
+
shell?.listeners.delete(paint)
|
|
4549
|
+
resolve(page)
|
|
4550
|
+
}
|
|
4551
|
+
const paint = (html: string) => onShell?.(html) && done(entry.page)
|
|
4552
|
+
const timer = setTimeout(done, UNSETTLED_PREFETCH_WAIT_MS, null)
|
|
4553
|
+
void entry.page.then(done)
|
|
4554
|
+
if (shell?.html) paint(shell.html)
|
|
4555
|
+
else shell?.listeners.add(paint)
|
|
4556
|
+
})
|
|
4557
|
+
}
|
|
4558
|
+
|
|
4559
|
+
// Core reuses a kept copy of a dynamic page this long; past it only the copy's frame paints.
|
|
4560
|
+
const KEPT_DATA_MS = 5_000
|
|
4561
|
+
|
|
4562
|
+
function keptDataFresh(entry: PrefetchEntry, now: number): boolean {
|
|
4563
|
+
return !entry.settled || now - (entry.settledTime ?? entry.time) < KEPT_DATA_MS
|
|
4564
|
+
}
|
|
4514
4565
|
|
|
4515
4566
|
async function pageForNavigation(
|
|
4516
4567
|
url: URL,
|
|
4517
4568
|
options: SoftNavigateOptions = {},
|
|
4518
|
-
|
|
4569
|
+
/** Paints a streamed shell; true when it committed the navigation. */
|
|
4570
|
+
onShell?: (shellHtml: string) => boolean,
|
|
4519
4571
|
/**
|
|
4520
4572
|
* Paint a cached STATIC STAGE into the live page before the dynamic-stage request goes
|
|
4521
4573
|
* out. Unlike `onShell` this markup is not a loading fallback but the route's real static
|
|
@@ -4552,11 +4604,17 @@ async function pageForNavigation(
|
|
|
4552
4604
|
pageFrame.eligible = false
|
|
4553
4605
|
onStaticStage?.(html, postponedShell)
|
|
4554
4606
|
}
|
|
4555
|
-
const
|
|
4607
|
+
const usable = prefetchEntriesForNavigation(key).filter(
|
|
4556
4608
|
entry =>
|
|
4557
4609
|
entryMatchesNavState(entry, departureNavState) &&
|
|
4558
4610
|
(!entry.settled || now - entry.time < entry.staleTimeMs),
|
|
4559
4611
|
)
|
|
4612
|
+
// Core prefers a kept copy whose data is still fresh.
|
|
4613
|
+
const cached =
|
|
4614
|
+
(typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
|
|
4615
|
+
!prefetchStaleTimePolicy &&
|
|
4616
|
+
usable.find(entry => keptDataFresh(entry, now))) ||
|
|
4617
|
+
usable[0]
|
|
4560
4618
|
// Popstate restores a history entry's own parallel-route state; a prefetched response was
|
|
4561
4619
|
// rendered against the pre-navigation state and may not match, so back/forward always
|
|
4562
4620
|
// fetches. A refresh bypasses the cache entirely.
|
|
@@ -4577,18 +4635,21 @@ async function pageForNavigation(
|
|
|
4577
4635
|
const page =
|
|
4578
4636
|
cached.settled || cached.full
|
|
4579
4637
|
? await cached.page
|
|
4580
|
-
:
|
|
4638
|
+
: typeof __PNEXT_NEXT_ROUTER__ === 'undefined'
|
|
4639
|
+
? await attachToPrefetch(cached, onShell)
|
|
4640
|
+
: await Promise.race([cached.page, unsettledPrefetchDeadline()])
|
|
4581
4641
|
if (page && !page.shellOnly) {
|
|
4582
4642
|
if (
|
|
4583
4643
|
typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
|
|
4584
4644
|
!prefetchStaleTimePolicy &&
|
|
4585
|
-
|
|
4645
|
+
!keptDataFresh(cached, now) &&
|
|
4586
4646
|
!documentStaticHintFromHtml(page.html)?.isStatic
|
|
4587
4647
|
) {
|
|
4588
|
-
|
|
4589
|
-
|
|
4590
|
-
|
|
4591
|
-
|
|
4648
|
+
// Next's way past the data window: paint the frame (loading fallbacks included), fetch the data.
|
|
4649
|
+
const frame = sliceShell(page.html)
|
|
4650
|
+
// The response shell is this frame's twin; painting it again would swap the same layout.
|
|
4651
|
+
if (frame !== null && onShell?.(frame)) onShell = () => false
|
|
4652
|
+
} else return page
|
|
4592
4653
|
}
|
|
4593
4654
|
// Attached to an in-flight (or already settled) SHELL prefetch for this exact target:
|
|
4594
4655
|
// the navigation issued no duplicate fetch, so paint the static stage it landed and let
|
|
@@ -5151,17 +5212,23 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5151
5212
|
// server-side, and re-painting would replace a committed loading state with a second,
|
|
5152
5213
|
// different one. A segment's loading fallback commits ONCE per navigation.
|
|
5153
5214
|
let cachedStagePainted = false
|
|
5215
|
+
// The route whose islands a stage paint mounted; the commit keeps them instead of remounting.
|
|
5216
|
+
let paintedRoute: BrowserRouteState | undefined
|
|
5217
|
+
// A painted loading boundary or stage is a committed navigation state.
|
|
5218
|
+
const commitPaint = () => {
|
|
5219
|
+
paintedRoute = window.__PNEXT_ROUTE__
|
|
5220
|
+
pushOptimisticUrl()
|
|
5221
|
+
scheduleNavigationScroll(url, options)
|
|
5222
|
+
}
|
|
5154
5223
|
const devSoftNavigation = nextDevDocument()
|
|
5155
5224
|
const onShell =
|
|
5156
5225
|
restorePage || options.pop || refreshLike
|
|
5157
5226
|
? undefined
|
|
5158
5227
|
: (shellHtml: string) => {
|
|
5159
|
-
if (cachedStagePainted) return
|
|
5160
|
-
if (
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
pushOptimisticUrl()
|
|
5164
|
-
scheduleNavigationScroll(url, options)
|
|
5228
|
+
if (cachedStagePainted || devSoftNavigation) return false
|
|
5229
|
+
if (!showLoadingShell(shellHtml, sequence, url, undefined, false, true)) return false
|
|
5230
|
+
commitPaint()
|
|
5231
|
+
return true
|
|
5165
5232
|
}
|
|
5166
5233
|
// The per-segment cache's answer for this navigation, looked up ONCE: it decides both
|
|
5167
5234
|
// whether the generic loading shell should paint (a real cached static segment is strictly
|
|
@@ -5181,8 +5248,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5181
5248
|
if (devSoftNavigation) return
|
|
5182
5249
|
if (!commitStaticStage(html, sequence, url, postponedShell)) return
|
|
5183
5250
|
cachedStagePainted = true
|
|
5184
|
-
|
|
5185
|
-
scheduleNavigationScroll(url, options)
|
|
5251
|
+
commitPaint()
|
|
5186
5252
|
}
|
|
5187
5253
|
: undefined
|
|
5188
5254
|
// Loading-shell reuse: when the navigation must fetch, paint the CACHED shell for the
|
|
@@ -5215,8 +5281,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5215
5281
|
: false
|
|
5216
5282
|
if (shellPainted) {
|
|
5217
5283
|
cachedStagePainted = true
|
|
5218
|
-
|
|
5219
|
-
scheduleNavigationScroll(url, options)
|
|
5284
|
+
commitPaint()
|
|
5220
5285
|
}
|
|
5221
5286
|
}
|
|
5222
5287
|
// Which navigations may fetch the PAGE frame alone. Never on popstate (a history entry
|
|
@@ -5389,7 +5454,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5389
5454
|
// would compare the destination with itself and hide every param transition.
|
|
5390
5455
|
const parallelSlotsChanged = navSlotsChanged(doc)
|
|
5391
5456
|
const remountPageIslands =
|
|
5392
|
-
(options.pop && !parallelSlotsChanged) || routeParamBoundaryChanged(doc)
|
|
5457
|
+
(options.pop && !parallelSlotsChanged) || routeParamBoundaryChanged(doc, paintedRoute)
|
|
5393
5458
|
const committedRoute = documentRouteState(doc)
|
|
5394
5459
|
if (committedRoute) window.__PNEXT_ROUTE__ = committedRoute
|
|
5395
5460
|
|
|
@@ -5646,19 +5711,6 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
|
|
|
5646
5711
|
if (!options.pop && departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
|
|
5647
5712
|
restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
|
|
5648
5713
|
}
|
|
5649
|
-
if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && keptCopies.has(page))
|
|
5650
|
-
void revalidateCommittedPage(targetUrl, sequence, page)
|
|
5651
|
-
}
|
|
5652
|
-
|
|
5653
|
-
async function revalidateCommittedPage(url: URL, sequence: number, page: PrefetchedPage) {
|
|
5654
|
-
const fresh = await keptCopies.get(page)
|
|
5655
|
-
if (!fresh?.ok || fresh.html === page.html || sequence !== navigationSequence) return
|
|
5656
|
-
await softNavigate(url.href, {
|
|
5657
|
-
replace: true,
|
|
5658
|
-
scroll: false,
|
|
5659
|
-
refreshLike: true,
|
|
5660
|
-
cachedPage: fresh,
|
|
5661
|
-
})
|
|
5662
5714
|
}
|
|
5663
5715
|
|
|
5664
5716
|
function isBotUserAgent() {
|