@barefootjs/jsx 0.5.2 → 0.6.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 (53) hide show
  1. package/dist/adapters/parsed-expr-emitter.d.ts +1 -1
  2. package/dist/adapters/parsed-expr-emitter.d.ts.map +1 -1
  3. package/dist/combine-client-js.d.ts.map +1 -1
  4. package/dist/expression-parser.d.ts +1 -1
  5. package/dist/expression-parser.d.ts.map +1 -1
  6. package/dist/index.js +330 -70
  7. package/dist/ir-to-client-js/collect-elements.d.ts +26 -14
  8. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-loop.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts +8 -3
  11. package/dist/ir-to-client-js/control-flow/plan/event-delegation.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/emit-reactive.d.ts.map +1 -1
  13. package/dist/ir-to-client-js/generate-init.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/html-template.d.ts +30 -1
  15. package/dist/ir-to-client-js/html-template.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/imports.d.ts +2 -2
  17. package/dist/ir-to-client-js/imports.d.ts.map +1 -1
  18. package/dist/ir-to-client-js/phases/provider-and-child-inits.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts +3 -3
  20. package/dist/ir-to-client-js/plan/static-array-child-init.d.ts.map +1 -1
  21. package/dist/ir-to-client-js/types.d.ts +36 -4
  22. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  23. package/dist/ir-to-client-js/utils.d.ts +19 -1
  24. package/dist/ir-to-client-js/utils.d.ts.map +1 -1
  25. package/package.json +2 -2
  26. package/src/__tests__/__snapshots__/doc-examples.test.ts.snap +203 -203
  27. package/src/__tests__/child-components-in-map.test.ts +333 -0
  28. package/src/__tests__/combine-client-js.test.ts +47 -0
  29. package/src/__tests__/dangerously-set-inner-html.test.ts +82 -0
  30. package/src/__tests__/expression-parser.test.ts +167 -13
  31. package/src/__tests__/ir-to-client-js/reactivity.test.ts +1 -0
  32. package/src/__tests__/staged-ir/06-multi-stage-soak.test.ts +18 -3
  33. package/src/__tests__/static-loop-csr-materialize.test.ts +6 -4
  34. package/src/__tests__/text-slot-escaping.test.ts +56 -0
  35. package/src/adapters/parsed-expr-emitter.ts +7 -0
  36. package/src/combine-client-js.ts +66 -22
  37. package/src/expression-parser.ts +200 -17
  38. package/src/ir-to-client-js/collect-elements.ts +170 -32
  39. package/src/ir-to-client-js/control-flow/plan/build-event-delegation.ts +1 -1
  40. package/src/ir-to-client-js/control-flow/plan/build-loop.ts +2 -1
  41. package/src/ir-to-client-js/control-flow/plan/event-delegation.ts +8 -3
  42. package/src/ir-to-client-js/control-flow/stringify/event-delegation.ts +3 -3
  43. package/src/ir-to-client-js/emit-reactive.ts +9 -0
  44. package/src/ir-to-client-js/emit-registration.ts +1 -1
  45. package/src/ir-to-client-js/generate-init.ts +16 -1
  46. package/src/ir-to-client-js/html-template.ts +238 -12
  47. package/src/ir-to-client-js/imports.ts +1 -1
  48. package/src/ir-to-client-js/index.ts +1 -0
  49. package/src/ir-to-client-js/phases/provider-and-child-inits.ts +12 -1
  50. package/src/ir-to-client-js/plan/build-static-array-child-init.ts +4 -8
  51. package/src/ir-to-client-js/plan/static-array-child-init.ts +3 -3
  52. package/src/ir-to-client-js/types.ts +37 -4
  53. package/src/ir-to-client-js/utils.ts +41 -1
@@ -32,7 +32,7 @@
32
32
  * sentinel before destructuring (#951 TDZ-safe).
33
33
  */
34
34
 
35
- import { toDomEventName, varSlotId, substituteLoopBindings, DATA_KEY, keyAttrName } from '../../utils'
35
+ import { toDomEventName, varSlotId, substituteLoopBindings, buildLoopChildIndexSubtraction, DATA_KEY, keyAttrName } from '../../utils'
36
36
  import type {
37
37
  EventDelegationPlan,
38
38
  KeyedItemLookup,
@@ -187,11 +187,11 @@ function emitStaticIndexLookup(
187
187
  lookup: StaticIndexItemLookup,
188
188
  containerVar: string,
189
189
  ): void {
190
- const { arrayExpr, param, mapPreamble, siblingOffset } = lookup
190
+ const { arrayExpr, param, mapPreamble, offset } = lookup
191
191
  ls.push(` let __el = ${varSlotId(ev.childSlotId)}El`)
192
192
  ls.push(` while (__el.parentElement && __el.parentElement !== ${containerVar}) __el = __el.parentElement`)
193
193
  ls.push(` if (__el.parentElement === ${containerVar}) {`)
194
- const idxOffset = siblingOffset ? ` - ${siblingOffset}` : ''
194
+ const idxOffset = buildLoopChildIndexSubtraction(offset ?? undefined)
195
195
  ls.push(` const __idx = Array.from(${containerVar}.children).indexOf(__el)${idxOffset}`)
196
196
  ls.push(` const ${param} = ${arrayExpr}[__idx]`)
197
197
  if (mapPreamble) ls.push(` ${mapPreamble}`)
@@ -17,6 +17,15 @@ import { createTemplateAwareStringProtector } from './html-template'
17
17
  */
18
18
  export function emitAttrUpdate(target: string, attrName: string, expression: string, meta: AttrMeta): string[] {
19
19
  const htmlName = toHtmlAttrName(attrName)
20
+ if (attrName === 'dangerouslySetInnerHTML' || htmlName === 'dangerouslySetInnerHTML') {
21
+ // `{ __html }` is not an attribute — it replaces the element's content.
22
+ // Assign `innerHTML` (raw, intentional escape hatch) to mirror the SSR
23
+ // adapters' native `dangerouslySetInnerHTML` handling instead of
24
+ // stringifying the object into a bogus attribute.
25
+ return [
26
+ `{ const __v = ${expression}; ${target}.innerHTML = __v != null && __v.__html != null ? String(__v.__html) : '' }`,
27
+ ]
28
+ }
20
29
  if (htmlName === 'style') {
21
30
  return [
22
31
  `{ const __v = styleToCss(${expression}); if (__v != null) ${target}.setAttribute('style', __v); else ${target}.removeAttribute('style') }`,
@@ -168,7 +168,7 @@ export function emitRegistrationAndHydration(
168
168
  // transformation runs at this layer (#1277).
169
169
  const csrInlinableConstants = csrInlinableConstantsFromCtx(ctx)
170
170
  const templateHtml = generateCsrTemplate(
171
- _ir.root, csrInlinableConstants, ctx, undefined, restSpreadNames, ctx.propsObjectName, unsafeLocalNames
171
+ _ir.root, csrInlinableConstants, ctx, undefined, restSpreadNames, ctx.propsObjectName, unsafeLocalNames, ctx.deferredChildSlots
172
172
  )
173
173
  if (templateHtml) {
174
174
  defParts.push(`template: (${PROPS_PARAM}) => \`${templateHtml}\``)
@@ -14,7 +14,8 @@ import { PROPS_PARAM } from './utils'
14
14
  import { buildReferencesGraph } from './build-references'
15
15
  import { computePropUsage } from './compute-prop-usage'
16
16
  import { IMPORT_PLACEHOLDER, MODULE_CONSTANTS_PLACEHOLDER } from './imports'
17
- import { emitRegistrationAndHydration } from './emit-registration'
17
+ import { emitRegistrationAndHydration, csrInlinableConstantsFromCtx } from './emit-registration'
18
+ import { computeDeferredChildSlots } from './html-template'
18
19
  import { emitChildComponentImports } from './child-components'
19
20
  import { classifyLocalDeclarations } from './init-declarations'
20
21
  import { emitModuleLevelDeclarations, resolveFinalImports } from './emit-module-level'
@@ -55,6 +56,20 @@ export function generateInitFunction(
55
56
  // duplicate warnings (#1247).
56
57
  const inlinability = buildInlinableConstants(ctx, graph, ir.root)
57
58
 
59
+ // Decide which direct child components must defer their render to init
60
+ // because a forwarded prop resolves to an init-scope-only / non-inlinable
61
+ // local (dropped-prop fix). The child-init phase reads this set to emit
62
+ // `upsertChild` instead of `initChild`; `emitRegistrationAndHydration`
63
+ // reads it to emit a `data-bf-ph` placeholder instead of
64
+ // `renderChild(...)`. Computed here, once `unsafeLocalNames` is known.
65
+ ctx.deferredChildSlots = computeDeferredChildSlots(
66
+ ir.root,
67
+ ctx,
68
+ csrInlinableConstantsFromCtx(ctx),
69
+ inlinability.unsafeLocalNames,
70
+ ctx.propsObjectName,
71
+ )
72
+
58
73
  // --- Emission: declarative phase pipeline. Each entry in `PHASES`
59
74
  // declares its inputs (dependsOn) and emission action (run); the
60
75
  // stable topological execution preserves the legacy by-position
@@ -2,7 +2,7 @@
2
2
  * IR → HTML template string generation and validation.
3
3
  */
4
4
 
5
- import type { AttrValue, IRAttribute, IRNode } from '../types'
5
+ import type { AttrValue, IRAttribute, IRNode, IRProp } from '../types'
6
6
  import { isBooleanAttr } from '../html-constants'
7
7
  import { toHtmlAttrName, attrValueToString, quotePropName, PROPS_PARAM, DATA_BF_PH, keyAttrName, loopStartMarker, loopEndMarker, loopItemMarker, freeIdsFromRefs, setIntersects, wrapExprWithLoopParams } from './utils'
8
8
  import type { LoopParamSpec } from './utils'
@@ -192,11 +192,16 @@ const UNSAFE_TEMPLATE_EXPR = 'undefined'
192
192
  * @param attr - Attribute metadata (only presenceOrUndefined flag is used)
193
193
  */
194
194
  function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined?: boolean): string {
195
+ // `dangerouslySetInnerHTML={{ __html }}` is not an attribute — its value
196
+ // becomes the element's raw innerHTML (emitted as element content). Never
197
+ // serialise the `{ __html }` object into a `dangerouslySetInnerHTML="…"`
198
+ // attribute.
199
+ if (attrName === 'dangerouslySetInnerHTML') return ''
195
200
  if (isBooleanAttr(attrName) || presenceOrUndefined) {
196
201
  return `\${${valExpr} ? '${attrName}' : ''}`
197
202
  }
198
203
  if (attrName === 'style') {
199
- return `\${((v) => v != null ? 'style="' + v + '"' : '')(styleToCss(${valExpr}))}`
204
+ return `\${((v) => v != null ? 'style="' + ${escapeAttrValueExpr('v')} + '"' : '')(styleToCss(${valExpr}))}`
200
205
  }
201
206
  // `data-key` / `data-key-N` is a reconciliation contract — every loop item
202
207
  // must carry one. Emit unconditionally; if the user passes `key={undefined}`
@@ -205,7 +210,60 @@ function templateAttrExpr(attrName: string, valExpr: string, presenceOrUndefined
205
210
  if (attrName === 'data-key' || attrName.startsWith('data-key-')) {
206
211
  return `${attrName}="\${${valExpr}}"`
207
212
  }
208
- return `\${(${valExpr}) != null ? '${attrName}="' + (${valExpr}) + '"' : ''}`
213
+ return `\${(${valExpr}) != null ? '${attrName}="' + ${escapeAttrValueExpr(valExpr)} + '"' : ''}`
214
+ }
215
+
216
+ /**
217
+ * Build a runtime expression that HTML-escapes an interpolated attribute
218
+ * value, matching the SSR adapters' attribute escaping (Hono escapes
219
+ * `& " ' < >`) via the `escapeAttr` runtime helper. The client template
220
+ * assembles an HTML string inserted via `innerHTML`, so an unescaped `"`
221
+ * / `<` / `>` in a value — e.g. UnoCSS arbitrary variants like
222
+ * `[class*="size-"]` or `has-[>svg]` — corrupts attribute parsing (and
223
+ * diverges from the SSR-rendered bytes). Escaping at interpolation time
224
+ * is the only correct layer: a post-assembly pass can't tell a delimiter
225
+ * `"` from a value `"`.
226
+ */
227
+ function escapeAttrValueExpr(valExpr: string): string {
228
+ return `escapeAttr(${valExpr})`
229
+ }
230
+
231
+ /**
232
+ * Build a runtime expression that HTML-escapes an interpolated **text
233
+ * content** slot, via the `escapeText` runtime helper. Only the
234
+ * `<!--bf:sN-->${expr}<!--/-->` text-marker form is text content: the
235
+ * runtime treats whatever sits between the markers as the slot's text, so
236
+ * a string value containing `<` / `&` (e.g. `{user.name}`) must be escaped
237
+ * to parse correctly under `innerHTML` and to match the SSR-rendered
238
+ * bytes. Bare `${...}` interpolations — `{children}` passthrough and
239
+ * `renderChild(...)` output — are pre-rendered HTML and must NOT be
240
+ * escaped, so this is applied only at the four text-marker emit sites.
241
+ * Hono escapes text content with the same set as attribute values
242
+ * (`& " ' < >`), so `escapeText` delegates to the same operation.
243
+ */
244
+ function escapeTextSlotExpr(innerExpr: string): string {
245
+ return `escapeText(${innerExpr})`
246
+ }
247
+
248
+ /**
249
+ * `dangerouslySetInnerHTML={{ __html: E }}` makes the element's content its
250
+ * raw innerHTML — the intentional, React-style escape hatch. Returns the
251
+ * raw-content template expression to use *instead of* the element's normal
252
+ * children (emitted UNescaped by design, mirroring the SSR adapters'
253
+ * native handling), or `null` when the element carries no such attribute.
254
+ * The attribute itself is suppressed in `templateAttrExpr`, and the
255
+ * matching reactive update is emitted by `emitAttrUpdate` (assigns
256
+ * `innerHTML`). `toExpr` is the walker's value transform (`wrapExpr` /
257
+ * `transformExpr`) so the `{ __html }` object is lowered the same way an
258
+ * attribute value would be.
259
+ */
260
+ function dangerouslyHtmlChildren(
261
+ attrs: ReadonlyArray<IRAttribute>,
262
+ toExpr: (v: { expr: string; templateExpr?: string }) => string,
263
+ ): string | null {
264
+ const attr = attrs.find(a => a.name === 'dangerouslySetInnerHTML')
265
+ if (!attr || attr.value.kind !== 'expression') return null
266
+ return `\${((${toExpr(attr.value)}) ?? {}).__html ?? ''}`
209
267
  }
210
268
 
211
269
  /**
@@ -322,6 +380,12 @@ export interface MergeContext {
322
380
  function isMergeableAttr(a: IRAttribute, ctx: MergeContext): boolean {
323
381
  if (ctx.honorClientOnly && a.clientOnly) return false
324
382
  if (a.name === 'key') return false
383
+ // `dangerouslySetInnerHTML` is not an attribute — its `{ __html }` value
384
+ // becomes the element's raw innerHTML (emitted as content, set via
385
+ // `innerHTML` in init). Keep it out of the `spreadAttrs({...})` merge so
386
+ // it isn't serialised back into a bogus `dangerouslySetInnerHTML="…"`
387
+ // attribute when the element also carries a spread.
388
+ if (a.name === 'dangerouslySetInnerHTML') return false
325
389
  const v = a.value
326
390
  if (v.kind === 'jsx-children') return false
327
391
  if (v.kind === 'boolean-shorthand') return false
@@ -476,7 +540,7 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
476
540
 
477
541
  const attrs = attrParts.join(' ')
478
542
  const childrenRecurse = (n: IRNode): string => irToHtmlTemplate(n, restSpreadNames, loopDepth, loopParams, branchSlotsVar, insideLoop, false)
479
- const children = node.children.map(childrenRecurse).join('')
543
+ const children = dangerouslyHtmlChildren(node.attrs, v => wrapExpr(v.expr)) ?? node.children.map(childrenRecurse).join('')
480
544
 
481
545
  // Non-void elements must use open+close tags (HTML parsers ignore self-closing on div, span, etc.)
482
546
  if (children || !VOID_ELEMENTS.has(node.tag)) {
@@ -491,7 +555,15 @@ export function irToHtmlTemplate(node: IRNode, restSpreadNames?: Set<string>, lo
491
555
  case 'expression':
492
556
  if (node.expr === 'null' || node.expr === 'undefined') return ''
493
557
  if (node.slotId) {
494
- return `<!--bf:${node.slotId}-->\${${wrapInterpolation(wrapExpr(node.expr))}}<!--/-->`
558
+ const inner = wrapInterpolation(wrapExpr(node.expr))
559
+ // In branch-slot context `wrapInterpolation` routes the value
560
+ // through `__bfSlot`, which returns raw `<!--bf-slot:N-->` markers
561
+ // for live `Node` values (spliced back by `insert()`). Escaping
562
+ // would corrupt those markers and drop slotted content (#1694
563
+ // regression). `__bfSlot` owns coercion of its own value, so the
564
+ // text-escape applies only to the non-slot (plain text) form.
565
+ const slotted = branchSlotsVar ? inner : escapeTextSlotExpr(inner)
566
+ return `<!--bf:${node.slotId}-->\${${slotted}}<!--/-->`
495
567
  }
496
568
  return `\${${wrapInterpolation(wrapExpr(node.expr))}}`
497
569
 
@@ -656,7 +728,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
656
728
  }
657
729
 
658
730
  const attrs = attrParts.join(' ')
659
- const children = node.children.map(recurse).join('')
731
+ const children = dangerouslyHtmlChildren(node.attrs, v => wrapExpr(v.expr)) ?? node.children.map(recurse).join('')
660
732
 
661
733
  if (children || !VOID_ELEMENTS.has(node.tag)) {
662
734
  return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
@@ -670,7 +742,7 @@ export function irToPlaceholderTemplate(node: IRNode, restSpreadNames?: Set<stri
670
742
  case 'expression':
671
743
  if (node.expr === 'null' || node.expr === 'undefined') return ''
672
744
  if (node.slotId) {
673
- return `<!--bf:${node.slotId}-->\${${wrapExpr(node.expr)}}<!--/-->`
745
+ return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(wrapExpr(node.expr))}}<!--/-->`
674
746
  }
675
747
  return `\${${wrapExpr(node.expr)}}`
676
748
 
@@ -890,6 +962,20 @@ export interface TemplateOptions {
890
962
  loopDepth?: number
891
963
  /** Emit `bf-s` placeholder on scoped elements inside a jsx-children prop (#1320). */
892
964
  inHoistedChildren?: boolean
965
+ /**
966
+ * Slot ids of direct child components whose render must be DEFERRED to
967
+ * init because at least one forwarded (non-`/* @client *\/`) prop value
968
+ * references an init-scope-only / non-inlinable local — the module-scope
969
+ * template lambda can't supply it, so eagerly calling `renderChild` with
970
+ * the prop dropped would make the child template read `undefined`.
971
+ *
972
+ * For these slots the CSR `component` case emits a `data-bf-ph`
973
+ * placeholder instead of `renderChild(...)`; the parent init replaces it
974
+ * via `upsertChild` (→ `createComponent` with the complete getter props).
975
+ * Computed up front by `computeDeferredChildSlots` so the init phase and
976
+ * the template phase agree on which children defer (dropped-prop fix).
977
+ */
978
+ deferredChildSlots?: ReadonlySet<string>
893
979
  }
894
980
 
895
981
  /**
@@ -1041,7 +1127,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1041
1127
  }
1042
1128
 
1043
1129
  const attrs = attrParts.join(' ')
1044
- const children = node.children.map(childrenRecurse).join('')
1130
+ const children = dangerouslyHtmlChildren(node.attrs, v => transformExpr(v.expr, v.templateExpr)) ?? node.children.map(childrenRecurse).join('')
1045
1131
 
1046
1132
  if (children || !VOID_ELEMENTS.has(node.tag)) {
1047
1133
  return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
@@ -1055,7 +1141,7 @@ function irToComponentTemplateWithOpts(node: IRNode, opts: TemplateOptions): str
1055
1141
  case 'expression':
1056
1142
  if (node.expr === 'null' || node.expr === 'undefined') return ''
1057
1143
  if (node.slotId) {
1058
- return `<!--bf:${node.slotId}-->\${${transformExpr(node.expr, node.templateExpr)}}<!--/-->`
1144
+ return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(transformExpr(node.expr, node.templateExpr))}}<!--/-->`
1059
1145
  }
1060
1146
  return `\${${transformExpr(node.expr, node.templateExpr)}}`
1061
1147
 
@@ -1281,6 +1367,7 @@ export function generateCsrTemplate(
1281
1367
  restSpreadNames?: Set<string>,
1282
1368
  propsObjectName?: string | null,
1283
1369
  unsafeLocalNames?: Set<string>,
1370
+ deferredChildSlots?: ReadonlySet<string>,
1284
1371
  ): string {
1285
1372
  // Build the substitution env once per component. Signals + memos come
1286
1373
  // from `buildSignalMemoEnv`; inlinable constants layer in here so
@@ -1295,7 +1382,134 @@ export function generateCsrTemplate(
1295
1382
  }
1296
1383
  }
1297
1384
  }
1298
- return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, insideLoop, unsafeLocalNames, loopDepth: -1 })
1385
+ return generateCsrTemplateWithOpts(node, { inlinableConstants, restSpreadNames, propsObjectName, csrEnv, insideLoop, unsafeLocalNames, deferredChildSlots, loopDepth: -1 })
1386
+ }
1387
+
1388
+ /**
1389
+ * Build the per-component CSR substitution env (signals + memos + inlinable
1390
+ * constants), matching what `generateCsrTemplate` builds. Shared so the
1391
+ * deferred-child analysis and the template emit agree on substitution
1392
+ * results.
1393
+ */
1394
+ function buildCsrEnvForCtx(
1395
+ ctx: ClientJsContext,
1396
+ inlinableConstants: Map<string, string> | undefined,
1397
+ propsObjectName?: string | null,
1398
+ ): CsrEnv {
1399
+ const base = buildSignalMemoEnv(ctx.signals, ctx.memos, propsObjectName ?? null)
1400
+ const csrEnv: CsrEnv = { substitutions: new Map(base.substitutions), propsObjectName: base.propsObjectName }
1401
+ if (inlinableConstants) {
1402
+ for (const [name, value] of inlinableConstants) {
1403
+ if (!csrEnv.substitutions.has(name)) {
1404
+ csrEnv.substitutions.set(name, { kind: 'identifier', replacement: value, freeIdentifiers: new Set() })
1405
+ }
1406
+ }
1407
+ }
1408
+ return csrEnv
1409
+ }
1410
+
1411
+ /**
1412
+ * Decide whether a single forwarded component prop value would be DROPPED
1413
+ * by the CSR `component` emit — i.e. after `csrSubstitute` its expression
1414
+ * still references a name in `unsafeLocalNames`. Mirrors the
1415
+ * `transformExpr` UNSAFE gate so the deferral analysis matches the actual
1416
+ * template output exactly.
1417
+ */
1418
+ function propResolvesUnsafe(
1419
+ prop: IRProp,
1420
+ env: CsrEnv,
1421
+ unsafeLocalNames: ReadonlySet<string>,
1422
+ ): boolean {
1423
+ if (unsafeLocalNames.size === 0) return false
1424
+ let source: string | undefined
1425
+ switch (prop.value.kind) {
1426
+ case 'expression':
1427
+ case 'spread':
1428
+ source = prop.value.expr
1429
+ break
1430
+ case 'template':
1431
+ source = attrValueToString(prop.value, { useTemplate: true }) ?? undefined
1432
+ break
1433
+ default:
1434
+ // literal / boolean / jsx-children carry no init-scope identifiers.
1435
+ return false
1436
+ }
1437
+ if (!source) return false
1438
+ const { freeIdentifiers } = csrSubstitute(source, env)
1439
+ return setIntersects(freeIdentifiers, unsafeLocalNames)
1440
+ }
1441
+
1442
+ /**
1443
+ * Walk the component IR and collect the slot ids of DIRECT child
1444
+ * components whose render must be deferred to init because at least one
1445
+ * forwarded (non-`/* @client *\/`, non-event) prop resolves to an
1446
+ * init-scope-only / non-inlinable local. The module-scope CSR template
1447
+ * lambda can't supply such a value, so `renderChild(...)` would drop the
1448
+ * prop and the child template would read `undefined` and throw.
1449
+ *
1450
+ * Only top-level (non-loop, non-clientOnly-conditional) children are
1451
+ * considered — those are the ones rendered via the `renderChild(...)` form
1452
+ * in the registration template and wired through `ctx.childInits`. Loop /
1453
+ * conditional-branch children already go through their own
1454
+ * placeholder + `createComponent` materialize paths.
1455
+ */
1456
+ export function computeDeferredChildSlots(
1457
+ node: IRNode,
1458
+ ctx: ClientJsContext,
1459
+ inlinableConstants: Map<string, string> | undefined,
1460
+ unsafeLocalNames: ReadonlySet<string> | undefined,
1461
+ propsObjectName?: string | null,
1462
+ ): Set<string> {
1463
+ const deferred = new Set<string>()
1464
+ if (!unsafeLocalNames || unsafeLocalNames.size === 0) return deferred
1465
+ const env = buildCsrEnvForCtx(ctx, inlinableConstants, propsObjectName)
1466
+
1467
+ const visit = (n: IRNode): void => {
1468
+ switch (n.type) {
1469
+ case 'component': {
1470
+ if (n.name === 'Portal') {
1471
+ n.children.forEach(visit)
1472
+ return
1473
+ }
1474
+ if (n.slotId) {
1475
+ const dropped = n.props.some(p => {
1476
+ // Spread props (`...`) are forwarded via the rest-spread path
1477
+ // (`restSpreadNames`), not the per-prop inline form, so they are
1478
+ // out of scope for this drop check; `key` and event handlers
1479
+ // (`onX`) likewise never carry init-scope render values. This
1480
+ // filter set MUST mirror the `propsEntries` filter in the CSR
1481
+ // `component` emit below so the deferral decision matches output.
1482
+ if (p.name === '...' || p.name.startsWith('...') || p.name === 'key') return false
1483
+ if (p.name.startsWith('on') && p.name.length > 2 && p.name[2] === p.name[2].toUpperCase()) return false
1484
+ if (p.clientOnly) return false
1485
+ return propResolvesUnsafe(p, env, unsafeLocalNames)
1486
+ })
1487
+ if (dropped) deferred.add(n.slotId)
1488
+ }
1489
+ // Do not descend into a component's JSX-children props here: those
1490
+ // children render in the parent scope only when hoisted, and the
1491
+ // deferral concern is the direct child component's own props.
1492
+ return
1493
+ }
1494
+ case 'element':
1495
+ n.children.forEach(visit)
1496
+ return
1497
+ case 'fragment':
1498
+ n.children.forEach(visit)
1499
+ return
1500
+ case 'conditional':
1501
+ // Conditional branch children are handled by the branch
1502
+ // materialize path, not the top-level renderChild form.
1503
+ return
1504
+ case 'loop':
1505
+ // Loop children go through the loop materialize path.
1506
+ return
1507
+ default:
1508
+ return
1509
+ }
1510
+ }
1511
+ visit(node)
1512
+ return deferred
1299
1513
  }
1300
1514
 
1301
1515
  function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): string {
@@ -1417,7 +1631,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1417
1631
  }
1418
1632
 
1419
1633
  const attrs = attrParts.join(' ')
1420
- const children = node.children.map(childrenRecurse).join('')
1634
+ const children = dangerouslyHtmlChildren(node.attrs, v => transformExpr(v.expr, v.templateExpr)) ?? node.children.map(childrenRecurse).join('')
1421
1635
 
1422
1636
  if (children || !VOID_ELEMENTS.has(node.tag)) {
1423
1637
  return `<${node.tag}${attrs ? ' ' + attrs : ''}>${children}</${node.tag}>`
@@ -1440,7 +1654,7 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1440
1654
  // an empty placeholder instead.
1441
1655
  const expr = transformed === UNSAFE_TEMPLATE_EXPR ? "''" : transformed
1442
1656
  if (node.slotId) {
1443
- return `<!--bf:${node.slotId}-->\${${expr}}<!--/-->`
1657
+ return `<!--bf:${node.slotId}-->\${${escapeTextSlotExpr(expr)}}<!--/-->`
1444
1658
  }
1445
1659
  return `\${${expr}}`
1446
1660
  }
@@ -1470,6 +1684,18 @@ function generateCsrTemplateWithOpts(node: IRNode, opts: TemplateOptions): strin
1470
1684
  return node.children.map(recurse).join('')
1471
1685
  }
1472
1686
 
1687
+ // Deferred child (dropped-prop fix): at least one forwarded prop
1688
+ // resolves to an init-scope-only local the module-scope template
1689
+ // lambda can't supply. Emitting `renderChild('Child', { /* prop
1690
+ // dropped */ })` would make the child template read `undefined` and
1691
+ // throw. Emit a `data-bf-ph` placeholder instead — the parent init
1692
+ // resolves it via `upsertChild` → `createComponent` with the full
1693
+ // getter props (mirrors the `irToPlaceholderTemplate` deferral and
1694
+ // the clientOnly-conditional empty-marker precedent).
1695
+ if (node.slotId && opts.deferredChildSlots?.has(node.slotId)) {
1696
+ return `<div ${DATA_BF_PH}="${node.slotId}"></div>`
1697
+ }
1698
+
1473
1699
  const propsEntries = node.props
1474
1700
  .filter(p => p.name !== '...' && !p.name.startsWith('...') && p.name !== 'key')
1475
1701
  .filter(p => !(p.name.startsWith('on') && p.name.length > 2 && p.name[2] === p.name[2].toUpperCase()))
@@ -11,7 +11,7 @@ export const RUNTIME_IMPORT_CANDIDATES = [
11
11
  'createComponent', 'renderChild', 'registerComponent', 'registerTemplate', 'initChild', 'upsertChild', 'updateClientMarker',
12
12
  'createPortal',
13
13
  'provideContext', 'createContext', 'useContext',
14
- 'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss',
14
+ 'forwardProps', 'applyRestAttrs', 'splitProps', 'spreadAttrs', 'styleToCss', 'escapeAttr', 'escapeText',
15
15
  'qsa', 'qsaItem', 'qsaChildScope', 'qsaChildScopes', 'upsertChildItem', '__slot', '__bfSlot', '__bfText',
16
16
  ] as const
17
17
 
@@ -176,6 +176,7 @@ function createContext(
176
176
  loopElements: [],
177
177
  refElements: [],
178
178
  childInits: [],
179
+ deferredChildSlots: new Set(),
179
180
  reactiveProps: [],
180
181
  reactiveChildProps: [],
181
182
  reactiveAttrs: [],
@@ -28,8 +28,19 @@ export function emitProviderAndChildInits(lines: string[], ctx: ClientJsContext)
28
28
  lines.push('')
29
29
  lines.push(` // Initialize child components with props`)
30
30
  for (const child of ctx.childInits) {
31
+ const registryName = nameForRegistryRef(child.name)
32
+ // Deferred child (dropped-prop fix): the registration template emits
33
+ // a `data-bf-ph` placeholder for this slot rather than rendering it.
34
+ // `upsertChild` resolves both shapes — an existing SSR scope (→
35
+ // initChild) or the placeholder (→ createComponent with the full
36
+ // getter props). Use it so the child is created/initialised with
37
+ // complete props instead of running against a missing prop.
38
+ if (child.slotId && ctx.deferredChildSlots.has(child.slotId)) {
39
+ lines.push(` upsertChild(__scope, '${registryName}', '${child.slotId}', ${child.propsExpr})`)
40
+ continue
41
+ }
31
42
  const scopeRef = child.slotId ? `_${varSlotId(child.slotId)}` : '__scope'
32
- lines.push(` initChild('${nameForRegistryRef(child.name)}', ${scopeRef}, ${child.propsExpr})`)
43
+ lines.push(` initChild('${registryName}', ${scopeRef}, ${child.propsExpr})`)
33
44
  }
34
45
  }
35
46
  }
@@ -16,7 +16,7 @@
16
16
  import type { IRLoopChildComponent } from '../../types'
17
17
  import type { NestedLoop, TopLevelLoop } from '../types'
18
18
  import type { ClientJsContext } from '../types'
19
- import { quotePropName, varSlotId, attrValueToString } from '../utils'
19
+ import { quotePropName, varSlotId, attrValueToString, buildLoopChildIndexExpr } from '../utils'
20
20
  import { irChildrenToJsExpr } from '../html-template'
21
21
  import { buildCompSelector } from '../control-flow/shared'
22
22
 
@@ -98,7 +98,7 @@ function buildOuterNestedPlan(
98
98
  arrayExpr: elem.array,
99
99
  param: elem.param,
100
100
  indexParam,
101
- offsetExpr: elem.siblingOffset ? `${indexParam} + ${elem.siblingOffset}` : indexParam,
101
+ offsetExpr: buildLoopChildIndexExpr(indexParam, elem.offset),
102
102
  outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
103
103
  propsExpr: buildStaticPropsExpr(comp.props),
104
104
  }
@@ -122,16 +122,12 @@ function buildInnerLoopNestedPlan(
122
122
  outerArrayExpr: elem.array,
123
123
  outerParam: elem.param,
124
124
  outerIndexParam,
125
- outerOffsetExpr: elem.siblingOffset
126
- ? `${outerIndexParam} + ${elem.siblingOffset}`
127
- : outerIndexParam,
125
+ outerOffsetExpr: buildLoopChildIndexExpr(outerIndexParam, elem.offset),
128
126
  outerPreludeStatements: elem.mapPreamble ? [elem.mapPreamble] : [],
129
127
  innerContainerSlotId: innerLoop.containerSlotId ?? null,
130
128
  innerArrayExpr: innerLoop.array,
131
129
  innerParam: innerLoop.param,
132
- innerOffsetExpr: innerLoop.siblingOffset
133
- ? `__innerIdx + ${innerLoop.siblingOffset}`
134
- : '__innerIdx',
130
+ innerOffsetExpr: buildLoopChildIndexExpr('__innerIdx', innerLoop.offset),
135
131
  innerPreludeStatements: innerLoop.mapPreamble ? [innerLoop.mapPreamble] : [],
136
132
  depth: innerLoop.depth,
137
133
  comps,
@@ -75,7 +75,7 @@ export interface OuterNestedInitPlan {
75
75
  arrayExpr: string
76
76
  param: string
77
77
  indexParam: string
78
- /** `indexParam` or `${indexParam} + ${siblingOffset}` — already substituted. */
78
+ /** `indexParam` plus any sibling-offset terms (`+ 1 + (arr).length`) — already substituted. */
79
79
  offsetExpr: string
80
80
  /**
81
81
  * Outer `.map()` callback preamble locals (#1064), emitted inside the
@@ -99,7 +99,7 @@ export interface InnerLoopNestedInitPlan {
99
99
  outerArrayExpr: string
100
100
  outerParam: string
101
101
  outerIndexParam: string
102
- /** Outer offset — `outerIndexParam` or `${outerIndexParam} + ${siblingOffset}`. */
102
+ /** Outer offset — `outerIndexParam` plus any sibling-offset terms. */
103
103
  outerOffsetExpr: string
104
104
  /**
105
105
  * Outer `.map()` callback preamble locals, emitted after the
@@ -115,7 +115,7 @@ export interface InnerLoopNestedInitPlan {
115
115
  innerContainerSlotId: string | null
116
116
  innerArrayExpr: string
117
117
  innerParam: string
118
- /** Inner offset — `__innerIdx` or `__innerIdx + ${siblingOffset}`. */
118
+ /** Inner offset — `__innerIdx` plus any sibling-offset terms. */
119
119
  innerOffsetExpr: string
120
120
  /**
121
121
  * Inner `.map()` callback preamble locals, emitted after the
@@ -59,6 +59,16 @@ export interface ClientJsContext {
59
59
  loopElements: TopLevelLoop[]
60
60
  refElements: RefElement[]
61
61
  childInits: ChildInit[]
62
+ /**
63
+ * Slot ids of direct child components whose render is DEFERRED to init
64
+ * (dropped-prop fix). For these, the CSR registration template emits a
65
+ * `data-bf-ph` placeholder instead of `renderChild(...)`, and the init
66
+ * body uses `upsertChild` (→ `createComponent` with full getter props)
67
+ * instead of `initChild`. Computed in `generateInitFunction` once
68
+ * `unsafeLocalNames` is known, then read by the child-init phase and the
69
+ * registration-template emit so both agree on which children defer.
70
+ */
71
+ deferredChildSlots: Set<string>
62
72
  reactiveProps: ReactiveComponentProp[]
63
73
  reactiveChildProps: ReactiveChildProp[]
64
74
  reactiveAttrs: ReactiveAttribute[]
@@ -166,6 +176,29 @@ export interface ConditionalBranchReactiveAttr extends AttrMeta {
166
176
  expression: string
167
177
  }
168
178
 
179
+ /**
180
+ * How many element children precede a loop's items in its container — the
181
+ * offset applied to `container.children[idx]` so each item resolves to the
182
+ * right element during hydration.
183
+ *
184
+ * Two contributions, kept distinct because they codegen differently:
185
+ * - `staticCount`: siblings of statically-known element count, folded to a
186
+ * compile-time integer (`+ 1`).
187
+ * - `dynamicTerms`: one JS expression per sibling whose element count is
188
+ * only known at runtime — a preceding `.map()` (`(arr).length`) or a
189
+ * preceding conditional (`(cond ? 1 : 0)`) — each added as `+ <term>`.
190
+ *
191
+ * Carried as one value object end-to-end (collect → IR loop → plan → codegen)
192
+ * so a future offset contributor is added in one place, not threaded as a new
193
+ * field through every layer (#1693).
194
+ */
195
+ export interface LoopOffset {
196
+ /** Folded element count of statically-sized preceding siblings. `0` = none. */
197
+ staticCount: number
198
+ /** Runtime element-count expressions of dynamic preceding siblings; `[]` = none. */
199
+ dynamicTerms: readonly string[]
200
+ }
201
+
169
202
  /**
170
203
  * Fields shared by every flavour of collected loop (top-level, branch-scoped, nested).
171
204
  * The three loop-info variants (`TopLevelLoop`, `BranchLoop`, `NestedLoop`) each extend
@@ -363,8 +396,8 @@ export interface NestedLoop extends LoopCore {
363
396
  childComponents?: import('../types').IRLoopChildComponent[]
364
397
  /** True when this loop is inside a conditional branch (handled by insert() bindEvents instead) */
365
398
  insideConditional?: boolean
366
- /** Number of non-loop DOM siblings before this loop in its container element */
367
- siblingOffset?: number
399
+ /** Offset of this loop's items past its preceding container siblings (#1693). */
400
+ offset?: LoopOffset
368
401
  // Per-item bindings (events / reactiveAttrs / reactiveTexts / refs / conditionals)
369
402
  // now live on `LoopCore.bindings` — see issue #1244 §B.
370
403
  }
@@ -468,8 +501,8 @@ export interface TopLevelLoop extends LoopCore {
468
501
  useElementReconciliation?: boolean // True: reconcileElements + composite rendering (native root with child components)
469
502
  /** Inner loop metadata for composite element reconciliation (array, param, key, container) */
470
503
  innerLoops?: NestedLoop[]
471
- /** Number of non-loop DOM siblings before this loop in its parent element. Used to offset children[idx] access. */
472
- siblingOffset?: number
504
+ /** Offset of this loop's items past its preceding container siblings (#1693). */
505
+ offset?: LoopOffset
473
506
  filterPredicate?: {
474
507
  param: string
475
508
  raw: string // Original filter predicate expression or block body