@vesk/compiler 0.2.43 → 0.2.44

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.
@@ -73,6 +73,14 @@ export declare function compileClientBoth(source: string, _componentName: string
73
73
  exported: string;
74
74
  }>;
75
75
  reexportPaths: string[];
76
+ componentNames: string[];
76
77
  };
78
+ /**
79
+ * The component names a `.vsk` file declares. Used by the bundle to spot
80
+ * same-name declarations across files, which collide on the single global
81
+ * registry. Prefer the free `componentNames` from `compileClientBoth`; this
82
+ * exists for call sites that already have the source but no IR to hand.
83
+ */
84
+ export declare function vskComponentNames(source: string, sourcePath?: string): string[];
77
85
  export { compileClient as compile, isStaticIR };
78
86
  //# sourceMappingURL=client-codegen.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client-codegen.d.ts","sourceRoot":"","sources":["../src/client-codegen.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAuB,MAAM,uBAAuB,CAAC;AACzE,OAAO,EAGL,cAAc,EAQd,gBAAgB,EASjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAkGxD,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,SAAS,GAAG,MAAM,CAAC;CAC1B;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAI1D;AAiDD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,gBAAgB,GAAG,cAAc,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,MAAM,CAgJlI;AAID,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAU5E;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,MAAM,EAAE,CAuB9F;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,MAAM,CAY5F;AAoBD,qDAAqD;AACrD,MAAM,MAAM,SAAS,GAAG;IAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtC;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,KAAK,SAAI,GAAG,SAAS,CAQtE;AAi7DD,iBAAS,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAe3C;AAmJD,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAO9C;AAuED,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,GAAE;IAAE,WAAW,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,aAAa,CAAC,EAAE,SAAS,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,MAAM,CAOpQ;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,GAAG,IAAI,EAC7B,UAAU,CAAC,EAAE,MAAM,EACnB,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,GACjD;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,OAAO,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAAC,aAAa,EAAE,MAAM,EAAE,CAAA;CAAE,CAqClI;AAED,OAAO,EAAE,aAAa,IAAI,OAAO,EAAE,UAAU,EAAE,CAAC"}
1
+ {"version":3,"file":"client-codegen.d.ts","sourceRoot":"","sources":["../src/client-codegen.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,MAAM,EAAuB,MAAM,uBAAuB,CAAC;AACzE,OAAO,EAGL,cAAc,EAQd,gBAAgB,EASjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAkGxD,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,SAAS,GAAG,MAAM,CAAC;CAC1B;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAI1D;AAiDD,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,gBAAgB,GAAG,cAAc,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,MAAM,CAgJlI;AAID,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAU5E;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,MAAM,EAAE,CAuB9F;AAED;;;;;;GAMG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,GAAG,MAAM,CAY5F;AAoBD,qDAAqD;AACrD,MAAM,MAAM,SAAS,GAAG;IAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtC;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,KAAK,SAAI,GAAG,SAAS,CAQtE;AAi7DD,iBAAS,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAe3C;AAmJD,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAO9C;AAuED,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,GAAE;IAAE,WAAW,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,eAAe,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,aAAa,CAAC,EAAE,SAAS,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,MAAM,CAOpQ;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,cAAc,EAAE,MAAM,GAAG,IAAI,EAC7B,UAAU,CAAC,EAAE,MAAM,EACnB,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,UAAU,CAAC,EAAE,OAAO,CAAA;CAAE,GACjD;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,OAAO,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAAC,aAAa,EAAE,MAAM,EAAE,CAAC;IAAC,cAAc,EAAE,MAAM,EAAE,CAAA;CAAE,CAyC5J;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAG/E;AAED,OAAO,EAAE,aAAa,IAAI,OAAO,EAAE,UAAU,EAAE,CAAC"}
@@ -2833,13 +2833,27 @@ export function compileClientBoth(source, _componentName, sourcePath, opts) {
2833
2833
  ? collectVskReexportPaths(ir.reexportSources, sourcePath)
2834
2834
  : [];
2835
2835
  if (opts?.skipHyd)
2836
- return { comp, hyd: '', name, aliases, reexportPaths };
2836
+ return { comp, hyd: '', name, aliases, reexportPaths, componentNames: ir.components.map((c) => c.name) };
2837
2837
  return {
2838
2838
  comp,
2839
2839
  hyd: emitClientFromIR(irHyd, { forceClient: true, hydrate: true, includeTopLevel: false, nameAllocator: alloc, markerless: opts?.markerless !== false }),
2840
2840
  name,
2841
2841
  aliases,
2842
2842
  reexportPaths,
2843
+ // Every component NAME this file registers, not just the resolved one —
2844
+ // the bundle needs the full list to spot same-name declarations across
2845
+ // files (see findVskComponentCollisions).
2846
+ componentNames: ir.components.map((c) => c.name),
2843
2847
  };
2844
2848
  }
2849
+ /**
2850
+ * The component names a `.vsk` file declares. Used by the bundle to spot
2851
+ * same-name declarations across files, which collide on the single global
2852
+ * registry. Prefer the free `componentNames` from `compileClientBoth`; this
2853
+ * exists for call sites that already have the source but no IR to hand.
2854
+ */
2855
+ export function vskComponentNames(source, sourcePath) {
2856
+ const ast = parse(source, sourcePath ? { filename: sourcePath } : {});
2857
+ return generateIR(ast, source, sourcePath).components.map((c) => c.name);
2858
+ }
2845
2859
  export { compileClient as compile, isStaticIR };
package/dist/errors.d.ts CHANGED
@@ -22,7 +22,20 @@ export declare class VeskError extends Error {
22
22
  code?: string;
23
23
  constructor(message: string, opts?: VeskErrorOptions);
24
24
  static notFound(name: string, candidates?: string[], context?: VeskErrorOptions): VeskError;
25
- static vskNamespaceTag(context?: VeskErrorOptions): VeskError;
25
+ /**
26
+ * A `.vsk` namespace is resolved at the JSX-tag level: `<ns.Icon />` looks
27
+ * the component up in the registry by its exported name, exactly like
28
+ * `import { Icon }`, and no module object is ever built. The two forms that
29
+ * genuinely need a real module object are reported here.
30
+ *
31
+ * - `nested` — `<ns.Sub.Icon />`. A `.vsk` module's exports are flat
32
+ * component names, so there is no object to walk into.
33
+ * - `value` — `ns.max` read as a value. Component tags resolve; plain value
34
+ * reads do not, because nothing binds `ns` at runtime.
35
+ */
36
+ static vskNamespaceMember(context?: VeskErrorOptions & {
37
+ form?: 'nested' | 'value';
38
+ }): VeskError;
26
39
  static classDecl(context?: VeskErrorOptions): VeskError;
27
40
  static serverBlockInClient(compName: string, context?: VeskErrorOptions): VeskError;
28
41
  static clientBlockInServer(compName: string, context?: VeskErrorOptions): VeskError;
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAoBxD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,EAAE,WAAW,GAAE,MAAU,GAAG,MAAM,GAAG,IAAI,CAWrG;AAqCD,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,SAAI,EAAE,KAAK,SAAI,GAAG,MAAM,CAkBrG;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAgBD,qBAAa,SAAU,SAAQ,KAAK;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;gBAEF,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,gBAAqB;IAYxD,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,GAAE,MAAM,EAAO,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IA8BnG,MAAM,CAAC,eAAe,CAAC,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAmBjE,MAAM,CAAC,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAoB3D,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAmBvF,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAmBvF,MAAM,CAAC,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,GAAE,MAAM,EAAO,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAO3G,MAAM,CAAC,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,GAAE,MAAM,EAAO,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAYvG,MAAM,CAAC,sBAAsB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAmB/G,MAAM,CAAC,cAAc,CAAC,OAAO,GAAE,gBAAgB,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,SAAS;IAmBpF,QAAQ,IAAI,MAAM;IA0BlB,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CAclC"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAoBxD;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,EAAE,WAAW,GAAE,MAAU,GAAG,MAAM,GAAG,IAAI,CAWrG;AAqCD,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,SAAI,EAAE,KAAK,SAAI,GAAG,MAAM,CAkBrG;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,EAAE,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAgBD,qBAAa,SAAU,SAAQ,KAAK;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,CAAC;gBAEF,OAAO,EAAE,MAAM,EAAE,IAAI,GAAE,gBAAqB;IAYxD,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,GAAE,MAAM,EAAO,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IA8BnG;;;;;;;;;;OAUG;IACH,MAAM,CAAC,kBAAkB,CAAC,OAAO,GAAE,gBAAgB,GAAG;QAAE,IAAI,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAA;KAAO,GAAG,SAAS;IA6BpG,MAAM,CAAC,SAAS,CAAC,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAoB3D,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAmBvF,MAAM,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAmBvF,MAAM,CAAC,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,GAAE,MAAM,EAAO,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAO3G,MAAM,CAAC,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,GAAE,MAAM,EAAO,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAYvG,MAAM,CAAC,sBAAsB,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,gBAAqB,GAAG,SAAS;IAmB/G,MAAM,CAAC,cAAc,CAAC,OAAO,GAAE,gBAAgB,GAAG;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,SAAS;IAmBpF,QAAQ,IAAI,MAAM;IA0BlB,MAAM,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;CAclC"}
package/dist/errors.js CHANGED
@@ -150,19 +150,40 @@ export class VeskError extends Error {
150
150
  : '',
151
151
  });
152
152
  }
153
- static vskNamespaceTag(context = {}) {
154
- return new VeskError('Namespace imports are not supported for `.vsk` modules.', {
153
+ /**
154
+ * A `.vsk` namespace is resolved at the JSX-tag level: `<ns.Icon />` looks
155
+ * the component up in the registry by its exported name, exactly like
156
+ * `import { Icon }`, and no module object is ever built. The two forms that
157
+ * genuinely need a real module object are reported here.
158
+ *
159
+ * - `nested` — `<ns.Sub.Icon />`. A `.vsk` module's exports are flat
160
+ * component names, so there is no object to walk into.
161
+ * - `value` — `ns.max` read as a value. Component tags resolve; plain value
162
+ * reads do not, because nothing binds `ns` at runtime.
163
+ */
164
+ static vskNamespaceMember(context = {}) {
165
+ const nested = context.form === 'nested';
166
+ return new VeskError(nested
167
+ ? 'A `.vsk` namespace is flat, so `<ns.Sub.Icon />` cannot be resolved.'
168
+ : 'A `.vsk` namespace resolves component tags only, so `ns.value` is not available.', {
155
169
  ...context,
156
170
  code: context.code || 'V0410',
157
- suggestions: [
158
- "Import the component by name: import { MyIcon } from './icons.vsk'",
159
- "Rename on import: import { MyIcon as Icon } from './icons.vsk'",
160
- ],
171
+ suggestions: nested
172
+ ? [
173
+ "Import the component by name: import { Icon } from './icons.vsk'",
174
+ "Import from the module that owns Sub: import * as Sub from './sub.vsk'",
175
+ ]
176
+ : [
177
+ "Import the value by name: import { MAX } from './constants.vsk'",
178
+ 'Move the value into a .ts module and import it from there — those are real ES modules with live bindings.',
179
+ ],
161
180
  nextSteps: [
162
- 'A `.vsk` component is a registry entry keyed by its name, not a module namespace object, so `<ns.Icon />` cannot be resolved.',
163
- 'If you need a namespace object, import from a `.ts`/`.js` module instead — those are real ES modules.',
181
+ 'A `.vsk` tag resolves by component name in the global registry, not by module exports, so `import * as ns` + `<ns.Icon />` needs no module object.',
182
+ nested
183
+ ? 'Re-export with a flat name (`export { Icon }`) or import the component directly.'
184
+ : 'Re-export the value under a flat name and import it directly, or keep the value in a .ts module.',
164
185
  ],
165
- tip: 'A `.vsk` module exposes its components as named exports; use a named or renamed import rather than `import * as ns`.',
186
+ tip: '`<ns.Icon />` works; only nested paths and value reads need a real module namespace.',
166
187
  });
167
188
  }
168
189
  static classDecl(context = {}) {
@@ -1 +1 @@
1
- {"version":3,"file":"ir-generator.d.ts","sourceRoot":"","sources":["../src/ir-generator.ts"],"names":[],"mappings":"AACA,OAAO,EACL,MAAM,EAsBP,MAAM,uBAAuB,CAAC;AA2T/B,wBAAgB,YAAY,CAAC,MAAM,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,GAAG,CAAC;IAAC,cAAc,CAAC,EAAE,GAAG,CAAC;IAAC,UAAU,CAAC,EAAE,GAAG,EAAE,CAAA;CAAE,CAAC,GAAG,SAAS,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkCnL;AA++BD,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAgS9E"}
1
+ {"version":3,"file":"ir-generator.d.ts","sourceRoot":"","sources":["../src/ir-generator.ts"],"names":[],"mappings":"AACA,OAAO,EACL,MAAM,EAsBP,MAAM,uBAAuB,CAAC;AA2T/B,wBAAgB,YAAY,CAAC,MAAM,EAAE,KAAK,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,GAAG,CAAC;IAAC,cAAc,CAAC,EAAE,GAAG,CAAC;IAAC,UAAU,CAAC,EAAE,GAAG,EAAE,CAAA;CAAE,CAAC,GAAG,SAAS,GAAG,IAAI,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkCnL;AA6nCD,wBAAgB,UAAU,CAAC,GAAG,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAgS9E"}
@@ -407,6 +407,26 @@ function jsxTagRootName(nameNode) {
407
407
  node = node.object;
408
408
  return node && node.type === 'JSXIdentifier' ? node.name : '';
409
409
  }
410
+ /** The final property of a dotted JSX tag: `NS.A.B` → `B`. */
411
+ function jsxTagMemberName(nameNode) {
412
+ let node = nameNode;
413
+ while (node && node.type === 'JSXMemberExpression') {
414
+ node = node.property;
415
+ if (node && node.type === 'JSXIdentifier')
416
+ return node.name;
417
+ }
418
+ return '';
419
+ }
420
+ /** How many member hops a dotted tag has: `NS.A` → 1, `NS.A.B` → 2. */
421
+ function jsxTagDepth(nameNode) {
422
+ let depth = 0;
423
+ let node = nameNode;
424
+ while (node && node.type === 'JSXMemberExpression') {
425
+ depth++;
426
+ node = node.object;
427
+ }
428
+ return depth;
429
+ }
410
430
  // Set for the duration of one `generateIR` call so `processJSXElement` — which
411
431
  // is reached from eleven call sites and would otherwise need a context
412
432
  // parameter threaded through all of them — can reject `<ns.Tag />` for a
@@ -414,24 +434,154 @@ function jsxTagRootName(nameNode) {
414
434
  let currentVskNamespaceLocals = null;
415
435
  let currentSourceFile;
416
436
  /**
417
- * Local names bound by `import * as ns from './x.vsk'`. Only `.vsk` targets are
418
- * collected — a namespace import from a real module (`./x.ts`, a package) is a
419
- * genuine object and `<ns.Tag />` works there.
437
+ * Local names bound by `import * as ns from './x.vsk'` that are NOT shadowed by
438
+ * any other binding in the file. A `.vsk` namespace is only special when the
439
+ * name still refers to the import: if a parameter or a local shadows it, the
440
+ * tag is an ordinary member expression on a real object and must keep the
441
+ * `calleeExpr` path. Names the file re-binds are therefore dropped here, which
442
+ * also keeps the value-position check from rejecting valid shadowed code.
420
443
  */
421
444
  function collectVskNamespaceLocals(ast) {
422
- const out = new Set();
445
+ const namespaceLocals = new Map();
423
446
  for (const node of (ast.body || [])) {
424
447
  if (node.type !== 'ImportDeclaration')
425
448
  continue;
426
449
  if (typeof node.source?.value !== 'string' || !node.source.value.endsWith('.vsk'))
427
450
  continue;
428
451
  for (const spec of node.specifiers || []) {
429
- if (spec.type === 'ImportNamespaceSpecifier' && spec.local?.name)
430
- out.add(spec.local.name);
452
+ if (spec.type === 'ImportNamespaceSpecifier' && spec.local?.name) {
453
+ namespaceLocals.set(spec.local.name, { path: node.source.value, shadowed: false });
454
+ }
431
455
  }
432
456
  }
457
+ if (namespaceLocals.size === 0)
458
+ return new Set();
459
+ markShadowedBindings(ast, namespaceLocals);
460
+ return new Set([...namespaceLocals].filter(([, v]) => !v.shadowed).map(([k]) => k));
461
+ }
462
+ /**
463
+ * Flags every namespace local that the file also binds by some other means.
464
+ * The namespace import itself is the only declaration that does NOT shadow —
465
+ * everything else (var/let/const, function and class declarations, any
466
+ * parameter list, catch bindings) means the name is a plain local from here on.
467
+ */
468
+ function markShadowedBindings(node, namespaceLocals) {
469
+ if (!node || typeof node !== 'object')
470
+ return;
471
+ if (Array.isArray(node)) {
472
+ for (const child of node)
473
+ markShadowedBindings(child, namespaceLocals);
474
+ return;
475
+ }
476
+ if (typeof node.type === 'string') {
477
+ if (node.type === 'ImportNamespaceSpecifier')
478
+ return; // the import itself
479
+ for (const name of declaredBindingNames(node)) {
480
+ const entry = namespaceLocals.get(name);
481
+ if (entry)
482
+ entry.shadowed = true;
483
+ }
484
+ }
485
+ for (const key of Object.keys(node)) {
486
+ if (key === 'loc' || key === 'range' || key === 'start' || key === 'end')
487
+ continue;
488
+ markShadowedBindings(node[key], namespaceLocals);
489
+ }
490
+ }
491
+ /** Names a single binding-bearing node introduces. */
492
+ function declaredBindingNames(node) {
493
+ const out = [];
494
+ const pushPattern = (id) => {
495
+ if (!id)
496
+ return;
497
+ if (id.type === 'Identifier') {
498
+ out.push(id.name);
499
+ return;
500
+ }
501
+ if (id.type === 'ObjectPattern') {
502
+ for (const prop of id.properties || []) {
503
+ if (prop.type === 'RestElement')
504
+ pushPattern(prop.argument);
505
+ else
506
+ pushPattern(prop.value);
507
+ }
508
+ return;
509
+ }
510
+ if (id.type === 'ArrayPattern') {
511
+ for (const el of id.elements || [])
512
+ if (el)
513
+ pushPattern(el.type === 'RestElement' ? el.argument : el);
514
+ return;
515
+ }
516
+ if (id.type === 'AssignmentPattern')
517
+ pushPattern(id.left);
518
+ if (id.type === 'RestElement')
519
+ pushPattern(id.argument);
520
+ };
521
+ switch (node.type) {
522
+ case 'VariableDeclarator':
523
+ pushPattern(node.id);
524
+ break;
525
+ case 'FunctionDeclaration':
526
+ case 'FunctionExpression':
527
+ case 'ArrowFunctionExpression':
528
+ if (node.id)
529
+ pushPattern(node.id);
530
+ for (const param of node.params || [])
531
+ pushPattern(param);
532
+ break;
533
+ case 'ClassDeclaration':
534
+ case 'ClassExpression':
535
+ if (node.id)
536
+ pushPattern(node.id);
537
+ break;
538
+ case 'CatchClause':
539
+ pushPattern(node.param);
540
+ break;
541
+ default:
542
+ break;
543
+ }
433
544
  return out;
434
545
  }
546
+ /**
547
+ * Rejects reading a member off a `.vsk` namespace in *value* position. A
548
+ * namespace is resolved only for component tags, so `ns.max` in an expression
549
+ * has nothing bound to it at runtime — say so here instead of failing later
550
+ * with a bare `ReferenceError: ns is not defined`.
551
+ */
552
+ function assertNoVskNamespaceValue(source, expr) {
553
+ if (!currentVskNamespaceLocals || currentVskNamespaceLocals.size === 0)
554
+ return;
555
+ const hit = findNamespaceValueUse(expr, currentVskNamespaceLocals);
556
+ if (!hit)
557
+ return;
558
+ const { line, column } = offsetToLineCol(source, hit.start ?? 0);
559
+ throw VeskError.vskNamespaceMember({ file: currentSourceFile, line, column, code: codeFrame(source, line, column), form: 'value' });
560
+ }
561
+ function findNamespaceValueUse(node, locals) {
562
+ if (!node || typeof node !== 'object')
563
+ return null;
564
+ if (Array.isArray(node)) {
565
+ for (const child of node) {
566
+ const hit = findNamespaceValueUse(child, locals);
567
+ if (hit)
568
+ return hit;
569
+ }
570
+ return null;
571
+ }
572
+ if (typeof node.type !== 'string')
573
+ return null;
574
+ if (node.type === 'MemberExpression' && node.object?.type === 'Identifier' && locals.has(node.object.name))
575
+ return node;
576
+ for (const key of Object.keys(node)) {
577
+ if (key === 'loc' || key === 'range' || key === 'start' || key === 'end')
578
+ continue;
579
+ const hit = findNamespaceValueUse(node[key], locals);
580
+ if (hit)
581
+ return hit;
582
+ }
583
+ return null;
584
+ }
435
585
  function isHTMLTag(name) {
436
586
  return name.length > 0 && name[0] === name[0].toLowerCase();
437
587
  }
@@ -443,6 +593,7 @@ function isMapCall(expr) {
443
593
  expr.arguments[0].type === 'ArrowFunctionExpression');
444
594
  }
445
595
  function toExpression(source, expr) {
596
+ assertNoVskNamespaceValue(source, expr);
446
597
  return new Expression(getSource(source, expr), [], expr, source);
447
598
  }
448
599
  function processAttribute(source, attr) {
@@ -920,12 +1071,24 @@ function processJSXElement(source, element) {
920
1071
  // registry lookup by dotted string.
921
1072
  if (nameNode && nameNode.type === 'JSXMemberExpression') {
922
1073
  const root = jsxTagRootName(nameNode);
923
- if (currentVskNamespaceLocals && currentVskNamespaceLocals.has(root)) {
1074
+ const isVskNamespace = !!currentVskNamespaceLocals && currentVskNamespaceLocals.has(root);
1075
+ if (isVskNamespace && jsxTagDepth(nameNode) > 1) {
1076
+ // A `.vsk` module's exports are flat component names, so there is no
1077
+ // nested namespace object to walk into.
924
1078
  const { line, column } = offsetToLineCol(source, nameNode.start ?? 0);
925
- throw VeskError.vskNamespaceTag({ file: currentSourceFile, line, column, code: codeFrame(source, line, column) });
1079
+ throw VeskError.vskNamespaceMember({ file: currentSourceFile, line, column, code: codeFrame(source, line, column), form: 'nested' });
926
1080
  }
927
1081
  const { props, spreadProps, slots } = extractProps(source, element);
928
1082
  const children = selfClosing ? [] : processJSXChildren(source, element.children || []);
1083
+ if (isVskNamespace) {
1084
+ // `<ns.Icon />` after `import * as ns from './lib.vsk'` resolves to the
1085
+ // same registry entry `import { Icon }` would, keyed by the EXPORTED name.
1086
+ // Deliberately carries no `calleeExpr`: both codegen paths must take the
1087
+ // registry branch so `<ns.Icon />` keeps the "component was not found"
1088
+ // guard and hydrates like any other compiled component. The namespace
1089
+ // object itself is never built.
1090
+ return [new ComponentCall(jsxTagMemberName(nameNode), props, [...children, ...slots.map((s) => new PropSlot(s.name, s.nodes))], spreadProps, element.start)];
1091
+ }
929
1092
  return [new ComponentCall(tagName, props, [...children, ...slots.map((s) => new PropSlot(s.name, s.nodes))], spreadProps, element.start, getSource(source, nameNode))];
930
1093
  }
931
1094
  if (!isHTMLTag(tagName) && selfClosing) {
@@ -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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"}
@@ -10,8 +10,9 @@ import { buildComponentMap } from '@vesk/compiler/src/server-jsgen';
10
10
  import { transformTopLevelForActions } from '@vesk/compiler/src/actions';
11
11
  import { collectVskImportPaths, collectVskReexportPaths, vskRegistryAliases, applyVskRegistryAliases } from '@vesk/compiler/src/vsk-imports';
12
12
  import { inlineMdImportsFrom, guessProjectRoots } from '@vesk/compiler/src/md-inline';
13
- import { withSsrStore, ssrSink } from '@vesk/compiler/src/ssr-store';
13
+ import { withSsrStore, withSsrStoreOf, adoptSsrStore, ssrSink, keepSsrSlot, dropSsrSlot, isSsrSlotLive } from '@vesk/compiler/src/ssr-store';
14
14
  import { applyLocalModuleImports } from '@vesk/compiler/src/module-imports';
15
+ import { VskComponentOwners } from '@vesk/compiler/src/vsk-collision';
15
16
  // Registry-hoisted sub components are invoked with the caller's `__vesk`,
16
17
  // but each component's scope declaration destructures its DEFINING file's
17
18
  // top-level bindings. When two `.vsk` files declare the same top-level name
@@ -22,9 +23,12 @@ function scopedVesk(fn, fallback) {
22
23
  return fn.__veskScope || fallback;
23
24
  }
24
25
  export function compileFile(source, options) {
25
- return compileFileInternal(source, options?.sourcePath, new Set());
26
+ const owners = new VskComponentOwners();
27
+ const result = compileFileInternal(source, options?.sourcePath, new Set(), owners);
28
+ owners.report('SSR');
29
+ return result;
26
30
  }
27
- function compileFileInternal(source, sourcePath, seenImportFiles) {
31
+ function compileFileInternal(source, sourcePath, seenImportFiles, owners) {
28
32
  if (sourcePath) {
29
33
  const dir = dirname(sourcePath);
30
34
  source = inlineMdImportsFrom(source, sourcePath, guessProjectRoots(dir));
@@ -33,6 +37,12 @@ function compileFileInternal(source, sourcePath, seenImportFiles) {
33
37
  const ir = generateIR(ast, source, sourcePath);
34
38
  const componentMap = buildComponentMap(ir, true);
35
39
  const ownComponentNames = ir.components.map((c) => c.name);
40
+ // Every component shares one registry, so a name declared in two files
41
+ // resolves to one of them. SSR keeps the FIRST, the client keeps the LAST —
42
+ // report it rather than letting the two sides render different components.
43
+ if (sourcePath && owners)
44
+ for (const n of ownComponentNames)
45
+ owners.claim(n, sourcePath);
36
46
  const __vesk = loadRuntimeImports(ir.imports);
37
47
  applyLocalModuleImports(__vesk, ir.imports, sourcePath);
38
48
  if (sourcePath) {
@@ -55,7 +65,7 @@ function compileFileInternal(source, sourcePath, seenImportFiles) {
55
65
  catch {
56
66
  continue;
57
67
  }
58
- const sub = compileFileInternal(importedSrc, importPath, seenImportFiles);
68
+ const sub = compileFileInternal(importedSrc, importPath, seenImportFiles, owners);
59
69
  for (const [name, fn] of sub.componentMap) {
60
70
  if (!componentMap.has(name))
61
71
  componentMap.set(name, fn);
@@ -95,32 +105,39 @@ export function render(source, componentName, props = {}, registry = new Map(),
95
105
  const targetComp = compiled.ir.components.find((c) => c.name === componentName);
96
106
  const isAsyncComp = !!(targetComp && (targetComp.isAsync || targetComp.ssrAwait));
97
107
  globalThis.__vsk_ssr = true;
98
- if (!globalThis.__vsk_ssr_token)
99
- globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
100
- const renderToken = globalThis.__vsk_ssr_token;
101
- if (isAsyncComp) {
102
- return withSsrStore(() => (async () => {
103
- let bodyHtml;
104
- try {
105
- bodyHtml = await renderFn(props, fullRegistry, scopedVesk(renderFn, __vesk));
106
- }
107
- finally {
108
- delete globalThis.__vsk_ssr;
109
- await settleSsrPromises(renderToken);
110
- clearSsrCells(renderToken);
111
- }
112
- return bodyHtml;
113
- })());
114
- }
115
- let bodyHtml;
116
- try {
117
- bodyHtml = withSsrStore(() => renderFn(props, fullRegistry, scopedVesk(renderFn, __vesk)));
118
- }
119
- finally {
120
- delete globalThis.__vsk_ssr;
121
- clearSsrCells(renderToken);
122
- }
123
- return bodyHtml;
108
+ // Resolve the token INSIDE the render scope. The global is an AsyncLocalStorage
109
+ // accessor (see ssr-store.ts), so outside a scope it reads undefined and any
110
+ // assignment is dropped — reading it first would leave renderToken undefined
111
+ // and silently skip the settle/cleanup below.
112
+ return withSsrStore(() => {
113
+ if (!globalThis.__vsk_ssr_token)
114
+ globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
115
+ const renderToken = globalThis.__vsk_ssr_token;
116
+ keepSsrSlot(renderToken);
117
+ if (isAsyncComp) {
118
+ return (async () => {
119
+ let bodyHtml;
120
+ try {
121
+ bodyHtml = await renderFn(props, fullRegistry, scopedVesk(renderFn, __vesk));
122
+ }
123
+ finally {
124
+ delete globalThis.__vsk_ssr;
125
+ await settleSsrPromises(renderToken);
126
+ clearSsrCells(renderToken);
127
+ }
128
+ return bodyHtml;
129
+ })();
130
+ }
131
+ let bodyHtml;
132
+ try {
133
+ bodyHtml = renderFn(props, fullRegistry, scopedVesk(renderFn, __vesk));
134
+ }
135
+ finally {
136
+ delete globalThis.__vsk_ssr;
137
+ clearSsrCells(renderToken);
138
+ }
139
+ return bodyHtml;
140
+ });
124
141
  }
125
142
  export function renderPage(source, componentName, props = {}, registry = new Map(), options = {}) {
126
143
  resetVskState(!!options.hydrate, options.markerless !== false);
@@ -151,9 +168,10 @@ export function renderPage(source, componentName, props = {}, registry = new Map
151
168
  if (!globalThis.__vsk_ssr_token)
152
169
  globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
153
170
  const renderToken = globalThis.__vsk_ssr_token;
171
+ keepSsrSlot(renderToken);
154
172
  pruneSsrDataSlots();
155
173
  if (targetComp && (targetComp.isAsync || targetComp.ssrAwait)) {
156
- return withSsrStore(() => (async () => {
174
+ return (async () => {
157
175
  let bodyHtml;
158
176
  try {
159
177
  bodyHtml = await renderFn(ssrProps, fullRegistry, scopedVesk(renderFn, __vesk));
@@ -172,11 +190,11 @@ export function renderPage(source, componentName, props = {}, registry = new Map
172
190
  head: renderHeadHtml(targetComp, ssrProps),
173
191
  props: ssrProps
174
192
  };
175
- })());
193
+ })();
176
194
  }
177
195
  let bodyHtml;
178
196
  try {
179
- bodyHtml = withSsrStore(() => renderFn(ssrProps, fullRegistry, scopedVesk(renderFn, __vesk)));
197
+ bodyHtml = renderFn(ssrProps, fullRegistry, scopedVesk(renderFn, __vesk));
180
198
  }
181
199
  finally {
182
200
  delete globalThis.__vsk_ssr;
@@ -186,11 +204,11 @@ export function renderPage(source, componentName, props = {}, registry = new Map
186
204
  if (pending && pending.length > 0) {
187
205
  // A sync component still started server resources (useFetch): settle them
188
206
  // before returning so the handoff serializes real data, not loading state.
189
- return withSsrStore(() => (async () => {
207
+ return (async () => {
190
208
  await settleSsrPromises(renderToken);
191
209
  clearSsrCells(renderToken);
192
210
  return { body: bodyHtml, head: headHtml, props: ssrProps };
193
- })());
211
+ })();
194
212
  }
195
213
  clearSsrCells(renderToken);
196
214
  return { body: bodyHtml, head: headHtml, props: ssrProps };
@@ -217,6 +235,10 @@ function clearSsrCells(token) {
217
235
  return;
218
236
  delete globalThis[`__vsk_ssr_promises_${token}`];
219
237
  delete globalThis[`__vsk_ssr_failures_${token}`];
238
+ // This render is done with the token: release the slot so the reaper below
239
+ // can collect paths that never run renderFullPage (a bare renderPage/render
240
+ // leaves its data slot behind for the full-page merge).
241
+ dropSsrSlot(token);
220
242
  const cells = globalThis.__vsk_ssr_cells;
221
243
  if (!cells || !(cells instanceof Map))
222
244
  return;
@@ -233,24 +255,50 @@ function clearSsrCells(token) {
233
255
  async function settleSsrPromises(token) {
234
256
  if (!token)
235
257
  return;
236
- const pending = globalThis[`__vsk_ssr_promises_${token}`];
237
- if (!pending || pending.length === 0)
238
- return;
239
- await Promise.allSettled(pending);
258
+ const key = `__vsk_ssr_promises_${token}`;
259
+ // Drain until the queue stops growing. The runtime only ever pushes onto
260
+ // this array, and settling one promise can register another: an async
261
+ // component body re-runs after its previous await and starts the next
262
+ // fetch. A single allSettled over a snapshot returns before that lands, so
263
+ // the render serializes an empty data slot and ships without its ssr-data
264
+ // script — the client then refetches what SSR already had.
265
+ for (let round = 0; round < 10; round++) {
266
+ const pending = globalThis[key];
267
+ if (!pending || pending.length === 0)
268
+ return;
269
+ const before = pending.length;
270
+ await Promise.allSettled(pending.slice());
271
+ const after = globalThis[key];
272
+ if (!after || after.length <= before)
273
+ return;
274
+ }
240
275
  }
241
276
  /**
242
277
  * Bound the per-token SSR data slots against abandonment (dev-partial
243
278
  * renderPage / render() that never runs renderFullPage). Slots are keyed by
244
- * Math.random tokens, so deleted-slot accounting by token can't be cleaned by
279
+ * per-request tokens, so deleted-slot accounting by token can't be cleaned by
245
280
  * an owning render; cap the map instead. RenderFullPage/RenderPageStream delete
246
281
  * their own slot + token in every path, so this only guards the untracked ones.
282
+ *
283
+ * Live renders are exempt. Slots are only reapable once no render owns them
284
+ * (see isSsrSlotLive) — a cap that ignored ownership would delete a
285
+ * *concurrent* request's handoff, which is the empty-ssr-data bug again, just
286
+ * triggered by load rather than by interleaving.
247
287
  */
248
288
  function pruneSsrDataSlots() {
249
289
  const g = globalThis;
250
- const keys = Object.keys(g).filter((k) => typeof k === 'string' && k.startsWith('__vsk_ssr_data_'));
251
- if (keys.length <= 40)
290
+ const prefix = '__vsk_ssr_data_';
291
+ const stale = [];
292
+ for (const k of Object.keys(g)) {
293
+ if (typeof k !== 'string' || !k.startsWith(prefix))
294
+ continue;
295
+ if (isSsrSlotLive(k.slice(prefix.length)))
296
+ continue;
297
+ stale.push(k);
298
+ }
299
+ if (stale.length <= 40)
252
300
  return;
253
- keys.sort().slice(0, keys.length - 40).forEach((k) => {
301
+ stale.sort().slice(0, stale.length - 40).forEach((k) => {
254
302
  delete g[k];
255
303
  });
256
304
  }
@@ -411,6 +459,7 @@ export async function renderFullPage(source, componentName, props = {}, registry
411
459
  if (!globalThis.__vsk_ssr_token)
412
460
  globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
413
461
  const renderToken = globalThis.__vsk_ssr_token;
462
+ keepSsrSlot(renderToken);
414
463
  pruneSsrDataSlots();
415
464
  // Start this render's handoff store fresh: setSsrData mirrors into
416
465
  // globalThis.__vsk_ssr_data (see resource.ts) because the AsyncLocalStorage
@@ -505,12 +554,24 @@ ${dataScriptBlock}${clientScript}</body>
505
554
  finally {
506
555
  delete globalThis.__vsk_ssr;
507
556
  delete globalThis[`__vsk_ssr_data_${renderToken}`];
508
- delete globalThis.__vsk_ssr_token;
557
+ // The token lives in the AsyncLocalStorage store now (see ssr-store.ts);
558
+ // it goes away with the scope. Deleting the global would destroy the
559
+ // request-scoped accessor that generated render code reads.
560
+ dropSsrSlot(renderToken);
509
561
  pruneSsrDataSlots();
510
562
  }
511
563
  });
512
564
  }
513
565
  export function renderPageStream(source, componentName, props = {}, registry = new Map(), options = {}) {
566
+ // One store for the whole stream. `next()` is driven by the consumer, so a
567
+ // per-`next()` scope would mint a new token every chunk, orphan the slots
568
+ // written by earlier chunks, and leave the tail merge reading an untouched
569
+ // slot (the page then streams with no ssr-data script). Adopting the ambient
570
+ // store keeps the chunks, the promise tracker and that merge on the request's
571
+ // single token; every pull below runs through `withSsrStoreOf(streamStore)`.
572
+ const streamStore = adoptSsrStore();
573
+ keepSsrSlot(streamStore.token);
574
+ const streamToken = streamStore.token;
514
575
  async function* raw() {
515
576
  const cached = options.cached || undefined;
516
577
  const ir = cached ? cached.ir : generateIR(parse(source, options.sourcePath ? { filename: options.sourcePath } : {}), source, options.sourcePath);
@@ -584,9 +645,7 @@ export function renderPageStream(source, componentName, props = {}, registry = n
584
645
  globalThis.__vsk_ssr = true;
585
646
  // Fresh handoff store per render; see renderFullPage note.
586
647
  globalThis.__vsk_ssr_data = {};
587
- if (!globalThis.__vsk_ssr_token)
588
- globalThis.__vsk_ssr_token = Math.random().toString(36).slice(2);
589
- const renderToken = globalThis.__vsk_ssr_token;
648
+ const renderToken = streamToken;
590
649
  pruneSsrDataSlots();
591
650
  let bodyHtml;
592
651
  try {
@@ -607,7 +666,7 @@ export function renderPageStream(source, componentName, props = {}, registry = n
607
666
  console.error(`[render-trace] renderPageStream comp=${componentName} keys=${Object.keys(ssrData).join(',') || '∅'} slot=${Object.keys(slot).join(',') || '∅'} sink=${Object.keys(ssrSink.snapshot()).join(',') || '∅'}`);
608
667
  }
609
668
  delete globalThis[`__vsk_ssr_data_${renderToken}`];
610
- delete globalThis.__vsk_ssr_token;
669
+ dropSsrSlot(renderToken);
611
670
  pruneSsrDataSlots();
612
671
  const dataScripts = buildDataScripts(ssrProps, ssrData, options.externalDataScript);
613
672
  const dataScriptBlock = dataScripts.length > 0 ? '\n' + dataScripts.join('\n') : '';
@@ -619,13 +678,29 @@ export function renderPageStream(source, componentName, props = {}, registry = n
619
678
  yield `\n</div>${dataScriptBlock}${clientScript}</body>\n</html>\n`;
620
679
  }
621
680
  const gen = raw();
681
+ // Drive every pull in the stream's own store. `gen.next()` is called by the
682
+ // consumer, so it runs in the consumer's context — without this the render
683
+ // would see whatever token the caller's request happens to hold (or none),
684
+ // while the merge above reads `streamToken`.
622
685
  async function* scoped() {
623
- let result;
624
- do {
625
- result = (await withSsrStore(() => gen.next()));
626
- if (!result.done)
627
- yield result.value;
628
- } while (!result.done);
686
+ try {
687
+ let result;
688
+ do {
689
+ result = (await withSsrStoreOf(streamStore, () => gen.next()));
690
+ if (!result.done)
691
+ yield result.value;
692
+ } while (!result.done);
693
+ }
694
+ finally {
695
+ // Release the claim `raw()`'s own clearSsrCells already balanced, then
696
+ // sweep anything the stream left behind.
697
+ dropSsrSlot(streamToken);
698
+ delete globalThis[`__vsk_ssr_data_${streamToken}`];
699
+ delete globalThis[`__vsk_ssr_promises_${streamToken}`];
700
+ delete globalThis[`__vsk_ssr_failures_${streamToken}`];
701
+ delete globalThis.__vsk_ssr_data;
702
+ pruneSsrDataSlots();
703
+ }
629
704
  }
630
705
  return scoped();
631
706
  }
@@ -1,4 +1,64 @@
1
1
  import type { SsrDataSink } from '@vesk/runtime/src/resource';
2
+ export interface SsrStore {
3
+ /** Per-request token. Keyed data slots hang off this, so it must be unique
4
+ * per concurrent render — a shared token lets a finishing render delete a
5
+ * stranger's data slot. */
6
+ token?: string;
7
+ /** Handoff data for the current request. */
8
+ data: Record<string, unknown>;
9
+ }
10
+ /**
11
+ * A store that is not yet attached to a scope. Callers that drive several
12
+ * scopes (streaming renders) own it and hand it to `withSsrStoreOf` so the
13
+ * token and sink survive across the boundary.
14
+ */
15
+ export declare function createSsrStore(): SsrStore;
16
+ /**
17
+ * The store for the current request: the ambient one when a scope is already
18
+ * open, otherwise a fresh caller-owned one. Callers that outlive a single
19
+ * `withSsrStore` (streaming renders drive chunks from the consumer, long after
20
+ * the scope's promise resolved) use this so their work still lands on the
21
+ * request's token instead of minting a second one.
22
+ */
23
+ export declare function adoptSsrStore(): SsrStore;
24
+ /**
25
+ * Run `fn` in a request scope, reusing the ambient store when one is open.
26
+ *
27
+ * The scope owns a claim on its token for its whole lifetime, which is what
28
+ * keeps a request's data slot reap-exempt between its stages. A request runs
29
+ * `renderPage` (page) -> `renderPage` (each nested layout) -> `renderFullPage`
30
+ * (document), and each of those claims and releases the slot on entry/exit.
31
+ * Without the scope-level claim the token is briefly unowned between stages, so
32
+ * with more than ~40 renders in flight a concurrent reaper sees the slot as
33
+ * abandoned and deletes the data the document render still has to serialize.
34
+ */
2
35
  export declare function withSsrStore<T>(fn: () => T): T;
36
+ /**
37
+ * Run `fn` in a scope backed by a caller-owned store, so the token and the sink
38
+ * survive across `yield`s. Streaming renders need this: each `next()` is driven
39
+ * by the consumer, not by the generator, so a per-`next()` `withSsrStore` would
40
+ * mint a new token every chunk and every data slot written by an earlier chunk
41
+ * would be orphaned.
42
+ */
43
+ export declare function withSsrStoreOf<T>(store: SsrStore, fn: () => T): T;
44
+ /** The current request's token, or undefined outside a render scope. */
45
+ export declare function currentSsrToken(): string | undefined;
46
+ /**
47
+ * Mint a fresh token for the current scope. Used by the dev servers' data-nav
48
+ * path, which renders several routes within one request and must not let an
49
+ * earlier route's slot settle into a later one.
50
+ */
51
+ export declare function resetSsrToken(): string;
52
+ /** Claim a token's data slot so the reaper never deletes it mid-render. */
53
+ export declare function keepSsrSlot(token: string | undefined): void;
54
+ /** Release one claim on a token's data slot. */
55
+ export declare function dropSsrSlot(token: string | undefined): void;
56
+ /**
57
+ * True while a render still owns this token's slot. Slots are keyed by
58
+ * per-request tokens, so a cap-based reaper that ignores ownership deletes a
59
+ * *concurrent* request's handoff and the second page ships with no ssr-data
60
+ * script — the original bug, just triggered by load instead of interleaving.
61
+ */
62
+ export declare function isSsrSlotLive(token: string): boolean;
3
63
  export declare const ssrSink: SsrDataSink;
4
64
  //# sourceMappingURL=ssr-store.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ssr-store.d.ts","sourceRoot":"","sources":["../src/ssr-store.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAI9D,wBAAgB,YAAY,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAI9C;AAED,eAAO,MAAM,OAAO,EAAE,WAgBrB,CAAC"}
1
+ {"version":3,"file":"ssr-store.d.ts","sourceRoot":"","sources":["../src/ssr-store.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAI9D,MAAM,WAAW,QAAQ;IACvB;;gCAE4B;IAC5B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4CAA4C;IAC5C,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAkBD;;;;GAIG;AACH,wBAAgB,cAAc,IAAI,QAAQ,CAEzC;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,IAAI,QAAQ,CAExC;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAuB9C;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAEjE;AAED,wEAAwE;AACxE,wBAAgB,eAAe,IAAI,MAAM,GAAG,SAAS,CAEpD;AAED;;;;GAIG;AACH,wBAAgB,aAAa,IAAI,MAAM,CAYtC;AAED,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAG3D;AAED,gDAAgD;AAChD,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAM3D;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED,eAAO,MAAM,OAAO,EAAE,WAgBrB,CAAC"}
package/dist/ssr-store.js CHANGED
@@ -3,30 +3,181 @@ import { AsyncLocalStorage } from 'node:async_hooks';
3
3
  // checkout the compiler typechecks BEFORE @vesk/runtime's dist exists, and
4
4
  // resolving types through the barrel then fails.
5
5
  import { setSsrSink } from '@vesk/runtime/src/index-server';
6
+ const TOKEN_KEY = '__vsk_ssr_token';
6
7
  const storage = new AsyncLocalStorage();
8
+ /**
9
+ * Tokens whose `__vsk_ssr_data_<token>` slot a live render still owns, with a
10
+ * refcount. A request runs `renderPage` (page) -> `renderPage` (each nested
11
+ * layout) -> `renderFullPage` (document) inside ONE store, and each of those
12
+ * claims the slot on entry and releases it on exit. A plain Set would let the
13
+ * first inner release revoke the claim the document render still depends on,
14
+ * so the reaper could reap a live request's handoff. Counts balance instead.
15
+ */
16
+ const liveSlots = new Map();
17
+ function newSsrToken() {
18
+ return Math.random().toString(36).slice(2);
19
+ }
20
+ /**
21
+ * A store that is not yet attached to a scope. Callers that drive several
22
+ * scopes (streaming renders) own it and hand it to `withSsrStoreOf` so the
23
+ * token and sink survive across the boundary.
24
+ */
25
+ export function createSsrStore() {
26
+ return { token: newSsrToken(), data: {} };
27
+ }
28
+ /**
29
+ * The store for the current request: the ambient one when a scope is already
30
+ * open, otherwise a fresh caller-owned one. Callers that outlive a single
31
+ * `withSsrStore` (streaming renders drive chunks from the consumer, long after
32
+ * the scope's promise resolved) use this so their work still lands on the
33
+ * request's token instead of minting a second one.
34
+ */
35
+ export function adoptSsrStore() {
36
+ return storage.getStore() ?? createSsrStore();
37
+ }
38
+ function isThenable(value) {
39
+ return typeof value?.then === 'function';
40
+ }
41
+ /**
42
+ * Run `fn` in a request scope, reusing the ambient store when one is open.
43
+ *
44
+ * The scope owns a claim on its token for its whole lifetime, which is what
45
+ * keeps a request's data slot reap-exempt between its stages. A request runs
46
+ * `renderPage` (page) -> `renderPage` (each nested layout) -> `renderFullPage`
47
+ * (document), and each of those claims and releases the slot on entry/exit.
48
+ * Without the scope-level claim the token is briefly unowned between stages, so
49
+ * with more than ~40 renders in flight a concurrent reaper sees the slot as
50
+ * abandoned and deletes the data the document render still has to serialize.
51
+ */
7
52
  export function withSsrStore(fn) {
8
53
  const existing = storage.getStore();
9
54
  if (existing)
10
55
  return fn();
11
- return storage.run({}, fn);
56
+ const store = createSsrStore();
57
+ keepSsrSlot(store.token);
58
+ let result;
59
+ try {
60
+ result = storage.run(store, fn);
61
+ }
62
+ catch (err) {
63
+ dropSsrSlot(store.token);
64
+ throw err;
65
+ }
66
+ // A streaming render hands back a Response while its body is still pending;
67
+ // releasing on promise settlement is exactly right for both cases, because a
68
+ // render that outlives the scope claims the token itself (renderPageStream).
69
+ if (isThenable(result)) {
70
+ return result.then((value) => { dropSsrSlot(store.token); return value; }, (err) => { dropSsrSlot(store.token); throw err; });
71
+ }
72
+ dropSsrSlot(store.token);
73
+ return result;
74
+ }
75
+ /**
76
+ * Run `fn` in a scope backed by a caller-owned store, so the token and the sink
77
+ * survive across `yield`s. Streaming renders need this: each `next()` is driven
78
+ * by the consumer, not by the generator, so a per-`next()` `withSsrStore` would
79
+ * mint a new token every chunk and every data slot written by an earlier chunk
80
+ * would be orphaned.
81
+ */
82
+ export function withSsrStoreOf(store, fn) {
83
+ return storage.run(store, fn);
84
+ }
85
+ /** The current request's token, or undefined outside a render scope. */
86
+ export function currentSsrToken() {
87
+ return storage.getStore()?.token;
88
+ }
89
+ /**
90
+ * Mint a fresh token for the current scope. Used by the dev servers' data-nav
91
+ * path, which renders several routes within one request and must not let an
92
+ * earlier route's slot settle into a later one.
93
+ */
94
+ export function resetSsrToken() {
95
+ const store = storage.getStore();
96
+ const token = newSsrToken();
97
+ if (store) {
98
+ if (store.token)
99
+ liveSlots.delete(store.token);
100
+ store.token = token;
101
+ }
102
+ // The caller owns the request from here, so hold the claim for the whole
103
+ // scope: nested page/layout renders each add and remove their own count, and
104
+ // this one keeps the slot reap-exempt until the caller is done with it.
105
+ liveSlots.set(token, 1);
106
+ return token;
107
+ }
108
+ /** Claim a token's data slot so the reaper never deletes it mid-render. */
109
+ export function keepSsrSlot(token) {
110
+ if (!token)
111
+ return;
112
+ liveSlots.set(token, (liveSlots.get(token) ?? 0) + 1);
113
+ }
114
+ /** Release one claim on a token's data slot. */
115
+ export function dropSsrSlot(token) {
116
+ if (!token)
117
+ return;
118
+ const count = liveSlots.get(token);
119
+ if (count === undefined)
120
+ return;
121
+ if (count <= 1)
122
+ liveSlots.delete(token);
123
+ else
124
+ liveSlots.set(token, count - 1);
125
+ }
126
+ /**
127
+ * True while a render still owns this token's slot. Slots are keyed by
128
+ * per-request tokens, so a cap-based reaper that ignores ownership deletes a
129
+ * *concurrent* request's handoff and the second page ships with no ssr-data
130
+ * script — the original bug, just triggered by load instead of interleaving.
131
+ */
132
+ export function isSsrSlotLive(token) {
133
+ return liveSlots.has(token);
12
134
  }
13
135
  export const ssrSink = {
14
136
  set: (key, value) => {
15
137
  const store = storage.getStore();
16
138
  if (store)
17
- store[key] = value;
139
+ store.data[key] = value;
18
140
  },
19
141
  get: (key) => {
20
142
  const store = storage.getStore();
21
- return store ? store[key] : undefined;
143
+ return store ? store.data[key] : undefined;
22
144
  },
23
- snapshot: () => ({ ...(storage.getStore() ?? {}) }),
145
+ snapshot: () => ({ ...(storage.getStore()?.data ?? {}) }),
24
146
  clear: () => {
25
147
  const store = storage.getStore();
26
148
  if (store) {
27
- for (const key of Object.keys(store))
28
- delete store[key];
149
+ for (const key of Object.keys(store.data))
150
+ delete store.data[key];
29
151
  }
30
152
  },
31
153
  };
32
154
  setSsrSink(ssrSink);
155
+ // The generated server code reads `globalThis.__vsk_ssr_token` synchronously
156
+ // at the top of every component render, and the runtime's promise tracker and
157
+ // data writers read it the same way. Back that global with the ALS store so
158
+ // those readers become request-scoped instead of process-global. Before this,
159
+ // two concurrent renders adopted the same token and whichever finished first
160
+ // deleted the other's data slot, so the second page shipped with an empty
161
+ // handoff and the client refetched what SSR already had.
162
+ //
163
+ // Outside a render scope the accessor reads/writes nothing, which is what
164
+ // keeps a stray write from leaking into the next request. Bundles that never
165
+ // load this module keep the plain global and the previous behaviour.
166
+ //
167
+ // NEVER `delete globalThis.__vsk_ssr_token`. This is a configurable accessor,
168
+ // so `delete` removes the property definition outright — it does not route
169
+ // through the setter — and the process silently falls back to a
170
+ // process-global token, reintroducing the race for every later request. Clear
171
+ // a token by assigning through `globalThis` inside a scope, or with
172
+ // `resetSsrToken`.
173
+ Object.defineProperty(globalThis, TOKEN_KEY, {
174
+ configurable: true,
175
+ get() {
176
+ return storage.getStore()?.token;
177
+ },
178
+ set(value) {
179
+ const store = storage.getStore();
180
+ if (store)
181
+ store.token = value;
182
+ },
183
+ });
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Same-name component declarations across `.vsk` files.
3
+ *
4
+ * `.vsk` components are not module-local bindings — every component a bundle
5
+ * compiles is registered in ONE global registry under its declared name
6
+ * (`__components["Helper"]`, `__registry.get("Helper")`). Two files that each
7
+ * declare `component Helper` therefore land on the same key, and which one wins
8
+ * depends on the order the bundle graph happened to walk. There is no type
9
+ * error and no runtime error: the page just renders somebody else's component.
10
+ *
11
+ * That is the failure mode worth reporting. It is *not* the same as importing a
12
+ * non-exported component: making `export` load-bearing would not prevent a
13
+ * collision, because two files can both `export component Helper` and still
14
+ * overwrite one key. So this check looks only at declaration names, never at
15
+ * the registry (the registry holds no ownership metadata and cannot say which
16
+ * file a symbol came from).
17
+ *
18
+ * Deliberately a WARNING, not an error. The loose name-keyed registry is
19
+ * long-standing and load-bearing, and a hard error here would break builds that
20
+ * render correctly today.
21
+ */
22
+ export interface VskComponentCollision {
23
+ name: string;
24
+ files: string[];
25
+ }
26
+ export declare class VskComponentOwners {
27
+ private owners;
28
+ /** Records that `file` declares a component called `name`. */
29
+ claim(name: string, file: string): void;
30
+ /** Names declared by more than one file, each with its declaring files. */
31
+ collisions(): VskComponentCollision[];
32
+ /**
33
+ * Warns once per colliding name. Returns the message per collision so a
34
+ * caller (or a test) can assert on the text without capturing stderr.
35
+ *
36
+ * Deduped by name for the life of the process: the SSR path compiles one
37
+ * file graph per `compileFile` call, so without this a single build would
38
+ * repeat the same warning once per entry page.
39
+ */
40
+ report(context: string): string[];
41
+ }
42
+ /** Test hook: forget which collisions have been reported. */
43
+ export declare function resetVskCollisionReports(): void;
44
+ //# sourceMappingURL=vsk-collision.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vsk-collision.d.ts","sourceRoot":"","sources":["../src/vsk-collision.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,MAAM,CAAkC;IAEhD,8DAA8D;IAC9D,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IASvC,2EAA2E;IAC3E,UAAU,IAAI,qBAAqB,EAAE;IAQrC;;;;;;;OAOG;IACH,MAAM,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,EAAE;CAelC;AAQD,6DAA6D;AAC7D,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C"}
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Same-name component declarations across `.vsk` files.
3
+ *
4
+ * `.vsk` components are not module-local bindings — every component a bundle
5
+ * compiles is registered in ONE global registry under its declared name
6
+ * (`__components["Helper"]`, `__registry.get("Helper")`). Two files that each
7
+ * declare `component Helper` therefore land on the same key, and which one wins
8
+ * depends on the order the bundle graph happened to walk. There is no type
9
+ * error and no runtime error: the page just renders somebody else's component.
10
+ *
11
+ * That is the failure mode worth reporting. It is *not* the same as importing a
12
+ * non-exported component: making `export` load-bearing would not prevent a
13
+ * collision, because two files can both `export component Helper` and still
14
+ * overwrite one key. So this check looks only at declaration names, never at
15
+ * the registry (the registry holds no ownership metadata and cannot say which
16
+ * file a symbol came from).
17
+ *
18
+ * Deliberately a WARNING, not an error. The loose name-keyed registry is
19
+ * long-standing and load-bearing, and a hard error here would break builds that
20
+ * render correctly today.
21
+ */
22
+ export class VskComponentOwners {
23
+ owners = new Map();
24
+ /** Records that `file` declares a component called `name`. */
25
+ claim(name, file) {
26
+ let set = this.owners.get(name);
27
+ if (!set) {
28
+ set = new Set();
29
+ this.owners.set(name, set);
30
+ }
31
+ set.add(file);
32
+ }
33
+ /** Names declared by more than one file, each with its declaring files. */
34
+ collisions() {
35
+ const out = [];
36
+ for (const [name, files] of this.owners) {
37
+ if (files.size > 1)
38
+ out.push({ name, files: [...files] });
39
+ }
40
+ return out.sort((a, b) => a.name.localeCompare(b.name));
41
+ }
42
+ /**
43
+ * Warns once per colliding name. Returns the message per collision so a
44
+ * caller (or a test) can assert on the text without capturing stderr.
45
+ *
46
+ * Deduped by name for the life of the process: the SSR path compiles one
47
+ * file graph per `compileFile` call, so without this a single build would
48
+ * repeat the same warning once per entry page.
49
+ */
50
+ report(context) {
51
+ const collisions = this.collisions();
52
+ const fresh = [];
53
+ for (const { name, files } of collisions) {
54
+ if (reportedCollisions.has(name))
55
+ continue;
56
+ reportedCollisions.add(name);
57
+ fresh.push(`${name}: ${files.join(', ')}`);
58
+ console.warn(`[vesk] ${context}: component "${name}" is declared in ${files.length} files and resolves to only one of them — `
59
+ + `${files.join(', ')}. Which one wins depends on compile order, and the client and server registries pick differently, `
60
+ + `so this can render a different component in the browser than in the SSR HTML. Rename one of them.`);
61
+ }
62
+ return fresh;
63
+ }
64
+ }
65
+ /**
66
+ * Names already warned about. The client bundle and the SSR path each compile
67
+ * overlapping file graphs, so a per-instance set would double-report.
68
+ */
69
+ const reportedCollisions = new Set();
70
+ /** Test hook: forget which collisions have been reported. */
71
+ export function resetVskCollisionReports() {
72
+ reportedCollisions.clear();
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vesk/compiler",
3
- "version": "0.2.43",
3
+ "version": "0.2.44",
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.43",
23
+ "@vesk/types": "^0.2.44",
24
24
  "acorn": "^8.15.0",
25
25
  "esrap": "^2.3.0",
26
26
  "is-reference": "^3.0.0",