@wular/pnext 0.0.19 → 0.0.20

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.
Files changed (75) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/reference/compat.md +2 -2
  4. package/reference/getting-started.md +2 -2
  5. package/src/cli/adapters/vercel.ts +27 -0
  6. package/src/cli/analyze.ts +8 -7
  7. package/src/cli/boot/trace.ts +9 -3
  8. package/src/cli/build.ts +23 -15
  9. package/src/cli/dev.ts +23 -0
  10. package/src/cli/migrate/report.ts +1 -1
  11. package/src/cli/migrate/run.ts +2 -2
  12. package/src/cli/migrate/scan.ts +128 -1
  13. package/src/cli/serve/pipeline.ts +42 -42
  14. package/src/cli/start.ts +4 -2
  15. package/src/client/build.ts +51 -25
  16. package/src/client/entry.ts +27 -4
  17. package/src/client/router/page-slot.ts +81 -0
  18. package/src/client/router/runtime.ts +107 -112
  19. package/src/compat/bundler/cjs-exports.ts +26 -20
  20. package/src/compat/client/css-order.ts +1 -1
  21. package/src/compat/client/navigation-scroll.ts +36 -40
  22. package/src/compat/css/modules.ts +6 -2
  23. package/src/compat/next/font/cache.ts +10 -22
  24. package/src/compat/next/font/runtime.ts +20 -0
  25. package/src/compat/next/legacy-request-apis.ts +41 -5
  26. package/src/compat/next/script.tsx +16 -24
  27. package/src/compat/react/client-lite.ts +1 -0
  28. package/src/compat/react/client.ts +11 -0
  29. package/src/compat/react/dom-client.ts +11 -2
  30. package/src/compat/react/dom-react-server.ts +4 -0
  31. package/src/compat/react/dom.ts +11 -2
  32. package/src/compat/react/internals.ts +65 -0
  33. package/src/compat/react/parity.ts +47 -0
  34. package/src/compat/react/preact.ts +31 -1
  35. package/src/compat/react/react-server.ts +7 -0
  36. package/src/compat/react/server.ts +16 -2
  37. package/src/compat/react/style-values.ts +68 -0
  38. package/src/compat/register/render.ts +14 -3
  39. package/src/compat/register/routing.ts +57 -7
  40. package/src/compat/tsconfig-defaults.ts +4 -0
  41. package/src/compat/typecheck/check.ts +26 -71
  42. package/src/compat/typecheck/worker.ts +3 -2
  43. package/src/compat-bootstrap.ts +4 -0
  44. package/src/css/build.ts +153 -14
  45. package/src/css/postcss.ts +32 -3
  46. package/src/dev/restart/client-key.ts +1 -1
  47. package/src/dev/restart/global-css.ts +12 -94
  48. package/src/dev/restart/record.ts +106 -0
  49. package/src/dev/restart/route-facts.ts +11 -90
  50. package/src/dev/server.ts +208 -41
  51. package/src/extensions.ts +69 -3
  52. package/src/render/hooks.ts +1 -1
  53. package/src/render/metadata.ts +89 -17
  54. package/src/render/renderer.ts +322 -136
  55. package/src/render/resource-hints.ts +62 -9
  56. package/src/render/slots.tsx +2 -1
  57. package/src/resolve/scan-facts.ts +13 -5
  58. package/src/routing/forwarded.ts +20 -7
  59. package/src/routing/handler.ts +6 -2
  60. package/src/routing/href.ts +11 -1
  61. package/src/routing/metadata-files.ts +16 -3
  62. package/src/routing/proxy.ts +6 -3
  63. package/src/routing/routes.ts +253 -34
  64. package/src/runtime/fetch-host.ts +42 -0
  65. package/src/runtime/fingerprint.ts +132 -0
  66. package/src/runtime/loader.ts +7 -5
  67. package/src/runtime/module-cache.ts +3 -36
  68. package/src/runtime/modules.ts +333 -77
  69. package/src/runtime/vendor-build.ts +34 -5
  70. package/src/runtime/vendor.ts +1 -18
  71. package/src/types.ts +6 -0
  72. package/src/utils/asset-hash.ts +22 -0
  73. package/src/utils/code.ts +41 -0
  74. package/src/utils/html.ts +4 -1
  75. package/src/utils/serialize.ts +66 -7
@@ -45,6 +45,7 @@ import type {
45
45
  SoftNavigateOptions,
46
46
  } from './types'
47
47
  import type { LinkClickTarget } from './hub'
48
+ import { elementInPageSlot, graftPageSlot, loadingShellTarget } from './page-slot'
48
49
  // ---------------------------------------------------------------------------
49
50
  // DOCUMENTS
50
51
  // ---------------------------------------------------------------------------
@@ -507,10 +508,13 @@ function pruneStylesheets(doc: Document) {
507
508
  }
508
509
  }
509
510
 
511
+ // Built stylesheets answer to `/assets/` (core) and `/_next/static/` (next-compat, Next's own path).
512
+ const BUILT_SHEET = /^\/(?:assets|_next\/static)\//
513
+
510
514
  function pnextStylesheet(href: string) {
511
515
  try {
512
516
  const url = new URL(href, location.href)
513
- return url.origin === location.origin && url.pathname.startsWith('/assets/')
517
+ return url.origin === location.origin && BUILT_SHEET.test(url.pathname)
514
518
  } catch {
515
519
  return false
516
520
  }
@@ -695,7 +699,7 @@ function preservedSegmentIslands(segments: Element[]): LiveIslandRoot[] {
695
699
  for (const root of segment.querySelectorAll('pnext-client[data-pnext-client]')) {
696
700
  const live = root as LiveIslandRoot
697
701
  if (!isTopLevelIslandRoot(root)) continue
698
- if (root.closest('#pnext-page')) continue
702
+ if (elementInPageSlot(root)) continue
699
703
  if (!live.__pnextLive) continue
700
704
  live.__pnextIncoming ??= live.cloneNode(true) as Element
701
705
  roots.push(live)
@@ -704,72 +708,17 @@ function preservedSegmentIslands(segments: Element[]): LiveIslandRoot[] {
704
708
  return roots
705
709
  }
706
710
 
707
- /**
708
- * Resolve a live segment's page slot as a "replace its content" function. A page
709
- * that mounts nothing never materializes the wrapper div (the renderer's `keep`
710
- * marker mode, which preserves Next's structural DOM), so the graft splices
711
- * between the `pnext-page:` comment anchors instead. Null when neither form is
712
- * present, so there is nothing to graft.
713
- */
714
- function livePageSlot(segment: Element): ((nodes: Node[]) => void) | null {
715
- const element = segment.querySelector('#pnext-page')
716
- if (element)
717
- return nodes => element.replaceChildren(...reusePageNodes([...element.childNodes], nodes))
718
- const walker = document.createTreeWalker(segment, NodeFilter.SHOW_COMMENT)
719
- let start: Comment | null = null
720
- while (walker.nextNode()) {
721
- const comment = walker.currentNode as Comment
722
- if (comment.data.startsWith('pnext-page:')) {
723
- start = comment
724
- break
725
- }
726
- }
727
- const parent = start?.parentNode
728
- if (!start || !parent) return null
729
- return nodes => {
730
- const liveNodes: ChildNode[] = []
731
- let node = start.nextSibling
732
- while (node && !(node.nodeType === Node.COMMENT_NODE && node.nodeValue === '/pnext-page')) {
733
- liveNodes.push(node)
734
- node = node.nextSibling
735
- }
736
- const next = reusePageNodes(liveNodes, nodes)
737
- for (const stale of liveNodes) stale.remove()
738
- for (const incoming of next) parent.insertBefore(incoming, node)
739
- }
740
- }
741
-
742
- // Next re-renders a page in place, so unchanged DOM keeps its node identity.
743
- // An incoming child byte-identical to a live one IS that element: hand back the
744
- // live node instead of the imported copy. Islands keep their own graft path.
745
- function reusePageNodes(live: readonly ChildNode[], incoming: readonly Node[]): Node[] {
746
- const candidates = live.filter(
747
- (node): node is Element =>
748
- node instanceof Element &&
749
- !(node instanceof HTMLScriptElement) &&
750
- !node.matches('pnext-client, #pnext-page') &&
751
- node.querySelector(`pnext-client, #pnext-page, [${PRESERVE_ATTRIBUTE}]`) == null,
752
- )
753
- if (candidates.length === 0) return [...incoming]
754
- return incoming.map(node => {
755
- if (!(node instanceof Element)) return node
756
- const index = candidates.findIndex(candidate => candidate.outerHTML === node.outerHTML)
757
- return index === -1 ? node : candidates.splice(index, 1)[0]!
758
- })
759
- }
760
-
761
711
  function graftPreservedServerSegments(fragment: DocumentFragment, preserved: Element[]) {
762
712
  for (const placeholder of [...fragment.querySelectorAll(`[${SEGMENT_PRESERVE_ATTRIBUTE}]`)]) {
763
713
  const live = preserved[Number(placeholder.getAttribute(SEGMENT_PRESERVE_ATTRIBUTE))]
764
714
  placeholder.removeAttribute(SEGMENT_PRESERVE_ATTRIBUTE)
765
715
  if (!live) continue
766
- const livePage = livePageSlot(live)
767
- const incomingPage = placeholder.querySelector('#pnext-page')
768
- if (!livePage || !incomingPage) continue
769
- // MOVE (not clone) the incoming page nodes: they already belong to this
716
+ // MOVE (not clone) the incoming page slot: it already belongs to this
770
717
  // document, and cloning would turn an island root the island graft just
771
- // spliced in back into an inert copy, dropping its live preact tree.
772
- livePage([...incomingPage.childNodes])
718
+ // spliced in back into an inert copy, dropping its live preact tree. Moving
719
+ // the slot itself also preserves whether this destination needs a mount
720
+ // element or Next-flat comment anchors.
721
+ if (!graftPageSlot(live, placeholder)) continue
773
722
  placeholder.replaceWith(live)
774
723
  }
775
724
  }
@@ -879,7 +828,7 @@ function matchPreservedIslands(
879
828
  if (!isTopLevelIslandRoot(root)) continue
880
829
  if (!(root as LiveIslandRoot).__pnextLive) continue
881
830
  if (remountTemplates && isTemplateIsland(root)) continue
882
- if (remountPageIslands && root.closest('#pnext-page')) continue
831
+ if (remountPageIslands && elementInPageSlot(root)) continue
883
832
  const id = root.getAttribute('data-pnext-client') ?? ''
884
833
  const queue = liveById.get(id)
885
834
  if (queue) queue.push(root)
@@ -890,13 +839,13 @@ function matchPreservedIslands(
890
839
  for (const placeholder of doc.body.querySelectorAll('pnext-client[data-pnext-client]')) {
891
840
  if (!isTopLevelIslandRoot(placeholder)) continue
892
841
  if (remountTemplates && isTemplateIsland(placeholder)) continue
893
- if (remountPageIslands && placeholder.closest('#pnext-page')) continue
842
+ if (remountPageIslands && elementInPageSlot(placeholder)) continue
894
843
  // Inside a preserved server segment the LAYOUT-level islands travel with the
895
844
  // live segment DOM that graftPreservedServerSegments splices in, so the island
896
845
  // graft (which runs first) must not move them out of it. Page-slot islands
897
846
  // still preserve: their placeholders move into the live page slot.
898
847
  const preservedSegment = placeholder.closest(`[${SEGMENT_PRESERVE_ATTRIBUTE}]`)
899
- if (preservedSegment && !placeholder.closest('#pnext-page')) continue
848
+ if (preservedSegment && !elementInPageSlot(placeholder)) continue
900
849
  const live = liveById.get(placeholder.getAttribute('data-pnext-client') ?? '')?.shift()
901
850
  if (!live) continue
902
851
  // An island carrying its own params scope (a parallel-route slot stamps its
@@ -974,9 +923,9 @@ function swapBody(
974
923
  preserved: LiveIslandRoot[] = [],
975
924
  segments: Element[] = [],
976
925
  preservedPage: PreservedClientPage | null = null,
926
+ reusable = reusableBodyChildren(),
977
927
  ) {
978
928
  const fragment = document.createDocumentFragment()
979
- const reusable = reusableBodyChildren()
980
929
  // The incoming document's entry src, remembered on <html> below so a snapshot
981
930
  // of the swapped (script-less) live DOM can still name its entry.
982
931
  let entrySrc: string | null = null
@@ -1043,7 +992,7 @@ function restoreSwapFocus(focused: Element | null): void {
1043
992
  // document) and nothing the island/segment/page grafts need to target.
1044
993
  function isReusableBodyChild(node: Node): node is Element {
1045
994
  if (!(node instanceof Element)) return false
1046
- if (node instanceof HTMLScriptElement) return false
995
+ if (node.tagName === 'SCRIPT') return false
1047
996
  if (node.matches('pnext-client, pnext-layout, #pnext-page')) return false
1048
997
  return (
1049
998
  node.querySelector(
@@ -1053,22 +1002,12 @@ function isReusableBodyChild(node: Node): node is Element {
1053
1002
  }
1054
1003
 
1055
1004
  function reusableBodyChildren(): Element[] {
1056
- const candidates: Element[] = []
1057
1005
  // Once the entry has mounted, a client page dissolves `#pnext-page` into comment
1058
1006
  // anchors, so its elements sit directly in <body>. They belong to the departing
1059
1007
  // render and must not be mistaken for root-layout DOM - skip the anchored range.
1060
- let insidePageSlot = false
1061
- for (const node of document.body.childNodes) {
1062
- if (node.nodeType === Node.COMMENT_NODE) {
1063
- const data = node.nodeValue ?? ''
1064
- if (data.startsWith('pnext-page:')) insidePageSlot = true
1065
- else if (data === '/pnext-page') insidePageSlot = false
1066
- continue
1067
- }
1068
- if (insidePageSlot || !isReusableBodyChild(node)) continue
1069
- candidates.push(node)
1070
- }
1071
- return candidates
1008
+ return [...document.body.children].filter(
1009
+ node => !elementInPageSlot(node) && isReusableBodyChild(node),
1010
+ )
1072
1011
  }
1073
1012
 
1074
1013
  // Match an incoming body child to a still-available live one and hand back the
@@ -2661,7 +2600,10 @@ export function prefetchRoute(
2661
2600
  const cacheKey = prefetchCacheKey(key, full)
2662
2601
  // Prefetching the page we are already on is a no-op (Next's router produces
2663
2602
  // an empty delta for the current tree) — and it must not spend a request.
2664
- if (key === location.pathname + location.search && !options.currentUrl) {
2603
+ if (
2604
+ (key === location.pathname + location.search || key === routerState.activeRouteKey) &&
2605
+ !options.currentUrl
2606
+ ) {
2665
2607
  return Promise.resolve(null)
2666
2608
  }
2667
2609
  // A default prefetch carries only static segment data; search params are dynamic data
@@ -3746,8 +3688,7 @@ function showLoadingShell(
3746
3688
  const suspense = marker ?? inline?.anchor
3747
3689
  if (!suspense && !allowWithoutBoundary) return false
3748
3690
  if (!loadingBoundaryChanges(marker, target)) return false
3749
- const container = loadingShellTarget()
3750
- if (!container) return false
3691
+ const { container, markerRange } = loadingShellTarget()
3751
3692
  // A painted loading shell IS a committed navigation (pushOptimisticUrl moves the address
3752
3693
  // bar the instant this returns true), so the window route state must reflect the
3753
3694
  // DESTINATION before any island reads useParams - otherwise usePathname and useParams
@@ -3759,6 +3700,7 @@ function showLoadingShell(
3759
3700
  // loading boundary. Keep that prefix when painting the fallback: replacing the target
3760
3701
  // with only the suspense children loses the eagerly prefetched layout.
3761
3702
  const incomingTarget =
3703
+ (markerRange ? doc.getElementById('pnext-page') : null) ??
3762
3704
  (container.id ? doc.getElementById(container.id) : null) ??
3763
3705
  doc.querySelector('[data-pnext-root]') ??
3764
3706
  (container === document.body ? doc.body : null)
@@ -3787,7 +3729,10 @@ function showLoadingShell(
3787
3729
  const liveOwner = liveIslandOwner(container)
3788
3730
  const incomingOwner = liveOwner && incomingIslandFor(doc, liveOwner)
3789
3731
  if (liveOwner && incomingOwner) liveOwner.__pnextIncoming = incomingOwner
3790
- else container.replaceChildren(fragment)
3732
+ else if (markerRange) {
3733
+ for (const node of markerRange[2]) node.remove()
3734
+ markerRange[0].after(fragment)
3735
+ } else container.replaceChildren(fragment)
3791
3736
  // Any painted shell can carry islands (a loading fallback that calls
3792
3737
  // useOffline(), say), and an unmounted island keeps its SSR value forever.
3793
3738
  // mountRoute is idempotent, so mounting on every paint is safe.
@@ -3985,13 +3930,6 @@ function loadingBoundaryChanges(marker: Element | null, target: URL): boolean {
3985
3930
  return divergence <= depth
3986
3931
  }
3987
3932
 
3988
- function loadingShellTarget(): HTMLElement | null {
3989
- const page = document.getElementById('pnext-page')
3990
- if (page instanceof HTMLElement) return page
3991
- const root = document.querySelector('[data-pnext-root]')
3992
- return root instanceof HTMLElement ? root : document.body
3993
- }
3994
-
3995
3933
  // Choosing the document a navigation commits: cache hit, in-flight prefetch,
3996
3934
  // segment reuse or a fresh fetch — and recording what the answer taught us.
3997
3935
 
@@ -4040,6 +3978,10 @@ async function pageForNavigation(
4040
3978
  const paintStaticStage = (html: string) => {
4041
3979
  if (staticStagePainted) return
4042
3980
  staticStagePainted = true
3981
+ // A cached stage with parallel slots has dynamic continuations beside the page. Those cannot
3982
+ // be reconstructed from a page-only frame, so this navigation needs the whole target document.
3983
+ if (html.includes('data-pnext-slot=') || slotStateSensitive(departureNavState, html))
3984
+ pageFrame.eligible = false
4043
3985
  onStaticStage?.(html)
4044
3986
  }
4045
3987
  const cached = prefetchEntriesForNavigation(key).find(
@@ -4118,14 +4060,14 @@ async function pageForNavigation(
4118
4060
  // whole-document fetch below unchanged.
4119
4061
  const framed = pageFrame.eligible
4120
4062
  ? await fetchPageFrameNavigation(url, {
4121
- navState: options.navState,
4063
+ navState: options.navState ?? departureNavState,
4122
4064
  sameUrl: pageFrame.sameUrl,
4123
4065
  }).catch(() => null)
4124
4066
  : null
4125
4067
  const page =
4126
4068
  framed ??
4127
4069
  (await fetchPage(url.href, {
4128
- navState: options.navState,
4070
+ navState: options.navState ?? departureNavState,
4129
4071
  onShell,
4130
4072
  }).catch(() => null))
4131
4073
  // Visited-page seeding: the navigation response is as fresh as any prefetch —
@@ -4515,10 +4457,14 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4515
4457
  // into the live body. Capturing later would save the target's fallback as
4516
4458
  // the previous history entry and restore a permanently stuck "Loading...".
4517
4459
  if (!options.pop) saveScrollPosition()
4460
+ if (departingBfcacheId) saveFormState(departingBfcacheId)
4518
4461
  // Snapshot the departing page's live island roots NOW, before a loading shell
4519
4462
  // can paint over (and detach) them below — they are stashed for back/forward
4520
4463
  // restore once the incoming document tells us which are preserved vs left.
4521
4464
  const departingLiveRoots = collectTopLevelLiveRoots()
4465
+ // Snapshot plain root-layout DOM before a cached/streamed shell can detach it. The final swap
4466
+ // reconciles onto these nodes so server layouts keep the same identity Next's root reconciler does.
4467
+ const departingReusableBody = reusableBodyChildren()
4522
4468
  // Painting a prefetched loading shell IS a committed navigation, so push the requested URL
4523
4469
  // here and let usePathname() and the address bar reflect the destination while the fetch is
4524
4470
  // still in flight; the final commit replaces this entry with the resolved one (or, on a
@@ -4593,10 +4539,17 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4593
4539
  // Loading-shell reuse: when the navigation must fetch, paint the CACHED shell for the
4594
4540
  // target pathname synchronously - search-param independent, so `?a` reuses the shell a `?b`
4595
4541
  // prefetch streamed - instead of waiting for the response's shell chunk.
4542
+ // A sibling runtime prefetch may have supplied a reusable PAGE frame while this URL's
4543
+ // param-dependent LAYOUT frame is missing. The route shell is not vary-aware, so do not
4544
+ // paint a sibling's layout while the destination layout is still on the wire.
4596
4545
  if (
4597
4546
  onShell &&
4598
4547
  !refreshLike &&
4599
4548
  !segmentHit &&
4549
+ !segmentCachePolicy?.needsLayoutFrameOnly?.({
4550
+ pathname: url.pathname,
4551
+ search: url.search,
4552
+ }) &&
4600
4553
  !hasFreshDocument(url) &&
4601
4554
  !peekSegmentBody(url.pathname)
4602
4555
  ) {
@@ -4625,7 +4578,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4625
4578
  eligible:
4626
4579
  !options.pop &&
4627
4580
  (!refreshLike || options.pageRefresh === true) &&
4628
- !slotStateSensitive(departureNavState),
4581
+ !slotStateSensitive(departureNavState, segmentHit?.html),
4629
4582
  // The DEPARTING URL, not the live one: an optimistic shell paint above may
4630
4583
  // already have pushed the destination into the address bar.
4631
4584
  sameUrl: url.pathname === previousPathname && url.search === previousSearch,
@@ -4682,7 +4635,7 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4682
4635
  !options.cachedPage &&
4683
4636
  skippedSegmentsUngraftable(
4684
4637
  doc,
4685
- options.freshSegments === true || (refreshLike && !options.pageRefresh),
4638
+ options.freshSegments || cachedStagePainted || (refreshLike && !options.pageRefresh),
4686
4639
  )
4687
4640
  ) {
4688
4641
  const fullPage = await fetchPage(url.href, {
@@ -4772,7 +4725,10 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4772
4725
  // a preserved segment, and so this pass agrees with the identical dry run in
4773
4726
  // skippedSegmentsUngraftable above.
4774
4727
  const preservedSegments =
4775
- options.freshSegments || (refreshLike && !options.pageRefresh) || navSlotsChanged(doc)
4728
+ options.freshSegments ||
4729
+ cachedStagePainted ||
4730
+ (refreshLike && !options.pageRefresh) ||
4731
+ navSlotsChanged(doc)
4776
4732
  ? (clearSegmentPreserveTags(doc), [])
4777
4733
  : matchPreservedServerSegments(doc)
4778
4734
  const preservedIslands = matchPreservedIslands(doc, remountTemplates, remountPageIslands)
@@ -4782,11 +4738,6 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4782
4738
  remountPageIslands,
4783
4739
  )
4784
4740
  const targetRouteKey = bfRouteKey(targetUrl.pathname, targetUrl.search)
4785
- // Browser-back form restoration: snapshot the departing entry's form-control state so a
4786
- // later popstate can restore what a fresh mount cannot (typed values are DOM state, lost on
4787
- // remount). Snapshotted for EVERY navigation, not just the ones that mint a new id: a
4788
- // navigation that KEEPS the bfcacheId must come out the other side with the same values.
4789
- if (departingBfcacheId) saveFormState(departingBfcacheId)
4790
4741
  // Live islands the incoming tree keeps in place (preserved layout/segment
4791
4742
  // islands): the departing page's OTHER roots are the ones to stash.
4792
4743
  const keptLiveIslands = new Set<Element>([
@@ -4824,7 +4775,13 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4824
4775
  // across a navigation. pnext's swap DETACHES preserved subtrees to graft them into the new
4825
4776
  // body, and detaching blurs - remember the focused node so it can be refocused.
4826
4777
  const focusedBeforeSwap = document.activeElement
4827
- swapBody(doc, [...preservedIslands, ...restoredIslands], preservedSegments, preservedPage)
4778
+ swapBody(
4779
+ doc,
4780
+ [...preservedIslands, ...restoredIslands],
4781
+ preservedSegments,
4782
+ preservedPage,
4783
+ departingReusableBody,
4784
+ )
4828
4785
  stylesheetReconciler?.(doc)
4829
4786
  // The swapped document carries the render's parallel-route state; pin it
4830
4787
  // (plus the document itself) to this history entry so back/forward restores
@@ -4840,9 +4797,16 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4840
4797
  await entryModule?.mountRoute?.()
4841
4798
  if (sequence !== navigationSequence) return
4842
4799
  restoreSwapFocus(focusedBeforeSwap)
4800
+ if (!options.pop && departingBfcacheId && previousPathname !== targetUrl.pathname) {
4801
+ restoreSharedLayoutFormState(departingBfcacheId, previousPathname, targetUrl.pathname)
4802
+ }
4843
4803
  // History traversal restores the popped entry's form state over the freshly
4844
4804
  // mounted tree (browser back/forward form restoration semantics).
4845
- if (options.pop) restoreFormState(historyBfcacheId())
4805
+ if (options.pop) {
4806
+ const targetBfcacheId = historyBfcacheId()
4807
+ restoreFormState(targetBfcacheId)
4808
+ if (targetBfcacheId) restoreFormStateWhenMounted(targetBfcacheId, sequence)
4809
+ }
4846
4810
  // Same-entry-identity navigation (a search-param nav on the same pathname, a refresh):
4847
4811
  // `key={bfcacheId}` subtrees do NOT remount, so typed values must survive. The commit can
4848
4812
  // still rebuild the page DOM under them (a PPR route streams its page slot in AFTER the
@@ -4928,6 +4892,21 @@ function saveFormState(bfcacheId: string) {
4928
4892
  }
4929
4893
  }
4930
4894
 
4895
+ function restoreControl(
4896
+ control: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement,
4897
+ saved: FormControlState,
4898
+ onlyEmpty: boolean,
4899
+ ) {
4900
+ if (control.localName !== saved.tag) return
4901
+ if (
4902
+ control.localName === 'input' &&
4903
+ (control.getAttribute('type')?.toLowerCase() ?? '') !== saved.type
4904
+ )
4905
+ return
4906
+ if (onlyEmpty && control.value !== '') return
4907
+ if (control.value !== saved.value) control.value = saved.value
4908
+ }
4909
+
4931
4910
  function restoreFormState(bfcacheId: string | undefined, onlyEmpty = false) {
4932
4911
  if (!bfcacheId) return
4933
4912
  const state = entryFormStateCache.get(bfcacheId)
@@ -4935,18 +4914,34 @@ function restoreFormState(bfcacheId: string | undefined, onlyEmpty = false) {
4935
4914
  const controls = formControls()
4936
4915
  if (controls.length !== state.length) return
4937
4916
  for (const [index, control] of controls.entries()) {
4938
- const saved = state[index]!
4939
- if (control.localName !== saved.tag) continue
4940
- if (
4941
- control.localName === 'input' &&
4942
- (control.getAttribute('type')?.toLowerCase() ?? '') !== saved.type
4943
- )
4944
- continue
4945
4917
  // `onlyEmpty`: a freshly mounted control is empty, so filling it restores
4946
4918
  // what the remount lost — while anything already carrying a value (the app
4947
4919
  // set it, or the user typed after the commit) is left alone.
4948
- if (onlyEmpty && control.value !== '') continue
4949
- if (control.value !== saved.value) control.value = saved.value
4920
+ restoreControl(control, state[index]!, onlyEmpty)
4921
+ }
4922
+ }
4923
+
4924
+ /** Restore uncontrolled controls owned by a layout whose URL scope did not change. */
4925
+ function restoreSharedLayoutFormState(
4926
+ bfcacheId: string,
4927
+ departingPathname: string,
4928
+ targetPathname: string,
4929
+ ) {
4930
+ const state = entryFormStateCache.get(bfcacheId)
4931
+ if (!state) return
4932
+ const controls = formControls()
4933
+ if (controls.length !== state.length) return
4934
+ const departing = departingPathname.split('/').filter(Boolean)
4935
+ const target = targetPathname.split('/').filter(Boolean)
4936
+ for (const [index, control] of controls.entries()) {
4937
+ if (elementInPageSlot(control)) continue
4938
+ const raw = control
4939
+ .closest('pnext-client[data-pnext-layout-segments]')
4940
+ ?.getAttribute('data-pnext-layout-segments')
4941
+ if (!raw) continue
4942
+ const depth = (JSON.parse(raw) as { depth?: number }).depth
4943
+ if (!depth || departing.slice(0, depth).join('/') !== target.slice(0, depth).join('/')) continue
4944
+ restoreControl(control, state[index]!, false)
4950
4945
  }
4951
4946
  }
4952
4947
 
@@ -13,7 +13,7 @@ import { rewriteFacts } from '../../resolve/scan-facts'
13
13
  import { spliceSource } from '../../runtime/module-transform'
14
14
  import { clientReferenceId } from '../../client/reference'
15
15
  import { hasUseClientDirective } from '../../client/reference-stub'
16
- import { isIdentifier, uniqueIdentifier } from '../../utils/code'
16
+ import { esbuildEntryExportNames, isIdentifier, uniqueIdentifier } from '../../utils/code'
17
17
  import { fileMemo, fileMemoAsync } from './source-cache'
18
18
 
19
19
  const encoder = new TextEncoder()
@@ -148,9 +148,17 @@ export function commonJsClientReferencePlugin(): Plugin {
148
148
  }
149
149
  }
150
150
 
151
+ // Minified ESM keeps every statement on one line, which `isCommonJsModuleSource`'s line-anchored test
152
+ // misses (`…;export{H as RWebShare};`). Such an entry taken for CommonJS gets the facade above, whose
153
+ // `__esModule ? .default` unwrap of esbuild's `__toCommonJS` namespace yields undefined and publishes
154
+ // no named exports at all. Checked HERE rather than in the shared predicate: that one also decides
155
+ // transform bail-outs and vendor entry shape, where the looser test changes unrelated behaviour.
156
+ const MINIFIED_ESM_SYNTAX = /[;}](?:import(?:\s|[{'"*])|export(?:\s|[{*]))/
157
+
151
158
  const isCommonJsJavaScriptFile = fileMemo((file: string) => {
152
159
  try {
153
- return isCommonJsModuleSource(readFileSync(file, 'utf8'), file)
160
+ const source = readFileSync(file, 'utf8')
161
+ return isCommonJsModuleSource(source, file) && !MINIFIED_ESM_SYNTAX.test(source)
154
162
  } catch {
155
163
  return true
156
164
  }
@@ -366,15 +374,29 @@ function recoverCommonJsNamedExports(code: string) {
366
374
  )
367
375
  }
368
376
 
369
- function commonJsNamespaceBinding(code: string, defaultName: string) {
377
+ function commonJsNamespaceBinding(
378
+ code: string,
379
+ defaultName: string,
380
+ depth = 0,
381
+ ): string | undefined {
370
382
  const assignment = new RegExp(
371
383
  `(?:^|\\n)(?:var|const|let)\\s+${escapeRegExp(defaultName)}\\s*=\\s*([A-Za-z_$][\\w$]*)\\.default\\s*;`,
372
384
  ).exec(code)
373
385
  if (assignment?.[1]) return assignment[1]
374
386
 
375
- return new RegExp(
387
+ const unwrap = new RegExp(
376
388
  `(?:^|\\n)(?:var|const|let)\\s+${escapeRegExp(defaultName)}\\s*=\\s*([A-Za-z_$][\\w$]*)\\s*!=\\s*null\\s*&&[\\s\\S]*?\\?\\s*\\1\\.default\\s*:\\s*\\1\\s*;`,
377
389
  ).exec(code)?.[1]
390
+ if (unwrap) return unwrap
391
+
392
+ // esbuild aliases the interop facade's namespace before exporting it as default. An `__esModule`
393
+ // entry with no `default` unwraps to undefined, so following the alias is what keeps the names
394
+ // being read off the raw `module.exports` rather than off nothing.
395
+ if (depth >= 4) return undefined
396
+ const alias = new RegExp(
397
+ `(?:^|\\n)(?:var|const|let)\\s+${escapeRegExp(defaultName)}\\s*=\\s*([A-Za-z_$][\\w$]*)\\s*;`,
398
+ ).exec(code)?.[1]
399
+ return alias ? commonJsNamespaceBinding(code, alias, depth + 1) : undefined
378
400
  }
379
401
 
380
402
  function hasNonDefaultExport(code: string) {
@@ -572,19 +594,3 @@ export const namedFacadeExportNames = fileMemo((file: string): string[] =>
572
594
  name => name !== 'default' && name !== '__esModule' && !reservedWords.has(name),
573
595
  ),
574
596
  )
575
-
576
- // Recover named exports from esbuild's `__export(entry, {...}); module.exports = __toCommonJS(entry)` shape.
577
- function esbuildEntryExportNames(code: string) {
578
- const toCommonJs = [
579
- ...code.matchAll(/module\.exports\s*=\s*__toCommonJS\w*\(\s*([A-Za-z_$][\w$]*)\s*\)/g),
580
- ]
581
- const entryVar = toCommonJs.at(-1)?.[1]
582
- if (!entryVar) return []
583
- const exportCall = new RegExp(
584
- `__export\\w*\\(\\s*${entryVar}\\s*,\\s*\\{([\\s\\S]*?)\\}\\s*\\)`,
585
- ).exec(code)
586
- if (!exportCall?.[1]) return []
587
- return [...exportCall[1].matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)\s*:/g)]
588
- .map(match => match[1])
589
- .filter((name): name is string => Boolean(name && isIdentifier(name)))
590
- }
@@ -32,5 +32,5 @@ function absoluteHref(link: HTMLLinkElement) {
32
32
 
33
33
  function nextStylesheet(href: string) {
34
34
  const url = new URL(href, location.href)
35
- return url.origin === location.origin && url.pathname.startsWith('/assets/')
35
+ return url.origin === location.origin && url.pathname.startsWith('/_next/static/')
36
36
  }
@@ -16,29 +16,13 @@ export function nextScrollPlan(
16
16
  return targetInViewport ? 'preserve' : 'top-then-target'
17
17
  }
18
18
 
19
- export function firstScrollCandidate<T>(
20
- candidates: Iterable<T>,
21
- shouldSkip: (candidate: T) => boolean,
22
- ): T | undefined {
23
- for (const candidate of candidates) if (!shouldSkip(candidate)) return candidate
24
- return undefined
25
- }
26
-
27
- export function deepestSingleChild<T>(node: T | undefined, onlyChild: (node: T) => T | undefined) {
28
- while (node) {
29
- const child = onlyChild(node)
30
- if (!child) return node
31
- node = child
32
- }
33
- return undefined
34
- }
35
-
36
19
  export function topOnlyScrollPosition(scrollX: number): [number, number] {
37
20
  return [scrollX, 0]
38
21
  }
39
22
 
40
23
  export const applyNextNavigationScroll: NavigationScrollAction = (url, options) => {
41
- const target = scrollFocusTarget()
24
+ const segmentElements = changedSegmentElements()
25
+ const target = scrollFocusTarget(segmentElements)
42
26
  const plan = nextScrollPlan(
43
27
  url,
44
28
  options,
@@ -56,7 +40,7 @@ export const applyNextNavigationScroll: NavigationScrollAction = (url, options)
56
40
  // zero-rect sibling there and runs out - the navigation scrolls nowhere and focuses nothing. pnext
57
41
  // renders those elements in place, so recognize the leading hoisted element and reproduce the same
58
42
  // no-op. Placed after `restore` so history traversal still restores its saved position.
59
- if (segmentLeadsWithHoistedElement()) return
43
+ if (segmentLeadsWithHoistedElement(segmentElements)) return
60
44
  if (plan === 'hash') {
61
45
  scrollToHash(url.hash)
62
46
  return
@@ -90,25 +74,13 @@ function targetTopInViewport(element: HTMLElement, viewportHeight: number) {
90
74
  return top >= 0 && top <= viewportHeight
91
75
  }
92
76
 
93
- function scrollFocusTarget(): HTMLElement | null {
94
- const segment = changedSegmentRoot()
95
- if (!segment) return null
77
+ function scrollFocusTarget(nodes: HTMLElement[]): HTMLElement | null {
96
78
  // Next resolves the target with findDOMNode() on the changed segment: the
97
79
  // FIRST host element the segment rendered, never a descendant of it. The page
98
80
  // container IS that segment, so its first element child is the target as-is.
99
81
  // The fallback roots below wrap the segment in pnext's own chrome, so there
100
82
  // the single-child chain is walked down to the content that segment rendered.
101
- const first =
102
- segment.id === PAGE_CONTAINER_ID
103
- ? (segment.firstElementChild ?? undefined)
104
- : deepestSingleChild(segment.firstElementChild ?? undefined, node =>
105
- node.nextElementSibling === null ? (node.firstElementChild ?? undefined) : undefined,
106
- )
107
- const nodes: HTMLElement[] = []
108
- for (let node = first; node; node = node.nextElementSibling ?? undefined) {
109
- if (node instanceof HTMLElement) nodes.push(node)
110
- }
111
- return firstScrollCandidate(nodes, shouldSkipScrollElement) ?? null
83
+ return nodes.find(node => !shouldSkipScrollElement(node)) ?? null
112
84
  }
113
85
 
114
86
  /** The renderer's page-slot wrapper — the segment a navigation replaced. */
@@ -121,17 +93,41 @@ const HOISTED_TAGS = new Set(['style', 'link', 'title', 'meta', 'base'])
121
93
  * Whether the changed segment's first rendered element is one React would have hoisted into <head> -
122
94
  * the state in which Next's scroll walk never reaches the page content (see the call site).
123
95
  */
124
- function segmentLeadsWithHoistedElement(): boolean {
125
- const segment = changedSegmentRoot()
126
- const first = segment?.firstElementChild
127
- return first !== null && first !== undefined && HOISTED_TAGS.has(first.localName)
96
+ function segmentLeadsWithHoistedElement(elements: HTMLElement[]): boolean {
97
+ return elements[0] !== undefined && HOISTED_TAGS.has(elements[0].localName)
128
98
  }
129
99
 
130
- function changedSegmentRoot(): HTMLElement | null {
100
+ function changedSegmentElements(): HTMLElement[] {
131
101
  const page = document.getElementById(PAGE_CONTAINER_ID)
132
- if (page instanceof HTMLElement) return page
102
+ if (page instanceof HTMLElement) return Array.from(page.children).filter(isHtmlElement)
103
+
104
+ const walker = document.createTreeWalker(document.body, 128 /* NodeFilter.SHOW_COMMENT */)
105
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
106
+ if (!node.nodeValue?.startsWith('pnext-page:')) continue
107
+ const elements: HTMLElement[] = []
108
+ for (let sibling = node.nextSibling; sibling; sibling = sibling.nextSibling) {
109
+ if (sibling.nodeType === 8 /* Node.COMMENT_NODE */ && sibling.nodeValue === '/pnext-page')
110
+ return elements
111
+ if (sibling instanceof HTMLElement) elements.push(sibling)
112
+ }
113
+ break
114
+ }
115
+
133
116
  const root = document.querySelector('[data-pnext-root]')
134
- return root instanceof HTMLElement ? root : document.body
117
+ const segment = root instanceof HTMLElement ? root : document.body
118
+ let first = segment.firstElementChild instanceof HTMLElement ? segment.firstElementChild : null
119
+ while (first?.nextElementSibling === null && first.firstElementChild instanceof HTMLElement)
120
+ first = first.firstElementChild
121
+ const elements: HTMLElement[] = []
122
+ for (let node = first; node;) {
123
+ elements.push(node)
124
+ node = node.nextElementSibling instanceof HTMLElement ? node.nextElementSibling : null
125
+ }
126
+ return elements
127
+ }
128
+
129
+ function isHtmlElement(element: Element): element is HTMLElement {
130
+ return element instanceof HTMLElement
135
131
  }
136
132
 
137
133
  function scrollToHash(hash: string) {
@@ -4,6 +4,7 @@ import path from 'node:path'
4
4
  import type { Plugin } from 'esbuild'
5
5
  import type { ResolvedConfig } from '../../config'
6
6
  import { resolveImport } from '../../resolve/imports'
7
+ import { hoistCssImports } from '../../css/build'
7
8
  import { maybeLightningcssTransform } from './lightningcss'
8
9
  import { escapeRegex } from '../../utils/code'
9
10
 
@@ -43,9 +44,12 @@ export function nextCssModuleChunkPlugin(config: ResolvedConfig): Plugin {
43
44
  setup(build) {
44
45
  build.onLoad({ filter: /\.css$/ }, ({ path: file }) => {
45
46
  const source = nextCssSource(file, config.root)
47
+ // This onLoad claims every `.css`, so core's directive-order plugin
48
+ // (registered after it) never sees a compat app's stylesheet — hoist here.
49
+ const raw = hoistCssImports(readFileSync(source, 'utf8'))
46
50
  const contents = file.endsWith('.module.css')
47
- ? transformCssModule(readFileSync(source, 'utf8'), source, config.root)
48
- : rewriteTransparentBodyChildren(readFileSync(source, 'utf8'))
51
+ ? transformCssModule(raw, source, config.root)
52
+ : rewriteTransparentBodyChildren(raw)
49
53
  return {
50
54
  // No-op unless next.config enables experimental.useLightningcss.
51
55
  contents: maybeLightningcssTransform(contents, source, config.root),