sygnal 6.4.0 → 6.6.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/dist/astro/index.cjs.js +398 -25
  3. package/dist/astro/index.cjs.js.map +1 -1
  4. package/dist/astro/index.mjs +398 -25
  5. package/dist/astro/index.mjs.map +1 -1
  6. package/dist/astro/server.cjs.js +3 -2
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs +3 -2
  9. package/dist/astro/server.mjs.map +1 -1
  10. package/dist/devtools.cjs.js +141 -3
  11. package/dist/devtools.cjs.js.map +1 -1
  12. package/dist/devtools.esm.js +141 -4
  13. package/dist/devtools.esm.js.map +1 -1
  14. package/dist/diagnostics.cjs.js +140 -21
  15. package/dist/diagnostics.cjs.js.map +1 -1
  16. package/dist/diagnostics.esm.js +140 -21
  17. package/dist/diagnostics.esm.js.map +1 -1
  18. package/dist/index.cjs.js +54 -24
  19. package/dist/index.cjs.js.map +1 -1
  20. package/dist/index.d.ts +139 -16
  21. package/dist/index.esm.js +54 -24
  22. package/dist/index.esm.js.map +1 -1
  23. package/dist/sygnal.min.js +1 -1
  24. package/dist/sygnal.min.js.map +1 -1
  25. package/dist/vike/config/package.json +1 -1
  26. package/dist/vike/onRenderClient.cjs.js +63 -43
  27. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  28. package/dist/vike/onRenderClient.mjs +63 -43
  29. package/dist/vike/onRenderClient.mjs.map +1 -1
  30. package/dist/vike/onRenderHtml.cjs.js +21 -25
  31. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  32. package/dist/vike/onRenderHtml.mjs +21 -25
  33. package/dist/vike/onRenderHtml.mjs.map +1 -1
  34. package/dist/vite/plugin.cjs.js +398 -25
  35. package/dist/vite/plugin.cjs.js.map +1 -1
  36. package/dist/vite/plugin.mjs +398 -25
  37. package/dist/vite/plugin.mjs.map +1 -1
  38. package/llms.txt +3 -3
  39. package/package.json +1 -1
  40. package/src/core/cell.ts +25 -7
  41. package/src/core/hooks.ts +6 -0
  42. package/src/core/hosts/collection.ts +6 -11
  43. package/src/core/hosts/switchable.ts +2 -2
  44. package/src/core/instance.ts +2 -2
  45. package/src/core/view.ts +2 -0
  46. package/src/cycle/dom/utils.ts +4 -2
  47. package/src/devtools.d.ts +13 -0
  48. package/src/devtools.ts +3 -0
  49. package/src/extra/devGraph.ts +111 -0
  50. package/src/extra/devMcp.ts +1 -1
  51. package/src/extra/devtools.ts +18 -0
  52. package/src/extra/diagnostics/checks/collections.ts +31 -25
  53. package/src/extra/diagnostics/checks/index.ts +1 -1
  54. package/src/extra/diagnostics/checks/inspect.ts +68 -6
  55. package/src/extra/diagnostics/checks/next.ts +22 -0
  56. package/src/extra/diagnostics/checks/public.d.ts +121 -12
  57. package/src/extra/diagnostics/codes.ts +10 -0
  58. package/src/index.d.ts +19 -3
  59. package/src/vike/onRenderClient.ts +44 -46
  60. package/src/vike/onRenderHtml.ts +5 -26
  61. package/src/vike/shell.ts +33 -0
  62. package/src/vite/graph.ts +247 -0
  63. package/src/vite/mcp.ts +3 -3
  64. package/src/vite/plugin.d.ts +12 -0
  65. package/src/vite/plugin.ts +136 -22
@@ -66,6 +66,21 @@ function removed(component: any, name: string, anchor: string, detail?: string):
66
66
  })
67
67
  }
68
68
 
69
+ /**
70
+ * SYG428 (6.5.0): `state="a.b"` / `from="a.b"` is a path, read as `state.a.b`. Before 6.5.0 it was
71
+ * one key named "a.b"; when the owner's state still has such a key, the designator now misses it.
72
+ */
73
+ function dottedKey(f: any, what: string, designator: any, ownerState: any): void {
74
+ if (typeof designator != 'string' || !/[.[]/.test(designator) || !isPlainObject(ownerState)) return
75
+ if (!Object.prototype.hasOwnProperty.call(ownerState, designator) || !once(`SYG428:${f.name}:${designator}`)) return
76
+ devReport('SYG428', {
77
+ component: f.name,
78
+ message: `${what}="${designator}" is a path (since 6.5.0), read as ${designator.replace(/\[(\d+)\]/g, '.$1').split('.').map((k: string) => `[${JSON.stringify(k)}]`).join('')}, but the state has a key named "${designator}"`,
79
+ fix: `Rename the key so it has no dot (and point ${what} at it), or use a lens: ${what}={{ get: (s) => s['${designator}'], set: (s, v) => ({ ...s, '${designator}': v }) }}`,
80
+ data: {designator, what},
81
+ })
82
+ }
83
+
69
84
  /** SYG427: CHILD.select(x) with no component (an import of a name the module doesn't export) */
70
85
  function notComponent(f: any, x: any): void {
71
86
  if (!once(`SYG427:${f.name}`)) return
@@ -373,11 +388,16 @@ export function nextHooks(_api: any): any {
373
388
  // G-527: one chain of SYG149 checks over the latest patches (G-501: it stops once every instance
374
389
  // that renders a named Collection is disposed, the app's dispose included)
375
390
  const vtCheck = vtChecker(() => vtp.size && on())
391
+ // SYG428: the live instances by id, to read a child's parent state
392
+ const live = new Map<any, any>()
376
393
  return {
377
394
  onCreate(iv: any) {
395
+ live.set(iv.id, iv)
378
396
  if (!on()) return
379
397
  log.onCreate(iv)
380
398
  const f = fac(iv)
399
+ const parent = live.get(iv.parentId)
400
+ if (parent) dottedKey(f, `<${f.name} state`, iv.props?.state, parent.state)
381
401
  removedStatics(f)
382
402
  if (!iv.def.intent) wire(iv, [])
383
403
  },
@@ -418,6 +438,7 @@ export function nextHooks(_api: any): any {
418
438
  },
419
439
  onDispose(iv: any) {
420
440
  vtp.delete(iv)
441
+ live.delete(iv.id)
421
442
  H.onDispose(fac(iv))
422
443
  },
423
444
  onAction(iv: any, a: any) { log.onAction(iv, a) },
@@ -451,6 +472,7 @@ export function nextHooks(_api: any): any {
451
472
  if (w.length) removed(f, f.name, 'collection-wrapper', w.map(k => k == 'dataset' ? 'data-*' : k == 'hook' ? 'ref / hook' : k).join(', '))
452
473
  }
453
474
  try { checkCollection(f, {data: {props}}) } catch (_) { /* ignore */ }
475
+ dottedKey(f, `<${sel == 'collection' ? 'Collection' : 'VirtualCollection'} from`, props.from, owner.state)
454
476
  // G-419: SYG148 for a prefix that isn't an identifier; a valid one is checked for
455
477
  // duplicate names after each patch (SYG149). G-460: a falsy one names nothing (as the host)
456
478
  const p = props.viewTransitionName
@@ -96,9 +96,23 @@ export function getCodeInfo(code: string): DiagnosticCodeInfo | undefined
96
96
 
97
97
  /** How an action is dispatched. */
98
98
  /** 'agent' (static, PLAN-6 K-1): only the component's `agent` declaration dispatches it */
99
- export type InspectActionTrigger = 'intent' | 'next' | 'reply' | 'builtin' | 'agent' | 'unknown'
99
+ /**
100
+ * 'behavior' (static, PLAN-10 A-8): a `uses` entry's action nothing else triggers that the behavior
101
+ * may dispatch itself where the checker can't list it (a package behavior, an open model, ...)
102
+ */
103
+ export type InspectActionTrigger = 'intent' | 'next' | 'reply' | 'builtin' | 'agent' | 'behavior' | 'unknown'
100
104
 
101
- export interface InspectAction {
105
+ /**
106
+ * PLAN-10 A-4 (static only): a source position, 1-based. `file` (relative to the working
107
+ * directory) only when it isn't the component's file.
108
+ */
109
+ export interface InspectLocation {
110
+ line: number
111
+ column: number
112
+ file?: string
113
+ }
114
+
115
+ export interface InspectAction extends Partial<InspectLocation> {
102
116
  name: string
103
117
  /**
104
118
  * 'intent': returned by the component's intent. 'builtin': BOOTSTRAP,
@@ -112,18 +126,56 @@ export interface InspectAction {
112
126
  trigger: InspectActionTrigger
113
127
  /** sinks of the model entry (STATE, EVENTS, EFFECT, PARENT, custom drivers); [] without a model entry */
114
128
  sinks: string[]
129
+ /** the `uses` key of the behavior that owns it ('pager' for 'pager.NEXT') */
130
+ behavior?: string
131
+ /** static, PLAN-10 A-4: the model entry (line/column on the action: the intent property, else this) */
132
+ modelAt?: InspectLocation
133
+ /**
134
+ * static, PLAN-10 A-7 (actions with a STATE sink only): the top-level state keys the STATE
135
+ * reducer(s) may write (a behavior's: its slice); [] when it never changes state; null when unknown
136
+ */
137
+ writes?: string[] | null
138
+ /** static, PLAN-10 A-7: a STATE reducer may return undefined (removes a Collection item) */
139
+ removes?: true
115
140
  }
116
141
 
117
- export interface InspectChild {
142
+ export interface InspectChild extends Partial<InspectLocation> {
118
143
  name: string
119
- via: 'tag' | 'collection' | 'switchable' | 'slot'
144
+ /**
145
+ * 'layout' (static, PLAN-10 A-9): Vike's shell, a +Wrapper / +Layout rendering the next +Layout in
146
+ * or a +Page it applies to (Wrapper(s) > Layout(s) > Page); no location (no JSX site)
147
+ */
148
+ via: 'tag' | 'collection' | 'switchable' | 'slot' | 'layout'
120
149
  /** Collection: the `from` state field, when known */
121
150
  from?: string | null
122
151
  /** runtime: number of live instances behind this entry (e.g. Collection items) */
123
152
  count?: number
153
+ /** runtime, PLAN-10 D522: the ids of those instances */
154
+ ids?: string[]
155
+ /**
156
+ * static, PLAN-10 A-3: the id of the component it renders (resolved through the import); null when
157
+ * it isn't in the graph (a package, a widget, a file not scanned, a dynamic value)
158
+ */
159
+ id?: string | null
160
+ /** static, PLAN-10 A-1: a literal `state` lens (`<Greeting state="host" />`) */
161
+ state?: string
162
+ /** static, PLAN-10 A-2: the package the tag is imported from ('sygnal/ui') */
163
+ external?: string
164
+ /** static, PLAN-10 A-2: a widget tag (defineWidget, fromZag, fromReact, the sygnal/ui Zag parts) */
165
+ widget?: true
124
166
  }
125
167
 
126
- export interface InspectSelector {
168
+ /** PLAN-10 A-5 (static): a CHILD.select(Child) call in an intent */
169
+ export interface InspectChildSelect extends InspectLocation {
170
+ /** the selected component's name as written */
171
+ child: string | null
172
+ /** its id; null when it isn't in the graph */
173
+ id: string | null
174
+ /** the intent actions whose stream contains the call (the receivers of the child's PARENT sink) */
175
+ actions: string[]
176
+ }
177
+
178
+ export interface InspectSelector extends Partial<InspectLocation> {
127
179
  /** CSS selector passed to DOM.select() (chained selects joined with a space) */
128
180
  selector: string
129
181
  /** event types listened to; null when unknown (runtime, real DOM) */
@@ -139,10 +191,12 @@ export interface InspectSelector {
139
191
  isolationHit: string | null
140
192
  /** PLAN-4 CT-1: the control this selector is (its key), when it is `[data-control="<Key>"]` */
141
193
  control?: string
194
+ /** the `uses` key of the behavior that listens to it */
195
+ behavior?: string
142
196
  }
143
197
 
144
198
  /** PLAN-4 CT-1: a control (controls()) a component renders */
145
- export interface InspectControl {
199
+ export interface InspectControl extends Partial<InspectLocation> {
146
200
  /** the control's key (rendered as data-control="<name>") */
147
201
  name: string
148
202
  /** the intrinsic tag of a tag spec; null for a spec object */
@@ -157,10 +211,14 @@ export interface InspectDiagnostic {
157
211
  code: string
158
212
  severity: DiagnosticSeverity
159
213
  component?: string
214
+ /** static, PLAN-10 A-3: the id of the component it is attached to */
215
+ componentId?: string
160
216
  message: string
161
217
  fix?: string
162
218
  docsUrl?: string
163
219
  data?: any
220
+ /** runtime only: when it was reported (ms since the epoch; each is reported once per process) */
221
+ timestamp?: number
164
222
  /** static only */
165
223
  file?: string
166
224
  line?: number
@@ -215,6 +273,12 @@ export interface InspectComponent {
215
273
  name: string
216
274
  /** runtime: instance number (as a string); static: 'file:line' of the definition */
217
275
  id: string
276
+ /**
277
+ * static, PLAN-10 D521: 'file#name', stable when lines move (hosts key selection and positions by it);
278
+ * a second component of the same name in one file gets 'file#name#2', and so on. Runtime (PLAN-10 P-2,
279
+ * D544): the same key when sygnal/vite's dev server stamped the component function, else absent
280
+ */
281
+ key?: string
218
282
  /** runtime: the parent instance's id; static: null (see the parents' `children`) */
219
283
  parentId: string | null
220
284
  /** static only: source file, relative to the working directory */
@@ -224,6 +288,32 @@ export interface InspectComponent {
224
288
  * component renders it, else how it is first rendered
225
289
  */
226
290
  kind: 'root' | 'child' | 'collection-item' | 'switchable'
291
+ /** PLAN-10 A-1 / D522: rendered as a component (or started as an entry) but has no statics */
292
+ viewOnly?: true
293
+ /**
294
+ * static, PLAN-10 A-6: started by run(), defineElement() or a Vike +Page / +Layout / +Wrapper; 'unknown' on the
295
+ * other roots when an entry call's component can't be resolved (picked at run time).
296
+ * runtime (D522): true on an app's root instance
297
+ */
298
+ entry?: true | 'unknown'
299
+ /**
300
+ * static, Vike only (PLAN-10 A-9): on a +Page's component its route ('/orders/@id': from the
301
+ * filesystem, or the literal string a +route file / the config's `route` sets); null for a Route
302
+ * Function or a route the checker can't read (with `routeDynamic`) and for _error/+Page. On a
303
+ * +Layout's / +Wrapper's: the URL prefix of the directory it applies to
304
+ */
305
+ route?: string | null
306
+ /**
307
+ * static, Vike only (PLAN-10 A-9): every route of a component several +Page files export (`route` is
308
+ * the first, in path order; null for a dynamic one or the error page); omitted otherwise
309
+ */
310
+ routes?: (string | null)[]
311
+ /** static, Vike only (PLAN-10 A-9): the route is a Route Function or can't be read (route: null) */
312
+ routeDynamic?: true
313
+ /** runtime, PLAN-10 D522: the app (run()) it belongs to: its root's index among the live roots, oldest first */
314
+ app?: number
315
+ /** runtime, PLAN-10 D522: false on a hidden Switchable page and below it; omitted when shown */
316
+ shown?: false
227
317
  actions: InspectAction[]
228
318
  /** state keys (runtime: current state; static: initialState) */
229
319
  stateKeys: string[]
@@ -231,14 +321,27 @@ export interface InspectComponent {
231
321
  calculated: string[]
232
322
  /** context fields this component provides (Component.context) */
233
323
  contextProvides: string[]
234
- /** context fields its view reads (static only; null at runtime) */
324
+ /** context fields its view reads (static: the view source; runtime, D530: what its latest render read) */
235
325
  contextConsumes?: string[] | null
326
+ /**
327
+ * static, PLAN-10 A-7: top-level state keys the view (and its child lenses, calculated and
328
+ * context functions) reads literally; omitted when none
329
+ */
330
+ stateReads?: string[]
331
+ /** static, PLAN-10 A-7: stateReads may be incomplete (the state used whole somewhere) */
332
+ stateReadsPartial?: true
236
333
  /** EVENTS types this component emits */
237
334
  eventsEmitted: string[]
238
335
  /** EVENTS types this component selects ('*' = EVENTS.select() without a type) */
239
336
  eventsSelected: string[]
240
337
  children: InspectChild[]
241
338
  selectors: InspectSelector[]
339
+ /** static, PLAN-10 A-4: where the `context` static's entries are */
340
+ contextEntries?: Array<InspectLocation & { name: string }>
341
+ /** static, PLAN-10 A-4: the EVENTS emit and select sites of its intent and model */
342
+ eventSites?: Array<InspectLocation & { type: string; role: 'emit' | 'select' }>
343
+ /** static, PLAN-10 A-5: CHILD.select(Child) in its intent */
344
+ childSelects?: InspectChildSelect[]
242
345
  /** PLAN-4 CT-1: the controls it renders (runtime: seen in its renders); omitted when none */
243
346
  controls?: InspectControl[]
244
347
  /** static only, PLAN-4 GS-2 (G-224): the element commands (ELEMENT sink) its model sends; omitted when none */
@@ -253,7 +356,7 @@ export interface InspectComponent {
253
356
  }
254
357
 
255
358
  /** PLAN-4 GS-2: an element command a model entry sends (static inspect, sygnal-check --graph) */
256
- export interface InspectCommand {
359
+ export interface InspectCommand extends Partial<InspectLocation> {
257
360
  /** the model entry that sends it */
258
361
  action: string
259
362
  /** the command's method (its first key) */
@@ -275,13 +378,15 @@ export interface InspectAgent {
275
378
  description: string | null
276
379
  /** the declaration has a read projection */
277
380
  read: boolean
278
- actions: Array<{ name: string; consequential: boolean; input: boolean }>
381
+ actions: Array<{ name: string; consequential: boolean; input: boolean } & Partial<InspectLocation>>
382
+ /** the read-only `agent.queries` tools; omitted when none */
383
+ queries?: Array<{ name: string; input: boolean } & Partial<InspectLocation>>
279
384
  /** spreads: what is listed may be incomplete */
280
385
  partial?: true
281
386
  }
282
387
 
283
388
  /** PLAN-4 GS-7: a literal timer spec of a `timers` static (static inspect, sygnal-check --graph) */
284
- export interface InspectTimer {
389
+ export interface InspectTimer extends Partial<InspectLocation> {
285
390
  name: string
286
391
  every?: number | null
287
392
  after?: number | null
@@ -316,8 +421,12 @@ export interface InspectGraph {
316
421
  /** which implementation produced the graph */
317
422
  source?: 'runtime' | 'static'
318
423
  components: InspectComponent[]
319
- /** EVENTS bus: type -> names of the components that emit / select it */
320
- events: Record<string, { emitters: string[]; selectors: string[] }>
424
+ /**
425
+ * EVENTS bus: type -> names of the components that emit / select it (each name once); emitterIds /
426
+ * selectorIds (PLAN-10 A-3, D522, D528): the id of every such component (static) or instance
427
+ * (runtime), not parallel to the names
428
+ */
429
+ events: Record<string, { emitters: string[]; selectors: string[]; emitterIds?: string[]; selectorIds?: string[] }>
321
430
  /** diagnostics not tied to a listed component */
322
431
  diagnostics: InspectDiagnostic[]
323
432
  /** runtime, PLAN-3 5-3: makeFetchDriver cache entries, by sink name (empty when no cache) */
@@ -152,6 +152,9 @@ export const CODE_TITLES: Record<string, string> = {
152
152
  SYG127: 'Behavior collision or unresolvable uses entry',
153
153
  SYG128: 'Duplicate control key',
154
154
  SYG129: "CHILD.select() of a component this one doesn't render",
155
+ // PLAN-10 X-1 (D512): graph-wide checks, static only (sygnal-check)
156
+ SYG170: 'Context field read with no provider',
157
+ SYG171: 'PARENT sink no parent reads',
155
158
  SYG112: 'Reply action has no model entry',
156
159
  SYG130: 'href() names no route or leaves out a param',
157
160
  SYG131: 'Route params the pattern does not use',
@@ -281,6 +284,8 @@ export const CODE_TITLES: Record<string, string> = {
281
284
  // checks/next.ts and sygnal-check: a component reference that is not a function (an import of
282
285
  // a name the module doesn't export is undefined under Vitest)
283
286
  SYG427: 'CHILD.select() of something that is not a component',
287
+ // 6.5.0 (checks/next.ts): state="a.b" / from="a.b" is a path; the parent state has a literal "a.b" key
288
+ SYG428: 'A state path that names a key containing a dot',
284
289
  // PLAN-9 (D470): checks/next.ts; dev entry
285
290
  SYG453: 'Two children with the same identity share one instance',
286
291
  SYG601: "Retired in 6.0: Missing or invalid sources",
@@ -425,6 +430,9 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
425
430
  SYG128: 'error',
426
431
  // PLAN-4 4-G2: static only (sygnal-check); listed so the entry knows its title
427
432
  SYG129: 'warn',
433
+ // PLAN-10 X-1: static only (sygnal-check)
434
+ SYG170: 'warn',
435
+ SYG171: 'warn',
428
436
  SYG221: 'error',
429
437
  // PLAN-4 GS-4 (state.ts)
430
438
  SYG222: 'warn',
@@ -491,6 +499,8 @@ export const DEV_CODE_SEVERITY: Record<string, DiagnosticSeverity> = {
491
499
  SYG426: 'warn',
492
500
  // checks/next.ts: CHILD.select(undefined), usually a missing export
493
501
  SYG427: 'error',
502
+ // checks/next.ts: a path designator that 6.4 read as one key with a dot
503
+ SYG428: 'warn',
494
504
  // PLAN-9 (checks/next.ts): D470 key identity, two children with one identity
495
505
  SYG453: 'warn',
496
506
  SYG612: 'error',
package/src/index.d.ts CHANGED
@@ -59,7 +59,9 @@ export type ViewProps<STATE = any, PROPS = {}, CONTEXT = {}> =
59
59
 
60
60
  /**
61
61
  * The `state` prop a parent passes to a sub-component in JSX: the name of a field of the
62
- * parent's state (`state="editor"`) or a lens. Without it the child shares the parent's state.
62
+ * parent's state (`state="editor"`), a path to nested state (`state="settings.rules"`,
63
+ * `state="boards[0].title"`; since 6.5.0: a write copies only the levels on the path), or a
64
+ * lens. Without it the child shares the parent's state.
63
65
  */
64
66
  export type StateProp = string | Lense<any, any>
65
67
 
@@ -1574,13 +1576,25 @@ export type ArrayKeysOf<STATE> = {
1574
1576
  [KEY in keyof STATE]-?: NonNullable<STATE[KEY]> extends ReadonlyArray<any> ? KEY : never
1575
1577
  }[keyof STATE] & string
1576
1578
 
1579
+ /**
1580
+ * Array-valued keys of STATE and dotted paths to arrays in its nested objects, up to three levels
1581
+ * (`'tasks' | 'board.cards'`; since 6.5.0). Paths through array positions (`'boards.0.cards'`)
1582
+ * aren't listed: for a list item, use `byId`.
1583
+ */
1584
+ export type ArrayPathsOf<STATE, DEPTH extends unknown[] = []> = DEPTH['length'] extends 3 ? never : {
1585
+ [KEY in keyof STATE & string]-?: NonNullable<STATE[KEY]> extends ReadonlyArray<any> ? KEY
1586
+ : NonNullable<STATE[KEY]> extends Record<string, any> ? `${KEY}.${ArrayPathsOf<NonNullable<STATE[KEY]>, [...DEPTH, 1]>}`
1587
+ : never
1588
+ }[keyof STATE & string]
1589
+
1577
1590
  /**
1578
1591
  * Valid `from` values for a Collection: any string or Lense while the parent STATE is unknown
1579
- * (`any`); otherwise an array-valued key of STATE or a Lense over STATE.
1592
+ * (`any`); otherwise an array-valued key of STATE, a dotted path to one (`'board.cards'`), or a
1593
+ * Lense over STATE.
1580
1594
  */
1581
1595
  export type CollectionFrom<STATE = any> = 0 extends (1 & STATE)
1582
1596
  ? string | Lense
1583
- : ArrayKeysOf<STATE> | Lense<STATE, any>
1597
+ : ArrayPathsOf<STATE> | Lense<STATE, any>
1584
1598
 
1585
1599
  /**
1586
1600
  * Collection props. Pass the parent component's state type as STATE to type-check `from`:
@@ -1797,6 +1811,8 @@ export type {
1797
1811
  InspectRecentAction,
1798
1812
  InspectCommand,
1799
1813
  InspectTimer,
1814
+ InspectLocation,
1815
+ InspectChildSelect,
1800
1816
  } from './extra/diagnostics/checks/public'
1801
1817
 
1802
1818
  /**
@@ -13,12 +13,16 @@
13
13
  * - Wrappers are outermost (context providers, state management)
14
14
  * - Layouts are inside wrappers (visual structure, navigation chrome)
15
15
  * - Page is innermost (route-specific content, swapped on navigation)
16
+ * Cumulative Layouts / Wrappers nest the root's outermost (G-803, see shell.ts). Navigating to a
17
+ * page with another shell (pages/admin/+Layout) keeps the shell components both pages share
18
+ * mounted, from the outside in, and adds or removes the rest.
16
19
  */
17
20
 
18
21
  // Import from the package entry so rollup externalizes it
19
22
  // @ts-ignore — resolved at runtime via package exports
20
23
  import { run } from 'sygnal'
21
24
  import { owned } from '../extra/owned'
25
+ import { shellOf, uidPart, type ShellPart } from './shell'
22
26
 
23
27
  declare global {
24
28
  interface Window {
@@ -47,6 +51,8 @@ interface PageContext {
47
51
 
48
52
  /** Track the running Sygnal app for disposal on navigation */
49
53
  let currentApp: { sources: any; sinks: any; dispose: () => void } | null = null
54
+ /** G-803: the running shell, outermost first (read by the wrapper's view); null: an app without one */
55
+ let currentShell: ShellPart[] | null = null
50
56
 
51
57
  /**
52
58
  * Mutable references read by the wrapper's view function.
@@ -59,16 +65,6 @@ let currentRouteParams: any = {}
59
65
  let currentUrlPathname: string = ''
60
66
  let pageNavCounter: number = 0
61
67
 
62
- /**
63
- * PLAN-4 G-207: the uid() path part of a shell component or the Page: wrapper_0 → 'w0',
64
- * layout_1 → 'l1', page → 'p'. Given as the nested vnode's `id` prop, so the Page's uid is
65
- * 'u-w0-l0-p' however the shell nests it; onRenderHtml renders each one with the same root
66
- * (renderToString's `uid`), so server and client ids match. (Same helper in onRenderHtml.ts.)
67
- */
68
- function uidPart(key: string): string {
69
- return key == 'page' ? 'p' : key[0] + key.slice(key.indexOf('_') + 1)
70
- }
71
-
72
68
  /**
73
69
  * PLAN-4.6 R4 (04 §3.11): the core instantiates a component from the vnode's `data.c`, a
74
70
  * component function, and reads its statics there (no options object). A shell component or a
@@ -143,11 +139,11 @@ function pageChildVNode(pageState: any, stateField: any = 'page'): any {
143
139
  *
144
140
  * Nesting order: Wrapper(s) > Layout(s) > Page
145
141
  *
146
- * The wrapper's view reads `currentPage` from the mutable closure, so
147
- * on client-side navigation only the Page sub-component changes while
148
- * the Layout and Wrapper stay mounted with their state preserved.
142
+ * The wrapper's view reads `currentPage` and `currentShell` from the mutable closure, so
143
+ * on client-side navigation only the Page sub-component (and the shell components the new page
144
+ * doesn't share) change while the Layout and Wrapper stay mounted with their state preserved.
149
145
  */
150
- function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
146
+ function createLayoutWrapper(Page: any): any {
151
147
  currentPage = Page
152
148
  currentPageName = Page.componentName || Page.name || 'VikePageComponent'
153
149
 
@@ -158,9 +154,6 @@ function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
158
154
  // one (and the Page) gets a { get, set } lens onto its own ROOT slice: an outer component's
159
155
  // reducer replacing its state can't wipe the inner slices (R2-2), and no view sees another
160
156
  // component's slice.
161
- const keys = shellKeys(wrappers, layouts)
162
- const shell = [...wrappers, ...layouts].map((comp: any, i: number) => ({ comp, key: keys[i] }))
163
-
164
157
  // The latest root state. Only the outermost lens gets the root itself; the nested lenses get
165
158
  // their parent shell component's state. In both directions the outermost lens runs first and
166
159
  // synchronously (state: root -> outer get -> inner get; a reducer: outer get, inner get, inner
@@ -180,10 +173,13 @@ function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
180
173
  return outermost ? root : { ...state }
181
174
  },
182
175
  })
183
- const lenses = keys.map((key, i) => rootLens(key, i === 0, true))
176
+ // one lens per key and place (outermost or not), the same object on every render
177
+ const lenses: Record<string, any> = {}
178
+ const lensOf = (key: string, outermost: boolean) => (lenses[key + outermost] ||= rootLens(key, outermost, true))
184
179
  const pageLens = rootLens('page', false, false)
185
180
 
186
181
  function LayoutWrapperView({ state }: any) {
182
+ const shell = currentShell || []
187
183
  if (shell.length === 0) {
188
184
  // No wrappers or layouts — render Page directly
189
185
  return pageChildVNode(state.page)
@@ -193,14 +189,14 @@ function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
193
189
  let inner: any = componentVNode(
194
190
  shell[lastIdx].comp,
195
191
  shell[lastIdx].key,
196
- lenses[lastIdx],
192
+ lensOf(shell[lastIdx].key, lastIdx === 0),
197
193
  [pageChildVNode(state.page, pageLens)],
198
194
  state[shell[lastIdx].key]
199
195
  )
200
196
 
201
197
  // Wrap with outer shell components
202
198
  for (let i = lastIdx - 1; i >= 0; i--) {
203
- inner = componentVNode(shell[i].comp, shell[i].key, lenses[i], [inner], state[shell[i].key])
199
+ inner = componentVNode(shell[i].comp, shell[i].key, lensOf(shell[i].key, i === 0), [inner], state[shell[i].key])
204
200
  }
205
201
 
206
202
  // Wrap in a plain div so the view returns a regular DOM vnode.
@@ -223,6 +219,7 @@ function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
223
219
  // The wrapper's initial state: a sibling slice for each shell component and the Page.
224
220
  // Must match onRenderHtml's serialized state.
225
221
  const initialState: any = {}
222
+ const shell = currentShell || []
226
223
  shell.forEach(({ comp, key }: any) => { initialState[key] = { ...(comp.initialState || {}) } })
227
224
  initialState.page = Page.initialState || {}
228
225
  // G-275: owned (built from the hydrated state and pageContext.data: never frozen in dev)
@@ -250,33 +247,18 @@ function createLayoutWrapper(wrappers: any[], layouts: any[], Page: any): any {
250
247
  return LayoutWrapperView
251
248
  }
252
249
 
253
- /** State keys of the shell components, outermost first: wrapper_0, ..., layout_0, ... */
254
- function shellKeys(wrappers: any[], layouts: any[]): string[] {
255
- return [
256
- ...wrappers.map((_: any, i: number) => 'wrapper_' + i),
257
- ...layouts.map((_: any, i: number) => 'layout_' + i),
258
- ]
259
- }
260
-
261
- /**
262
- * Normalize a cumulative config value into an array of functions.
263
- */
264
- function toComponentArray(val: any): any[] {
265
- if (!val) return []
266
- return (Array.isArray(val) ? val : [val]).filter((c: any) => typeof c === 'function')
267
- }
268
-
269
250
  export function onRenderClient(pageContext: PageContext) {
270
251
  const { Page, config } = pageContext
271
252
  const data = pageContext.data || {}
272
253
 
273
- const wrappers = toComponentArray(config.Wrapper)
274
- const layouts = toComponentArray(config.Layout)
275
- const hasShell = wrappers.length > 0 || layouts.length > 0
254
+ // The shell, outermost first (G-803: Vike lists cumulative configs closest first); the state
255
+ // slices of its components and the Page's are siblings at the root (D50)
256
+ const parts = shellOf(config)
257
+ const hasShell = parts.length > 0
258
+ const shell = parts.map(p => p.key)
276
259
 
277
- // The shell is wrappers (outermost) + layouts; their state slices and the Page's are
278
- // siblings at the root (D50).
279
- const shell = shellKeys(wrappers, layouts)
260
+ // a shell page after a page without one (or the reverse, below): a new app
261
+ if (hasShell && currentApp && !currentShell) { currentApp.dispose(); currentApp = null }
280
262
 
281
263
  // PLAN-3 5-5 (H-7): seed the caches of the fetch drivers (makeFetchDriver({ cache: queryCache() })
282
264
  // exposes it as driver.cache) before the page renders, so its resources start at 'success'
@@ -304,8 +286,22 @@ export function onRenderClient(pageContext: PageContext) {
304
286
  pageNavCounter++
305
287
 
306
288
  const newPageState = { ...(Page.initialState || {}), ...data }
307
- // replace the page slice, keeping every shell slice (G-106, D50)
308
- const swapPage = (state: any) => ({ ...state, page: newPageState })
289
+ // replace the page slice, keeping every shell slice (G-106, D50). G-803: a page with another
290
+ // shell keeps the slices of the components it shares at the same place (key) and starts the
291
+ // others from their initialState
292
+ const old = currentShell!
293
+ const same = parts.length === old.length && parts.every((p, i) => p.comp === old[i].comp)
294
+ let swapPage = (state: any) => ({ ...state, page: newPageState })
295
+ if (!same) {
296
+ currentShell = parts
297
+ swapPage = (state: any) => {
298
+ const next: any = { page: newPageState }
299
+ for (const { comp, key } of parts) {
300
+ next[key] = old.some(o => o.key === key && o.comp === comp) ? state[key] : { ...(comp.initialState || {}) }
301
+ }
302
+ return next
303
+ }
304
+ }
309
305
  // (PLAN-4.6 core: through the runtime API, 04 §3.11)
310
306
  const rt = (currentApp as any).__runtime
311
307
  rt?.setState('root', swapPage)
@@ -322,7 +318,7 @@ export function onRenderClient(pageContext: PageContext) {
322
318
  if (initialState && initialState[shell[shell.length - 1]] !== undefined) {
323
319
  // Hydrated shell state: sibling slices (D50), or the 5.4.0 shape with the Page state
324
320
  // under the innermost shell slice (R2-9) — distribute the slices to each component
325
- const allShellComps = [...wrappers, ...layouts]
321
+ const allShellComps = parts.map(p => p.comp)
326
322
  let pageState = initialState.page
327
323
  shell.forEach((key: string, i: number) => {
328
324
  let slice = initialState[key]
@@ -348,7 +344,8 @@ export function onRenderClient(pageContext: PageContext) {
348
344
  urlPathname: () => currentUrlPathname,
349
345
  }
350
346
 
351
- const Component = createLayoutWrapper(wrappers, layouts, Page)
347
+ currentShell = parts
348
+ const Component = createLayoutWrapper(Page)
352
349
 
353
350
  try {
354
351
  // 3-B2: `__vike` marks the shell's components (Page, Layouts, Wrappers: sub-components of
@@ -372,6 +369,7 @@ export function onRenderClient(pageContext: PageContext) {
372
369
  currentApp.dispose()
373
370
  currentApp = null
374
371
  }
372
+ currentShell = null
375
373
 
376
374
  let initialState: any
377
375
  // 3-B2: the server's state is here when the page hydrates its HTML
@@ -16,6 +16,7 @@
16
16
 
17
17
  // @ts-ignore — resolved at runtime via package exports
18
18
  import { renderToString, renderHead, queryCache } from 'sygnal'
19
+ import { shellOf, uidPart } from './shell'
19
20
 
20
21
  interface PageContext {
21
22
  Page: any
@@ -54,16 +55,6 @@ function esc(str: string): string {
54
55
  .replace(/"/g, '&quot;')
55
56
  }
56
57
 
57
- /**
58
- * PLAN-4 G-207: the uid() path part of a shell component or the Page (wrapper_0 → 'w0',
59
- * layout_1 → 'l1', page → 'p'), as onRenderClient gives each nested vnode in its `id` prop. Each
60
- * one renders here as its own root, so its root is 'u' plus the parts of the shell around it and
61
- * its own: the same uids as the client's nested instances.
62
- */
63
- function uidPart(key: string): string {
64
- return key == 'page' ? 'p' : key[0] + key.slice(key.indexOf('_') + 1)
65
- }
66
-
67
58
  export function onRenderHtml(pageContext: PageContext) {
68
59
  const { Page, config } = pageContext
69
60
  const data = pageContext.data || {}
@@ -128,22 +119,10 @@ export function onRenderHtml(pageContext: PageContext) {
128
119
  // PLAN-3 5-5 (H-7): the seeded cache the components' resources render from
129
120
  const cache = pageContext.queryCache ? queryCache({ initial: pageContext.queryCache }) : undefined
130
121
 
131
- // Determine if Wrapper(s) and/or Layout(s) are present — this affects hydration state shape
132
- const wrapperArray = config.Wrapper
133
- ? (Array.isArray(config.Wrapper) ? config.Wrapper : [config.Wrapper])
134
- .filter((W: any) => typeof W === 'function')
135
- : []
136
- const layoutArray = config.Layout
137
- ? (Array.isArray(config.Layout) ? config.Layout : [config.Layout])
138
- .filter((L: any) => typeof L === 'function')
139
- : []
140
- const hasShell = wrapperArray.length > 0 || layoutArray.length > 0
141
- // Combined shell: wrappers (outermost) + layouts (innermost around Page)
142
- // Must match the order used in onRenderClient's createLayoutWrapper.
143
- const shell = [
144
- ...wrapperArray.map((w: any, i: number) => ({ comp: w, key: 'wrapper_' + i })),
145
- ...layoutArray.map((l: any, i: number) => ({ comp: l, key: 'layout_' + i })),
146
- ]
122
+ // Wrapper(s) and/or Layout(s), outermost first (G-803: Vike lists them closest first; shellOf
123
+ // reverses them, the same order as onRenderClient's): they decide the hydration state's shape
124
+ const shell = shellOf(config)
125
+ const hasShell = shell.length > 0
147
126
  // G-207: with a shell, the client nests each shell component and the Page in the one outside it
148
127
  // with an `id` prop (onRenderClient), so their uid roots are 'u-w0', 'u-w0-l0', 'u-w0-l0-p'
149
128
  const uidRoot = (i: number) => ['u', ...shell.slice(0, i + 1).map(s => uidPart(s.key))].join('-')
@@ -0,0 +1,33 @@
1
+ /**
2
+ * G-803: the shell components of a page (its Wrappers and Layouts), outermost first, with their
3
+ * state keys. Shared by onRenderHtml and onRenderClient so the server and the client nest them
4
+ * the same way.
5
+ *
6
+ * Vike's cumulative configs (`Layout` and `Wrapper` are, see +config.ts) list the values closest
7
+ * to the page first: pages/admin/+Layout before pages/+Layout (vike's resolveVikeConfigInternal:
8
+ * sortAfterInheritanceOrder puts the longer inheritance root first). So the root's come last, and
9
+ * the shell reverses each list: every Wrapper outside every Layout (as vike-react), and within each
10
+ * the root's outermost. Keys count from the outside in (wrapper_0, ..., layout_0, ...), so a root
11
+ * Layout keeps `layout_0` on every page, nested or not.
12
+ */
13
+ export interface ShellPart { comp: any; key: string }
14
+
15
+ const comps = (val: any): any[] =>
16
+ (val ? (Array.isArray(val) ? val : [val]) : []).filter((c: any) => typeof c === 'function')
17
+
18
+ export function shellOf(config: { Wrapper?: any; Layout?: any }): ShellPart[] {
19
+ return [
20
+ ...comps(config.Wrapper).reverse().map((comp, i) => ({ comp, key: 'wrapper_' + i })),
21
+ ...comps(config.Layout).reverse().map((comp, i) => ({ comp, key: 'layout_' + i })),
22
+ ]
23
+ }
24
+
25
+ /**
26
+ * PLAN-4 G-207: the uid() path part of a shell component or the Page: wrapper_0 → 'w0',
27
+ * layout_1 → 'l1', page → 'p'. onRenderClient gives it as the nested vnode's `id` prop, so the
28
+ * Page's uid is 'u-w0-l0-p' however the shell nests it; onRenderHtml renders each one with the
29
+ * same root (renderToString's `uid`), so server and client ids match.
30
+ */
31
+ export function uidPart(key: string): string {
32
+ return key == 'page' ? 'p' : key[0] + key.slice(key.indexOf('_') + 1)
33
+ }