@fluixi/compiler 1.0.0-alpha.68 → 1.0.0-alpha.70

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.
Files changed (87) hide show
  1. package/dist/babel-NY6FXXLW.mjs +2 -0
  2. package/dist/codegen/backend.cjs +1 -1
  3. package/dist/codegen/backend.d.ts +14 -0
  4. package/dist/codegen/backend.d.ts.map +1 -1
  5. package/dist/codegen/backends/imperative.cjs +1 -1
  6. package/dist/codegen/backends/imperative.d.ts +10 -1
  7. package/dist/codegen/backends/imperative.d.ts.map +1 -1
  8. package/dist/codegen/backends/imperative.js +127 -16
  9. package/dist/codegen/backends/imperative.mjs +1 -1
  10. package/dist/codegen/partial-template.cjs +1 -0
  11. package/dist/codegen/partial-template.d.ts +62 -0
  12. package/dist/codegen/partial-template.d.ts.map +1 -0
  13. package/dist/codegen/partial-template.js +0 -0
  14. package/dist/codegen/partial-template.mjs +1 -0
  15. package/dist/codegen/serialize-static.cjs +1 -0
  16. package/dist/codegen/serialize-static.d.ts +23 -0
  17. package/dist/codegen/serialize-static.d.ts.map +1 -0
  18. package/dist/codegen/serialize-static.js +140 -0
  19. package/dist/codegen/serialize-static.mjs +1 -0
  20. package/dist/frontend/babel/build-ir-lit.cjs +1 -1
  21. package/dist/frontend/babel/build-ir-lit.d.ts.map +1 -1
  22. package/dist/frontend/babel/build-ir-lit.js +5 -1
  23. package/dist/frontend/babel/build-ir-lit.mjs +1 -1
  24. package/dist/frontend/babel/build-ir.cjs +1 -1
  25. package/dist/frontend/babel/build-ir.d.ts.map +1 -1
  26. package/dist/frontend/babel/build-ir.js +36 -8
  27. package/dist/frontend/babel/build-ir.mjs +1 -1
  28. package/dist/frontend/babel/index.cjs +2 -2
  29. package/dist/frontend/babel/index.mjs +2 -2
  30. package/dist/frontend/babel/lower-template.cjs +1 -1
  31. package/dist/frontend/babel/lower-template.d.ts.map +1 -1
  32. package/dist/frontend/babel/lower-template.js +34 -5
  33. package/dist/frontend/babel/lower-template.mjs +1 -1
  34. package/dist/frontend/babel/plugin.cjs +2 -2
  35. package/dist/frontend/babel/plugin.d.ts +14 -0
  36. package/dist/frontend/babel/plugin.d.ts.map +1 -1
  37. package/dist/frontend/babel/plugin.js +291 -34
  38. package/dist/frontend/babel/plugin.mjs +2 -2
  39. package/dist/index.cjs +1 -1
  40. package/dist/index.d.ts +3 -0
  41. package/dist/index.d.ts.map +1 -1
  42. package/dist/index.js +5 -0
  43. package/dist/index.mjs +1 -1
  44. package/dist/integrations.cjs +8 -8
  45. package/dist/integrations.d.ts +13 -0
  46. package/dist/integrations.d.ts.map +1 -1
  47. package/dist/integrations.js +9 -0
  48. package/dist/integrations.mjs +3 -3
  49. package/dist/ir/nodes.cjs +1 -1
  50. package/dist/ir/nodes.d.ts +43 -0
  51. package/dist/ir/nodes.d.ts.map +1 -1
  52. package/dist/optimize/analyze-static.cjs +1 -0
  53. package/dist/optimize/analyze-static.d.ts +40 -0
  54. package/dist/optimize/analyze-static.d.ts.map +1 -0
  55. package/dist/optimize/analyze-static.js +131 -0
  56. package/dist/optimize/analyze-static.mjs +1 -0
  57. package/dist/optimize/collapse-strategies.cjs +1 -0
  58. package/dist/optimize/collapse-strategies.d.ts +31 -0
  59. package/dist/optimize/collapse-strategies.d.ts.map +1 -0
  60. package/dist/optimize/collapse-strategies.js +33 -0
  61. package/dist/optimize/collapse-strategies.mjs +1 -0
  62. package/dist/options.cjs +1 -1
  63. package/dist/options.d.ts +24 -0
  64. package/dist/options.d.ts.map +1 -1
  65. package/dist/resolve/builtins.cjs +1 -0
  66. package/dist/resolve/builtins.d.ts +69 -0
  67. package/dist/resolve/builtins.d.ts.map +1 -0
  68. package/dist/resolve/builtins.js +64 -0
  69. package/dist/resolve/builtins.mjs +1 -0
  70. package/dist/resolve/component-resolver.cjs +1 -0
  71. package/dist/resolve/component-resolver.d.ts +69 -0
  72. package/dist/resolve/component-resolver.d.ts.map +1 -0
  73. package/dist/resolve/component-resolver.js +95 -0
  74. package/dist/resolve/component-resolver.mjs +1 -0
  75. package/dist/resolve/load-directive.cjs +1 -0
  76. package/dist/resolve/load-directive.d.ts +54 -0
  77. package/dist/resolve/load-directive.d.ts.map +1 -0
  78. package/dist/resolve/load-directive.js +93 -0
  79. package/dist/resolve/load-directive.mjs +1 -0
  80. package/dist/resolve/resolve-ir.cjs +1 -0
  81. package/dist/resolve/resolve-ir.d.ts +54 -0
  82. package/dist/resolve/resolve-ir.d.ts.map +1 -0
  83. package/dist/resolve/resolve-ir.js +58 -0
  84. package/dist/resolve/resolve-ir.mjs +1 -0
  85. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  86. package/package.json +4 -4
  87. package/dist/babel-6UTCV3CC.mjs +0 -2
@@ -11,6 +11,11 @@
11
11
  * - Control flow optimization
12
12
  */
13
13
  import { declare } from '@babel/helper-plugin-utils';
14
+ import { createComponentResolver, } from '../../resolve/component-resolver.js';
15
+ import { resolveComponentSources } from '../../resolve/resolve-ir.js';
16
+ import { collapseStrategies } from '../../optimize/collapse-strategies.js';
17
+ import { EAGER, isDeferred, isLoadDirective, parseLoadDirective, LoadDirectiveError, } from '../../resolve/load-directive.js';
18
+ import { DOM_CONTROL_FLOW, CORE_CONTROL_FLOW, ROUTER_COMPONENTS as ROUTER_COMPONENT_NAMES, } from '../../resolve/builtins.js';
14
19
  import { types as t, template, } from '@babel/core';
15
20
  import { buildIR } from './build-ir.js';
16
21
  import { buildIRFromLit } from './build-ir-lit.js';
@@ -18,27 +23,17 @@ import { imperativeBackend } from '../../codegen/backends/imperative.js';
18
23
  // ============================================================================
19
24
  // Constants
20
25
  // ============================================================================
21
- const CONTROL_FLOW_COMPONENTS = new Set([
22
- 'Show',
23
- 'For',
24
- 'Index',
25
- 'Switch',
26
- 'Match',
27
- 'Portal',
28
- 'Dynamic',
29
- 'ErrorBoundary',
30
- 'Suspense',
31
- 'SuspenseList',
32
- 'Await',
33
- ]);
26
+ // The names come from `resolve/builtins.ts`, which publishes them for the TS plugin,
27
+ // the language server and codemods — tools that need to know where `<Show>` comes from
28
+ // but must not re-derive it. What stays here is the part that is genuinely codegen:
29
+ // which names get special lowering, reactive-getter props, and memo exemption.
30
+ const CONTROL_FLOW_COMPONENTS = new Set([...DOM_CONTROL_FLOW, ...CORE_CONTROL_FLOW]);
34
31
  // Control-flow components that live in @fluixi/core, NOT @fluixi/dom — they must
35
32
  // be auto-imported from core (dom doesn't export them).
36
- const CORE_ONLY_CONTROL_FLOW = new Set(['Suspense', 'SuspenseList', 'Await']);
33
+ const CORE_ONLY_CONTROL_FLOW = new Set(CORE_CONTROL_FLOW);
37
34
  // Router components auto-imported (from routerModule) the same way control flow is:
38
35
  // use `<Router>`/`<Outlet>`/… in a template and the import is injected if absent.
39
- // (`Routes` isn't a component — routing is `<Router routes={…}>`; `lazy` is a function
40
- // call, not a tag, so neither can be auto-imported this way.)
41
- const ROUTER_COMPONENTS = new Set(['Router', 'Outlet', 'Redirect', 'Link']);
36
+ const ROUTER_COMPONENTS = new Set(ROUTER_COMPONENT_NAMES);
42
37
  // Control-flow components read a single reactive *value* prop inside their own tracking
43
38
  // scope. Emit that prop as a getter (like `children`) so reading it re-runs the caller's
44
39
  // expression — otherwise `each={items()}` / `when={cond()}` snapshot once and never update.
@@ -84,17 +79,24 @@ const DELEGATABLE_EVENTS = [
84
79
  // ============================================================================
85
80
  export default declare((api, options = {}) => {
86
81
  api.assertVersion(7);
87
- const { runtime = 'automatic', importSource = '@fluixi/jsx', pragma = 'jsx', pragmaFrag = 'Fragment', development = false, detectReactivity = true, useLitHTML = true, hoistStatics = true, delegateEvents = true, delegatedEvents = DELEGATABLE_EVENTS, optimizeControlFlow = true, sourceMaps = true, signalModule = '@fluixi/dom', controlFlowModule = '@fluixi/dom', routerModule = '@fluixi/core/router-next', reactiveModule = '@fluixi/reactive/signal', autoShowTransform = false, backend = 'imperative', codegen = 'inline',
82
+ const { runtime = 'automatic', importSource = '@fluixi/jsx', pragma = 'jsx', pragmaFrag = 'Fragment', development = false, detectReactivity = true, useLitHTML = true, hoistStatics = true, delegateEvents = true, delegatedEvents = DELEGATABLE_EVENTS, optimizeControlFlow = true, sourceMaps = true, signalModule = '@fluixi/dom', controlFlowModule = '@fluixi/dom', routerModule = '@fluixi/core/router-next', reactiveModule = '@fluixi/reactive/signal', autoShowTransform = false, backend = 'imperative', codegen = 'inline', templateClone = true, partialTemplates = true,
88
83
  // `both` by default: JSX and html`` are compiled out of the box, so
89
84
  // `fluixi()` needs no `format` option. Injected imports are deduped, so a
90
85
  // file mixing the two styles won't redeclare a binding.
91
- format = 'both', litTag = 'html', } = options;
86
+ format = 'both', litTag = 'html', resolve: resolveRules = [], } = options;
87
+ // Built once per plugin instance, not per file: the rules are static and the
88
+ // resolver memoises lookups, so every file in the build shares one cache.
89
+ const componentResolver = createComponentResolver(resolveRules);
92
90
  const compileLit = format === 'lit' || format === 'both';
93
91
  const pluginState = {
94
92
  backend,
95
93
  codegen,
96
94
  irImports: new Set(),
97
95
  libModule: '@fluixi/core',
96
+ controlFlowModule,
97
+ routerModule,
98
+ templateClone,
99
+ partialTemplates,
98
100
  hasJSX: false,
99
101
  hasReactivity: false,
100
102
  hasLitHTML: false,
@@ -102,8 +104,12 @@ export default declare((api, options = {}) => {
102
104
  needsControlFlowImport: false,
103
105
  needsCreateMemo: false,
104
106
  controlFlowComponents: new Set(),
107
+ resolvedComponents: new Map(),
108
+ deferredComponents: new Map(),
109
+ componentResolver,
105
110
  delegatedEvents: new Set(),
106
111
  staticElements: new Map(),
112
+ staticTemplates: new Map(),
107
113
  staticCounter: 0,
108
114
  };
109
115
  return {
@@ -139,11 +145,19 @@ export default declare((api, options = {}) => {
139
145
  needsControlFlowImport: false,
140
146
  needsCreateMemo: false,
141
147
  controlFlowComponents: new Set(),
148
+ resolvedComponents: new Map(),
149
+ deferredComponents: new Map(),
150
+ componentResolver,
142
151
  delegatedEvents: new Set(),
143
152
  staticElements: new Map(),
153
+ staticTemplates: new Map(),
144
154
  autoShow: autoShowTransform,
145
155
  staticCounter: 0,
146
156
  libModule: '@fluixi/core',
157
+ controlFlowModule,
158
+ routerModule,
159
+ templateClone,
160
+ partialTemplates,
147
161
  backend,
148
162
  codegen,
149
163
  irImports: new Set(),
@@ -211,7 +225,10 @@ export default declare((api, options = {}) => {
211
225
  imports.push(t.importDeclaration(specifiers, t.stringLiteral(signalModule)));
212
226
  }
213
227
  }
214
- else if (state.hasJSX && runtime === 'automatic') {
228
+ else if (state.hasJSX && runtime === 'automatic' && backend !== 'imperative') {
229
+ // Not under the imperative backend: `codegen: 'ir'` lands here because the
230
+ // branch above excludes it, and emitted an unused jsx/jsxs/Fragment import.
231
+ // The IR path produces runtime calls, never JSX factory calls.
215
232
  // Add JSX runtime imports
216
233
  const specifiers = [
217
234
  t.importSpecifier(t.identifier('jsx'), t.identifier('jsx')),
@@ -255,6 +272,50 @@ export default declare((api, options = {}) => {
255
272
  if (fromRouter.length > 0)
256
273
  imports.push(declFor(fromRouter, routerModule));
257
274
  }
275
+ // Imports for tags matched by the `resolve` rules. Same binding guard as
276
+ // control flow: a hand-written import or a local of the same name wins, so
277
+ // enabling resolution can never change what an existing file means.
278
+ //
279
+ // Grouped by module so several components from one package share a single
280
+ // declaration, and emitted with the local name equal to the tag — the
281
+ // generated code refers to the tag, not to the export.
282
+ // Deferred components become value bindings, not imports: a literal
283
+ // `import()` is what lets the bundler split, hash and tree-shake the chunk
284
+ // as ordinary behaviour rather than as an integration.
285
+ const deferredDecls = [];
286
+ if (state.deferredComponents.size > 0) {
287
+ for (const [local, entry] of state.deferredComponents) {
288
+ if (path.scope.hasBinding(local))
289
+ continue;
290
+ deferredDecls.push(t.variableDeclaration('const', [
291
+ t.variableDeclarator(t.identifier(local), t.callExpression(t.identifier('deferred'), [
292
+ t.arrowFunctionExpression([], t.callExpression(t.import(), [t.stringLiteral(entry.module)])),
293
+ t.objectExpression([
294
+ t.objectProperty(t.identifier('strategy'), strategyLiteral(entry.strategy)),
295
+ t.objectProperty(t.identifier('export'), t.stringLiteral(entry.export)),
296
+ ]),
297
+ ])),
298
+ ]));
299
+ }
300
+ if (deferredDecls.length > 0 && !path.scope.hasBinding('deferred')) {
301
+ imports.push(t.importDeclaration([t.importSpecifier(t.identifier('deferred'), t.identifier('deferred'))], t.stringLiteral(state.libModule ?? '@fluixi/core')));
302
+ }
303
+ }
304
+ if (state.resolvedComponents.size > 0) {
305
+ const byModule = new Map();
306
+ for (const [local, resolved] of state.resolvedComponents) {
307
+ if (path.scope.hasBinding(local))
308
+ continue;
309
+ const list = byModule.get(resolved.module) ?? [];
310
+ list.push({ local, exported: resolved.export });
311
+ byModule.set(resolved.module, list);
312
+ }
313
+ for (const [mod, entries] of byModule) {
314
+ imports.push(t.importDeclaration(entries.map(({ local, exported }) => exported === 'default'
315
+ ? t.importDefaultSpecifier(t.identifier(local))
316
+ : t.importSpecifier(t.identifier(local), t.identifier(exported))), t.stringLiteral(mod)));
317
+ }
318
+ }
258
319
  // Add event delegation setup if needed
259
320
  if (delegateEvents && state.delegatedEvents.size > 0) {
260
321
  imports.push(t.importDeclaration([
@@ -275,6 +336,28 @@ export default declare((api, options = {}) => {
275
336
  });
276
337
  path.node.body.unshift(...hoisted);
277
338
  }
339
+ // After the hoisted statics but before imports are unshifted, so the
340
+ // final order is: imports, deferred bindings, everything else.
341
+ if (deferredDecls.length > 0) {
342
+ path.node.body.unshift(...deferredDecls);
343
+ }
344
+ // Static templates, parsed once per module rather than per instance.
345
+ if (state.staticTemplates.size > 0) {
346
+ const decls = [];
347
+ for (const [key, name] of state.staticTemplates) {
348
+ const svg = key.startsWith('svg:');
349
+ const html = svg ? key.slice(4) : key;
350
+ decls.push(t.variableDeclaration('const', [
351
+ t.variableDeclarator(t.identifier(name),
352
+ // Just the HTML. Parsing is deferred to `templateNode`, which
353
+ // knows whether this is a server render, a hydration or a fresh
354
+ // client render — a module-scope `createTemplate` would touch
355
+ // `document` at import time and break SSR outright.
356
+ t.stringLiteral(html)),
357
+ ]));
358
+ }
359
+ path.node.body.unshift(...decls);
360
+ }
278
361
  }
279
362
  // Add all imports at the top, deduped. When a file mixes inline JSX
280
363
  // and html`` (format 'both'), the two injection paths can each emit an
@@ -296,9 +379,12 @@ export default declare((api, options = {}) => {
296
379
  const element = path.node;
297
380
  const openingElement = element.openingElement;
298
381
  // Router components auto-import like control flow, but aren't optimized —
299
- // just register the name so the import gets injected if it isn't bound.
382
+ // just register the name so the import gets injected if it isn't bound. A
383
+ // configured rule takes precedence; registering anyway would inject the
384
+ // router's import alongside the resolved one and bind the name twice.
300
385
  if (t.isJSXIdentifier(openingElement.name) &&
301
- ROUTER_COMPONENTS.has(openingElement.name.name)) {
386
+ ROUTER_COMPONENTS.has(openingElement.name.name) &&
387
+ !state.componentResolver?.resolve(openingElement.name.name)) {
302
388
  state.controlFlowComponents.add(openingElement.name.name);
303
389
  state.needsControlFlowImport = true;
304
390
  }
@@ -494,11 +580,102 @@ function emitImperativeElement(tag, isNative, props, spreads, children, state) {
494
580
  * `createMemo(() => createComponent(Comp, props))` — same shape jsx-runtime
495
581
  * produces for components, so it stays reactive when used as a child.
496
582
  */
583
+ /**
584
+ * Read a `load:` attribute's literal value. A `${…}` hole is rejected: the strategy
585
+ * decides how the module is emitted, so it has to be known at compile time.
586
+ */
587
+ function readLoadDirective(name, attr) {
588
+ let value = null;
589
+ if (attr.value) {
590
+ if (t.isStringLiteral(attr.value))
591
+ value = attr.value.value;
592
+ else {
593
+ throw new LoadDirectiveError(`'${name}' needs a literal value, not an expression — the strategy is compile-time.`);
594
+ }
595
+ }
596
+ return parseLoadDirective(name, value);
597
+ }
598
+ /**
599
+ * Two usages of one component in the same file share a binding, so their strategies
600
+ * have to agree. The earlier-loading one wins: the module is in whichever chunk loads
601
+ * first regardless, so deferring the other buys nothing — the same reasoning as the
602
+ * collapse pass, applied within a file.
603
+ */
604
+ /** The strategy as an object literal the runtime can read. */
605
+ function strategyLiteral(strategy) {
606
+ const props = [
607
+ t.objectProperty(t.identifier('kind'), t.stringLiteral(strategy.kind)),
608
+ ];
609
+ if (strategy.kind === 'visible' && strategy.rootMargin) {
610
+ props.push(t.objectProperty(t.identifier('rootMargin'), t.stringLiteral(strategy.rootMargin)));
611
+ }
612
+ if (strategy.kind === 'interaction') {
613
+ props.push(t.objectProperty(t.identifier('events'), t.arrayExpression(strategy.events.map((e) => t.stringLiteral(e)))));
614
+ }
615
+ if (strategy.kind === 'media') {
616
+ props.push(t.objectProperty(t.identifier('query'), t.stringLiteral(strategy.query)));
617
+ }
618
+ return t.objectExpression(props);
619
+ }
620
+ function mergeStrategy(existing, incoming) {
621
+ if (!isDeferred(existing) || !isDeferred(incoming))
622
+ return EAGER;
623
+ return existing;
624
+ }
497
625
  function emitComponentCall(comp, propsObj, state) {
498
- // Router components (Router/Outlet/…) auto-import like control flow register the
499
- // name here (the nested lowering chokepoint) so `<Outlet/>` inside markup is caught,
500
- // not only a top-level router tag. Unbound names are injected at Program.exit.
501
- if (t.isIdentifier(comp) && ROUTER_COMPONENTS.has(comp.name)) {
626
+ // Configured tags register here rather than in the JSX element visitor: this is the
627
+ // chokepoint every component passes through, and the visitor missed children because
628
+ // a lowered parent is replaced before its subtree is walked.
629
+ //
630
+ // A configured rule wins over the router built-ins. Those are only an import — no
631
+ // special lowering, no reactive-getter props — so letting a project's own `Link` lose
632
+ // to them silently was the same failure `Switch` has: the tag renders the router's
633
+ // component and nothing reports it. Control flow still wins, because there the name
634
+ // selects codegen and a rule cannot supply that.
635
+ // Control flow needs its import injected too, and registering it here rather than in
636
+ // the JSX visitor is what makes nested usage work: a lowered parent is replaced before
637
+ // its subtree is walked, so `<div><Show/></div>` never reached the visitor and `Show`
638
+ // compiled to a reference to nothing. Every fixture imported Show by hand, so nothing
639
+ // caught it.
640
+ if (t.isIdentifier(comp) && CONTROL_FLOW_COMPONENTS.has(comp.name)) {
641
+ state.controlFlowComponents.add(comp.name);
642
+ state.needsControlFlowImport = true;
643
+ }
644
+ let resolvedByRule = false;
645
+ if (t.isIdentifier(comp) && !CONTROL_FLOW_COMPONENTS.has(comp.name) && state.componentResolver) {
646
+ const resolved = state.componentResolver.resolve(comp.name);
647
+ if (resolved) {
648
+ const strategy = state.pendingLoad ?? EAGER;
649
+ // An eager usage seen earlier in the file already put the module in the chunk,
650
+ // so a later deferred usage of the same name collapses to eager. Without this the
651
+ // file gets both a static import and a `const` for one name — a redeclaration.
652
+ const alreadyEager = state.resolvedComponents.has(comp.name);
653
+ if (isDeferred(strategy) && !alreadyEager) {
654
+ // A deferred component becomes a `deferred(() => import(...))` binding rather
655
+ // than a static import, so the bundler puts it in its own chunk.
656
+ const existing = state.deferredComponents.get(comp.name);
657
+ const merged = existing ? mergeStrategy(existing.strategy, strategy) : strategy;
658
+ if (isDeferred(merged)) {
659
+ state.deferredComponents.set(comp.name, { ...resolved, strategy: merged });
660
+ }
661
+ else {
662
+ state.deferredComponents.delete(comp.name);
663
+ state.resolvedComponents.set(comp.name, resolved);
664
+ }
665
+ }
666
+ else {
667
+ // An eager usage anywhere wins: the module is in the chunk already.
668
+ state.deferredComponents.delete(comp.name);
669
+ state.resolvedComponents.set(comp.name, resolved);
670
+ }
671
+ resolvedByRule = true;
672
+ }
673
+ }
674
+ state.pendingLoad = undefined;
675
+ // Router components auto-import like control flow — register the name here so
676
+ // `<Outlet/>` nested in markup is caught, not only a top-level router tag. Unbound
677
+ // names are injected at Program.exit.
678
+ if (!resolvedByRule && t.isIdentifier(comp) && ROUTER_COMPONENTS.has(comp.name)) {
502
679
  state.controlFlowComponents.add(comp.name);
503
680
  state.needsControlFlowImport = true;
504
681
  }
@@ -518,17 +695,54 @@ function emitComponentCall(comp, propsObj, state) {
518
695
  * Produces the same runtime calls as the inline path — this is the route a future
519
696
  * swc front-end reuses.
520
697
  */
698
+ /**
699
+ * Hoist the backend's static templates to module scope and rewrite the emitted code to
700
+ * reference them. Deduped by HTML, so a subtree repeated in a file is parsed once.
701
+ */
702
+ function adoptTemplates(code, templates, state) {
703
+ if (!templates || templates.length === 0)
704
+ return code;
705
+ const rename = new Map();
706
+ for (const tpl of templates) {
707
+ const key = `${tpl.svg ? 'svg:' : ''}${tpl.html}`;
708
+ let name = state.staticTemplates.get(key);
709
+ if (!name) {
710
+ name = `_tmpl$${state.staticTemplates.size}`;
711
+ state.staticTemplates.set(key, name);
712
+ }
713
+ rename.set(tpl.id, name);
714
+ }
715
+ // One pass, not one replacement per id. The backend's local ids and the hoisted names
716
+ // share the `_tmpl$N` namespace, so replacing them in sequence can rewrite a name that
717
+ // an earlier replacement just produced — which silently points a template at another
718
+ // template's HTML.
719
+ return code.replace(/_tmpl\$\d+/g, (id) => rename.get(id) ?? id);
720
+ }
521
721
  function emitViaIR(node, state) {
522
722
  // `used` collects runtime symbols emitted for JSX nested inside expressions
523
723
  // (render-props, ternaries, fallback={<p/>}, …) so we import them too.
524
724
  const used = new Set();
525
725
  const ir = buildIR(node, used);
526
- const { code, imports } = imperativeBackend.emit(ir, {});
726
+ // The same passes the lit path runs. Without them `codegen: 'ir'` silently skipped
727
+ // resolution entirely — a resolved tag compiled to a reference with no import.
728
+ resolveComponentSources(ir, {
729
+ resolver: state.componentResolver,
730
+ modules: {
731
+ controlFlowModule: state.controlFlowModule,
732
+ coreModule: state.libModule,
733
+ routerModule: state.routerModule,
734
+ },
735
+ strategyFor: (n) => n.load,
736
+ });
737
+ collapseStrategies(ir);
738
+ const { code, imports, templates } = imperativeBackend.emit(ir, { templateClone: state.templateClone, partialTemplates: state.partialTemplates });
527
739
  for (const s of imports)
528
740
  used.add(s);
529
741
  for (const s of used)
530
742
  state.irImports.add(s);
531
- return template.expression(code, { placeholderPattern: false, plugins: ['typescript'] })();
743
+ collectRuntimeNeeds(ir, state);
744
+ const adopted = adoptTemplates(code, templates, state);
745
+ return template.expression(adopted, { placeholderPattern: false, plugins: ['typescript'] })();
532
746
  }
533
747
  /**
534
748
  * Merge injected import declarations so no module/name pair is imported twice.
@@ -572,7 +786,23 @@ function emitLitViaIR(node, state) {
572
786
  const used = new Set();
573
787
  const svg = t.isIdentifier(node.tag) && node.tag.name === 'svg';
574
788
  const ir = buildIRFromLit(node, used, { svg });
575
- const { code, imports } = imperativeBackend.emit(ir, {});
789
+ // Resolve pass: annotate each component reference with where it comes from, before
790
+ // codegen. Bindings are not checked here — Program.exit knows the whole file's scope
791
+ // and drops any injected import whose name turns out to be bound.
792
+ resolveComponentSources(ir, {
793
+ resolver: state.componentResolver,
794
+ modules: {
795
+ controlFlowModule: state.controlFlowModule,
796
+ coreModule: state.libModule,
797
+ routerModule: state.routerModule,
798
+ },
799
+ strategyFor: (node) => node.load,
800
+ });
801
+ // Demote deferred references whose module is already in this chunk: a lazy boundary
802
+ // around something already present adds a wrapper, a promise and a re-render to
803
+ // fetch nothing.
804
+ collapseStrategies(ir);
805
+ const { code, imports, templates } = imperativeBackend.emit(ir, { templateClone: state.templateClone, partialTemplates: state.partialTemplates });
576
806
  for (const s of imports)
577
807
  used.add(s);
578
808
  for (const s of used)
@@ -583,7 +813,7 @@ function emitLitViaIR(node, state) {
583
813
  // position — which is why compiled html`` had no source map at all. Give every
584
814
  // generated node the template's own location so a breakpoint or stack frame
585
815
  // lands on the template instead of nowhere.
586
- const expression = template.expression(code, {
816
+ const expression = template.expression(adoptTemplates(code, templates, state), {
587
817
  placeholderPattern: false,
588
818
  plugins: ['typescript'],
589
819
  })();
@@ -630,10 +860,29 @@ function inheritLocation(target, source) {
630
860
  * (-> auto-import from the control-flow module, like the inline JSX path).
631
861
  */
632
862
  function collectRuntimeNeeds(node, state) {
633
- if (node.kind === 'component' &&
634
- (CONTROL_FLOW_COMPONENTS.has(node.name) || ROUTER_COMPONENTS.has(node.name))) {
635
- state.controlFlowComponents.add(node.name);
636
- state.needsControlFlowImport = true;
863
+ if (node.kind === 'component' && node.source) {
864
+ // Provenance was decided by the resolve pass and recorded on the node; this only
865
+ // translates it into the two things Program.exit wires. Reading it here rather
866
+ // than re-resolving is the point — one decision, so import injection and every
867
+ // later pass cannot disagree.
868
+ if (node.source.origin === 'builtin') {
869
+ state.controlFlowComponents.add(node.name);
870
+ state.needsControlFlowImport = true;
871
+ }
872
+ else if (isDeferred(node.source.loading) && !state.resolvedComponents.has(node.name)) {
873
+ state.deferredComponents.set(node.name, {
874
+ module: node.source.module,
875
+ export: node.source.export,
876
+ strategy: node.source.loading,
877
+ });
878
+ }
879
+ else {
880
+ state.deferredComponents.delete(node.name);
881
+ state.resolvedComponents.set(node.name, {
882
+ module: node.source.module,
883
+ export: node.source.export,
884
+ });
885
+ }
637
886
  }
638
887
  if (node.kind === 'element' || node.kind === 'component' || node.kind === 'control') {
639
888
  if ('props' in node) {
@@ -836,6 +1085,9 @@ function transformAttributes(attributes, state, isNativeElement = true, tagName
836
1085
  const props = [];
837
1086
  const spreads = [];
838
1087
  const cfValueProp = CONTROL_FLOW_VALUE_PROPS[tagName];
1088
+ // `load:` is a directive, not a prop — it says when this component's module is
1089
+ // fetched. Cleared per element so a strategy cannot leak onto the next tag.
1090
+ state.pendingLoad = undefined;
839
1091
  for (const attr of attributes) {
840
1092
  if (t.isJSXSpreadAttribute(attr)) {
841
1093
  // Collect spread expressions separately
@@ -843,6 +1095,11 @@ function transformAttributes(attributes, state, isNativeElement = true, tagName
843
1095
  }
844
1096
  else if (t.isJSXAttribute(attr)) {
845
1097
  const name = getAttributeName(attr.name);
1098
+ if (isLoadDirective(name)) {
1099
+ // Same parser the html`` lowering uses, so the two syntaxes cannot drift.
1100
+ state.pendingLoad = readLoadDirective(name, attr);
1101
+ continue;
1102
+ }
846
1103
  const value = transformAttributeValue(attr.value, state, name, isNativeElement);
847
1104
  // Detect event handlers for delegation
848
1105
  if (EVENT_PROPS.test(name)) {