@uniweb/runtime 0.26.5 → 0.26.6

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": "0.26.5",
3
+ "version": "0.26.6",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -36,7 +36,7 @@
36
36
  "node": ">=20.19"
37
37
  },
38
38
  "dependencies": {
39
- "@uniweb/core": "^0.29.4",
39
+ "@uniweb/core": "^0.29.5",
40
40
  "@uniweb/theming": "^0.1.15"
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",
45
45
  "vite": "^7.3.1",
46
46
  "vitest": "^4.1.7",
47
- "@uniweb/build": "0.52.7"
47
+ "@uniweb/build": "0.54.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -102,6 +102,8 @@ export const ISOLATE_API = Object.freeze({
102
102
  resolvePageFetchConfigs: '0.14.1',
103
103
  executeFetchConfigs: '0.14.1',
104
104
  prefetchPageData: '0.14.1',
105
+ // the render's data step — the same question asked off the graph the render uses
106
+ loadPageData: UNRELEASED,
105
107
  // the composed render entry
106
108
  createPageRenderer: '0.14.2',
107
109
  prefetchAndHydrate: '0.14.2',
@@ -0,0 +1,138 @@
1
+ /**
2
+ * THE RENDER'S DATA STEP — the page a visit starts on, fetched by the rule that renders it.
3
+ *
4
+ * ⭐ **Why a step at all.** The server render is synchronous: while it renders, a section reads its
5
+ * data from the cache and never waits for a request (`EntityStore.resolve`). So whatever a page
6
+ * shows has to be there before the render starts. That is this, and nothing else: it is not an
7
+ * optimization, it is how a server-rendered page has content.
8
+ *
9
+ * ⭐ **Why it runs the render's own rule.** It asks, block by block, exactly what the render will
10
+ * look for — `EntityStore.fetch`, the same plan `resolve` reads back (`@uniweb/core/page-data`) —
11
+ * through a dispatcher built from the site's own foundation, so a transport the site chose answers
12
+ * here too. ⛔ Until 2026-09-19 a host's prefetch worked this out from the payload alone: it could
13
+ * not know which keys a component declares, which layout the page draws, or which transport a key
14
+ * is routed to, so it asked for every fetch on every level and still got four cases wrong
15
+ * (`kb/framework/plans/what-a-page-needs.md` §0). A payload cannot answer those; the graph the
16
+ * isolate is about to render with can.
17
+ *
18
+ * ⚖️ **What it still over-asks.** The blocks are the page's — body, the layout areas it uses, their
19
+ * child blocks and insets. Whether a component actually draws its children, or a layout its area,
20
+ * is known only by rendering; a block that declares keys and is never drawn is asked for anyway.
21
+ * Bounded by what such a block declares, and cheaper than rendering the page twice.
22
+ *
23
+ * @module
24
+ */
25
+ import DataStore, { deriveCacheKey } from '@uniweb/core/datastore'
26
+ import { resolvePage } from './ssr-renderer.js'
27
+ import { getComponentMeta } from './prepare-props.js'
28
+ import { createDefaultFetcher } from './default-fetcher.js'
29
+
30
+ /**
31
+ * Every block the page's render can reach: its body and the layout areas it uses (the runtime's own
32
+ * layout choice — the page's `layout:`, else the foundation's default, else the site's default set,
33
+ * minus any area the page hides), plus what those blocks hold.
34
+ */
35
+ function blocksOf(page) {
36
+ const out = []
37
+ const walk = (list) => {
38
+ for (const block of list || []) {
39
+ if (!block) continue
40
+ out.push(block)
41
+ walk(block.childBlocks)
42
+ walk(block.insets)
43
+ }
44
+ }
45
+ walk(page.getBodyBlocks?.() ?? page.bodyBlocks)
46
+ for (const area of Object.values(page.getLayoutAreas?.() ?? {})) walk(area)
47
+ return out
48
+ }
49
+
50
+ /**
51
+ * Fetch what one route's render will read.
52
+ *
53
+ * @param {Object} options
54
+ * @param {Object} options.website - the initialized Website the render will use (`initPrerender`)
55
+ * @param {string|Object} options.route - the route, or an already-resolved Page
56
+ * @param {Function} options.fetch - the transport, per request
57
+ * @param {boolean} [options.dev]
58
+ * @param {string|null} [options.locale] - ⛔ NOT a locale to fetch in: the Website is already
59
+ * locale-sliced (`initPrerenderForLocale`) and every address is resolved in ITS locale. Passing
60
+ * one that disagrees throws, because the alternative is a page prerendered in one language with
61
+ * the data of another, and nothing to see it.
62
+ * @param {'always'|'author'} [options.prerender] - `'always'` asks for everything, which is what an
63
+ * isolate rendering a visit's first page wants; `'author'` honours `prerender: false`.
64
+ * @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: *, meta?: Object, error?: string }>>}
65
+ * one entry per request the render will make, keyed downstream by `deriveCacheKey(config)`
66
+ */
67
+ export async function loadPageData({ website, route, fetch, dev = false, locale = null, prerender = 'always' }) {
68
+ if (prerender !== 'author' && prerender !== 'always') {
69
+ throw new Error(`loadPageData: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
70
+ }
71
+ const active = website?.getActiveLocale?.() ?? null
72
+ if (locale && active && locale !== active) {
73
+ throw new Error(
74
+ `loadPageData: this Website renders ${JSON.stringify(active)} and the caller asked for ` +
75
+ `${JSON.stringify(locale)}. A Website is locale-sliced — build one per locale ` +
76
+ '(`initPrerenderForLocale`) rather than asking this step for another language.'
77
+ )
78
+ }
79
+ const resolved = typeof route === 'string' ? resolvePage(website, route) : route
80
+ // Nothing matched: a genuine 404, and the caller's to turn into one. Nothing to fetch.
81
+ if (!resolved) return []
82
+ // ⭐ The same promotion the render applies: a content-less folder renders its designated child,
83
+ // and it is that child's data the page needs.
84
+ const page = typeof resolved.getRenderableSelf === 'function' ? resolved.getRenderableSelf() : resolved
85
+
86
+ // ⛔ A real Page, not page data. `createPageRenderer` tolerates plain data because rendering it
87
+ // still produces a page; here the same tolerance would walk raw sections, find no blocks and no
88
+ // graph behind them, and fetch NOTHING — a clean run, an empty page, no error anywhere. That is
89
+ // the failure this whole step exists to end, so it is refused.
90
+ if (typeof page.getBodyBlocks !== 'function') {
91
+ throw new Error('loadPageData: `route` must be a route or a Page from this Website — page data is not enough to say what its render reads.')
92
+ }
93
+
94
+ // A cache of this request's own: the graph's belongs to whatever it is rendering, and a page's
95
+ // data is asked fresh per request. The site's transports still apply (`dispatcherFor`).
96
+ const dispatcher = website.dispatcherFor({
97
+ dataStore: new DataStore(),
98
+ defaultFetcher: createDefaultFetcher({ basePath: website.config?.base || '', dev, fetch }),
99
+ })
100
+
101
+ /** One entry per request, in the order they were first asked for. */
102
+ const entries = new Map()
103
+ const record = (request, entry) => {
104
+ const key = deriveCacheKey(request)
105
+ if (!entries.has(key)) entries.set(key, entry)
106
+ }
107
+
108
+ const recording = {
109
+ peek: (request, ctx) => dispatcher.peek(request, ctx),
110
+ peekRecord: (id) => dispatcher.peekRecord(id),
111
+ dispatch: async (request, ctx) => {
112
+ if (prerender === 'author' && request?.prerender === false) {
113
+ // The author deferred this one to the browser and the caller honours that. Present, so a
114
+ // host can count what was declared against what was tried; not fetched, not hydrated.
115
+ record(request, { config: request, outcome: 'skipped', data: null })
116
+ return { data: null }
117
+ }
118
+ const result = await dispatcher.dispatch(request, ctx)
119
+ record(request, result?.error
120
+ ? { config: request, outcome: 'failed', data: null, error: result.error }
121
+ : { config: request, outcome: 'fetched', data: result?.data ?? null, ...(result?.meta ? { meta: result.meta } : {}) })
122
+ return result
123
+ },
124
+ }
125
+
126
+ const store = website.entityStore
127
+ // ⭐ Every block's first request goes out in the same tick, which is what lets a question door
128
+ // answer a page in one POST. A step that depends on an answer takes a later one, and only its
129
+ // own key waits.
130
+ await Promise.all([
131
+ // The page's own record: asked whether or not a section declares the route key, because the
132
+ // page names itself from it and a URL naming no record is a 404.
133
+ store.fetchPageRecord(page, { dispatcher: recording }),
134
+ ...blocksOf(page).map((block) => store.fetch(block, getComponentMeta(block.type), { dispatcher: recording })),
135
+ ])
136
+
137
+ return [...entries.values()]
138
+ }
@@ -7,9 +7,10 @@
7
7
  *
8
8
  * - `@uniweb/build`'s `prerender.js` — `renderPage` → classify → `injectPageContent`,
9
9
  * once per page in its loop.
10
- * - an SSR isolate rendering per request — `resolvePage` → `renderPage` →
11
- * `injectPageContent`, and it wrote the route lookup three times in three files
12
- * before `resolvePage` was exported at all (see that function's header).
10
+ * - an SSR isolate prerendering the page a visit starts on (the SPA renders every
11
+ * page after it) — `resolvePage` → `renderPage` → `injectPageContent`, and it wrote
12
+ * the route lookup three times in three files before `resolvePage` was exported at
13
+ * all (see that function's header).
13
14
  *
14
15
  * Two unlike callers is what makes the interface honest: a build bakes files and an
15
16
  * isolate answers a request, so anything only one of them needs stayed out.
@@ -31,7 +32,7 @@
31
32
  */
32
33
  import { resolvePage, renderPage, classifyRenderError, injectPageContent } from './ssr-renderer.js'
33
34
  import { hydrateDataStore } from './wire-foundation.js'
34
- import { prefetchPageData } from './prefetch.js'
35
+ import { loadPageData } from './load-page-data.js'
35
36
 
36
37
  /**
37
38
  * A renderer bound to one initialized Website and one shell.
@@ -121,6 +122,14 @@ export function createPageRenderer({ website, shell }) {
121
122
  * them to tell "nothing was tried" from "everything tried failed", which is a
122
123
  * different cache decision.
123
124
  *
125
+ * ⭐ **Since 2026-09-19 it asks what the RENDER will read, block by block** (`loadPageData`),
126
+ * on the Website it is handed: the keys each component declares, the layout the page actually
127
+ * draws, and the transport the site chose for a key. `content` is no longer read — the graph
128
+ * carries everything — and stays in the signature for callers that pass it.
129
+ * ⛔ `prefetchPageData` works from the payload alone and cannot know any of those; it asks for
130
+ * every fetch on every level instead, and still gets four cases wrong
131
+ * (`kb/framework/plans/what-a-page-needs.md` §0). Prefer this entry.
132
+ *
124
133
  * ⛔ The build lane does NOT call this: it hydrates every collection once, before
125
134
  * its page loop, from its own executor that honours the author's `prerender:` flag.
126
135
  * That difference is why this is a named isolate helper and not a step inside
@@ -164,7 +173,10 @@ export async function prefetchAndHydrate({ website, content, route, locale = nul
164
173
  'To use the ambient fetch deliberately, pass `fetch: globalThis.fetch`.'
165
174
  )
166
175
  }
167
- const fetched = await prefetchPageData({ content, route, locale, fetch, dev, prerender })
176
+ // ⛔ `locale` is forwarded to be CHECKED, not applied: the Website is locale-sliced already, so a
177
+ // caller whose two disagree gets an error rather than a page prerendered in one language with the
178
+ // data of another.
179
+ const fetched = await loadPageData({ website, route, fetch, dev, locale, prerender })
168
180
  hydrateDataStore(website, fetched)
169
181
  return fetched
170
182
  }
package/src/prefetch.js CHANGED
@@ -31,13 +31,17 @@
31
31
  * permissive default because the build and browser lanes call it in-process.
32
32
  * - `prerender` whether a fetch is tried — `'always'` (default) tries every config; `'author'`
33
33
  * honours the author's `prerender: false`. ⛔ The default is `'always'` because this
34
- * entry has exactly one kind of caller: an isolate rendering per request, where the
35
- * flag means nothing and always prerendering is the product ([Diego, 2026-07-28 and
36
- * 2026-09-03]: "`prerender: false` is not for the isolate"). The build lane, which
37
- * bakes static artifacts and does honour the flag, uses its own executor
38
- * (`build/src/prerender.js`) and never calls this. `'author'` is the explicit opt-in
39
- * for a caller that bakes; omitting the option must not silently reproduce the
40
- * 2026-07-28 outcome — prefetch a no-op on a live-data template, page still 200.
34
+ * entry has exactly one kind of caller: an isolate prerendering the page a visit
35
+ * starts on, where the flag means nothing and always prerendering is the product
36
+ * ([Diego, 2026-07-28 and 2026-09-03]: "`prerender: false` is not for the isolate").
37
+ * ⚠️ That page ONLY: the site is a SPA, and every page after it renders in the
38
+ * browser with data the entity store fetches, never through this. (Until 2026-09-19
39
+ * this said "an isolate rendering per request", which read as "every page".)
40
+ * The build lane, which bakes static artifacts and does honour the flag, uses its
41
+ * own executor (`build/src/prerender.js`) and never calls this. `'author'` is the
42
+ * explicit opt-in for a caller that bakes; omitting the option must not silently
43
+ * reproduce the 2026-07-28 outcome — prefetch a no-op on a live-data template,
44
+ * page still 200.
41
45
  * - returns one entry per DECLARED config, `{ config, outcome, data, meta?, error? }`, keyed
42
46
  * downstream by `deriveCacheKey(config)`. `outcome` is `fetched`, `failed`
43
47
  * (transport or HTTP error, `error` says which) or `skipped` (the author
package/src/ssr.js CHANGED
@@ -56,8 +56,18 @@ export {
56
56
  generate404Html,
57
57
  } from './ssr-renderer.js'
58
58
 
59
- // Server-side prefetch — the runtime executing a page's fetches for a host, so an isolate
60
- // receives `fetchedData` computed by our fetcher and the host carries no copy of it.
59
+ // ⭐ The render's data step — what the page a visit starts on will read, asked block by block on
60
+ // the Website the isolate renders with, so the keys a component declares, the layout the page
61
+ // draws and the transport a key is routed to are all known. A host that hydrates through its own
62
+ // door calls this and hydrates the list itself; `prefetchAndHydrate` is the same step plus the
63
+ // hydration.
64
+ export { loadPageData } from './load-page-data.js'
65
+
66
+ // Server-side prefetch from the PAYLOAD ALONE — the entry that predates the step above. It cannot
67
+ // know a component's declared keys, a page's layout or a site's transports, so it asks for every
68
+ // fetch on every level and still gets four cases wrong
69
+ // (`kb/framework/plans/what-a-page-needs.md` §0). ⛔ Kept for callers that have no Website to hand;
70
+ // prefer `loadPageData`.
61
71
  // [Diego, 2026-09-03]: the backend sets the records service; the fetch comes from the runtime.
62
72
  export {
63
73
  findPageForRoute,