@barefootjs/jsx 0.5.3 → 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.
@@ -398,25 +398,104 @@ describe('expression-parser', () => {
398
398
  const result = parseExpression("items().filter(({name = 'anon'}) => name.startsWith('a'))")
399
399
  expect(result.kind).toBe('higher-order')
400
400
  if (result.kind === 'higher-order') {
401
- // Predicate: `(_t.name ?? 'anon').startsWith('a')`.
402
- expect(result.predicate.kind).toBe('call')
403
- if (result.predicate.kind === 'call') {
404
- expect(result.predicate.callee.kind).toBe('member')
405
- if (result.predicate.callee.kind === 'member') {
406
- expect(result.predicate.callee.property).toBe('startsWith')
407
- expect(result.predicate.callee.object.kind).toBe('logical')
408
- if (result.predicate.callee.object.kind === 'logical') {
409
- expect(result.predicate.callee.object.op).toBe('??')
410
- expect(result.predicate.callee.object.right.kind).toBe('literal')
411
- if (result.predicate.callee.object.right.kind === 'literal') {
412
- expect(result.predicate.callee.object.right.value).toBe('anon')
413
- }
401
+ // Predicate: `(_t.name ?? 'anon').startsWith('a')`. Since #1448
402
+ // Tier B, `.startsWith` lowers to an `array-method` node (it was
403
+ // a generic `call` before), so the receiver is on `.object`.
404
+ expect(result.predicate.kind).toBe('array-method')
405
+ if (result.predicate.kind === 'array-method') {
406
+ expect(result.predicate.method).toBe('startsWith')
407
+ expect(result.predicate.object.kind).toBe('logical')
408
+ if (result.predicate.object.kind === 'logical') {
409
+ expect(result.predicate.object.op).toBe('??')
410
+ expect(result.predicate.object.right.kind).toBe('literal')
411
+ if (result.predicate.object.right.kind === 'literal') {
412
+ expect(result.predicate.object.right.value).toBe('anon')
414
413
  }
415
414
  }
416
415
  }
417
416
  }
418
417
  })
419
418
 
419
+ test('lowers .startsWith(search, position) — full arity (#1448 Tier B)', () => {
420
+ const result = parseExpression(`name().startsWith("world", 6)`)
421
+ expect(result.kind).toBe('array-method')
422
+ if (result.kind === 'array-method') {
423
+ expect(result.method).toBe('startsWith')
424
+ // Both the search string and the position survive as args so the
425
+ // adapter can emit the re-anchored test.
426
+ expect(result.args.length).toBe(2)
427
+ }
428
+ })
429
+
430
+ test('refuses .startsWith() with no search string (#1448 Tier B)', () => {
431
+ // JS coerces the missing argument to the string "undefined" — a
432
+ // degenerate result not worth lowering (mirrors `.includes()`).
433
+ const result = parseExpression(`name().startsWith()`)
434
+ expect(result.kind).toBe('unsupported')
435
+ })
436
+
437
+ test('lowers .replace(pattern, replacement, extra) — ignores 3rd+ arg (#1448 Tier B)', () => {
438
+ const result = parseExpression(`name().replace("o", "0", "extra")`)
439
+ expect(result.kind).toBe('array-method')
440
+ if (result.kind === 'array-method') {
441
+ expect(result.method).toBe('replace')
442
+ }
443
+ })
444
+
445
+ test('refuses .replace(pattern) with no replacement (#1448 Tier B)', () => {
446
+ // JS coerces the missing replacement to the string "undefined".
447
+ const result = parseExpression(`name().replace("o")`)
448
+ expect(result.kind).toBe('unsupported')
449
+ })
450
+
451
+ test('refuses .replace() with no arguments (#1448 Tier B)', () => {
452
+ // The zero-arg form is degenerate too (both pattern and
453
+ // replacement coerce to "undefined") — pin it alongside the
454
+ // one-arg case so neither regresses.
455
+ const result = parseExpression(`name().replace()`)
456
+ expect(result.kind).toBe('unsupported')
457
+ })
458
+
459
+ test('.replace with a non-regex unsupported arg surfaces its own reason (#1448 Tier B)', () => {
460
+ // An object-literal replacement must NOT be mislabelled as the
461
+ // deferred regex form — the guard only special-cases a
462
+ // regex-literal pattern.
463
+ const result = parseExpression(`name().replace("a", {x: 1})`)
464
+ expect(result.kind).toBe('unsupported')
465
+ if (result.kind === 'unsupported') {
466
+ expect(result.reason).not.toContain('regex form is deferred')
467
+ }
468
+ })
469
+
470
+ test('.replace(/re/, repl) refuses the regex form with the deferred reason (#1448 Tier B)', () => {
471
+ const result = parseExpression(`name().replace(/a/g, "b")`)
472
+ expect(result.kind).toBe('unsupported')
473
+ if (result.kind === 'unsupported') {
474
+ expect(result.reason).toContain('regex form is deferred')
475
+ }
476
+ })
477
+
478
+ test('lowers .repeat() and .repeat(n, extra) — full arity (#1448 Tier B)', () => {
479
+ // `.repeat()` is `repeat(0)` → "" in JS (not a RangeError); a
480
+ // second+ argument is ignored. Both stay on the lowering path.
481
+ for (const expr of [`name().repeat()`, `name().repeat(3, 4)`]) {
482
+ const result = parseExpression(expr)
483
+ expect(result.kind).toBe('array-method')
484
+ if (result.kind === 'array-method') {
485
+ expect(result.method).toBe('repeat')
486
+ }
487
+ }
488
+ })
489
+
490
+ test('lowers .padStart() / .padEnd(t, p, extra) — full arity (#1448 Tier B)', () => {
491
+ // `.padStart()` is `padStart(0)` → the receiver unchanged; a
492
+ // third+ argument is ignored. Both stay on the lowering path.
493
+ for (const expr of [`name().padStart()`, `name().padEnd(5, "0", "x")`]) {
494
+ const result = parseExpression(expr)
495
+ expect(result.kind).toBe('array-method')
496
+ }
497
+ })
498
+
420
499
  test('lowers .filter(({label = `untitled-${suffix}`}) => label) — template-literal default (#1531)', () => {
421
500
  const result = parseExpression('items().filter(({label = `untitled-${suffix}`}) => label)')
422
501
  expect(result.kind).toBe('higher-order')
@@ -1358,3 +1437,78 @@ describe('expression-parser', () => {
1358
1437
  })
1359
1438
  })
1360
1439
  })
1440
+
1441
+ // =============================================================================
1442
+ // Full-arity lowering for the array / string methods (#1448)
1443
+ // =============================================================================
1444
+ //
1445
+ // These methods lower at their full JS arity: zero-arg defaults
1446
+ // (`.join()` → `,`, `.slice()` → full copy) and JS-ignored trailing
1447
+ // arguments (`.trim(1)`, `.at(i, extra)`, `.slice(s, e, extra)`) are all
1448
+ // accepted. The only forms still refused are the ones whose EXTRA
1449
+ // argument changes the result and isn't lowered yet — the `fromIndex` of
1450
+ // `.includes`/`.indexOf`/`.lastIndexOf` and the variadic `.concat(a, b)`
1451
+ // — because silently dropping those would make SSR differ from the
1452
+ // client (worse than a build error).
1453
+ describe('expression-parser — array-method full-arity lowering (#1448)', () => {
1454
+ const supported: Array<[string, string]> = [
1455
+ // base forms
1456
+ ['join(sep)', 'arr.join("-")'],
1457
+ ['includes(x)', 'arr.includes(x)'],
1458
+ ['indexOf(x)', 'arr.indexOf(x)'],
1459
+ ['at(i)', 'arr.at(1)'],
1460
+ ['concat(other)', 'arr.concat(b)'],
1461
+ ['slice(start)', 'arr.slice(1)'],
1462
+ ['slice(start, end)', 'arr.slice(1, 2)'],
1463
+ ['trim()', 's.trim()'],
1464
+ // zero-arg defaults (every one of these escaping both its arm AND
1465
+ // the guard is the footgun the relaxation must NOT reintroduce)
1466
+ ['join() → default ","', 'arr.join()'],
1467
+ ['slice() → full copy', 'arr.slice()'],
1468
+ ['at() → at(0)', 'arr.at()'],
1469
+ ['concat() → shallow copy', 'arr.concat()'],
1470
+ ['reverse() base', 'arr.reverse()'],
1471
+ ['toReversed() base', 'arr.toReversed()'],
1472
+ ['toLowerCase() base', 's.toLowerCase()'],
1473
+ ['toUpperCase() base', 's.toUpperCase()'],
1474
+ ['lastIndexOf(x) base', 'arr.lastIndexOf(x)'],
1475
+ // JS-ignored trailing arguments
1476
+ ['slice(s, e, extra)', 'arr.slice(1, 2, 3)'],
1477
+ ['at(i, extra)', 'arr.at(1, 2)'],
1478
+ ['reverse(extra)', 'arr.reverse(1)'],
1479
+ ['toReversed(extra)', 'arr.toReversed(1)'],
1480
+ ['toLowerCase(extra)', 's.toLowerCase("x")'],
1481
+ ['toUpperCase(extra)', 's.toUpperCase("x")'],
1482
+ ['trim(extra)', 's.trim(1)'],
1483
+ ]
1484
+ for (const [label, expr] of supported) {
1485
+ test(`${label} — lowers to array-method`, () => {
1486
+ expect(parseExpression(expr).kind).toBe('array-method')
1487
+ })
1488
+ }
1489
+
1490
+ // Still refused (the extra argument is meaningful and not yet
1491
+ // lowered), plus the degenerate zero-arg search forms (`includes()`
1492
+ // searches for `undefined` — refused rather than guessed).
1493
+ const refused: Array<[string, string]> = [
1494
+ ['includes(x, fromIndex)', 'arr.includes(x, 1)'],
1495
+ ['indexOf(x, fromIndex)', 'arr.indexOf(x, 1)'],
1496
+ ['lastIndexOf(x, fromIndex)', 'arr.lastIndexOf(x, 1)'],
1497
+ ['concat(a, b) variadic', 'arr.concat(a, b)'],
1498
+ ['includes() zero-arg', 'arr.includes()'],
1499
+ ['indexOf() zero-arg', 'arr.indexOf()'],
1500
+ ]
1501
+ for (const [label, expr] of refused) {
1502
+ test(`${label} — refuses (meaningful extra arg, not yet lowered)`, () => {
1503
+ const result = parseExpression(expr)
1504
+ expect(result.kind).toBe('unsupported')
1505
+ if (result.kind === 'unsupported') {
1506
+ // Must NOT push `@client` (wrong remedy; doesn't work in
1507
+ // attribute / condition position), and must explain it's a
1508
+ // not-yet-lowered argument.
1509
+ expect(result.reason).not.toContain('@client')
1510
+ expect(result.reason).toContain('not yet lowered')
1511
+ }
1512
+ })
1513
+ }
1514
+ })
@@ -23,6 +23,7 @@ function makeContext(overrides: Partial<ClientJsContext> = {}): ClientJsContext
23
23
  loopElements: [],
24
24
  refElements: [],
25
25
  childInits: [],
26
+ deferredChildSlots: new Set(),
26
27
  reactiveProps: [],
27
28
  reactiveChildProps: [],
28
29
  reactiveAttrs: [],
@@ -80,9 +80,24 @@ describe('Multi-stage soak (DeskCanvas-shape)', () => {
80
80
  expect(clientJs).toMatch(/import\s+\{[^}]*useYjs[^}]*\}\s+from\s+['"]\.\/useYjs['"]/)
81
81
  })
82
82
 
83
- test('Module → Template: nodeTypes import IS visible to template', () => {
84
- const { templateBody } = compile(DESK_CANVAS_SHAPE, 'DeskCanvas.tsx')
85
- expect(templateBody).toMatch(/nodeTypes/)
83
+ test('Module → Init: nodeTypes is forwarded to the deferred child via init', () => {
84
+ // The `<Flow>` render carries init-scope-only props (`data-yjs={yjs.id}`
85
+ // where `yjs` is an init-local; `nodes={items()}`). The module-scope
86
+ // template lambda can't supply those, so the child render is DEFERRED
87
+ // to a `data-bf-ph` placeholder and `upsertChild` (→ createComponent)
88
+ // creates `Flow` with the COMPLETE getter props. The inlinable
89
+ // `nodeTypes` is therefore forwarded through the init `upsertChild`
90
+ // getter — never dropped — rather than baked into the template lambda.
91
+ // (Pre-fix this child was rendered eagerly via `renderChild('Flow', {
92
+ // nodeTypes })` with the init-scope props silently dropped — the
93
+ // dropped-prop bug.)
94
+ const { templateBody, initBody } = compile(DESK_CANVAS_SHAPE, 'DeskCanvas.tsx')
95
+ // Template defers the child render with a placeholder, not renderChild.
96
+ expect(templateBody).toMatch(/data-bf-ph="s0"/)
97
+ expect(templateBody).not.toMatch(/renderChild/)
98
+ // Init forwards nodeTypes (and every other prop) with complete values.
99
+ expect(initBody).toMatch(/upsertChild\(__scope,\s*'Flow',\s*'s0'/)
100
+ expect(initBody).toMatch(/get nodeTypes\(\)\s*\{\s*return nodeTypes\s*\}/)
86
101
  })
87
102
 
88
103
  // TODO(#1138 P3 5/N): `useYjs(...)` (init-local initializer) leaks into
@@ -66,6 +66,13 @@ export type ArrayMethod =
66
66
  | 'toLowerCase'
67
67
  | 'toUpperCase'
68
68
  | 'trim'
69
+ | 'split'
70
+ | 'startsWith'
71
+ | 'endsWith'
72
+ | 'replace'
73
+ | 'repeat'
74
+ | 'padStart'
75
+ | 'padEnd'
69
76
 
70
77
  /**
71
78
  * Method names handled by the dedicated `sortMethod()` dispatcher
@@ -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
@@ -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