@uniweb/core 0.28.0 → 0.29.1

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.
@@ -14,15 +14,9 @@
14
14
  * and in-flight dedup.
15
15
  */
16
16
 
17
- import { resolveFetchConfigs, pageRouteQuery, routeSelection, currentOf, othersView, othersOf, siteReaches } from './fetch-config.js'
17
+ import { resolveFetchConfigs, fetchEntries, pageRouteQuery, routeSelection, currentFor, othersView, othersOf, siteReaches } from './fetch-config.js'
18
+ import { declaredKeys, fillDeclaredKeys } from './data-keys.js'
18
19
  import { fillRoutePattern, matchesRouteParam } from './route-match.js'
19
-
20
- /**
21
- * A fetch config's binding key — the `content.data.<key>` a component reads.
22
- * `as` is the name; `schema` is what it was called until 2026-09-02 and still
23
- * arrives on any payload published before then. See `fetch-config.js`.
24
- */
25
- const bindingKeyOf = (cfg) => cfg?.as
26
20
  import { buildDetailConfig } from './detail-url.js'
27
21
 
28
22
  export default class EntityStore {
@@ -39,34 +33,36 @@ export default class EntityStore {
39
33
  }
40
34
 
41
35
  /**
42
- * Which schemas does this component want delivered?
36
+ * The fetches that reach a block, one level each, most specific first: its own, its
37
+ * page's, its parent page's, a nested page's route binding, and the site's.
43
38
  *
44
- * - meta missing → default-on: collect all available schemas.
45
- * - meta.inheritData === false → opt out entirely.
46
- * - Anything else → collect all (legacy inheritData arrays collapse here).
39
+ * ⭐ A page nested inside a parametric page receives its capturing page's route binding
40
+ * — the cascade reaches one parent up, and the binding may sit above that
41
+ * (`pageRouteQuery`). Its key only; the rest does not cascade. The site's binding
42
+ * reaches a layout section and a top-level page's sections, and nothing deeper
43
+ * (`siteReaches`).
47
44
  */
48
- _getRequestedSchemas(meta) {
49
- if (!meta) return []
50
- if (meta.inheritData === false) return null
51
- return []
45
+ _levels(block, route = this._route(block)) {
46
+ const page = block.page
47
+ return [
48
+ block.fetch,
49
+ page?.fetch,
50
+ page?.parent?.fetch,
51
+ route?.nested ? route.config : null,
52
+ siteReaches(page?.parent) ? block.website?.config?.fetch : null,
53
+ ]
52
54
  }
53
55
 
54
56
  /**
55
- * Walk the four-level hierarchy and collect fetch configs for the
56
- * requested schemas. First match per schema wins.
57
- *
58
- * This method's job is to read the four source slots off the object graph;
59
- * the rule applied to them (precedence, first-match-per-schema, locale
60
- * normalization, deferred-detail injection) lives in `./fetch-config.js`,
61
- * shared with every other host that has to answer the same question. Do not
62
- * re-inline it here — divergence between copies is what the extraction
63
- * exists to prevent.
57
+ * The options each fetch of a block is resolved with. The rule applied to a fetch
58
+ * (locale normalization, route variables, deferred-detail injection) lives in
59
+ * `./fetch-config.js`, shared with every other host that has to answer the same
60
+ * question. Do not re-inline it here — divergence between copies is what the
61
+ * extraction exists to prevent.
64
62
  */
65
- _findFetchConfigs(block, requested, route = this._route(block)) {
66
- const blockFetch = block.fetch
67
- const page = block.page
63
+ _resolveOptions(block) {
68
64
  const website = block.website
69
- const dynamicContext = block.dynamicContext || page?.dynamicContext
65
+ const dynamicContext = block.dynamicContext || block.page?.dynamicContext
70
66
  // The route's variables, for a query that binds `:path` / `:dir` / `:slug` —
71
67
  // `routeBinding`'s, carried as `params` by every parametric page this runtime
72
68
  // or its build makes. A page that carries only its capture gets the same three
@@ -81,36 +77,69 @@ export default class EntityStore {
81
77
  }
82
78
  : null))
83
79
  : null
80
+ return {
81
+ locale: website?.getActiveLocale?.() ?? null,
82
+ defaultLocale: website?.getDefaultLocale?.() ?? null,
83
+ // ⚠️ `queries`, matching `resolveFetchConfigs`. This passed `collections`
84
+ // after the payload key was renamed — a dead option name, silently: the
85
+ // resolver simply saw no queries and stopped injecting `detail:`.
86
+ queries: website?.config?.queries ?? null,
87
+ // The host's services; its `records` row is the live-records lane.
88
+ // Absent on every static site and on local dev, which is why
89
+ // `resolveQuerySource` treats absence as the ordinary case and reads
90
+ // the compiled artifact without comment.
91
+ services: website?.config?.services ?? null,
92
+ variables,
93
+ }
94
+ }
84
95
 
85
- return resolveFetchConfigs(
86
- [
87
- blockFetch,
88
- page?.fetch,
89
- page?.parent?.fetch,
90
- // ⭐ A page nested inside a parametric page receives its capturing page's
91
- // route binding — the cascade reaches one parent up, and the binding may sit
92
- // above that (`pageRouteQuery`). Its key only; the rest does not cascade.
93
- route?.nested ? route.config : null,
94
- // The site's binding reaches a layout section and a top-level page's
95
- // sections, and nothing deeper (`siteReaches`).
96
- siteReaches(page?.parent) ? website?.config?.fetch : null,
97
- ],
98
- {
99
- schemas: requested,
100
- locale: website?.getActiveLocale?.() ?? null,
101
- defaultLocale: website?.getDefaultLocale?.() ?? null,
102
- // ⚠️ `queries`, matching `resolveFetchConfigs`. This passed `collections`
103
- // after the payload key was renamed — a dead option name, silently: the
104
- // resolver simply saw no queries and stopped injecting `detail:`.
105
- queries: website?.config?.queries ?? null,
106
- // The host's services; its `records` row is the live-records lane.
107
- // Absent on every static site and on local dev, which is why
108
- // `resolveQuerySource` treats absence as the ordinary case and reads
109
- // the compiled artifact without comment.
110
- services: website?.config?.services ?? null,
111
- variables,
112
- },
113
- )
96
+ /**
97
+ * ⭐ WHAT THIS SECTION RECEIVES, AND FROM WHICH FETCH — ruled 2026-09-14 [Diego]. The keys
98
+ * its component declares and its foundation's (`website.declaredKeys`), each paired with
99
+ * the fetch that fills it by the one rule (`fillDeclaredKeys`, automatic `as`) over the
100
+ * levels that reach the block. A declared key the block already holds — a tagged data
101
+ * block, or its own fetch's prerendered answer under that name — is not the store's to
102
+ * fill, and a fetch that fills no declared key is not asked. Each filling fetch is
103
+ * resolved on its own, so two fetches of one `as` can fill two keys.
104
+ *
105
+ * ⭐ `held` is the answer the block already holds for a filling fetch, under the fetch's
106
+ * own key: a static build prerenders a section's own fetch into its content by `as`, so
107
+ * a component that names the key differently still receives it without the browser
108
+ * asking again. Only for the first fetch of that key reaching the block — the one whose
109
+ * answer a build put there.
110
+ *
111
+ * ⛔ Until 2026-09-14 every fetch reaching the block was resolved and delivered under its
112
+ * `as` — the first per key — whether the component read it or not, and a component
113
+ * declaring its key under another name received nothing.
114
+ *
115
+ * @returns {Map<string, { cfg: Object, held: * }>} declared key → the resolved config of
116
+ * the fetch that fills it, and the answer the block holds for it (undefined when none)
117
+ */
118
+ _fills(block, meta, route) {
119
+ const website = block.website
120
+ const declared = website?.declaredKeys ? website.declaredKeys(meta) : declaredKeys(meta?.data)
121
+ const out = new Map()
122
+ if (declared.length === 0) return out
123
+ const holds = block.heldData || {}
124
+ const levels = this._levels(block, route)
125
+ const fills = fillDeclaredKeys(declared, levels, {
126
+ queries: website?.config?.queries ?? null,
127
+ held: declared.map(([key]) => key).filter((key) => holds[key] !== undefined),
128
+ })
129
+ // where the first fetch of each key reaching the block sits — the one a held answer
130
+ // belongs to
131
+ const firstOfKey = new Map()
132
+ levels.forEach((level, at) => fetchEntries(level).forEach((fetch, index) => {
133
+ if (!firstOfKey.has(fetch.as)) firstOfKey.set(fetch.as, `${at}:${index}`)
134
+ }))
135
+ const options = this._resolveOptions(block)
136
+ for (const [key, { fetch, level, index }] of fills) {
137
+ const cfg = resolveFetchConfigs([fetch], options).get(fetch.as)
138
+ if (!cfg) continue
139
+ const held = firstOfKey.get(fetch.as) === `${level}:${index}` ? holds[fetch.as] : undefined
140
+ out.set(key, { cfg, held })
141
+ }
142
+ return out
114
143
  }
115
144
 
116
145
  /**
@@ -144,27 +173,66 @@ export default class EntityStore {
144
173
  }
145
174
 
146
175
  /**
147
- * Post-process assembled records: for each fetch config that declares a
148
- * `detailPage` page-ref, resolve it to a locale route template (O(1), via the
149
- * Website's `_pageIdMap`) and inject a `route` on each record — so a dynamic-list
150
- * card links to the query's canonical detail page regardless of which page
151
- * the list sits on. Runs after `data` is fully assembled, in BOTH the sync (peek)
152
- * and async (fetch) paths. Replaces the old runtime `getQueryDetailRoute`
153
- * page-tree scan. A dangling `detailPage` (unresolvable ref) is a no-op — the
154
- * component degrades gracefully; records with a baked `route` (file lane) are kept.
176
+ * ⭐ EVERY RECORD LINKS TO ITS QUERY'S PAGE — `$route`, filled here at render time, on
177
+ * every lane (ruled 2026-09-14 [Diego]). For each delivered key, the page is the
178
+ * fetch's `detailPage` when it names one that resolves, else the page whose route
179
+ * query is the fetch's query (`website.recordPageFor`); each record gets that page's
180
+ * route filled from its own fields (`fillRoutePattern`) as `$route`, and a record that
181
+ * cannot fill it, or a key whose query has no page, gets none.
182
+ *
183
+ * ⭐ `$` marks a system field, as on `$uuid`, `$name` and `$tags` — so the link never
184
+ * lands in the author's namespace. A component writes `<Link href={item.$route}>`. ⛔ Until 2026-09-14 the link was `route`: the build baked
185
+ * it into compiled records from `route:` on a query, overwriting an entity's own
186
+ * `route`, and this filled it only from a `detailPage`, skipping any record that
187
+ * already had one.
188
+ *
189
+ * Runs after `data` is assembled, in the sync (peek) and async (fetch) paths alike.
190
+ * Records are copied, never mutated: one cached record backs every section that
191
+ * shows it, each of which may link it to a different page.
155
192
  */
156
- _applyDetailRoutes(data, configs, website) {
157
- if (!data || !website?.resolveDetailPageTemplate) return
158
- for (const [schema, cfg] of configs) {
159
- if (!cfg.detailPage) continue
160
- const items = data[schema]
193
+ _applyRecordRoutes(data, configs, website) {
194
+ if (!data || !website) return
195
+ for (const [key, cfg] of configs) {
196
+ const items = data[key]
161
197
  if (!Array.isArray(items) || items.length === 0) continue
162
- const template = website.resolveDetailPageTemplate(cfg.detailPage)
198
+ const template = recordRouteTemplate(cfg, website)
163
199
  if (!template) continue
164
- data[schema] = items.map((item) => addDetailRoute(item, template))
200
+ const linked = items.map((item) => withRecordRoute(item, template))
201
+ // an unchanged list keeps its identity, so a render that re-links changes nothing
202
+ if (linked.some((item, i) => item !== items[i])) data[key] = linked
165
203
  }
166
204
  }
167
205
 
206
+ /**
207
+ * `$route` on the records a section holds under its own fetch's key — what a static
208
+ * build prerendered into its content (`block.heldData`). ⭐ A declared key the block holds
209
+ * is filled from what it holds, not from this store (`runtime/src/prepare-props.js::
210
+ * assembleData`), so those records never passed `_applyRecordRoutes`; without this, a list
211
+ * a section fetches for itself reached its component with no links on a prerendered
212
+ * site. Linked by the same rule, on every render and idempotently: data that is already
213
+ * linked is left as it is, so its identity survives a re-render. (A held answer this store
214
+ * delivers under another key is linked where it is delivered.)
215
+ *
216
+ * The block's data object is replaced, never written into, since the object the build
217
+ * delivered may back more than this block.
218
+ *
219
+ * @param {Object} block
220
+ */
221
+ linkOwnRecords(block) {
222
+ const held = block?.parsedContent?.data
223
+ if (!held || typeof held !== 'object' || !block.fetch) return
224
+ const website = block.website ?? this.website
225
+ const configs = resolveFetchConfigs([block.fetch], {
226
+ queries: website?.config?.queries ?? null,
227
+ services: website?.config?.services ?? null,
228
+ locale: website?.getActiveLocale?.() ?? null,
229
+ defaultLocale: website?.getDefaultLocale?.() ?? null,
230
+ })
231
+ const linked = { ...held }
232
+ this._applyRecordRoutes(linked, configs, website)
233
+ if (Object.keys(linked).some((key) => linked[key] !== held[key])) block.parsedContent.data = linked
234
+ }
235
+
168
236
  /**
169
237
  * Build a detail-URL fetch config from a query config + dynamic context.
170
238
  *
@@ -198,22 +266,10 @@ export default class EntityStore {
198
266
  */
199
267
  resolve(block, meta) {
200
268
  const dispatcher = this.website?.fetcher
201
- let requested = this._getRequestedSchemas(meta)
202
-
203
- // If the component hasn't declared data inheritance but the block itself
204
- // has a fetch config, target the block's schema explicitly rather than
205
- // collecting all cascade matches.
206
- if (requested === null && block.fetch) {
207
- const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
208
- const schemas = blockFetchList.filter(bindingKeyOf).map(bindingKeyOf)
209
- if (schemas.length > 0) requested = schemas
210
- }
211
-
212
- if (requested === null) return { status: 'none', data: null }
213
-
214
269
  const route = this._route(block)
215
- const configs = this._findFetchConfigs(block, requested, route)
216
- if (configs.size === 0) return { status: 'none', data: null }
270
+ const fills = this._fills(block, meta, route)
271
+ if (fills.size === 0) return { status: 'none', data: null }
272
+ const configs = new Map([...fills].map(([key, { cfg }]) => [key, cfg]))
217
273
 
218
274
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
219
275
  const ctx = this._ctx(block)
@@ -221,11 +277,16 @@ export default class EntityStore {
221
277
  const data = {}
222
278
  let allCached = true
223
279
 
224
- for (const [schema, cfg] of configs) {
225
- // `current:` applies under the key the page's URL narrows, and nowhere else.
226
- const current = dynamicContext && route && schema === route.key ? currentOf(cfg) : null
280
+ for (const [schema, { cfg, held }] of fills) {
281
+ if (held !== undefined) {
282
+ // The block holds this fetch's answer already — a static build prerendered it.
283
+ data[schema] = held
284
+ continue
285
+ }
286
+ // How this fetch uses the page's record: by the query it names (`currentFor`).
287
+ const current = dynamicContext ? currentFor(cfg, route) : null
227
288
  if (current === 'exclude') {
228
- // The route query's records without this page's, `limit` counting the others.
289
+ // The query's records without this page's, `limit` counting the others.
229
290
  const cached = dispatcher?.peek(othersView(cfg), ctx)
230
291
  if (cached) {
231
292
  data[schema] = othersOf(cached.data, cfg, isPageRecord(dynamicContext))
@@ -245,8 +306,9 @@ export default class EntityStore {
245
306
  } else if (current === 'only') {
246
307
  // Detail page: deliver the focused record as a length-1 array under the
247
308
  // query key. A deferred/remote query fetches the full per-record;
248
- // others use the matched record. Not found → []. Found in the route
249
- // query's whole selection, never in a list its `limit` cut (`routeSelection`).
309
+ // others use the matched record. Not found → []. Found in the set of the
310
+ // query the fetch names — the route query's, or another's under `current: only`
311
+ // — never in a list a fetch narrowed (`routeSelection`).
250
312
  const cached = dispatcher?.peek(routeSelection(cfg), ctx)
251
313
  if (cached) {
252
314
  const { paramName, paramValue } = dynamicContext
@@ -279,8 +341,8 @@ export default class EntityStore {
279
341
  allCached = false
280
342
  }
281
343
  } else {
282
- // Any other key — and `current: include`, the route query's list as the
283
- // binding describes it, this page's record among the rest.
344
+ // A fetch the page's record plays no part in (`currentFor`), and `current:
345
+ // include`: the records as the fetch describes them, the page's among the rest.
284
346
  const cached = dispatcher?.peek(cfg, ctx)
285
347
  if (cached) {
286
348
  data[schema] = cached.data
@@ -291,7 +353,7 @@ export default class EntityStore {
291
353
  }
292
354
 
293
355
  if (allCached) {
294
- this._applyDetailRoutes(data, configs, block.website)
356
+ this._applyRecordRoutes(data, configs, block.website)
295
357
  return { status: 'ready', data }
296
358
  }
297
359
  return { status: 'pending', data: null }
@@ -319,17 +381,10 @@ export default class EntityStore {
319
381
  const dispatcher = this.website?.fetcher
320
382
  if (!dispatcher) return { data: null, errors: null }
321
383
 
322
- let requested = this._getRequestedSchemas(meta)
323
- if (requested === null && block.fetch) {
324
- const blockFetchList = Array.isArray(block.fetch) ? block.fetch : [block.fetch]
325
- const schemas = blockFetchList.filter(bindingKeyOf).map(bindingKeyOf)
326
- if (schemas.length > 0) requested = schemas
327
- }
328
- if (requested === null) return { data: null, errors: null }
329
-
330
384
  const route = this._route(block)
331
- const configs = this._findFetchConfigs(block, requested, route)
332
- if (configs.size === 0) return { data: null, errors: null }
385
+ const fills = this._fills(block, meta, route)
386
+ if (fills.size === 0) return { data: null, errors: null }
387
+ const configs = new Map([...fills].map(([key, { cfg }]) => [key, cfg]))
333
388
 
334
389
  const dynamicContext = block.dynamicContext || block.page?.dynamicContext
335
390
  const ctx = this._ctx(block, { signal })
@@ -342,12 +397,18 @@ export default class EntityStore {
342
397
  reportFetchFailure(this.dev, block, key, cfg, message)
343
398
  }
344
399
 
345
- for (const [schema, cfg] of configs) {
346
- // `current:` applies under the key the page's URL narrows, and nowhere else.
347
- const current = dynamicContext && route && schema === route.key ? currentOf(cfg) : null
400
+ for (const [schema, { cfg, held }] of fills) {
401
+ if (held !== undefined) {
402
+ // The block holds this fetch's answer already — a static build prerendered it.
403
+ data[schema] = held
404
+ continue
405
+ }
406
+ // How this fetch uses the page's record: by the query it names (`currentFor`).
407
+ const current = dynamicContext ? currentFor(cfg, route) : null
348
408
  if (current === 'exclude') {
349
- // The route query's records without this page's, `limit` counting the others:
350
- // asked one longer (`othersView`), so removing the record still leaves enough.
409
+ // The query's records without this page's, the fetch's `limit` counting
410
+ // the others: its `narrow.limit` asked one higher (`othersView`), so removing
411
+ // the record still leaves enough.
351
412
  const view = othersView(cfg)
352
413
  parallelFetches.push(dispatcher.dispatch(view, ctx).then((result) => {
353
414
  if (result?.error) {
@@ -360,14 +421,15 @@ export default class EntityStore {
360
421
  }))
361
422
  } else if (current === 'only' && cfg.ask) {
362
423
  // ⭐ THE RECORDS SERVICE needs no list to find the record: the record is the
363
- // same question narrowed by the route's handle, so list and record are
364
- // asked together — one round trip, and no client-side scan gating the
365
- // fetch (F13, the live half). The list is asked too, because the record
366
- // index files its briefs; the record's own answer is the answer.
424
+ // query's set narrowed by the route's handle, so its one question
425
+ // answers the record if the set holds it and `[]` if not — no client-side
426
+ // scan gating the fetch (F13, the live half).
427
+ //
428
+ // ⛔ The list was asked beside it until 2026-09-14, for the record index to
429
+ // file its briefs, while the record question dropped the query's `sort` and
430
+ // `limit` and so could not say whether the set held the record. It checks the
431
+ // set now, and the list sent the whole set to render one record of it.
367
432
  const detailCfg = this._buildDetailConfig(cfg, dynamicContext)
368
- parallelFetches.push(dispatcher.dispatch(cfg, ctx).then((result) => {
369
- if (result?.error) fail(schema, cfg, result.error)
370
- }))
371
433
  parallelFetches.push(dispatcher.dispatch(detailCfg, ctx).then((result) => {
372
434
  if (result?.error) {
373
435
  fail(schema, detailCfg, result.error)
@@ -377,9 +439,9 @@ export default class EntityStore {
377
439
  data[schema] = answer.slice(0, 1) // a route resolves to ONE; `[]` is not found
378
440
  }))
379
441
  } else if (current === 'only') {
380
- // Detail page: focused record as a length-1 array under the query key,
381
- // found in the route query's whole selection (`routeSelection`) — a record
382
- // past a list's `limit` still has its page.
442
+ // Detail page: focused record as a length-1 array under the fetch's key,
443
+ // found in its query's set (`routeSelection`) — a record past a
444
+ // fetch's `limit` still has its page, and one past the query's has none.
383
445
  const { paramName, paramValue } = dynamicContext
384
446
  const selection = routeSelection(cfg)
385
447
 
@@ -450,7 +512,7 @@ export default class EntityStore {
450
512
  }
451
513
 
452
514
  if (parallelFetches.length > 0) await Promise.all(parallelFetches)
453
- this._applyDetailRoutes(data, configs, block.website)
515
+ this._applyRecordRoutes(data, configs, block.website)
454
516
  return { data, errors: Object.keys(errors).length ? errors : null }
455
517
  }
456
518
  }
@@ -505,20 +567,37 @@ function peekArray(dispatcher, cfg, ctx) {
505
567
  }
506
568
 
507
569
  /**
508
- * Interpolate a record's fields into a detail-page route template to build its
509
- * `route` (the canonical href for a card). `/blog/:slug` + `{ slug: 'a-post' }`
510
- * → `/blog/a-post`. Returns a SHALLOW COPY with `route` added — never mutates the
511
- * cached record (the same query may back several sections with different
512
- * detail pages). Idempotent + back-compat: a record that already carries a `route`
513
- * (the file lane bakes one via the query processor) is returned untouched. A
514
- * `:param` with no matching record field → no `route` (graceful; degrades to the
515
- * component's own fallback rather than emitting a broken href).
570
+ * The route template a fetch's records link to — its `detailPage`, else its query's page.
571
+ *
572
+ * ⭐ A `detailPage` that no longer resolves — its page deleted, or no longer parametric —
573
+ * falls back to the query's own page rather than taking every card's link away: the
574
+ * author picked among the query's pages, and the query still has one. (The website warns
575
+ * in dev when a ref dangles.)
576
+ *
577
+ * @param {Object} cfg - a resolved config
578
+ * @param {Object} website
579
+ * @returns {string|null}
580
+ */
581
+ function recordRouteTemplate(cfg, website) {
582
+ if (cfg?.detailPage) {
583
+ const picked = website.resolveDetailPageTemplate?.(cfg.detailPage)
584
+ if (picked) return picked
585
+ }
586
+ return typeof cfg?.query === 'string' ? (website.recordPageFor?.(cfg.query)?.route ?? null) : null
587
+ }
588
+
589
+ /**
590
+ * A record with its `$route` — the template filled from the record's own fields
591
+ * (`/blog/:slug` + `{ $name: 'a-post' }` → `/blog/a-post`). A SHALLOW COPY, never the
592
+ * cached record. A record that cannot fill the template — a `:param` with no value on it
593
+ * — is returned as it is, with no `$route`, so a component renders no link rather than a
594
+ * broken one.
516
595
  *
517
- * ⭐ The encoding is `fillRoutePattern`'s, shared with the build's bake, so the
518
- * two producers of `item.route` agree — they did not (F14, 2026-09-04).
596
+ * ⭐ The encoding is `fillRoutePattern`'s: the one encoder for a record's href, which the
597
+ * route matcher decodes (F14, 2026-09-04).
519
598
  */
520
- function addDetailRoute(item, template) {
521
- if (!item || typeof item !== 'object' || item.route !== undefined) return item
599
+ function withRecordRoute(item, template) {
600
+ if (!item || typeof item !== 'object') return item
522
601
  const route = fillRoutePattern(template, item)
523
- return route === null ? item : { ...item, route }
602
+ return route === null || item.$route === route ? item : { ...item, $route: route }
524
603
  }