@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/dist/ssr.js +64 -2
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/isolate-api.js +2 -0
- package/src/load-page-data.js +138 -0
- package/src/page-renderer.js +17 -5
- package/src/prefetch.js +11 -7
- package/src/ssr.js +12 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.26.
|
|
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.
|
|
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.
|
|
47
|
+
"@uniweb/build": "0.54.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/isolate-api.js
CHANGED
|
@@ -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
|
+
}
|
package/src/page-renderer.js
CHANGED
|
@@ -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
|
|
11
|
-
*
|
|
12
|
-
* before `resolvePage` was exported at
|
|
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 {
|
|
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
|
-
|
|
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
|
|
35
|
-
* flag means nothing and always prerendering is the product
|
|
36
|
-
* 2026-09-03]: "`prerender: false` is not for the isolate").
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
//
|
|
60
|
-
//
|
|
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,
|