@barefootjs/jsx 0.24.1 → 0.26.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 (46) hide show
  1. package/dist/adapters/dangerous-inner-html.d.ts +52 -24
  2. package/dist/adapters/dangerous-inner-html.d.ts.map +1 -1
  3. package/dist/analyzer.d.ts.map +1 -1
  4. package/dist/errors.d.ts +1 -0
  5. package/dist/errors.d.ts.map +1 -1
  6. package/dist/index.js +473 -165
  7. package/dist/ir-to-client-js/collect-elements.d.ts.map +1 -1
  8. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts +15 -1
  9. package/dist/ir-to-client-js/control-flow/plan/build-loop-child-arm.d.ts.map +1 -1
  10. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts +9 -6
  11. package/dist/ir-to-client-js/control-flow/plan/build-reactive-effects.d.ts.map +1 -1
  12. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts +11 -5
  13. package/dist/ir-to-client-js/control-flow/plan/loop-child-arm.d.ts.map +1 -1
  14. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts +27 -1
  15. package/dist/ir-to-client-js/control-flow/stringify/loop-child-arm.d.ts.map +1 -1
  16. package/dist/ir-to-client-js/control-flow/stringify/reactive-effects.d.ts.map +1 -1
  17. package/dist/ir-to-client-js/reactivity.d.ts +24 -2
  18. package/dist/ir-to-client-js/reactivity.d.ts.map +1 -1
  19. package/dist/ir-to-client-js/types.d.ts +13 -0
  20. package/dist/ir-to-client-js/types.d.ts.map +1 -1
  21. package/dist/to-locale-date-lowering.d.ts +31 -10
  22. package/dist/to-locale-date-lowering.d.ts.map +1 -1
  23. package/dist/types.d.ts +29 -5
  24. package/dist/types.d.ts.map +1 -1
  25. package/package.json +2 -2
  26. package/src/__tests__/dangerous-inner-html-resolver.test.ts +27 -7
  27. package/src/__tests__/nested-loop-conditional.test.ts +133 -0
  28. package/src/__tests__/profile-nested-binding-ids.test.ts +5 -2
  29. package/src/__tests__/reactive-factory-cross-file.test.ts +833 -0
  30. package/src/__tests__/reactive-factory-inlining.test.ts +527 -1
  31. package/src/__tests__/reactive-factory-rename-fidelity.test.ts +318 -0
  32. package/src/__tests__/to-locale-date-lowering.test.ts +27 -2
  33. package/src/adapters/dangerous-inner-html.ts +101 -48
  34. package/src/analyzer.ts +607 -142
  35. package/src/errors.ts +4 -0
  36. package/src/ir-to-client-js/collect-elements.ts +28 -2
  37. package/src/ir-to-client-js/control-flow/plan/build-loop-child-arm.ts +56 -2
  38. package/src/ir-to-client-js/control-flow/plan/build-reactive-effects.ts +30 -54
  39. package/src/ir-to-client-js/control-flow/plan/loop-child-arm.ts +11 -5
  40. package/src/ir-to-client-js/control-flow/stringify/loop-child-arm.ts +78 -4
  41. package/src/ir-to-client-js/control-flow/stringify/reactive-effects.ts +3 -25
  42. package/src/ir-to-client-js/reactivity.ts +61 -7
  43. package/src/ir-to-client-js/types.ts +13 -0
  44. package/src/rich-type-refusal.ts +3 -2
  45. package/src/to-locale-date-lowering.ts +50 -13
  46. package/src/types.ts +30 -5
@@ -45,13 +45,18 @@ describe('resolveDangerousInnerHtml (#2207)', () => {
45
45
  expect(resolveDangerousInnerHtml(el)).toEqual({ kind: 'static', html: '<i>x</i>' })
46
46
  })
47
47
 
48
- test('signal/prop-derived value resolves as dynamic', () => {
48
+ test('signal/prop-derived value resolves as dynamic and carries the inner expression (#2319)', () => {
49
49
  const el = rootElement(`
50
50
  function Test({ html }: { html: string }) { return <div dangerouslySetInnerHTML={{ __html: html }} /> }
51
51
  export { Test }
52
52
  `)
53
53
  const resolution = resolveDangerousInnerHtml(el)
54
54
  expect(resolution?.kind).toBe('dynamic')
55
+ // The adapter lowers `valueExpr`/`valueParsed` (the inner `__html` value),
56
+ // not the whole `{ __html: … }` object, through its raw-output sink.
57
+ if (resolution?.kind !== 'dynamic') throw new Error('expected dynamic')
58
+ expect(resolution.valueExpr).toBe('html')
59
+ expect(resolution.valueParsed).toEqual({ kind: 'identifier', name: 'html' })
55
60
  })
56
61
 
57
62
  test('a no-substitution template literal resolves as static (identical to a string literal)', () => {
@@ -87,12 +92,14 @@ describe('resolveDangerousInnerHtml (#2207)', () => {
87
92
  expect(resolveDangerousInnerHtml(el)?.kind).toBe('dynamic')
88
93
  })
89
94
 
90
- test('extra property alongside __html resolves as dynamic', () => {
95
+ test('extra property alongside __html resolves as unlowerable (not a { __html } object literal)', () => {
91
96
  const el = rootElement(`
92
97
  function Test() { return <div dangerouslySetInnerHTML={{ __html: '<b>x</b>', extra: 1 }} /> }
93
98
  export { Test }
94
99
  `)
95
- expect(resolveDangerousInnerHtml(el)?.kind).toBe('dynamic')
100
+ // Not the canonical single-property shape — no faithful lowering, so the
101
+ // adapter refuses with BF101 (#2319 lowers a lone `{ __html: expr }`).
102
+ expect(resolveDangerousInnerHtml(el)?.kind).toBe('unlowerable')
96
103
  })
97
104
 
98
105
  test('isDangerousInnerHtmlAttr only matches the exact prop name', () => {
@@ -187,11 +194,24 @@ describe('dangerousInnerHtmlMetacharViolation (#2207)', () => {
187
194
  })
188
195
 
189
196
  describe('dangerousInnerHtmlDiagnostic (#2207)', () => {
190
- test('produces a BF101 with a purpose-built message', () => {
191
- const diag = dangerousInnerHtmlDiagnostic('html', { file: 'Test.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } })
197
+ test('a wrong-shape refusal (no reason) states the single-__html-property contract', () => {
198
+ const diag = dangerousInnerHtmlDiagnostic('true', { file: 'Test.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } })
192
199
  expect(diag.code).toBe('BF101')
193
200
  expect(diag.severity).toBe('error')
194
- expect(diag.message).toContain('dangerouslySetInnerHTML requires a compile-time string literal')
195
- expect(diag.suggestion?.message).toContain('2215')
201
+ // The message names the canonical shape (a single `__html` property) so a
202
+ // near-miss like `{ __html: x, extra: 1 }` isn't told it "expects an
203
+ // { __html: … }" it already appears to have (#2319 review).
204
+ expect(diag.message).toContain('requires an object literal with a single `__html` property')
205
+ // A dynamic value is now lowered, not refused, so the escape-hatch text
206
+ // points at the object-literal contract and /* @client */ (#2319).
207
+ expect(diag.suggestion?.message).toContain('@client')
208
+ })
209
+
210
+ test('a valid-shape-but-unlowerable refusal (with reason) leads with the reason, not the shape', () => {
211
+ const diag = dangerousInnerHtmlDiagnostic('{ __html: `<b>${x}</b>` }', { file: 'Test.tsx', start: { line: 1, column: 0 }, end: { line: 1, column: 0 } }, 'no single-argument raw form on the Go adapter')
212
+ expect(diag.code).toBe('BF101')
213
+ expect(diag.message).toContain('cannot be lowered on this adapter — no single-argument raw form on the Go adapter')
214
+ // It must NOT claim the shape is wrong when it isn't.
215
+ expect(diag.message).not.toContain('requires an object literal with a single')
196
216
  })
197
217
  })
@@ -381,3 +381,136 @@ describe('nested loops/conditionals inside mapArray (#830, #839)', () => {
381
381
  expect(outsideInitChildCount).toBe(0)
382
382
  })
383
383
  })
384
+
385
+ describe('per-item conditional wrapping a dynamic attr/event element binds exactly once (#2347)', () => {
386
+ // A per-item conditional (`cond ? null : <el/>`) whose element itself has a
387
+ // dynamic attribute and/or event handler used to get bound twice: once
388
+ // directly against the loop item's own initial template clone (querying
389
+ // `qsa(__el, ...)` right in the mapArray callback), and again inside the
390
+ // conditional's own `insert()` bindEvents against whatever node it mounts.
391
+ // At runtime this doubled the click handler and left the attribute effect
392
+ // pointed at a detached node once insert() swapped branches.
393
+
394
+ test('Repro 1: reactive className on a conditionally-rendered element binds once, inside bindEvents', () => {
395
+ const source = `
396
+ 'use client'
397
+ import { createSignal } from '@barefootjs/client'
398
+
399
+ interface Item { handle: string; active: 0 | 1 }
400
+
401
+ export function List(props: { viewerHandle: string }) {
402
+ const [items, setItems] = createSignal<Item[]>([])
403
+ return (
404
+ <div>
405
+ {items().map(u => (
406
+ <div key={u.handle}>
407
+ {u.handle === props.viewerHandle ? null : (
408
+ <button className={u.active ? 'btn on' : 'btn'} onClick={() => {}}>
409
+ {u.active ? 'On' : 'Off'}
410
+ </button>
411
+ )}
412
+ </div>
413
+ ))}
414
+ </div>
415
+ )
416
+ }
417
+ `
418
+ const result = compileJSX(source, 'List.tsx', { adapter })
419
+ expect(result.errors).toHaveLength(0)
420
+ const content = result.files.find(f => f.type === 'clientJs')!.content
421
+
422
+ // Exactly one class effect, exactly one click listener.
423
+ expect((content.match(/setAttribute\('class'/g) ?? []).length).toBe(1)
424
+ expect((content.match(/addEventListener\('click'/g) ?? []).length).toBe(1)
425
+
426
+ // The class effect must live inside the conditional's own bindEvents
427
+ // (querying `__branchScope`, the node insert() actually mounts) —
428
+ // not directly against `__el` in the outer mapArray scope, which would
429
+ // go stale the moment insert() swaps in a fresh clone.
430
+ const bindEventsIndex = content.indexOf('bindEvents:')
431
+ const classEffectIndex = content.indexOf("setAttribute('class'")
432
+ expect(bindEventsIndex).toBeGreaterThan(-1)
433
+ expect(classEffectIndex).toBeGreaterThan(bindEventsIndex)
434
+ expect(content).toContain("qsa(__branchScope, '[bf=\"s2\"]')")
435
+ })
436
+
437
+ test('Repro 2: a doubly-nested per-item conditional binds click exactly once, in the innermost arm', () => {
438
+ const source = `
439
+ 'use client'
440
+ import { createSignal } from '@barefootjs/client'
441
+
442
+ interface Item { handle: string; active: 0 | 1 }
443
+
444
+ export function List(props: { viewerHandle: string; signedIn: boolean }) {
445
+ const [items, setItems] = createSignal<Item[]>([])
446
+ const onClick = (u: Item) => {}
447
+ return (
448
+ <div>
449
+ {items().map(u => (
450
+ <div key={u.handle}>
451
+ {u.handle === props.viewerHandle ? null : props.signedIn ? (
452
+ <button className={u.active ? 'btn on' : 'btn'} onClick={() => onClick(u)}>
453
+ {u.active ? 'On' : 'Off'}
454
+ </button>
455
+ ) : (
456
+ <a href="/login" rel="nofollow noreferrer">Off</a>
457
+ )}
458
+ </div>
459
+ ))}
460
+ </div>
461
+ )
462
+ }
463
+ `
464
+ const result = compileJSX(source, 'List.tsx', { adapter })
465
+ expect(result.errors).toHaveLength(0)
466
+ const content = result.files.find(f => f.type === 'clientJs')!.content
467
+
468
+ // A single click bound in the browser used to fire the handler twice —
469
+ // once from the outer arm's own qsa()+addEventListener, once again from
470
+ // the nested insert()'s bindEvents — silently double-toggling state.
471
+ expect((content.match(/addEventListener\('click'/g) ?? []).length).toBe(1)
472
+ expect((content.match(/setAttribute\('class'/g) ?? []).length).toBe(1)
473
+ })
474
+
475
+ test('a JSX-callback prop call as the WHOLE branch content does not get a nested text effect', () => {
476
+ // Regression pin for a fix-of-the-fix: the initial #2347 patch made
477
+ // `summarizeLoopChildBranch` collect reactive texts for any branch,
478
+ // which newly reached a per-item conditional whose branch is a single
479
+ // bare `expression` (no wrapping element) — e.g. a hoisted JSX-callback
480
+ // prop call (`renderNode={(n) => <Pill/>}`-shaped, #1211/#1213). That
481
+ // value is already fully re-evaluated and spliced via `__bfSlot`
482
+ // whenever insert() (re-)mounts the branch; wrapping it in an
483
+ // *additional* createEffect re-invokes the callback (producing a
484
+ // second, independent live element) on the very first run, and the
485
+ // loop-child arm's `$t()`-based anchor lookup can't cleanly displace an
486
+ // already-mounted Element — so the second instance renders beside the
487
+ // first instead of replacing it (surfaced via barefootjs-xyflow's
488
+ // renderNode reference page in CI).
489
+ const source = `
490
+ 'use client'
491
+ import { createSignal } from '@barefootjs/client'
492
+
493
+ interface Node { id: string }
494
+
495
+ export function Flow(props: { renderNode?: (n: Node) => unknown }) {
496
+ const [nodes] = createSignal<Node[]>([])
497
+ return (
498
+ <div>
499
+ {nodes().map(n => (
500
+ <div key={n.id}>
501
+ {props.renderNode ? props.renderNode(n) : <span>{n.id}</span>}
502
+ </div>
503
+ ))}
504
+ </div>
505
+ )
506
+ }
507
+ `
508
+ const result = compileJSX(source, 'Flow.tsx', { adapter })
509
+ expect(result.errors).toHaveLength(0)
510
+ const content = result.files.find(f => f.type === 'clientJs')!.content
511
+
512
+ // No nested createEffect re-invoking `props.renderNode(n)` — the
513
+ // conditional's own template()/insert() already owns this value.
514
+ expect(content).not.toMatch(/createEffect\(\(\) => \{ __rt_\w+ = __bfText\(__rt_\w+, \(?\s*props\.renderNode\(n\(\)\)/)
515
+ })
516
+ })
@@ -111,8 +111,11 @@ describe('nested loop binding ids (#1795 Phase 3)', () => {
111
111
 
112
112
  test('loop-child conditional insert() and its branch text carry ids', () => {
113
113
  const on = clientJs(nestedSource, 'Nested', true)
114
- // The branch-arm text (`{r.label}` inside the conditional's true arm).
115
- expect(on).toMatch(/__rt_s\d+\.textContent = String\(r\(\)\.label\) }, "Nested#binding:s\d+"\)/)
114
+ // The branch-arm text (`{r.label}` inside the conditional's true arm)
115
+ // __bfText (not a naive `.textContent = String(...)`) so a Child-position
116
+ // expression whose value is a live Node splices by identity instead of
117
+ // stringifying (#2347).
118
+ expect(on).toMatch(/__rt_s\d+ = __bfText\(__rt_s\d+, r\(\)\.label\) }, "Nested#binding:s\d+"\)/)
116
119
  // The inner loop's child text (`{t}`).
117
120
  expect(on).toMatch(/String\(t\(\)\) }, "Nested#binding:s\d+"\)/)
118
121
  })