sygnal 5.2.0 → 5.2.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.
@@ -6,53 +6,310 @@ import { run } from 'sygnal';
6
6
  * On first page load (hydration): reads server-serialized state and
7
7
  * boots the Sygnal app via run().
8
8
  *
9
- * On client-side navigation: disposes the previous app and boots
10
- * a fresh one for the new page.
9
+ * On client-side navigation with a Layout/Wrapper: the Layout and Wrapper
10
+ * stay mounted and only the Page sub-component is hot-swapped. Layout and
11
+ * Wrapper state persists across navigations. Without either, the app is
12
+ * disposed and recreated.
11
13
  *
12
- * Layout is rendered outside #page-view by onRenderHtml, so it persists
13
- * across navigations without being destroyed by Sygnal's DOM driver.
14
+ * Nesting order: Wrapper > Layout > Page
15
+ * - Wrappers are outermost (context providers, state management)
16
+ * - Layouts are inside wrappers (visual structure, navigation chrome)
17
+ * - Page is innermost (route-specific content, swapped on navigation)
14
18
  */
15
19
  // Import from the package entry so rollup externalizes it
16
20
  // @ts-ignore — resolved at runtime via package exports
17
21
  /** Track the running Sygnal app for disposal on navigation */
18
22
  let currentApp = null;
23
+ /**
24
+ * Mutable references read by the wrapper's view function.
25
+ * Updated on navigation to swap the Page without disposing the Layout.
26
+ */
27
+ let currentPage = null;
28
+ let currentPageName = '';
29
+ let currentPageData = {};
30
+ let currentRouteParams = {};
31
+ let currentUrlPathname = '';
32
+ let pageNavCounter = 0;
33
+ /**
34
+ * Build a component vnode that matches what the JSX pragma produces.
35
+ */
36
+ function componentVNode(comp, stateField, children, compInitialState) {
37
+ const name = comp.componentName || comp.name || 'FUNCTION_COMPONENT';
38
+ return {
39
+ sel: name,
40
+ data: {
41
+ props: {
42
+ state: stateField,
43
+ sygnalOptions: {
44
+ name,
45
+ view: comp,
46
+ model: comp.model,
47
+ intent: comp.intent,
48
+ hmrActions: comp.hmrActions,
49
+ context: comp.context,
50
+ peers: comp.peers,
51
+ components: comp.components,
52
+ initialState: compInitialState,
53
+ isolatedState: true,
54
+ calculated: comp.calculated,
55
+ storeCalculatedInState: comp.storeCalculatedInState,
56
+ DOMSourceName: comp.DOMSourceName,
57
+ stateSourceName: comp.stateSourceName,
58
+ onError: comp.onError,
59
+ debug: comp.debug,
60
+ },
61
+ },
62
+ },
63
+ children,
64
+ text: undefined,
65
+ elm: undefined,
66
+ key: '__vike_' + stateField + '__',
67
+ };
68
+ }
69
+ /**
70
+ * Build a vnode for the current Page as a child of the Layout.
71
+ * Reads from mutable `currentPage` / `currentPageName` so the wrapper
72
+ * view picks up the new Page component on navigation without being recreated.
73
+ */
74
+ function pageChildVNode(pageState) {
75
+ // Include pageNavCounter in sel so instantiateSubComponents detects a
76
+ // component swap even when both pages have the same function name (e.g. 'Page').
77
+ const sel = currentPageName + '__nav' + pageNavCounter;
78
+ return {
79
+ sel,
80
+ data: {
81
+ props: {
82
+ state: 'page',
83
+ sygnalOptions: {
84
+ name: sel,
85
+ view: currentPage,
86
+ model: currentPage.model,
87
+ intent: currentPage.intent,
88
+ hmrActions: currentPage.hmrActions,
89
+ context: currentPage.context,
90
+ peers: currentPage.peers,
91
+ components: currentPage.components,
92
+ initialState: pageState,
93
+ isolatedState: true,
94
+ calculated: currentPage.calculated,
95
+ storeCalculatedInState: currentPage.storeCalculatedInState,
96
+ DOMSourceName: currentPage.DOMSourceName,
97
+ stateSourceName: currentPage.stateSourceName,
98
+ onError: currentPage.onError,
99
+ debug: currentPage.debug,
100
+ },
101
+ },
102
+ },
103
+ children: [],
104
+ text: undefined,
105
+ elm: undefined,
106
+ key: '__vike_page__',
107
+ };
108
+ }
109
+ /**
110
+ * Create a synthetic wrapper component that nests Page inside Layout(s)
111
+ * inside Wrapper(s).
112
+ *
113
+ * Nesting order: Wrapper(s) > Layout(s) > Page
114
+ *
115
+ * The wrapper's view reads `currentPage` from the mutable closure, so
116
+ * on client-side navigation only the Page sub-component changes while
117
+ * the Layout and Wrapper stay mounted with their state preserved.
118
+ */
119
+ function createLayoutWrapper(wrappers, layouts, Page) {
120
+ currentPage = Page;
121
+ currentPageName = Page.componentName || Page.name || 'VikePageComponent';
122
+ // Combined shell = wrappers (outermost) + layouts (innermost around Page)
123
+ // State keys: wrapper_0, wrapper_1, ..., layout_0, layout_1, ...
124
+ // Page state lives under the innermost shell component's slice.
125
+ const shell = [
126
+ ...wrappers.map((w, i) => ({ comp: w, key: 'wrapper_' + i })),
127
+ ...layouts.map((l, i) => ({ comp: l, key: 'layout_' + i })),
128
+ ];
129
+ function LayoutWrapperView({ state }) {
130
+ if (shell.length === 0) {
131
+ // No wrappers or layouts — render Page directly
132
+ return pageChildVNode(state.page);
133
+ }
134
+ const lastIdx = shell.length - 1;
135
+ const innermostState = state[shell[lastIdx].key] || {};
136
+ let inner = componentVNode(shell[lastIdx].comp, shell[lastIdx].key, [pageChildVNode(innermostState.page)], innermostState);
137
+ // Wrap with outer shell components
138
+ for (let i = lastIdx - 1; i >= 0; i--) {
139
+ inner = componentVNode(shell[i].comp, shell[i].key, [inner], state[shell[i].key]);
140
+ }
141
+ // Wrap in a plain div so the view returns a regular DOM vnode.
142
+ // Sub-component vnodes at the root position are not processed correctly
143
+ // by the rendering pipeline — they must be children of a DOM element.
144
+ return {
145
+ sel: 'div',
146
+ data: { attrs: { id: 'vike-shell' } },
147
+ children: [inner],
148
+ text: undefined,
149
+ elm: undefined,
150
+ key: '__vike_shell__',
151
+ };
152
+ }
153
+ LayoutWrapperView.componentName = 'VikeLayoutWrapper';
154
+ // Build the wrapper's initial state with a slice for each shell component.
155
+ // Page state is nested under the innermost shell component's slice.
156
+ const wrapperInitialState = {};
157
+ shell.forEach(({ comp, key }, i) => {
158
+ const sliceState = { ...(comp.initialState || {}) };
159
+ if (i === shell.length - 1) {
160
+ sliceState.page = Page.initialState || {};
161
+ }
162
+ wrapperInitialState[key] = sliceState;
163
+ });
164
+ LayoutWrapperView.initialState = wrapperInitialState;
165
+ // Context uses mutable references so navigation updates are picked up.
166
+ // Wrapper and Layout contexts are merged once; page-level context
167
+ // (pageData, routeParams, urlPathname) reads from mutable variables
168
+ // updated on each navigation.
169
+ const shellContext = {};
170
+ shell.forEach(({ comp }) => {
171
+ if (comp.context) {
172
+ Object.assign(shellContext, comp.context);
173
+ }
174
+ });
175
+ LayoutWrapperView.context = {
176
+ ...shellContext,
177
+ ...(Page.context || {}),
178
+ pageData: () => currentPageData,
179
+ routeParams: () => currentRouteParams,
180
+ urlPathname: () => currentUrlPathname,
181
+ };
182
+ return LayoutWrapperView;
183
+ }
184
+ /**
185
+ * Normalize a cumulative config value into an array of functions.
186
+ */
187
+ function toComponentArray(val) {
188
+ if (!val)
189
+ return [];
190
+ return (Array.isArray(val) ? val : [val]).filter((c) => typeof c === 'function');
191
+ }
19
192
  function onRenderClient(pageContext) {
20
193
  const { Page, config } = pageContext;
21
194
  const data = pageContext.data || {};
22
- // Dispose previous app on client-side navigation
23
- if (currentApp) {
24
- currentApp.dispose();
25
- currentApp = null;
26
- }
27
- // On first load with SSR, pick up server-serialized state.
28
- // On client navigation (or SPA mode), merge data into initialState.
29
- let initialState;
30
- if (typeof window !== 'undefined' && window.__VIKE_SYGNAL_STATE__ !== undefined) {
31
- initialState = window.__VIKE_SYGNAL_STATE__;
32
- // Clear the global after reading to avoid stale state on soft navigation
33
- delete window.__VIKE_SYGNAL_STATE__;
195
+ const wrappers = toComponentArray(config.Wrapper);
196
+ const layouts = toComponentArray(config.Layout);
197
+ const hasShell = wrappers.length > 0 || layouts.length > 0;
198
+ // The shell is wrappers (outermost) + layouts. The innermost component
199
+ // holds the Page state as a nested sub-component.
200
+ const shell = [
201
+ ...wrappers.map((_, i) => 'wrapper_' + i),
202
+ ...layouts.map((_, i) => 'layout_' + i),
203
+ ];
204
+ const innermostKey = shell.length > 0 ? shell[shell.length - 1] : null;
205
+ // Update mutable context references (used by the wrapper's context functions)
206
+ currentPageData = data;
207
+ currentRouteParams = pageContext.routeParams || {};
208
+ currentUrlPathname = pageContext.urlPathname || '';
209
+ // --- Shell path: hot-swap Page, keep Layout/Wrapper alive ---
210
+ if (hasShell) {
211
+ if (currentApp) {
212
+ // Client-side navigation: swap the Page without disposing the shell.
213
+ currentPage = Page;
214
+ currentPageName = Page.componentName || Page.name || 'VikePageComponent';
215
+ pageNavCounter++;
216
+ const newPageState = { ...(Page.initialState || {}), ...data };
217
+ if (currentApp.sinks?.STATE?.shamefullySendNext) {
218
+ currentApp.sinks.STATE.shamefullySendNext((state) => {
219
+ const shellState = state[innermostKey] || {};
220
+ return {
221
+ ...state,
222
+ [innermostKey]: { ...shellState, page: newPageState },
223
+ };
224
+ });
225
+ }
226
+ }
227
+ else {
228
+ // First load: read hydrated state or build from initialState
229
+ let initialState;
230
+ if (typeof window !== 'undefined' && window.__VIKE_SYGNAL_STATE__ !== undefined) {
231
+ initialState = window.__VIKE_SYGNAL_STATE__;
232
+ delete window.__VIKE_SYGNAL_STATE__;
233
+ }
234
+ else {
235
+ initialState = null;
236
+ }
237
+ if (initialState && innermostKey && initialState[innermostKey] !== undefined) {
238
+ // Hydrated combined state — distribute slices to each shell component
239
+ const allShellComps = [...wrappers, ...layouts];
240
+ shell.forEach((key, i) => {
241
+ if (initialState[key] !== undefined) {
242
+ if (i === shell.length - 1) {
243
+ const { page: pageState, ...compState } = initialState[key];
244
+ allShellComps[i].initialState = compState;
245
+ Page.initialState = pageState || { ...(Page.initialState || {}), ...data };
246
+ }
247
+ else {
248
+ allShellComps[i].initialState = initialState[key];
249
+ }
250
+ }
251
+ });
252
+ }
253
+ else {
254
+ // SPA mode or client-side first navigation
255
+ Page.initialState = { ...(Page.initialState || {}), ...data };
256
+ }
257
+ // Inject page-level context into Page (for SSR renderToString compat)
258
+ Page.context = {
259
+ ...Page.context,
260
+ pageData: () => currentPageData,
261
+ routeParams: () => currentRouteParams,
262
+ urlPathname: () => currentUrlPathname,
263
+ };
264
+ const Component = createLayoutWrapper(wrappers, layouts, Page);
265
+ try {
266
+ currentApp = run(Component, {}, { mountPoint: '#page-view' });
267
+ }
268
+ catch (err) {
269
+ console.error('[sygnal/vike] Client render error:', err);
270
+ const container = document.getElementById('page-view');
271
+ if (container) {
272
+ container.innerHTML = `<div data-sygnal-error style="padding:2rem;color:#e74c3c;font-family:monospace">
273
+ <h2>Render Error</h2>
274
+ <pre>${String(err.message || err)}</pre>
275
+ </div>`;
276
+ }
277
+ }
278
+ }
279
+ // --- No shell path: dispose and recreate ---
34
280
  }
35
281
  else {
36
- initialState = {
37
- ...(Page.initialState || {}),
38
- ...data,
282
+ if (currentApp) {
283
+ currentApp.dispose();
284
+ currentApp = null;
285
+ }
286
+ let initialState;
287
+ if (typeof window !== 'undefined' && window.__VIKE_SYGNAL_STATE__ !== undefined) {
288
+ initialState = window.__VIKE_SYGNAL_STATE__;
289
+ delete window.__VIKE_SYGNAL_STATE__;
290
+ }
291
+ else {
292
+ initialState = { ...(Page.initialState || {}), ...data };
293
+ }
294
+ Page.initialState = initialState;
295
+ Page.context = {
296
+ ...Page.context,
297
+ pageData: () => currentPageData,
298
+ routeParams: () => currentRouteParams,
299
+ urlPathname: () => currentUrlPathname,
39
300
  };
40
- }
41
- // Set the initial state on the component before running
42
- Page.initialState = initialState;
43
- // Boot the Sygnal app into #page-view
44
- // Layout HTML lives outside #page-view, so it won't be destroyed
45
- try {
46
- currentApp = run(Page, {}, { mountPoint: '#page-view' });
47
- }
48
- catch (err) {
49
- console.error('[sygnal/vike] Client render error:', err);
50
- const container = document.getElementById('page-view');
51
- if (container) {
52
- container.innerHTML = `<div data-sygnal-error style="padding:2rem;color:#e74c3c;font-family:monospace">
53
- <h2>Render Error</h2>
54
- <pre>${String(err.message || err)}</pre>
55
- </div>`;
301
+ try {
302
+ currentApp = run(Page, {}, { mountPoint: '#page-view' });
303
+ }
304
+ catch (err) {
305
+ console.error('[sygnal/vike] Client render error:', err);
306
+ const container = document.getElementById('page-view');
307
+ if (container) {
308
+ container.innerHTML = `<div data-sygnal-error style="padding:2rem;color:#e74c3c;font-family:monospace">
309
+ <h2>Render Error</h2>
310
+ <pre>${String(err.message || err)}</pre>
311
+ </div>`;
312
+ }
56
313
  }
57
314
  }
58
315
  // Update document title on client navigation
@@ -9,6 +9,10 @@ var sygnal = require('sygnal');
9
9
  * wraps it in a full HTML document, and embeds serialized state for
10
10
  * client hydration.
11
11
  *
12
+ * When a Layout is configured, the Layout HTML wraps the Page HTML
13
+ * inside #page-view so the structure matches the client-side wrapper
14
+ * component (see onRenderClient.ts).
15
+ *
12
16
  * Returns a plain HTML string. Vike wraps it with dangerouslySkipEscape
13
17
  * automatically. We avoid importing from vike/server because our bundled
14
18
  * output lives in node_modules/sygnal/ where vike isn't resolvable.
@@ -50,12 +54,32 @@ function onRenderHtml(pageContext) {
50
54
  ...(Page.initialState || {}),
51
55
  ...data,
52
56
  };
53
- // Render the page component to HTML, with error boundary
57
+ // Inject page data, route params, and URL into the component's context
58
+ // so sub-components can access them without prop drilling during SSR.
59
+ Page.context = {
60
+ ...Page.context,
61
+ pageData: () => data,
62
+ routeParams: () => pageContext.routeParams || {},
63
+ urlPathname: () => pageContext.urlPathname || '',
64
+ };
65
+ // Determine if Wrapper(s) and/or Layout(s) are present — this affects hydration state shape
66
+ const wrapperArray = config.Wrapper
67
+ ? (Array.isArray(config.Wrapper) ? config.Wrapper : [config.Wrapper])
68
+ .filter((W) => typeof W === 'function')
69
+ : [];
70
+ const layoutArray = config.Layout
71
+ ? (Array.isArray(config.Layout) ? config.Layout : [config.Layout])
72
+ .filter((L) => typeof L === 'function')
73
+ : [];
74
+ const hasShell = wrapperArray.length > 0 || layoutArray.length > 0;
75
+ // Render the page component to HTML, with error boundary.
76
+ // When layouts are present, the hydration state is serialized separately
77
+ // as the wrapper's combined state (with page + layout slices).
54
78
  let pageHtml;
55
79
  try {
56
80
  pageHtml = sygnal.renderToString(Page, {
57
81
  state: initialState,
58
- hydrateState: '__VIKE_SYGNAL_STATE__',
82
+ hydrateState: hasShell ? false : '__VIKE_SYGNAL_STATE__',
59
83
  });
60
84
  }
61
85
  catch (err) {
@@ -65,8 +89,9 @@ function onRenderHtml(pageContext) {
65
89
  const fallbackVNode = Page.onError(err, { componentName: Page.name || 'Page' });
66
90
  if (fallbackVNode) {
67
91
  pageHtml = sygnal.renderToString(() => fallbackVNode, { state: {} });
68
- // Still embed the state so the client can attempt hydration
69
- pageHtml += `<script>window.__VIKE_SYGNAL_STATE__=${JSON.stringify(initialState)}</script>`;
92
+ if (!hasShell) {
93
+ pageHtml += `<script>window.__VIKE_SYGNAL_STATE__=${JSON.stringify(initialState)}</script>`;
94
+ }
70
95
  }
71
96
  else {
72
97
  pageHtml = `<div data-sygnal-error>Render error</div>`;
@@ -81,35 +106,49 @@ function onRenderHtml(pageContext) {
81
106
  pageHtml = `<div data-sygnal-error><!-- SSR render error: ${esc(String(err.message || err))} --></div>`;
82
107
  }
83
108
  }
84
- // If Layout(s) are defined, render them as static HTML wrapping the page.
85
- // The Layout is rendered OUTSIDE #page-view so that Sygnal's DOM driver
86
- // (which replaces the mount point content) doesn't destroy the Layout.
87
- let layoutBeforeHtml = '';
88
- let layoutAfterHtml = '';
89
- const layouts = config.Layout;
90
- if (layouts) {
91
- const layoutArray = Array.isArray(layouts) ? layouts : [layouts];
92
- for (const Layout of layoutArray) {
93
- if (typeof Layout === 'function') {
94
- // Render the Layout with a placeholder for page content.
95
- // The Layout view receives { innerHTML: '<!--PAGE-->' } and we split
96
- // the output around the placeholder to get before/after chunks.
97
- const PLACEHOLDER = '<!--SYGNAL_PAGE_SLOT-->';
98
- const layoutHtml = sygnal.renderToString(Layout, {
99
- state: Layout.initialState || {},
100
- props: { innerHTML: PLACEHOLDER },
101
- });
102
- const splitIdx = layoutHtml.indexOf(PLACEHOLDER);
103
- if (splitIdx !== -1) {
104
- layoutBeforeHtml += layoutHtml.substring(0, splitIdx);
105
- layoutAfterHtml = layoutHtml.substring(splitIdx + PLACEHOLDER.length) + layoutAfterHtml;
106
- }
107
- else {
108
- // Fallback: Layout didn't use innerHTML, render it before page content
109
- layoutBeforeHtml += layoutHtml;
110
- }
109
+ // If Layout(s) and/or Wrapper(s) are defined, render them wrapping the page content.
110
+ // HTML is rendered INSIDE #page-view so the structure matches the client-side
111
+ // wrapper component. Nesting order: Wrapper > Layout > Page.
112
+ // Each shell component receives page content via a placeholder in its children slot.
113
+ let pageViewContent = pageHtml;
114
+ if (hasShell) {
115
+ // Combined shell: wrappers (outermost) + layouts (innermost around Page)
116
+ // Must match the order used in onRenderClient's createLayoutWrapper.
117
+ const shell = [
118
+ ...wrapperArray.map((w, i) => ({ comp: w, key: 'wrapper_' + i })),
119
+ ...layoutArray.map((l, i) => ({ comp: l, key: 'layout_' + i })),
120
+ ];
121
+ // Wrap from innermost to outermost (reverse order)
122
+ for (let i = shell.length - 1; i >= 0; i--) {
123
+ const { comp } = shell[i];
124
+ const PLACEHOLDER = '<!--SYGNAL_PAGE_SLOT-->';
125
+ const compHtml = sygnal.renderToString(comp, {
126
+ state: comp.initialState || {},
127
+ props: { innerHTML: PLACEHOLDER },
128
+ });
129
+ const splitIdx = compHtml.indexOf(PLACEHOLDER);
130
+ if (splitIdx !== -1) {
131
+ pageViewContent = compHtml.substring(0, splitIdx) + pageViewContent + compHtml.substring(splitIdx + PLACEHOLDER.length);
132
+ }
133
+ else {
134
+ // Fallback: component didn't use children/innerHTML, wrap content after it
135
+ pageViewContent = compHtml + pageViewContent;
111
136
  }
112
137
  }
138
+ // Wrap in a shell div matching the client-side wrapper's root element
139
+ pageViewContent = `<div id="vike-shell">${pageViewContent}</div>`;
140
+ // Serialize the wrapper's combined state for hydration.
141
+ // Must match the state shape from createLayoutWrapper:
142
+ // { wrapper_0: {...}, layout_0: { ...layoutState, page: pageState } }
143
+ const wrapperState = {};
144
+ shell.forEach(({ comp, key }, i) => {
145
+ const compState = { ...(comp.initialState || {}) };
146
+ if (i === shell.length - 1) {
147
+ compState.page = initialState;
148
+ }
149
+ wrapperState[key] = compState;
150
+ });
151
+ pageViewContent += `<script>window.__VIKE_SYGNAL_STATE__=${JSON.stringify(wrapperState)}</script>`;
113
152
  }
114
153
  // Render Head component for <head> tags
115
154
  let headContent = '';
@@ -143,9 +182,7 @@ function onRenderHtml(pageContext) {
143
182
  ${headContent}
144
183
  </head>
145
184
  <body>
146
- ${layoutBeforeHtml}
147
- <div id="page-view">${pageHtml}</div>
148
- ${layoutAfterHtml}
185
+ <div id="page-view">${pageViewContent}</div>
149
186
  </body>
150
187
  </html>`;
151
188
  // Produce the shape Vike's isDocumentHtml() recognizes: { _escaped: string }
@@ -7,6 +7,10 @@ import { renderToString } from 'sygnal';
7
7
  * wraps it in a full HTML document, and embeds serialized state for
8
8
  * client hydration.
9
9
  *
10
+ * When a Layout is configured, the Layout HTML wraps the Page HTML
11
+ * inside #page-view so the structure matches the client-side wrapper
12
+ * component (see onRenderClient.ts).
13
+ *
10
14
  * Returns a plain HTML string. Vike wraps it with dangerouslySkipEscape
11
15
  * automatically. We avoid importing from vike/server because our bundled
12
16
  * output lives in node_modules/sygnal/ where vike isn't resolvable.
@@ -48,12 +52,32 @@ function onRenderHtml(pageContext) {
48
52
  ...(Page.initialState || {}),
49
53
  ...data,
50
54
  };
51
- // Render the page component to HTML, with error boundary
55
+ // Inject page data, route params, and URL into the component's context
56
+ // so sub-components can access them without prop drilling during SSR.
57
+ Page.context = {
58
+ ...Page.context,
59
+ pageData: () => data,
60
+ routeParams: () => pageContext.routeParams || {},
61
+ urlPathname: () => pageContext.urlPathname || '',
62
+ };
63
+ // Determine if Wrapper(s) and/or Layout(s) are present — this affects hydration state shape
64
+ const wrapperArray = config.Wrapper
65
+ ? (Array.isArray(config.Wrapper) ? config.Wrapper : [config.Wrapper])
66
+ .filter((W) => typeof W === 'function')
67
+ : [];
68
+ const layoutArray = config.Layout
69
+ ? (Array.isArray(config.Layout) ? config.Layout : [config.Layout])
70
+ .filter((L) => typeof L === 'function')
71
+ : [];
72
+ const hasShell = wrapperArray.length > 0 || layoutArray.length > 0;
73
+ // Render the page component to HTML, with error boundary.
74
+ // When layouts are present, the hydration state is serialized separately
75
+ // as the wrapper's combined state (with page + layout slices).
52
76
  let pageHtml;
53
77
  try {
54
78
  pageHtml = renderToString(Page, {
55
79
  state: initialState,
56
- hydrateState: '__VIKE_SYGNAL_STATE__',
80
+ hydrateState: hasShell ? false : '__VIKE_SYGNAL_STATE__',
57
81
  });
58
82
  }
59
83
  catch (err) {
@@ -63,8 +87,9 @@ function onRenderHtml(pageContext) {
63
87
  const fallbackVNode = Page.onError(err, { componentName: Page.name || 'Page' });
64
88
  if (fallbackVNode) {
65
89
  pageHtml = renderToString(() => fallbackVNode, { state: {} });
66
- // Still embed the state so the client can attempt hydration
67
- pageHtml += `<script>window.__VIKE_SYGNAL_STATE__=${JSON.stringify(initialState)}</script>`;
90
+ if (!hasShell) {
91
+ pageHtml += `<script>window.__VIKE_SYGNAL_STATE__=${JSON.stringify(initialState)}</script>`;
92
+ }
68
93
  }
69
94
  else {
70
95
  pageHtml = `<div data-sygnal-error>Render error</div>`;
@@ -79,35 +104,49 @@ function onRenderHtml(pageContext) {
79
104
  pageHtml = `<div data-sygnal-error><!-- SSR render error: ${esc(String(err.message || err))} --></div>`;
80
105
  }
81
106
  }
82
- // If Layout(s) are defined, render them as static HTML wrapping the page.
83
- // The Layout is rendered OUTSIDE #page-view so that Sygnal's DOM driver
84
- // (which replaces the mount point content) doesn't destroy the Layout.
85
- let layoutBeforeHtml = '';
86
- let layoutAfterHtml = '';
87
- const layouts = config.Layout;
88
- if (layouts) {
89
- const layoutArray = Array.isArray(layouts) ? layouts : [layouts];
90
- for (const Layout of layoutArray) {
91
- if (typeof Layout === 'function') {
92
- // Render the Layout with a placeholder for page content.
93
- // The Layout view receives { innerHTML: '<!--PAGE-->' } and we split
94
- // the output around the placeholder to get before/after chunks.
95
- const PLACEHOLDER = '<!--SYGNAL_PAGE_SLOT-->';
96
- const layoutHtml = renderToString(Layout, {
97
- state: Layout.initialState || {},
98
- props: { innerHTML: PLACEHOLDER },
99
- });
100
- const splitIdx = layoutHtml.indexOf(PLACEHOLDER);
101
- if (splitIdx !== -1) {
102
- layoutBeforeHtml += layoutHtml.substring(0, splitIdx);
103
- layoutAfterHtml = layoutHtml.substring(splitIdx + PLACEHOLDER.length) + layoutAfterHtml;
104
- }
105
- else {
106
- // Fallback: Layout didn't use innerHTML, render it before page content
107
- layoutBeforeHtml += layoutHtml;
108
- }
107
+ // If Layout(s) and/or Wrapper(s) are defined, render them wrapping the page content.
108
+ // HTML is rendered INSIDE #page-view so the structure matches the client-side
109
+ // wrapper component. Nesting order: Wrapper > Layout > Page.
110
+ // Each shell component receives page content via a placeholder in its children slot.
111
+ let pageViewContent = pageHtml;
112
+ if (hasShell) {
113
+ // Combined shell: wrappers (outermost) + layouts (innermost around Page)
114
+ // Must match the order used in onRenderClient's createLayoutWrapper.
115
+ const shell = [
116
+ ...wrapperArray.map((w, i) => ({ comp: w, key: 'wrapper_' + i })),
117
+ ...layoutArray.map((l, i) => ({ comp: l, key: 'layout_' + i })),
118
+ ];
119
+ // Wrap from innermost to outermost (reverse order)
120
+ for (let i = shell.length - 1; i >= 0; i--) {
121
+ const { comp } = shell[i];
122
+ const PLACEHOLDER = '<!--SYGNAL_PAGE_SLOT-->';
123
+ const compHtml = renderToString(comp, {
124
+ state: comp.initialState || {},
125
+ props: { innerHTML: PLACEHOLDER },
126
+ });
127
+ const splitIdx = compHtml.indexOf(PLACEHOLDER);
128
+ if (splitIdx !== -1) {
129
+ pageViewContent = compHtml.substring(0, splitIdx) + pageViewContent + compHtml.substring(splitIdx + PLACEHOLDER.length);
130
+ }
131
+ else {
132
+ // Fallback: component didn't use children/innerHTML, wrap content after it
133
+ pageViewContent = compHtml + pageViewContent;
109
134
  }
110
135
  }
136
+ // Wrap in a shell div matching the client-side wrapper's root element
137
+ pageViewContent = `<div id="vike-shell">${pageViewContent}</div>`;
138
+ // Serialize the wrapper's combined state for hydration.
139
+ // Must match the state shape from createLayoutWrapper:
140
+ // { wrapper_0: {...}, layout_0: { ...layoutState, page: pageState } }
141
+ const wrapperState = {};
142
+ shell.forEach(({ comp, key }, i) => {
143
+ const compState = { ...(comp.initialState || {}) };
144
+ if (i === shell.length - 1) {
145
+ compState.page = initialState;
146
+ }
147
+ wrapperState[key] = compState;
148
+ });
149
+ pageViewContent += `<script>window.__VIKE_SYGNAL_STATE__=${JSON.stringify(wrapperState)}</script>`;
111
150
  }
112
151
  // Render Head component for <head> tags
113
152
  let headContent = '';
@@ -141,9 +180,7 @@ function onRenderHtml(pageContext) {
141
180
  ${headContent}
142
181
  </head>
143
182
  <body>
144
- ${layoutBeforeHtml}
145
- <div id="page-view">${pageHtml}</div>
146
- ${layoutAfterHtml}
183
+ <div id="page-view">${pageViewContent}</div>
147
184
  </body>
148
185
  </html>`;
149
186
  // Produce the shape Vike's isDocumentHtml() recognizes: { _escaped: string }