@uniweb/runtime 1.2.1 → 1.2.3

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.1",
3
+ "version": "1.2.3",
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.6",
39
+ "@uniweb/core": "~1.4.7",
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.9.0"
47
+ "@uniweb/build": "1.10.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -68,7 +68,8 @@ function blocksOf(page) {
68
68
  * page asks for the site's and its parent's data and reading each address once is the difference
69
69
  * between N reads and N×M. ⛔ Not for a host serving requests: there a page's data is asked
70
70
  * fresh, and the default (a cache per call) is what does that. A shared cache changes nothing
71
- * about what is ASKED or returned — every request is still recorded, hit or miss.
71
+ * about what is ASKED or returned — every request is still recorded, hit or miss, and whether a
72
+ * record is held whole already is answered from this call's own requests, never the cache's.
72
73
  * @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: *, meta?: Object, error?: string }>>}
73
74
  * one entry per request the render will make, keyed downstream by `deriveCacheKey(config)`
74
75
  */
@@ -87,6 +88,11 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
87
88
  '(`initPrerenderForLocale`) rather than asking this step for another language.'
88
89
  )
89
90
  }
91
+ // ⭐ This graph is the one `globalThis.uniweb` names while the step reads it, which it does here,
92
+ // before its first `await`: each block's component meta and the layout the page draws come
93
+ // through the global, and a host holding several graphs in one isolate has it naming the newest
94
+ // (`Website#makeCurrent`).
95
+ website.makeCurrent()
90
96
  const resolved = typeof route === 'string' ? resolvePage(website, route) : route
91
97
  // Nothing matched: a genuine 404, and the caller's to turn into one. Nothing to fetch.
92
98
  if (!resolved) return []
@@ -117,9 +123,16 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
117
123
  if (!entries.has(key)) entries.set(key, entry)
118
124
  }
119
125
 
126
+ // ⭐ WHAT THIS CALL'S OWN REQUESTS ANSWERED — the record index the plan asks "held whole
127
+ // already?" before it asks for a record (R1). ⛔ Not the cache's: a shared one holds the records
128
+ // other pages fetched, and a record found there was never asked for, so never recorded, and the
129
+ // page carried nothing for it (2026-10-25). With a cache of this call's own, the two hold the
130
+ // same records.
131
+ const answered = new DataStore()
132
+
120
133
  const recording = {
121
134
  peek: (request, ctx) => dispatcher.peek(request, ctx),
122
- peekRecord: (id) => dispatcher.peekRecord(id),
135
+ peekRecord: (id) => answered.getRecord(id),
123
136
  dispatch: async (request, ctx) => {
124
137
  if (prerender === 'author' && request?.prerender === false) {
125
138
  // The author deferred this one to the browser and the caller honours that. Present, so a
@@ -128,6 +141,9 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
128
141
  return { data: null }
129
142
  }
130
143
  const result = await dispatcher.dispatch(request, ctx)
144
+ if (!result?.error && result?.data != null) {
145
+ answered.set(deriveCacheKey(request), result.meta ? { data: result.data, meta: result.meta } : { data: result.data })
146
+ }
131
147
  record(request, result?.error
132
148
  ? { config: request, outcome: 'failed', data: null, error: result.error }
133
149
  : { config: request, outcome: 'fetched', data: result?.data ?? null, ...(result?.meta ? { meta: result.meta } : {}) })
@@ -21,13 +21,15 @@
21
21
  * cache headers are host layout, and a runtime that assembled them would be
22
22
  * modelling a deployment it cannot see (the host drew this line itself,
23
23
  * 2026-09-03: a serve location is read from the payload, never constructed here).
24
- * - **Init and hydration.** The two lanes differ REALLY here, not incidentally: a
25
- * build initializes once per locale, hydrates its executor's answers up front and
26
- * then runs the data step for each page over one cache for the whole run; an
27
- * isolate initializes per locale and runs the step fresh for each request. Folding
28
- * either in would fit one caller and lie to the other. `initPrerender` /
29
- * `initPrerenderForLocale` / `loadPageData` / `hydrateDataStore` stay separate
30
- * exports, and `prefetchAndHydrate` below is the isolate's two-step, not a general one.
24
+ * - **Init and the data step.** The two lanes differ REALLY here, not incidentally: a
25
+ * build initializes once per locale and runs the data step for each page over one
26
+ * cache for the whole run; an isolate initializes per locale and runs the step fresh
27
+ * for each request. Folding either in would fit one caller and lie to the other.
28
+ * `initPrerender` / `initPrerenderForLocale` / `loadPageData` / `hydrateDataStore`
29
+ * stay separate exports, and `prefetchAndHydrate` below is the isolate's two-step,
30
+ * not a general one. ⭐ What both lanes DO share is the last step: the render reads
31
+ * exactly the entries its page carries. That is `render`'s `fetchedData`, since
32
+ * 2026-10-25, and both lanes pass it.
31
33
  * - **Anything build-only.** `injectBuildData` stays in the build lane; it is the
32
34
  * other half of the head seam and has its own parity guard.
33
35
  */
@@ -65,6 +67,12 @@ export function createPageRenderer({ website, shell }) {
65
67
  * Page, which the build lane already holds from its own loop.
66
68
  * @param {Object} [options]
67
69
  * @param {Object} [options.inject] - extra options forwarded to `injectPageContent`
70
+ * @param {Array<{config: Object, data: *, meta?: Object}>} [options.fetchedData] - the entries
71
+ * the page carries: the `content.fetchedData` its HTML embeds, which the browser boots with.
72
+ * ⭐ Given, the render reads exactly these. The Website forgets what it held, answers and the
73
+ * parametric pages made from them (`Website#forgetData`), and files these, all in this
74
+ * synchronous call, so a Website reused across requests renders each against its own and
75
+ * no other request runs in between. Absent, the render reads whatever the store holds.
68
76
  * @returns {{ outcome: 'rendered'|'notFound'|'failed', html: string|null,
69
77
  * page: Object|null, error: {type: string, message: string}|null }}
70
78
  * `notFound` means the URL names nothing, and `html` says whether there is a page to
@@ -75,7 +83,24 @@ export function createPageRenderer({ website, shell }) {
75
83
  * - `page` set, `html: null`, `error` set — that not-found state broke while rendering.
76
84
  * Send your own not-found page; `error` says what broke.
77
85
  */
78
- function render(target, { inject = {} } = {}) {
86
+ function render(target, { inject = {}, fetchedData } = {}) {
87
+ if (fetchedData !== undefined && !Array.isArray(fetchedData)) {
88
+ throw new Error('createPageRenderer: `fetchedData` must be the array of entries the page carries')
89
+ }
90
+ // ⭐ FIRST, THIS GRAPH IS THE ONE `globalThis.uniweb` NAMES. A block's component, the props
91
+ // pipeline and kit in the foundation reach the site through the global, and every graph built
92
+ // since this one was points it elsewhere — a host holds several in one isolate. The render is
93
+ // synchronous, so the value set here is the one it reads throughout (`Website#makeCurrent`).
94
+ website.makeCurrent()
95
+ // ⭐ BEFORE THE ROUTE IS RESOLVED: a parametric page is made from the store when it is first
96
+ // resolved, so resolving first would make it from what an earlier request left there.
97
+ // ⛔ Until 2026-10-25 a host that reused one Website hydrated each request's answers on top of
98
+ // every earlier request's, and a section that reads the store itself (kit's `useFetched`)
99
+ // could render answers its page did not carry, so the browser's first render differed.
100
+ if (fetchedData !== undefined) {
101
+ website.forgetData()
102
+ hydrateDataStore(website, fetchedData)
103
+ }
79
104
  const resolved = typeof target === 'string' ? resolvePage(website, target) : target
80
105
 
81
106
  // ⛔ Not an error: nothing matched, which is a genuine 404 and the caller's to
@@ -445,10 +445,16 @@ export async function prefetchIcons(siteContent, uniweb, onProgress = () => {})
445
445
  const url = iconUrl(family, name, cdnBase)
446
446
  const response = await fetch(url)
447
447
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
448
- const svg = await response.text()
449
- uniweb.iconCache.set(`${family}:${name}`, svg)
448
+ return [`${family}:${name}`, await response.text()]
450
449
  })
451
450
  )
451
+ // ⭐ Cached in the order the build names the icons (`icons.used`, sorted), never the order their
452
+ // fetches settle in: the cache is written into every page (`_iconCache`, `__ICON_CACHE__`), so
453
+ // in arrival order two builds of one site wrote different pages. ⛔ Until 2026-10-25 each icon
454
+ // was cached as it arrived (measured: three builds of one site, three orders).
455
+ for (const result of results) {
456
+ if (result.status === 'fulfilled') uniweb.iconCache.set(...result.value)
457
+ }
452
458
 
453
459
  const succeeded = results.filter(r => r.status === 'fulfilled').length
454
460
  const failedRefs = icons.filter((_, i) => results[i].status === 'rejected')