@wular/pnext 0.1.0 → 0.1.1

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.0",
3
+ "version": "0.1.1",
4
4
  "description": "A fast little framework for server-first React apps, fully compatible with Next.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -45,11 +45,11 @@ Prefetch warms the target page and its assets. Set the mode per link with the `p
45
45
 
46
46
  The app-wide default can be set with the `prefetch` field in `pnext.config.ts`, described in [Config](./config.md).
47
47
 
48
- Requests use low network priority. At most four run at once, though the hover-intent lane allows up to twelve. The core fallback expiry is five minutes. Prefetch does nothing in development.
48
+ Requests use low network priority. At most four run at once, though the hover-intent lane allows up to twelve. The core fallback expiry is five minutes. Development prefetches like production, except under `compat.next`, which follows Next and does not prefetch in development.
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. Back and forward stay soft and restore scroll. 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. 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.
53
53
 
54
54
  ## Redirects and not found
55
55
 
@@ -20,6 +20,7 @@ import {
20
20
  import {
21
21
  exportDocumentFetcher,
22
22
  loadingShellPredictionPolicy,
23
+ prefetchStaleTimePolicy,
23
24
  prefetchStaleTimeMs,
24
25
  revalidationPrefetchDelayMs,
25
26
  segmentCachePolicy,
@@ -30,7 +31,9 @@ import {
30
31
  emitLocationChange,
31
32
  emitNavigationCommit,
32
33
  emitNavigationStart,
34
+ navigationScrollAction,
33
35
  scheduleNavigationScroll,
36
+ setNavigationScrollAction,
34
37
  withSilentLocationChange,
35
38
  } from './events'
36
39
  import type {
@@ -39,6 +42,7 @@ import type {
39
42
  EntryModule,
40
43
  LinkPrefetchMode,
41
44
  LoadingShellPrediction,
45
+ NavigationScrollAction,
42
46
  PrefetchedPage,
43
47
  PrefetchOptions,
44
48
  SegmentCacheHit,
@@ -57,14 +61,17 @@ export interface BrowserRouteState {
57
61
  runtimePrefetch?: boolean
58
62
  }
59
63
 
64
+ const ROUTE_STATE_PREFIX = 'window.__PNEXT_ROUTE__='
65
+
60
66
  function documentRouteState(doc: Document): BrowserRouteState | undefined {
61
- const prefix = 'window.__PNEXT_ROUTE__='
62
67
  const source = [...doc.scripts]
63
68
  .map(script => script.textContent ?? '')
64
- .find(text => text.startsWith(prefix))
69
+ .find(text => text.startsWith(ROUTE_STATE_PREFIX))
65
70
  if (!source) return undefined
66
71
  try {
67
- return JSON.parse(source.slice(prefix.length).replace(/;\s*$/, '')) as BrowserRouteState
72
+ return JSON.parse(
73
+ source.slice(ROUTE_STATE_PREFIX.length).replace(/;\s*$/, ''),
74
+ ) as BrowserRouteState
68
75
  } catch {
69
76
  return undefined
70
77
  }
@@ -320,6 +327,8 @@ function materializeStreamedSegments(doc: Document) {
320
327
  for (const chunk of doc.querySelectorAll<HTMLElement>(
321
328
  'div[hidden][data-pnext-stream], template[data-pnext-stream]',
322
329
  )) {
330
+ // Its promotion script has nothing left to do, and re-running it would only trip a script CSP.
331
+ if (chunk.nextElementSibling?.tagName === 'SCRIPT') chunk.nextElementSibling.remove()
323
332
  const id = chunk.getAttribute('data-pnext-stream')
324
333
  const suspense = id
325
334
  ? doc.querySelector(`pnext-suspense[data-pnext-suspense="${CSS.escape(id)}"]`)
@@ -1053,6 +1062,11 @@ function swapBody(
1053
1062
  continue
1054
1063
  }
1055
1064
  if (node.hasAttribute('data-pnext-dev')) continue
1065
+ // Callers install the route state from the document; re-running it only trips a script CSP.
1066
+ if (node.text.startsWith(ROUTE_STATE_PREFIX)) {
1067
+ fragment.append(document.importNode(node, true))
1068
+ continue
1069
+ }
1056
1070
  // Parser-created scripts are inert; rebuild them so props, route
1057
1071
  // state, and streamed-chunk patches execute in document order when the
1058
1072
  // fragment lands in the live document.
@@ -1092,7 +1106,7 @@ function swapBody(
1092
1106
  cursor = next
1093
1107
  }
1094
1108
  }
1095
- if (paintHold) document.body.append(paintHold)
1109
+ if (paintHold) placeNavigationPaintHold(paintHold)
1096
1110
  // Track the entry of what is now on screen (dropped when the incoming route
1097
1111
  // has none, so a stale entry is never attributed to it).
1098
1112
  if (entrySrc) document.documentElement.setAttribute(ENTRY_SCRIPT_ATTRIBUTE, entrySrc)
@@ -1102,6 +1116,8 @@ function swapBody(
1102
1116
  /** Keep the last complete screen painted while client roots mount into the committed document. */
1103
1117
  type NavigationPaintHold = HTMLElement & {
1104
1118
  __pnextObserver?: MutationObserver
1119
+ /** The window scroll when the copy was taken. */
1120
+ __pnextScroll?: [number, number]
1105
1121
  }
1106
1122
 
1107
1123
  function removeNavigationPaintHold(hold: NavigationPaintHold) {
@@ -1124,12 +1140,17 @@ function createNavigationPaintHold(): NavigationPaintHold | null {
1124
1140
  element.textContent?.trim(),
1125
1141
  )
1126
1142
  if (roots.length === 0) return null
1127
- const hold = document.createElement('div')
1143
+ const hold: NavigationPaintHold = document.createElement('div')
1128
1144
  hold.setAttribute('data-pnext-navigation-paint-hold', '')
1129
1145
  hold.setAttribute('aria-hidden', 'true')
1130
1146
  hold.style.cssText =
1131
1147
  'position:fixed;inset:0;z-index:2147483646;overflow:hidden;pointer-events:none;background:Canvas;visibility:visible!important'
1132
- hold.append(...roots.map(root => root.cloneNode(true)))
1148
+ hold.__pnextScroll = [window.scrollX, window.scrollY]
1149
+ // A <body> shell over the whole hold: body styles lay out and paint the copy as the page was.
1150
+ const body = document.body.cloneNode() as HTMLElement
1151
+ body.style.minHeight = '100%'
1152
+ body.append(...roots.map(root => root.cloneNode(true)))
1153
+ hold.append(body)
1133
1154
  // The copy is paint-only: route mount scans and id lookups must never treat it as live UI.
1134
1155
  for (const node of hold.querySelectorAll('[data-pnext-client]'))
1135
1156
  node.removeAttribute('data-pnext-client')
@@ -1159,19 +1180,25 @@ function attachNavigationPaintHold(
1159
1180
  // The opaque fixed clone covers the viewport while the committed tree mounts.
1160
1181
  // Keep that live tree visible to focus management and selector observers;
1161
1182
  // hiding <body> also hides every real destination element from browser gates.
1162
- document.body.append(hold)
1183
+ placeNavigationPaintHold(hold)
1163
1184
  hold.__pnextObserver ??= new MutationObserver(() => {
1164
1185
  if (sequence !== navigationSequence) {
1165
1186
  return removeNavigationPaintHold(hold)
1166
1187
  }
1167
- if (!hold.isConnected) document.body.append(hold)
1188
+ if (!hold.isConnected) placeNavigationPaintHold(hold)
1168
1189
  })
1169
1190
  hold.__pnextObserver.observe(document.documentElement, { childList: true, subtree: true })
1170
1191
  const scrollRoot = hold.querySelector<HTMLElement>('[data-scroll-root]')
1171
1192
  if (scrollRoot) scrollRoot.scrollTop = scrollTop
1172
1193
  }
1173
1194
 
1174
- async function releaseNavigationPaintHold(hold: NavigationPaintHold | null) {
1195
+ /** Append the hold scrolled as the departing page was; appending resets an element's scroll. */
1196
+ function placeNavigationPaintHold(hold: NavigationPaintHold) {
1197
+ document.body.append(hold)
1198
+ if (hold.__pnextScroll) hold.scrollTo?.(...hold.__pnextScroll)
1199
+ }
1200
+
1201
+ async function releaseNavigationPaintHold(hold: NavigationPaintHold | null, reveal?: () => void) {
1175
1202
  if (!hold) return
1176
1203
  // mountRoute, client effects and the navigation commit have completed before
1177
1204
  // this runs. Keep the departing frame through the next rendering opportunity,
@@ -1179,6 +1206,7 @@ async function releaseNavigationPaintHold(hold: NavigationPaintHold | null) {
1179
1206
  if (window.requestAnimationFrame) {
1180
1207
  await new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()))
1181
1208
  }
1209
+ reveal?.()
1182
1210
  removeNavigationPaintHold(hold)
1183
1211
  }
1184
1212
 
@@ -1510,6 +1538,11 @@ function isDevDocument() {
1510
1538
  return Boolean(document.querySelector('script[data-pnext-dev]'))
1511
1539
  }
1512
1540
 
1541
+ // Next's dev router neither prefetches nor paints loading states; core dev navigates like production.
1542
+ function nextDevDocument() {
1543
+ return prefetchStaleTimePolicy !== undefined && isDevDocument()
1544
+ }
1545
+
1513
1546
  // `replace` is also set for history TRAVERSALS (options.pop): assign() would
1514
1547
  // push a new entry over the popped one and truncate the forward stack, so a
1515
1548
  // bailout during back/forward must replace the current entry instead.
@@ -1541,6 +1574,24 @@ function saveScrollPosition() {
1541
1574
  }
1542
1575
  }
1543
1576
 
1577
+ // Defined by compat.next builds, whose router policies replace core's scroll and refresh ones.
1578
+ declare const __PNEXT_NEXT_ROUTER__: boolean | undefined
1579
+
1580
+ // Core's scroll policy when compat installs none: a new page starts at the top, a traversal
1581
+ // returns to where its entry was left.
1582
+ const coreNavigationScroll: NavigationScrollAction = (url, { pop, scroll }) => {
1583
+ const state = historyState()
1584
+ const [x = 0, y = 0] = pop
1585
+ ? (entryScroll.get(state.__pnextEntry) ?? (state.__pnextScroll as number[] | undefined) ?? [])
1586
+ : []
1587
+ const target = !pop && url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)))
1588
+ if (target) target.scrollIntoView()
1589
+ else if (pop || scroll !== false) window.scrollTo(x, y)
1590
+ }
1591
+
1592
+ // Where each entry was left, pops included, which cannot write the state of the entry they leave.
1593
+ const entryScroll = new Map<unknown, number[]>()
1594
+
1544
1595
  function storeNavState() {
1545
1596
  try {
1546
1597
  history.replaceState(
@@ -2855,9 +2906,7 @@ export function prefetchRoute(
2855
2906
  href: string,
2856
2907
  options: PrefetchOptions = {},
2857
2908
  ): Promise<PrefetchedPage | null> {
2858
- // Dev pages render per request and entries build on demand; hover prefetch
2859
- // would hammer the dev server for little gain. Navigation still fetches.
2860
- if (isDevDocument()) return Promise.resolve(null)
2909
+ if (nextDevDocument()) return Promise.resolve(null)
2861
2910
  if (isBotUserAgent()) return Promise.resolve(null)
2862
2911
  // `strict` is handled at the facade, the only entry point that can be handed
2863
2912
  // an unparseable href; everything reaching here already resolved once.
@@ -3997,7 +4046,6 @@ export function showLoadingShell(
3997
4046
  ) {
3998
4047
  if (sequence !== navigationSequence) return false
3999
4048
  if (typeof DOMParser === 'undefined') return false
4000
- const paintHold = document.querySelector<HTMLElement>('[data-pnext-navigation-paint-hold]')
4001
4049
  const doc = new DOMParser().parseFromString(shellHtml, 'text/html')
4002
4050
  materializeClientIslandMarkers(doc)
4003
4051
  // A STATIC STAGE owns whatever chunks streamed with it, so resolve them before picking what
@@ -4085,7 +4133,6 @@ export function showLoadingShell(
4085
4133
  // useOffline(), say), and an unmounted island keeps its SSR value forever.
4086
4134
  // mountRoute is idempotent, so mounting on every paint is safe.
4087
4135
  mountPaintedIslands(doc)
4088
- if (paintHold) document.body.append(paintHold)
4089
4136
  return true
4090
4137
  }
4091
4138
 
@@ -4263,7 +4310,6 @@ function unwrapSuspenseFallbacks(root: ParentNode): void {
4263
4310
  */
4264
4311
  export function paintStaticStageSubtree(html: string): boolean {
4265
4312
  if (typeof DOMParser === 'undefined') return false
4266
- const paintHold = document.querySelector<HTMLElement>('[data-pnext-navigation-paint-hold]')
4267
4313
  const doc = new DOMParser().parseFromString(html, 'text/html')
4268
4314
  if (!isPNextDocument(doc)) return false
4269
4315
  // Materialize BEFORE comparing structure: the renderer ships the page slot (and
@@ -4297,7 +4343,6 @@ export function paintStaticStageSubtree(html: string): boolean {
4297
4343
  // against the tree painted here. `swapBody`'s own body-child reuse still applies.
4298
4344
  swapBody(doc)
4299
4345
  mountPaintedIslands(doc)
4300
- if (paintHold) document.body.append(paintHold)
4301
4346
  return true
4302
4347
  }
4303
4348
 
@@ -4425,6 +4470,10 @@ function unsettledPrefetchDeadline(): Promise<null> {
4425
4470
  return new Promise(resolve => setTimeout(() => resolve(null), UNSETTLED_PREFETCH_WAIT_MS))
4426
4471
  }
4427
4472
 
4473
+ // Kept copies older than REVALIDATE_AFTER_MS that core commits at once, then refreshes in place.
4474
+ const keptCopies = new WeakSet<PrefetchedPage>()
4475
+ const REVALIDATE_AFTER_MS = 2_000
4476
+
4428
4477
  async function pageForNavigation(
4429
4478
  url: URL,
4430
4479
  options: SoftNavigateOptions = {},
@@ -4491,7 +4540,15 @@ async function pageForNavigation(
4491
4540
  cached.settled || cached.full
4492
4541
  ? await cached.page
4493
4542
  : await Promise.race([cached.page, unsettledPrefetchDeadline()])
4494
- if (page && !page.shellOnly) return page
4543
+ if (page && !page.shellOnly) {
4544
+ if (
4545
+ typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
4546
+ !prefetchStaleTimePolicy &&
4547
+ now - cached.time > REVALIDATE_AFTER_MS
4548
+ )
4549
+ keptCopies.add(page)
4550
+ return page
4551
+ }
4495
4552
  // Attached to an in-flight (or already settled) SHELL prefetch for this exact target:
4496
4553
  // the navigation issued no duplicate fetch, so paint the static stage it landed and let
4497
4554
  // the dynamic stage stream in below.
@@ -4988,6 +5045,8 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4988
5045
  // into the live body. Capturing later would save the target's fallback as
4989
5046
  // the previous history entry and restore a permanently stuck "Loading...".
4990
5047
  if (!options.pop) saveScrollPosition()
5048
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined')
5049
+ entryScroll.set(routerState.renderedEntryId, [window.scrollX, window.scrollY])
4991
5050
  if (departingBfcacheId) saveFormState(departingBfcacheId)
4992
5051
  // Snapshot the departing page's live island roots NOW, before a loading shell
4993
5052
  // can paint over (and detach) them below — they are stashed for back/forward
@@ -4996,11 +5055,6 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
4996
5055
  // Snapshot plain root-layout DOM before a cached/streamed shell can detach it. The final swap
4997
5056
  // reconciles onto these nodes so server layouts keep the same identity Next's root reconciler does.
4998
5057
  const departingReusableBody = reusableBodyChildren()
4999
- // Capture the complete outgoing screen before any loading/static stage can replace it. The
5000
- // resolved client-root commit uses this copy only while its first complete paint settles.
5001
- const paintHoldScrollTop =
5002
- document.querySelector<HTMLElement>('[data-scroll-root]')?.scrollTop ?? 0
5003
- const paintHold = createNavigationPaintHold()
5004
5058
  // Painting a prefetched loading shell IS a committed navigation, so push the requested URL
5005
5059
  // here and let usePathname() and the address bar reflect the destination while the fetch is
5006
5060
  // still in flight; the final commit replaces this entry with the resolved one (or, on a
@@ -5056,19 +5110,14 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5056
5110
  // server-side, and re-painting would replace a committed loading state with a second,
5057
5111
  // different one. A segment's loading fallback commits ONCE per navigation.
5058
5112
  let cachedStagePainted = false
5059
- const devSoftNavigation = isDevDocument()
5113
+ const devSoftNavigation = nextDevDocument()
5060
5114
  const onShell =
5061
5115
  restorePage || options.pop || refreshLike
5062
5116
  ? undefined
5063
5117
  : (shellHtml: string) => {
5064
5118
  if (cachedStagePainted) return
5065
5119
  if (devSoftNavigation) return
5066
- // In dev, compilation can suspend an application boundary even though the route's real
5067
- // content is otherwise ready. Keep the departing page until that compile-only hole is
5068
- // resolved; an explicit loading.js boundary remains paintable through the ordinary
5069
- // loading-boundary path. Production retains streamed application-Suspense behavior.
5070
- const devNavigation = shellHtml.includes('data-pnext-dev')
5071
- if (!showLoadingShell(shellHtml, sequence, url, undefined, false, !devNavigation)) return
5120
+ if (!showLoadingShell(shellHtml, sequence, url, undefined, false, true)) return
5072
5121
  // A loading boundary is a committed navigation state.
5073
5122
  pushOptimisticUrl()
5074
5123
  scheduleNavigationScroll(url, options)
@@ -5396,6 +5445,11 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5396
5445
  // page slot is a marker-range proxy); the entry's unmount compares roots by
5397
5446
  // identity, so it rides the same set.
5398
5447
  if (preservedPage) keptIslands.add(preservedPage.root as unknown as Element)
5448
+ // Copy the screen as it is now, a loading or static stage included, before anything unmounts.
5449
+ // The resolved client-root commit shows this copy only while its first complete paint settles.
5450
+ const paintHoldScrollTop =
5451
+ document.querySelector<HTMLElement>('[data-scroll-root]')?.scrollTop ?? 0
5452
+ const paintHold = createNavigationPaintHold()
5399
5453
  // Wake route-keyed layout children while the departing DOM is still attached. Their layout
5400
5454
  // cleanups can save shell state before unmount/swap; pop mounts the destination first instead.
5401
5455
  window.__PNEXT_ACTIVE_ENTRY__?.unmount?.(keptIslands)
@@ -5465,7 +5519,8 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5465
5519
  // selector waiter can observe the destination immediately after this stack unwinds; pruning in
5466
5520
  // the later post-mount phase let it catch the target node between its correct route sheet and a
5467
5521
  // stale asynchronous prune from the preceding navigation.
5468
- if (!url.hash) pruneStylesheets(doc)
5522
+ // Core keeps the departing sheets while the paint hold still shows the departing screen.
5523
+ if (!url.hash && (stylesheetReconciler || !paintHold)) pruneStylesheets(doc)
5469
5524
  // The swapped document carries the render's parallel-route state; pin it
5470
5525
  // (plus the document itself) to this history entry so back/forward restores
5471
5526
  // what was actually shown, without a server round trip.
@@ -5514,7 +5569,10 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5514
5569
  }
5515
5570
  }
5516
5571
  } finally {
5517
- await releaseNavigationPaintHold(paintHold)
5572
+ await releaseNavigationPaintHold(paintHold, () => {
5573
+ if (!url.hash && !stylesheetReconciler && sequence === navigationSequence)
5574
+ pruneStylesheets(doc)
5575
+ })
5518
5576
  }
5519
5577
  if (sequence !== navigationSequence) return
5520
5578
  // Mounting/location subscribers run after the initial pre-mount scroll action.
@@ -5547,6 +5605,23 @@ export async function softNavigate(href: string, options: SoftNavigateOptions =
5547
5605
  if (!options.pop && departingBfcacheId && departingBfcacheId === historyBfcacheId()) {
5548
5606
  restoreFormStateWhenMounted(departingBfcacheId, sequence, true)
5549
5607
  }
5608
+ if (
5609
+ typeof __PNEXT_NEXT_ROUTER__ === 'undefined' &&
5610
+ keptCopies.has(page) &&
5611
+ !documentStaticHintFromHtml(page.html)?.isStatic
5612
+ )
5613
+ void revalidateCommittedPage(targetUrl, sequence, page)
5614
+ }
5615
+
5616
+ async function revalidateCommittedPage(url: URL, sequence: number, page: PrefetchedPage) {
5617
+ const fresh = await fetchPage(url.href, { fullRender: true }).catch(() => null)
5618
+ if (!fresh?.ok || fresh.html === page.html || sequence !== navigationSequence) return
5619
+ await softNavigate(url.href, {
5620
+ replace: true,
5621
+ scroll: false,
5622
+ refreshLike: true,
5623
+ cachedPage: fresh,
5624
+ })
5550
5625
  }
5551
5626
 
5552
5627
  function isBotUserAgent() {
@@ -6103,5 +6178,7 @@ export function installRouterFull() {
6103
6178
  )
6104
6179
  window.addEventListener('popstate', onPopState)
6105
6180
  window.addEventListener('pageshow', onPageShow)
6181
+ if (typeof __PNEXT_NEXT_ROUTER__ === 'undefined' && !navigationScrollAction)
6182
+ setNavigationScrollAction(coreNavigationScroll)
6106
6183
  watchEagerPrefetchLinks()
6107
6184
  }
@@ -387,6 +387,8 @@ export function registerBundlerExtensions(config: ResolvedConfig): void {
387
387
  // the defaults + resolver from every client bundle.
388
388
  __PNEXT_IMAGE_CONFIG_INLINE__: JSON.stringify(getImagesConfig()),
389
389
  __PNEXT_IMAGE_CONFIG_INLINED__: 'true',
390
+ // Next's router policies replace core's scroll and refresh ones, so client bundles drop those.
391
+ ...(nextCompatEnabled(config) ? { __PNEXT_NEXT_ROUTER__: 'true' } : {}),
390
392
  // publicRuntimeConfig inlined the same way; omitted entirely when unset so an app that
391
393
  // never configures it adds zero bytes (unlike images, there is no default to drop).
392
394
  ...(Object.keys(publicRuntimeConfig()).length > 0
@@ -7001,7 +7001,11 @@ function unwrapUnusedPageSlot(html: string, mode: PageSlotMarkerMode): string {
7001
7001
  return html.replace(/<!--pnext-page:[^>]*-->/, '<!--pnext-page:-->')
7002
7002
  }
7003
7003
  const slot = pageSlotSpan(html)
7004
- return slot ? html.slice(0, slot.start) + slot.children + html.slice(slot.end) : html
7004
+ if (!slot) return html
7005
+ // `keep` leaves the empty anchors a soft navigation grafts a kept layout's next page between.
7006
+ const children =
7007
+ mode === 'keep' ? `<!--pnext-page:-->${slot.children}<!--/pnext-page-->` : slot.children
7008
+ return html.slice(0, slot.start) + children + html.slice(slot.end)
7005
7009
  }
7006
7010
 
7007
7011
  function serializeNeutralClientIslands(html: string) {