@barefootjs/jsx 0.17.0 → 0.18.0

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 (88) hide show
  1. package/dist/adapters/env-signal.d.ts +42 -7
  2. package/dist/adapters/env-signal.d.ts.map +1 -1
  3. package/dist/adapters/interface.d.ts +20 -4
  4. package/dist/adapters/interface.d.ts.map +1 -1
  5. package/dist/adapters/parsed-expr-emitter.d.ts +3 -1
  6. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  7. package/dist/analyzer-context.d.ts.map +1 -1
  8. package/dist/analyzer.d.ts.map +1 -1
  9. package/dist/augment-inherited-props.d.ts +19 -0
  10. package/dist/augment-inherited-props.d.ts.map +1 -1
  11. package/dist/compiler.d.ts.map +1 -1
  12. package/dist/expression-parser.d.ts +48 -2
  13. package/dist/expression-parser.d.ts.map +1 -1
  14. package/dist/index.d.ts +24 -5
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1728 -969
  17. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/compute-inlinability.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  20. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts +9 -0
  21. package/dist/ir-to-client-js/control-flow/plan/loop.d.ts.map +1 -1
  22. package/dist/ir-to-client-js/control-flow/stringify/loop.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts +19 -0
  24. package/dist/ir-to-client-js/control-flow/stringify/template-parse.d.ts.map +1 -1
  25. package/dist/ir-to-client-js/emit-registration.d.ts +4 -2
  26. package/dist/ir-to-client-js/emit-registration.d.ts.map +1 -1
  27. package/dist/ir-to-client-js/html-template.d.ts +41 -0
  28. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  29. package/dist/ir-to-client-js/types.d.ts +22 -1
  30. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  31. package/dist/jsx-to-ir.d.ts.map +1 -1
  32. package/dist/loop-destructure.d.ts +55 -18
  33. package/dist/loop-destructure.d.ts.map +1 -1
  34. package/dist/lowering-registry.d.ts +13 -0
  35. package/dist/lowering-registry.d.ts.map +1 -1
  36. package/dist/relocate.d.ts +28 -0
  37. package/dist/relocate.d.ts.map +1 -1
  38. package/dist/ssr-defaults.d.ts.map +1 -1
  39. package/dist/ssr-seed-plan.d.ts +84 -0
  40. package/dist/ssr-seed-plan.d.ts.map +1 -0
  41. package/dist/types.d.ts +79 -0
  42. package/dist/types.d.ts.map +1 -1
  43. package/package.json +2 -2
  44. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +284 -12
  45. package/src/__tests__/augment-inherited-props.test.ts +96 -0
  46. package/src/__tests__/compiler-runtime-contract.test.ts +11 -1
  47. package/src/__tests__/compiler-stress-1244.test.ts +13 -4
  48. package/src/__tests__/csr-substitution-safety-divergence.test.ts +137 -0
  49. package/src/__tests__/destructured-map-params.test.ts +11 -1
  50. package/src/__tests__/expression-parser.test.ts +74 -3
  51. package/src/__tests__/free-identifiers.test.ts +55 -0
  52. package/src/__tests__/ir-sort-comparator.test.ts +261 -0
  53. package/src/__tests__/loop-destructure.test.ts +313 -0
  54. package/src/__tests__/loop-hoisted-template.test.ts +235 -0
  55. package/src/__tests__/materialize-getter-calls.test.ts +58 -0
  56. package/src/__tests__/props-destructuring.test.ts +110 -0
  57. package/src/__tests__/serialize-parsed-expr.test.ts +88 -2
  58. package/src/__tests__/ssr-defaults.test.ts +20 -0
  59. package/src/__tests__/ssr-seed-plan.test.ts +212 -0
  60. package/src/__tests__/staged-ir/11-template-primitive-registry.test.ts +231 -1
  61. package/src/__tests__/tagged-template-interleave.test.ts +268 -0
  62. package/src/__tests__/unsupported-expression.test.ts +194 -7
  63. package/src/adapters/env-signal.ts +57 -9
  64. package/src/adapters/interface.ts +20 -4
  65. package/src/adapters/parsed-expr-emitter.ts +19 -2
  66. package/src/analyzer-context.ts +20 -0
  67. package/src/analyzer.ts +74 -1
  68. package/src/augment-inherited-props.ts +139 -9
  69. package/src/compiler.ts +10 -1
  70. package/src/expression-parser.ts +421 -50
  71. package/src/index.ts +30 -3
  72. package/src/ir-to-client-js/collect-elements.ts +15 -1
  73. package/src/ir-to-client-js/compute-inlinability.ts +6 -1
  74. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +1 -0
  75. package/src/ir-to-client-js/control-flow/plan/loop.ts +9 -0
  76. package/src/ir-to-client-js/control-flow/stringify/loop.ts +30 -8
  77. package/src/ir-to-client-js/control-flow/stringify/template-parse.ts +30 -0
  78. package/src/ir-to-client-js/emit-registration.ts +4 -2
  79. package/src/ir-to-client-js/html-template.ts +198 -1
  80. package/src/ir-to-client-js/index.ts +1 -0
  81. package/src/ir-to-client-js/types.ts +22 -0
  82. package/src/jsx-to-ir.ts +409 -24
  83. package/src/loop-destructure.ts +89 -36
  84. package/src/lowering-registry.ts +16 -0
  85. package/src/relocate.ts +201 -14
  86. package/src/ssr-defaults.ts +34 -32
  87. package/src/ssr-seed-plan.ts +146 -0
  88. package/src/types.ts +76 -0
package/src/index.ts CHANGED
@@ -12,6 +12,10 @@ export type { CompileResult, CompileOptions, CompileOptionsWithAdapter, FileOutp
12
12
  export { extractSsrDefaults } from './ssr-defaults.ts'
13
13
  export type { SsrDefault } from './ssr-defaults.ts'
14
14
 
15
+ // Backend-neutral SSR seed plan (in-template derived signal/memo seeding)
16
+ export { computeSsrSeedPlan } from './ssr-seed-plan.ts'
17
+ export type { SsrSeedPlan, SsrSeedStep } from './ssr-seed-plan.ts'
18
+
15
19
  // Pure IR types
16
20
  export type {
17
21
  ComponentIR,
@@ -22,6 +26,9 @@ export type {
22
26
  IRConditional,
23
27
  IRLoop,
24
28
  IRLoopChildComponent,
29
+ LoopParamBinding,
30
+ LoopBindingPathSegment,
31
+ RestExcludeKey,
25
32
  IRComponent,
26
33
  IRFragment,
27
34
  IRSlot,
@@ -46,6 +53,8 @@ export type {
46
53
  TypeDefinition,
47
54
  SourceLocation,
48
55
  CompilerError,
56
+ ConformancePin,
57
+ ConformancePins,
49
58
  } from './types.ts'
50
59
 
51
60
  // Analyzer
@@ -77,13 +86,15 @@ export type { JsxAdapterConfig } from './adapters/jsx-adapter.ts'
77
86
  export { rewriteImportsForTemplate } from './adapters/template-imports.ts'
78
87
  export { emitParsedExpr } from './adapters/parsed-expr-emitter.ts'
79
88
  export type { ParsedExprEmitter, HigherOrderMethod, ArrayMethod, SortMethod, LiteralType } from './adapters/parsed-expr-emitter.ts'
80
- export { importsSearchParams, searchParamsLocalNames, queryHrefLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
89
+ export { importsSearchParams, searchParamsLocalNames, envSignalLocalNames, envSignalReaderFor, ENV_SIGNAL_READERS, queryHrefLocalNames, matchSearchParamsMethodCall } from './adapters/env-signal.ts'
90
+ export type { EnvSignalReader } from './adapters/env-signal.ts'
81
91
  export { matchQueryHrefCall, queryHrefArgs, type QueryHrefCall, type QueryHrefTriple } from './query-href-lowering.ts'
82
92
  export {
83
93
  registerLoweringPlugin,
84
94
  getLoweringPlugins,
85
95
  prepareLoweringMatchers,
86
96
  matchLoweringCall,
97
+ isValidHelperId,
87
98
  __resetLoweringPluginsForTest,
88
99
  type LoweringPlugin,
89
100
  type LoweringNode,
@@ -255,6 +266,22 @@ export interface BuildOptions {
255
266
  * Forwarded to `compileJSX` as `CompileOptions.localImportPrefixes`.
256
267
  */
257
268
  localImportPrefixes?: string[]
269
+ /**
270
+ * How the CLI produces `barefoot.js` (the client runtime bundle):
271
+ * - `'treeshake'` (default) — bundle only the runtime exports this
272
+ * project's compiled client JS actually imports, plus a small
273
+ * always-kept public mount API (`render`, `hydrate`, etc.).
274
+ * - `'full'` — copy the entire prebuilt runtime bundle verbatim.
275
+ * See `@barefootjs/cli`'s `runtime-treeshake.ts` for the collector and
276
+ * `ALWAYS_KEEP_RUNTIME_EXPORTS` for the always-kept names.
277
+ */
278
+ runtimeBundle?: 'treeshake' | 'full'
279
+ /**
280
+ * Extra `@barefootjs/client*` export names to force-keep in `barefoot.js`
281
+ * under `runtimeBundle: 'treeshake'` — for names only ever referenced
282
+ * from hand-written page scripts the CLI never compiles.
283
+ */
284
+ runtimeKeep?: string[]
258
285
  }
259
286
 
260
287
  // AttrValue constructors
@@ -276,12 +303,12 @@ export {
276
303
  export { ErrorCodes, createError, formatError, generateCodeFrame } from './errors.ts'
277
304
 
278
305
  // Expression Parser
279
- export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
306
+ export { parseExpression, tsNodeToParsedExpr, asCallbackMethodCall, CALLBACK_METHODS, sortComparatorFromArrow, serializeParsedExpr, freeVarsInBody, freeIdentifiers, materializeGetterCalls, isSupported, exprToString, stringifyParsedExpr, identifierPath, parseBlockBody, parseBlockBodyTolerant, foldBlockToExpr, predicateTernaryToLogical, containsHigherOrder, extractArrowBodyExpression, parseStyleObjectEntries, parseProviderObjectLiteral, type ProviderObjectMember, type FoldBlockOptions } from './expression-parser.ts'
280
307
  export type { StyleObjectEntry } from './expression-parser.ts'
281
308
  export type { ParsedExpr, ObjectLiteralProperty, ParsedStatement, SortComparator, SortKey, FlatDepth, SupportLevel, SupportResult, TemplatePart } from './expression-parser.ts'
282
309
  export { buildLoopChainExpr } from './loop-chain.ts'
283
310
  export type { LoopChainInputs } from './loop-chain.ts'
284
- export { isLowerableObjectRestDestructure } from './loop-destructure.ts'
311
+ export { isLowerableLoopDestructure, isLowerableObjectRestDestructure } from './loop-destructure.ts'
285
312
 
286
313
  // Debug analysis
287
314
  export {
@@ -6,7 +6,7 @@ import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp
6
6
  import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types.ts'
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils.ts'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings } from './reactivity.ts'
9
- import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr } from './html-template.ts'
9
+ import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr, buildLoopSkeletonTemplate } from './html-template.ts'
10
10
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling.ts'
11
11
  import { walkIR, stopAt } from './walker.ts'
12
12
  import { buildLoopChainExpr } from '../loop-chain.ts'
@@ -671,6 +671,7 @@ export function collectElements(
671
671
 
672
672
  let template = ''
673
673
  let staticItemTemplate: string | undefined
674
+ let skeletonTemplate: string | undefined
674
675
  if (l.childComponent) {
675
676
  template = '' // childComponent path uses createComponent directly
676
677
  // CSR materialize fallback (#1268): when the loop array references an
@@ -714,6 +715,18 @@ export function collectElements(
714
715
  staticItemTemplate = useElementReconciliation
715
716
  ? irToPlaceholderTemplate(l.children[0], buildRestSpreadNames(ctx), 0)
716
717
  : irToHtmlTemplate(l.children[0], buildRestSpreadNames(ctx), 0)
718
+ } else if (!useElementReconciliation && !l.bodyIsMultiRoot && !l.bodyIsItemConditional) {
719
+ // Hoisted shared-template fast path (perf): only for the plain
720
+ // `mapArray` shape — single-root, dynamic array, no element
721
+ // reconciliation. `buildLoopSkeletonTemplate` re-derives safety
722
+ // from the raw IR (spread attrs, conditionals, unslotted dynamic
723
+ // expressions, …) and returns `null` for anything it can't prove
724
+ // safe; the plan builder (`build-loop.ts`) falls back to the
725
+ // per-row `template` above whenever this stays `undefined`.
726
+ skeletonTemplate = buildLoopSkeletonTemplate(l.children[0], {
727
+ reactiveAttrKeys: new Set(bindings.reactiveAttrs.map(a => `${a.childSlotId}::${a.attrName}`)),
728
+ reactiveTextSlotIds: new Set(bindings.reactiveTexts.map(t => t.slotId)),
729
+ }) ?? undefined
717
730
  }
718
731
  }
719
732
 
@@ -732,6 +745,7 @@ export function collectElements(
732
745
  iterationShape: l.iterationShape,
733
746
  template,
734
747
  staticItemTemplate,
748
+ skeletonTemplate,
735
749
  childEventHandlers: childHandlers,
736
750
  bindings,
737
751
  childComponent: l.childComponent,
@@ -64,7 +64,12 @@ export function buildEnvFromCtx(ctx: ClientJsContext): RelocateEnv {
64
64
  effects: ctx.effects,
65
65
  onMounts: ctx.onMounts,
66
66
  initStatements: ctx.initStatements,
67
- imports: [],
67
+ // Real component imports (#2069) — `buildRelocateEnvFromIR` calls
68
+ // `prepareLoweringMatchers(metadata)` on this reconstructed object,
69
+ // and plugin `prepare()` resolves local import names from
70
+ // `metadata.imports`. `[]` here would silently disable every
71
+ // import-aware LoweringPlugin for the client-JS inline-safety gate.
72
+ imports: ctx.imports,
68
73
  templateImports: [],
69
74
  namedExports: [],
70
75
  localFunctions: ctx.localFunctions,
@@ -102,6 +102,7 @@ export function buildPlainLoopPlan(elem: TopLevelLoop, profileComponentName?: st
102
102
  indexParam: elem.index || '__idx',
103
103
  mapPreambleWrapped: elem.mapPreamble ? wrap(elem.mapPreamble) : '',
104
104
  template: elem.template,
105
+ skeletonTemplate: elem.skeletonTemplate,
105
106
  reactiveEffects: hasReactive ? buildLoopReactiveEffectsPlan(elem, profileComponentName) : null,
106
107
  childRefs: buildChildRefBindings(elem.bindings.refs, elem.param, elem.paramBindings),
107
108
  bodyIsMultiRoot: elem.bodyIsMultiRoot ?? false,
@@ -94,6 +94,15 @@ interface PlainLoopVariant extends DynamicLoopCommon {
94
94
  mapPreambleWrapped: string
95
95
  /** HTML template string for one item. */
96
96
  template: string
97
+ /**
98
+ * Shared once-per-loop skeleton template (perf) — see
99
+ * `buildLoopSkeletonTemplate` (html-template.ts). When present, the
100
+ * stringifier declares it once (before the `mapArray` call) and clones
101
+ * from it instead of re-parsing `template`'s interpolated innerHTML per
102
+ * row. `undefined` means the loop body wasn't proven safe to hoist; the
103
+ * stringifier falls back to the legacy per-row `emitTemplateCloneInline`.
104
+ */
105
+ skeletonTemplate?: string
97
106
  /** Resolved reactive-effects plan — null forces the single-line renderItem shape. */
98
107
  reactiveEffects: ReactiveEffectsPlan | null
99
108
  /**
@@ -27,7 +27,7 @@
27
27
  import { emitRefCall, varSlotId, profileBindingId } from '../../utils.ts'
28
28
  import { emitAttrUpdate } from '../../emit-reactive.ts'
29
29
  import { stringifyReactiveEffects } from './reactive-effects.ts'
30
- import { emitTemplateCloneInline, emitLoopItemElementSetup } from './template-parse.ts'
30
+ import { emitTemplateCloneInline, emitLoopItemElementSetup, emitHoistedTemplateDecl, hoistedCloneExpr } from './template-parse.ts'
31
31
  import { stringifyComponentLoop } from './component-loop.ts'
32
32
  import { stringifyCompositeLoop } from './composite-loop.ts'
33
33
  import type { LoopChildRefBinding, LoopPlan, PlainLoopPlan, StaticLoopPlan } from '../plan/types.ts'
@@ -117,6 +117,7 @@ export function stringifyPlainLoop(
117
117
  indexParam,
118
118
  mapPreambleWrapped,
119
119
  template,
120
+ skeletonTemplate,
120
121
  reactiveEffects,
121
122
  childRefs,
122
123
  bodyIsMultiRoot,
@@ -133,6 +134,21 @@ export function stringifyPlainLoop(
133
134
  return
134
135
  }
135
136
 
137
+ // Hoisted shared-template fast path (perf, see `buildLoopSkeletonTemplate`):
138
+ // declare the once-per-loop template BEFORE the `mapArray` call so every
139
+ // row clones from an already-parsed node instead of re-running
140
+ // `document.createElement('template')` + an `innerHTML` parse per row.
141
+ // `bodyIsMultiRoot` is re-checked defensively even though the plan builder
142
+ // only sets `skeletonTemplate` when it's already false.
143
+ const hoistedTpl = !bodyIsMultiRoot && skeletonTemplate ? skeletonTemplate : null
144
+ // Keyed off `markerId` (unique per loop, #1087), NOT `containerVar` —
145
+ // sibling `.map()` calls under the same parent share the container slot,
146
+ // so a container-derived name would collide ("has already been declared").
147
+ const tplVar = `__tpl_${markerId.replace(/[^A-Za-z0-9_$]/g, '_')}`
148
+ if (hoistedTpl) {
149
+ emitHoistedTemplateDecl(lines, topIndent, tplVar, hoistedTpl)
150
+ }
151
+
136
152
  // `childRefs` need `__el` as a handle to invoke the user's callback inside
137
153
  // the factory, so non-empty refs force the multi-line layout the same way
138
154
  // reactive effects do (#1244).
@@ -141,7 +157,9 @@ export function stringifyPlainLoop(
141
157
  // Single-line renderItem (no reactive effects, single root, no refs).
142
158
  const unwrapInline = paramUnwrap ? `${paramUnwrap} ` : ''
143
159
  const preamble = mapPreambleWrapped ? `${mapPreambleWrapped}; ` : ''
144
- const cloneExpr = emitTemplateCloneInline(template)
160
+ const cloneExpr = hoistedTpl
161
+ ? `return ${hoistedCloneExpr(tplVar, hoistedTpl)}`
162
+ : emitTemplateCloneInline(template)
145
163
  lines.push(
146
164
  `${topIndent}mapArray(() => ${arrayExpr}, ${containerVar}, ${keyFn}, (${paramHead}, ${indexParam}, __existing) => { ${unwrapInline}${preamble}if (__existing) return __existing; ${cloneExpr} }, '${markerId}'${loopBfId})`,
147
165
  )
@@ -153,12 +171,16 @@ export function stringifyPlainLoop(
153
171
  const bodyIndent = topIndent + ' '
154
172
  if (paramUnwrap) lines.push(`${bodyIndent}${paramUnwrap}`)
155
173
  if (mapPreambleWrapped) lines.push(`${bodyIndent}${mapPreambleWrapped}`)
156
- emitLoopItemElementSetup(lines, {
157
- template,
158
- bodyIsMultiRoot,
159
- indent: bodyIndent,
160
- singleRootLayout: 'inline',
161
- })
174
+ if (hoistedTpl) {
175
+ lines.push(`${bodyIndent}const __el = __existing ?? ${hoistedCloneExpr(tplVar, hoistedTpl)}`)
176
+ } else {
177
+ emitLoopItemElementSetup(lines, {
178
+ template,
179
+ bodyIsMultiRoot,
180
+ indent: bodyIndent,
181
+ singleRootLayout: 'inline',
182
+ })
183
+ }
162
184
  if (reactiveEffects !== null) {
163
185
  stringifyReactiveEffects(lines, reactiveEffects, { indent: bodyIndent, elVar: '__el', bodyIsMultiRoot })
164
186
  }
@@ -145,6 +145,36 @@ export function emitTemplateCloneInline(template: string): string {
145
145
  return `const __tpl = document.createElement('template'); __tpl.innerHTML = \`${template}\`; return __tpl.content.firstElementChild.cloneNode(true)`
146
146
  }
147
147
 
148
+ /**
149
+ * Emit the ONE-TIME declaration of a loop's hoisted shared template (perf):
150
+ * built once per loop, before the `mapArray` call, so every row clones from
151
+ * an already-parsed node instead of re-running `document.createElement
152
+ * ('template')` + an `innerHTML` parse per row. `skeletonTemplate` is the
153
+ * STATIC-ONLY skeleton produced by `buildLoopSkeletonTemplate` (dynamic attrs
154
+ * omitted, text markers empty) — never the per-row interpolated `template`.
155
+ *
156
+ * SVG namespace wrap mirrors `emitTemplateCloneLines` (#135 / #1088):
157
+ * `templateRootIsSvg` is re-checked against the skeleton (same root tag as
158
+ * the interpolated template, so the same wrap decision applies).
159
+ */
160
+ export function emitHoistedTemplateDecl(lines: string[], indent: string, tplVar: string, skeletonTemplate: string): void {
161
+ const isSvg = templateRootIsSvg(skeletonTemplate)
162
+ const html = isSvg ? `<svg>${skeletonTemplate}</svg>` : skeletonTemplate
163
+ lines.push(`${indent}const ${tplVar} = document.createElement('template')`)
164
+ lines.push(`${indent}${tplVar}.innerHTML = \`${html}\``)
165
+ }
166
+
167
+ /**
168
+ * Clone expression reading off a hoisted template variable declared via
169
+ * `emitHoistedTemplateDecl`, in place of the per-row
170
+ * `emitTemplateCloneInline` / `emitTemplateCloneLines` parse-and-clone.
171
+ */
172
+ export function hoistedCloneExpr(tplVar: string, skeletonTemplate: string): string {
173
+ return templateRootIsSvg(skeletonTemplate)
174
+ ? `${tplVar}.content.firstElementChild.firstElementChild.cloneNode(true)`
175
+ : `${tplVar}.content.firstElementChild.cloneNode(true)`
176
+ }
177
+
148
178
  /**
149
179
  * Multi-line variant for code paths that emit each line separately.
150
180
  * Returns three statements with no trailing newlines.
@@ -102,8 +102,10 @@ export function buildInlinableConstants(
102
102
  * — the AST substitution + chain resolution now lives upstream.
103
103
  *
104
104
  * Excludes constants whose entry is `null` (unsafe to inline) — those
105
- * stay in `unsafeLocalNames` and the CSR template's expression
106
- * substitution surfaces the UNSAFE sentinel for any reference to them.
105
+ * are also folded into the unsafe set `generateCsrTemplate` uses
106
+ * internally (see `mergeCsrNullUnsafe` in html-template.ts, #2106), so
107
+ * every caller of `generateCsrTemplate` gets a consistent
108
+ * substitute-or-fallback decision with no extra wiring required here.
107
109
  */
108
110
  export function csrInlinableConstantsFromCtx(ctx: ClientJsContext): Map<string, string> {
109
111
  const out = new Map<string, string>()
@@ -702,6 +702,139 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
702
702
  }
703
703
  }
704
704
 
705
+ /**
706
+ * Slots a top-level (`mapArray`-driven) loop body proves safe to hoist into a
707
+ * shared, once-per-loop template (perf: avoid per-row `document.createElement
708
+ * ('template')` + innerHTML parse + escapeText/escapeAttr, see
709
+ * `buildLoopSkeletonTemplate`).
710
+ */
711
+ export interface LoopSkeletonSafeSlots {
712
+ /** `"<childSlotId>::<attrName>"` pairs already covered by a loop-child reactive-attribute `createEffect`. */
713
+ reactiveAttrKeys: ReadonlySet<string>
714
+ /** Text-marker slot ids already covered by a loop-child reactive-text `createEffect`. */
715
+ reactiveTextSlotIds: ReadonlySet<string>
716
+ }
717
+
718
+ /**
719
+ * Build the STATIC skeleton of a top-level loop body — the shared template
720
+ * cloned once per row instead of re-parsed from a per-row interpolated
721
+ * `innerHTML` string (perf: create-heavy `.map()` loops, see
722
+ * spec/compiler.md "Loop emission shapes").
723
+ *
724
+ * The skeleton keeps every static element / attr / text verbatim, keeps `bf="sN"`
725
+ * marker attributes (needed for `qsa` / `$t` lookups), and keeps text-marker
726
+ * comments (`<!--bf:sN--><!--/-->`) but EMPTIES the interpolation between them.
727
+ * Every dynamic attribute is DROPPED entirely rather than interpolated — both
728
+ * forms rely on the loop-child `createEffect`s (already emitted alongside the
729
+ * clone, see `stringifyReactiveEffects`) to fill in the real value on their
730
+ * eager first run, so nothing is lost — UNLESS a dynamic attr/text isn't
731
+ * proven covered by one of those effects, in which case this function refuses
732
+ * (returns `null`) and the caller falls back to the per-row interpolated
733
+ * template (`irToHtmlTemplate`). `key` is special-cased to an always-empty
734
+ * `data-key=""` placeholder — `mapArray` stamps the real key onto freshly
735
+ * created elements itself (see `map-array.ts`), so the clone path never needs
736
+ * to bake one in.
737
+ *
738
+ * Refuses (returns `null`) on anything not proven safe: spread attrs,
739
+ * `dangerouslySetInnerHTML`, a dynamic attribute/text not present in `safe`,
740
+ * a bare (unslotted) dynamic expression (no DOM anchor to backfill later),
741
+ * conditionals, child components, nested loops, and provider/async/if-statement
742
+ * boundaries. Callers additionally gate on the loop shape itself (single-root,
743
+ * non-static, no `useElementReconciliation`) before invoking this — see
744
+ * `collect-elements.ts`'s `loop` visitor.
745
+ */
746
+ export function buildLoopSkeletonTemplate(node: IRNode, safe: LoopSkeletonSafeSlots): string | null {
747
+ switch (node.type) {
748
+ case 'element': {
749
+ const attrParts: string[] = []
750
+ for (const a of node.attrs) {
751
+ if (a.name === '...') return null
752
+ if (a.name === 'dangerouslySetInnerHTML') return null
753
+ if (a.name === 'key') {
754
+ attrParts.push(`${keyAttrName(0)}=""`)
755
+ continue
756
+ }
757
+ const v = a.value
758
+ switch (v.kind) {
759
+ case 'literal':
760
+ attrParts.push(`${toHtmlAttrName(a.name)}="${v.value}"`)
761
+ break
762
+ case 'boolean-attr':
763
+ attrParts.push(toHtmlAttrName(a.name))
764
+ break
765
+ case 'boolean-shorthand':
766
+ case 'jsx-children':
767
+ // Never legal on an intrinsic element in well-formed IR — emit nothing.
768
+ break
769
+ case 'expression':
770
+ case 'template': {
771
+ const attrKey = node.slotId ? `${node.slotId}::${a.name}` : null
772
+ if (!attrKey || !safe.reactiveAttrKeys.has(attrKey)) return null
773
+ // Covered by a loop-child createEffect — omit from the skeleton
774
+ // entirely; the effect's eager first run fills it in.
775
+ break
776
+ }
777
+ case 'spread':
778
+ return null
779
+ }
780
+ }
781
+
782
+ if (node.slotId) attrParts.push(`bf="${node.slotId}"`)
783
+
784
+ const attrs = attrParts.join(' ')
785
+ let children = ''
786
+ for (const child of node.children) {
787
+ const rendered = buildLoopSkeletonTemplate(child, safe)
788
+ if (rendered === null) return null
789
+ children += rendered
790
+ }
791
+
792
+ if (children || !VOID_ELEMENTS.has(node.tag)) {
793
+ return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
794
+ }
795
+ return `<${node.tag}${attrs ? ' ' + attrs : ''} />`
796
+ }
797
+
798
+ case 'text':
799
+ return node.value
800
+
801
+ case 'expression':
802
+ if (node.expr === 'null' || node.expr === 'undefined') return ''
803
+ if (!node.slotId) {
804
+ // No DOM anchor to backfill later — this is a one-time SSR-baked
805
+ // value with no corresponding createEffect. Can't safely omit.
806
+ return null
807
+ }
808
+ if (!safe.reactiveTextSlotIds.has(node.slotId)) return null
809
+ return `<!--bf:${node.slotId}--><!--/-->`
810
+
811
+ case 'fragment': {
812
+ let out = ''
813
+ for (const child of node.children) {
814
+ const rendered = buildLoopSkeletonTemplate(child, safe)
815
+ if (rendered === null) return null
816
+ out += rendered
817
+ }
818
+ return out
819
+ }
820
+
821
+ // Conditionals, child components, nested loops, and provider/async/
822
+ // if-statement/slot boundaries are all out of scope for the hoisted
823
+ // fast path — the caller falls back to `irToHtmlTemplate`.
824
+ case 'conditional':
825
+ case 'component':
826
+ case 'loop':
827
+ case 'if-statement':
828
+ case 'provider':
829
+ case 'async':
830
+ case 'slot':
831
+ return null
832
+
833
+ default:
834
+ return assertNever(node)
835
+ }
836
+ }
837
+
705
838
  /**
706
839
  * Generate an HTML template for composite element reconciliation.
707
840
  * Identical to irToHtmlTemplate except component nodes become placeholder
@@ -1382,7 +1515,71 @@ export function generateCsrTemplate(
1382
1515
  }
1383
1516
  }
1384
1517
  }
1385
- return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, insideLoop, unsafeLocalNames, deferredChildSlots, loopDepth: -1 })
1518
+ const effectiveUnsafeLocalNames = mergeCsrNullUnsafe(ctx, unsafeLocalNames)
1519
+ return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, insideLoop, unsafeLocalNames: effectiveUnsafeLocalNames, deferredChildSlots, loopDepth: -1 })
1520
+ }
1521
+
1522
+ /**
1523
+ * Fold `ctx.csrInlinable`'s null verdicts into `unsafeLocalNames`, so
1524
+ * `generateCsrTemplate`'s callers can't independently drift out of sync
1525
+ * with `ctx.csrInlinable` (#2106).
1526
+ *
1527
+ * The two "is this constant's value safe to inline into a module-scope
1528
+ * template" checks in `compute-inlinability.ts` can legitimately disagree:
1529
+ *
1530
+ * - Stage-2 classification (`classifyConstantInitial`) runs
1531
+ * `isInlinableInTemplate` on the RAW initializer text — deliberately,
1532
+ * so it can still see bridged prop args (`useYjs(props.X)`) for the
1533
+ * #1138 rejection. A call whose raw receiver is an identifier path
1534
+ * (`someModuleArray.includes(name)`) can pass this check (e.g. an
1535
+ * adapter with a broad `acceptsTemplateCall` accepts any
1536
+ * identifier-path callee) even though `name` is a bridged prop arg.
1537
+ * - `populateCsrInlinable` re-runs the same check on the
1538
+ * CSR-*substituted* form, where `someModuleArray` has already been
1539
+ * literal-inlined (`['a','b'].includes(name)`). The callee is no
1540
+ * longer an identifier path at all, so the adapter can't vouch for
1541
+ * it, and the bridged-arg rejection correctly fires — recorded as
1542
+ * `ctx.csrInlinable.get(name) === null`.
1543
+ *
1544
+ * `unsafeLocalNames` (passed in from the Stage-2-derived
1545
+ * `toLegacyInlinability` result) only reflects the first, looser verdict,
1546
+ * so a name `populateCsrInlinable` refused could still be missing from
1547
+ * it. Left alone, `transformExpr` below finds no CSR substitution for the
1548
+ * name (`inlinableConstants` — built from `ctx.csrInlinable` by the
1549
+ * caller — excludes null entries) AND no unsafe flag, so the bare,
1550
+ * module-scope-invisible identifier leaks straight into the emitted
1551
+ * template text (`ReferenceError` at template evaluation, #2106).
1552
+ *
1553
+ * `ctx.csrInlinable` is the ground truth for the CSR path — this makes it
1554
+ * the single source added to (never subtracted from) the effective
1555
+ * unsafe set used below: any post-substitution refusal is unsafe here,
1556
+ * full stop, regardless of what the looser Stage-2 check concluded.
1557
+ * Doing this once, inside `generateCsrTemplate` itself, means every
1558
+ * caller (the full-init path in `emit-registration.ts` AND the
1559
+ * template-only path in `index.ts`) gets the correction for free instead
1560
+ * of each needing to remember to fold `ctx.csrInlinable` in themselves.
1561
+ *
1562
+ * System-construct (`createContext()`, `new WeakMap()`) and JSX-inline
1563
+ * constants are exempted even though `populateCsrInlinable` also marks
1564
+ * them `null` — that's "not applicable" routing (module-scope singleton
1565
+ * referenced by name at runtime / already inlined at IR level), not an
1566
+ * unsafe reference, and `toLegacyInlinability` never treated them as
1567
+ * unsafe either.
1568
+ */
1569
+ function mergeCsrNullUnsafe(ctx: ClientJsContext, unsafeLocalNames: Set<string> | undefined): Set<string> | undefined {
1570
+ let merged: Set<string> | null = null
1571
+ // Built lazily on the first null verdict, so the common all-inlinable
1572
+ // component pays nothing; a Set keeps the merge linear in the number
1573
+ // of local constants instead of a per-name `.find()` scan.
1574
+ let exemptNames: Set<string> | null = null
1575
+ for (const [name, entry] of ctx.csrInlinable) {
1576
+ if (entry !== null || unsafeLocalNames?.has(name)) continue
1577
+ exemptNames ??= new Set(ctx.localConstants.filter((c) => c.isJsx || c.systemConstructKind).map((c) => c.name))
1578
+ if (exemptNames.has(name)) continue
1579
+ if (!merged) merged = new Set(unsafeLocalNames ?? [])
1580
+ merged.add(name)
1581
+ }
1582
+ return merged ?? unsafeLocalNames
1386
1583
  }
1387
1584
 
1388
1585
  /**
@@ -171,6 +171,7 @@ function createContext(
171
171
  initStatements: ir.metadata.initStatements ?? [],
172
172
  localFunctions: ir.metadata.localFunctions,
173
173
  localConstants: ir.metadata.localConstants,
174
+ imports: ir.metadata.imports,
174
175
  propsParams: ir.metadata.propsParams,
175
176
  propsObjectName: ir.metadata.propsObjectName,
176
177
  restPropsName: ir.metadata.restPropsName,
@@ -19,6 +19,7 @@ import type {
19
19
  ConstantInfo,
20
20
  ParamInfo,
21
21
  CompilerError,
22
+ ImportInfo,
22
23
  } from '../types.ts'
23
24
  import type { CsrInlinabilityMap } from './csr-substitute.ts'
24
25
 
@@ -53,6 +54,15 @@ export interface ClientJsContext {
53
54
  initStatements: InitStatementInfo[]
54
55
  localFunctions: FunctionInfo[]
55
56
  localConstants: ConstantInfo[]
57
+ /**
58
+ * Component-file import list, threaded through so `buildEnvFromCtx`'s
59
+ * reconstructed `IRMetadata`-shaped object carries REAL imports (#2069)
60
+ * — `prepareLoweringMatchers` needs the real list to resolve a
61
+ * `LoweringPlugin`'s local import names (e.g. `queryHrefLocalNames`);
62
+ * an empty `imports: []` would silently disable every import-aware
63
+ * plugin for the client-JS inline-safety gate.
64
+ */
65
+ imports: ImportInfo[]
56
66
  propsParams: ParamInfo[]
57
67
  propsObjectName: string | null
58
68
  restPropsName: string | null
@@ -497,6 +507,18 @@ export interface TopLevelLoop extends LoopCore {
497
507
  * that becomes `[]` in the CSR template substitution.
498
508
  */
499
509
  staticItemTemplate?: string
510
+ /**
511
+ * Shared once-per-loop skeleton template (perf), built by
512
+ * `buildLoopSkeletonTemplate` when the loop body is a statically-analyzable
513
+ * single-root element tree whose only dynamic parts are text/attribute
514
+ * slots already covered by a loop-child `createEffect`. Present only for
515
+ * dynamic (`mapArray`) top-level loops that pass the safety predicate in
516
+ * `collect-elements.ts`'s `loop` visitor — `undefined` means "fall back to
517
+ * the per-row interpolated `template` above" (conditionals, spread attrs,
518
+ * multi-root bodies, nested components/loops, or any dynamic attr/text not
519
+ * proven covered by an effect).
520
+ */
521
+ skeletonTemplate?: string
500
522
  childEventHandlers: string[] // Bare-identifier event handler names (for the reachability graph)
501
523
  childComponent?: IRLoopChildComponent // For createComponent-based rendering
502
524
  nestedComponents?: IRLoopChildComponent[] // For nested components in loop bodies