@vesk/compiler 0.2.92 → 0.2.94

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.
package/dist/parser.js CHANGED
@@ -573,26 +573,317 @@ export function parse(source, options = {}) {
573
573
  column = (err.loc.column ?? 0) + 1;
574
574
  }
575
575
  else {
576
- const m = err.message.match(/\((\d+):(\d+)\)/);
577
- if (m) {
578
- line = parseInt(m[1], 10);
579
- column = parseInt(m[2], 10) + 1;
576
+ // Parse "(line:column)" from the message without regex.
577
+ const parenOpen = err.message.indexOf('(');
578
+ if (parenOpen >= 0) {
579
+ const parenClose = err.message.indexOf(')', parenOpen);
580
+ if (parenClose > parenOpen) {
581
+ const inside = err.message.slice(parenOpen + 1, parenClose);
582
+ const colon = inside.indexOf(':');
583
+ if (colon > 0) {
584
+ line = parseInt(inside.slice(0, colon), 10);
585
+ column = parseInt(inside.slice(colon + 1), 10) + 1;
586
+ }
587
+ }
580
588
  }
581
589
  }
582
590
  const filename = options.filename || '';
583
- const cleanMessage = err.message.replace(/\s*\(\d+:\d+\)\s*$/, '');
584
- const frame = line > 0 ? codeFrame(source, line, column, 5, 5) : '';
591
+ // Strip the trailing "(line:column)" acorn appends, without regex.
592
+ let cleanMessage = err.message;
593
+ const lastParen = cleanMessage.lastIndexOf('(');
594
+ if (lastParen >= 0) {
595
+ const closeParen = cleanMessage.indexOf(')', lastParen);
596
+ if (closeParen > lastParen) {
597
+ const inside = cleanMessage.slice(lastParen + 1, closeParen);
598
+ const colon = inside.indexOf(':');
599
+ if (colon > 0 &&
600
+ inside.slice(0, colon).split('').every((ch) => ch >= '0' && ch <= '9') &&
601
+ inside.slice(colon + 1).split('').every((ch) => ch >= '0' && ch <= '9')) {
602
+ cleanMessage = cleanMessage.slice(0, lastParen).trimEnd();
603
+ }
604
+ }
605
+ }
606
+ // --- Convert acorn's raw JSX errors into descriptive Vesk messages ---
607
+ let message = cleanMessage;
608
+ let errLine = line;
609
+ let errColumn = column;
610
+ let code;
611
+ const extraHelp = [];
612
+ const frame = errLine > 0 ? codeFrame(source, errLine, errColumn, 5, 5) : '';
585
613
  const hint = filename ? ` in ${filename}` : '';
586
- throw new VeskError(`${cleanMessage}${hint}`, {
614
+ // "Unterminated JSX contents" — acorn hit EOF inside a JSX block.
615
+ if (cleanMessage.includes('Unterminated JSX contents')) {
616
+ throw VeskError.jsxUnterminated({
617
+ file: filename,
618
+ line: errLine,
619
+ column: errColumn,
620
+ frame,
621
+ });
622
+ }
623
+ // "JSX attributes must only be assigned a non-empty expression" — `{}`.
624
+ if (cleanMessage.includes('JSX attributes must only be assigned a non-empty expression')) {
625
+ throw VeskError.jsxEmptyExpression({
626
+ file: filename,
627
+ line: errLine,
628
+ column: errColumn,
629
+ frame,
630
+ });
631
+ }
632
+ // "Expected corresponding JSX closing tag for <X>" — acorn points at the
633
+ // opening tag, but it could mean either a mismatched closing tag
634
+ // (<div></span>) or a genuinely unclosed tag (<div><span>...</div>).
635
+ const jsxClosePrefix = 'Expected corresponding JSX closing tag for <';
636
+ if (cleanMessage.startsWith(jsxClosePrefix)) {
637
+ const mismatched = findMismatchedJsxTag(source, errLine);
638
+ if (mismatched) {
639
+ throw VeskError.jsxMismatchedTag(mismatched.openingTag, mismatched.closingTag, {
640
+ file: filename,
641
+ line: mismatched.line,
642
+ column: mismatched.column,
643
+ frame: codeFrame(source, mismatched.line, mismatched.column, 5, 5),
644
+ });
645
+ }
646
+ const rest = cleanMessage.slice(jsxClosePrefix.length);
647
+ const gt = rest.indexOf('>');
648
+ const tagName = gt >= 0 ? rest.slice(0, gt) : rest;
649
+ throw VeskError.jsxUnclosedTag(tagName, {
650
+ file: filename,
651
+ line: errLine,
652
+ column: errColumn,
653
+ frame,
654
+ suggestedFix: `Insert </${tagName}> before the tag that closes the parent element.`,
655
+ });
656
+ }
657
+ // "Unexpected token" inside a component body often means a JSX tag was
658
+ // opened but never closed — acorn reports it late, at the closing `}`.
659
+ if (cleanMessage.includes('Unexpected token') && errLine > 0) {
660
+ const srcLine = (source.split('\n')[errLine - 1] ?? '').trim();
661
+ const isBlockCloser = srcLine.startsWith('}') || srcLine.startsWith(')');
662
+ if (isBlockCloser) {
663
+ const unclosed = findUnclosedJsxTag(source, errLine);
664
+ if (unclosed) {
665
+ throw VeskError.jsxUnclosedTag(unclosed.tagName, {
666
+ file: filename,
667
+ line: unclosed.line,
668
+ column: unclosed.column,
669
+ frame: errLine > 0 ? codeFrame(source, unclosed.line, unclosed.column, 5, 5) : '',
670
+ suggestedFix: `Insert </${unclosed.tagName}> before the tag that closes the parent element.`,
671
+ });
672
+ }
673
+ }
674
+ // `{if (cond) { <jsx/> }}` — LLMs often wrap statements in braces in
675
+ // statement mode, but Vesk doesn't need them. Look for `{` immediately
676
+ // before the offending token (skipping whitespace) followed by a
677
+ // statement keyword (`if`, `for`, `while`, `switch`, `try`, `const`,
678
+ // `let`, `var`, `function`, `class`, `return`).
679
+ if (errColumn > 1) {
680
+ const lineText = source.split('\n')[errLine - 1] ?? '';
681
+ // Walk back from the error column to find the `{` that opened the
682
+ // block (or JSX expression container) that shouldn't be there.
683
+ let j = errColumn - 2; // 0-based index of char before error token
684
+ while (j >= 0 && (lineText[j] === ' ' || lineText[j] === '\t'))
685
+ j--;
686
+ if (j >= 0 && lineText[j] === '{') {
687
+ // Read the keyword after `{` (skipping whitespace)
688
+ let k = errColumn - 1; // 0-based index of error token start
689
+ while (k < lineText.length && (lineText[k] === ' ' || lineText[k] === '\t'))
690
+ k++;
691
+ let keyword = '';
692
+ while (k < lineText.length && ((lineText[k] >= 'a' && lineText[k] <= 'z') || (lineText[k] >= 'A' && lineText[k] <= 'Z') || lineText[k] === '_' || lineText[k] === '$')) {
693
+ keyword += lineText[k];
694
+ k++;
695
+ }
696
+ const statementKeywords = new Set(['if', 'for', 'while', 'switch', 'try', 'const', 'let', 'var', 'function', 'class', 'return', 'do', 'with']);
697
+ if (statementKeywords.has(keyword)) {
698
+ const startLine = errLine;
699
+ const startColumn = j + 1; // 1-based column of `{`
700
+ throw VeskError.blockStatementInStatementMode({
701
+ file: filename,
702
+ line: startLine,
703
+ column: startColumn,
704
+ frame: codeFrame(source, startLine, startColumn, 5, 5),
705
+ });
706
+ }
707
+ }
708
+ }
709
+ }
710
+ // Targeted help for a bare template literal used as a JSX attribute value,
711
+ // e.g. <Link href=`/admin/learners/${id}`>. JSX attributes must be a quoted
712
+ // string or a `{...}` expression; a backtick on its own is not valid JSX.
713
+ if (errLine > 0 &&
714
+ cleanMessage.includes('JSX value should be either an expression or a quoted JSX text')) {
715
+ const srcLine = (source.split('\n')[errLine - 1] ?? '');
716
+ if (srcLine.includes('`')) {
717
+ extraHelp.push('JSX attribute values must be a quoted string (`href="..."`) or a `{...}` expression (`href={expr}`).', 'A bare backtick template literal is not valid JSX — wrap it in braces:', ' href={`/admin/learners/${id}`}');
718
+ }
719
+ }
720
+ throw new VeskError(`${message}${hint}`, {
587
721
  file: filename,
588
- line,
589
- column,
590
- code: frame,
591
- nextSteps: [
592
- 'Check the line indicated by the ^ marker for missing brackets, quotes, or JSX syntax.',
593
- 'If you wrote literal { or } inside JSX text, escape them as {\'{\'} and {\'}\'} or use &lbrace; &rbrace;.',
594
- 'Ensure all JSX tags are properly closed and component names start with an uppercase letter.',
595
- ],
722
+ line: errLine,
723
+ column: errColumn,
724
+ frame,
725
+ code,
726
+ nextSteps: extraHelp.length ? extraHelp : [],
596
727
  });
597
728
  }
598
729
  }
730
+ /**
731
+ * Scan the source (character by character, no regex) tracking JSX opening and
732
+ * closing tags up to `upToLine`. Returns the most recently opened tag that
733
+ * was never closed, or `null` if all tags are balanced. Used only to produce
734
+ * a better error message when acorn reports a generic "Unexpected token"
735
+ * for an unclosed JSX tag.
736
+ */
737
+ function findUnclosedJsxTag(source, upToLine) {
738
+ const stack = [];
739
+ let i = 0;
740
+ let currentLine = 1;
741
+ const isIdentStart = (code) => (code >= 97 && code <= 122) || (code >= 65 && code <= 90) || code === 95; // a-z A-Z _
742
+ while (i < source.length) {
743
+ if (source[i] === '\n') {
744
+ currentLine++;
745
+ i++;
746
+ if (currentLine > upToLine)
747
+ break;
748
+ continue;
749
+ }
750
+ if (currentLine > upToLine)
751
+ break;
752
+ if (source[i] === '<') {
753
+ // Closing tag: </tagName>
754
+ if (i + 1 < source.length && source[i + 1] === '/') {
755
+ let j = i + 2;
756
+ while (j < source.length && source[j] !== '>' && source[j] !== '\n')
757
+ j++;
758
+ const tagName = source.slice(i + 2, j);
759
+ // Only pop if it matches the most recently opened unclosed tag.
760
+ if (stack.length > 0 && stack[stack.length - 1].tagName === tagName) {
761
+ stack.pop();
762
+ }
763
+ // Skip past the closing >.
764
+ while (j < source.length && source[j] !== '>')
765
+ j++;
766
+ if (j < source.length)
767
+ j++;
768
+ i = j;
769
+ continue;
770
+ }
771
+ // Opening tag: <tagName ...> (skip namespace prefixes like ns.)
772
+ let j = i + 1;
773
+ while (j < source.length &&
774
+ (source[j] === '.' || source[j] === ':' ||
775
+ (source.charCodeAt(j) >= 97 && source.charCodeAt(j) <= 122) ||
776
+ (source.charCodeAt(j) >= 65 && source.charCodeAt(j) <= 90) ||
777
+ source.charCodeAt(j) === 95)) {
778
+ j++;
779
+ }
780
+ const tagName = source.slice(i + 1, j);
781
+ if (tagName.length > 0 && isIdentStart(tagName.charCodeAt(0))) {
782
+ // Check for self-closing: <tagName />
783
+ let k = j;
784
+ let selfClosing = false;
785
+ while (k < source.length && source[k] !== '>') {
786
+ if (source[k] === '/' && k + 1 < source.length && source[k + 1] === '>') {
787
+ selfClosing = true;
788
+ break;
789
+ }
790
+ k++;
791
+ }
792
+ if (!selfClosing) {
793
+ const loc = offsetToLineCol(source, i);
794
+ stack.push({ tagName, line: loc.line, column: loc.column });
795
+ }
796
+ }
797
+ // Skip past the closing >.
798
+ while (j < source.length && source[j] !== '>')
799
+ j++;
800
+ if (j < source.length)
801
+ j++;
802
+ i = j;
803
+ continue;
804
+ }
805
+ i++;
806
+ }
807
+ // The most recently opened tag still on the stack is the unclosed one.
808
+ if (stack.length > 0) {
809
+ return stack[stack.length - 1];
810
+ }
811
+ return null;
812
+ }
813
+ /**
814
+ * Scan the source tracking JSX tags up to `upToLine`. Returns the first
815
+ * closing tag that does not match the most recently opened tag,
816
+ * e.g. `</span>` after `<div>`. Character scan only — no regex.
817
+ */
818
+ function findMismatchedJsxTag(source, upToLine) {
819
+ const stack = [];
820
+ let i = 0;
821
+ let currentLine = 1;
822
+ const isIdentStart = (code) => (code >= 97 && code <= 122) || (code >= 65 && code <= 90) || code === 95;
823
+ while (i < source.length) {
824
+ if (source[i] === '\n') {
825
+ currentLine++;
826
+ i++;
827
+ if (currentLine > upToLine)
828
+ break;
829
+ continue;
830
+ }
831
+ if (currentLine > upToLine)
832
+ break;
833
+ if (source[i] === '<') {
834
+ // Closing tag: </tagName>
835
+ if (i + 1 < source.length && source[i + 1] === '/') {
836
+ let j = i + 2;
837
+ while (j < source.length && source[j] !== '>' && source[j] !== '\n')
838
+ j++;
839
+ const closingTag = source.slice(i + 2, j);
840
+ while (j < source.length && source[j] !== '>')
841
+ j++;
842
+ if (j < source.length)
843
+ j++;
844
+ const loc = offsetToLineCol(source, i);
845
+ // Mismatch: a closing tag that doesn't match the most recent opening tag.
846
+ if (stack.length > 0 && stack[stack.length - 1] !== closingTag) {
847
+ return { openingTag: stack[stack.length - 1], closingTag, line: loc.line, column: loc.column };
848
+ }
849
+ // Match: pop the stack.
850
+ if (stack.length > 0)
851
+ stack.pop();
852
+ i = j;
853
+ continue;
854
+ }
855
+ // Opening tag: <tagName ...>
856
+ let j = i + 1;
857
+ while (j < source.length &&
858
+ (source[j] === '.' || source[j] === ':' ||
859
+ (source.charCodeAt(j) >= 97 && source.charCodeAt(j) <= 122) ||
860
+ (source.charCodeAt(j) >= 65 && source.charCodeAt(j) <= 90) ||
861
+ source.charCodeAt(j) === 95)) {
862
+ j++;
863
+ }
864
+ const tagName = source.slice(i + 1, j);
865
+ if (tagName.length > 0 && isIdentStart(tagName.charCodeAt(0))) {
866
+ // Self-closing: <tagName />
867
+ let k = j;
868
+ let selfClosing = false;
869
+ while (k < source.length && source[k] !== '>') {
870
+ if (source[k] === '/' && k + 1 < source.length && source[k + 1] === '>') {
871
+ selfClosing = true;
872
+ break;
873
+ }
874
+ k++;
875
+ }
876
+ if (!selfClosing)
877
+ stack.push(tagName);
878
+ }
879
+ while (j < source.length && source[j] !== '>')
880
+ j++;
881
+ if (j < source.length)
882
+ j++;
883
+ i = j;
884
+ continue;
885
+ }
886
+ i++;
887
+ }
888
+ return null;
889
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"precompile.d.ts","sourceRoot":"","sources":["../src/precompile.ts"],"names":[],"mappings":"AASA,OAAO,EAGL,cAAc,EACf,MAAM,mCAAmC,CAAC;AAC3C,OAAO,KAAK,EACV,kBAAkB,EAGnB,MAAM,uCAAuC,CAAC;AAG/C;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,MAAM,EACd,UAAU,CAAC,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,cAAc,GACxB,kBAAkB,CAapB;AAeD,iFAAiF;AACjF,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C"}
1
+ {"version":3,"file":"precompile.d.ts","sourceRoot":"","sources":["../src/precompile.ts"],"names":[],"mappings":"AASA,OAAO,EAIL,cAAc,EACf,MAAM,mCAAmC,CAAC;AAC3C,OAAO,KAAK,EACV,kBAAkB,EAGnB,MAAM,uCAAuC,CAAC;AAG/C;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,MAAM,EAAE,MAAM,EACd,UAAU,CAAC,EAAE,MAAM,EACnB,QAAQ,CAAC,EAAE,cAAc,GACxB,kBAAkB,CAsBpB;AA8CD,iFAAiF;AACjF,wBAAgB,oBAAoB,IAAI,IAAI,CAE3C"}
@@ -26,16 +26,54 @@ export function precompileFile(source, sourcePath, registry) {
26
26
  const hit = precompileCache.get(sourcePath);
27
27
  // Compare the source, not just the path: the dev server rebuilds in-process
28
28
  // and a stale plan would silently ship yesterday's component.
29
- if (hit !== undefined && hit.source === source)
29
+ if (hit !== undefined && hit.source === source) {
30
+ // A plan is only half of the output: it references module keys that live
31
+ // in the caller's registry. A second build in the same process (prod then
32
+ // dev under `e2e-setup`) starts with a fresh registry, so replaying the
33
+ // memoized plan without its modules left `__veskModules` empty and every
34
+ // bare-package import (`vesk-qr`, …) resolved to `undefined`. Keys are
35
+ // path-derived, so registering the same absPaths here is idempotent.
36
+ for (const { absPath, entry } of hit.modules)
37
+ reg.register(absPath, entry);
30
38
  return hit.plan;
39
+ }
31
40
  const plan = precompileFileInternal(source, sourcePath, new Set(), reg);
32
41
  // Bound it: plans are sizeable on a large app, and a watch session can
33
42
  // outlive many builds.
34
43
  if (precompileCache.size >= 512)
35
44
  precompileCache.clear();
36
- precompileCache.set(sourcePath, { source, plan });
45
+ precompileCache.set(sourcePath, { source, plan, modules: collectPlanModules(plan, reg) });
37
46
  return plan;
38
47
  }
48
+ /**
49
+ * Every module entry a plan (and its nested `.vsk` sub-plans) resolves
50
+ * against, including transitive deps. The shared `__veskModules` registry
51
+ * must contain the whole closure, not just the keys named by `plan.modules`,
52
+ * or `evaluateModule` cannot walk `deps` at hydrate time.
53
+ */
54
+ function collectPlanModules(plan, registry) {
55
+ const out = [];
56
+ const seen = new Set();
57
+ const visitKey = (key) => {
58
+ if (seen.has(key))
59
+ return;
60
+ seen.add(key);
61
+ const entry = registry.getModule(key);
62
+ if (!entry || !entry.absPath)
63
+ return;
64
+ out.push({ absPath: entry.absPath, entry });
65
+ for (const depKey of Object.values(entry.deps))
66
+ visitKey(depKey);
67
+ };
68
+ const visitPlan = (p) => {
69
+ for (const mod of p.modules)
70
+ visitKey(mod.key);
71
+ for (const sub of p.subFiles)
72
+ visitPlan(sub.plan);
73
+ };
74
+ visitPlan(plan);
75
+ return out;
76
+ }
39
77
  /**
40
78
  * Compiled plans, keyed by absolute path.
41
79
  *
@@ -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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"}
@@ -463,23 +463,24 @@ export function buildDataScripts(ssrProps, ssrData, external) {
463
463
  return scripts;
464
464
  }
465
465
  export async function renderFullPage(source, componentName, props = {}, registry = new Map(), options = {}) {
466
- return withSsrStore(async () => {
467
- globalThis.__vsk_ssr = true;
468
- // Reuse the request token when a page/layout render already started one (the
469
- // generated adapter handler renders page -> layouts -> document within one
470
- // process), otherwise start fresh. One request = one token = one data slot.
471
- if (!globalThis.__vsk_ssr_token)
472
- globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
473
- const renderToken = globalThis.__vsk_ssr_token;
474
- keepSsrSlot(renderToken);
475
- pruneSsrDataSlots();
476
- // Start this render's handoff store fresh: setSsrData mirrors into
477
- // globalThis.__vsk_ssr_data (see resource.ts) because the AsyncLocalStorage
478
- // sink snapshot can be empty when the write runs in a forked async context.
479
- // Resetting here also gives the /about-after-/posts no-leak behavior the
480
- // ALS isolation was introduced to fix.
481
- globalThis.__vsk_ssr_data = {};
482
- try {
466
+ // Adopt the ambient request store when one is open: renderPage runs in the
467
+ // enclosing withSsrStore scope and attributes setSsrData writes to that
468
+ // store's token, so renderFullPage must read the same slot to hand off SSR
469
+ // data. Only mint a fresh store when called stand-alone (no ambient scope),
470
+ // which keeps concurrent requests isolated through their distinct tokens.
471
+ const store = adoptSsrStore();
472
+ const renderToken = store.token;
473
+ keepSsrSlot(renderToken);
474
+ pruneSsrDataSlots();
475
+ globalThis.__vsk_ssr = true;
476
+ // Start this render's handoff store fresh: setSsrData mirrors into
477
+ // globalThis.__vsk_ssr_data (see resource.ts) because the AsyncLocalStorage
478
+ // sink snapshot can be empty when the write runs in a forked async context.
479
+ // Resetting here also gives the /about-after-/posts no-leak behavior.
480
+ globalThis.__vsk_ssr_data = {};
481
+ let result;
482
+ try {
483
+ result = await withSsrStoreOf(store, async () => {
483
484
  let ssrProps = { ...props };
484
485
  let serializedProps = null;
485
486
  const cached = options.cached || undefined;
@@ -500,18 +501,20 @@ export async function renderFullPage(source, componentName, props = {}, registry
500
501
  // Defensive settle: page/layout renders already awaited their own SSR
501
502
  // promises before cleanup; this covers any resource started here (loadFn).
502
503
  await settleSsrPromises(renderToken);
503
- // Merge the ALS sink snapshot with the request's per-token data slot. The
504
- // slot is the deterministic channel for writes that happened in a forked
505
- // async context (native fetch) — the sink alone can miss them. The slot is
506
- // request-scoped (deleted below), so a late write can never leak into a
507
- // following request the way the flat global would.
504
+ // Merge the request's per-token data slot. The slot is the deterministic
505
+ // channel for writes that happened in a forked async context (native fetch
506
+ // .then() callbacks run outside the ALS scope) and is keyed per render
507
+ // token, so concurrent renders never mix data. The ALS sink (ssrSink) is
508
+ // NOT read here because withSsrStore reuses the ambient store when one is
509
+ // already open, so concurrent renderFullPage calls share a single sink and
510
+ // their writes would cross-contaminate. Every setSsrData write also lands
511
+ // in the slot, so the slot alone is a complete handoff source.
508
512
  const slot = globalThis[`__vsk_ssr_data_${renderToken}`] || {};
509
513
  const ssrData = {
510
- ...ssrSink.snapshot(),
511
514
  ...slot,
512
515
  };
513
516
  if (process.env.VESK_SSR_TRACE) {
514
- console.error(`[render-trace] renderFullPage comp=${componentName} keys=${Object.keys(ssrData).join(',') || '∅'} slot=${Object.keys(slot).join(',') || '∅'} sink=${Object.keys(ssrSink.snapshot()).join(',') || '∅'}`);
517
+ console.error(`[render-trace] renderFullPage comp=${componentName} keys=${Object.keys(ssrData).join(',') || '∅'} slot=${Object.keys(slot).join(',') || '∅'}`);
515
518
  }
516
519
  let headHtml = rendered.head;
517
520
  if (options.pageHead) {
@@ -565,17 +568,15 @@ ${bodyHtml}
565
568
  ${dataScriptBlock}${clientScript}</body>
566
569
  </html>`;
567
570
  return applyHtmlPlugins(docHtml, options.plugins, { sourcePath: options.sourcePath, url: options.__vesk?.url || undefined });
568
- }
569
- finally {
570
- delete globalThis.__vsk_ssr;
571
- delete globalThis[`__vsk_ssr_data_${renderToken}`];
572
- // The token lives in the AsyncLocalStorage store now (see ssr-store.ts);
573
- // it goes away with the scope. Deleting the global would destroy the
574
- // request-scoped accessor that generated render code reads.
575
- dropSsrSlot(renderToken);
576
- pruneSsrDataSlots();
577
- }
578
- });
571
+ });
572
+ }
573
+ finally {
574
+ delete globalThis.__vsk_ssr;
575
+ delete globalThis[`__vsk_ssr_data_${renderToken}`];
576
+ dropSsrSlot(renderToken);
577
+ pruneSsrDataSlots();
578
+ }
579
+ return result;
579
580
  }
580
581
  export function renderPageStream(source, componentName, props = {}, registry = new Map(), options = {}) {
581
582
  // One store for the whole stream. `next()` is driven by the consumer, so a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vesk/compiler",
3
- "version": "0.2.92",
3
+ "version": "0.2.94",
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.92",
23
+ "@vesk/types": "^0.2.94",
24
24
  "acorn": "^8.15.0",
25
25
  "esrap": "^2.3.0",
26
26
  "is-reference": "^3.0.0",