@barefootjs/mojolicious 0.17.1 → 0.18.1

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.
@@ -1,5 +1,5 @@
1
1
  package Mojolicious::Plugin::BarefootJS;
2
- our $VERSION = "0.17.0";
2
+ our $VERSION = "0.18.0";
3
3
  use Mojo::Base 'Mojolicious::Plugin', -signatures;
4
4
 
5
5
  use Mojo::File qw(path);
@@ -31,28 +31,64 @@ sub register ($self, $app, $config = {}) {
31
31
  $c->stash->{'bf.instance'} //= BarefootJS->new($c, $config);
32
32
  });
33
33
 
34
- my $manifest = _load_manifest($app, $config);
35
- return unless $manifest;
36
-
37
- # Cache the set of UI-registry slot keys so we can answer
38
- # "is this template name a child or a top-level page?" with a
39
- # single hash lookup at render time. Top-level entries are
40
- # everything that isn't `__barefoot__` and doesn't match
41
- # `ui/<name>/index` the same partition `register_components_from_manifest`
42
- # applies internally.
43
- my %is_child_entry;
44
- for my $entry_name (keys %$manifest) {
45
- next if $entry_name eq '__barefoot__';
46
- next unless $entry_name =~ m{^ui/[^/]+/index$};
47
- $is_child_entry{$entry_name} = 1;
48
- }
34
+ return if exists $config->{manifest_path} && !defined $config->{manifest_path};
35
+ my $manifest_path = path(
36
+ $config->{manifest_path} // $app->home->child('dist/templates/manifest.json'));
37
+
38
+ # Manifest resolution is lazy, re-checked per render (#2126). The
39
+ # scaffold's dev script starts `bf build --watch` and the web server
40
+ # concurrently, so the app routinely boots before the first build has
41
+ # written `manifest.json`. Loading once at register time turned that
42
+ # startup race into a permanent failure: auto-init stayed disabled for
43
+ # the server's lifetime and every top-level render died under strict
44
+ # (`Global symbol "$initial" requires explicit package name`). The
45
+ # cache is keyed on the file's (mtime, size), so the steady-state cost
46
+ # is one stat() per render — and `bf build --watch` rebuilds (new
47
+ # ssrDefaults, `bf add`ed components) are picked up without a restart.
48
+ my ($cached_sig, $manifest, $is_child_entry);
49
+ my $load_manifest = sub {
50
+ my ($size, $mtime) = (stat "$manifest_path")[7, 9];
51
+ unless (defined $mtime) {
52
+ # Missing (or unreadable) — drop the cache so the manifest is
53
+ # re-read as soon as the first build writes it.
54
+ ($cached_sig, $manifest, $is_child_entry) = (undef, undef, undef);
55
+ return undef;
56
+ }
57
+ my $sig = "$mtime/$size";
58
+ return $manifest if defined $cached_sig && $cached_sig eq $sig;
59
+ # Cache parse failures under the same signature so a broken file
60
+ # warns once, not on every render, and retries when it changes.
61
+ $cached_sig = $sig;
62
+ ($manifest, $is_child_entry) = (undef, undef);
63
+ my $m = eval { decode_json($manifest_path->slurp) };
64
+ if ($@ || ref($m) ne 'HASH') {
65
+ $app->log->warn("BarefootJS: cannot parse manifest at $manifest_path: $@") if $@;
66
+ return undef;
67
+ }
68
+ $manifest = $m;
69
+
70
+ # Cache the set of UI-registry slot keys so we can answer
71
+ # "is this template name a child or a top-level page?" with a
72
+ # single hash lookup at render time. Top-level entries are
73
+ # everything that isn't `__barefoot__` and doesn't match
74
+ # `ui/<name>/index` — the same partition `register_components_from_manifest`
75
+ # applies internally.
76
+ $is_child_entry = {};
77
+ for my $entry_name (keys %$manifest) {
78
+ next if $entry_name eq '__barefoot__';
79
+ next unless $entry_name =~ m{^ui/[^/]+/index$};
80
+ $is_child_entry->{$entry_name} = 1;
81
+ }
82
+ return $manifest;
83
+ };
49
84
 
50
85
  $app->hook(before_render => sub ($c, $args) {
51
86
  my $template = $args->{template};
52
87
  return unless defined $template && length $template;
53
- my $entry = $manifest->{$template};
88
+ my $m = $load_manifest->() or return;
89
+ my $entry = $m->{$template};
54
90
  return unless $entry;
55
- return if $is_child_entry{$template};
91
+ return if $is_child_entry->{$template};
56
92
  # Idempotency guard for nested renders. A controller might
57
93
  # call `render_to_string` inside an action and then `render`
58
94
  # — without this we'd re-init `bf` on the second pass and
@@ -72,7 +108,7 @@ sub register ($self, $app, $config = {}) {
72
108
  $c->stash->{'bf.auto_init_done'} = 1;
73
109
 
74
110
  $bf->_scope_id($template . '_' . substr(rand() =~ s/^0\.//r, 0, 6));
75
- $bf->register_components_from_manifest($manifest);
111
+ $bf->register_components_from_manifest($m);
76
112
 
77
113
  # Seed each ssrDefault into the stash unless the caller has
78
114
  # already supplied a value for that key — callers always win.
@@ -98,20 +134,6 @@ sub register ($self, $app, $config = {}) {
98
134
  });
99
135
  }
100
136
 
101
- sub _load_manifest ($app, $config) {
102
- return undef if exists $config->{manifest_path} && !defined $config->{manifest_path};
103
- my $manifest_path = $config->{manifest_path}
104
- // $app->home->child('dist/templates/manifest.json');
105
- my $file = path($manifest_path);
106
- return undef unless -r $file;
107
- my $manifest = eval { decode_json($file->slurp) };
108
- if ($@ || ref($manifest) ne 'HASH') {
109
- $app->log->warn("BarefootJS: cannot parse manifest at $file: $@") if $@;
110
- return undef;
111
- }
112
- return $manifest;
113
- }
114
-
115
137
  1;
116
138
  __END__
117
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@barefootjs/mojolicious",
3
- "version": "0.17.1",
3
+ "version": "0.18.1",
4
4
  "description": "Mojolicious EP template adapter for BarefootJS - generates .html.ep files from IR",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -52,7 +52,7 @@
52
52
  "directory": "packages/adapter-mojolicious"
53
53
  },
54
54
  "dependencies": {
55
- "@barefootjs/shared": "0.17.1"
55
+ "@barefootjs/shared": "0.18.1"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@barefootjs/jsx": ">=0.2.0",
@@ -60,6 +60,6 @@
60
60
  },
61
61
  "devDependencies": {
62
62
  "@barefootjs/adapter-tests": "0.1.0",
63
- "@barefootjs/jsx": "0.17.1"
63
+ "@barefootjs/jsx": "0.18.1"
64
64
  }
65
65
  }
@@ -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,137 +23,21 @@ 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
- // #2038: a filter predicate containing a nested `.find(...)` callback.
69
- // `find*` returns an element, not a boolean — there is no inline grep
70
- // form, and the emitter used to degrade the call to its receiver.
71
- // The nested `.some` sibling (`filter-nested-callback-predicate`) is
72
- // NOT pinned: Mojo lowers it to a real inline Perl `grep` and must
73
- // render to Hono parity instead.
74
- // https://github.com/piconic-ai/barefootjs/issues/2038
75
- 'filter-nested-find-predicate': [{ code: 'BF101', severity: 'error' }],
76
- // #1467 demo-corpus context providers (`radio-group`, `accordion`,
77
- // `dialog`, `popover`, `select`, `dropdown-menu`, `combobox`,
78
- // `command`) are no longer pinned — an object-literal provider value
79
- // (`{ open: () => props.open ?? false, onOpenChange: (v) => {…} }`)
80
- // lowers to a Perl hashref via `parseProviderObjectLiteral` (#1897):
81
- // getter members snapshot their body's SSR value, handler /
82
- // function-shaped members lower to `undef`. The command demo's
83
- // `ref={(el) => {…}}` function prop on an imported component is
84
- // skipped at SSR like `on*` handlers.
85
- //
86
- // #1467 Phase 2e: `data-table` is no longer pinned here either — it
87
- // compiles clean (`selected()[index]` → `index-access`,
88
- // `.toFixed(2)` → `bf->to_fixed`, `/* @client */` memo SSR-folded)
89
- // and renders to Hono parity on real Mojolicious. The keyed-loop
90
- // scope-ID divergence (#1896) was fixed by the body-children
91
- // `inLoop` reset (loop-item children get `_bf_slot`); data-table is
92
- // off `skipJsx` entirely and only kept in `skipMarkerConformance`
93
- // below for the shared `/* @client */` keyed-map slot-id elision
94
- // contract (same as `todo-app`), not a render or BF101 gap.
95
- // #1443: `[a, b].filter(Boolean).join(' ')` (the registry Slot's
96
- // shape) now lowers to `join(' ', @{[grep { $_ } @{[$a, $b]}]})`.
97
- // No BF101 expected — pinned positively via the
98
- // `branch-local-filter-join` template-output test below.
99
- //
100
- // #1448 Tier A — JS Array / String methods that the Mojo adapter
101
- // hasn't lowered yet. Each row drops once the corresponding
102
- // method PR lands. Hono / CSR pass these out of the box (they
103
- // evaluate JS at runtime) so the pin only applies here.
104
- //
105
- // `array-includes` / `string-includes` no longer pinned — both
106
- // shapes lower via the shared `array-method` IR + `bf->includes`
107
- // runtime dispatch (#1448 Tier A first PR).
108
- // `array-indexOf` / `array-lastIndexOf` no longer pinned —
109
- // value-equality `bf->index_of` / `bf->last_index_of` helpers
110
- // handle the shape (#1448 Tier A second PR).
111
- // `array-at` no longer pinned — `bf->at` (Mojo) / `bf_at` (Go)
112
- // handle the negative-index lookup (#1448 Tier A third PR).
113
- // `array-concat` no longer pinned — `bf->concat` (Mojo) /
114
- // `bf_concat` (Go) merge two arrays into a new array
115
- // (#1448 Tier A fourth PR).
116
- // `array-slice` no longer pinned — `bf->slice` (Mojo) /
117
- // `bf_slice` (Go) carve out a sub-range with JS-compat
118
- // negative-index / out-of-bounds clamping (#1448 Tier A
119
- // fifth PR).
120
- // `array-reverse` / `array-toReversed` no longer pinned —
121
- // both share the `bf->reverse` / `bf_reverse` helper since
122
- // SSR templates render a snapshot and the JS mutate-vs-new
123
- // distinction has no template-level meaning (#1448 Tier A
124
- // sixth PR).
125
- // `string-toLowerCase` / `string-toUpperCase` no longer pinned —
126
- // Perl's native `lc` / `uc` (Mojo) and pre-existing
127
- // `bf_lower` / `bf_upper` (Go) handle the JS method names
128
- // (#1448 Tier A seventh + eighth PRs).
129
- // `string-trim` no longer pinned — pre-existing `bf_trim`
130
- // (Go) and new `bf->trim` helper (Mojo) handle the strip
131
- // (#1448 Tier A ninth PR, closing out Tier A).
132
- // `.find` / `.findIndex` / `.findLast` / `.findLastIndex` are no longer
133
- // pinned — the Mojo `callbackMethod` predicate arm now lowers them to the
134
- // runtime `bf->find` / `find_index` / `find_last` / `find_last_index` helpers
135
- // (per-element coderef predicate), matching Xslate. `.join` was never
136
- // pinned (handled by `renderArrayMethod`'s `case 'join'`).
137
- // #2073 follow-up: a function-reference `.map(format)` callback has no
138
- // arrow body to serialize — not a CALLBACK_METHODS shape — so the
139
- // UNSUPPORTED_METHODS gate refuses it with BF101 rather than emitting
140
- // a broken template.
141
- 'array-map-function-reference': [{ code: 'BF101', severity: 'error' }],
142
- },
143
- // `JSON_STRINGIFY_VIA_CONST` and `MATH_FLOOR_VIA_CONST` now pass
144
- // via `MojoAdapter.templatePrimitives` (#1189). The two remaining
145
- // cases stay skipped because the V1 registry is identifier-path-
146
- // only and explicit:
147
- // - `USER_IMPORT_VIA_CONST` — a bespoke user import isn't in
148
- // the registry and can't be rendered server-side without
149
- // user-supplied helper mappings.
150
- // - `NO_DOUBLE_REWRITE_OF_PROPS_OBJECT` — uses `customSerialize`
151
- // too, same reason.
152
- // Adding new entries to `templatePrimitives` should narrow this
153
- // skip set; see `MOJO_TEMPLATE_PRIMITIVES` in `mojo-adapter.ts`
154
- // for the full V1 surface.
155
- skipTemplatePrimitives: new Set([
156
- TemplatePrimitiveCaseId.USER_IMPORT_VIA_CONST,
157
- TemplatePrimitiveCaseId.NO_DOUBLE_REWRITE_OF_PROPS_OBJECT,
158
- ]),
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").
159
41
  // `client-only` / `client-only-loop-with-sibling-cond` /
160
42
  // `filter-nested-callback-predicate-client` are no longer skipped —
161
43
  // `renderLoop` now emits the `bf->comment("loop:<id>")` boundary pair
@@ -1859,7 +1741,6 @@ export { C }
1859
1741
  // contract here is: no errors + the comparator never lowers + no
1860
1742
  // rendered `<li>` survives.
1861
1743
  const unsupportedSort: Array<[string, string]> = [
1862
- ['function-reference comparator', `items().toSorted(myCmp).map(x => <li key={x.name}>{x.name}</li>)`],
1863
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>)`],
1864
1745
  ]
1865
1746
  for (const [label, chain] of unsupportedSort) {
@@ -1883,6 +1764,17 @@ export function C() {
1883
1764
  })
1884
1765
  }
1885
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
+
1886
1778
  // End-to-end proof via perl + Mojolicious: the `@client` form renders
1887
1779
  // a `<!--bf-client:sN-->` placeholder. The bare form is now caught at
1888
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
+ })