@barefootjs/jsx 0.23.0 → 0.25.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.
@@ -10,7 +10,10 @@
10
10
  * for destructures of non-recognised factory shapes.
11
11
  */
12
12
 
13
- import { describe, test, expect } from 'bun:test'
13
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
14
+ import { mkdtempSync, writeFileSync, rmSync, mkdirSync } from 'fs'
15
+ import { tmpdir } from 'os'
16
+ import path from 'path'
14
17
  import { analyzeComponent } from '../analyzer'
15
18
  import { compileJSX } from '../compiler'
16
19
  import { TestAdapter } from '../adapters/test-adapter'
@@ -482,3 +485,526 @@ describe('Object-return reactive factories (#2325)', () => {
482
485
  expect(result.errors.find(e => e.code === 'BF110')).toBeUndefined()
483
486
  })
484
487
  })
488
+
489
+ describe('Guard-clause / nested-return factories decline (#2341 BUG-3)', () => {
490
+ test('T1: if-guard return declines with BF110, no inert splice (issue repro)', () => {
491
+ const source = `
492
+ 'use client'
493
+ import { createSignal } from '@barefootjs/client'
494
+ function useBounded(initial: number, max: number) {
495
+ const [n, setN] = createSignal(initial)
496
+ const bump = () => setN(Math.min(n() + 1, max))
497
+ if (initial > max) {
498
+ return { n, bump }
499
+ }
500
+ return { n, bump }
501
+ }
502
+ export function Bounded() {
503
+ const { n, bump } = useBounded(0, 10)
504
+ return <button onClick={bump}>{n()}</button>
505
+ }
506
+ `
507
+
508
+ const ctx = analyzeComponent(source, 'Bounded.tsx')
509
+ expect(ctx.signals.length).toBe(0)
510
+
511
+ const result = compileJSX(source, 'Bounded.tsx', { adapter })
512
+ const bf110 = result.errors.find(e => e.code === 'BF110')
513
+ expect(bf110).toBeDefined()
514
+ expect(bf110!.message).toContain('useBounded')
515
+ const clientJs = result.files.find(f => f.type === 'clientJs')
516
+ if (clientJs) {
517
+ expect(clientJs.content).not.toContain('if (0 > 10)')
518
+ }
519
+ })
520
+
521
+ test('T2: return inside try/catch declines', () => {
522
+ const source = `
523
+ 'use client'
524
+ import { createSignal } from '@barefootjs/client'
525
+ function useBounded(initial: number, max: number) {
526
+ const [n, setN] = createSignal(initial)
527
+ const bump = () => setN(Math.min(n() + 1, max))
528
+ try {
529
+ return { n, bump }
530
+ } catch {}
531
+ return { n, bump }
532
+ }
533
+ export function Bounded() {
534
+ const { n, bump } = useBounded(0, 10)
535
+ return <button onClick={bump}>{n()}</button>
536
+ }
537
+ `
538
+
539
+ const ctx = analyzeComponent(source, 'Bounded.tsx')
540
+ expect(ctx.signals.length).toBe(0)
541
+
542
+ const result = compileJSX(source, 'Bounded.tsx', { adapter })
543
+ const bf110 = result.errors.find(e => e.code === 'BF110')
544
+ expect(bf110).toBeDefined()
545
+ })
546
+
547
+ test('T3: return inside a for-loop declines', () => {
548
+ const source = `
549
+ 'use client'
550
+ import { createSignal } from '@barefootjs/client'
551
+ function useBounded(initial: number, max: number, seed: number[]) {
552
+ const [n, setN] = createSignal(initial)
553
+ const bump = () => setN(Math.min(n() + 1, max))
554
+ for (const x of seed) {
555
+ if (x < 0) return { n, bump }
556
+ }
557
+ return { n, bump }
558
+ }
559
+ export function Bounded() {
560
+ const { n, bump } = useBounded(0, 10, [1, 2, 3])
561
+ return <button onClick={bump}>{n()}</button>
562
+ }
563
+ `
564
+
565
+ const ctx = analyzeComponent(source, 'Bounded.tsx')
566
+ expect(ctx.signals.length).toBe(0)
567
+
568
+ const result = compileJSX(source, 'Bounded.tsx', { adapter })
569
+ const bf110 = result.errors.find(e => e.code === 'BF110')
570
+ expect(bf110).toBeDefined()
571
+ })
572
+
573
+ test('T4: braced-arrow-callback returns do NOT decline (false-positive guard)', () => {
574
+ const source = `
575
+ 'use client'
576
+ import { createSignal } from '@barefootjs/client'
577
+ function createCounter(initial: number) {
578
+ const [n, setN] = createSignal(initial)
579
+ const bump = () => { return setN(n() + 1) }
580
+ return { n, bump }
581
+ }
582
+ export function Counter() {
583
+ const { n, bump } = createCounter(0)
584
+ return <button onClick={bump}>{n()}</button>
585
+ }
586
+ `
587
+
588
+ const result = compileJSX(source, 'Counter.tsx', { adapter })
589
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
590
+ const ctx = analyzeComponent(source, 'Counter.tsx')
591
+ expect(ctx.signals.length).toBe(1)
592
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
593
+ expect(clientJs).toContain('createSignal')
594
+ })
595
+
596
+ test('T4b: return inside an object-literal method does NOT decline (Copilot review, PR #2342)', () => {
597
+ // The nested-return boundary check originally stopped only at
598
+ // FunctionDeclaration/FunctionExpression/ArrowFunction, so a `return`
599
+ // inside an object-literal method (or a class method/accessor) declared
600
+ // in the factory body was incorrectly counted toward the total, wrongly
601
+ // declassifying an otherwise-inlinable factory. ts.isFunctionLike
602
+ // (methods/accessors/constructors, not just plain functions) fixes it.
603
+ const source = `
604
+ 'use client'
605
+ import { createSignal } from '@barefootjs/client'
606
+ function createLogger(initial: number) {
607
+ const [n, setN] = createSignal(initial)
608
+ const logger = {
609
+ describe() {
610
+ if (n() > 0) return 'positive'
611
+ return 'non-positive'
612
+ },
613
+ }
614
+ return { n, setN, logger }
615
+ }
616
+ export function Counter() {
617
+ const { n, setN, logger } = createLogger(0)
618
+ return <button onClick={() => setN(n() + 1)}>{logger.describe()}</button>
619
+ }
620
+ `
621
+
622
+ const result = compileJSX(source, 'Counter.tsx', { adapter })
623
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
624
+ const ctx = analyzeComponent(source, 'Counter.tsx')
625
+ expect(ctx.signals.length).toBe(1)
626
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
627
+ expect(clientJs).toContain('createSignal')
628
+ expect(clientJs).toContain("'positive'")
629
+ })
630
+
631
+ describe('T5: cross-file guard-clause factory', () => {
632
+ let fixtureDir: string
633
+
634
+ beforeAll(() => {
635
+ fixtureDir = mkdtempSync(path.join(tmpdir(), 'bf-factory-guard-clause-'))
636
+ })
637
+
638
+ afterAll(() => {
639
+ rmSync(fixtureDir, { recursive: true, force: true })
640
+ })
641
+
642
+ function writeFixture(name: string, content: string): string {
643
+ const p = path.join(fixtureDir, name)
644
+ mkdirSync(path.dirname(p), { recursive: true })
645
+ writeFileSync(p, content, 'utf8')
646
+ return p
647
+ }
648
+
649
+ test('guard-clause factory in an imported helper declines with BF110 (reactive-shaped path)', () => {
650
+ writeFixture('hooks-bounded.tsx', `'use client'
651
+ import { createSignal } from '@barefootjs/client'
652
+
653
+ export function useBounded(initial: number, max: number) {
654
+ const [n, setN] = createSignal(initial)
655
+ const bump = () => setN(Math.min(n() + 1, max))
656
+ if (initial > max) {
657
+ return { n, bump }
658
+ }
659
+ return { n, bump }
660
+ }
661
+ `)
662
+ const consumerSource = `'use client'
663
+ import { useBounded } from './hooks-bounded'
664
+
665
+ export function Bounded() {
666
+ const { n, bump } = useBounded(0, 10)
667
+ return <button onClick={bump}>{n()}</button>
668
+ }
669
+ `
670
+ const consumerPath = writeFixture('bounded-consumer.tsx', consumerSource)
671
+
672
+ const result = compileJSX(consumerSource, consumerPath, { adapter })
673
+ const bf110 = result.errors.find(e => e.code === 'BF110')
674
+ expect(bf110).toBeDefined()
675
+ expect(bf110!.message).toContain('does not match the inlinable factory shape')
676
+ })
677
+ })
678
+ })
679
+
680
+ describe('Real-world factory matrix (#2341)', () => {
681
+ test('M1: onMount/onCleanup inside a factory body', () => {
682
+ const source = `
683
+ 'use client'
684
+ import { createSignal, onMount, onCleanup } from '@barefootjs/client'
685
+
686
+ function useTick() {
687
+ const [tick, setTick] = createSignal(0)
688
+ onMount(() => setTick(1))
689
+ onCleanup(() => setTick(0))
690
+ return { tick, setTick }
691
+ }
692
+
693
+ export function Ticker() {
694
+ const { tick, setTick } = useTick()
695
+ return <button onClick={() => setTick(tick() + 1)}>{tick()}</button>
696
+ }
697
+ `
698
+
699
+ const ctx = analyzeComponent(source, 'Ticker.tsx')
700
+ expect(ctx.signals.length).toBe(1)
701
+
702
+ const result = compileJSX(source, 'Ticker.tsx', { adapter })
703
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
704
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
705
+ expect(clientJs).toContain('onMount(')
706
+ expect(clientJs).toContain('onCleanup(')
707
+ })
708
+
709
+ test('M2: createEffect inside a factory body', () => {
710
+ const source = `
711
+ 'use client'
712
+ import { createSignal, createEffect } from '@barefootjs/client'
713
+
714
+ function useTitle(initial: number) {
715
+ const [count, setCount] = createSignal(initial)
716
+ createEffect(() => { document.title = String(count()) })
717
+ return [count, setCount] as const
718
+ }
719
+
720
+ export function TitleUpdater() {
721
+ const [count, setCount] = useTitle(0)
722
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
723
+ }
724
+ `
725
+
726
+ const result = compileJSX(source, 'TitleUpdater.tsx', { adapter })
727
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
728
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
729
+ expect(clientJs).toContain('createEffect')
730
+ })
731
+
732
+ test('M3: createDisposableEffect inside a factory body', () => {
733
+ const source = `
734
+ 'use client'
735
+ import { createSignal, createDisposableEffect } from '@barefootjs/client'
736
+
737
+ function useDisposableTitle(initial: number) {
738
+ const [count, setCount] = createSignal(initial)
739
+ createDisposableEffect(() => { document.title = String(count()) })
740
+ return [count, setCount] as const
741
+ }
742
+
743
+ export function DisposableTitleUpdater() {
744
+ const [count, setCount] = useDisposableTitle(0)
745
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
746
+ }
747
+ `
748
+
749
+ const result = compileJSX(source, 'DisposableTitleUpdater.tsx', { adapter })
750
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
751
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
752
+ expect(clientJs).toContain('createDisposableEffect')
753
+ })
754
+
755
+ test('M4: realistic store (Sora useListStore scale) — 3 signals + 2 memos', () => {
756
+ const source = `
757
+ 'use client'
758
+ import { createSignal, createMemo } from '@barefootjs/client'
759
+
760
+ function useListStore(initial: string[]) {
761
+ const [items, setItems] = createSignal(initial)
762
+ const [filter, setFilter] = createSignal('')
763
+ const [editing, setEditing] = createSignal('')
764
+ const visible = createMemo(() => items().filter(i => i.includes(filter())))
765
+ const count = createMemo(() => visible().length)
766
+ const clear = () => setItems([])
767
+ return { items, setItems, filter, setFilter, editing, setEditing, visible, count, clear }
768
+ }
769
+
770
+ export function ListView() {
771
+ const { items, setItems, filter, setFilter, editing, setEditing, visible, count, clear } = useListStore([])
772
+ return (
773
+ <div onClick={() => { setItems([...items(), 'x']); setFilter('a'); setEditing('x'); clear() }}>
774
+ {visible().length} / {count()} / {editing()}
775
+ </div>
776
+ )
777
+ }
778
+ `
779
+
780
+ const ctx = analyzeComponent(source, 'ListView.tsx')
781
+ expect(ctx.signals.length).toBe(3)
782
+ expect(ctx.memos.length).toBe(2)
783
+
784
+ const result = compileJSX(source, 'ListView.tsx', { adapter })
785
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
786
+ })
787
+
788
+ test('M5: identifier hygiene across 3 call sites of the same factory', () => {
789
+ // Each call site inlines a fresh copy of the factory body; the
790
+ // undestructured internal `setValue` must be suffix-renamed uniquely
791
+ // per call site (_bf0/_bf1/_bf2) while `value`/`bump` are renamed
792
+ // directly to each call site's own tuple names.
793
+ const source = `
794
+ 'use client'
795
+ import { createSignal } from '@barefootjs/client'
796
+
797
+ function createPair(initial: number) {
798
+ const [value, setValue] = createSignal(initial)
799
+ const bump = () => setValue(value() + 1)
800
+ return [value, bump] as const
801
+ }
802
+
803
+ export function Triple() {
804
+ const [a, bumpA] = createPair(1)
805
+ const [b, bumpB] = createPair(2)
806
+ const [c, bumpC] = createPair(3)
807
+ return <button onClick={() => { bumpA(); bumpB(); bumpC() }}>{a()} {b()} {c()}</button>
808
+ }
809
+ `
810
+
811
+ const result = compileJSX(source, 'Triple.tsx', { adapter })
812
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
813
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
814
+ expect(clientJs.match(/createSignal\(/g)?.length).toBe(3)
815
+ expect(clientJs).toContain('_bf0')
816
+ expect(clientJs).toContain('_bf1')
817
+ expect(clientJs).toContain('_bf2')
818
+ })
819
+
820
+ test('M6: generic factory — type arguments do not disturb inlining', () => {
821
+ const source = `
822
+ 'use client'
823
+ import { createSignal } from '@barefootjs/client'
824
+
825
+ function createBox<T>(initial: T) {
826
+ const [v, setV] = createSignal<T>(initial)
827
+ return { v, setV }
828
+ }
829
+
830
+ export function Box() {
831
+ const { v, setV } = createBox<string>('x')
832
+ return <button onClick={() => setV('y')}>{v()}</button>
833
+ }
834
+ `
835
+
836
+ const ctx = analyzeComponent(source, 'Box.tsx')
837
+ expect(ctx.signals.length).toBe(1)
838
+
839
+ const result = compileJSX(source, 'Box.tsx', { adapter })
840
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
841
+ })
842
+
843
+ test('M7: mixed reactive/non-reactive body — plain locals are also renamed correctly', () => {
844
+ const source = `
845
+ 'use client'
846
+ import { createSignal } from '@barefootjs/client'
847
+
848
+ function createSession(initial: number) {
849
+ const startedAt = Date.now()
850
+ const label = \`session-\${startedAt}\`
851
+ const [count, setCount] = createSignal(initial)
852
+ const bump = () => { setCount(count() + 1); console.log(label) }
853
+ return [count, bump] as const
854
+ }
855
+
856
+ export function Session() {
857
+ const [count, bump] = createSession(0)
858
+ return <button onClick={bump}>{count()}</button>
859
+ }
860
+ `
861
+
862
+ const result = compileJSX(source, 'Session.tsx', { adapter })
863
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
864
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
865
+ expect(clientJs).toContain('Date.now()')
866
+ expect(clientJs).toMatch(/startedAt_bf\d+/)
867
+ })
868
+
869
+ test('M8: default-value destructure of an object-return factory declines with BF111', () => {
870
+ const source = `
871
+ 'use client'
872
+ import { createSignal } from '@barefootjs/client'
873
+
874
+ function createCounter(initial: number) {
875
+ const [count, setCount] = createSignal(initial)
876
+ return { count, setCount }
877
+ }
878
+
879
+ export function Counter() {
880
+ const { count = 5 } = createCounter(0)
881
+ return <p>{count}</p>
882
+ }
883
+ `
884
+
885
+ const result = compileJSX(source, 'Counter.tsx', { adapter })
886
+ const bf111 = result.errors.find(e => e.code === 'BF111')
887
+ expect(bf111).toBeDefined()
888
+ })
889
+
890
+ test('M9: unknown destructured property declines with BF110', () => {
891
+ const source = `
892
+ 'use client'
893
+ import { createSignal } from '@barefootjs/client'
894
+
895
+ function createCounter(initial: number) {
896
+ const [count, setCount] = createSignal(initial)
897
+ return { count, setCount }
898
+ }
899
+
900
+ export function Counter() {
901
+ const { missing } = createCounter(0)
902
+ return <p>{missing}</p>
903
+ }
904
+ `
905
+
906
+ const result = compileJSX(source, 'Counter.tsx', { adapter })
907
+ const bf110 = result.errors.find(e => e.code === 'BF110')
908
+ expect(bf110).toBeDefined()
909
+ expect(bf110!.message).toContain('missing')
910
+ expect(bf110!.message).toContain('not present in its return')
911
+ })
912
+
913
+ test('M10: whole-store (non-destructured) call is left untouched', () => {
914
+ // Pins the current fallback posture — a non-destructured factory call
915
+ // is not inlined at all, so none of the factory diagnostics apply.
916
+ // Whether it should eventually be double-invocation-safe is out of
917
+ // scope for #2341 (tracked separately in the issue).
918
+ const source = `
919
+ 'use client'
920
+ import { createSignal } from '@barefootjs/client'
921
+
922
+ function createCounter(initial: number) {
923
+ const [count, setCount] = createSignal(initial)
924
+ return { count, setCount }
925
+ }
926
+
927
+ export function Counter() {
928
+ const store = createCounter(0)
929
+ return <p>{store.count}</p>
930
+ }
931
+ `
932
+
933
+ const result = compileJSX(source, 'Counter.tsx', { adapter })
934
+ expect(result.errors.find(e => e.code?.startsWith('BF11'))).toBeUndefined()
935
+ })
936
+
937
+ test('M11: 3-element tuple return', () => {
938
+ const source = `
939
+ 'use client'
940
+ import { createSignal } from '@barefootjs/client'
941
+
942
+ function createResettable(initial: number) {
943
+ const [value, setValue] = createSignal(initial)
944
+ const reset = () => setValue(initial)
945
+ return [value, setValue, reset] as const
946
+ }
947
+
948
+ export function Resettable() {
949
+ const [count, setCount, resetCount] = createResettable(0)
950
+ return <button onClick={() => { setCount(count() + 1); resetCount() }}>{count()}</button>
951
+ }
952
+ `
953
+
954
+ const ctx = analyzeComponent(source, 'Resettable.tsx')
955
+ expect(ctx.signals.length).toBe(1)
956
+
957
+ const result = compileJSX(source, 'Resettable.tsx', { adapter })
958
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
959
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
960
+ expect(clientJs).toContain('count')
961
+ expect(clientJs).toContain('setCount')
962
+ expect(clientJs).toContain('resetCount')
963
+ })
964
+
965
+ test('M12: a complex (non-atomic) argument expression keeps its parens at the splice site', () => {
966
+ const source = `
967
+ 'use client'
968
+ import { createSignal } from '@barefootjs/client'
969
+
970
+ function createCounter(initial: number) {
971
+ const [count, setCount] = createSignal(initial)
972
+ return [count, setCount] as const
973
+ }
974
+
975
+ export function Counter() {
976
+ const a = 1
977
+ const [count, setCount] = createCounter(a > 0 ? a : 0)
978
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
979
+ }
980
+ `
981
+
982
+ const result = compileJSX(source, 'Counter.tsx', { adapter })
983
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
984
+ const clientJs = result.files.find(f => f.type === 'clientJs')!.content
985
+ expect(clientJs).toContain('(a > 0 ? a : 0)')
986
+ })
987
+
988
+ test('M13: SSR seed value flows through the inlined factory', () => {
989
+ const source = `
990
+ 'use client'
991
+ import { createSignal } from '@barefootjs/client'
992
+
993
+ function createCounter(initial: number) {
994
+ const [count, setCount] = createSignal(initial)
995
+ return [count, setCount] as const
996
+ }
997
+
998
+ export function Counter() {
999
+ const [count, setCount] = createCounter(42)
1000
+ return <button onClick={() => setCount(count() + 1)}>{count()}</button>
1001
+ }
1002
+ `
1003
+
1004
+ const result = compileJSX(source, 'Counter.tsx', { adapter })
1005
+ expect(result.errors.filter(e => e.severity === 'error')).toHaveLength(0)
1006
+ const template = result.files.find(f => f.type === 'markedTemplate')
1007
+ expect(template).toBeDefined()
1008
+ expect(template!.content).toContain('42')
1009
+ })
1010
+ })