@uniweb/runtime 1.2.8 → 1.2.10

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.8",
3
+ "version": "1.2.10",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,15 +36,15 @@
36
36
  "node": ">=22.12"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "~1.4.10",
40
- "@uniweb/theming": "~1.2.1"
39
+ "@uniweb/theming": "~1.2.1",
40
+ "@uniweb/core": "~1.4.11"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@vitejs/plugin-react": "^4.5.2",
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",
@@ -0,0 +1,23 @@
1
+ import { FONT_LINKS_MARKER } from '@uniweb/theming'
2
+
3
+ /**
4
+ * Give a page the theme's font links when it arrived without them.
5
+ *
6
+ * A built page carries them in its `<head>` (`@uniweb/build`), and so does a prerendered
7
+ * one (`injectPageContent` in `ssr-renderer.js`). A host that serves the shell alone and
8
+ * leaves every page to the browser carries neither. `ensureThemeCss` computes the links
9
+ * onto the graph for such a lane, but nothing put them on the page: the browser added the
10
+ * theme's CSS (`ThemeProvider`) and never its fonts, so every family the theme imports
11
+ * fell back to a system face. This is the browser's half of the prerender's font block,
12
+ * deduped on the same marker, so a page that already has them is left alone.
13
+ *
14
+ * @param {object} themeData - `website.themeData`, with the `links` block `@uniweb/theming` generated
15
+ * @param {Document|null} [doc]
16
+ * @returns {boolean} whether it added them
17
+ */
18
+ export function injectFontLinks(themeData, doc = typeof document === 'undefined' ? null : document) {
19
+ const head = doc?.head
20
+ if (!head || !themeData?.links || head.innerHTML.includes(FONT_LINKS_MARKER)) return false
21
+ head.insertAdjacentHTML('beforeend', `${FONT_LINKS_MARKER}\n${themeData.links}`)
22
+ return true
23
+ }
@@ -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 { navigateInApp } from '../navigation.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)
@@ -108,8 +108,12 @@ export function inAppLink(anchor) {
108
108
  return { anchor, href, routeHref, website }
109
109
  }
110
110
 
111
- /** Whether a page of `website` has content not fetched yet: a site whose content is split. */
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
+ */
112
115
  function hasPagesToFetch(website) {
116
+ if (readsData(website)) return true
113
117
  return !!website?.pages?.some((page) => page.hasContent() && !page.isContentLoaded())
114
118
  }
115
119
 
@@ -1,26 +1,28 @@
1
1
  /**
2
2
  * Prefetch on intent: a link's page is fetched while the reader is about to follow it.
3
3
  *
4
- * Only a site whose content is split loads this module (`useLinkInterceptor`). A site whose
5
- * pages all arrived with it never downloads it, so the runtime every site carries pays only for
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
6
7
  * the decision.
7
8
  *
8
- * A pointer resting on a link, or focus staying on one, for INTENT_DELAY fetches the split
9
- * content of the page the link goes to. A press fetches it at once, since a touch has no hover.
10
- * The click that follows then usually finds the content here, and the page changes without
11
- * waiting (`navigation.js`). A pointer sweeping across a rail of links fetches nothing, and
12
- * nothing is fetched for a connection that asks to save data.
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.
13
15
  */
14
16
 
15
- import { prefetchContent } from './navigation.js'
17
+ import { prefetchPage } from './navigation.js'
16
18
  import { findAnchorElement, inAppLink, localeSwitchOf } from './hooks/useLinkInterceptor.js'
17
19
 
18
20
  /** How long a pointer rests on a link, or focus stays on it, before its page is fetched. */
19
21
  const INTENT_DELAY = 65
20
22
 
21
23
  /**
22
- * Fetch the split content of the page a link goes to, when a click on it would navigate in the
23
- * app: not a fragment on this page, a `<Link reload>`, or a page in another locale.
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.
24
26
  *
25
27
  * @param {HTMLAnchorElement} anchor
26
28
  */
@@ -28,7 +30,7 @@ function prefetchFor(anchor) {
28
30
  const link = inAppLink(anchor)
29
31
  if (!link || link.href.startsWith('#') || anchor.hasAttribute('data-reload')) return
30
32
  if (localeSwitchOf(link.routeHref, link.website)) return
31
- prefetchContent(link.routeHref)
33
+ prefetchPage(link.routeHref)
32
34
  }
33
35
 
34
36
  /**
@@ -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) {
package/src/navigation.js CHANGED
@@ -5,20 +5,26 @@
5
5
  * `useNavigate()` (the routing components `setup.js` registers) and a link in prose
6
6
  * (`useLinkInterceptor`). It is one function so the two paths cannot decide differently again.
7
7
  *
8
- * ⭐ **The current page is held until the next one can be shown.** On a site whose content is
9
- * split, a page not visited yet has no sections until its content file arrives, and
10
- * `PageRenderer` renders nothing for its body until then. So the content is fetched first, while
11
- * the current page stays on screen and usable. The router is told once the content has arrived,
12
- * or after `CONTENT_PREFETCH_TIMEOUT`. That holds for every visitor: it depends on neither the
13
- * View Transitions API nor a motion preference.
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.
14
20
  *
15
21
  * ⭐ **The animation is a layer on the swap.** The swap runs inside
16
22
  * `document.startViewTransition` when four things hold: view transitions are on
17
23
  * (`website.viewTransitions`, on unless the foundation says `false`), the browser has the API,
18
- * the visitor has no reduced-motion preference, and the next page is ready. The update callback
19
- * resolves once the router has committed the new page (`nextPageCommit`). React Router commits a
20
- * navigation inside `React.startTransition`, after `navigate()` has returned. A callback that
21
- * resolved when `navigate()` did had the browser capture the old page as the new one.
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.
22
28
  *
23
29
  * ⛔ **Until 2026-10-27 the two paths decided differently**, and neither held the page right:
24
30
  * - `setup.js` read `foundationConfig.viewTransitions` as an opt-in, which
@@ -30,10 +36,18 @@
30
36
  * - A route carrying a query or a fragment found no page to wait for (`Website#getPage` takes a
31
37
  * path).
32
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.
33
44
  */
34
45
 
35
- /** How long a navigation waits for the next page's content before it goes anyway. */
36
- const CONTENT_PREFETCH_TIMEOUT = 1000
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
37
51
 
38
52
  /**
39
53
  * How long a view transition waits for the router to commit. A navigation that commits nothing
@@ -60,39 +74,94 @@ function pageAt(to) {
60
74
  return website.getPage(route.split(/[?#]/)[0]) || null
61
75
  }
62
76
 
63
- /** Whether `page` can be shown now: `PageRenderer`'s own test. */
77
+ /** Whether `page`'s content can be shown now: `PageRenderer`'s own test. */
64
78
  function isReady(page) {
65
79
  return !page || !page.hasContent() || page.isContentLoaded()
66
80
  }
67
81
 
68
- /** The wait for `page`'s split content, bounded by CONTENT_PREFETCH_TIMEOUT; undefined when there is none. */
69
- function contentOf(page) {
70
- if (isReady(page)) return
71
- return Promise.race([
72
- page.loadContent(),
73
- new Promise((resolve) => setTimeout(resolve, CONTENT_PREFETCH_TIMEOUT)),
74
- ])
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
75
92
  }
76
93
 
77
94
  /**
78
- * Fetch the split content of the page `route` names, giving up after CONTENT_PREFETCH_TIMEOUT.
79
- * Undefined when there is nothing to fetch: no such page, a page with no content, or one already
80
- * loaded.
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.
81
136
  *
82
137
  * @param {string} route - a router path; a query or fragment is ignored
83
138
  * @returns {Promise|undefined}
84
139
  */
85
140
  export function prefetchContent(route) {
86
- return contentOf(pageAt(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(() => {})
87
156
  }
88
157
 
89
158
  // The navigation the next swap belongs to. A later navigation supersedes one still waiting for
90
- // its content, so a slow page reached by an earlier click cannot replace the page clicked last.
159
+ // its page, so a slow page reached by an earlier click cannot replace the page clicked last.
91
160
  let latest = 0
92
161
 
93
162
  /**
94
- * Navigate in the app. The current page is held until the next one's content is here, then the
95
- * pages are swapped, animated when view transitions apply.
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.
96
165
  *
97
166
  * @param {Function} navigate - React Router's `navigate`
98
167
  * @param {string|Object|number} to - where to, as `navigate` takes it
@@ -105,8 +174,8 @@ export function navigateInApp(navigate, to, options) {
105
174
  const swap = () => {
106
175
  if (ticket !== latest) return
107
176
  const website = globalThis.uniweb?.activeWebsite
108
- // A page still not here after the wait goes without the animation: a crossfade to an empty
109
- // body would only draw out the wait.
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.
110
179
  const animate = isReady(page) && website?.viewTransitions
111
180
  && typeof document !== 'undefined' && document.startViewTransition && !prefersReducedMotion
112
181
  if (!animate) {
@@ -125,9 +194,9 @@ export function navigateInApp(navigate, to, options) {
125
194
  transition.ready.catch(() => {})
126
195
  }
127
196
 
128
- const pending = contentOf(page)
129
- // A fetch that fails still navigates: the page then shows what its content answered.
130
- if (pending) pending.then(swap, swap)
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)
131
200
  else swap()
132
201
  }
133
202
 
@@ -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
+ }
package/src/setup.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  import { ChildBlocks } from './components/PageRenderer.jsx'
32
32
  import Background from './components/Background.jsx'
33
33
  import { wireFoundationCapabilities, hydrateDataStore, ensureThemeCss } from './wire-foundation.js'
34
+ import { injectFontLinks } from './font-links.js'
34
35
  import { navigateInApp } from './navigation.js'
35
36
 
36
37
  // Re-export so existing consumers importing hydrateDataStore from
@@ -274,6 +275,9 @@ export function initUniweb({ content, foundation, extensions = [], routingCompon
274
275
  if (typeof document === 'undefined' || !document.getElementById('uniweb-theme')) {
275
276
  ensureThemeCss(uniweb, foundation)
276
277
  }
278
+ // And the theme's font links, which the CSS alone does not bring: a page that came
279
+ // with neither would otherwise set every imported family in a system face.
280
+ injectFontLinks(uniweb.activeWebsite?.themeData)
277
281
 
278
282
  uniweb.routingComponents = routingComponents
279
283
  uniweb.localeSwitch = localeSwitch