@uniweb/runtime 0.26.6 → 0.27.0
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 +12 -115
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/isolate-api.js +2 -5
- package/src/load-page-data.js +11 -4
- package/src/ssr.js +10 -12
- package/src/wire-foundation.js +21 -11
- package/src/prefetch.js +0 -261
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
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.6",
|
|
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.54.
|
|
47
|
+
"@uniweb/build": "0.54.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/isolate-api.js
CHANGED
|
@@ -97,13 +97,10 @@ export const ISOLATE_API = Object.freeze({
|
|
|
97
97
|
renderAppearanceBootScript: '0.8.30',
|
|
98
98
|
// page resolution
|
|
99
99
|
resolvePage: '0.9.5',
|
|
100
|
-
//
|
|
100
|
+
// which page a route names
|
|
101
101
|
findPageForRoute: '0.14.1',
|
|
102
|
-
resolvePageFetchConfigs: '0.14.1',
|
|
103
|
-
executeFetchConfigs: '0.14.1',
|
|
104
|
-
prefetchPageData: '0.14.1',
|
|
105
102
|
// the render's data step — the same question asked off the graph the render uses
|
|
106
|
-
loadPageData:
|
|
103
|
+
loadPageData: '0.26.6',
|
|
107
104
|
// the composed render entry
|
|
108
105
|
createPageRenderer: '0.14.2',
|
|
109
106
|
prefetchAndHydrate: '0.14.2',
|
package/src/load-page-data.js
CHANGED
|
@@ -61,10 +61,16 @@ function blocksOf(page) {
|
|
|
61
61
|
* the data of another, and nothing to see it.
|
|
62
62
|
* @param {'always'|'author'} [options.prerender] - `'always'` asks for everything, which is what an
|
|
63
63
|
* isolate rendering a visit's first page wants; `'author'` honours `prerender: false`.
|
|
64
|
+
* @param {Object} [options.cache] - a `DataStore` to answer from and fill, shared across calls.
|
|
65
|
+
* ⭐ For a caller that runs the step for MANY pages of one site — a static build — where every
|
|
66
|
+
* page asks for the site's and its parent's data and reading each address once is the difference
|
|
67
|
+
* between N reads and N×M. ⛔ Not for a host serving requests: there a page's data is asked
|
|
68
|
+
* fresh, and the default (a cache per call) is what does that. A shared cache changes nothing
|
|
69
|
+
* about what is ASKED or returned — every request is still recorded, hit or miss.
|
|
64
70
|
* @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: *, meta?: Object, error?: string }>>}
|
|
65
71
|
* one entry per request the render will make, keyed downstream by `deriveCacheKey(config)`
|
|
66
72
|
*/
|
|
67
|
-
export async function loadPageData({ website, route, fetch, dev = false, locale = null, prerender = 'always' }) {
|
|
73
|
+
export async function loadPageData({ website, route, fetch, dev = false, locale = null, prerender = 'always', cache = null }) {
|
|
68
74
|
if (prerender !== 'author' && prerender !== 'always') {
|
|
69
75
|
throw new Error(`loadPageData: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
|
|
70
76
|
}
|
|
@@ -91,10 +97,11 @@ export async function loadPageData({ website, route, fetch, dev = false, locale
|
|
|
91
97
|
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
98
|
}
|
|
93
99
|
|
|
94
|
-
// A cache of this
|
|
95
|
-
// data is asked fresh per request. The site's transports still apply
|
|
100
|
+
// A cache of this call's own unless the caller shares one: the graph's belongs to whatever it is
|
|
101
|
+
// rendering, and a page's data is asked fresh per request. The site's transports still apply
|
|
102
|
+
// (`dispatcherFor`).
|
|
96
103
|
const dispatcher = website.dispatcherFor({
|
|
97
|
-
dataStore: new DataStore(),
|
|
104
|
+
dataStore: cache ?? new DataStore(),
|
|
98
105
|
defaultFetcher: createDefaultFetcher({ basePath: website.config?.base || '', dev, fetch }),
|
|
99
106
|
})
|
|
100
107
|
|
package/src/ssr.js
CHANGED
|
@@ -63,18 +63,16 @@ export {
|
|
|
63
63
|
// hydration.
|
|
64
64
|
export { loadPageData } from './load-page-data.js'
|
|
65
65
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// (
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
prefetchPageData,
|
|
77
|
-
} from './prefetch.js'
|
|
66
|
+
// Which page a route names — the rule, not a copy of it (`@uniweb/core/route-match`). A host
|
|
67
|
+
// resolving a route before it asks us anything imports this rather than comparing strings: the
|
|
68
|
+
// SPA normalizes a trailing slash, and a copy that did not made `/about/` prefetch nothing and
|
|
69
|
+
// then render fine (2026-09-12).
|
|
70
|
+
// ⛔ `prefetchPageData`, `resolvePageFetchConfigs` and `executeFetchConfigs` are GONE (2026-09-20).
|
|
71
|
+
// They worked from the payload alone, which cannot say which keys a component declares, which
|
|
72
|
+
// layout a page draws, or which transport a key is routed to — four measured cases wrong
|
|
73
|
+
// (`kb/framework/plans/what-a-page-needs.md` §0). `loadPageData` above is the step that replaced
|
|
74
|
+
// them, and the one host that called them confirmed the switch before they went.
|
|
75
|
+
export { findPageForRoute } from '@uniweb/core/route-match'
|
|
78
76
|
|
|
79
77
|
// The composed render entry — resolve, render, inject, as one call. Built because
|
|
80
78
|
// framework itself had two callers of this unshared sequence (the build's prerender
|
package/src/wire-foundation.js
CHANGED
|
@@ -148,14 +148,18 @@ export function sliceContentForLocale(content, locale) {
|
|
|
148
148
|
* fetched data so the dispatcher's first probe hits the cache instead
|
|
149
149
|
* of refetching.
|
|
150
150
|
*
|
|
151
|
-
* The
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
151
|
+
* The value MUST be wrapped as `{ data }`, or `cached.data` reads `undefined`. Three call sites
|
|
152
|
+
* used to inline this loop independently (browser SPA, Node SSG, and a server-side renderer); one
|
|
153
|
+
* of the three used the wrong shape, silently killing prefetched-data reuse wherever it ran. This
|
|
154
|
+
* helper is the one canonical implementation.
|
|
155
|
+
*
|
|
156
|
+
* ⭐ **The KEY is the dispatcher's, not ours** (`fetcher.hydrate`). ⛔ This filed every entry under
|
|
157
|
+
* `deriveCacheKey(entry.config)` until 2026-09-20, which is right for every request the framework's
|
|
158
|
+
* own fetcher answers and wrong for one the site routes to a transport that declares its own
|
|
159
|
+
* `cacheKey` — the documented case being a request whose identity depends on state its address does
|
|
160
|
+
* not carry. Measured: such an entry landed where no `peek` looks, so every prerendered page of that
|
|
161
|
+
* site rendered empty and the browser refetched it, with no error anywhere. One derivation, one
|
|
162
|
+
* home — the dispatcher's, which is where selection happens.
|
|
159
163
|
*
|
|
160
164
|
* @param {import('@uniweb/core').Website} website
|
|
161
165
|
* @param {Array<{config: Object, data: any}>} fetchedData
|
|
@@ -163,12 +167,18 @@ export function sliceContentForLocale(content, locale) {
|
|
|
163
167
|
export function hydrateDataStore(website, fetchedData) {
|
|
164
168
|
if (!website?.dataStore || !fetchedData?.length) return
|
|
165
169
|
for (const entry of fetchedData) {
|
|
166
|
-
// A
|
|
167
|
-
//
|
|
170
|
+
// A data step's list carries every declared config with an `outcome`; only what was actually
|
|
171
|
+
// fetched enters the store. A list without outcomes (the SSG lane's) is all fetched.
|
|
168
172
|
if (entry.outcome && entry.outcome !== 'fetched') continue
|
|
169
173
|
// `meta` (the depth the records were fetched at) rides along, so the store
|
|
170
174
|
// files them in its record index exactly as a runtime fetch would.
|
|
171
|
-
|
|
175
|
+
const value = entry.meta ? { data: entry.data, meta: entry.meta } : { data: entry.data }
|
|
176
|
+
if (typeof website.fetcher?.hydrate === 'function') {
|
|
177
|
+
website.fetcher.hydrate(entry.config, value, { website })
|
|
178
|
+
} else {
|
|
179
|
+
// A graph with no dispatcher — a stub in a test, or a caller assembling one by hand.
|
|
180
|
+
website.dataStore.set(deriveCacheKey(entry.config), value)
|
|
181
|
+
}
|
|
172
182
|
}
|
|
173
183
|
}
|
|
174
184
|
|
package/src/prefetch.js
DELETED
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Server-side data prefetch — the runtime executing a page's fetches for a host.
|
|
3
|
-
*
|
|
4
|
-
* L2 (graph state, no React): reads a payload, resolves the fetch configs the way the
|
|
5
|
-
* entity store does at render time, executes them through the runtime's own default
|
|
6
|
-
* fetcher, and returns the `[{ config, data }]` list `hydrateDataStore` expects.
|
|
7
|
-
*
|
|
8
|
-
* ⭐ Why this exists — one implementation of the fetch, in the runtime. A host that renders
|
|
9
|
-
* pages in an isolate hands the isolate `fetchedData`. Until this module the host had to
|
|
10
|
-
* compute that itself: resolve the configs, issue the requests, unwrap the responses in the
|
|
11
|
-
* shape the datastore expects — a copy of the runtime's logic, in another repo, drifting
|
|
12
|
-
* (the records envelope went silently unread that way on 2026-09-02). [Diego, 2026-09-03]:
|
|
13
|
-
* *the backend sets the records service; the fetch comes from the runtime.* The host now calls
|
|
14
|
-
* this and carries no copy; the host that renders in an isolate agreed to exactly
|
|
15
|
-
* that shape the same day.
|
|
16
|
-
*
|
|
17
|
-
* ⛔ Contract with the host, deliberately small:
|
|
18
|
-
* - `content` the render payload (`site-content.json` / `__DATA__`), config included —
|
|
19
|
-
* `config.services` and `config.base` are read from it.
|
|
20
|
-
* - `route` the page to prefetch for; a `[slug]` template resolves through the same
|
|
21
|
-
* matcher the SPA uses, so `/blog/post-1` finds `/blog/:slug`.
|
|
22
|
-
* - `fetch` how to dispatch a request. The runtime composes the address; the host
|
|
23
|
-
* decides how a site-relative one is reached (its origin, a binding).
|
|
24
|
-
* ⛔ **Crossing an isolate boundary, this survives only as an RPC method
|
|
25
|
-
* argument.** Through an entrypoint's `fetch(Request)` with a serialized
|
|
26
|
-
* body it arrives `undefined` (measured by a host against a real
|
|
27
|
-
* isolate loader, 2026-09-03) — and the fetcher then falls
|
|
28
|
-
* back to `globalThis.fetch`, so the request leaves from the isolate,
|
|
29
|
-
* outside whatever budget the host wrapped around it. `prefetchAndHydrate`
|
|
30
|
-
* refuses a non-function for exactly this reason; this entry keeps the
|
|
31
|
-
* permissive default because the build and browser lanes call it in-process.
|
|
32
|
-
* - `prerender` whether a fetch is tried — `'always'` (default) tries every config; `'author'`
|
|
33
|
-
* honours the author's `prerender: false`. ⛔ The default is `'always'` because this
|
|
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.
|
|
45
|
-
* - returns one entry per DECLARED config, `{ config, outcome, data, meta?, error? }`, keyed
|
|
46
|
-
* downstream by `deriveCacheKey(config)`. `outcome` is `fetched`, `failed`
|
|
47
|
-
* (transport or HTTP error, `error` says which) or `skipped` (the author
|
|
48
|
-
* deferred it to the browser with `prerender: false`). `hydrateDataStore`
|
|
49
|
-
* takes the list as-is and hydrates only `fetched` entries — a host reads the
|
|
50
|
-
* outcomes to tell "nothing was tried" from "everything tried failed", which
|
|
51
|
-
* is a different cache decision (measured by a host, 2026-09-03).
|
|
52
|
-
*
|
|
53
|
-
* It resolves nothing the host owns and models no host route layout: every address is
|
|
54
|
-
* `{base}/…` from the payload, or the records service the host itself
|
|
55
|
-
* published at `config.services.records`.
|
|
56
|
-
*/
|
|
57
|
-
import { resolveFetchConfigs, fetchEntries, pageRouteQuery, routeSelection, currentFor, othersView, siteReaches } from '@uniweb/core/fetch-config'
|
|
58
|
-
import { deriveCacheKey } from '@uniweb/core/datastore'
|
|
59
|
-
import { findPageForRoute, isDynamicRoute, routeBinding, parentRouteOf } from '@uniweb/core/route-match'
|
|
60
|
-
import { buildDetailConfig } from '@uniweb/core/detail-url'
|
|
61
|
-
import { resolveDefaultLocale } from '@uniweb/core/locale-config'
|
|
62
|
-
import { findLayoutEntry } from '@uniweb/core/layout-name'
|
|
63
|
-
import { createDefaultFetcher } from './default-fetcher.js'
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* ⭐ **RE-EXPORTED, NOT IMPLEMENTED HERE — the rule lives in `@uniweb/core/route-match`.**
|
|
67
|
-
*
|
|
68
|
-
* This module carried its own copy until 2026-09-12, composed out of core's leaves,
|
|
69
|
-
* and it disagreed with the SPA: this copy compared raw route strings, while
|
|
70
|
-
* `Website#getPage` normalizes a trailing slash — so `/about/` prefetched nothing
|
|
71
|
-
* and then rendered fine, which is the shape that hides the split.
|
|
72
|
-
*
|
|
73
|
-
* ⇒ The leaves were importable and the RULE was not, which is what invites a caller
|
|
74
|
-
* to compose its own. Moving it into the leaf every caller already imports leaves one
|
|
75
|
-
* answer to *which page is this* and nowhere for a second to form. The export keeps
|
|
76
|
-
* its name here and on `@uniweb/runtime/ssr`, so the isolate API is unchanged.
|
|
77
|
-
*/
|
|
78
|
-
export { findPageForRoute }
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* The page a page inherits from, by the one rule every lane uses (`parentRouteOf`):
|
|
82
|
-
* the declared `parent` when it names a page, else the route minus its last segment.
|
|
83
|
-
* ⛔ This read the declared field only until 2026-09-11. A payload that omits it —
|
|
84
|
-
* published payloads may — gave every page no parent here while the SPA inferred
|
|
85
|
-
* one, so `/members/alice` prefetched nothing: not the list, not the record
|
|
86
|
-
* (measured). The page was then filled by the browser's own request.
|
|
87
|
-
*/
|
|
88
|
-
function parentPageOf(page, pages) {
|
|
89
|
-
const byRoute = new Map(pages.filter((p) => p?.route).map((p) => [p.route, p]))
|
|
90
|
-
const route = parentRouteOf(page.route, { declared: page.parent ?? null, has: (r) => byRoute.has(r) })
|
|
91
|
-
return route ? byRoute.get(route) : null
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The layout sections a page may render — every section of the layout areas it can
|
|
96
|
-
* use, subsections included. A page that names a layout uses that set (matched as the
|
|
97
|
-
* runtime matches it, `@uniweb/core/layout-name`) and nothing else; a page that names
|
|
98
|
-
* none uses its foundation's default layout, which a payload does not say, so every
|
|
99
|
-
* set's sections are included — a superset, which a prefetch can afford.
|
|
100
|
-
*/
|
|
101
|
-
function layoutSectionsFor(content, page) {
|
|
102
|
-
const sets = content?.layouts && typeof content.layouts === 'object' ? content.layouts : {}
|
|
103
|
-
const name = typeof page?.layout?.name === 'string' && page.layout.name ? page.layout.name : null
|
|
104
|
-
const named = name ? findLayoutEntry(sets, name) : undefined
|
|
105
|
-
const chosen = name ? (named ? [named] : []) : Object.values(sets)
|
|
106
|
-
const out = []
|
|
107
|
-
const walk = (sections) => {
|
|
108
|
-
for (const s of sections || []) {
|
|
109
|
-
if (!s || typeof s !== 'object') continue
|
|
110
|
-
out.push(s)
|
|
111
|
-
walk(s.subsections)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
for (const set of chosen) for (const area of Object.values(set || {})) walk(area?.sections)
|
|
115
|
-
return out
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Every fetch config a page will need at render time, resolved once and de-duplicated by
|
|
120
|
-
* cache key: the page's, its parent's, each section's own (including nested sections),
|
|
121
|
-
* its layout sections', and the site's where it reaches (`siteReaches`) — each through
|
|
122
|
-
* `resolveFetchConfigs`, the same resolver the entity store uses, so a host prefetches
|
|
123
|
-
* exactly what the render will ask for.
|
|
124
|
-
*
|
|
125
|
-
* @returns {Object[]} resolved fetch configs
|
|
126
|
-
*/
|
|
127
|
-
export function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
|
|
128
|
-
const { page, params } = findPageForRoute(content, route)
|
|
129
|
-
if (!page) return []
|
|
130
|
-
const pages = content?.pages || []
|
|
131
|
-
const parent = parentPageOf(page, pages)
|
|
132
|
-
// The route's binding — the SPA's (`routeBinding`, `@uniweb/core/route-match`).
|
|
133
|
-
const binding = isDynamicRoute(page.route) && Object.keys(params).length
|
|
134
|
-
? routeBinding(page.route, params, page.paramName ?? null)
|
|
135
|
-
: null
|
|
136
|
-
const options = {
|
|
137
|
-
locale,
|
|
138
|
-
defaultLocale: resolveDefaultLocale(content?.config) ?? null,
|
|
139
|
-
queries: content?.config?.queries ?? null,
|
|
140
|
-
services: content?.config?.services ?? null,
|
|
141
|
-
variables: binding?.variables ?? null,
|
|
142
|
-
}
|
|
143
|
-
const siteFetch = content?.config?.fetch ?? null
|
|
144
|
-
// The route query: the key this page's URL names one record of, chosen at the page
|
|
145
|
-
// that captured its variable — by the rule the entity store reads it with
|
|
146
|
-
// (`pageRouteQuery`) — never a payload field.
|
|
147
|
-
const chosen = binding
|
|
148
|
-
? pageRouteQuery(page, {
|
|
149
|
-
routeOf: (p) => p.route,
|
|
150
|
-
parentOf: (p) => parentPageOf(p, pages),
|
|
151
|
-
fetchOf: (p) => p.fetch ?? null,
|
|
152
|
-
sectionsOf: (p) => p.sections,
|
|
153
|
-
site: siteFetch,
|
|
154
|
-
})
|
|
155
|
-
: null
|
|
156
|
-
// A nested page's sections receive its capturing page's route binding, which the
|
|
157
|
-
// one-parent cascade may not reach — the entity store adds it the same way.
|
|
158
|
-
const routeSource = chosen?.nested ? chosen.config : null
|
|
159
|
-
|
|
160
|
-
const out = new Map()
|
|
161
|
-
const put = (cfg) => {
|
|
162
|
-
const key = deriveCacheKey(cfg)
|
|
163
|
-
if (!out.has(key)) out.set(key, cfg)
|
|
164
|
-
}
|
|
165
|
-
// ⭐ EVERY fetch of every level, each resolved on its own — not the first per key. A section
|
|
166
|
-
// receives the keys its component declares, and two fetches of one `as` at two levels can
|
|
167
|
-
// fill two of them (`fillDeclaredKeys`, 2026-09-14), which a prefetch holding no
|
|
168
|
-
// component's meta cannot rule out. A superset, which a prefetch can afford.
|
|
169
|
-
const add = (sources) => {
|
|
170
|
-
const resolved = sources.flatMap((source) => fetchEntries(source).map((entry) => resolveFetchConfigs([entry], options).get(entry.as)))
|
|
171
|
-
for (const cfg of resolved.filter(Boolean)) {
|
|
172
|
-
// how the fetch uses the page's record — by the query it names, as the entity store asks
|
|
173
|
-
const current = currentFor(cfg, chosen)
|
|
174
|
-
if (current === 'exclude') {
|
|
175
|
-
// the list one longer, from which the render removes the page's record
|
|
176
|
-
put(othersView(cfg))
|
|
177
|
-
continue
|
|
178
|
-
}
|
|
179
|
-
if (current !== 'only') {
|
|
180
|
-
// no part for the page's record, or `current: include` — the list as the fetch describes it
|
|
181
|
-
put(cfg)
|
|
182
|
-
continue
|
|
183
|
-
}
|
|
184
|
-
// ⭐ A parametric page is ABOUT one record, and on a lane with a per-record
|
|
185
|
-
// source (the records service, a `deferred:` query's per-record file) that
|
|
186
|
-
// record is a request of its own. Built for EVERY config that takes the page's
|
|
187
|
-
// record, by the one rule the entity store uses (`buildDetailConfig`), so
|
|
188
|
-
// the question prefetched is the question the render asks — and since that
|
|
189
|
-
// question drops a fetch's own narrowing, sections that narrow differently ask
|
|
190
|
-
// one record question between them.
|
|
191
|
-
if (cfg.detail && binding.paramValue !== undefined) {
|
|
192
|
-
const detailCfg = buildDetailConfig(cfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) })
|
|
193
|
-
if (detailCfg) put(detailCfg)
|
|
194
|
-
}
|
|
195
|
-
// ⭐ On the records service the record question checks the query's set on its
|
|
196
|
-
// own, so nothing else is asked. Off it, the record is FOUND in the set —
|
|
197
|
-
// the query as saved, without the fetch's `narrow` (`routeSelection`) — which is
|
|
198
|
-
// all the render reads for it. ⛔ Until 2026-09-14 the service's list was asked
|
|
199
|
-
// beside the record, and the page embedded the whole set to show one record.
|
|
200
|
-
if (!cfg.ask) put(routeSelection(cfg))
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
// The cascade a block sees: its own fetch, page, parent, a nested page's route
|
|
204
|
-
// binding, and the site's — which reaches a top-level page's sections only.
|
|
205
|
-
const siteForPage = siteReaches(parent) ? siteFetch : null
|
|
206
|
-
add([page.fetch ?? null, parent?.fetch ?? null, routeSource, siteForPage])
|
|
207
|
-
const walk = (sections) => {
|
|
208
|
-
for (const s of sections || []) {
|
|
209
|
-
if (s?.fetch) add([s.fetch, page.fetch ?? null, parent?.fetch ?? null, routeSource, siteForPage])
|
|
210
|
-
if (s?.subsections) walk(s.subsections)
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
walk(page.sections)
|
|
214
|
-
// ⭐ Layout sections belong to the site: the site's binding reaches them on every
|
|
215
|
-
// page, beside each one's own fetch. A layout area has no parent page.
|
|
216
|
-
for (const s of layoutSectionsFor(content, page)) add([s.fetch ?? null, siteFetch])
|
|
217
|
-
return [...out.values()]
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Execute resolved fetch configs through the runtime's default fetcher.
|
|
222
|
-
*
|
|
223
|
-
* @param {Object[]} configs resolved configs (from `resolvePageFetchConfigs` or the host's own
|
|
224
|
-
* call to `resolveFetchConfigs`)
|
|
225
|
-
* @param {Object} opts
|
|
226
|
-
* @param {Object} opts.content the payload — `config.base`, `config.services`
|
|
227
|
-
* @param {Function} [opts.fetch] the transport; defaults to the global `fetch`
|
|
228
|
-
* @param {boolean} [opts.dev]
|
|
229
|
-
* @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: any, error?: string }>>}
|
|
230
|
-
*/
|
|
231
|
-
export async function executeFetchConfigs(configs, { content, fetch = null, dev = false, prerender = 'always', locale = null } = {}) {
|
|
232
|
-
if (prerender !== 'author' && prerender !== 'always') {
|
|
233
|
-
throw new Error(`executeFetchConfigs: prerender must be 'author' or 'always', got ${JSON.stringify(prerender)}`)
|
|
234
|
-
}
|
|
235
|
-
const fetcher = createDefaultFetcher({
|
|
236
|
-
basePath: content?.config?.base || '',
|
|
237
|
-
dev,
|
|
238
|
-
fetch,
|
|
239
|
-
})
|
|
240
|
-
// The page's locale, which a list's texts are collated in when the runtime sorts it.
|
|
241
|
-
const ctx = { website: null, locale: locale ?? content?.config?.activeLocale ?? resolveDefaultLocale(content?.config) ?? null }
|
|
242
|
-
// Dispatched together, not one after another: the records service batches the
|
|
243
|
-
// requests issued in one tick into one POST, and a page's configs are
|
|
244
|
-
// independent of each other. Order is preserved in the result.
|
|
245
|
-
return Promise.all((configs || []).filter(Boolean).map(async (config) => {
|
|
246
|
-
if (prerender === 'author' && config.prerender === false) {
|
|
247
|
-
// The author deferred this one to the browser and the caller honours that. Present, so a
|
|
248
|
-
// host can count what was declared against what was tried; not hydrated.
|
|
249
|
-
return { config, outcome: 'skipped', data: null }
|
|
250
|
-
}
|
|
251
|
-
const result = await fetcher.resolve(config, ctx)
|
|
252
|
-
if (result?.error) return { config, outcome: 'failed', data: null, error: result.error }
|
|
253
|
-
return { config, outcome: 'fetched', data: result?.data ?? null, ...(result?.meta ? { meta: result.meta } : {}) }
|
|
254
|
-
}))
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Resolve and execute in one call: what a host passes the isolate as `fetchedData`. */
|
|
258
|
-
export async function prefetchPageData({ content, route, locale = null, fetch = null, dev = false, prerender = 'always' }) {
|
|
259
|
-
const configs = resolvePageFetchConfigs(content, route, { locale })
|
|
260
|
-
return executeFetchConfigs(configs, { content, fetch, dev, prerender, locale })
|
|
261
|
-
}
|