@barefootjs/mojolicious 0.17.0 → 0.18.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.
@@ -6,12 +6,10 @@
6
6
 
7
7
  import { describe, test, expect } from 'bun:test'
8
8
  import { MojoAdapter } from '../adapter/mojo-adapter'
9
- import {
10
- runAdapterConformanceTests,
11
- TemplatePrimitiveCaseId,
12
- } from '@barefootjs/adapter-tests'
9
+ import { runAdapterConformanceTests } from '@barefootjs/adapter-tests'
13
10
  import { renderMojoComponent, PerlNotAvailableError } from '../test-render'
14
11
  import { compileJSX, type ComponentIR } from '@barefootjs/jsx'
12
+ import { conformancePins } from '../conformance-pins'
15
13
 
16
14
  runAdapterConformanceTests({
17
15
  name: 'mojo',
@@ -25,134 +23,27 @@ runAdapterConformanceTests({
25
23
  // body children (TableCell) now receive `_bf_slot` for deterministic
26
24
  // parent-scope-derived IDs matching Hono.
27
25
  // Per-fixture build-time contracts for shapes the Mojo adapter
28
- // intentionally refuses to lower. Owned by this adapter test file
29
- // (not by the shared fixtures) so adding a new adapter doesn't
30
- // require touching any cross-adapter file.
31
- expectedDiagnostics: {
32
- // Sibling-imported child component in a loop body: Mojo emits
33
- // a cross-template call that needs separate registration. BF103
34
- // makes the requirement loud. (The barefoot CLI passes
35
- // `siblingTemplatesRegistered: true` so CLI builds suppress it.)
36
- 'static-array-children': [{ code: 'BF103', severity: 'error' }],
37
- // TodoApp / TodoAppSSR import `TodoItem` from a sibling file and
38
- // call it inside a keyed `.map`. Same BF103 surface as the
39
- // synthetic `static-array-children` above pinned at adapter
40
- // level so the shared-component corpus stays adapter-neutral.
41
- 'todo-app': [{ code: 'BF103', severity: 'error' }],
42
- 'todo-app-ssr': [{ code: 'BF103', severity: 'error' }],
43
- // Array-destructure loop param (`([k, v]) => ...`) lowers to
44
- // invalid Perl (`% my $[k, v] = $entries->[$_i];`).
45
- 'static-array-from-props': [{ code: 'BF104', severity: 'error' }],
46
- // Both BF103 (imported child) and BF104 (destructure) fire.
47
- 'static-array-from-props-with-component': [
48
- { code: 'BF103', severity: 'error' },
49
- { code: 'BF104', severity: 'error' },
50
- ],
51
- // #1310: rest destructure in .map() callback. The object-rest shape read
52
- // via member access (`rest-destructure-object-in-map`) now lowers — each
53
- // binding becomes a Perl `my` local off the per-item var (`$rest` aliases
54
- // the item so `$rest->{flag}` resolves). The other three stay refused:
55
- // rest SPREAD (`{...rest}`) needs a residual hash, and array-index /
56
- // nested paths can't unpack into scalar `my`s.
57
- 'rest-destructure-object-spread-in-map': [{ code: 'BF104', severity: 'error' }],
58
- 'rest-destructure-array-in-map': [{ code: 'BF104', severity: 'error' }],
59
- 'rest-destructure-nested-in-map': [{ code: 'BF104', severity: 'error' }],
60
- // `style-3-signals` / `style-object-dynamic` no longer pinned — a
61
- // `style={{ … }}` object literal now lowers to a CSS string with dynamic
62
- // values interpolated (`background-color:<%= $color %>;padding:8px`) via
63
- // `tryLowerStyleObject` (#1322).
64
- // #1244 stress catalog #12 (#1323): tagged-template-literal call
65
- // (`cn\`base \${tone()}\``) — same family as #1322 above and refused
66
- // via the same gate.
67
- 'tagged-template-classname': [{ code: 'BF101', severity: 'error' }],
68
- // #1467 demo-corpus context providers (`radio-group`, `accordion`,
69
- // `dialog`, `popover`, `select`, `dropdown-menu`, `combobox`,
70
- // `command`) are no longer pinned — an object-literal provider value
71
- // (`{ open: () => props.open ?? false, onOpenChange: (v) => {…} }`)
72
- // lowers to a Perl hashref via `parseProviderObjectLiteral` (#1897):
73
- // getter members snapshot their body's SSR value, handler /
74
- // function-shaped members lower to `undef`. The command demo's
75
- // `ref={(el) => {…}}` function prop on an imported component is
76
- // skipped at SSR like `on*` handlers.
77
- //
78
- // #1467 Phase 2e: `data-table` is no longer pinned here either — it
79
- // compiles clean (`selected()[index]` → `index-access`,
80
- // `.toFixed(2)` → `bf->to_fixed`, `/* @client */` memo SSR-folded)
81
- // and renders to Hono parity on real Mojolicious. The keyed-loop
82
- // scope-ID divergence (#1896) was fixed by the body-children
83
- // `inLoop` reset (loop-item children get `_bf_slot`); data-table is
84
- // off `skipJsx` entirely and only kept in `skipMarkerConformance`
85
- // below for the shared `/* @client */` keyed-map slot-id elision
86
- // contract (same as `todo-app`), not a render or BF101 gap.
87
- // #1443: `[a, b].filter(Boolean).join(' ')` (the registry Slot's
88
- // shape) now lowers to `join(' ', @{[grep { $_ } @{[$a, $b]}]})`.
89
- // No BF101 expected — pinned positively via the
90
- // `branch-local-filter-join` template-output test below.
91
- //
92
- // #1448 Tier A — JS Array / String methods that the Mojo adapter
93
- // hasn't lowered yet. Each row drops once the corresponding
94
- // method PR lands. Hono / CSR pass these out of the box (they
95
- // evaluate JS at runtime) so the pin only applies here.
96
- //
97
- // `array-includes` / `string-includes` no longer pinned — both
98
- // shapes lower via the shared `array-method` IR + `bf->includes`
99
- // runtime dispatch (#1448 Tier A first PR).
100
- // `array-indexOf` / `array-lastIndexOf` no longer pinned —
101
- // value-equality `bf->index_of` / `bf->last_index_of` helpers
102
- // handle the shape (#1448 Tier A second PR).
103
- // `array-at` no longer pinned — `bf->at` (Mojo) / `bf_at` (Go)
104
- // handle the negative-index lookup (#1448 Tier A third PR).
105
- // `array-concat` no longer pinned — `bf->concat` (Mojo) /
106
- // `bf_concat` (Go) merge two arrays into a new array
107
- // (#1448 Tier A fourth PR).
108
- // `array-slice` no longer pinned — `bf->slice` (Mojo) /
109
- // `bf_slice` (Go) carve out a sub-range with JS-compat
110
- // negative-index / out-of-bounds clamping (#1448 Tier A
111
- // fifth PR).
112
- // `array-reverse` / `array-toReversed` no longer pinned —
113
- // both share the `bf->reverse` / `bf_reverse` helper since
114
- // SSR templates render a snapshot and the JS mutate-vs-new
115
- // distinction has no template-level meaning (#1448 Tier A
116
- // sixth PR).
117
- // `string-toLowerCase` / `string-toUpperCase` no longer pinned —
118
- // Perl's native `lc` / `uc` (Mojo) and pre-existing
119
- // `bf_lower` / `bf_upper` (Go) handle the JS method names
120
- // (#1448 Tier A seventh + eighth PRs).
121
- // `string-trim` no longer pinned — pre-existing `bf_trim`
122
- // (Go) and new `bf->trim` helper (Mojo) handle the strip
123
- // (#1448 Tier A ninth PR, closing out Tier A).
124
- // `.find` / `.findIndex` / `.findLast` / `.findLastIndex` are no longer
125
- // pinned — the Mojo `callbackMethod` predicate arm now lowers them to the
126
- // runtime `bf->find` / `find_index` / `find_last` / `find_last_index` helpers
127
- // (per-element coderef predicate), matching Xslate. `.join` was never
128
- // pinned (handled by `renderArrayMethod`'s `case 'join'`).
129
- },
130
- // `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` now pass
131
- // via `MojoAdapter.templatePrimitives` (#1189). The two remaining
132
- // cases stay skipped because the V1 registry is identifier-path-
133
- // only and explicit:
134
- // - `USER_IMPORT_VIA_CONST` — a bespoke user import isn't in
135
- // the registry and can't be rendered server-side without
136
- // user-supplied helper mappings.
137
- // - `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` — uses `customSerialize`
138
- // too, same reason.
139
- // Adding new entries to `templatePrimitives` should narrow this
140
- // skip set; see `MOJO_TEMPLATE_PRIMITIVES` in `mojo-adapter.ts`
141
- // for the full V1 surface.
142
- skipTemplatePrimitives: new Set([
143
- TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
144
- TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
145
- ]),
146
- // Mojo `renderLoop` does not yet emit the `bf->comment("loop:<id>")`
147
- // boundary markers when the loop is `@client` (Hono and Go both do).
148
- // The client runtime relies on these markers to locate the insertion
149
- // anchor when hydrating the array; without them, mapArray() resolves
150
- // anchor = null and appends after sibling markers (#872 parity).
151
- // Tracked as a follow-up; remove from this set when Mojo emits the
152
- // boundary pair for clientOnly loops too.
26
+ // intentionally refuses to lower. Lives in `../conformance-pins` (not
27
+ // by the shared fixtures) so adding a new adapter doesn't require
28
+ // touching any cross-adapter file.
29
+ expectedDiagnostics: conformancePins,
30
+ // `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` pass via
31
+ // `MojoAdapter.templatePrimitives` (#1189) the identifier-path
32
+ // registry for well-known JS builtins. `USER_IMPORT_VIA_CONST` and
33
+ // `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` now ALSO pass (#2069): a bespoke
34
+ // user import can never be added to the string-keyed registry, but the
35
+ // shared `RelocateEnv.loweringMatchers` acceptance path recognises it
36
+ // via a `LoweringPlugin` the case setup registers around the compile
37
+ // (see `packages/adapter-tests/src/cases/template-primitives.ts`)the
38
+ // same seam a real userland plugin author would use. No skips left, so
39
+ // `skipTemplatePrimitives` is omitted entirely (defaults to "skip
40
+ // nothing").
41
+ // `client-only` / `client-only-loop-with-sibling-cond` /
42
+ // `filter-nested-callback-predicate-client` are no longer skipped
43
+ // `renderLoop` now emits the `bf->comment("loop:<id>")` boundary pair
44
+ // for clientOnly loops (Hono / Go parity), so mapArray() can locate
45
+ // its insertion anchor at hydration time (#872 / #1087).
153
46
  skipMarkerConformance: new Set([
154
- 'client-only',
155
- 'client-only-loop-with-sibling-cond',
156
47
  // Same as Hono: `/* @client */` markers on TodoApp's keyed `.map`
157
48
  // intentionally elide a slot id from the SSR template that the IR
158
49
  // still declares (s6). See hono-adapter.test for the contract.
@@ -792,6 +683,31 @@ export function C() {
792
683
  expect(template).toContain('scalar(@{[grep { $_->{active} } @{$t->{tags}}]})')
793
684
  })
794
685
 
686
+ test('lowers nested .some(...) in filter predicate to an inline grep — no BF101 (#2038)', () => {
687
+ // The evaluator refuses the nested arrow (`serializeParsedExpr` → null),
688
+ // but the Perl filter emitter has a FAITHFUL form for nested
689
+ // filter / every / some: a real inline `grep` closing over the outer
690
+ // loop var. Pin the emitted EP shape positively so the #2038 loudness
691
+ // fix (which targets the degrade-only arms: nested `find*`,
692
+ // sort / reduce / flatMap — see the `filter-nested-find-predicate`
693
+ // expectedDiagnostics entry) never over-reaches into this supported
694
+ // shape. The rendered-HTML side of this contract lives in the shared
695
+ // `filter-nested-callback-predicate` fixture (Hono-parity render).
696
+ const adapter = new MojoAdapter()
697
+ const result = compileJSX(`'use client'
698
+ import { createSignal } from '@barefootjs/client'
699
+ type Item = { id: number }
700
+ export function Picker() {
701
+ const [items] = createSignal<Item[]>([])
702
+ const [picked] = createSignal<Item[]>([])
703
+ return <ul>{items().filter(t => !picked().some(p => p.id === t.id)).map(t => <li key={t.id}>{t.id}</li>)}</ul>
704
+ }`, 'C.tsx', { adapter })
705
+ expect(result.errors?.filter(e => e.code === 'BF101') ?? []).toEqual([])
706
+ const template = result.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
707
+ expect(template).toContain('grep')
708
+ expect(template).toContain('@{$picked}')
709
+ })
710
+
795
711
  test('lowers .filter(function (x) { return x.done }).map(...) — function-keyword filter (#1443)', () => {
796
712
  // Function expressions with a single `return <expr>` body normalise
797
713
  // to the arrow-fn IR shape at parse time, so the higher-order
@@ -1035,11 +951,26 @@ export { A }`, 'A.tsx', { adapter })
1035
951
  expect(template).toContain('"property":"done"')
1036
952
  }
1037
953
 
1038
- // Fallback: a method-call predicate (`x => x.name.includes('a')`) is
1039
- // outside the evaluator surface, so `.every` keeps the inline grep form.
954
+ // #2075: `.includes(x)` is now in the evaluator surface (`array-method`
955
+ // gate, shared with the Perl `Evaluator.pm` runtime), so a method-call
956
+ // predicate built from it ALSO routes through `every_eval` rather than
957
+ // falling back — it's no longer the "unsupported method call" example.
958
+ const includesAdapter = new MojoAdapter()
959
+ const includesResult = compileJSX(`function A({ items }: { items: { name: string }[] }) {
960
+ return <div>{items.every(x => x.name.includes('a')) ? 'y' : 'n'}</div>
961
+ }
962
+ export { A }`, 'A.tsx', { adapter: includesAdapter })
963
+ const includesTemplate = includesResult.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
964
+ expect(includesTemplate).toContain('bf->every_eval($items,')
965
+ expect(includesTemplate).toContain('"method":"includes"')
966
+ expect(includesTemplate).not.toContain('grep {')
967
+
968
+ // Fallback: a method-call predicate the evaluator still can't model
969
+ // (`.toUpperCase()` is outside the `array-method` gate — only `includes`
970
+ // is recognized there) keeps the inline grep form.
1040
971
  const adapter = new MojoAdapter()
1041
972
  const fb = compileJSX(`function A({ items }: { items: { name: string }[] }) {
1042
- return <div>{items.every(x => x.name.includes('a')) ? 'y' : 'n'}</div>
973
+ return <div>{items.every(x => x.name.toUpperCase() === 'A') ? 'y' : 'n'}</div>
1043
974
  }
1044
975
  export { A }`, 'A.tsx', { adapter })
1045
976
  const fbTemplate = fb.files.find(f => f.path.endsWith('.html.ep'))?.content ?? ''
@@ -1488,6 +1419,110 @@ export { C }
1488
1419
  })
1489
1420
  })
1490
1421
 
1422
+ describe('MojoAdapter - #2075 searchParams()-derived memo seeding', () => {
1423
+ // A memo derived from the createSearchParams() env signal must seed
1424
+ // in-template from the canonical per-request `$searchParams` reader —
1425
+ // including under a local alias (`const [sp] = …`), which the expression
1426
+ // lowering canonicalises.
1427
+ test('seeds an aliased scalar derived memo from the canonical reader', () => {
1428
+ const { template } = compileAndGenerate(`
1429
+ 'use client'
1430
+ import { createMemo, createSearchParams } from '@barefootjs/client'
1431
+ export function SortStatus() {
1432
+ const [sp] = createSearchParams()
1433
+ const sort = createMemo(() => sp().get('sort') ?? 'date')
1434
+ return <p>sort: {sort()}</p>
1435
+ }
1436
+ `)
1437
+ expect(template).toContain("% my $sort = ($searchParams->get('sort') // 'date');")
1438
+ })
1439
+
1440
+ // A list-filter memo chained off the derived memo seeds too: the inline
1441
+ // grep's `$_` topic and the callback param are lowering-internal bindings,
1442
+ // not out-of-scope template vars (the pre-#2075 availability check
1443
+ // rejected them and the list rendered empty at SSR).
1444
+ test('seeds a filter memo chained off the derived memo', () => {
1445
+ const { template } = compileAndGenerate(`
1446
+ 'use client'
1447
+ import { createMemo, createSearchParams } from '@barefootjs/client'
1448
+ export function TaggedList(props: { items: { title: string; tags: string[] }[] }) {
1449
+ const [searchParams] = createSearchParams()
1450
+ const tag = createMemo(() => searchParams().get('tag') ?? '')
1451
+ const visible = createMemo(() => props.items.filter((p) => !tag() || p.tags.includes(tag())))
1452
+ return <ul>{visible().map((p) => <li key={p.title}>{p.title}</li>)}</ul>
1453
+ }
1454
+ `)
1455
+ expect(template).toContain("% my $tag = ($searchParams->get('tag') // '');")
1456
+ expect(template).toMatch(/% my \$visible = \[grep/)
1457
+ })
1458
+
1459
+ // The seed-scope guard used to scan the LOWERED
1460
+ // Perl string, allowing every arrow-callback param tree-wide. That let an
1461
+ // outer, unbound `p` (shadowed only inside the callback) slip past the
1462
+ // guard as if it were the callback's own bound `$p` — emitting a bogus
1463
+ // seed line that would crash Perl strict mode. The guard now walks the
1464
+ // parsed SOURCE tree with proper lexical scoping (`freeIdentifiers`), so
1465
+ // this shape seeds nothing and falls back to the null/ssr-defaults path.
1466
+ test('an outer unbound `p` shadowed only inside the callback does not seed', () => {
1467
+ const { template } = compileAndGenerate(`
1468
+ 'use client'
1469
+ import { createMemo } from '@barefootjs/client'
1470
+ export function C(props: { items: { ok: boolean }[] }) {
1471
+ const visible = createMemo(() => props.items.filter((p) => p.ok) && p)
1472
+ return <div>{String(visible())}</div>
1473
+ }
1474
+ `)
1475
+ expect(template).not.toContain('my $visible')
1476
+ })
1477
+
1478
+ // An out-of-scope bare `_` reference (not the `grep` topic var of an
1479
+ // in-scope higher-order lowering) must not seed either — the old
1480
+ // unconditional `allowed.add('_')` masked this.
1481
+ test('an out-of-scope bare `_` reference does not seed', () => {
1482
+ const { template } = compileAndGenerate(`
1483
+ 'use client'
1484
+ import { createMemo } from '@barefootjs/client'
1485
+ export function C(props: { count: number }) {
1486
+ const doubled = createMemo(() => props.count * 2 + _)
1487
+ return <div>{doubled()}</div>
1488
+ }
1489
+ `)
1490
+ expect(template).not.toContain('my $doubled')
1491
+ })
1492
+ })
1493
+
1494
+ describe('MojoAdapter - #2073 value-producing .map(cb)', () => {
1495
+ function emitMap(expr: string): string {
1496
+ const a = new MojoAdapter()
1497
+ const ir = compileToIR(`
1498
+ function C({ tags, users }: { tags: string[]; users: { name: string }[] }) {
1499
+ return <div>{${expr}}</div>
1500
+ }
1501
+ export { C }
1502
+ `, a)
1503
+ return a.generate(ir).template ?? ''
1504
+ }
1505
+
1506
+ // The blog-showcase shape (#1938/#1939): a value-returning `.map` (string
1507
+ // projection, not JSX) lowers through the evaluator — `bf->map_eval`
1508
+ // projects each element (no flatten) and composes through `.join`.
1509
+ test('.map(t => `#${t}`).join(" ") emits bf->map_eval composed into join', () => {
1510
+ const t = emitMap("tags.map(t => `#${t}`).join(' ')")
1511
+ expect(t).toContain(`join(' ', @{bf->map_eval($tags,`)
1512
+ expect(t).toContain(`"kind":"template-literal"`)
1513
+ })
1514
+
1515
+ test('.map(u => u.name) emits bf->map_eval with the field projection', () => {
1516
+ const t = emitMap("users.map(u => u.name).join(', ')")
1517
+ expect(t).toContain(`bf->map_eval($users,`)
1518
+ expect(t).toContain(`"property":"name"`)
1519
+ })
1520
+
1521
+ // The function-reference `.map(format)` BF101 refusal is now covered
1522
+ // cross-adapter by the `array-map-function-reference` shared fixture's
1523
+ // `expectedDiagnostics` entry above.
1524
+ })
1525
+
1491
1526
  describe('MojoAdapter - #1448 Tier C .flatMap(field projection)', () => {
1492
1527
  function emitFlatMap(expr: string): string {
1493
1528
  const a = new MojoAdapter()
@@ -1706,7 +1741,6 @@ export { C }
1706
1741
  // contract here is: no errors + the comparator never lowers + no
1707
1742
  // rendered `<li>` survives.
1708
1743
  const unsupportedSort: Array<[string, string]> = [
1709
- ['function-reference comparator', `items().toSorted(myCmp).map(x => <li key={x.name}>{x.name}</li>)`],
1710
1744
  ['localeCompare locale/options arg', `items().toSorted((a, b) => a.name.localeCompare(b.name, "ja", { numeric: true })).map(x => <li key={x.name}>{x.name}</li>)`],
1711
1745
  ]
1712
1746
  for (const [label, chain] of unsupportedSort) {
@@ -1730,6 +1764,17 @@ export function C() {
1730
1764
  })
1731
1765
  }
1732
1766
 
1767
+ // #2090: a function-reference comparator (`.toSorted(myCmp)`, `myCmp` a
1768
+ // same-file const arrow) now resolves through the analyzer's scope
1769
+ // machinery and compiles — no BF021, and the sort lowers exactly like an
1770
+ // inline comparator (a `bf->sort` / `bf->sort_eval` call in the template).
1771
+ test('sort follow-up (function-reference comparator): resolves and compiles without BF021', () => {
1772
+ const chain = `items().toSorted(myCmp).map(x => <li key={x.name}>{x.name}</li>)`
1773
+ const result = emitLoop(chain, false)
1774
+ expect(result.errors).toEqual([])
1775
+ expect(result.template).toMatch(/bf->sort/)
1776
+ })
1777
+
1733
1778
  // End-to-end proof via perl + Mojolicious: the `@client` form renders
1734
1779
  // a `<!--bf-client:sN-->` placeholder. The bare form is now caught at
1735
1780
  // build with BF101 and degrades to an empty, render-safe slot (no
@@ -0,0 +1,215 @@
1
+ // Multi-component registry modules on the Mojo plugin path (#2132).
2
+ //
3
+ // A registry module that exports several components from one file —
4
+ // `ui/toast/index.tsx` exporting ToastProvider / Toast / ToastTitle — compiles
5
+ // to one EP template PER component, and every compiled parent invokes each one
6
+ // under its snake_cased name (`bf->render_child('toast_provider')`). The
7
+ // manifest entry used to carry only the module's FIRST template, so
8
+ // `register_components_from_manifest` registered nothing for the
9
+ // sub-components and every page using Toast / Dialog / Tabs 500'd with
10
+ // "No renderer registered for child component 'toast_provider'".
11
+ //
12
+ // This is the in-repo equivalent of the issue's repro: `bf add toast` →
13
+ // render a <Toast>-using component on mojo → expect 200 with toast markup.
14
+ // It boots a real Mojolicious app with the production plugin against
15
+ // compiler-produced templates plus a manifest in the shape `bf build` now
16
+ // emits (per-component rows under `components` — pinned on the emitter side
17
+ // by packages/cli/src/__tests__/build-manifest-components.test.ts).
18
+ //
19
+ // Runs only when `perl` with Mojolicious is installed (same skip policy as
20
+ // stock-route.test.ts, which this file mirrors).
21
+
22
+ import { describe, test, expect, beforeAll } from 'bun:test'
23
+ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
24
+ import { tmpdir } from 'node:os'
25
+ import path from 'node:path'
26
+ import { compileJSX } from '@barefootjs/jsx'
27
+ import { MojoAdapter } from '../adapter/mojo-adapter'
28
+
29
+ const MOJO_LIB_DIR = path.resolve(import.meta.dir, '../../lib')
30
+ const PERL_CORE_LIB_DIR = path.resolve(import.meta.dir, '../../../adapter-perl/lib')
31
+
32
+ function perlWithMojoAvailable(): boolean {
33
+ try {
34
+ const proc = Bun.spawnSync(['perl', '-MMojolicious', '-MTest::Mojo', '-e1'], {
35
+ env: process.env,
36
+ })
37
+ return proc.exitCode === 0
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ const PERL_AVAILABLE = perlWithMojoAvailable()
44
+
45
+ // The registry-toast shape: one module, several exported components, one of
46
+ // them ("Toast") snake_casing to the module's directory name — the collision
47
+ // that used to render the WRONG template even when the name resolved.
48
+ const TOAST_TSX = `'use client'
49
+
50
+ interface ToastProviderProps {
51
+ children?: any
52
+ }
53
+
54
+ export function ToastProvider({ children }: ToastProviderProps) {
55
+ return <div data-slot="toast-provider">{children}</div>
56
+ }
57
+
58
+ interface ToastProps {
59
+ open?: boolean
60
+ children?: any
61
+ }
62
+
63
+ export function Toast({ open = false, children }: ToastProps) {
64
+ return (
65
+ <div data-slot="toast" data-state={open ? 'open' : 'closed'}>
66
+ {children}
67
+ </div>
68
+ )
69
+ }
70
+
71
+ export function ToastTitle({ children }: { children?: any }) {
72
+ return <div data-slot="toast-title">{children}</div>
73
+ }
74
+ `
75
+
76
+ // The issue's ToastProbe: a page component driving the module's
77
+ // sub-components, with a signal feeding Toast's `open` prop.
78
+ const PROBE_TSX = `'use client'
79
+ import { createSignal } from '@barefootjs/client'
80
+ import { ToastProvider, Toast, ToastTitle } from '@/components/ui/toast'
81
+
82
+ export function ToastProbe() {
83
+ const [open, setOpen] = createSignal(true)
84
+ return (
85
+ <ToastProvider>
86
+ <Toast open={open()}>
87
+ <ToastTitle>hi</ToastTitle>
88
+ </Toast>
89
+ </ToastProvider>
90
+ )
91
+ }
92
+ `
93
+
94
+ const APP_PL = `#!/usr/bin/env perl
95
+ use Mojolicious::Lite -signatures;
96
+
97
+ plugin 'BarefootJS';
98
+
99
+ app->renderer->paths->[0] = app->home->child('dist/templates');
100
+
101
+ get '/toast-probe' => sub ($c) {
102
+ $c->render(template => 'ToastProbe', layout => 'default');
103
+ };
104
+
105
+ app->start;
106
+
107
+ __DATA__
108
+
109
+ @@ layouts/default.html.ep
110
+ <!DOCTYPE html>
111
+ <html><body>
112
+ <main><%== content %></main>
113
+ %== $c->bf->scripts
114
+ </body></html>
115
+ `
116
+
117
+ const SMOKE_PL = `use Mojo::Base -strict;
118
+ use Test::More;
119
+ use Test::Mojo;
120
+ use Mojo::File qw(curfile);
121
+
122
+ my $t = Test::Mojo->new(curfile->dirname->child('app.pl'));
123
+
124
+ # The issue's regression bar: 200 with toast markup (was a 500 with
125
+ # "No renderer registered for child component 'toast_provider'").
126
+ $t->get_ok('/toast-probe')->status_is(200)
127
+ ->element_exists('[data-slot="toast-provider"]', 'provider rendered')
128
+ ->element_exists('[data-slot="toast"]', 'toast rendered')
129
+ ->element_exists('[data-slot="toast-title"]', 'title rendered')
130
+ ->text_like('[data-slot="toast-title"]', qr/hi/, 'children reached the title')
131
+ ->element_exists('[data-slot="toast"][data-state="open"]',
132
+ 'parent signal reached the sub-component prop (not Toast\\'s own open=false default)');
133
+
134
+ done_testing;
135
+ `
136
+
137
+ describe.skipIf(!PERL_AVAILABLE)('Mojo multi-component registry modules (#2132)', () => {
138
+ let appDir: string
139
+
140
+ beforeAll(() => {
141
+ appDir = mkdtempSync(path.join(tmpdir(), 'bf-mojo-multi-component-'))
142
+ mkdirSync(path.join(appDir, 'dist/templates/ui/toast'), { recursive: true })
143
+
144
+ const adapter = new MojoAdapter()
145
+
146
+ // Compile the multi-component module: one template + ssr-defaults pair
147
+ // per exported component, each stamped with its componentName.
148
+ const toastResult = compileJSX(TOAST_TSX, 'components/ui/toast/index.tsx', { adapter })
149
+ const toastErrors = toastResult.errors.filter(e => e.severity === 'error')
150
+ if (toastErrors.length > 0) {
151
+ throw new Error(`toast module compile failed:\n${toastErrors.map(e => e.message).join('\n')}`)
152
+ }
153
+ const componentRows: Record<string, { markedTemplate: string; ssrDefaults?: unknown }> = {}
154
+ const toastTemplates = toastResult.files.filter(f => f.type === 'markedTemplate')
155
+ for (const tpl of toastTemplates) {
156
+ const fileName = path.basename(tpl.path)
157
+ writeFileSync(path.join(appDir, 'dist/templates/ui/toast', fileName), tpl.content)
158
+ const defaults = toastResult.files.find(
159
+ f => f.type === 'ssrDefaults' && f.componentName === tpl.componentName,
160
+ )
161
+ componentRows[tpl.componentName!] = {
162
+ markedTemplate: `templates/ui/toast/${fileName}`,
163
+ ...(defaults ? { ssrDefaults: JSON.parse(defaults.content) } : {}),
164
+ }
165
+ }
166
+ expect(Object.keys(componentRows).sort()).toEqual(['Toast', 'ToastProvider', 'ToastTitle'])
167
+
168
+ // Compile the probe page.
169
+ const probeResult = compileJSX(PROBE_TSX, 'components/ToastProbe.tsx', { adapter })
170
+ const probeErrors = probeResult.errors.filter(e => e.severity === 'error')
171
+ if (probeErrors.length > 0) {
172
+ throw new Error(`probe compile failed:\n${probeErrors.map(e => e.message).join('\n')}`)
173
+ }
174
+ const probeTemplate = probeResult.files.find(f => f.type === 'markedTemplate')
175
+ const probeDefaults = probeResult.files.find(f => f.type === 'ssrDefaults')
176
+ if (!probeTemplate) throw new Error('probe compile produced no template')
177
+ writeFileSync(path.join(appDir, 'dist/templates/ToastProbe.html.ep'), probeTemplate.content)
178
+
179
+ // Same entry shape `bf build` writes to dist/templates/manifest.json
180
+ // (see build-manifest-components.test.ts for the emitter pin).
181
+ writeFileSync(
182
+ path.join(appDir, 'dist/templates/manifest.json'),
183
+ JSON.stringify({
184
+ ToastProbe: {
185
+ markedTemplate: 'templates/ToastProbe.html.ep',
186
+ ...(probeDefaults ? { ssrDefaults: JSON.parse(probeDefaults.content) } : {}),
187
+ },
188
+ 'ui/toast/index': {
189
+ markedTemplate: componentRows[toastTemplates[0].componentName!].markedTemplate,
190
+ components: componentRows,
191
+ },
192
+ }),
193
+ )
194
+ writeFileSync(path.join(appDir, 'app.pl'), APP_PL)
195
+ writeFileSync(path.join(appDir, 'smoke.pl'), SMOKE_PL)
196
+ })
197
+
198
+ test('a <Toast>-using page renders 200 with toast markup', async () => {
199
+ const perl5lib = [MOJO_LIB_DIR, PERL_CORE_LIB_DIR, process.env.PERL5LIB]
200
+ .filter(Boolean)
201
+ .join(':')
202
+ const proc = Bun.spawn(['perl', 'smoke.pl'], {
203
+ cwd: appDir,
204
+ env: { ...process.env, PERL5LIB: perl5lib },
205
+ stdout: 'pipe',
206
+ stderr: 'pipe',
207
+ })
208
+ const [stdout, stderr, exitCode] = await Promise.all([
209
+ new Response(proc.stdout).text(),
210
+ new Response(proc.stderr).text(),
211
+ proc.exited,
212
+ ])
213
+ expect(exitCode, `TAP output:\n${stdout}\n${stderr}`).toBe(0)
214
+ })
215
+ })