sygnal 6.5.0 → 7.0.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 (104) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +2 -0
  3. package/dist/astro/index.cjs.js +423 -27
  4. package/dist/astro/index.cjs.js.map +1 -1
  5. package/dist/astro/index.mjs +423 -27
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/server.cjs.js.map +1 -1
  8. package/dist/astro/server.mjs.map +1 -1
  9. package/dist/devtools.cjs.js +141 -3
  10. package/dist/devtools.cjs.js.map +1 -1
  11. package/dist/devtools.esm.js +141 -4
  12. package/dist/devtools.esm.js.map +1 -1
  13. package/dist/diagnostics.cjs.js +169 -14
  14. package/dist/diagnostics.cjs.js.map +1 -1
  15. package/dist/diagnostics.esm.js +168 -15
  16. package/dist/diagnostics.esm.js.map +1 -1
  17. package/dist/guide/accessibility.md +2 -0
  18. package/dist/guide/ai-chat.md +6 -6
  19. package/dist/guide/behaviors.md +1 -1
  20. package/dist/guide/element-commands.md +4 -2
  21. package/dist/guide/forms-reference.md +89 -3
  22. package/dist/guide/forms.md +8 -8
  23. package/dist/guide/http.md +77 -7
  24. package/dist/guide/migrating-to-7.md +314 -0
  25. package/dist/guide/recipes/ai-summarize.md +2 -1
  26. package/dist/guide/recipes/i18n.md +1 -1
  27. package/dist/guide/ui/tabs.md +5 -4
  28. package/dist/guide/ui/tooltip.md +24 -0
  29. package/dist/index.cjs.js +781 -320
  30. package/dist/index.cjs.js.map +1 -1
  31. package/dist/index.d.ts +319 -43
  32. package/dist/index.esm.js +781 -320
  33. package/dist/index.esm.js.map +1 -1
  34. package/dist/sygnal.min.js +1 -1
  35. package/dist/sygnal.min.js.map +1 -1
  36. package/dist/ui.cjs.js +96 -11
  37. package/dist/ui.cjs.js.map +1 -1
  38. package/dist/ui.esm.js +96 -12
  39. package/dist/ui.esm.js.map +1 -1
  40. package/dist/vike/config/package.json +1 -1
  41. package/dist/vike/onRenderClient.cjs.js +63 -43
  42. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  43. package/dist/vike/onRenderClient.mjs +63 -43
  44. package/dist/vike/onRenderClient.mjs.map +1 -1
  45. package/dist/vike/onRenderHtml.cjs.js +21 -25
  46. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  47. package/dist/vike/onRenderHtml.mjs +21 -25
  48. package/dist/vike/onRenderHtml.mjs.map +1 -1
  49. package/dist/vite/plugin.cjs.js +410 -27
  50. package/dist/vite/plugin.cjs.js.map +1 -1
  51. package/dist/vite/plugin.mjs +410 -27
  52. package/dist/vite/plugin.mjs.map +1 -1
  53. package/llms.txt +17 -16
  54. package/package.json +1 -1
  55. package/src/astro/index.d.ts +8 -0
  56. package/src/astro/index.ts +18 -0
  57. package/src/core/actions.ts +28 -21
  58. package/src/core/define.ts +2 -0
  59. package/src/core/hooks.ts +8 -2
  60. package/src/core/hosts/collection.ts +4 -3
  61. package/src/core/instance.ts +60 -16
  62. package/src/core/runtime.ts +5 -1
  63. package/src/core/view.ts +2 -0
  64. package/src/cycle/dom/utils.ts +4 -2
  65. package/src/devtools.d.ts +13 -0
  66. package/src/devtools.ts +3 -0
  67. package/src/extra/ai/chat/behavior.ts +3 -3
  68. package/src/extra/ai/commandBar.ts +3 -3
  69. package/src/extra/ai/compose/behavior.ts +3 -3
  70. package/src/extra/ai/transports/shared.ts +8 -2
  71. package/src/extra/behaviors.ts +21 -0
  72. package/src/extra/devGraph.ts +111 -0
  73. package/src/extra/devMcp.ts +1 -1
  74. package/src/extra/devtools.ts +18 -0
  75. package/src/extra/diagnostics/checks/fetch.ts +21 -1
  76. package/src/extra/diagnostics/checks/forms.ts +4 -3
  77. package/src/extra/diagnostics/checks/index.ts +2 -1
  78. package/src/extra/diagnostics/checks/inspect.ts +68 -6
  79. package/src/extra/diagnostics/checks/next.ts +3 -0
  80. package/src/extra/diagnostics/checks/public.d.ts +133 -12
  81. package/src/extra/diagnostics/checks/timingAudit.ts +47 -0
  82. package/src/extra/diagnostics/codes.ts +19 -0
  83. package/src/extra/fetchDriver.ts +112 -7
  84. package/src/extra/fetchEventSource.ts +113 -0
  85. package/src/extra/form.ts +68 -26
  86. package/src/extra/formHelpers.ts +3 -1
  87. package/src/extra/lens.ts +3 -2
  88. package/src/extra/queryCache.ts +4 -3
  89. package/src/extra/socketDriver.ts +10 -4
  90. package/src/extra/testing.ts +68 -11
  91. package/src/index.d.ts +199 -30
  92. package/src/index.ts +1 -0
  93. package/src/shared.ts +3 -0
  94. package/src/ui/dialog.ts +3 -2
  95. package/src/ui/tooltips.ts +92 -0
  96. package/src/ui.d.ts +35 -0
  97. package/src/ui.ts +1 -0
  98. package/src/vike/onRenderClient.ts +44 -46
  99. package/src/vike/onRenderHtml.ts +5 -26
  100. package/src/vike/shell.ts +33 -0
  101. package/src/vite/graph.ts +255 -0
  102. package/src/vite/mcp.ts +3 -3
  103. package/src/vite/plugin.d.ts +19 -0
  104. package/src/vite/plugin.ts +144 -25
@@ -0,0 +1,92 @@
1
+ /*
2
+ * PLAN-11 T-1: Tooltips, one behavior for every tooltip of a component (glossary terms in a
3
+ * paragraph, icon buttons in a toolbar): one state slice and two timers however many triggers
4
+ * there are, instead of a tooltip() per trigger.
5
+ *
6
+ * Article.uses = { tips: tooltips({ trigger: '.term', tip: '.term-tip' }) }
7
+ *
8
+ * Each trigger names its tip with aria-describedby (the tip's id); the tips are `role="tooltip"
9
+ * popover="manual"` elements placed by CSS anchor positioning, as for tooltip(). One tip shows at
10
+ * a time. Moving from one trigger to another while a tip is shown switches at once (no second
11
+ * show delay), as tooltip groups do.
12
+ *
13
+ * Options: `trigger`, `tip` (required selectors matching every trigger / every tip),
14
+ * `showDelay` (500 ms), `hideDelay` (100 ms).
15
+ * State: { id (the tip of the trigger or tip last entered), open (the id of the tip shown),
16
+ * pending: null | 'show' | 'hide', hover, focus }. Actions: ENTER / LEAVE ({ via: 'hover' |
17
+ * 'focus', id }), SHOW / HIDE (the timers), ESCAPE, TOGGLED ({ id, open }). Timers
18
+ * '<key>.show' / '<key>.hide' as tooltip()'s. Needs makeTimerDriver() (tests: fake timers).
19
+ * Same rules as tooltip(): pointer and focus tracked apart, touch ignored, hovering the tip keeps
20
+ * it open (WCAG 1.4.13), Escape listened for only while a tip is shown or about to be.
21
+ */
22
+ import {ABORT, defineBehavior, dropRepeats, xs} from '../index'
23
+
24
+ const mouse = (e: any) => e?.pointerType != 'touch'
25
+ const el = (e: any) => e?.ownerTarget || e?.currentTarget || e?.target
26
+ // the trigger's tip id (its aria-describedby: the first id when it lists several), or the tip's own
27
+ const tipOf = (e: any) => String(el(e)?.getAttribute?.('aria-describedby') || '').trim().split(/\s+/)[0] || null
28
+ const idOf = (e: any) => el(e)?.id || null
29
+ const at = (id: string) => `[id="${id.replace(/["\\]/g, '\\$&')}"]`
30
+
31
+ const base = /*#__PURE__*/ defineBehavior({
32
+ initialState: {id: null as null | string, open: null as null | string, pending: null as null | string, hover: false, focus: false},
33
+ timers: (s: any, o: any) => ({
34
+ show: s?.pending == 'show' && {after: o.showDelay ?? 500, action: 'SHOW'},
35
+ hide: s?.pending == 'hide' && {after: o.hideDelay ?? 100, action: 'HIDE'},
36
+ }),
37
+ intent: ({DOM, STATE}: any, {trigger, tip}: any) => ({
38
+ ENTER: xs.merge(
39
+ DOM.pointerenter(trigger).filter(mouse).map((e: any) => ({via: 'hover', id: tipOf(e)})),
40
+ DOM.pointerenter(tip).filter(mouse).map((e: any) => ({via: 'hover', id: idOf(e)})),
41
+ DOM.focus(trigger).map((e: any) => ({via: 'focus', id: tipOf(e)})),
42
+ ).filter((x: any) => !!x.id),
43
+ LEAVE: xs.merge(
44
+ DOM.pointerleave(trigger).filter(mouse).map((e: any) => ({via: 'hover', id: tipOf(e)})),
45
+ DOM.pointerleave(tip).filter(mouse).map((e: any) => ({via: 'hover', id: idOf(e)})),
46
+ DOM.blur(trigger).map((e: any) => ({via: 'focus', id: tipOf(e)})),
47
+ ).filter((x: any) => !!x.id),
48
+ ESCAPE: (STATE ? STATE.stream.map((s: any) => !!(s?.open || s?.pending)).compose(dropRepeats()) : xs.of(true))
49
+ .map((on: boolean) => (on ? DOM.keydown('document').key().filter((k: string) => k == 'Escape') : xs.never()))
50
+ .flatten(),
51
+ TOGGLED: DOM.toggle(tip).map((e: any) => ({id: idOf(e), open: e.newState == 'open'})).filter((x: any) => !!x.id),
52
+ }),
53
+ model: {
54
+ ENTER: {
55
+ STATE: (s: any, {via, id}: any) => {
56
+ const k = via == 'focus' ? 'focus' : 'hover'
57
+ // another trigger: it takes over (a tip already shown switches at once, in ELEMENT)
58
+ if (id != s.id) return {...s, id, hover: k == 'hover', focus: k == 'focus', pending: s.open ? null : 'show'}
59
+ const pending = s.open == id ? null : 'show'
60
+ return s[k] && s.pending == pending ? ABORT : {...s, [k]: true, pending}
61
+ },
62
+ // 7.0: outputs get the updated slice; "another trigger than before" reads the previous one
63
+ ELEMENT: (s: any, {id}: any, _n: any, p: any, _o: any, k: string) => {
64
+ const w = p?.previousState ? p.previousState[k] : s
65
+ return id != w.id && w.open && w.open != id ? [{hidePopover: at(w.open)}, {showPopover: at(id)}] : ABORT
66
+ },
67
+ },
68
+ LEAVE: (s: any, {via, id}: any) => {
69
+ if (id != s.id) return ABORT
70
+ const k = via == 'focus' ? 'focus' : 'hover'
71
+ const n = {...s, [k]: false}
72
+ const pending = n.hover || n.focus ? s.pending : s.open ? 'hide' : null
73
+ return !s[k] && s.pending == pending ? ABORT : {...n, pending}
74
+ },
75
+ SHOW: {
76
+ STATE: (s: any) => ({...s, pending: null}),
77
+ ELEMENT: (s: any) => (!s.id || s.open == s.id ? ABORT : s.open ? [{hidePopover: at(s.open)}, {showPopover: at(s.id)}] : {showPopover: at(s.id)}),
78
+ },
79
+ HIDE: {
80
+ STATE: (s: any) => ({...s, pending: null}),
81
+ ELEMENT: (s: any) => (s.open ? {hidePopover: at(s.open)} : ABORT),
82
+ },
83
+ ESCAPE: {
84
+ STATE: (s: any) => (s.open || s.pending ? {...s, pending: null} : ABORT),
85
+ ELEMENT: (s: any) => (s.open ? {hidePopover: at(s.open)} : ABORT),
86
+ },
87
+ TOGGLED: (s: any, {id, open}: any) => (open ? (s.open == id ? ABORT : {...s, open: id}) : s.open == id ? {...s, open: null} : ABORT),
88
+ },
89
+ })
90
+
91
+ /** Every tooltip of a component as one behavior: `uses = { tips: tooltips({ trigger: '.term', tip: '.term-tip' }) }`. */
92
+ export const tooltips = (options: any = {}): any => base({...options, id: null, open: null, pending: null, hover: false, focus: false})
package/src/ui.d.ts CHANGED
@@ -75,6 +75,41 @@ export interface TooltipActions { ENTER: 'hover' | 'focus' | undefined; LEAVE: '
75
75
  /** A tooltip: `uses = { tip: tooltip({ trigger: '.save', tip: '.save-tip' }) }`. Needs makeTimerDriver(). */
76
76
  export function tooltip(options: TooltipOptions): Behavior<TooltipState, TooltipActions, {}, TooltipOptions>
77
77
 
78
+ // ── Tooltips (PLAN-11) ────────────────────────────────────────────────
79
+ export interface TooltipsState {
80
+ /** The tip id of the trigger (or tip) last entered */
81
+ id: string | null
82
+ /** The id of the tip shown, or null */
83
+ open: string | null
84
+ pending: null | 'show' | 'hide'
85
+ hover: boolean
86
+ focus: boolean
87
+ }
88
+ export interface TooltipsOptions {
89
+ /** Every trigger (a selector matching them all); each names its tip with aria-describedby */
90
+ trigger: BehaviorTarget
91
+ /** Every tip: role="tooltip" popover="manual" with an id, placed with CSS anchor positioning */
92
+ tip: BehaviorTarget
93
+ /** ms before a tip shows (default 500); moving to another trigger while one shows switches at once */
94
+ showDelay?: number
95
+ /** ms before it hides (default 100) */
96
+ hideDelay?: number
97
+ }
98
+ export interface TooltipsActions {
99
+ ENTER: { via: 'hover' | 'focus'; id: string }
100
+ LEAVE: { via: 'hover' | 'focus'; id: string }
101
+ SHOW: any
102
+ HIDE: any
103
+ ESCAPE: string
104
+ TOGGLED: { id: string; open: boolean }
105
+ }
106
+ /**
107
+ * PLAN-11 (6.7): every tooltip of a component as one behavior (one state slice, two timers):
108
+ * `uses = { tips: tooltips({ trigger: '.term', tip: '.term-tip' }) }`, each trigger with
109
+ * `aria-describedby` naming its tip's id. One tip shows at a time. Needs makeTimerDriver().
110
+ */
111
+ export function tooltips(options: TooltipsOptions): Behavior<TooltipsState, TooltipsActions, {}, TooltipsOptions>
112
+
78
113
  // ── Tabs ──────────────────────────────────────────────────────────────
79
114
  export interface TabsState {
80
115
  /** The ids' prefix (the key in `uses` unless the `id` option names it) */
package/src/ui.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  export {dialog} from './ui/dialog'
10
10
  export {popover} from './ui/popover'
11
11
  export {tooltip} from './ui/tooltip'
12
+ export {tooltips} from './ui/tooltips'
12
13
  export {tabs, tabsAttrs} from './ui/tabs'
13
14
  export {accordion, accordionAttrs} from './ui/accordion'
14
15
  export {disclosure, disclosureAttrs} from './ui/disclosure'
@@ -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
+ }