@wular/pnext 0.1.2 → 0.1.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wular/pnext",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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. When a link shows a dynamic page again from the router cache, the page refreshes in the background and the fresh render replaces it in place. Cross-origin targets, non-HTML responses, and fetch failures fall back to a full page load.
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
 
@@ -14,7 +14,13 @@ import type { EntryModule } from './types'
14
14
  // region (install, soft nav, popstate), so it lives on one record: a module-level `let` can only be
15
15
  // reassigned inside the module that declares it. State only the runtime touches stays in ./runtime.
16
16
 
17
- export const routerState = {
17
+ export const routerState: {
18
+ observedLocationKey: string
19
+ activeRouteKey: string | undefined
20
+ renderedEntryId: string | undefined
21
+ /** The navigation that last painted a fallback, and when. */
22
+ fallbackPaint?: [sequence: number, at: number]
23
+ } = {
18
24
  // pathname+search of the URL the router last observed. A popstate that leaves both unchanged is a
19
25
  // same-document FRAGMENT traversal - Chrome fires popstate, not just hashchange, for those - and
20
26
  // treating one as a history traversal would refetch and swap the body, remounting the live tree
@@ -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(doc: Document): boolean {
133
- const current = documentRouteState(document)
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 ? await readStreamedBody(response, options.onShell) : await response.text())
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
- // Kept copies older than REVALIDATE_AFTER_MS that core commits, then swaps for their fresh render.
4512
- const keptCopies = new WeakMap<PrefetchedPage, Promise<PrefetchedPage | null>>()
4513
- const REVALIDATE_AFTER_MS = 2_000
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
- onShell?: (shellHtml: string) => void,
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 cached = prefetchEntriesForNavigation(key).find(
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
- : await Promise.race([cached.page, unsettledPrefetchDeadline()])
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
- now - (cached.settledTime ?? cached.time) > REVALIDATE_AFTER_MS &&
4645
+ !keptDataFresh(cached, now) &&
4586
4646
  !documentStaticHintFromHtml(page.html)?.isStatic
4587
4647
  ) {
4588
- const fresh = fetchPage(url.href, { fullRender: true }).catch(() => null)
4589
- keptCopies.set(page, fresh)
4590
- }
4591
- return page
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,25 @@ 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
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined')
5221
+ routerState.fallbackPaint = [sequence, performance.now()]
5222
+ pushOptimisticUrl()
5223
+ scheduleNavigationScroll(url, options)
5224
+ }
5154
5225
  const devSoftNavigation = nextDevDocument()
5155
5226
  const onShell =
5156
5227
  restorePage || options.pop || refreshLike
5157
5228
  ? undefined
5158
5229
  : (shellHtml: string) => {
5159
- if (cachedStagePainted) return
5160
- if (devSoftNavigation) return
5161
- if (!showLoadingShell(shellHtml, sequence, url, undefined, false, true)) return
5162
- // A loading boundary is a committed navigation state.
5163
- pushOptimisticUrl()
5164
- scheduleNavigationScroll(url, options)
5230
+ if (cachedStagePainted || devSoftNavigation) return false
5231
+ if (!showLoadingShell(shellHtml, sequence, url, undefined, false, true)) return false
5232
+ commitPaint()
5233
+ return true
5165
5234
  }
5166
5235
  // The per-segment cache's answer for this navigation, looked up ONCE: it decides both
5167
5236
  // whether the generic loading shell should paint (a real cached static segment is strictly
@@ -5181,8 +5250,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5181
5250
  if (devSoftNavigation) return
5182
5251
  if (!commitStaticStage(html, sequence, url, postponedShell)) return
5183
5252
  cachedStagePainted = true
5184
- pushOptimisticUrl()
5185
- scheduleNavigationScroll(url, options)
5253
+ commitPaint()
5186
5254
  }
5187
5255
  : undefined
5188
5256
  // Loading-shell reuse: when the navigation must fetch, paint the CACHED shell for the
@@ -5215,8 +5283,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5215
5283
  : false
5216
5284
  if (shellPainted) {
5217
5285
  cachedStagePainted = true
5218
- pushOptimisticUrl()
5219
- scheduleNavigationScroll(url, options)
5286
+ commitPaint()
5220
5287
  }
5221
5288
  }
5222
5289
  // Which navigations may fetch the PAGE frame alone. Never on popstate (a history entry
@@ -5351,6 +5418,14 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5351
5418
  ])
5352
5419
  if (sequence !== navigationSequence) return abandonFetchedPage(page)
5353
5420
  }
5421
+ // React's FALLBACK_THROTTLE_MS: content replacing a committed fallback reveals no sooner than 300 ms after it.
5422
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && routerState.fallbackPaint?.[0] === sequence) {
5423
+ const wait = routerState.fallbackPaint[1] + 300 - performance.now()
5424
+ if (wait > 10) {
5425
+ await new Promise(resolve => setTimeout(resolve, wait))
5426
+ if (sequence !== navigationSequence) return abandonFetchedPage(page)
5427
+ }
5428
+ }
5354
5429
 
5355
5430
  if (!options.pop) {
5356
5431
  const bfcacheId = optimisticBfcacheId ?? nextBfcacheIdForNavigation(targetUrl, previousPathname)
@@ -5389,7 +5464,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5389
5464
  // would compare the destination with itself and hide every param transition.
5390
5465
  const parallelSlotsChanged = navSlotsChanged(doc)
5391
5466
  const remountPageIslands =
5392
- (options.pop && !parallelSlotsChanged) || routeParamBoundaryChanged(doc)
5467
+ (options.pop && !parallelSlotsChanged) || routeParamBoundaryChanged(doc, paintedRoute)
5393
5468
  const committedRoute = documentRouteState(doc)
5394
5469
  if (committedRoute) window.__PNEXT_ROUTE__ = committedRoute
5395
5470
 
@@ -5646,19 +5721,6 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5646
5721
  if (!options.pop && departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
5647
5722
  restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
5648
5723
  }
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
5724
  }
5663
5725
 
5664
5726
  function isBotUserAgent() {