@vesk/compiler 0.2.22 → 0.2.24

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.
@@ -3,6 +3,30 @@ import { isStaticIR, collectTrackedNames, transformTracked, transformTrackedInit
3
3
  import { unwrapTrackCall, stripTrackGeneric, hasTopLevelComma, skipWhitespace, findBalancedEnd, startsWithIdentifier } from '@vesk/compiler/src/scan';
4
4
  import { escapeHtml, indent, exprJS, extractTopLevelNames, extractRuntimeNames, buildParamInit, __vskHydrate, __vskImportedNames, setVskImportedNames, setVskForceClaim, takeVskForceClaim, nextVskId, } from '@vesk/compiler/src/server-utils';
5
5
  import { localValueImportNames } from '@vesk/compiler/src/module-imports';
6
+ // Hydrate-mode component-boundary wrapper. Each server-rendered component call
7
+ // is preceded by a `<!--vsk-->` marker and a single container element: the
8
+ // hydration walker adopts that element as the component root
9
+ // (`walker.nextElement()`) and scopes interior claims via
10
+ // `subWalker(rootEl).contains(...)`, so fragment components with multiple root
11
+ // siblings need one shared container here.
12
+ //
13
+ // The container MUST NOT be a layout box. The client renderer has no such box
14
+ // — component nodes are appended straight into the parent — so a block wrapper
15
+ // that collapses to the component's own height confines every child that
16
+ // depends on the parent layout: `position: sticky`, `h-full`/`min-h-screen`,
17
+ // inset/percent offsets, flex/grid stretch. `display: contents` keeps the DOM
18
+ // node for claiming while suppressing its box, making SSR layout identical to
19
+ // the client's for every property, not just sticky.
20
+ //
21
+ // The wrapper tag must be `<span>`, not `<div>`. The HTML parser implicitly
22
+ // closes an open `<p>` on a flow-content start tag like `<div>`, so a `<div>`
23
+ // wrapper inside a `<p>` (a component called inside a paragraph, e.g. a `Link`
24
+ // in an eyebrow row) is hoisted out of the paragraph, stranding its `<!--vsk-->`
25
+ // marker in the now-empty `<p>` → `nextElementSibling` is null → the hydration
26
+ // claim misses and every later claim tag-shifts, wiping the SSR content. `<span>`
27
+ // is phrasing content (legal inside `<p>`) and never triggers an implicit close,
28
+ // while `display: contents` keeps it box-less everywhere a `<div>` would be.
29
+ const HYDRATE_COMPONENT_WRAPPER = '<!--vsk--><span style="display:contents">';
6
30
  export function irNodeToJS(node, importedNames, isAsync = false, tracked) {
7
31
  importedNames = importedNames || __vskImportedNames;
8
32
  if (node instanceof StaticNode)
@@ -185,18 +209,27 @@ function opaqueRegionToJS(node, isAsync = false, tracked) {
185
209
  const lines = [];
186
210
  const cond = exprJSX(node.condition, tracked);
187
211
  lines.push(`if (${cond}) {`);
212
+ // Static branch content still needs server claim markers: the client
213
+ // re-branches an if/else-if region on every tracked change (OpaqueDynamicRegion
214
+ // claimStatic codegen), so opposed to plain component bodies it must be able
215
+ // to adopt the SSR nodes in place — not leave them orphaned next to the
216
+ // region's anchors. Mirrors mapRegionToJS' alternate handling below.
217
+ setVskForceClaim(true);
188
218
  for (const n of node.consequentNodes) {
189
219
  const code = irNodeToJS(n, null, isAsync, tracked);
190
220
  if (code)
191
221
  lines.push(indent(code));
192
222
  }
223
+ setVskForceClaim(false);
193
224
  if (node.alternateNodes.length > 0) {
194
225
  lines.push(`} else {`);
226
+ setVskForceClaim(true);
195
227
  for (const n of node.alternateNodes) {
196
228
  const code = irNodeToJS(n, null, isAsync, tracked);
197
229
  if (code)
198
230
  lines.push(indent(code));
199
231
  }
232
+ setVskForceClaim(false);
200
233
  }
201
234
  lines.push(`}`);
202
235
  return lines.join('\n');
@@ -205,6 +238,12 @@ function mapRegionToJS(node, isAsync = false, tracked) {
205
238
  const lines = [];
206
239
  const arr = exprJSX(node.expression, tracked);
207
240
  const item = node.itemVariable;
241
+ // In hydration SSR, stamp each keyed item root with `data-vsk-key` so the
242
+ // client can claim items by key instead of rebuilding them. The binding is
243
+ // injected into a shadow copy of the item root at codegen time — it flows
244
+ // through the existing dynamic-attribute machinery (which also forces a
245
+ // claim marker on otherwise-static roots).
246
+ const bodyForItem = __vskHydrate && node.keyExpr ? keyedItemTemplate(node) : node.bodyTemplate;
208
247
  const hasAlternate = node.alternateNodes.length > 0;
209
248
  const arrVar = hasAlternate ? `__a${nextVskId()}` : null;
210
249
  if (hasAlternate) {
@@ -224,7 +263,7 @@ function mapRegionToJS(node, isAsync = false, tracked) {
224
263
  lines.push('let __i = 0;');
225
264
  lines.push(`for (const ${item} of ${loopArr}) {`);
226
265
  lines.push(indent(`const ${node.indexVariable} = __i;`));
227
- for (const n of node.bodyTemplate) {
266
+ for (const n of bodyForItem) {
228
267
  const code = irNodeToJS(n, null, isAsync, tracked);
229
268
  if (code)
230
269
  lines.push(indent(code));
@@ -234,7 +273,7 @@ function mapRegionToJS(node, isAsync = false, tracked) {
234
273
  }
235
274
  else {
236
275
  lines.push(`for (const ${item} of ${loopArr}) {`);
237
- for (const n of node.bodyTemplate) {
276
+ for (const n of bodyForItem) {
238
277
  const code = irNodeToJS(n, null, isAsync, tracked);
239
278
  if (code)
240
279
  lines.push(indent(code));
@@ -246,6 +285,27 @@ function mapRegionToJS(node, isAsync = false, tracked) {
246
285
  }
247
286
  return lines.join('\n');
248
287
  }
288
+ /**
289
+ * Returns a per-item body where the item root element (the first top-level
290
+ * StaticNode) carries an injected `data-vsk-key` dynamic attribute matching
291
+ * the map's `keyExpr`. Only the common case — an element as the item root —
292
+ * is supported; other root shapes (conditionals/components) render without a
293
+ * key and degrade to fresh rendering, exactly as before.
294
+ */
295
+ function keyedItemTemplate(node) {
296
+ if (!node.keyExpr)
297
+ return node.bodyTemplate;
298
+ const template = node.bodyTemplate;
299
+ const idx = template.findIndex((n) => n instanceof StaticNode);
300
+ if (idx === -1)
301
+ return template;
302
+ const root = template[idx];
303
+ const copy = new StaticNode(root.tag, root.attributes, [...root.children, new DynamicBinding(node.keyExpr, 'attribute', 'data-vsk-key')], root.keyExpr);
304
+ copy.selfClosing = root.selfClosing;
305
+ const out = template.slice();
306
+ out[idx] = copy;
307
+ return out;
308
+ }
249
309
  function whileLoopToJS(node, isAsync = false, tracked) {
250
310
  const lines = [];
251
311
  if (node.isDoWhile) {
@@ -388,7 +448,9 @@ function componentCallToJS(node, importedNames, isAsync = false, tracked) {
388
448
  lines.push(`const ${calleeVar} = ${callee};`);
389
449
  const callExpr = `${awaitKw}${calleeVar}(${propsObj}, __registry, (${calleeVar}.__veskScope || __vesk))`;
390
450
  if (__vskHydrate) {
391
- lines.push(`__out.push('<!--vsk--><div>' + (${callExpr} || '') + '</div>');`);
451
+ // Wrapper purpose documented at HYDRATE_COMPONENT_WRAPPER; it must never
452
+ // become a layout box (see the constant's comment for why).
453
+ lines.push(`__out.push(${JSON.stringify(HYDRATE_COMPONENT_WRAPPER)} + (${callExpr} || '') + '</span>');`);
392
454
  }
393
455
  else {
394
456
  lines.push(`__out.push(${callExpr} || '');`);
@@ -446,8 +508,12 @@ export function generateFunctionBody(comp, importedNames) {
446
508
  __currentCompName = '';
447
509
  return lines.join('\n');
448
510
  }
449
- export function buildComponentMap(irRoot, useSharedScope) {
450
- const map = new Map();
511
+ /**
512
+ * Builds the compiled body source for every component in an IR root. Used by
513
+ * both `buildComponentMap` (live functions) and `precompile.ts` (AOT SSR plans
514
+ * that reconstruct the same functions at function-bundle load time).
515
+ */
516
+ export function buildComponentEntries(irRoot) {
451
517
  const runtimeNames = extractRuntimeNames(irRoot.imports);
452
518
  const localValueNames = localValueImportNames(irRoot.imports);
453
519
  const topNames = extractTopLevelNames(irRoot.topLevelCode);
@@ -463,6 +529,7 @@ export function buildComponentMap(irRoot, useSharedScope) {
463
529
  const allNames = [...new Set([...runtimeNames, ...topNames, ...extraNames, ...localValueNames])];
464
530
  const scopeDecl = allNames.length > 0 ? `const { ${allNames.join(', ')} } = __vesk;\n` : '';
465
531
  setVskImportedNames(importedNames);
532
+ const entries = [];
466
533
  for (const comp of irRoot.components) {
467
534
  const bodyCode = generateFunctionBody(comp, importedNames);
468
535
  const paramInit = buildParamInit(comp.paramNames);
@@ -470,15 +537,22 @@ export function buildComponentMap(irRoot, useSharedScope) {
470
537
  ? `console.error('[SSR-CALL]', ${JSON.stringify(comp.name)}, props ? JSON.stringify(props) : String(props));\n`
471
538
  : '';
472
539
  const code = `${scopeDecl}${paramInit}${diag}\n${bodyCode}`;
540
+ entries.push({ name: comp.name, source: code, isAsync: comp.isAsync || comp.ssrAwait });
541
+ }
542
+ setVskImportedNames(null);
543
+ return entries;
544
+ }
545
+ export function buildComponentMap(irRoot, useSharedScope) {
546
+ const map = new Map();
547
+ for (const entry of buildComponentEntries(irRoot)) {
473
548
  let fn;
474
- if (comp.isAsync || comp.ssrAwait) {
475
- fn = new Function('props', '__registry', '__vesk', `return (async () => {\n${code}\n})()`);
549
+ if (entry.isAsync) {
550
+ fn = new Function('props', '__registry', '__vesk', `return (async () => {\n${entry.source}\n})()`);
476
551
  }
477
552
  else {
478
- fn = new Function('props', '__registry', '__vesk', code);
553
+ fn = new Function('props', '__registry', '__vesk', entry.source);
479
554
  }
480
- map.set(comp.name, fn);
555
+ map.set(entry.name, fn);
481
556
  }
482
- setVskImportedNames(null);
483
557
  return map;
484
558
  }
@@ -1 +1 @@
1
- {"version":3,"file":"server-render.d.ts","sourceRoot":"","sources":["../src/server-render.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server-render.d.ts","sourceRoot":"","sources":["../src/server-render.ts"],"names":[],"mappings":"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"}
@@ -83,7 +83,8 @@ export function render(source, componentName, props = {}, registry = new Map(),
83
83
  const targetComp = compiled.ir.components.find((c) => c.name === componentName);
84
84
  const isAsyncComp = !!(targetComp && (targetComp.isAsync || targetComp.ssrAwait));
85
85
  globalThis.__vsk_ssr = true;
86
- globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
86
+ if (!globalThis.__vsk_ssr_token)
87
+ globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
87
88
  const renderToken = globalThis.__vsk_ssr_token;
88
89
  if (isAsyncComp) {
89
90
  return withSsrStore(() => (async () => {
@@ -93,6 +94,7 @@ export function render(source, componentName, props = {}, registry = new Map(),
93
94
  }
94
95
  finally {
95
96
  delete globalThis.__vsk_ssr;
97
+ await settleSsrPromises(renderToken);
96
98
  clearSsrCells(renderToken);
97
99
  }
98
100
  return bodyHtml;
@@ -130,8 +132,14 @@ export function renderPage(source, componentName, props = {}, registry = new Map
130
132
  const targetComp = ir.components.find((c) => c.name === componentName);
131
133
  const doRender = (ssrProps) => {
132
134
  globalThis.__vsk_ssr = true;
133
- globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
135
+ // Reuse an in-flight render token: the generated adapter handler renders
136
+ // page -> layouts -> document sequentially within one request, and all of
137
+ // them must share a single data slot so the final renderFullPage merge can
138
+ // serialize the whole handoff. Only create when no render began yet.
139
+ if (!globalThis.__vsk_ssr_token)
140
+ globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
134
141
  const renderToken = globalThis.__vsk_ssr_token;
142
+ pruneSsrDataSlots();
135
143
  if (targetComp && (targetComp.isAsync || targetComp.ssrAwait)) {
136
144
  return withSsrStore(() => (async () => {
137
145
  let bodyHtml;
@@ -140,6 +148,11 @@ export function renderPage(source, componentName, props = {}, registry = new Map
140
148
  }
141
149
  finally {
142
150
  delete globalThis.__vsk_ssr;
151
+ // Settle before cleanup: the fetch resolution callbacks write the
152
+ // SSR data (flat + per-token slot), and without this the promises
153
+ // were deleted by clearSsrCells before they ever resolved — the
154
+ // handoff came back empty and hydration claimed stale SSR nodes.
155
+ await settleSsrPromises(renderToken);
143
156
  clearSsrCells(renderToken);
144
157
  }
145
158
  return {
@@ -155,9 +168,19 @@ export function renderPage(source, componentName, props = {}, registry = new Map
155
168
  }
156
169
  finally {
157
170
  delete globalThis.__vsk_ssr;
158
- clearSsrCells(renderToken);
159
171
  }
160
172
  const headHtml = targetComp ? renderHeadHtml(targetComp, ssrProps) : '';
173
+ const pending = globalThis[`__vsk_ssr_promises_${renderToken}`];
174
+ if (pending && pending.length > 0) {
175
+ // A sync component still started server resources (useFetch): settle them
176
+ // before returning so the handoff serializes real data, not loading state.
177
+ return withSsrStore(() => (async () => {
178
+ await settleSsrPromises(renderToken);
179
+ clearSsrCells(renderToken);
180
+ return { body: bodyHtml, head: headHtml, props: ssrProps };
181
+ })());
182
+ }
183
+ clearSsrCells(renderToken);
161
184
  return { body: bodyHtml, head: headHtml, props: ssrProps };
162
185
  };
163
186
  if (ir.loadFn) {
@@ -182,7 +205,6 @@ function clearSsrCells(token) {
182
205
  return;
183
206
  delete globalThis[`__vsk_ssr_promises_${token}`];
184
207
  delete globalThis[`__vsk_ssr_failures_${token}`];
185
- delete globalThis.__vsk_ssr_token;
186
208
  const cells = globalThis.__vsk_ssr_cells;
187
209
  if (!cells || !(cells instanceof Map))
188
210
  return;
@@ -191,6 +213,35 @@ function clearSsrCells(token) {
191
213
  cells.delete(k);
192
214
  }
193
215
  }
216
+ /**
217
+ * Await every server resource promise registered under a render token. The
218
+ * fetch callbacks call setSsrData (flat + per-token slot) once they resolve,
219
+ * so a render MUST settle before cleanup or its handoff serializes nothing.
220
+ */
221
+ async function settleSsrPromises(token) {
222
+ if (!token)
223
+ return;
224
+ const pending = globalThis[`__vsk_ssr_promises_${token}`];
225
+ if (!pending || pending.length === 0)
226
+ return;
227
+ await Promise.allSettled(pending);
228
+ }
229
+ /**
230
+ * Bound the per-token SSR data slots against abandonment (dev-partial
231
+ * renderPage / render() that never runs renderFullPage). Slots are keyed by
232
+ * Math.random tokens, so deleted-slot accounting by token can't be cleaned by
233
+ * an owning render; cap the map instead. RenderFullPage/RenderPageStream delete
234
+ * their own slot + token in every path, so this only guards the untracked ones.
235
+ */
236
+ function pruneSsrDataSlots() {
237
+ const g = globalThis;
238
+ const keys = Object.keys(g).filter((k) => typeof k === 'string' && k.startsWith('__vsk_ssr_data_'));
239
+ if (keys.length <= 40)
240
+ return;
241
+ keys.sort().slice(0, keys.length - 40).forEach((k) => {
242
+ delete g[k];
243
+ });
244
+ }
194
245
  /**
195
246
  * Run the active plugins' `onHead` hooks (config order) against an assembled
196
247
  * `<head>` string. A hook returns the new head, or `null` to keep the input.
@@ -279,11 +330,17 @@ export async function ssg(source, componentName, customProps, options = {}) {
279
330
  const cssUrls = options.cssUrls || (options.cssUrl ? [options.cssUrl] : []);
280
331
  const cssLink = cssUrls.map(u => `\t<link rel="stylesheet" href="${u}" />\n`).join('');
281
332
  const renderCtx = { sourcePath: options.sourcePath };
282
- let finalHead = ['\t<meta charset="utf-8" />', '\t<meta name="viewport" content="width=device-width, initial-scale=1" />'].join('\n');
333
+ const baseHeadParts = [];
334
+ const lowerHead = (headHtml || '').toLowerCase();
335
+ if (!lowerHead.includes('charset'))
336
+ baseHeadParts.push('\t<meta charset="utf-8" />');
337
+ if (!lowerHead.includes('viewport'))
338
+ baseHeadParts.push('\t<meta name="viewport" content="width=device-width, initial-scale=1" />');
339
+ let finalHead = baseHeadParts.join('\n');
283
340
  if (cssLink)
284
- finalHead += '\n' + cssLink.trimEnd();
341
+ finalHead += (finalHead ? '\n' : '') + cssLink.trimEnd();
285
342
  if (headHtml)
286
- finalHead += '\n' + headHtml.split('\n').map((l) => '\t' + l).join('\n');
343
+ finalHead += (finalHead ? '\n' : '') + headHtml.split('\n').map((l) => '\t' + l).join('\n');
287
344
  finalHead = await applyHeadInjects(finalHead, { plugins: options.plugins, headExtra: options.headExtra }, renderCtx);
288
345
  const html = `<!DOCTYPE html>
289
346
  <html>
@@ -336,6 +393,19 @@ export function buildDataScripts(ssrProps, ssrData, external) {
336
393
  export async function renderFullPage(source, componentName, props = {}, registry = new Map(), options = {}) {
337
394
  return withSsrStore(async () => {
338
395
  globalThis.__vsk_ssr = true;
396
+ // Reuse the request token when a page/layout render already started one (the
397
+ // generated adapter handler renders page -> layouts -> document within one
398
+ // process), otherwise start fresh. One request = one token = one data slot.
399
+ if (!globalThis.__vsk_ssr_token)
400
+ globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
401
+ const renderToken = globalThis.__vsk_ssr_token;
402
+ pruneSsrDataSlots();
403
+ // Start this render's handoff store fresh: setSsrData mirrors into
404
+ // globalThis.__vsk_ssr_data (see resource.ts) because the AsyncLocalStorage
405
+ // sink snapshot can be empty when the write runs in a forked async context.
406
+ // Resetting here also gives the /about-after-/posts no-leak behavior the
407
+ // ALS isolation was introduced to fix.
408
+ globalThis.__vsk_ssr_data = {};
339
409
  try {
340
410
  let ssrProps = { ...props };
341
411
  let serializedProps = null;
@@ -354,7 +424,22 @@ export async function renderFullPage(source, componentName, props = {}, registry
354
424
  serializedProps = JSON.stringify(ssrProps);
355
425
  }
356
426
  const rendered = await renderPage(source, componentName, ssrProps, registry, { ...options, __vesk: options.__vesk });
357
- const ssrData = { ...ssrSink.snapshot() };
427
+ // Defensive settle: page/layout renders already awaited their own SSR
428
+ // promises before cleanup; this covers any resource started here (loadFn).
429
+ await settleSsrPromises(renderToken);
430
+ // Merge the ALS sink snapshot with the request's per-token data slot. The
431
+ // slot is the deterministic channel for writes that happened in a forked
432
+ // async context (native fetch) — the sink alone can miss them. The slot is
433
+ // request-scoped (deleted below), so a late write can never leak into a
434
+ // following request the way the flat global would.
435
+ const slot = globalThis[`__vsk_ssr_data_${renderToken}`] || {};
436
+ const ssrData = {
437
+ ...ssrSink.snapshot(),
438
+ ...slot,
439
+ };
440
+ if (process.env.VESK_SSR_TRACE) {
441
+ console.error(`[render-trace] renderFullPage comp=${componentName} keys=${Object.keys(ssrData).join(',') || '∅'} slot=${Object.keys(slot).join(',') || '∅'} sink=${Object.keys(ssrSink.snapshot()).join(',') || '∅'}`);
442
+ }
358
443
  let headHtml = rendered.head;
359
444
  if (options.pageHead) {
360
445
  const merged = mergeHeadHtml(options.pageHead, rendered.head);
@@ -373,7 +458,12 @@ export async function renderFullPage(source, componentName, props = {}, registry
373
458
  : '';
374
459
  const dataScripts = buildDataScripts(ssrProps, ssrData, options.externalDataScript);
375
460
  const dataScriptBlock = dataScripts.length > 0 ? '\n' + dataScripts.join('\n') + '\n' : '';
376
- const headLines = ['\t<meta charset="utf-8" />', '\t<meta name="viewport" content="width=device-width, initial-scale=1" />'];
461
+ const lowerHeadFull = (headHtml || '').toLowerCase();
462
+ const headLines = [];
463
+ if (!lowerHeadFull.includes('charset'))
464
+ headLines.push('\t<meta charset="utf-8" />');
465
+ if (!lowerHeadFull.includes('viewport'))
466
+ headLines.push('\t<meta name="viewport" content="width=device-width, initial-scale=1" />');
377
467
  if (cssLink)
378
468
  headLines.push(cssLink.trimEnd());
379
469
  if (headHtml)
@@ -402,6 +492,9 @@ ${dataScriptBlock}${clientScript}</body>
402
492
  }
403
493
  finally {
404
494
  delete globalThis.__vsk_ssr;
495
+ delete globalThis[`__vsk_ssr_data_${renderToken}`];
496
+ delete globalThis.__vsk_ssr_token;
497
+ pruneSsrDataSlots();
405
498
  }
406
499
  });
407
500
  }
@@ -436,7 +529,7 @@ export function renderPageStream(source, componentName, props = {}, registry = n
436
529
  const targetComp = ir.components.find((c) => c.name === componentName);
437
530
  const cssUrls = options.cssUrls || (options.cssUrl ? [options.cssUrl] : []);
438
531
  const cssLink = cssUrls.map(u => `\t<link rel="stylesheet" href="${u}" />\n`).join('');
439
- const headParts = ['\t<meta charset="utf-8" />', '\t<meta name="viewport" content="width=device-width, initial-scale=1" />'];
532
+ const headParts = [];
440
533
  if (cssLink)
441
534
  headParts.push(cssLink.trimEnd());
442
535
  if (options.security) {
@@ -448,6 +541,7 @@ export function renderPageStream(source, componentName, props = {}, registry = n
448
541
  if (sec.autoEscape !== false)
449
542
  headParts.push('\t<!-- vesk: auto-escape enabled -->');
450
543
  }
544
+ let streamHeadHtml = null;
451
545
  if (targetComp) {
452
546
  let headHtml = renderHeadHtml(targetComp, ssrProps);
453
547
  if (options.pageHead) {
@@ -459,26 +553,50 @@ export function renderPageStream(source, componentName, props = {}, registry = n
459
553
  }
460
554
  }
461
555
  }
462
- if (headHtml)
556
+ if (headHtml) {
557
+ streamHeadHtml = headHtml;
463
558
  headParts.push('\t' + headHtml.split('\n').join('\n\t'));
559
+ }
464
560
  }
561
+ const lowerStreamHead = (streamHeadHtml || '').toLowerCase();
562
+ const baseStream = [];
563
+ if (!lowerStreamHead.includes('charset'))
564
+ baseStream.push('\t<meta charset="utf-8" />');
565
+ if (!lowerStreamHead.includes('viewport'))
566
+ baseStream.push('\t<meta name="viewport" content="width=device-width, initial-scale=1" />');
567
+ headParts.unshift(...baseStream);
465
568
  yield '<!DOCTYPE html>\n<html>\n<head>\n';
466
569
  const finalHead = await applyHeadInjects(headParts.join('\n'), { plugins: options.plugins, headExtra: options.headExtra }, { sourcePath: options.sourcePath, url: options.__vesk?.url || undefined });
467
570
  yield finalHead + '\n';
468
571
  yield '</head>\n<body>\n<div id="root">\n';
469
572
  globalThis.__vsk_ssr = true;
470
- globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
573
+ // Fresh handoff store per render; see renderFullPage note.
574
+ globalThis.__vsk_ssr_data = {};
575
+ if (!globalThis.__vsk_ssr_token)
576
+ globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
471
577
  const renderToken = globalThis.__vsk_ssr_token;
578
+ pruneSsrDataSlots();
472
579
  let bodyHtml;
473
580
  try {
474
581
  bodyHtml = await Promise.resolve(renderFn(ssrProps, fullRegistry, scopedVesk(renderFn, __vesk)));
475
582
  }
476
583
  finally {
477
584
  delete globalThis.__vsk_ssr;
585
+ await settleSsrPromises(renderToken);
478
586
  clearSsrCells(renderToken);
479
587
  }
480
588
  yield bodyHtml;
481
- const ssrData = { ...ssrSink.snapshot() };
589
+ const slot = globalThis[`__vsk_ssr_data_${renderToken}`] || {};
590
+ const ssrData = {
591
+ ...ssrSink.snapshot(),
592
+ ...slot,
593
+ };
594
+ if (process.env.VESK_SSR_TRACE) {
595
+ console.error(`[render-trace] renderPageStream comp=${componentName} keys=${Object.keys(ssrData).join(',') || '∅'} slot=${Object.keys(slot).join(',') || '∅'} sink=${Object.keys(ssrSink.snapshot()).join(',') || '∅'}`);
596
+ }
597
+ delete globalThis[`__vsk_ssr_data_${renderToken}`];
598
+ delete globalThis.__vsk_ssr_token;
599
+ pruneSsrDataSlots();
482
600
  const dataScripts = buildDataScripts(ssrProps, ssrData, options.externalDataScript);
483
601
  const dataScriptBlock = dataScripts.length > 0 ? '\n' + dataScripts.join('\n') : '';
484
602
  // Mirror renderFullPage: a full document must carry the client bundle or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vesk/compiler",
3
- "version": "0.2.22",
3
+ "version": "0.2.24",
4
4
  "type": "module",
5
5
  "description": "Vesk compiler — lexer, parser, semantic analysis, IR, codegen",
6
6
  "main": "./dist/index.js",
@@ -20,7 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "@sveltejs/acorn-typescript": "^1.0.0",
23
- "@vesk/types": "^0.2.22",
23
+ "@vesk/types": "^0.2.24",
24
24
  "acorn": "^8.15.0",
25
25
  "esrap": "^2.3.0",
26
26
  "is-reference": "^3.0.0",