@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
@@ -46,6 +46,13 @@ export type ParsedExpr =
46
46
  | 'toLowerCase'
47
47
  | 'toUpperCase'
48
48
  | 'trim'
49
+ | 'split'
50
+ | 'startsWith'
51
+ | 'endsWith'
52
+ | 'replace'
53
+ | 'repeat'
54
+ | 'padStart'
55
+ | 'padEnd'
49
56
  object: ParsedExpr
50
57
  args: ParsedExpr[]
51
58
  }
@@ -225,12 +232,46 @@ const UNSUPPORTED_METHODS = new Set([
225
232
  // unsupported array methods above get), pointing users at the
226
233
  // `/* @client */` escape hatch. Each name drops off as its lowering
227
234
  // lands. See #1448 "Unsupported string methods" Tier B / Tier C.
228
- 'split', 'startsWith', 'endsWith', 'replace', 'replaceAll',
229
- 'repeat', 'padStart', 'padEnd',
235
+ // `split` is no longer here — `String.prototype.split(sep)` lowers
236
+ // via the `array-method` IR + `bf_split` (Go) / `bf->split` (Mojo),
237
+ // returning an array that composes with `.join()` / `.map()` / etc.
238
+ // See #1448 Tier B.
239
+ // `startsWith` / `endsWith` are no longer here — both lower via the
240
+ // `array-method` IR + `bf_starts_with` / `bf_ends_with` (Go) and
241
+ // `bf->starts_with` / `bf->ends_with` (Mojo). See #1448 Tier B.
242
+ // `replace` is no longer here — the string-pattern form lowers via
243
+ // the `array-method` IR + `bf_replace` (Go) / `bf->replace` (Mojo);
244
+ // the regex-pattern form is refused at the parse arm below (it would
245
+ // need the per-adapter regex-flavour decision). `replaceAll` stays
246
+ // refused. See #1448 Tier B.
247
+ // `repeat` is no longer here — `String.prototype.repeat(n)` lowers via
248
+ // the `array-method` IR + `bf_repeat` (Go) / `bf->repeat` (Mojo).
249
+ // See #1448 Tier B.
250
+ // `padStart` / `padEnd` are no longer here — both lower via the
251
+ // `array-method` IR + `bf_pad_start` / `bf_pad_end` (Go) and
252
+ // `bf->pad_start` / `bf->pad_end` (Mojo). See #1448 Tier B.
253
+ 'replaceAll',
230
254
  'charAt', 'charCodeAt', 'codePointAt', 'normalize',
231
255
  'substring', 'substr', 'match', 'matchAll', 'search',
232
256
  ])
233
257
 
258
+ // Methods that lower at their single-argument form but whose EXTRA
259
+ // argument is meaningful and NOT yet lowered: the `fromIndex` of
260
+ // `.includes` / `.indexOf` / `.lastIndexOf` (the 2-arg form) and the
261
+ // additional arrays of a variadic `.concat(a, b, …)`. The relaxed
262
+ // per-method arms in `convertNode` accept every method's zero-arg
263
+ // defaults (`.join()` / `.slice()` / `.concat()` / `.at()`) and
264
+ // JS-ignored trailing arguments; this guard catches only the remaining
265
+ // meaningful-extra forms, refusing them with BF101 because silently
266
+ // dropping the argument would make the SSR output differ from the
267
+ // client. See #1448.
268
+ const LOWERED_ARRAY_METHODS = new Set([
269
+ 'includes',
270
+ 'indexOf',
271
+ 'lastIndexOf',
272
+ 'concat',
273
+ ])
274
+
234
275
  // =============================================================================
235
276
  // Expression Parser
236
277
  // =============================================================================
@@ -344,7 +385,10 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
344
385
  // etc. later means widening the IR discriminator, not adding more
345
386
  // branches to every adapter's call dispatch.
346
387
  if (callee.kind === 'member' && !callee.computed) {
347
- if (callee.property === 'join' && args.length === 1) {
388
+ // `.join()` / `.join(sep)` JS defaults the separator to `,` when
389
+ // omitted and ignores any extra arguments. Accept every arity; the
390
+ // adapters supply the default separator and read only `args[0]`.
391
+ if (callee.property === 'join') {
348
392
  return { kind: 'array-method', method: 'join', object: callee.object, args }
349
393
  }
350
394
  // `.includes(x)` — shared between `Array.prototype.includes` and
@@ -369,20 +413,28 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
369
413
  // element). Go has `bf_at` registered already (see runtime
370
414
  // FuncMap); Mojo's `bf->at` wraps the same arithmetic.
371
415
  // See #1448 Tier A.
372
- if (callee.property === 'at' && args.length === 1) {
416
+ // `.at(i)` JS ignores any argument past the first, and `.at()`
417
+ // with no argument is `.at(0)` (the first element). Accept every
418
+ // arity; the adapters read `args[0]` (defaulting the index to 0).
419
+ if (callee.property === 'at') {
373
420
  return { kind: 'array-method', method: 'at', object: callee.object, args }
374
421
  }
375
- // `.concat(other)` — merges two arrays in order. Go uses
376
- // `bf_concat` (reflect-based append into `[]any`); Mojo uses
377
- // `bf->concat` (Perl list builder). Variadic shapes (`.concat(a, b)`)
378
- // are out of scope for this PR — gated to single-arg here.
379
- if (callee.property === 'concat' && args.length === 1) {
422
+ // `.concat()` / `.concat(other)` — `.concat()` returns a shallow
423
+ // copy (indistinguishable from the receiver in an SSR snapshot),
424
+ // and `.concat(other)` merges the two arrays. Go uses `bf_concat`
425
+ // (reflect-based append into `[]any`); Mojo uses `bf->concat`
426
+ // (Perl list builder). The VARIADIC form (`.concat(a, b, )`) is
427
+ // not lowered yet — it's refused by the guard below rather than
428
+ // silently dropping the extra arrays.
429
+ if (callee.property === 'concat' && args.length <= 1) {
380
430
  return { kind: 'array-method', method: 'concat', object: callee.object, args }
381
431
  }
382
- // `.slice(start)` / `.slice(start, end)` — both forms route
383
- // through `bf_slice` (Go) / `bf->slice` (Mojo); the helpers
384
- // treat a missing / undef `end` as "to length".
385
- if (callee.property === 'slice' && (args.length === 1 || args.length === 2)) {
432
+ // `.slice()` / `.slice(start)` / `.slice(start, end)` — route
433
+ // through `bf_slice` (Go) / `bf->slice` (Mojo). A missing `start`
434
+ // defaults to 0 (full copy), a missing / undef `end` means "to
435
+ // length", and JS ignores any third+ argument. Accept every arity;
436
+ // the adapters read only `args[0]` / `args[1]`.
437
+ if (callee.property === 'slice') {
386
438
  return { kind: 'array-method', method: 'slice', object: callee.object, args }
387
439
  }
388
440
  // `.reverse()` and `.toReversed()` — both zero-arg shapes
@@ -390,7 +442,8 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
390
442
  // of state, so JS's mutate-and-return-receiver (`reverse`)
391
443
  // vs return-new-array (`toReversed`) distinction has no
392
444
  // template-level meaning; both produce a new reversed array.
393
- if ((callee.property === 'reverse' || callee.property === 'toReversed') && args.length === 0) {
445
+ // JS takes no argument and ignores any that are passed.
446
+ if (callee.property === 'reverse' || callee.property === 'toReversed') {
394
447
  return { kind: 'array-method', method: callee.property, object: callee.object, args }
395
448
  }
396
449
  // `.toLowerCase()` — string-only (the IR carries a value-builtin
@@ -398,20 +451,150 @@ function convertNode(node: ts.Node, raw: string): ParsedExpr {
398
451
  // label is a misnomer for string methods but the mechanical
399
452
  // pipeline matches). Go uses the existing `bf_lower` helper;
400
453
  // Mojo uses Perl's native `lc`. See #1448 Tier A.
401
- if (callee.property === 'toLowerCase' && args.length === 0) {
454
+ if (callee.property === 'toLowerCase') {
402
455
  return { kind: 'array-method', method: 'toLowerCase', object: callee.object, args }
403
456
  }
404
457
  // `.toUpperCase()` — Go uses the existing `bf_upper` helper;
405
458
  // Mojo uses Perl's native `uc`.
406
- if (callee.property === 'toUpperCase' && args.length === 0) {
459
+ if (callee.property === 'toUpperCase') {
407
460
  return { kind: 'array-method', method: 'toUpperCase', object: callee.object, args }
408
461
  }
409
462
  // `.trim()` — Go uses the existing `bf_trim` helper; Mojo uses
410
463
  // a new `bf->trim` method that mirrors JS's "strip leading +
411
464
  // trailing whitespace" semantic via a Perl regex.
412
- if (callee.property === 'trim' && args.length === 0) {
465
+ if (callee.property === 'trim') {
413
466
  return { kind: 'array-method', method: 'trim', object: callee.object, args }
414
467
  }
468
+ // `.split()` / `.split(sep)` / `.split(sep, limit)` — string →
469
+ // array, full JS arity. `.split()` (no separator) returns the
470
+ // whole string as a single element; `.split(sep)` splits on the
471
+ // (literal) separator; the optional `limit` caps the number of
472
+ // pieces. JS ignores a third+ argument. Go uses `bf_split`
473
+ // (`strings.Split`, optional limit, normalised to `[]any`) and
474
+ // `bf_arr` for the no-separator whole-string case; Mojo uses
475
+ // `bf->split`. The regex-separator form stays refused (the parser
476
+ // never reaches here for it — a regex literal arg is `unsupported`
477
+ // and propagates). See #1448 Tier B.
478
+ if (callee.property === 'split') {
479
+ return { kind: 'array-method', method: 'split', object: callee.object, args }
480
+ }
481
+ // Arity guard for the forms whose EXTRA argument changes the
482
+ // result and is not yet lowered: the `fromIndex` of `.includes` /
483
+ // `.indexOf` / `.lastIndexOf` (the 2-arg form), and the additional
484
+ // arrays of a variadic `.concat(a, b, …)`. Silently dropping those
485
+ // would make the SSR output *differ* from the client (worse than a
486
+ // build error), so they refuse with BF101 until lowered. (The
487
+ // single-argument forms, zero-arg defaults, and JS-ignored
488
+ // trailing arguments of every method are accepted by the relaxed
489
+ // arms above.) See #1448.
490
+ if (LOWERED_ARRAY_METHODS.has(callee.property)) {
491
+ const argName = callee.property === 'concat' ? 'other' : 'x'
492
+ const detail =
493
+ callee.property === 'concat'
494
+ ? 'the variadic `.concat(a, b, …)` form'
495
+ : `\`.${callee.property}(…)\` with ${args.length} argument(s)`
496
+ return {
497
+ kind: 'unsupported',
498
+ raw,
499
+ reason: `${detail} is not yet lowered to the Go/Mojo template adapters. Use the single-argument \`.${callee.property}(${argName})\` form, or pre-compute the value before the template.`,
500
+ }
501
+ }
502
+ // `.startsWith(search, position?)` / `.endsWith(search, endPosition?)`
503
+ // — string → boolean, full JS arity. Go uses `bf_starts_with` /
504
+ // `bf_ends_with` (wrapping `strings.HasPrefix` / `strings.HasSuffix`,
505
+ // with an optional position that re-anchors the comparison); Mojo
506
+ // uses `bf->starts_with` / `bf->ends_with` (substr comparison). JS
507
+ // ignores a third+ argument. The zero-arg form (`.startsWith()`) is
508
+ // refused: JS coerces the missing search to the literal string
509
+ // "undefined", a degenerate result not worth lowering (mirrors the
510
+ // `.includes()` zero-arg refusal). See #1448 Tier B.
511
+ if (callee.property === 'startsWith' || callee.property === 'endsWith') {
512
+ if (args.length === 0) {
513
+ return {
514
+ kind: 'unsupported',
515
+ raw,
516
+ reason: `\`.${callee.property}()\` with no search string is not lowered — JS coerces the missing argument to the string "undefined", a degenerate result. Pass an explicit search string, or pre-compute the value before the template.`,
517
+ }
518
+ }
519
+ return { kind: 'array-method', method: callee.property, object: callee.object, args }
520
+ }
521
+ // `.replace(pattern, replacement)` — string-pattern form,
522
+ // replacing the FIRST occurrence (JS semantics for a string
523
+ // pattern). Go uses `bf_replace` (`strings.Replace` with n=1);
524
+ // Mojo uses `bf->replace` (index/substr splice, no regex). A
525
+ // regex-literal pattern parses as `unsupported` (convertNode has
526
+ // no regex arm), so it's refused explicitly here rather than
527
+ // emitting a broken `.Replace` — the Perl `s///` vs Go
528
+ // `regexp.ReplaceAllString` flavour gap is the open design
529
+ // question in #1448. `replaceAll` stays refused entirely.
530
+ //
531
+ // Full JS arity: a third+ argument is ignored (the adapter reads
532
+ // only the pattern + replacement). The one- and zero-argument
533
+ // forms are refused: JS coerces the missing replacement (and
534
+ // pattern) to the literal string "undefined", a degenerate result
535
+ // (mirrors the `.includes()` / `.startsWith()` zero-arg refusal).
536
+ if (callee.property === 'replace') {
537
+ if (args.length < 2) {
538
+ return {
539
+ kind: 'unsupported',
540
+ raw,
541
+ reason: `\`.replace(${args.length === 0 ? '' : 'pattern'})\` needs both a pattern and a replacement — JS coerces the missing argument to the string "undefined", a degenerate result. Pass both arguments, or pre-compute the value before the template.`,
542
+ }
543
+ }
544
+ // A regex-literal pattern is the deferred form (the Perl `s///`
545
+ // vs Go `regexp.ReplaceAllString` flavour gap, #1448) — detect it
546
+ // on the TS node so the message is accurate. Any OTHER unsupported
547
+ // pattern/replacement (an object literal, an unsupported call, …)
548
+ // surfaces ITS OWN reason rather than being mislabelled as the
549
+ // regex form.
550
+ const patternNode = node.arguments[0]
551
+ if (patternNode && ts.isRegularExpressionLiteral(patternNode)) {
552
+ return {
553
+ kind: 'unsupported',
554
+ raw,
555
+ reason:
556
+ 'String.prototype.replace supports only a string pattern + string replacement (the regex form is deferred); use a string pattern or wrap the expression in /* @client */',
557
+ }
558
+ }
559
+ const badArg =
560
+ args[0].kind === 'unsupported'
561
+ ? args[0]
562
+ : args[1].kind === 'unsupported'
563
+ ? args[1]
564
+ : undefined
565
+ if (badArg && badArg.kind === 'unsupported') {
566
+ return { kind: 'unsupported', raw, reason: badArg.reason }
567
+ }
568
+ return { kind: 'array-method', method: 'replace', object: callee.object, args }
569
+ }
570
+ // `.repeat(n)` — string → string (the receiver concatenated `n`
571
+ // times). Go uses `bf_repeat` (`strings.Repeat`, clamping a
572
+ // negative count to "" instead of panicking); Mojo uses
573
+ // `bf->repeat` (Perl's `x` operator). JS throws RangeError for a
574
+ // negative count, but SSR templates degrade to the empty string
575
+ // rather than crashing the render. See #1448 Tier B.
576
+ // Full JS arity: `.repeat()` (no count) is `repeat(0)` → "" (JS
577
+ // coerces the missing count to 0, not a RangeError), and a
578
+ // second+ argument is ignored. The adapter supplies the `0` for
579
+ // the no-argument form. See #1448 Tier B.
580
+ if (callee.property === 'repeat') {
581
+ return { kind: 'array-method', method: 'repeat', object: callee.object, args }
582
+ }
583
+ // `.padStart(target, pad?)` / `.padEnd(target, pad?)` — string →
584
+ // string, padded to `target` length with `pad` (default a single
585
+ // space) repeated + truncated to fill. Go uses `bf_pad_start` /
586
+ // `bf_pad_end`; Mojo uses `bf->pad_start` / `bf->pad_end`. Both
587
+ // count length in code points (Go runes / Perl chars) so they
588
+ // stay byte-equal — this differs from JS's UTF-16-unit length
589
+ // only for astral-plane receivers, which are vanishingly rare in
590
+ // numeric / space padding. See #1448 Tier B.
591
+ // Full JS arity: `.padStart()` (no target) is `padStart(0)` → the
592
+ // receiver unchanged (JS coerces the missing target to 0), and a
593
+ // third+ argument is ignored. The adapter supplies the `0` for the
594
+ // no-argument form and reads only target + padString.
595
+ if (callee.property === 'padStart' || callee.property === 'padEnd') {
596
+ return { kind: 'array-method', method: callee.property, object: callee.object, args }
597
+ }
415
598
  // `.sort(cmp)` / `.toSorted(cmp)` (#1448 Tier B). The comparator
416
599
  // is extracted into a structured `SortComparator` at parse time;
417
600
  // unrecognised shapes fall through to `unsupported` so adapters
@@ -3,55 +3,170 @@
3
3
  */
4
4
 
5
5
  import { type IRNode, type IRElement, type IRComponent, type IRLoop, type IRProp, pickAttrMetaFromIR } from '../types'
6
- import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, NestedLoop } from './types'
6
+ import type { ClientJsContext, ConditionalBranchChildComponent, ConditionalBranchReactiveAttr, BranchLoop, ConditionalBranchTextEffect, ConditionalElement, LoopChildBindings, LoopChildBranchSummary, LoopChildConditional, LoopOffset, NestedLoop } from './types'
7
7
  import { attrValueToString, freeIdsFromRefs, quotePropName, PROPS_PARAM } from './utils'
8
8
  import { classifyReactivity, decideWrapForAttr, decideWrapForChildProp, decideWrapFromAstFlags, collectEventHandlersFromIR, collectConditionalBranchEvents, collectConditionalBranchRefs, collectConditionalBranchChildComponents, collectLoopChildEventsWithNesting, collectLoopChildReactiveAttrs, collectLoopChildReactiveTexts, collectLoopChildRefs, emptyLoopChildBindings } from './reactivity'
9
9
  import { irToHtmlTemplate, irToPlaceholderTemplate, irChildrenToJsExpr } from './html-template'
10
10
  import { expandDynamicPropValue, expandConstantForReactivity } from './prop-handling'
11
11
  import { walkIR, stopAt } from './walker'
12
+ import { buildLoopChainExpr } from '../loop-chain'
12
13
 
13
- /** Check if an IR node produces a DOM child element (for sibling offset counting). */
14
- function producesDomChild(node: IRNode): boolean {
14
+ /** Expressions that render nothing (0 DOM nodes) `&&` / `?:` empty branches. */
15
+ const EMPTY_RENDER_EXPRS = new Set(['null', 'undefined', 'false', "''", '""', '``'])
16
+
17
+ /**
18
+ * Number of *element* children a node contributes to its parent's `.children`
19
+ * run — the collection that `container.children[idx]` indexes and that event
20
+ * delegation's `Array.from(container.children).indexOf(...)` walks. `.children`
21
+ * is element-only, so text / comment nodes never count.
22
+ *
23
+ * Returns a folded integer when the count is statically known, a JS expression
24
+ * string when it depends on runtime state, or `null` when the element count is
25
+ * statically undecidable (the caller then falls back to the legacy count):
26
+ * - element / component / provider / async → `1` (one root element)
27
+ * - text / empty-render expression (`null`/`false`/…) → `0`
28
+ * - plain loop → `(arr).length`; per-item-conditional / flatMap loop → `null`
29
+ * (renders a runtime-variable count, not `array.length`) (#1693)
30
+ * - conditional → fold to a number when both branches match, else
31
+ * `(cond ? t : f)`; `null` when a branch is undecidable (e.g. the `??`/`||`
32
+ * left operand, a bare expression that may render an element OR text)
33
+ * - fragment → sum of its children (transparent wrapper)
34
+ * - bare expression / slot / everything else → `null` (undecidable)
35
+ */
36
+ function domElementCount(node: IRNode): number | string | null {
37
+ switch (node.type) {
38
+ case 'element':
39
+ case 'component':
40
+ case 'provider':
41
+ case 'async':
42
+ return 1
43
+ case 'text':
44
+ return 0
45
+ case 'expression':
46
+ // `&&` / `?:` empty branches (`null`, `false`, …) render nothing; any
47
+ // other expression may resolve to an element or to text — undecidable.
48
+ return EMPTY_RENDER_EXPRS.has(node.expr.trim()) ? 0 : null
49
+ case 'loop':
50
+ // A per-item-conditional body (#1665) or flatMap renders a
51
+ // runtime-variable element count per item, not `array.length`.
52
+ if (node.bodyIsItemConditional || node.method === 'flatMap') return null
53
+ return `(${buildLoopChainExpr({
54
+ base: node.array,
55
+ sortComparator: node.sortComparator,
56
+ filterPredicate: node.filterPredicate,
57
+ chainOrder: node.chainOrder,
58
+ })}).length`
59
+ case 'conditional': {
60
+ const t = domElementCount(node.whenTrue)
61
+ const f = domElementCount(node.whenFalse)
62
+ if (t === null || f === null) return null
63
+ if (typeof t === 'number' && typeof f === 'number' && t === f) return t
64
+ // Active branch chosen at runtime — reuse the raw `condition`, the exact
65
+ // form `insert()` evaluates in the same init scope.
66
+ return `(${node.condition} ? ${t} : ${f})`
67
+ }
68
+ case 'fragment':
69
+ return sumElementCounts(node.children)
70
+ default:
71
+ // slot / if-statement: element count not statically known.
72
+ return null
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Sum `domElementCount` over a run of nodes, folding the static part. Returns
78
+ * `null` if any child's count is undecidable — the whole run is then unknown.
79
+ */
80
+ function sumElementCounts(nodes: readonly IRNode[]): number | string | null {
81
+ let staticCount = 0
82
+ const dynamic: string[] = []
83
+ for (const n of nodes) {
84
+ const c = domElementCount(n)
85
+ if (c === null) return null
86
+ if (typeof c === 'number') staticCount += c
87
+ else dynamic.push(c)
88
+ }
89
+ if (dynamic.length === 0) return staticCount
90
+ const parts = staticCount > 0 ? [String(staticCount), ...dynamic] : dynamic
91
+ return parts.length === 1 ? parts[0] : `(${parts.join(' + ')})`
92
+ }
93
+
94
+ /**
95
+ * Pre-#1693 element-count heuristic, used as the fallback for nodes whose count
96
+ * `domElementCount` cannot decide. Mirrors the old `producesDomChild` exactly,
97
+ * so an undecidable sibling contributes precisely what it did before this fix —
98
+ * guaranteeing no regression on shapes the new counting can't improve (a bare
99
+ * expression, a `??`/`||` fallback, a per-item-conditional loop).
100
+ */
101
+ function legacyElementCount(node: IRNode): number {
15
102
  return node.type === 'element' || node.type === 'component' || node.type === 'provider'
16
103
  || node.type === 'async'
17
104
  || node.type === 'text' || (node.type === 'expression' && !node.reactive)
18
105
  || node.type === 'conditional'
106
+ ? 1
107
+ : 0
19
108
  }
20
109
 
21
110
  /**
22
- * Pre-pass: for every loop node in the IR tree, record the number of non-loop
23
- * DOM siblings that appear before it in its parent container. Read when
24
- * constructing TopLevelLoop and NestedLoop so the client JS can offset
25
- * children[idx] access past statically-rendered siblings.
111
+ * Pre-pass: for every loop node in the IR tree, record the sibling nodes that
112
+ * appear before it in its parent container. Read when constructing
113
+ * TopLevelLoop and NestedLoop so the client JS can offset children[idx]
114
+ * access past everything rendered ahead of the loop's items.
26
115
  *
27
116
  * Counting must happen for every container whose children render as a
28
117
  * contiguous run of DOM siblings into the same parent — not just `element`.
29
118
  * A loop nested directly inside a component (`<Wrapper><span/>{xs.map(...)}`
30
119
  * </Wrapper>`), fragment, provider, or async boundary has its preceding
31
- * static sibling rendered as a sibling of the loop's items too, so
32
- * `children[idx]` access is shifted exactly as it is under an element parent
33
- * (#1688). Before this, a static sibling before a `.map()` inside a
34
- * (self-portaling) component dropped the first item's nested child component
35
- * during hydration because the offset was silently zero.
120
+ * siblings rendered as siblings of the loop's items too, so `children[idx]`
121
+ * access is shifted exactly as it is under an element parent (#1688).
122
+ *
123
+ * Transparent containers (fragment / provider / async) render no DOM element
124
+ * wrapper, so their children are siblings in the nearest ancestor element
125
+ * not in a container of their own. `recordRun` therefore threads ONE
126
+ * preceding-sibling accumulator through them, so a loop inside a fragment sees
127
+ * the parent element's earlier siblings too, not just the fragment's own
128
+ * children (#1699). `<Box><hr/><hr/><>{xs.map(...)}</></Box>` must offset the
129
+ * items past both `<hr/>`s.
130
+ *
131
+ * The siblings are stored raw; `resolveLoopOffset` turns each into its element
132
+ * count via `domElementCount`. That generalisation closes the #1688 follow-up
133
+ * (#1693): a preceding `.map()` contributes `array.length` and a preceding
134
+ * conditional contributes a `(cond ? … : …)` term, both resolved at runtime —
135
+ * a static-only count resolved later groups' nested children against the wrong
136
+ * `children[idx]`, leaving them inert after hydration.
36
137
  *
37
138
  * Computed once up front (instead of during collection) so the offset data
38
139
  * lives in an explicit value rather than a module-level WeakMap mutated by
39
140
  * two separate traversals.
40
141
  */
41
- export function computeLoopSiblingOffsets(root: IRNode): Map<IRLoop, number> {
42
- const offsets = new Map<IRLoop, number>()
43
- const recordChildren = (children: IRNode[]): void => {
44
- let nonLoopCount = 0
142
+ export function computeLoopSiblingOffsets(root: IRNode): Map<IRLoop, IRNode[]> {
143
+ const offsets = new Map<IRLoop, IRNode[]>()
144
+ // Walk a flat DOM run, flattening transparent containers inline so their
145
+ // children join the same preceding-sibling accumulator.
146
+ const recordRun = (children: IRNode[], preceding: IRNode[]): void => {
45
147
  for (const child of children) {
46
148
  if (child.type === 'loop') {
47
- if (nonLoopCount > 0) offsets.set(child, nonLoopCount)
48
- } else if (producesDomChild(child)) {
49
- nonLoopCount++
149
+ // Record the preceding run only when something precedes this loop (a
150
+ // leading loop keeps bare `children[idx]`). `!offsets.has`: the
151
+ // enclosing run records the loop first, in pre-order, with the full
152
+ // preceding context; a later standalone visit of the transparent
153
+ // wrapper (still descended for loops that sit *directly* in a root /
154
+ // loop-body / branch fragment) must not overwrite it with a shorter
155
+ // run.
156
+ if (preceding.length > 0 && !offsets.has(child)) {
157
+ offsets.set(child, [...preceding])
158
+ }
159
+ preceding.push(child)
160
+ } else if (child.type === 'fragment' || child.type === 'provider' || child.type === 'async') {
161
+ // Transparent: no element wrapper — its children render into this run.
162
+ recordRun(child.children, preceding)
163
+ } else {
164
+ preceding.push(child)
50
165
  }
51
166
  }
52
167
  }
53
168
  const containerVisit = ({ node, descend }: { node: { children: IRNode[] }; descend: () => void }): void => {
54
- recordChildren(node.children)
169
+ recordRun(node.children, [])
55
170
  descend()
56
171
  }
57
172
  walkIR(root, null, {
@@ -68,6 +183,29 @@ export function computeLoopSiblingOffsets(root: IRNode): Map<IRLoop, number> {
68
183
  return offsets
69
184
  }
70
185
 
186
+ /**
187
+ * Resolve a loop's preceding-sibling run into the `LoopOffset` value object
188
+ * stored on `TopLevelLoop` / `NestedLoop`: the folded static element count
189
+ * plus one dynamic term (`(arr).length`, `(cond ? … : …)`) per sibling whose
190
+ * count is only known at runtime. Siblings whose count is statically
191
+ * undecidable fall back to `legacyElementCount` (the pre-#1693 behaviour).
192
+ * Returns `undefined` when nothing precedes the loop (or only non-element
193
+ * nodes do), so the loop keeps bare `children[idx]`.
194
+ */
195
+ function resolveLoopOffset(preceding: IRNode[] | undefined): LoopOffset | undefined {
196
+ if (!preceding || preceding.length === 0) return undefined
197
+ let staticCount = 0
198
+ const dynamicTerms: string[] = []
199
+ for (const node of preceding) {
200
+ const c = domElementCount(node)
201
+ if (c === null) staticCount += legacyElementCount(node)
202
+ else if (typeof c === 'number') staticCount += c
203
+ else dynamicTerms.push(c)
204
+ }
205
+ if (staticCount === 0 && dynamicTerms.length === 0) return undefined
206
+ return { staticCount, dynamicTerms }
207
+ }
208
+
71
209
  /**
72
210
  * Options controlling `collectInnerLoops` traversal and payload collection.
73
211
  *
@@ -129,7 +267,7 @@ export const branchInnerLoopOptions: CollectInnerLoopsOptions = {
129
267
  */
130
268
  export function collectInnerLoops(
131
269
  nodes: IRNode[],
132
- siblingOffsets: Map<IRLoop, number>,
270
+ siblingOffsets: Map<IRLoop, IRNode[]>,
133
271
  outerLoopParam?: string,
134
272
  ctx?: ClientJsContext,
135
273
  options?: CollectInnerLoopsOptions,
@@ -258,7 +396,7 @@ export function collectInnerLoops(
258
396
  refsOuterParam: refsOuter,
259
397
  childComponents,
260
398
  insideConditional: !flat && scope.insideCond ? true : undefined,
261
- siblingOffset: flat ? undefined : (siblingOffsets.get(n) || undefined),
399
+ offset: flat ? undefined : resolveLoopOffset(siblingOffsets.get(n)),
262
400
  bindings,
263
401
  })
264
402
  // Branch-mode callers handle deeper nesting via their own collection paths.
@@ -286,7 +424,7 @@ export function collectInnerLoops(
286
424
  */
287
425
  function decideLoopRendering(
288
426
  loop: IRLoop,
289
- siblingOffsets: Map<IRLoop, number>,
427
+ siblingOffsets: Map<IRLoop, IRNode[]>,
290
428
  ctx: ClientJsContext | undefined,
291
429
  ): { useElementReconciliation: boolean; innerLoops: NestedLoop[] | undefined } {
292
430
  const hasNestedComps = (loop.nestedComponents?.length ?? 0) > 0
@@ -440,7 +578,7 @@ function buildBranchChildComponents(
440
578
  export function collectElements(
441
579
  node: IRNode,
442
580
  ctx: ClientJsContext,
443
- siblingOffsets: Map<IRLoop, number>,
581
+ siblingOffsets: Map<IRLoop, IRNode[]>,
444
582
  insideConditional = false,
445
583
  ): void {
446
584
  walkIR<boolean>(node, insideConditional, {
@@ -595,7 +733,7 @@ export function collectElements(
595
733
  isStaticArray: l.isStaticArray,
596
734
  useElementReconciliation,
597
735
  innerLoops: (useElementReconciliation || (l.isStaticArray && innerLoops?.length)) ? innerLoops : undefined,
598
- siblingOffset: siblingOffsets.get(l) || undefined,
736
+ offset: resolveLoopOffset(siblingOffsets.get(l)),
599
737
  filterPredicate: l.filterPredicate ? {
600
738
  param: l.filterPredicate.param,
601
739
  raw: l.filterPredicate.raw,
@@ -854,7 +992,7 @@ function collectBranchTextEffects(node: IRNode): ConditionalBranchTextEffect[] {
854
992
  function collectBranchLoops(
855
993
  node: IRNode,
856
994
  ctx: ClientJsContext | undefined,
857
- siblingOffsets: Map<IRLoop, number>,
995
+ siblingOffsets: Map<IRLoop, IRNode[]>,
858
996
  ): BranchLoop[] {
859
997
  const loops: BranchLoop[] = []
860
998
  const restNames = ctx ? buildRestSpreadNames(ctx) : undefined
@@ -953,7 +1091,7 @@ function collectBranchLoops(
953
1091
  function buildConditionalMetadata(
954
1092
  node: IRNode & { type: 'conditional' },
955
1093
  ctx: ClientJsContext,
956
- siblingOffsets: Map<IRLoop, number>,
1094
+ siblingOffsets: Map<IRLoop, IRNode[]>,
957
1095
  ): ConditionalElement {
958
1096
  const restNames = buildRestSpreadNames(ctx)
959
1097
  // Use loopDepth=-1 so the first loop encountered inside the branch emits
@@ -983,7 +1121,7 @@ function buildConditionalMetadata(
983
1121
  function summarizeBranch(
984
1122
  node: IRNode,
985
1123
  ctx: ClientJsContext,
986
- siblingOffsets: Map<IRLoop, number>,
1124
+ siblingOffsets: Map<IRLoop, IRNode[]>,
987
1125
  ): import('./types').BranchSummary {
988
1126
  return {
989
1127
  events: collectConditionalBranchEvents(node),
@@ -1003,7 +1141,7 @@ function summarizeBranch(
1003
1141
  function collectBranchConditionals(
1004
1142
  node: IRNode,
1005
1143
  ctx: ClientJsContext,
1006
- siblingOffsets: Map<IRLoop, number>,
1144
+ siblingOffsets: Map<IRLoop, IRNode[]>,
1007
1145
  ): ConditionalElement[] {
1008
1146
  const result: ConditionalElement[] = []
1009
1147
  walkIR(node, null, {
@@ -1051,7 +1189,7 @@ function collectBranchConditionals(
1051
1189
  export function collectLoopChildBindings(
1052
1190
  children: readonly IRNode[],
1053
1191
  ctx: ClientJsContext,
1054
- siblingOffsets: Map<IRLoop, number>,
1192
+ siblingOffsets: Map<IRLoop, IRNode[]>,
1055
1193
  loopParam: string,
1056
1194
  loopParamBindings: readonly import('../types').LoopParamBinding[] | undefined,
1057
1195
  ): LoopChildBindings {
@@ -1069,7 +1207,7 @@ export function collectLoopChildBindings(
1069
1207
  export function collectLoopChildConditionals(
1070
1208
  node: IRNode,
1071
1209
  ctx: ClientJsContext,
1072
- siblingOffsets: Map<IRLoop, number>,
1210
+ siblingOffsets: Map<IRLoop, IRNode[]>,
1073
1211
  loopParam?: string,
1074
1212
  loopParamBindings?: readonly import('../types').LoopParamBinding[],
1075
1213
  ): LoopChildConditional[] {
@@ -1144,7 +1282,7 @@ export function collectLoopChildConditionals(
1144
1282
  function summarizeLoopChildBranch(
1145
1283
  node: IRNode,
1146
1284
  ctx: ClientJsContext,
1147
- siblingOffsets: Map<IRLoop, number>,
1285
+ siblingOffsets: Map<IRLoop, IRNode[]>,
1148
1286
  loopParam?: string,
1149
1287
  loopParamBindings?: readonly import('../types').LoopParamBinding[],
1150
1288
  ): LoopChildBranchSummary {
@@ -69,7 +69,7 @@ export function buildStaticArrayDelegationPlan(elem: TopLevelLoop): EventDelegat
69
69
  arrayExpr: buildChainedArrayExpr(elem),
70
70
  param: elem.param,
71
71
  mapPreamble: elem.mapPreamble ?? null,
72
- siblingOffset: elem.siblingOffset ?? null,
72
+ offset: elem.offset ?? null,
73
73
  },
74
74
  }
75
75
  }
@@ -25,6 +25,7 @@ import type {
25
25
  } from '../../types'
26
26
  import {
27
27
  buildChainedArrayExpr,
28
+ buildLoopChildIndexExpr,
28
29
  setIntersects,
29
30
  varSlotId,
30
31
  wrapLoopParamAsAccessor,
@@ -129,7 +130,7 @@ export function buildStaticLoopPlan(elem: TopLevelLoop, unsafeLocalNames: Set<st
129
130
  }
130
131
 
131
132
  const indexParam = elem.index || '__idx'
132
- const childIndexExpr = elem.siblingOffset ? `${indexParam} + ${elem.siblingOffset}` : indexParam
133
+ const childIndexExpr = buildLoopChildIndexExpr(indexParam, elem.offset)
133
134
 
134
135
  return {
135
136
  kind: 'static',
@@ -5,7 +5,7 @@
5
5
  * container variable and the per-event item-lookup strategy.
6
6
  */
7
7
 
8
- import type { LoopChildEvent, TopLevelLoop } from '../../types'
8
+ import type { LoopChildEvent, LoopOffset, TopLevelLoop } from '../../types'
9
9
 
10
10
  /**
11
11
  * Plan for a loop's event-delegation block. Covers three legacy emitters:
@@ -72,6 +72,11 @@ export interface StaticIndexItemLookup {
72
72
  arrayExpr: string
73
73
  param: string
74
74
  mapPreamble: string | null
75
- /** Sibling offset for `__idx` arithmetic; `null` when no offset. */
76
- siblingOffset: number | null
75
+ /**
76
+ * Offset of the loop's items past its preceding container siblings. Its
77
+ * terms are subtracted from the DOM child index to recover the array index,
78
+ * so later `static + .map()` groups resolve the correct item (#1693).
79
+ * `null` when nothing precedes the loop.
80
+ */
81
+ offset: LoopOffset | null
77
82
  }