@uniweb/runtime 0.24.0 → 0.25.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 +59 -33
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/collect-records.js +23 -21
- package/src/default-fetcher.js +89 -41
- package/src/prefetch.js +21 -13
- package/src/prepare-props.js +53 -21
- package/src/ssr-renderer.js +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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.
|
|
39
|
+
"@uniweb/core": "^0.29.0",
|
|
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.49.0"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
package/src/collect-records.js
CHANGED
|
@@ -15,19 +15,21 @@
|
|
|
15
15
|
* page render uses**, applying each saved query's own `scope` and `where` — and
|
|
16
16
|
* asks through the same client.
|
|
17
17
|
*
|
|
18
|
-
* ##
|
|
18
|
+
* ## ⭐ Each query is asked AS SAVED — its `limit` included
|
|
19
19
|
*
|
|
20
|
-
* A
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
20
|
+
* A query defines its SET: the records its `scope` and `where` select, in its
|
|
21
|
+
* `sort`, up to its `limit` (ruled 2026-09-14 [Diego]). A parametric page has a URL
|
|
22
|
+
* for each record of its route query's set and no other, so the set is exactly the
|
|
23
|
+
* population a site's pages reach — a field note older than a query's 100 most recent
|
|
24
|
+
* has no page to index. Asked with no `narrow`, the service answers the set whole in
|
|
25
|
+
* one answer, so a walk has nothing to page; it still follows a cursor if one ever
|
|
26
|
+
* comes back, bounded by `maxPages`.
|
|
25
27
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* ⛔ **Until 2026-09-14 this dropped the query's `limit`**, on the rule it replaced — a
|
|
29
|
+
* `limit` was the list page's presentation and every record matching `scope` + `where`
|
|
30
|
+
* had a page — which was right under that rule. ⚠️ And that rule's own history: the
|
|
31
|
+
* first version, in 0.17.0, passed `limit` through while claiming the corpus was the
|
|
32
|
+
* population detail pages reach, and the consumer found it (2026-09-06).
|
|
31
33
|
*
|
|
32
34
|
* ## What the caller supplies
|
|
33
35
|
*
|
|
@@ -61,15 +63,16 @@ import { createDefaultFetcher } from './default-fetcher.js'
|
|
|
61
63
|
* list shows; **`true` — whole records — is what an index wants**: a brief index
|
|
62
64
|
* cannot match body text the record's own detail page displays, and a reader who
|
|
63
65
|
* finds a word on the page and not in search meets the inconsistency two
|
|
64
|
-
* rankings would produce. The cost is the caller's and
|
|
65
|
-
* An external query is never collected — it has no records service
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
66
|
+
* rankings would produce. The cost is the caller's, and bounded by each query's
|
|
67
|
+
* own `limit`. An external query is never collected — it has no records service
|
|
68
|
+
* to ask.
|
|
69
|
+
* @param {number} [options.maxPages] - the caller's own bound on a walk that pages. A
|
|
70
|
+
* question with no `narrow` is answered whole with no cursor, so today's walk is one
|
|
71
|
+
* request per query; the bound holds should a service page anyway.
|
|
69
72
|
* @returns {Promise<{records: Object, errors: Object|null, meta: Object}>}
|
|
70
73
|
* `records` is keyed by query NAME, each a flat array; `errors` is keyed the
|
|
71
|
-
* same and is null when nothing failed; `meta[name]
|
|
72
|
-
* `{
|
|
74
|
+
* same and is null when nothing failed; `meta[name]`, when there is anything to
|
|
75
|
+
* say, carries `{ whole?, pages?, partial?, unavailable? }`.
|
|
73
76
|
*
|
|
74
77
|
* ⭐ **`partial` means NOT THE WHOLE POPULATION**, and a key can be in BOTH
|
|
75
78
|
* `records` and `errors`: a walk that failed or was aborted with pages already
|
|
@@ -110,9 +113,8 @@ export async function collectSiteRecords(
|
|
|
110
113
|
// `path` has no live lane, and reading that file is the caller's business,
|
|
111
114
|
// not ours — it is in the site's own URL space and they already serve it.
|
|
112
115
|
if (!cfg.ask) return
|
|
113
|
-
//
|
|
114
|
-
const {
|
|
115
|
-
const asked = { ...population, whole, exhaustive: true }
|
|
116
|
+
// The query as saved — the set its pages reach, its `limit` included; see the header.
|
|
117
|
+
const asked = { ...cfg, whole, exhaustive: true }
|
|
116
118
|
if (typeof maxPages === 'number' && maxPages > 0) asked.maxPages = maxPages
|
|
117
119
|
|
|
118
120
|
const result = await fetcher.resolve(asked, { signal })
|
package/src/default-fetcher.js
CHANGED
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
* question the page asked, answered per key (the records contract, as this
|
|
16
16
|
* client reads it).
|
|
17
17
|
*
|
|
18
|
-
* `where:` / `sort:` / `limit:`
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* that per site: the
|
|
18
|
+
* A query's set — `scope:` / `where:` / `sort:` / `limit:` — and the `narrow`
|
|
19
|
+
* a fetch takes of it are evaluated HERE, locally, over what the first lane
|
|
20
|
+
* returns — with `@uniweb/core`'s one evaluator (`evaluateQuery`), the same the
|
|
21
|
+
* build uses — and at the source when asked. Nothing decides that per site: the
|
|
22
|
+
* LANE decides.
|
|
22
23
|
*
|
|
23
24
|
* ⛔ RETIRED 2026-09-04 [Diego]: `fetcher.baseUrl`, `headers`, `envelope`,
|
|
24
25
|
* `supports`, `request.style` / `request.rename` and the `json-body`
|
|
@@ -52,8 +53,7 @@
|
|
|
52
53
|
|
|
53
54
|
import {
|
|
54
55
|
substitutePlaceholders,
|
|
55
|
-
|
|
56
|
-
applyScope,
|
|
56
|
+
evaluateQuery,
|
|
57
57
|
sortRecords,
|
|
58
58
|
sortToWire,
|
|
59
59
|
deriveCacheKey,
|
|
@@ -230,14 +230,27 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
230
230
|
|
|
231
231
|
/**
|
|
232
232
|
* One question of a batch, in the records service's own vocabulary
|
|
233
|
-
* (the records contract, §2)
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
233
|
+
* (the records contract, §2).
|
|
234
|
+
* ⭐ TWO LEVELS, ruled 2026-09-14 [Diego]:
|
|
235
|
+
*
|
|
236
|
+
* - **the top level is the query as saved** — `schema` (required), `scope` a bare
|
|
237
|
+
* path, `where`, `sort` one key spelled `date` / `-date`, `limit` — and `whole`
|
|
238
|
+
* when the whole record is wanted. Its records are the set, and the top level is
|
|
239
|
+
* the same on every question about it;
|
|
240
|
+
* - **`narrow`** — how this page takes less than the set: `where`, `match` (a
|
|
241
|
+
* parametric page's record), `sort`, `cursor` (to resume a previous answer) and
|
|
242
|
+
* `limit`. Absent when the question asks for the set as it is.
|
|
243
|
+
*
|
|
244
|
+
* ⛔ Until 2026-09-14 the two were one flat question — the page's `where` joined the
|
|
245
|
+
* query's, its `sort` and `limit` replaced the query's — and `match` and `cursor` sat
|
|
246
|
+
* at the top level, where the service no longer takes them. There is no fallback:
|
|
247
|
+
* the service answers an unknown or misplaced field with a `400` for the request.
|
|
248
|
+
*
|
|
249
|
+
* ⭐ A where-object crosses exactly as authored: the evaluator and the service speak
|
|
250
|
+
* one language (`@uniweb/core`'s where evaluator). ⛔ `nin` was respelled `not_in`
|
|
251
|
+
* here until 2026-09-13, when the evaluator took `not_in` as its only spelling.
|
|
252
|
+
* Anything the service does not answer (a retired operator) is sent as written and
|
|
253
|
+
* answered there: never approximated.
|
|
241
254
|
*/
|
|
242
255
|
function toQuestion(request) {
|
|
243
256
|
const q = { schema: request.schema }
|
|
@@ -248,26 +261,61 @@ function toQuestion(request) {
|
|
|
248
261
|
const scope = typeof request.scope === 'string' && request.scope ? request.scope : null
|
|
249
262
|
if (scope) q.scope = scope
|
|
250
263
|
if (where) q.where = where
|
|
251
|
-
|
|
252
|
-
// key and the URL's value, beside the author's `where`, never merged into it
|
|
253
|
-
// (`buildDetailConfig`; the records contract as we read it, §1d).
|
|
254
|
-
if (request.match && typeof request.match === 'object') q.match = request.match
|
|
255
|
-
const sort = sortToWire(request.sort)
|
|
264
|
+
const sort = wireSort(request.sort)
|
|
256
265
|
if (sort) q.sort = sort
|
|
257
266
|
if (typeof request.limit === 'number' && request.limit > 0) q.limit = request.limit
|
|
258
267
|
// ⛔ ONLY WHEN TRUE. The brief is the default and absent means the brief, so
|
|
259
268
|
// sending `whole: false` would be noise on every list question the client makes.
|
|
260
269
|
if (request.whole === true) q.whole = true
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
// `exhaustive` deliberately
|
|
264
|
-
// how many times to ask,
|
|
265
|
-
if (typeof request.cursor === 'string' && request.cursor) q.cursor = request.cursor
|
|
266
|
-
// ⛔ `maxPages` does not cross either, for the same reason `exhaustive` does not:
|
|
267
|
-
// both say how many times to ask, never what is being asked.
|
|
270
|
+
const narrow = toNarrow(request.narrow)
|
|
271
|
+
if (narrow) q.narrow = narrow
|
|
272
|
+
// ⛔ `exhaustive` and `maxPages` deliberately do NOT cross — they are client
|
|
273
|
+
// instructions about how many times to ask, never part of what is being asked.
|
|
268
274
|
return q
|
|
269
275
|
}
|
|
270
276
|
|
|
277
|
+
/**
|
|
278
|
+
* A question's `narrow`, with only the fields the service takes inside it — any other
|
|
279
|
+
* is a `400` for the whole request there — or `null` when nothing is left.
|
|
280
|
+
*
|
|
281
|
+
* - `where` crosses as authored;
|
|
282
|
+
* - `match` — the record of a parametric page: one key and the URL's value, beside
|
|
283
|
+
* the author's `where`, never merged into it (`buildDetailConfig`);
|
|
284
|
+
* - `sort` in the service's spelling;
|
|
285
|
+
* - ⭐ `cursor` — the ONLY field in a question that is not the author's: opaque, from a
|
|
286
|
+
* previous answer's `cursors`, sent back on the same question and nothing else
|
|
287
|
+
* changed. A cursor is a narrowing like the rest, so the top level stays the query as
|
|
288
|
+
* saved on every page;
|
|
289
|
+
* - `limit`, when it cuts.
|
|
290
|
+
*/
|
|
291
|
+
function toNarrow(narrow) {
|
|
292
|
+
if (!narrow || typeof narrow !== 'object') return null
|
|
293
|
+
const n = {}
|
|
294
|
+
if (narrow.where && typeof narrow.where === 'object') n.where = narrow.where
|
|
295
|
+
if (narrow.match && typeof narrow.match === 'object') n.match = narrow.match
|
|
296
|
+
const sort = wireSort(narrow.sort)
|
|
297
|
+
if (sort) n.sort = sort
|
|
298
|
+
if (typeof narrow.cursor === 'string' && narrow.cursor) n.cursor = narrow.cursor
|
|
299
|
+
if (typeof narrow.limit === 'number' && narrow.limit > 0) n.limit = narrow.limit
|
|
300
|
+
return Object.keys(n).length > 0 ? n : null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A `sort` in the service's spelling — `date` / `-date` — or, for one outside the
|
|
305
|
+
* language, the author's text AS WRITTEN, which the service answers by its own rule
|
|
306
|
+
* (ignored: the author's arrangement). ⛔ Never a throw: this runs while a page's whole
|
|
307
|
+
* batch is being composed, before any request, and until 2026-09-14 a sort `parseSort`
|
|
308
|
+
* refuses — two keys, a bad direction — threw here and left every question of the batch
|
|
309
|
+
* pending forever.
|
|
310
|
+
*/
|
|
311
|
+
function wireSort(sort) {
|
|
312
|
+
try {
|
|
313
|
+
return sortToWire(sort)
|
|
314
|
+
} catch {
|
|
315
|
+
return typeof sort === 'string' && sort.trim() ? sort : null
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
271
319
|
/**
|
|
272
320
|
* Send one batch to the records service and hand each question its own answer.
|
|
273
321
|
*
|
|
@@ -275,8 +323,9 @@ function toQuestion(request) {
|
|
|
275
323
|
* contract rev D, 2026-09-13), each map absent when empty: `data` answers exactly the
|
|
276
324
|
* keys sent — `[]` when nothing matched, and `[]` too for a question an author got
|
|
277
325
|
* wrong, since the service answers a mistake rather than refusing it; `whole[key]` says
|
|
278
|
-
* a key was served as whole records; `cursors[key]` that more records remain
|
|
279
|
-
* question's `limit
|
|
326
|
+
* a key was served as whole records; `cursors[key]` that more records remain past the
|
|
327
|
+
* question's `narrow.limit` — a query's own `limit` defines the set and never produces
|
|
328
|
+
* one; `partial[key]` that a source of the key did not answer. A key
|
|
280
329
|
* missing from `data` is a protocol violation and is reported as an error, never as
|
|
281
330
|
* silence. ⛔ `errors` and `limits` are not read: the service stopped sending them in
|
|
282
331
|
* the same revision, and this client read both until 2026-09-14.
|
|
@@ -430,10 +479,13 @@ async function flushAsked(url, queue, doFetch) {
|
|
|
430
479
|
if (pending.size === 0) return
|
|
431
480
|
// Re-ask each unfinished key on its own — the cursor is per key, so a batch
|
|
432
481
|
// would have to correlate several independent positions through one body.
|
|
482
|
+
// ⭐ The cursor rides inside `narrow` and NOTHING ELSE in the question changes: the
|
|
483
|
+
// service resumes only the question a cursor came from, and answers a cursor sent
|
|
484
|
+
// with anything else changed with a `400`.
|
|
433
485
|
await Promise.all([...pending].map(([entry, state]) => {
|
|
434
486
|
const next = {
|
|
435
487
|
...entry,
|
|
436
|
-
request: { ...entry.request, cursor: state.cursor },
|
|
488
|
+
request: { ...entry.request, narrow: { ...(entry.request.narrow || {}), cursor: state.cursor } },
|
|
437
489
|
collected: state.collected,
|
|
438
490
|
page: state.page,
|
|
439
491
|
}
|
|
@@ -489,20 +541,16 @@ function sortLocale(request, ctx) {
|
|
|
489
541
|
|
|
490
542
|
/**
|
|
491
543
|
* Evaluate the query over what the source returned — the ONE evaluator,
|
|
492
|
-
* `@uniweb/core`'s
|
|
493
|
-
*
|
|
544
|
+
* `@uniweb/core`'s `evaluateQuery`, so the browser orders and filters exactly as
|
|
545
|
+
* the build does: the query's set first (`scope` over each record's placement,
|
|
546
|
+
* `where`, `sort`, `limit`), then the fetch's `narrow` of it. Only the sort is this
|
|
547
|
+
* lane's own, for what it does with a bad `sort:` in production.
|
|
494
548
|
*/
|
|
495
549
|
function applyOperators(data, request, { dev = false, locale = null } = {}) {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
// wrote the file — `scope: :dir` bound per page reaches here (2026-09-11).
|
|
501
|
-
if (typeof request.scope === 'string' && request.scope) result = applyScope(result, request.scope)
|
|
502
|
-
if (request.where) result = matchWhere(request.where, result)
|
|
503
|
-
if (request.sort) result = applySort(result, request.sort, dev, locale)
|
|
504
|
-
if (typeof request.limit === 'number' && request.limit > 0) result = result.slice(0, request.limit)
|
|
505
|
-
return result
|
|
550
|
+
return evaluateQuery(data, request, {
|
|
551
|
+
locale,
|
|
552
|
+
sort: (items, sortExpr, options) => applySort(items, sortExpr, dev, options.locale),
|
|
553
|
+
})
|
|
506
554
|
}
|
|
507
555
|
|
|
508
556
|
/**
|
package/src/prefetch.js
CHANGED
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
* `{base}/…` from the payload, or the records service the host itself
|
|
51
51
|
* published at `config.services.records`.
|
|
52
52
|
*/
|
|
53
|
-
import { resolveFetchConfigs, pageRouteQuery, routeSelection,
|
|
53
|
+
import { resolveFetchConfigs, fetchEntries, pageRouteQuery, routeSelection, currentFor, othersView, siteReaches } from '@uniweb/core/fetch-config'
|
|
54
54
|
import { deriveCacheKey } from '@uniweb/core/datastore'
|
|
55
55
|
import { findPageForRoute, isDynamicRoute, routeBinding, parentRouteOf } from '@uniweb/core/route-match'
|
|
56
56
|
import { buildDetailConfig } from '@uniweb/core/detail-url'
|
|
@@ -149,7 +149,6 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
|
|
|
149
149
|
site: siteFetch,
|
|
150
150
|
})
|
|
151
151
|
: null
|
|
152
|
-
const routeKey = chosen?.key ?? null
|
|
153
152
|
// A nested page's sections receive its capturing page's route binding, which the
|
|
154
153
|
// one-parent cascade may not reach — the entity store adds it the same way.
|
|
155
154
|
const routeSource = chosen?.nested ? chosen.config : null
|
|
@@ -159,33 +158,42 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
|
|
|
159
158
|
const key = deriveCacheKey(cfg)
|
|
160
159
|
if (!out.has(key)) out.set(key, cfg)
|
|
161
160
|
}
|
|
161
|
+
// ⭐ EVERY fetch of every level, each resolved on its own — not the first per key. A section
|
|
162
|
+
// receives the keys its component declares, and two fetches of one `as` at two levels can
|
|
163
|
+
// fill two of them (`fillDeclaredKeys`, 2026-09-14), which a prefetch holding no
|
|
164
|
+
// component's meta cannot rule out. A superset, which a prefetch can afford.
|
|
162
165
|
const add = (sources) => {
|
|
163
|
-
|
|
164
|
-
|
|
166
|
+
const resolved = sources.flatMap((source) => fetchEntries(source).map((entry) => resolveFetchConfigs([entry], options).get(entry.as)))
|
|
167
|
+
for (const cfg of resolved.filter(Boolean)) {
|
|
168
|
+
// how the fetch uses the page's record — by the query it names, as the entity store asks
|
|
169
|
+
const current = currentFor(cfg, chosen)
|
|
165
170
|
if (current === 'exclude') {
|
|
166
171
|
// the list one longer, from which the render removes the page's record
|
|
167
172
|
put(othersView(cfg))
|
|
168
173
|
continue
|
|
169
174
|
}
|
|
170
175
|
if (current !== 'only') {
|
|
171
|
-
//
|
|
176
|
+
// no part for the page's record, or `current: include` — the list as the fetch describes it
|
|
172
177
|
put(cfg)
|
|
173
178
|
continue
|
|
174
179
|
}
|
|
175
180
|
// ⭐ A parametric page is ABOUT one record, and on a lane with a per-record
|
|
176
181
|
// source (the records service, a `deferred:` query's per-record file) that
|
|
177
|
-
// record is a request of its own. Built for EVERY config the
|
|
178
|
-
//
|
|
179
|
-
// question
|
|
180
|
-
//
|
|
182
|
+
// record is a request of its own. Built for EVERY config that takes the page's
|
|
183
|
+
// record, by the one rule the entity store uses (`buildDetailConfig`), so
|
|
184
|
+
// the question prefetched is the question the render asks — and since that
|
|
185
|
+
// question drops a fetch's own narrowing, sections that narrow differently ask
|
|
186
|
+
// one record question between them.
|
|
181
187
|
if (cfg.detail && binding.paramValue !== undefined) {
|
|
182
188
|
const detailCfg = buildDetailConfig(cfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) })
|
|
183
189
|
if (detailCfg) put(detailCfg)
|
|
184
190
|
}
|
|
185
|
-
// On the records service the
|
|
186
|
-
//
|
|
187
|
-
// `
|
|
188
|
-
|
|
191
|
+
// ⭐ On the records service the record question checks the query's set on its
|
|
192
|
+
// own, so nothing else is asked. Off it, the record is FOUND in the set —
|
|
193
|
+
// the query as saved, without the fetch's `narrow` (`routeSelection`) — which is
|
|
194
|
+
// all the render reads for it. ⛔ Until 2026-09-14 the service's list was asked
|
|
195
|
+
// beside the record, and the page embedded the whole set to show one record.
|
|
196
|
+
if (!cfg.ask) put(routeSelection(cfg))
|
|
189
197
|
}
|
|
190
198
|
}
|
|
191
199
|
// The cascade a block sees: its own fetch, page, parent, a nested page's route
|
package/src/prepare-props.js
CHANGED
|
@@ -313,27 +313,54 @@ export function applyDefaults(params, defaults) {
|
|
|
313
313
|
}
|
|
314
314
|
|
|
315
315
|
/**
|
|
316
|
-
*
|
|
316
|
+
* ⭐ `content.data` HOLDS THE KEYS THE COMPONENT DECLARES, AND NOTHING ELSE — ruled 2026-09-14
|
|
317
|
+
* [Diego]. Every key in its `meta.js` `data:` and its foundation's `main.js` `data:`
|
|
318
|
+
* (`website.declaredKeys`), each filled, in order, from:
|
|
317
319
|
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* entity
|
|
321
|
-
*
|
|
322
|
-
*
|
|
320
|
+
* 1. what the section holds under that key (`block.heldData`) — a tagged data block, or
|
|
321
|
+
* its own fetch's answer a static build prerendered into it;
|
|
322
|
+
* 2. what the entity store delivered for it — the fetch that fills it (`fillDeclaredKeys`);
|
|
323
|
+
* 3. `null` — nothing fills it, or its fetch is still out (`block.dataLoading`) or failed
|
|
324
|
+
* (`block.dataError[key]`). ⭐ Never `[]`, which is an answer with no records.
|
|
325
|
+
*
|
|
326
|
+
* A tagged data block under a key the component does not declare is left out, and said so
|
|
327
|
+
* in dev (`warnUndeclaredBlocks`); it stays in `content.sequence`. The data object is
|
|
328
|
+
* rebuilt on each render from those sources, never from the last render's.
|
|
329
|
+
*
|
|
330
|
+
* ⛔ Until 2026-09-14 this merged every delivered key into what the section held, so a
|
|
331
|
+
* section received every fetch that reached it and every tagged block it had.
|
|
323
332
|
*/
|
|
324
|
-
function
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
if (
|
|
331
|
-
|
|
332
|
-
changed = true
|
|
333
|
-
}
|
|
333
|
+
function assembleData(block, meta, entityData) {
|
|
334
|
+
const declared = block.website?.declaredKeys ? block.website.declaredKeys(meta) : []
|
|
335
|
+
const held = block.heldData || {}
|
|
336
|
+
const data = {}
|
|
337
|
+
for (const [key] of declared) {
|
|
338
|
+
if (held[key] !== undefined) data[key] = held[key]
|
|
339
|
+
else if (entityData && entityData[key] !== undefined) data[key] = entityData[key]
|
|
340
|
+
else data[key] = null
|
|
334
341
|
}
|
|
335
|
-
|
|
336
|
-
|
|
342
|
+
warnUndeclaredBlocks(block, declared)
|
|
343
|
+
block.parsedContent.data = data
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Say once, in dev, that a tagged data block did not reach its section's component — its
|
|
348
|
+
* key is not declared. The block is still in `content.sequence`, where a component that
|
|
349
|
+
* renders the sequence finds it.
|
|
350
|
+
*/
|
|
351
|
+
const warnedBlocks = new Set()
|
|
352
|
+
function warnUndeclaredBlocks(block, declared) {
|
|
353
|
+
if (!block.website?.entityStore?.dev) return
|
|
354
|
+
const keys = new Set(declared.map(([key]) => key))
|
|
355
|
+
for (const item of block.parsedContent?.sequence || []) {
|
|
356
|
+
if (item?.type !== 'dataBlock' || typeof item.tag !== 'string' || keys.has(item.tag)) continue
|
|
357
|
+
const memo = `${block.type}::${item.tag}`
|
|
358
|
+
if (warnedBlocks.has(memo)) continue
|
|
359
|
+
warnedBlocks.add(memo)
|
|
360
|
+
console.warn(
|
|
361
|
+
`[uniweb] ${block.type}: the \`${item.tag}\` data block is not in content.data — ${block.type} does not ` +
|
|
362
|
+
`declare \`${item.tag}\` in its meta.js \`data:\`. It stays in content.sequence.`
|
|
363
|
+
)
|
|
337
364
|
}
|
|
338
365
|
}
|
|
339
366
|
|
|
@@ -434,8 +461,9 @@ function runPropsHandler(content, params, block) {
|
|
|
434
461
|
* renderers (`BlockRenderer.jsx` CSR and `ssr-renderer.js` SSG) share
|
|
435
462
|
* the same code path:
|
|
436
463
|
*
|
|
437
|
-
* 1.
|
|
438
|
-
*
|
|
464
|
+
* 1. Assemble `block.parsedContent.data` from the keys the component
|
|
465
|
+
* declares: what the section holds, else what EntityStore delivered,
|
|
466
|
+
* else `null` (`assembleData`).
|
|
439
467
|
* 2. Run the foundation data handler (if registered) to filter or
|
|
440
468
|
* reshape the assembled data.
|
|
441
469
|
* 3. Run the foundation content handler (if registered) on the
|
|
@@ -456,7 +484,11 @@ function runPropsHandler(content, params, block) {
|
|
|
456
484
|
* @returns {Object} Prepared props: { content, params }
|
|
457
485
|
*/
|
|
458
486
|
export function prepareProps(block, meta, entityData = null) {
|
|
459
|
-
|
|
487
|
+
assembleData(block, meta, entityData)
|
|
488
|
+
// ⭐ A list the section held before the store answered — its own fetch, prerendered
|
|
489
|
+
// into its content by a static build — gets its records' `$route` by the store's rule,
|
|
490
|
+
// which the store did not deliver it by (2026-09-14).
|
|
491
|
+
block.website?.entityStore?.linkOwnRecords?.(block)
|
|
460
492
|
runDataHandler(block)
|
|
461
493
|
runContentHandler(block)
|
|
462
494
|
|
package/src/ssr-renderer.js
CHANGED
|
@@ -231,8 +231,8 @@ export function renderBlock(block, { as = 'section' } = {}) {
|
|
|
231
231
|
}, `Component not found: ${block.type}`)
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
// Resolve
|
|
235
|
-
//
|
|
234
|
+
// Resolve entity data synchronously (SSG has no async): EntityStore fills the keys
|
|
235
|
+
// the component declares (`meta.data`) from the fetches that reach the block.
|
|
236
236
|
const meta = getComponentMeta(block.type)
|
|
237
237
|
const entityStore = block.website?.entityStore
|
|
238
238
|
let entityData = null
|