@uniweb/runtime 0.24.0 → 0.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "0.24.0",
3
+ "version": "0.26.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.28.0",
39
+ "@uniweb/core": "^0.29.1",
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.48.0"
47
+ "@uniweb/build": "0.50.0"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^19.0.0",
@@ -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
- * ## ⛔ `limit` IS DROPPED, and it is the one place a corpus must diverge
18
+ * ## ⭐ Each query is asked AS SAVED — its `limit` included
19
19
  *
20
- * A saved query's `limit` is the LIST PAGE's presentation: `limit: 20` means the
21
- * page shows twenty. **Its detail pages still exist for every record matching
22
- * `scope` + `where`** — so a corpus that honoured `limit` would index twenty and
23
- * miss every page beyond them, which is worse than indexing nothing because the
24
- * gap is invisible.
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
- * ⚠️ **This shipped wrong in 0.17.0 and was found by the consumer, not by us**
27
- * (2026-09-06): the config was passed through unchanged, `limit` crossed as the
28
- * question's own, and the corpus was capped. The claim that this surface met
29
- * "the population its detail pages can reach" was made *"read charitably"* — a
30
- * phrase doing work that one `sed` would have done better.
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 is bounded by `maxPages`.
65
- * An external query is never collected — it has no records service to ask.
66
- * @param {number} [options.maxPages] - the caller's own bound on the walk. The
67
- * default is a bound, not a target; a caller that knows its per-request budget
68
- * passes its own.
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]` carries
72
- * `{ depth, pages, partial?, bound? }`.
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
- // `limit` is the list page's, never the corpus's — see the header.
114
- const { limit, ...population } = cfg
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 })
@@ -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:` are evaluated HERE, locally, over what the
19
- * first lane returns — with `@uniweb/core`'s one evaluator, the same the build
20
- * uses to materialize a file — and at the source when asked. Nothing decides
21
- * that per site: the LANE decides.
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
- matchWhere,
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): `schema` required, `scope` a bare path, `sort`
234
- * one key spelled `date` / `-date`, `whole` when the whole record is wanted,
235
- * `match` on a parametric page's record. ⭐ The where-object crosses exactly as
236
- * authored: the evaluator and the service speak one language (`@uniweb/core`'s where evaluator).
237
- * ⛔ `nin` was respelled `not_in` here until 2026-09-13, when the evaluator took
238
- * `not_in` as its only spelling. Anything the service does not answer (a dotted
239
- * path, a retired operator) is sent as written and answered there: never
240
- * approximated.
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
- // ⭐ The record of a parametric page: the question unchanged plus `match` — one
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
- // ⭐ `cursor` is the ONLY field here that is not the author's: it is opaque and
262
- // it comes from a previous answer's `cursors` (the records contract §2). ⛔ And
263
- // `exhaustive` deliberately does NOT cross — it is a client instruction about
264
- // how many times to ask, not part of the question being asked.
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 under the
279
- * question's `limit`; `partial[key]` that a source of the key did not answer. A key
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, so the browser orders and filters exactly as the build
493
- * did when it materialized `/data/<name>.json`.
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
- if (!Array.isArray(data)) return data
497
- let result = data
498
- // `scope` first: it names the branch the rest of the query reads. On this lane
499
- // it is evaluated over each record's placement (`path`), as the build did when it
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
  /**
@@ -164,7 +164,16 @@ export const ISOLATE_API = Object.freeze({
164
164
  * ⚖️ Revisit at 1.0: once sites are pinned in the field, *"it does not break
165
165
  * sites"* stops being free, and this paragraph is the thing to re-argue.
166
166
  */
167
- export const WIRE_FLOOR = '0.21.0'
167
+ /*
168
+ * ⭐ **0.25.0, raised 2026-09-14 [Diego: "you can raise minUsable now to latest"]** — and
169
+ * it is also an incompatibility floor again: 0.25.0 is the first runtime that asks the
170
+ * records service with `narrow` — the query as saved at the top, the fetch's `where`,
171
+ * `sort`, `limit`, `match` and `cursor` inside `narrow`. Every earlier one sends a
172
+ * top-level `match` or `cursor`, which the service refuses with a whole-request `400`
173
+ * since it took `narrow`. It also carries declared-key delivery and `$route`, so a host
174
+ * at the floor may rely on both.
175
+ */
176
+ export const WIRE_FLOOR = '0.25.0'
168
177
 
169
178
  /**
170
179
  * ⭐ **THE MINIMUM RUNTIME VERSION A SITE MAY BE PUBLISHED AT.** At or above it,
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, currentOf, othersView, siteReaches } from '@uniweb/core/fetch-config'
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
- for (const cfg of resolveFetchConfigs(sources, options).values()) {
164
- const current = routeKey && cfg.as === routeKey ? currentOf(cfg) : null
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
- // any other key, or `current: include` — the list as the binding describes it
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 route key
178
- // resolves to — a section re-declaring the route query asks its own record
179
- // question — by the one rule the entity store uses (`buildDetailConfig`), so
180
- // the question prefetched is the question the render asks.
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 list is asked beside the record; off it, the
186
- // record is FOUND in the route query's whole selection — the list without its
187
- // `limit` (`routeSelection`) — which is all the render reads for it.
188
- put(cfg.ask ? cfg : routeSelection(cfg))
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
@@ -313,27 +313,54 @@ export function applyDefaults(params, defaults) {
313
313
  }
314
314
 
315
315
  /**
316
- * Merge entity data onto a block's parsedContent.data.
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
- * Section-level data already on the block (from prerender fetches via
319
- * blockData.parsedContent.data in the Block constructor) takes priority;
320
- * entity data only fills missing keys. Mutates `block.parsedContent.data`
321
- * in place so the vanilla JS layer holds the assembled data and
322
- * subsequent reads see the same shape.
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 mergeEntityData(block, entityData) {
325
- if (!entityData) return
326
- const current = block.parsedContent.data || {}
327
- let changed = false
328
- const merged = { ...current }
329
- for (const key of Object.keys(entityData)) {
330
- if (merged[key] === undefined) {
331
- merged[key] = entityData[key]
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
- if (changed) {
336
- block.parsedContent.data = merged
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. Merge entity data (resolved by EntityStore) onto
438
- * `block.parsedContent.data`.
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
- mergeEntityData(block, entityData)
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
 
@@ -231,8 +231,8 @@ export function renderBlock(block, { as = 'section' } = {}) {
231
231
  }, `Component not found: ${block.type}`)
232
232
  }
233
233
 
234
- // Resolve inherited entity data synchronously (SSG has no async).
235
- // EntityStore walks page/site hierarchy to find data matching meta.inheritData.
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