@uniweb/runtime 1.2.7 → 1.2.9

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": "@uniweb/runtime",
3
- "version": "1.2.7",
3
+ "version": "1.2.9",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=22.12"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "~1.4.10",
39
+ "@uniweb/core": "~1.4.11",
40
40
  "@uniweb/theming": "~1.2.1"
41
41
  },
42
42
  "devDependencies": {
@@ -44,7 +44,7 @@
44
44
  "esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0 || ^0.28.0",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "1.10.8"
47
+ "@uniweb/build": "1.10.10"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -6,12 +6,13 @@
6
6
  * Manages head meta tags for SEO and social sharing.
7
7
  */
8
8
 
9
- import React, { useMemo, useEffect, useReducer, useRef } from 'react'
9
+ import React, { useMemo, useEffect, useLayoutEffect, useReducer, useRef } from 'react'
10
10
  import { useLocation, useNavigate } from 'react-router-dom'
11
11
  import BlockRenderer from './BlockRenderer.jsx'
12
12
  import Layout from './Layout.jsx'
13
13
  import { useHeadMeta } from '../hooks/useHeadMeta.js'
14
14
  import { useSectionViews } from '../hooks/useSectionViews.js'
15
+ import { pageCommitted } from '../navigation.js'
15
16
  import { buildSectionOverrides } from '@uniweb/theming'
16
17
  import { carryQuery } from '@uniweb/core/resolve-route'
17
18
  import { Default404 } from '../default-404.js'
@@ -206,6 +207,10 @@ export default function PageRenderer() {
206
207
 
207
208
  useHeadMeta(headMeta, { siteName })
208
209
 
210
+ // ⭐ The DOM holds the page the router committed, and the browser has not painted it yet. A view
211
+ // transition waiting on the navigation may capture it now (`navigation.js`).
212
+ useLayoutEffect(() => { pageCommitted() }, [location])
213
+
209
214
  // For dynamic pages created before collection data was available (hard reload),
210
215
  // the page title starts as the template name (e.g. '[id]'). Subscribe to
211
216
  // DataStore updates so we re-render once the collection loads — _createDynamicPage
@@ -246,10 +251,12 @@ export default function PageRenderer() {
246
251
  )
247
252
  }
248
253
 
249
- // Wait for content before rendering blocks (split page content): reached after an in-app
250
- // navigation to a page not loaded yet, and it renders nothing until the sections arrive. Not at
251
- // boot — the page a visit starts on carries its own sections (`splitPageSections`) — and never
252
- // over the prerendered HTML: the browser's first commit replaces it (`createRoot`, index.jsx).
254
+ // Wait for content before rendering blocks (split page content); nothing renders until the
255
+ // sections arrive. An in-app navigation waits for them before it reaches here
256
+ // (`navigation.js`), so this is the fallback: content slower than that wait, or a navigation made
257
+ // around the runtime's. Not at boot — the page a visit starts on carries its own sections
258
+ // (`splitPageSections`) — and never over the prerendered HTML: the browser's first commit
259
+ // replaces it (`createRoot`, index.jsx).
253
260
  if (!contentReady) return null
254
261
 
255
262
  const appearance = website?.themeData?.appearance
@@ -15,7 +15,7 @@
15
15
  import { useEffect, useCallback } from 'react'
16
16
  import { useNavigate, useLocation } from 'react-router-dom'
17
17
  import { localeOfPath } from '@uniweb/core/resolve-route'
18
- import { prefetchContent, prefersReducedMotion } from '../setup.js'
18
+ import { navigateInApp, readsData } from '../navigation.js'
19
19
 
20
20
  /**
21
21
  * Check if a URL is internal (same origin, no external protocol)
@@ -64,7 +64,7 @@ function shouldNavigate(event) {
64
64
  * @param {EventTarget} target - The event target
65
65
  * @returns {HTMLAnchorElement|null}
66
66
  */
67
- function findAnchorElement(target) {
67
+ export function findAnchorElement(target) {
68
68
  let element = target
69
69
  while (element && element !== document.body) {
70
70
  if (element.tagName === 'A') {
@@ -75,6 +75,48 @@ function findAnchorElement(target) {
75
75
  return null
76
76
  }
77
77
 
78
+ /**
79
+ * A link the runtime may route, or null: an anchor whose href stays on this site, downloads
80
+ * nothing and opens in this tab.
81
+ *
82
+ * `routeHref` is where the browser would go, as a path: the href resolved against this page's
83
+ * URL, so a query (`?tab=2`) or a relative href (`about`) is judged like any other path. The base
84
+ * path comes off, because React Router and locale detection work with router-relative paths (a
85
+ * <Link reload> carries the base).
86
+ *
87
+ * ⛔ Until 2026-10-24 an href that was not a path counted as the default locale's, so on any
88
+ * other locale's page it was a page load: a query or a relative link reloaded the page, and left
89
+ * an editor's preview frame.
90
+ *
91
+ * @param {HTMLAnchorElement|null} anchor
92
+ * @returns {{ anchor: HTMLAnchorElement, href: string, routeHref: string, website: Object }|null}
93
+ */
94
+ export function inAppLink(anchor) {
95
+ const href = anchor?.getAttribute('href')
96
+ if (!href || !isInternalLink(href)) return null
97
+ if (anchor.hasAttribute('download')) return null
98
+ const target = anchor.getAttribute('target')
99
+ if (target && target !== '_self') return null
100
+
101
+ const website = globalThis.uniweb?.activeWebsite
102
+ const basePath = website?.basePath || ''
103
+ const resolved = new URL(href, window.location.href)
104
+ let routeHref = resolved.pathname + resolved.search + resolved.hash
105
+ if (basePath && routeHref.startsWith(basePath)) {
106
+ routeHref = routeHref.slice(basePath.length) || '/'
107
+ }
108
+ return { anchor, href, routeHref, website }
109
+ }
110
+
111
+ /**
112
+ * Whether a page of `website` can have something to fetch before it is shown: content not here
113
+ * yet, on a site whose content is split, or data, which a site declaring queries reads.
114
+ */
115
+ function hasPagesToFetch(website) {
116
+ if (readsData(website)) return true
117
+ return !!website?.pages?.some((page) => page.hasContent() && !page.isContentLoaded())
118
+ }
119
+
78
120
  /**
79
121
  * The locale and page a link goes to when it leaves the active locale, or null.
80
122
  *
@@ -149,40 +191,9 @@ export function useLinkInterceptor(options = {}) {
149
191
  // Check if we should handle this click
150
192
  if (!shouldNavigate(event)) return
151
193
 
152
- // Find the anchor element
153
- const anchor = findAnchorElement(event.target)
154
- if (!anchor) return
155
-
156
- // Get the href
157
- const href = anchor.getAttribute('href')
158
- if (!href) return
159
-
160
- // Check if it's an internal link
161
- if (!isInternalLink(href)) return
162
-
163
- // Check for download attribute
164
- if (anchor.hasAttribute('download')) return
165
-
166
- // Check for target="_blank" or other non-self targets
167
- const target = anchor.getAttribute('target')
168
- if (target && target !== '_self') return
169
-
170
- const website = globalThis.uniweb?.activeWebsite
171
- const basePath = website?.basePath || ''
172
-
173
- // Where the browser would go, as a path: the href resolved against this page's URL, so
174
- // a query (`?tab=2`) or a relative href (`about`) is judged like any other path. The
175
- // base path comes off, because React Router and locale detection work with
176
- // router-relative paths (a <Link reload> carries the base).
177
- //
178
- // ⛔ Until 2026-10-24 an href that was not a path counted as the default locale's, so
179
- // on any other locale's page it was a page load: a query or a relative link reloaded
180
- // the page, and left an editor's preview frame.
181
- const resolved = new URL(href, window.location.href)
182
- let routeHref = resolved.pathname + resolved.search + resolved.hash
183
- if (basePath && routeHref.startsWith(basePath)) {
184
- routeHref = routeHref.slice(basePath.length) || '/'
185
- }
194
+ const link = inAppLink(findAnchorElement(event.target))
195
+ if (!link) return
196
+ const { anchor, href, routeHref, website } = link
186
197
 
187
198
  // A link to another locale's page is a page load, since that locale is its own document,
188
199
  // and the browser makes it. A host that switches locale its own way (`uniweb.localeSwitch`:
@@ -214,27 +225,33 @@ export function useLinkInterceptor(options = {}) {
214
225
  return
215
226
  }
216
227
 
217
- // Use React Router navigation (with router-relative path)
218
- // React Router will handle the path, and our useEffect above
219
- // will handle scrolling to hash after navigation completes
220
- const useTransition = website?.viewTransitions && document.startViewTransition
221
- && !prefersReducedMotion
222
-
223
- if (useTransition) {
224
- document.startViewTransition(async () => {
225
- await prefetchContent(routeHref)
226
- navigate(routeHref)
227
- })
228
- } else {
229
- navigate(routeHref)
230
- }
228
+ // React Router takes the router-relative path, and the effect above scrolls to a fragment
229
+ // once the page is there. Like a <Link>, the click holds this page until the next one's
230
+ // content has arrived, and the swap is animated when view transitions apply (`navigation.js`).
231
+ navigateInApp(navigate, routeHref)
231
232
  }
232
233
 
233
- // Add click listener to document
234
234
  document.addEventListener('click', handleClick)
235
235
 
236
+ // ⭐ A page is fetched while the reader is about to follow a link to it (`link-intent.js`),
237
+ // on a site with pages still to fetch. A site whose pages all came with it never downloads
238
+ // that module. `cancelled` covers an effect torn down before the module arrives.
239
+ let cancelled = false
240
+ let stopIntent = null
241
+ if (hasPagesToFetch(globalThis.uniweb?.activeWebsite)) {
242
+ import('../link-intent.js')
243
+ .then((m) => {
244
+ if (!cancelled) stopIntent = m.watchLinkIntent()
245
+ })
246
+ .catch(() => {
247
+ // Without it, a click still holds the page until its content arrives.
248
+ })
249
+ }
250
+
236
251
  return () => {
237
252
  document.removeEventListener('click', handleClick)
253
+ cancelled = true
254
+ if (stopIntent) stopIntent()
238
255
  }
239
256
  }, [enabled, navigate])
240
257
  }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Prefetch on intent: a link's page is fetched while the reader is about to follow it.
3
+ *
4
+ * Only a site with something to fetch before a page is shown loads this module
5
+ * (`useLinkInterceptor`): split content not here yet, or data, which a site declaring queries
6
+ * reads. A site with neither never downloads it, so the runtime every site carries pays only for
7
+ * the decision.
8
+ *
9
+ * A pointer resting on a link, or focus staying on one, for INTENT_DELAY fetches what the page
10
+ * the link goes to will read: its split content, then its sections' data, whatever is not held
11
+ * already. A press fetches it at once, since a touch has no hover. The click that follows then
12
+ * usually finds it all here, and the page changes without waiting (`navigation.js`). A pointer
13
+ * sweeping across a rail of links fetches nothing, and nothing is fetched for a connection that
14
+ * asks to save data.
15
+ */
16
+
17
+ import { prefetchPage } from './navigation.js'
18
+ import { findAnchorElement, inAppLink, localeSwitchOf } from './hooks/useLinkInterceptor.js'
19
+
20
+ /** How long a pointer rests on a link, or focus stays on it, before its page is fetched. */
21
+ const INTENT_DELAY = 65
22
+
23
+ /**
24
+ * Fetch what the page a link goes to will read, when a click on it would navigate in the app: not
25
+ * a fragment on this page, a `<Link reload>`, or a page in another locale.
26
+ *
27
+ * @param {HTMLAnchorElement} anchor
28
+ */
29
+ function prefetchFor(anchor) {
30
+ const link = inAppLink(anchor)
31
+ if (!link || link.href.startsWith('#') || anchor.hasAttribute('data-reload')) return
32
+ if (localeSwitchOf(link.routeHref, link.website)) return
33
+ prefetchPage(link.routeHref)
34
+ }
35
+
36
+ /**
37
+ * Fetch a link's page whenever a reader is about to follow it.
38
+ *
39
+ * @returns {() => void} stops watching
40
+ */
41
+ export function watchLinkIntent() {
42
+ if (globalThis.navigator?.connection?.saveData) return () => {}
43
+
44
+ let intent = null
45
+
46
+ const rest = (event) => {
47
+ const anchor = findAnchorElement(event.target)
48
+ if (!anchor || anchor === intent?.anchor) return
49
+ clearTimeout(intent?.timer)
50
+ intent = { anchor, timer: setTimeout(() => prefetchFor(anchor), INTENT_DELAY) }
51
+ }
52
+
53
+ const leave = (event) => {
54
+ if (!intent || intent.anchor.contains(event.relatedTarget)) return
55
+ clearTimeout(intent.timer)
56
+ intent = null
57
+ }
58
+
59
+ const press = (event) => {
60
+ const anchor = findAnchorElement(event.target)
61
+ if (anchor) prefetchFor(anchor)
62
+ }
63
+
64
+ const listeners = [
65
+ ['pointerover', rest],
66
+ ['focusin', rest],
67
+ ['pointerout', leave],
68
+ ['focusout', leave],
69
+ ['pointerdown', press],
70
+ ]
71
+ for (const [type, listener] of listeners) document.addEventListener(type, listener)
72
+
73
+ return () => {
74
+ for (const [type, listener] of listeners) document.removeEventListener(type, listener)
75
+ clearTimeout(intent?.timer)
76
+ }
77
+ }
@@ -27,26 +27,7 @@ import { resolveRequestedPage } from './resolve-page.js'
27
27
  import { getComponentMeta } from './prepare-props.js'
28
28
  import { createDefaultFetcher } from './default-fetcher.js'
29
29
  import { requireTransport } from './require-transport.js'
30
-
31
- /**
32
- * Every block the page's render can reach: its body and the layout areas it uses (the runtime's own
33
- * layout choice — the page's `layout:`, else the foundation's default, else the site's default set,
34
- * minus any area the page hides), plus what those blocks hold.
35
- */
36
- function blocksOf(page) {
37
- const out = []
38
- const walk = (list) => {
39
- for (const block of list || []) {
40
- if (!block) continue
41
- out.push(block)
42
- walk(block.childBlocks)
43
- walk(block.insets)
44
- }
45
- }
46
- walk(page.getBodyBlocks?.() ?? page.bodyBlocks)
47
- for (const area of Object.values(page.getLayoutAreas?.() ?? {})) walk(area)
48
- return out
49
- }
30
+ import { blocksOf } from './page-blocks.js'
50
31
 
51
32
  /**
52
33
  * Fetch what one route's render will read.
@@ -136,7 +117,11 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
136
117
  const answered = new DataStore()
137
118
 
138
119
  const recording = {
139
- peek: (request, ctx) => dispatcher.peek(request, ctx),
120
+ // ⭐ What the plan uses without asking (`keyProgram`'s held steps) is what THIS call's own
121
+ // requests answered, as `peekRecord` is: the page carries every answer its render reads, so
122
+ // it may use nothing it did not ask for itself — not another page's answer in a cache the
123
+ // static build shares.
124
+ peek: (request) => answered.get(deriveCacheKey(request)),
140
125
  peekRecord: (id) => answered.getRecord(id),
141
126
  dispatch: async (request, ctx) => {
142
127
  if (prerender === 'author' && request?.prerender === false) {
@@ -0,0 +1,229 @@
1
+ /**
2
+ * In-app navigation: what the runtime does between a click and the next page.
3
+ *
4
+ * Every navigation the runtime makes goes through `navigateInApp`. That covers kit's `<Link>` and
5
+ * `useNavigate()` (the routing components `setup.js` registers) and a link in prose
6
+ * (`useLinkInterceptor`). It is one function so the two paths cannot decide differently again.
7
+ *
8
+ * ⭐ **The current page is held until the next one can be shown, content and data.** On a site
9
+ * whose content is split, a page not visited yet has no sections until its content file arrives,
10
+ * and `PageRenderer` renders nothing for its body until then. A section that reads data asks for it
11
+ * only once it has rendered (`BlockRenderer`), and shows its loading state until the answer arrives.
12
+ * So both are asked first, while the current page stays on screen and usable:
13
+ * - the content;
14
+ * - then, its sections known, what they will read, by the plan their render runs (`EntityStore`).
15
+ *
16
+ * Only what is not held is asked. A block the cache answers already is passed over, and within a
17
+ * block the dispatcher answers what it holds and joins what is in flight. The router is told once
18
+ * the content and data are here, or after `HOLD_TIMEOUT`. That holds for every visitor: it depends
19
+ * on neither the View Transitions API nor a motion preference.
20
+ *
21
+ * ⭐ **The animation is a layer on the swap.** The swap runs inside
22
+ * `document.startViewTransition` when four things hold: view transitions are on
23
+ * (`website.viewTransitions`, on unless the foundation says `false`), the browser has the API,
24
+ * the visitor has no reduced-motion preference, and the next page's content is here. The update
25
+ * callback resolves once the router has committed the new page (`nextPageCommit`). React Router
26
+ * commits a navigation inside `React.startTransition`, after `navigate()` has returned. A callback
27
+ * that resolved when `navigate()` did had the browser capture the old page as the new one.
28
+ *
29
+ * ⛔ **Until 2026-10-27 the two paths decided differently**, and neither held the page right:
30
+ * - `setup.js` read `foundationConfig.viewTransitions` as an opt-in, which
31
+ * `Website#viewTransitions` stopped being on 2026-04-11. So a `<Link>` neither animated nor
32
+ * waited for the content, and on a split site its body went blank until the content arrived
33
+ * (measured on two docs sites: 160–330 ms).
34
+ * - A prose link waited inside the transition, with the page frozen, and its callback resolved
35
+ * before the commit, so the crossfade went from the old page to itself.
36
+ * - A route carrying a query or a fragment found no page to wait for (`Website#getPage` takes a
37
+ * path).
38
+ * - A reduced-motion preference skipped the wait along with the animation.
39
+ *
40
+ * Later that day the hold took in the page's data. Until then the page was held for its content
41
+ * alone, and a section reading data showed its loading state after the swap. Measured on a site's
42
+ * blog: its content was in at 331 ms, its data was asked only after the render, and the list
43
+ * showed at 582 ms.
44
+ */
45
+
46
+ import { getComponentMeta } from './prepare-props.js'
47
+ import { blocksOf } from './page-blocks.js'
48
+
49
+ /** How long a navigation holds the current page for the next one's content and data. */
50
+ const HOLD_TIMEOUT = 1000
51
+
52
+ /**
53
+ * How long a view transition waits for the router to commit. A navigation that commits nothing
54
+ * then cannot hold the frozen page until the browser gives up on it.
55
+ */
56
+ const COMMIT_TIMEOUT = 1000
57
+
58
+ /** Read once, when the runtime loads. */
59
+ export const prefersReducedMotion =
60
+ typeof window !== 'undefined'
61
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches
62
+
63
+ /**
64
+ * The page a navigation target names, found by its path: `Website#getPage` takes no query or
65
+ * fragment, and a docs link names a fragment as often as not.
66
+ *
67
+ * @param {string|Object} to - a router path, or `navigate`'s `{ pathname }`
68
+ * @returns {import('@uniweb/core').Page|null}
69
+ */
70
+ function pageAt(to) {
71
+ const website = globalThis.uniweb?.activeWebsite
72
+ const route = typeof to === 'string' ? to : to?.pathname
73
+ if (!route || !website) return null
74
+ return website.getPage(route.split(/[?#]/)[0]) || null
75
+ }
76
+
77
+ /** Whether `page`'s content can be shown now: `PageRenderer`'s own test. */
78
+ function isReady(page) {
79
+ return !page || !page.hasContent() || page.isContentLoaded()
80
+ }
81
+
82
+ /**
83
+ * Whether the site declares a query. Data comes from a named query (`config.queries`), so a site
84
+ * that declares none reads none, and its pages are not walked for it.
85
+ *
86
+ * @param {Object|null|undefined} website
87
+ * @returns {boolean}
88
+ */
89
+ export function readsData(website) {
90
+ const queries = website?.config?.queries
91
+ return !!queries && typeof queries === 'object' && Object.keys(queries).length > 0
92
+ }
93
+
94
+ /**
95
+ * Ask what `page`'s render will read and the cache does not hold: off a parametric page, the
96
+ * page's own record, which names it; and each block's data, by the same call the renderer makes
97
+ * after mount (`EntityStore.fetch`).
98
+ *
99
+ * @param {Object} page - its content here
100
+ * @returns {Promise|undefined} undefined when nothing was asked
101
+ */
102
+ function dataOf(page) {
103
+ const website = page.website
104
+ const store = website?.entityStore
105
+ if (!store || !readsData(website)) return
106
+ const asks = []
107
+ if (page.dynamicContext) asks.push(store.fetchPageRecord(page))
108
+ for (const block of blocksOf(page)) {
109
+ const meta = getComponentMeta(block.type)
110
+ if (store.resolve(block, meta).status === 'pending') asks.push(store.fetch(block, meta))
111
+ }
112
+ return asks.length ? Promise.all(asks) : undefined
113
+ }
114
+
115
+ /**
116
+ * The wait for what `page`'s render reads and is not here yet: its split content, then, its
117
+ * sections known, their data.
118
+ *
119
+ * @param {Object|null} page
120
+ * @returns {Promise|undefined} undefined when it is all here
121
+ */
122
+ function loadPage(page) {
123
+ if (!page) return
124
+ if (!isReady(page)) return page.loadContent().then(() => dataOf(page))
125
+ return dataOf(page)
126
+ }
127
+
128
+ /** `wait`, or the end of HOLD_TIMEOUT, whichever comes first. */
129
+ function bounded(wait) {
130
+ return Promise.race([wait, new Promise((resolve) => setTimeout(resolve, HOLD_TIMEOUT))])
131
+ }
132
+
133
+ /**
134
+ * Fetch the split content of the page `route` names, giving up after HOLD_TIMEOUT. Undefined when
135
+ * there is nothing to fetch: no such page, a page with no content, or one already loaded.
136
+ *
137
+ * @param {string} route - a router path; a query or fragment is ignored
138
+ * @returns {Promise|undefined}
139
+ */
140
+ export function prefetchContent(route) {
141
+ const page = pageAt(route)
142
+ if (isReady(page)) return
143
+ return bounded(page.loadContent())
144
+ }
145
+
146
+ /**
147
+ * Fetch what the page `route` names will read when it is shown, its split content and then its
148
+ * data, ahead of a navigation to it (`link-intent.js`). Nothing waits on it: what arrives is held
149
+ * for the render, and a request that fails is the navigation's to make again.
150
+ *
151
+ * @param {string} route - a router path; a query or fragment is ignored
152
+ * @returns {Promise|undefined} undefined when it is all here
153
+ */
154
+ export function prefetchPage(route) {
155
+ return loadPage(pageAt(route))?.catch(() => {})
156
+ }
157
+
158
+ // The navigation the next swap belongs to. A later navigation supersedes one still waiting for
159
+ // its page, so a slow page reached by an earlier click cannot replace the page clicked last.
160
+ let latest = 0
161
+
162
+ /**
163
+ * Navigate in the app. The current page is held until the next one's content and data are here,
164
+ * then the pages are swapped, animated when view transitions apply.
165
+ *
166
+ * @param {Function} navigate - React Router's `navigate`
167
+ * @param {string|Object|number} to - where to, as `navigate` takes it
168
+ * @param {Object} [options] - `navigate`'s options
169
+ */
170
+ export function navigateInApp(navigate, to, options) {
171
+ const ticket = ++latest
172
+ const page = pageAt(to)
173
+
174
+ const swap = () => {
175
+ if (ticket !== latest) return
176
+ const website = globalThis.uniweb?.activeWebsite
177
+ // A page whose content is still not here after the wait goes without the animation: a
178
+ // crossfade to an empty body would only draw out the wait.
179
+ const animate = isReady(page) && website?.viewTransitions
180
+ && typeof document !== 'undefined' && document.startViewTransition && !prefersReducedMotion
181
+ if (!animate) {
182
+ navigate(to, options)
183
+ return
184
+ }
185
+ const transition = document.startViewTransition(() => {
186
+ // Asked before navigating: a router that commits synchronously settles it from inside `navigate`.
187
+ const committed = nextPageCommit()
188
+ navigate(to, options)
189
+ return committed
190
+ })
191
+ // A transition the browser skips, for a duplicate `view-transition-name` or a newer
192
+ // transition, still runs its callback, so the page is swapped all the same. The browser
193
+ // reports the reason itself.
194
+ transition.ready.catch(() => {})
195
+ }
196
+
197
+ const pending = loadPage(page)
198
+ // A request that fails still navigates: the page then shows what it has, as it renders it.
199
+ if (pending) bounded(pending).then(swap, swap)
200
+ else swap()
201
+ }
202
+
203
+ const commitWaiters = new Set()
204
+
205
+ /**
206
+ * A promise settled when `PageRenderer` next commits a page, or after COMMIT_TIMEOUT.
207
+ *
208
+ * @returns {Promise<void>}
209
+ */
210
+ function nextPageCommit() {
211
+ return new Promise((resolve) => {
212
+ const settle = () => {
213
+ clearTimeout(timer)
214
+ commitWaiters.delete(settle)
215
+ resolve()
216
+ }
217
+ const timer = setTimeout(settle, COMMIT_TIMEOUT)
218
+ commitWaiters.add(settle)
219
+ })
220
+ }
221
+
222
+ /**
223
+ * Called by `PageRenderer` from a layout effect, once the DOM holds the page the router
224
+ * committed and before the browser paints it. That is the moment a view transition may capture
225
+ * it. It has no SSR twin, since the prerender makes no navigation.
226
+ */
227
+ export function pageCommitted() {
228
+ for (const settle of [...commitWaiters]) settle()
229
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Every block a page's render can reach: its body and the layout areas it uses (the runtime's own
3
+ * layout choice — the page's `layout:`, else the foundation's default, else the site's default set,
4
+ * minus any area the page hides), plus what those blocks hold.
5
+ *
6
+ * ⭐ One walk for the two that ask for a page's data before its render: a host's data step
7
+ * (`load-page-data.js`) and the browser before it shows a page it navigates to (`navigation.js`).
8
+ * So the two cannot disagree about which blocks a page reaches.
9
+ *
10
+ * @param {Object} page - a Page
11
+ * @returns {Object[]} its blocks, each one once
12
+ */
13
+ export function blocksOf(page) {
14
+ const out = []
15
+ const walk = (list) => {
16
+ for (const block of list || []) {
17
+ if (!block) continue
18
+ out.push(block)
19
+ walk(block.childBlocks)
20
+ walk(block.insets)
21
+ }
22
+ }
23
+ walk(page.getBodyBlocks?.() ?? page.bodyBlocks)
24
+ for (const area of Object.values(page.getLayoutAreas?.() ?? {})) walk(area)
25
+ return out
26
+ }