@vesk/compiler 0.2.21 → 0.2.23

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)
@@ -205,6 +229,12 @@ function mapRegionToJS(node, isAsync = false, tracked) {
205
229
  const lines = [];
206
230
  const arr = exprJSX(node.expression, tracked);
207
231
  const item = node.itemVariable;
232
+ // In hydration SSR, stamp each keyed item root with `data-vsk-key` so the
233
+ // client can claim items by key instead of rebuilding them. The binding is
234
+ // injected into a shadow copy of the item root at codegen time — it flows
235
+ // through the existing dynamic-attribute machinery (which also forces a
236
+ // claim marker on otherwise-static roots).
237
+ const bodyForItem = __vskHydrate && node.keyExpr ? keyedItemTemplate(node) : node.bodyTemplate;
208
238
  const hasAlternate = node.alternateNodes.length > 0;
209
239
  const arrVar = hasAlternate ? `__a${nextVskId()}` : null;
210
240
  if (hasAlternate) {
@@ -224,7 +254,7 @@ function mapRegionToJS(node, isAsync = false, tracked) {
224
254
  lines.push('let __i = 0;');
225
255
  lines.push(`for (const ${item} of ${loopArr}) {`);
226
256
  lines.push(indent(`const ${node.indexVariable} = __i;`));
227
- for (const n of node.bodyTemplate) {
257
+ for (const n of bodyForItem) {
228
258
  const code = irNodeToJS(n, null, isAsync, tracked);
229
259
  if (code)
230
260
  lines.push(indent(code));
@@ -234,7 +264,7 @@ function mapRegionToJS(node, isAsync = false, tracked) {
234
264
  }
235
265
  else {
236
266
  lines.push(`for (const ${item} of ${loopArr}) {`);
237
- for (const n of node.bodyTemplate) {
267
+ for (const n of bodyForItem) {
238
268
  const code = irNodeToJS(n, null, isAsync, tracked);
239
269
  if (code)
240
270
  lines.push(indent(code));
@@ -246,6 +276,27 @@ function mapRegionToJS(node, isAsync = false, tracked) {
246
276
  }
247
277
  return lines.join('\n');
248
278
  }
279
+ /**
280
+ * Returns a per-item body where the item root element (the first top-level
281
+ * StaticNode) carries an injected `data-vsk-key` dynamic attribute matching
282
+ * the map's `keyExpr`. Only the common case — an element as the item root —
283
+ * is supported; other root shapes (conditionals/components) render without a
284
+ * key and degrade to fresh rendering, exactly as before.
285
+ */
286
+ function keyedItemTemplate(node) {
287
+ if (!node.keyExpr)
288
+ return node.bodyTemplate;
289
+ const template = node.bodyTemplate;
290
+ const idx = template.findIndex((n) => n instanceof StaticNode);
291
+ if (idx === -1)
292
+ return template;
293
+ const root = template[idx];
294
+ const copy = new StaticNode(root.tag, root.attributes, [...root.children, new DynamicBinding(node.keyExpr, 'attribute', 'data-vsk-key')], root.keyExpr);
295
+ copy.selfClosing = root.selfClosing;
296
+ const out = template.slice();
297
+ out[idx] = copy;
298
+ return out;
299
+ }
249
300
  function whileLoopToJS(node, isAsync = false, tracked) {
250
301
  const lines = [];
251
302
  if (node.isDoWhile) {
@@ -388,7 +439,9 @@ function componentCallToJS(node, importedNames, isAsync = false, tracked) {
388
439
  lines.push(`const ${calleeVar} = ${callee};`);
389
440
  const callExpr = `${awaitKw}${calleeVar}(${propsObj}, __registry, (${calleeVar}.__veskScope || __vesk))`;
390
441
  if (__vskHydrate) {
391
- lines.push(`__out.push('<!--vsk--><div>' + (${callExpr} || '') + '</div>');`);
442
+ // Wrapper purpose documented at HYDRATE_COMPONENT_WRAPPER; it must never
443
+ // become a layout box (see the constant's comment for why).
444
+ lines.push(`__out.push(${JSON.stringify(HYDRATE_COMPONENT_WRAPPER)} + (${callExpr} || '') + '</span>');`);
392
445
  }
393
446
  else {
394
447
  lines.push(`__out.push(${callExpr} || '');`);
@@ -446,8 +499,12 @@ export function generateFunctionBody(comp, importedNames) {
446
499
  __currentCompName = '';
447
500
  return lines.join('\n');
448
501
  }
449
- export function buildComponentMap(irRoot, useSharedScope) {
450
- const map = new Map();
502
+ /**
503
+ * Builds the compiled body source for every component in an IR root. Used by
504
+ * both `buildComponentMap` (live functions) and `precompile.ts` (AOT SSR plans
505
+ * that reconstruct the same functions at function-bundle load time).
506
+ */
507
+ export function buildComponentEntries(irRoot) {
451
508
  const runtimeNames = extractRuntimeNames(irRoot.imports);
452
509
  const localValueNames = localValueImportNames(irRoot.imports);
453
510
  const topNames = extractTopLevelNames(irRoot.topLevelCode);
@@ -463,6 +520,7 @@ export function buildComponentMap(irRoot, useSharedScope) {
463
520
  const allNames = [...new Set([...runtimeNames, ...topNames, ...extraNames, ...localValueNames])];
464
521
  const scopeDecl = allNames.length > 0 ? `const { ${allNames.join(', ')} } = __vesk;\n` : '';
465
522
  setVskImportedNames(importedNames);
523
+ const entries = [];
466
524
  for (const comp of irRoot.components) {
467
525
  const bodyCode = generateFunctionBody(comp, importedNames);
468
526
  const paramInit = buildParamInit(comp.paramNames);
@@ -470,15 +528,22 @@ export function buildComponentMap(irRoot, useSharedScope) {
470
528
  ? `console.error('[SSR-CALL]', ${JSON.stringify(comp.name)}, props ? JSON.stringify(props) : String(props));\n`
471
529
  : '';
472
530
  const code = `${scopeDecl}${paramInit}${diag}\n${bodyCode}`;
531
+ entries.push({ name: comp.name, source: code, isAsync: comp.isAsync || comp.ssrAwait });
532
+ }
533
+ setVskImportedNames(null);
534
+ return entries;
535
+ }
536
+ export function buildComponentMap(irRoot, useSharedScope) {
537
+ const map = new Map();
538
+ for (const entry of buildComponentEntries(irRoot)) {
473
539
  let fn;
474
- if (comp.isAsync || comp.ssrAwait) {
475
- fn = new Function('props', '__registry', '__vesk', `return (async () => {\n${code}\n})()`);
540
+ if (entry.isAsync) {
541
+ fn = new Function('props', '__registry', '__vesk', `return (async () => {\n${entry.source}\n})()`);
476
542
  }
477
543
  else {
478
- fn = new Function('props', '__registry', '__vesk', code);
544
+ fn = new Function('props', '__registry', '__vesk', entry.source);
479
545
  }
480
- map.set(comp.name, fn);
546
+ map.set(entry.name, fn);
481
547
  }
482
- setVskImportedNames(null);
483
548
  return map;
484
549
  }
@@ -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.21",
3
+ "version": "0.2.23",
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.21",
23
+ "@vesk/types": "^0.2.23",
24
24
  "acorn": "^8.15.0",
25
25
  "esrap": "^2.3.0",
26
26
  "is-reference": "^3.0.0",