diff --git a/CHANGELOG.md b/CHANGELOG.md index d2fd6632..43655124 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ Per-package detail lives in the GitHub release tagged `@`. ## Unreleased +### Added — subject-keyed panels (render-all, predicate-gated) + +Implements the counter-proposal from the cat-factory slice-4 triage (`docs/subject-keyed-zones-triage.md`): **panels**, a named region whose module-contributed entries are selected at render time by a runtime **subject**, gated by per-entry `when(subject)` predicates, ordered, and rendered **all-matching** — the render-all, open-contribution sibling of the pick-one component-pairing helpers. Contributions ride the existing module → slot path; no new registration seam, package, or descriptor change. Deliberately _not_ named "zones" (that word already names two pick-one primitives). + +- **`@modular-frontend/core`** — `PanelEntry`, `PanelGroupHandle`, `definePanelGroup(slotKey)` (phantom-typed handle, same convention as `defineJourneyHandle`), and `resolvePanels(entries, subject, opts?)`: a pure read-side projection — duplicate-id validation first (throw by default, `onDuplicate: "first-wins" | "last-wins"` to shadow intentionally; runs before the null-subject guard so a registration bug surfaces even while nothing is selected), null subject → empty, `when(subject)` filter, stable `order` sort. The duplicate-id collapse is one shared implementation with `resolveComponentRegistry`, so the two reductions' stances cannot drift. Re-exported by `@modular-react/core` and `@modular-vue/core`. +- **`@modular-react/react`** — `usePanels` (memoized over the slots context + subject, stable empty identity for uncontributed groups), `` (render-all; subject injected as a `subject` prop **and** via `PanelSubjectContext`; per-panel error boundary; `empty` / `wrap` props; `subjectKey` folds the subject's identity into each panel's key to force remounts across selections), `usePanelSubject` (throws outside an outlet). +- **`@modular-vue/vue`** (re-exported from `@modular-vue/core`) — the same surface as a `computed`-based composable plus `` with `#empty` / `#wrap` slots and `provide`-based `usePanelSubject`. `usePanels` accepts `MaybeRefOrGetter` for the group, subject, and `onDuplicate`, and tracks **both** runtime slot sources — the reactive computed and the `recalculateSlots()` signal `Ref` — serving whichever produced the more recent evaluation, so dynamic-slot panels update on either path (matching the React host's behavior). +- **`ModuleErrorBoundary`** (both bindings) — new optional `label` prop (default `"Module"`) naming the failing unit in the notice and console message; `` passes `"Panel"` so a crashed panel isn't mislabeled a module. +- **Docs** — new [`docs/subject-panels.md`](docs/subject-panels.md) guide (entry shape, resolver semantics, both hosts, the Vue reactivity caveat, `when` vs `dynamicSlots`, `subjectKey`, panels-vs-pairing); the compositions README's sibling-primitive comparison extended three → four; cross-links from `reactive-slots-vue.md` and `remote-capability-manifests.md`; the triage doc gains its Resolution section. +- **Housekeeping (Gap D)** — the Vue family peer ranges (`vue`, `vue-core`, `vue-runtime`, `vue-nuxt`, `vue-journeys`, `vue-compositions`, `vue-testing`) widen to admit `@modular-frontend/core@^0.3.0`, aligning lagging `^0.1.0`-only ranges with the engine version already in the workspace. + ### Changed — downstream feedback: types, plugins, and the cancel affordance A second consumer feedback round (Vue + Nuxt this time), triaged critically — some items were already solved and only needed verification or docs; the rest are focused, backward-compatible changes. diff --git a/README.md b/README.md index d2272f30..3c7c5e90 100644 --- a/README.md +++ b/README.md @@ -84,26 +84,27 @@ For the walkthrough of what the scaffold produces (or how to build the workspace Conceptual documentation for building apps with the framework. Start with a getting-started guide, then dig into the shell patterns once you want to go beyond the defaults. -| Guide | What it covers | -| ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| [Getting started with React Router](docs/getting-started-react-router.md) | Scaffold, tour the generated workspace, add modules and stores, turn on the auth guard. | -| [Getting started with TanStack Router](docs/getting-started-tanstack-router.md) | Same walkthrough for the TSR integration, including the `staticData` type augmentation and `beforeLoad` auth guard. | -| [Getting started with Vue Router](docs/getting-started-vue-router.md) | Manual workspace setup for the Vue 3 + vue-router family — modules, the registry, zones via `meta`, stores, and the `beforeEach` auth guard. | -| [Framework-mode (React Router v7)](docs/framework-mode-react-router.md) | `resolveManifest()` integration with `@react-router/dev/vite` — keep file-based `routes.ts`, `+types/route.ts`, HMR, and SSR. | -| [Framework-mode (TanStack Router & Start)](docs/framework-mode-tanstack-router.md) | `resolveManifest()` integration with `@tanstack/router-plugin` and TanStack Start — keep file-based `routeTree.gen.ts`, typed routes, SSR. | -| [Framework-mode (Nuxt 4)](docs/framework-mode-nuxt.md) | `@modular-vue/nuxt` — graft module routes onto Nuxt's vue-router and install the modular contexts on the Nuxt Vue app, via a Nuxt module or `installModularApp` in your own plugin. | -| [Navigation: typed labels, dynamic hrefs, meta](docs/navigation.md) | `NavigationItem` — typed i18n keys, context-aware `to`, app-owned `meta` for permissions/badges. | -| [Shell Patterns (Fundamentals)](docs/shell-patterns.md) | Multi-zone layouts, command palette, module-to-shell communication, headless modules, optional deps, cross-store coordination. | -| [Shell Patterns for React Router](docs/shell-patterns-react-router.md) | Module route shape, route zones via `handle`, `useRouteData` for non-component metadata, auth guards, public shell routes. | -| [Shell Patterns for TanStack Router](docs/shell-patterns-tanstack-router.md) | Module route shape with `createRoute`/`getParentRoute`, route zones via `staticData`, `useRouteData`, `beforeLoad` auth. | -| [Shell Patterns for Vue Router](docs/shell-patterns-vue-router.md) | Router-owning vs framework mode, module route shape, zones and route data via `meta` (typed through `RouteMeta`), `useRouteData`, `beforeEach` auth. | -| [Workspace Patterns](docs/workspace-patterns.md) | Tabbed workspaces, component-only modules, `useActiveZones`, per-session state via `createScopedStore`. | -| [Sibling modules sharing a screen](docs/sibling-modules-shared-screen.md) | One generic screen (e.g. an integration manager) rendered by several sibling modules with per-module config flowing through typed handle (React Router) or staticData (TanStack Router). | -| [Journeys](packages/journeys/README.md) | Typed multi-module workflows with serializable shared state — entry/exit contracts, branch/complete/abort transitions, pluggable persistence. | -| [Journeys in Vue](docs/journeys-vue.md) | Hosting journeys in Vue — ``/``, lifecycle rules, the modal-mounted (no-URL) recipe, Pinia-backed persistence + the `Store` Pinia adapter, and Nuxt wiring. | -| [Compositions](packages/compositions/README.md) | Multi-module screen layout — arrange modules (and journeys) into named zones on a single screen, with a per-instance scoped store as the orchestration bus. | -| [Catalog](packages/catalog/README.md) | Build-time discovery portal: harvest every `defineModule` / `defineJourney`, surface owner/domain/tags facets, pre-compute the journey↔module cross-reference graph, ship as static HTML. | -| [Remote Capability Manifests](docs/remote-capability-manifests.md) | Drive slots/navigation from backend JSON — `RemoteModuleManifest`, `mergeRemoteManifests`, validation, SSR, and the single-module pattern. | +| Guide | What it covers | +| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Getting started with React Router](docs/getting-started-react-router.md) | Scaffold, tour the generated workspace, add modules and stores, turn on the auth guard. | +| [Getting started with TanStack Router](docs/getting-started-tanstack-router.md) | Same walkthrough for the TSR integration, including the `staticData` type augmentation and `beforeLoad` auth guard. | +| [Getting started with Vue Router](docs/getting-started-vue-router.md) | Manual workspace setup for the Vue 3 + vue-router family — modules, the registry, zones via `meta`, stores, and the `beforeEach` auth guard. | +| [Framework-mode (React Router v7)](docs/framework-mode-react-router.md) | `resolveManifest()` integration with `@react-router/dev/vite` — keep file-based `routes.ts`, `+types/route.ts`, HMR, and SSR. | +| [Framework-mode (TanStack Router & Start)](docs/framework-mode-tanstack-router.md) | `resolveManifest()` integration with `@tanstack/router-plugin` and TanStack Start — keep file-based `routeTree.gen.ts`, typed routes, SSR. | +| [Framework-mode (Nuxt 4)](docs/framework-mode-nuxt.md) | `@modular-vue/nuxt` — graft module routes onto Nuxt's vue-router and install the modular contexts on the Nuxt Vue app, via a Nuxt module or `installModularApp` in your own plugin. | +| [Navigation: typed labels, dynamic hrefs, meta](docs/navigation.md) | `NavigationItem` — typed i18n keys, context-aware `to`, app-owned `meta` for permissions/badges. | +| [Shell Patterns (Fundamentals)](docs/shell-patterns.md) | Multi-zone layouts, command palette, module-to-shell communication, headless modules, optional deps, cross-store coordination. | +| [Shell Patterns for React Router](docs/shell-patterns-react-router.md) | Module route shape, route zones via `handle`, `useRouteData` for non-component metadata, auth guards, public shell routes. | +| [Shell Patterns for TanStack Router](docs/shell-patterns-tanstack-router.md) | Module route shape with `createRoute`/`getParentRoute`, route zones via `staticData`, `useRouteData`, `beforeLoad` auth. | +| [Shell Patterns for Vue Router](docs/shell-patterns-vue-router.md) | Router-owning vs framework mode, module route shape, zones and route data via `meta` (typed through `RouteMeta`), `useRouteData`, `beforeEach` auth. | +| [Workspace Patterns](docs/workspace-patterns.md) | Tabbed workspaces, component-only modules, `useActiveZones`, per-session state via `createScopedStore`. | +| [Sibling modules sharing a screen](docs/sibling-modules-shared-screen.md) | One generic screen (e.g. an integration manager) rendered by several sibling modules with per-module config flowing through typed handle (React Router) or staticData (TanStack Router). | +| [Journeys](packages/journeys/README.md) | Typed multi-module workflows with serializable shared state — entry/exit contracts, branch/complete/abort transitions, pluggable persistence. | +| [Journeys in Vue](docs/journeys-vue.md) | Hosting journeys in Vue — ``/``, lifecycle rules, the modal-mounted (no-URL) recipe, Pinia-backed persistence + the `Store` Pinia adapter, and Nuxt wiring. | +| [Compositions](packages/compositions/README.md) | Multi-module screen layout — arrange modules (and journeys) into named zones on a single screen, with a per-instance scoped store as the orchestration bus. | +| [Subject-keyed panels](docs/subject-panels.md) | A named region whose module-contributed panels are selected by a runtime **subject**, gated by per-panel `when(subject)`, ordered, and rendered **all-matching** — the open-contribution, render-all sibling of component pairing. `definePanelGroup` · `resolvePanels` · `usePanels` / `` (React + Vue). | +| [Catalog](packages/catalog/README.md) | Build-time discovery portal: harvest every `defineModule` / `defineJourney`, surface owner/domain/tags facets, pre-compute the journey↔module cross-reference graph, ship as static HTML. | +| [Remote Capability Manifests](docs/remote-capability-manifests.md) | Drive slots/navigation from backend JSON — `RemoteModuleManifest`, `mergeRemoteManifests`, validation, SSR, and the single-module pattern. | ## What the code looks like diff --git a/docs/reactive-slots-vue.md b/docs/reactive-slots-vue.md index 8787e6c7..a5f7b02f 100644 --- a/docs/reactive-slots-vue.md +++ b/docs/reactive-slots-vue.md @@ -160,3 +160,6 @@ The signal path is unchanged: `useSlots()` returns the `Ref`, and - [Navigation](navigation.md) — the navigation manifest and item shape. - [Remote capability manifests](remote-capability-manifests.md) — backend-driven slot/nav contributions, which compose with either path. +- [Subject-keyed panels](subject-panels.md) — `usePanels` / `` + rely on the same reactive-source boundary: a subject-keyed `when(subject)` + predicate only re-runs when the subject is reactive Vue state. diff --git a/docs/remote-capability-manifests.md b/docs/remote-capability-manifests.md index 1c983e1f..ca073661 100644 --- a/docs/remote-capability-manifests.md +++ b/docs/remote-capability-manifests.md @@ -93,6 +93,8 @@ Sometimes a manifest entry needs to select **which component** renders it — a This is the sanctioned shape of "backend data lights up a locally-installed view." It is deliberately narrow: read [Why this isn't the anti-pattern](#why-this-isnt-the-anti-pattern) below before reaching for it, because a sloppy version — a shell-owned `string → component` map used to dodge shipping code — is exactly the [anti-pattern](#anti-patterns-to-avoid) this guide otherwise warns against. +> **Pairing is pick-one; panels are render-all.** `resolveComponentRegistry` / `pairById` select **one** component per id. When a region should instead show **every** contribution that applies to a runtime selection — an inspector rail where several detail panels light up for the selected item, keyed by a `when(subject)` predicate rather than an id — that is the render-all sibling, [subject-keyed panels](subject-panels.md). Same slot substrate, different reduction. + ### The shape **1. Components ship as code and register through the normal module → slot path.** A first-party module contributes `ComponentEntry[]` to a component-registry slot; a consumer deployment contributes its own entries to the _same_ slot via the app's registration seam — no layer fork. diff --git a/docs/subject-keyed-zones-triage.md b/docs/subject-keyed-zones-triage.md index f8720a87..ca213fcc 100644 --- a/docs/subject-keyed-zones-triage.md +++ b/docs/subject-keyed-zones-triage.md @@ -2,8 +2,11 @@ Downstream request: [kibertoad/cat-factory#1205](https://github.com/kibertoad/cat-factory/pull/1205) (`docs/initiatives/modular-vue-slice4-upstream-zones.md` on their side). Status here: -**triaged — accepted in substance, redirected in shape.** This document is the upstream -half of the co-evolution artifact: what we agree to build, what we decline, and why. +**triaged — accepted in substance, redirected in shape — and now implemented.** This +document is the upstream half of the co-evolution artifact: what we agree to build, what +we decline, and why. See [Resolution](#resolution--implemented) at the bottom for what +shipped and how the maintainer decisions landed; the guide is +[`docs/subject-panels.md`](subject-panels.md). ## Verdict in one paragraph @@ -271,3 +274,36 @@ panel group whose subject is the selected step — same handle, resolver, and ou 3. **Duplicate-id stance in `resolvePanels`** — throw (recommended, matches `resolveComponentRegistry`) vs dev-warn; an `onDuplicate` escape hatch can mirror the pairing API if a deployment needs intentional shadowing. + +## Resolution — implemented + +All three decisions landed on the recommended option, and the counter-proposal shipped +as-scoped (no new package, no descriptor change, no `/testing` subpath, no Nuxt manifest +threading): + +1. **Name — `panels`.** `definePanelGroup` / `resolvePanels` (engine), + `usePanels` / `` / `usePanelSubject` (bindings). "zones" rejected. +2. **React host in the same train — yes.** Engine + Vue + React all shipped together; + Angular gets `injectPanels` when its gate opens (the engine part is already done for it). +3. **Duplicate-id stance — throw by default**, with an `onDuplicate: "first-wins" | +"last-wins"` escape hatch mirroring `resolveComponentRegistry`. + +What shipped: + +- **Engine** (`@modular-frontend/core`): `PanelEntry`, `PanelGroupHandle`, + `definePanelGroup`, `resolvePanels` — a pure resolver (null subject → empty; `when` + filter; stable `order` sort; duplicate-id throw). Re-exported by `@modular-react/core` + and `@modular-vue/core`. +- **Vue** (`@modular-vue/vue`): `usePanels` (computed over the slots source + subject), + `` (render-all, subject injected as prop + `provide`, per-panel + `ModuleErrorBoundary`, `#empty` / `#wrap` slots), `usePanelSubject`. Re-exported by + `@modular-vue/core`. +- **React** (`@modular-react/react`): `usePanels`, ``, `usePanelSubject` — + the same surface with `useMemo` / context. +- **Docs**: [`docs/subject-panels.md`](subject-panels.md); the compositions README's + comparison table extended from three primitives to four; cross-links from + [`reactive-slots-vue.md`](reactive-slots-vue.md) (the reactivity boundary) and + [`remote-capability-manifests.md`](remote-capability-manifests.md) (pick-one vs + render-all). +- **Housekeeping (Gap D)**: the Vue family peer ranges widened to admit + `@modular-frontend/core@^0.3.0` (including `compositions` and `testing`). diff --git a/docs/subject-panels.md b/docs/subject-panels.md new file mode 100644 index 00000000..789efc63 --- /dev/null +++ b/docs/subject-panels.md @@ -0,0 +1,417 @@ +# Subject-keyed panels (`usePanels` / ``) + +This guide covers **panels**: a named region whose contributions are selected at +render time by a runtime **subject** (a piece of application state — the +currently selected item), filtered by per-contribution predicates, ordered, and +rendered **all-matching**. Any module — first-party or a consumer's — can +contribute a panel, through the module `slots` path you already use. + +The canonical shape is an **inspector rail**: the user selects something (a board +block, a table row, a graph node) and a column of detail panels appears, each +panel deciding for itself whether it applies to the current selection. + +> **Prerequisite:** you understand the module descriptor and slots from +> [Shell Patterns (Fundamentals)](shell-patterns.md). Panels add **no** new +> registration path — contributions are ordinary slot entries. The only new +> pieces are a typed entry shape, a pure resolver, and a thin per-binding host. + +## When to use panels + +Reach for panels when **all** of these hold: + +1. **A region shows detail for a current selection.** The contributions depend on + a runtime value (the "subject"), not on the route or on a composition's + internal state. +2. **Render-all, not pick-one.** Several panels can apply to one subject at once + and you want them all, ordered — not a single winner. +3. **Open contribution.** A consumer deployment shipping a custom item type + should be able to add a panel for it **without editing the host**. This is the + load-bearing reason panels are a library primitive rather than a local + `v-if` / `switch` map: a local map cannot be extended from outside. +4. **Arbitrary predicates.** Visibility is a conjunction over the subject's shape + and live state (`level === "frame" && type === "frontend" && !run.failed`), + not a fixed `kind → component` lookup. + +If instead you need to select **one** component by a data id (a wire manifest +naming which view to render), that is the pick-one pairing surface, not panels — +see [Panels vs component pairing](#panels-vs-component-pairing). + +For the full "which primitive?" matrix (route/module zones · panels · +compositions · journeys), see +[Comparison with sibling primitives](../packages/compositions/README.md#comparison-with-sibling-primitives). + +### Why this is not a "zone" + +The word _zone_ already names two unrelated, **pick-one** primitives in this +library — route/module zones (`useZones`, keyed on the active route) and +composition zones (`defineComposition({ zones })`, keyed on composition state, +closed to outside contribution). Panels differ from both on every axis: +render-all, subject-keyed, open. Overloading "zone" a third time would put +`useZones` and a subject-driven `useZone` one letter apart with disjoint +meanings. So the render-all primitive gets its own vocabulary: **panels**. + +## The pieces + +The primitive is split the same way the [component-pairing](../packages/compositions/README.md) helpers are: a **pure engine function** plus a **thin host per binding**. + +| Layer | Export | Package | +| ----------------------------------------- | ---------------------------------------- | -------------------------------------------------- | +| Typed entry shape | `PanelEntry` | `@modular-frontend/core` (re-exported by bindings) | +| Group handle | `definePanelGroup(slotKey)` | `@modular-frontend/core` | +| Pure resolver | `resolvePanels(entries, subject, opts?)` | `@modular-frontend/core` | +| Host hook/composable | `usePanels(group, subject)` | `@modular-react/react` · `@modular-vue/vue` | +| Host outlet | `` | `@modular-react/react` · `@modular-vue/vue` | +| Read the injected subject in panel bodies | `usePanelSubject()` | `@modular-react/react` · `@modular-vue/vue` | + +React consumers import everything from `@modular-react/react` (the engine parts +flow through `@modular-react/core`). Vue consumers import from `@modular-vue/vue` +or the `@modular-vue/core` facade. + +## The entry shape + +A panel is contributed as a `PanelEntry` in a module's `slots`, under +the group's slot key. Slot entries are opaque by design +(`SlotMap = Record`), so putting typed panel objects +in a slot is exactly what slots are for — not a workaround. + +```ts +export interface PanelEntry { + readonly id: string; // stable identity; duplicates are a registration bug + readonly component: UiComponent; // carried opaquely — never inspected by the engine + readonly when?: (subject: TSubject) => boolean; // absent = always visible + readonly order?: number; // ascending; absent = 0; ties keep contribution order + readonly props?: Record; // merged with the injected { subject } +} +``` + +The injected subject wins over `props`: a `subject` key placed in `props` is +overwritten by the outlet's own injection. + +### `when` vs `dynamicSlots` — which conditional goes where + +Both gate contributions on runtime state, at different altitudes, and they +compose rather than compete: + +- **`dynamicSlots`** decides which entries **exist** in the resolved manifest — + app-level state that changes rarely and applies to every render (roles, + permissions, feature flags). "Admins get the audit panel at all" is a + `dynamicSlots` conditional. +- **`when(subject)`** decides which existing entries **show for the current + selection** — per-render, per-subject. "This panel applies to frame-level + frontend blocks" is a `when` predicate. + +Rule of thumb: if the condition doesn't mention the subject, it belongs in +`dynamicSlots`; if it does, it belongs in `when`. A panel can use both — a +`dynamicSlots`-contributed entry still carries its own `when`. + +## The resolver + +`resolvePanels` is pure over its inputs, so a React `useMemo` or a Vue `computed` +re-runs it on change with no framework glue. Semantics, in order: + +1. **Duplicate ids throw** by default — two modules contributing the same panel + id to one group is a bug, the same stance as `resolveComponentRegistry` and + duplicate-module-id validation. Pass `onDuplicate: "first-wins"` / + `"last-wins"` when a deployment intentionally shadows a first-party id. + Validation runs before the null-subject guard, so a registration bug + surfaces on first resolve — including the usual initial state where nothing + is selected yet. +2. **Null subject → empty.** A `null` / `undefined` subject (nothing selected) + resolves to no panels; no predicate runs. +3. **Filter by predicate.** Panels without `when` always pass; those with one + pass iff it returns `true` for the (non-null) subject. +4. **Stable-sort by `order`** (ascending, absent = `0`); ties keep contribution + order. The input array is never mutated. + +```ts +import { resolvePanels } from "@modular-frontend/core"; + +const visible = resolvePanels(slots.inspectorPanels, selectedBlock); +// → the ordered PanelEntry[] whose when(selectedBlock) matched +``` + +Because it is a plain named export, cat-factory-style unit tests exercise the +predicate/order table by calling `resolvePanels` directly — no DOM, no host, no +`/testing` subpath. + +## Declaring a group + +`definePanelGroup` pins the subject type to a slot key. Export the handle once +and import it at both the host and every contributor, so the subject type is +stated in exactly one place. + +```ts +// inspector/panels.ts +import { definePanelGroup } from "@modular-react/core"; // or "@modular-vue/core" +import type { BoardBlock } from "./types"; + +export const inspectorPanels = definePanelGroup("inspectorPanels"); +``` + +## Contributing a panel (any module, including a consumer's) + +No descriptor change — a panel is a normal slot entry: + +```ts +import { defineModule } from "@modular-react/react"; +import { FrontendConfig } from "./FrontendConfig"; + +export default defineModule({ + id: "inspector-frames", + slots: { + inspectorPanels: [ + { + id: "frontend-config", + component: FrontendConfig, + order: 20, + when: (b) => b.level === "frame" && b.type === "frontend", + }, + ], + }, +}); +``` + +A **consumer** deployment adds a panel for a block type the host never knew about +by registering its own module with a namespaced id — no host edit: + +```ts +defineModule({ + id: "acme-inspector-extras", + slots: { + inspectorPanels: [ + { + id: "acme:security-report", + component: SecurityReport, + when: (b) => b.type === "acme-secure", + }, + ], + }, +}); +``` + +## The host — React + +```tsx +import { PanelsOutlet } from "@modular-react/react"; +import { inspectorPanels } from "./inspector/panels"; + +function Inspector({ selected }: { selected: BoardBlock | null }) { + return ( + + ); +} +``` + +Each panel component receives the subject as a `subject` prop **and** via context: + +```tsx +import { usePanelSubject } from "@modular-react/react"; + +function FrontendConfig({ subject }: { subject: BoardBlock }) { + // Either the prop above, or — for nested content — the context reader: + const block = usePanelSubject(); + return
{/* … */}
; +} +``` + +`usePanels(group, subject)` is available too when you want the resolved +`PanelEntry[]` without the outlet's rendering (e.g. to show a count badge). + +## The host — Vue + +```vue + + + +``` + +`usePanels(group, subject)` takes a `MaybeRefOrGetter` subject and returns a +`computed`; `usePanelSubject()` returns a reactive `computed` of the +current subject for use inside panel components. + +### Reactivity caveat (Vue) — read this + +The panels re-resolve when the **subject** changes, but only if the subject is +**Vue-reactive state** — a `ref` / `reactive` / Pinia value, or a `computed` +derived from one. A `when` predicate that reads a **non-reactive snapshot** +tracks nothing and will not re-run when that snapshot mutates (the "run just +flipped to failed" case). This is the same source-boundary rule +[`useReactiveSlots`](reactive-slots-vue.md) documents. In practice the subject +already qualifies: an app's `selectedBlock` is typically a Pinia `computed` +(`ui.selectedBlockId → board.getBlock`), which _is_ reactive, so mutable +run-state predicates track correctly. Pass the subject as a ref/getter over +reactive state and you get subject-reactive panels for free. + +The **contributions** need no such care: `usePanels` tracks **both** slot +sources the runtime provides — the tracked reactive computed _and_ the +imperatively-refreshed signal `Ref` — so panels contributed through +`dynamicSlots` re-resolve on either path: a reactive dependency changing, or a +`recalculateSlots()` call after non-reactive state changed. Whichever path your +app already uses for dynamic slots, panels follow it. + +React has no ambient reactivity to worry about here: `usePanels` re-runs on +render like any hook, keyed by its `subject` argument. + +## End-to-end walkthrough — an inspector rail + +The fragments above assemble into one small, complete scenario. A design tool +has a board of blocks; selecting one opens an inspector rail whose panels each +decide whether they apply to the selection. + +**1. The subject type** — the value the group is keyed on, defined once and +shared: + +```ts +// inspector/types.ts +export interface BoardBlock { + readonly id: string; + readonly label: string; + readonly level: "frame" | "leaf"; + readonly type: "frontend" | "backend" | "acme-secure"; +} +``` + +**2. The group handle** — one slot key, subject type pinned, exported for both +the host and every contributor: + +```ts +// inspector/panels.ts +import { definePanelGroup } from "@modular-react/core"; // or "@modular-vue/core" +import type { BoardBlock } from "./types"; + +export const inspectorPanels = definePanelGroup("inspectorPanels"); +``` + +**3. Two first-party panels**, contributed as ordinary slot entries — one always +shown, one gated to frame-level frontend blocks: + +```ts +export default defineModule({ + id: "inspector-core", + slots: { + inspectorPanels: [ + // No `when` → always shown once something is selected. `order: 0`. + { id: "identity", component: Identity, order: 0 }, + // Only for frame-level frontend blocks. Renders after `identity`. + { + id: "frontend-config", + component: FrontendConfig, + order: 20, + when: (b) => b.level === "frame" && b.type === "frontend", + }, + ], + }, +}); +``` + +**4. A consumer panel** for a block type the host never knew about — added by a +deployment's own module with a namespaced id, no host edit, slotted between the +two above by `order`: + +```ts +defineModule({ + id: "acme-inspector-extras", + slots: { + inspectorPanels: [ + { + id: "acme:security-report", + component: SecurityReport, + order: 10, + when: (b) => b.type === "acme-secure", + }, + ], + }, +}); +``` + +**5. What `` renders**, +traced across selections (ordered by `order`, ties by contribution order): + +| `selected` | Panels rendered, in order | Why | +| ----------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------- | +| `null` (nothing selected) | _none_ → `empty` / `#empty` | Null subject short-circuits to `[]` before any predicate runs | +| `{ level: "leaf", type: "backend" }` | `identity` | `identity` has no `when`; the other two predicates are false | +| `{ level: "frame", type: "frontend" }` | `identity`, `frontend-config` | `frontend-config`'s `when` matches; `order` 0 then 20 | +| `{ level: "frame", type: "acme-secure" }` | `identity`, `acme:security-report` | The consumer panel matches; `order` 0 then 10 — no host change was needed | + +Note the last row: the host module (`inspector-core`) has no knowledge of the +`acme-secure` type, yet a consumer panel appears for it, correctly ordered, +because contribution flows through the same `module.slots` path every module +already uses. That is the open-contribution property panels exist to provide. + +## `subjectKey` — remounting on selection change + +By default a panel's rendered instance is keyed on `entry.id` alone, so moving +between two subjects of the same type reuses the component instance (state and +DOM persist). Pass `subjectKey` to fold the subject's identity into the key, +forcing a remount when the selection changes. It is re-read on every render: +pass a `(subject) => string | number` to have the outlet derive the discriminator +from the current subject, or a string you compute from the subject in the caller +and update as the selection changes (a literal constant never varies, so it won't +drive remounts). Use it whenever a panel holds per-subject local state (a scroll +position, an expanded/collapsed toggle, an in-progress edit) that must not leak +across selections. + +## Sections, deliberately dropped + +An early design carried a `section` option ("body" / "banners" / "actions"). It +is omitted: one group with three sections is isomorphic to **three group ids** +(`inspector.body`, `inspector.banners`, `inspector.actions`), which are already +typed, already bucketed by the slot map, and need no new concept or ordering +rules. If a real interleaving need appears later, `section` can be added +compatibly; starting without it is the smaller contract. + +## Panels vs component pairing + +Both are **read-side projections of an already-resolved slot** — pure functions +that register nothing. They differ in the reduction: + +| | Component pairing (`resolveComponentRegistry` / `pairById`) | Panels (`resolvePanels`) | +| ----------- | ------------------------------------------------------------------ | ------------------------------------------------------------ | +| Reduction | **pick-one** by string id | **render-all** by predicate | +| Keyed on | a data id (often wire-delivered) | a runtime subject + per-entry `when(subject)` | +| Result | the one component registered under that id (or a `missing` bucket) | every matching entry, ordered | +| Typical use | a manifest row names which detail view to render | an inspector shows every panel that applies to the selection | + +If your manifest names _one_ view per row, pair by id +([Pairing wire-safe manifests with code-shipped components](remote-capability-manifests.md#pairing-wire-safe-manifests-with-code-shipped-components)). +If a region shows _all_ panels that apply to a selection, use panels. They +compose: a paired detail view can itself host a ``. + +## Testing + +- **Engine** — call `resolvePanels(entries, subject)` directly and assert the + ordered, filtered ids. No DOM. Duplicate-id and `onDuplicate` behavior is a + pure-function test. +- **Host** — mount `` with a slots context providing the group's + entries; assert which panels render, that the subject reaches them, that + `#empty` shows for a null / no-match subject, and that a throwing panel is + contained by its per-panel `ModuleErrorBoundary`. diff --git a/examples/README.md b/examples/README.md index b818ae24..1852c8d1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -10,6 +10,7 @@ examples/ │ ├── integration-manager/ Sibling modules sharing a screen (React Router) │ ├── customer-onboarding-journey/ Multi-module workflow via @modular-react/journeys (React Router) │ ├── editor-composition/ Multi-module screen via @modular-react/compositions (React Router) +│ ├── inspector-panels/ Subject-keyed panels — render-all inspector rail via │ ├── remote-capabilities/ Slots/navigation driven by a backend-served remote manifest │ └── active-project-manifest/ Per-project remote manifests swapped at runtime ├── tanstack-router/ @@ -57,6 +58,10 @@ A multi-module onboarding flow (`profile → plan → billing`) composed with `@ An editor screen with main canvas, integration source picker, and inspector panels, each owned by a different module and coordinated through `@modular-react/compositions`. Shows zone selectors, a per-instance scoped store, typed store projections via `input`, and the alternate hooks pattern (`useCompositionState` / `useCompositionDispatch`) for in-team panels. Documented alongside the [Compositions package](../packages/compositions/README.md). +### `inspector-panels` + +A design board whose inspector rail is driven by **subject-keyed panels**. Selecting a block is the runtime **subject**; each contributed panel decides via its own `when(block)` predicate whether it applies, and `` renders **every** match, ordered — the render-all counterpart to compositions' pick-one zones. A first-party module (`inspector-core`) contributes an always-on `identity` panel plus a frontend-only `frontend-config` panel; a **consumer** module (`acme-extras`) adds a panel for its own `acme-secure` block type with no edit to the host, showing the open-contribution property. Documented in [docs/subject-panels.md](../docs/subject-panels.md). + ### `remote-capabilities` Slots and navigation are driven by a backend-served `RemoteModuleManifest` JSON file instead of being baked into the module source. Useful for tenants that toggle features per environment. diff --git a/examples/react-router/inspector-panels/README.md b/examples/react-router/inspector-panels/README.md new file mode 100644 index 00000000..76b31694 --- /dev/null +++ b/examples/react-router/inspector-panels/README.md @@ -0,0 +1,71 @@ +# Inspector panels — subject-keyed panels (React Router) + +A runnable demonstration of **subject-keyed panels** (`definePanelGroup` / +`resolvePanels` / `usePanels` / ``): a named region whose +module-contributed panels are selected at render time by a runtime **subject**, +gated by per-panel `when(subject)` predicates, ordered, and rendered +**all-matching**. See the [Subject-keyed panels guide](../../../docs/subject-panels.md) +for the full pattern. + +The scenario is an **inspector rail**: a design board of blocks; selecting one +opens a column of detail panels, each panel deciding for itself whether it +applies to the selection. + +## Run it + +From the repo root: + +```bash +pnpm install +pnpm --filter "@example-rr-inspector-panels/shell" dev +``` + +Then open the printed URL and click the blocks in the left column. + +## Layout + +```text +inspector-panels/ +├── app-shared/ BoardBlock subject type, sample board, the panel-group handle +├── modules/ +│ ├── inspector-core/ First-party: `identity` (always) + `frontend-config` (frame-frontend) +│ └── acme-extras/ Consumer: `acme:security-report` (acme-secure) — added with no host edit +└── shell/ Registry wiring + the board host + rail + e2e +``` + +- **`app-shared`** owns the `BoardBlock` subject type and exports the shared + `inspectorPanels = definePanelGroup("inspectorPanels")` handle, + imported by both the host and every contributor so the subject type is stated + once. +- **`inspector-core`** and **`acme-extras`** each contribute `PanelEntry` + objects through the ordinary `slots` path — panels add no new registration + seam. `acme-extras` is deliberately a **consumer** module: it adds a panel for + a block type (`acme-secure`) the host never knew about, with a **namespaced + id** so it can't collide with a first-party one. +- **`shell`** holds the selected block in local state, hands it to + ``, and provides the + per-panel chrome (`wrap`) and the empty state (`empty`). The shell never + branches on block type. + +## What renders + +The panels resolve by predicate, then order (`identity` 0 · `acme:security-report` +10 · `frontend-config` 20). This matches the walkthrough table in the guide +row-for-row, and the e2e (`shell/e2e/smoke.spec.ts`) asserts exactly it: + +| Selected block | Panels rendered, in order | Why | +| -------------------------------- | ---------------------------------- | ----------------------------------------------------- | +| _nothing_ | _none_ → empty state | Null subject short-circuits before any predicate runs | +| `Auth service` (leaf · backend) | `identity` | `identity` has no `when`; the others don't match | +| `Login frame` (frame · frontend) | `identity`, `frontend-config` | `frontend-config`'s `when` matches; order 0 then 20 | +| `Secrets vault` (frame · acme) | `identity`, `acme:security-report` | The **consumer** panel matches; order 0 then 10 | + +The last row is the point: the host module knows nothing about `acme-secure`, +yet the consumer panel appears — correctly ordered — because contribution flows +through the same `module.slots` path every module already uses. + +## Test + +```bash +pnpm --filter "@example-rr-inspector-panels/shell" test:e2e +``` diff --git a/examples/react-router/inspector-panels/app-shared/package.json b/examples/react-router/inspector-panels/app-shared/package.json new file mode 100644 index 00000000..e42c5a3c --- /dev/null +++ b/examples/react-router/inspector-panels/app-shared/package.json @@ -0,0 +1,23 @@ +{ + "name": "@example-rr-inspector-panels/app-shared", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modular-react/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^6.0.2" + } +} diff --git a/examples/react-router/inspector-panels/app-shared/src/app-types.ts b/examples/react-router/inspector-panels/app-shared/src/app-types.ts new file mode 100644 index 00000000..1442fe23 --- /dev/null +++ b/examples/react-router/inspector-panels/app-shared/src/app-types.ts @@ -0,0 +1,21 @@ +import type { PanelEntry } from "@modular-react/core"; +import type { BoardBlock } from "./board.js"; + +/** + * Shared registry dependencies. The panel modules are dependency-free (they + * contribute static slot entries), so this stays minimal — kept for parity with + * the sibling examples and to show where cross-module services would live. + */ +export interface AppDependencies { + readonly auth: { readonly userId: string }; +} + +/** + * Slot contributions collected from every module. `inspectorPanels` is the slot + * the panel group is keyed on: each module contributes `PanelEntry` + * objects under it through the ordinary `slots` path — panels add no new + * registration seam. The base value is `[]`; modules concatenate onto it. + */ +export interface AppSlots { + readonly inspectorPanels: readonly PanelEntry[]; +} diff --git a/examples/react-router/inspector-panels/app-shared/src/board.ts b/examples/react-router/inspector-panels/app-shared/src/board.ts new file mode 100644 index 00000000..4025fb1e --- /dev/null +++ b/examples/react-router/inspector-panels/app-shared/src/board.ts @@ -0,0 +1,37 @@ +import { definePanelGroup } from "@modular-react/core"; + +/** + * The **subject** the inspector panels key on — a block on a design board. + * Selecting a block drives the inspector rail: each panel decides for itself, + * via its `when(block)` predicate, whether it applies to the selection. + * + * `level` and `type` are the two axes the sample panels gate on: + * - `inspector-core` contributes an always-on `identity` panel plus a + * `frontend-config` panel gated to frame-level frontend blocks. + * - the consumer `acme-extras` module contributes a `security-report` panel + * for its own `acme-secure` block type — with no edit to the host. + */ +export interface BoardBlock { + readonly id: string; + readonly label: string; + readonly level: "frame" | "leaf"; + readonly type: "frontend" | "backend" | "acme-secure"; +} + +/** + * The shared panel-group handle. Exported once and imported at both the host + * (`shell`, which renders ``) and every + * contributor (the panel modules), so the subject type is stated in exactly one + * place. Its only runtime field is the slot key modules contribute under. + */ +export const inspectorPanels = definePanelGroup("inspectorPanels"); + +/** + * Sample board. Each block exercises a different arm of the panel predicates — + * see the README's "what renders" table, which this data matches row-for-row. + */ +export const BOARD: readonly BoardBlock[] = [ + { id: "block-login", label: "Login frame", level: "frame", type: "frontend" }, + { id: "block-auth", label: "Auth service", level: "leaf", type: "backend" }, + { id: "block-vault", label: "Secrets vault", level: "frame", type: "acme-secure" }, +]; diff --git a/examples/react-router/inspector-panels/app-shared/src/index.ts b/examples/react-router/inspector-panels/app-shared/src/index.ts new file mode 100644 index 00000000..a1e92d51 --- /dev/null +++ b/examples/react-router/inspector-panels/app-shared/src/index.ts @@ -0,0 +1,3 @@ +export { BOARD, inspectorPanels } from "./board.js"; +export type { BoardBlock } from "./board.js"; +export type { AppDependencies, AppSlots } from "./app-types.js"; diff --git a/examples/react-router/inspector-panels/app-shared/tsconfig.json b/examples/react-router/inspector-panels/app-shared/tsconfig.json new file mode 100644 index 00000000..ef847b5c --- /dev/null +++ b/examples/react-router/inspector-panels/app-shared/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/react-router/inspector-panels/modules/acme-extras/package.json b/examples/react-router/inspector-panels/modules/acme-extras/package.json new file mode 100644 index 00000000..9fc2ac01 --- /dev/null +++ b/examples/react-router/inspector-panels/modules/acme-extras/package.json @@ -0,0 +1,29 @@ +{ + "name": "@example-rr-inspector-panels/acme-extras", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.tsx", + "types": "./src/index.tsx", + "exports": { + ".": { + "import": "./src/index.tsx", + "types": "./src/index.tsx" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@example-rr-inspector-panels/app-shared": "workspace:*", + "@react-router-modules/core": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "react": "^19.0.0", + "typescript": "^6.0.2" + }, + "peerDependencies": { + "react": "^19.0.0" + } +} diff --git a/examples/react-router/inspector-panels/modules/acme-extras/src/index.tsx b/examples/react-router/inspector-panels/modules/acme-extras/src/index.tsx new file mode 100644 index 00000000..0278d56e --- /dev/null +++ b/examples/react-router/inspector-panels/modules/acme-extras/src/index.tsx @@ -0,0 +1,44 @@ +import { defineModule } from "@react-router-modules/core"; +import type { + AppDependencies, + AppSlots, + BoardBlock, +} from "@example-rr-inspector-panels/app-shared"; + +/** + * A **consumer** module — the kind a downstream deployment ships. It adds a + * panel for its own `acme-secure` block type, which the host (`inspector-core`) + * knows nothing about, with **no edit to the host**: the panel flows in through + * the same `slots` path every module uses. This is the open-contribution + * property panels exist to provide. + * + * Its id is namespaced (`acme:security-report`) so it can never collide with a + * first-party panel id — the duplicate-id check would otherwise throw. `order` + * 10 slots it between `identity` (0) and `frontend-config` (20). + */ +function SecurityReport({ subject }: { subject: BoardBlock }) { + return ( +
+

Security report

+

+ Compliance scan for {subject.label} — secrets last rotated 3 days ago, no + findings. +

+
+ ); +} + +export default defineModule({ + id: "acme-extras", + version: "1.0.0", + slots: { + inspectorPanels: [ + { + id: "acme:security-report", + component: SecurityReport, + order: 10, + when: (b) => b.type === "acme-secure", + }, + ], + }, +}); diff --git a/examples/react-router/inspector-panels/modules/acme-extras/tsconfig.json b/examples/react-router/inspector-panels/modules/acme-extras/tsconfig.json new file mode 100644 index 00000000..8a223dcd --- /dev/null +++ b/examples/react-router/inspector-panels/modules/acme-extras/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/react-router/inspector-panels/modules/inspector-core/package.json b/examples/react-router/inspector-panels/modules/inspector-core/package.json new file mode 100644 index 00000000..e9afeeb1 --- /dev/null +++ b/examples/react-router/inspector-panels/modules/inspector-core/package.json @@ -0,0 +1,30 @@ +{ + "name": "@example-rr-inspector-panels/inspector-core", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.tsx", + "types": "./src/index.tsx", + "exports": { + ".": { + "import": "./src/index.tsx", + "types": "./src/index.tsx" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@example-rr-inspector-panels/app-shared": "workspace:*", + "@modular-react/react": "workspace:*", + "@react-router-modules/core": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "react": "^19.0.0", + "typescript": "^6.0.2" + }, + "peerDependencies": { + "react": "^19.0.0" + } +} diff --git a/examples/react-router/inspector-panels/modules/inspector-core/src/index.tsx b/examples/react-router/inspector-panels/modules/inspector-core/src/index.tsx new file mode 100644 index 00000000..6a881b4a --- /dev/null +++ b/examples/react-router/inspector-panels/modules/inspector-core/src/index.tsx @@ -0,0 +1,72 @@ +import { defineModule } from "@react-router-modules/core"; +import { usePanelSubject } from "@modular-react/react"; +import type { + AppDependencies, + AppSlots, + BoardBlock, +} from "@example-rr-inspector-panels/app-shared"; + +/** + * First-party panel module. Contributes two panels to the `inspectorPanels` + * group through the ordinary `slots` path — no descriptor change, no new + * registration seam: + * + * - `identity` — no `when`, so it shows for every selection (`order: 0`, + * rendered first). Reads the subject from the injected `subject` prop. + * - `frontend-config` — gated to frame-level frontend blocks (`order: 20`, + * rendered after `identity`). Reads the subject from context via + * `usePanelSubject`, the alternative to the prop for nested content. + */ + +/** Always-on panel — reads the subject from the `subject` prop the outlet injects. */ +function Identity({ subject }: { subject: BoardBlock }) { + return ( +
+

Identity

+
+
Label
+
{subject.label}
+
Level
+
{subject.level}
+
Type
+
+ {subject.type} +
+
+
+ ); +} + +/** + * Frontend-only panel — demonstrates `usePanelSubject`, the context reader that + * lets nested content reach the subject without prop-drilling. Only mounted when + * the group's `when` predicate below matches, so `usePanelSubject` is always + * inside a `` here and never throws. + */ +function FrontendConfig() { + const block = usePanelSubject(); + return ( +
+

Frontend config

+

+ Bundle split, route, and hydration settings for {block.label}. +

+
+ ); +} + +export default defineModule({ + id: "inspector-core", + version: "1.0.0", + slots: { + inspectorPanels: [ + { id: "identity", component: Identity, order: 0 }, + { + id: "frontend-config", + component: FrontendConfig, + order: 20, + when: (b) => b.level === "frame" && b.type === "frontend", + }, + ], + }, +}); diff --git a/examples/react-router/inspector-panels/modules/inspector-core/tsconfig.json b/examples/react-router/inspector-panels/modules/inspector-core/tsconfig.json new file mode 100644 index 00000000..8a223dcd --- /dev/null +++ b/examples/react-router/inspector-panels/modules/inspector-core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/react-router/inspector-panels/shell/e2e/smoke.spec.ts b/examples/react-router/inspector-panels/shell/e2e/smoke.spec.ts new file mode 100644 index 00000000..b55cf49c --- /dev/null +++ b/examples/react-router/inspector-panels/shell/e2e/smoke.spec.ts @@ -0,0 +1,112 @@ +import { test, expect, type ConsoleMessage, type Page } from "@playwright/test"; + +// Smoke + behavioral coverage for subject-keyed panels (`` / +// `usePanels` / `usePanelSubject`) mounted in a React Router shell. Mirrors the +// sibling examples' e2e style: a page/console-error collector wraps every test +// so runtime regressions (duplicate-module copies, uncaught render errors) +// surface as failures regardless of the specific UI assertion. + +function attachErrorCollectors(page: Page) { + const pageErrors: string[] = []; + const consoleErrors: string[] = []; + page.on("pageerror", (err) => { + pageErrors.push(`${err.message}\n${err.stack ?? ""}`); + }); + page.on("console", (msg: ConsoleMessage) => { + if (msg.type() !== "error") return; + consoleErrors.push(msg.text()); + }); + return { pageErrors, consoleErrors }; +} + +function assertNoErrors({ + pageErrors, + consoleErrors, +}: { + pageErrors: string[]; + consoleErrors: string[]; +}) { + expect(pageErrors, `Unhandled page errors:\n${pageErrors.join("\n---\n")}`).toEqual([]); + expect(consoleErrors, `console.error entries:\n${consoleErrors.join("\n---\n")}`).toEqual([]); +} + +/** Ordered `data-panel` ids currently rendered in the inspector rail. */ +async function renderedPanels(page: Page): Promise { + return page + .getByTestId("inspector") + .locator("[data-panel]") + .evaluateAll((els) => els.map((el) => el.getAttribute("data-panel") ?? "")); +} + +test("nothing selected renders the empty state and no panels", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await expect(page.getByTestId("inspector-empty")).toBeVisible(); + expect(await renderedPanels(page)).toEqual([]); + + assertNoErrors(errors); +}); + +test("a frame-level frontend block shows identity + frontend-config, ordered", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("block-block-login").click(); + + // `identity` (order 0) then `frontend-config` (order 20). The consumer + // panel does not match a frontend block. + expect(await renderedPanels(page)).toEqual(["identity", "frontend-config"]); + await expect(page.getByTestId("identity-type")).toHaveText("frontend"); + await expect(page.getByTestId("panel-acme:security-report")).toHaveCount(0); + await expect(page.getByTestId("inspector-empty")).toHaveCount(0); + + assertNoErrors(errors); +}); + +test("a plain backend block shows only the always-on identity panel", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("block-block-auth").click(); + + expect(await renderedPanels(page)).toEqual(["identity"]); + await expect(page.getByTestId("identity-type")).toHaveText("backend"); + await expect(page.getByTestId("panel-frontend-config")).toHaveCount(0); + + assertNoErrors(errors); +}); + +test("the consumer panel appears for its own block type, no host edit", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("block-block-vault").click(); + + // `identity` (order 0) then the consumer's `acme:security-report` (order 10). + // `frontend-config` does not match an acme-secure block. + expect(await renderedPanels(page)).toEqual(["identity", "acme:security-report"]); + await expect(page.getByTestId("panel-body-acme-security-report")).toContainText("Secrets vault"); + await expect(page.getByTestId("panel-frontend-config")).toHaveCount(0); + + assertNoErrors(errors); +}); + +test("re-selecting and clearing re-resolves the rail", async ({ page }) => { + const errors = attachErrorCollectors(page); + await page.goto("/"); + + await page.getByTestId("block-block-login").click(); + expect(await renderedPanels(page)).toEqual(["identity", "frontend-config"]); + + // Switch to the acme-secure block: the rail re-resolves to a different set. + await page.getByTestId("block-block-vault").click(); + expect(await renderedPanels(page)).toEqual(["identity", "acme:security-report"]); + + // Clearing the selection returns to the empty state. + await page.getByTestId("block-none").click(); + await expect(page.getByTestId("inspector-empty")).toBeVisible(); + expect(await renderedPanels(page)).toEqual([]); + + assertNoErrors(errors); +}); diff --git a/examples/react-router/inspector-panels/shell/index.html b/examples/react-router/inspector-panels/shell/index.html new file mode 100644 index 00000000..eb9be88d --- /dev/null +++ b/examples/react-router/inspector-panels/shell/index.html @@ -0,0 +1,12 @@ + + + + + + Subject-keyed panels (React Router) + + +
+ + + diff --git a/examples/react-router/inspector-panels/shell/package.json b/examples/react-router/inspector-panels/shell/package.json new file mode 100644 index 00000000..3c0ae06c --- /dev/null +++ b/examples/react-router/inspector-panels/shell/package.json @@ -0,0 +1,33 @@ +{ + "name": "@example-rr-inspector-panels/shell", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "typecheck": "tsc --noEmit", + "test:e2e": "playwright test" + }, + "dependencies": { + "@example-rr-inspector-panels/acme-extras": "workspace:*", + "@example-rr-inspector-panels/app-shared": "workspace:*", + "@example-rr-inspector-panels/inspector-core": "workspace:*", + "@modular-react/core": "workspace:*", + "@modular-react/react": "workspace:*", + "@react-router-modules/core": "workspace:*", + "@react-router-modules/runtime": "workspace:*", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router": "^7.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.58.2", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^6.0.1", + "typescript": "^6.0.2", + "vite": "^8.1.3" + } +} diff --git a/examples/react-router/inspector-panels/shell/playwright.config.ts b/examples/react-router/inspector-panels/shell/playwright.config.ts new file mode 100644 index 00000000..1518e5ef --- /dev/null +++ b/examples/react-router/inspector-panels/shell/playwright.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "@playwright/test"; + +const PORT = 5198; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.spec.ts", + timeout: 30_000, + retries: 0, + fullyParallel: false, + reporter: [["list"]], + use: { + headless: true, + baseURL: `http://localhost:${PORT}`, + }, + webServer: { + command: `pnpm exec vite --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}/`, + reuseExistingServer: false, + timeout: 60_000, + stdout: "pipe", + stderr: "pipe", + }, +}); diff --git a/examples/react-router/inspector-panels/shell/src/components/Home.tsx b/examples/react-router/inspector-panels/shell/src/components/Home.tsx new file mode 100644 index 00000000..473a6bcf --- /dev/null +++ b/examples/react-router/inspector-panels/shell/src/components/Home.tsx @@ -0,0 +1,109 @@ +import { useState } from "react"; +import { PanelsOutlet } from "@modular-react/react"; +import { BOARD, inspectorPanels } from "@example-rr-inspector-panels/app-shared"; + +/** + * The host. Local state holds the selected block id; the resolved `BoardBlock` + * (or `null`) is the **subject** handed to ``. The outlet reads + * the group's slot entries from the slots context, filters them by each panel's + * `when(subject)`, orders them, and renders every match — the shell itself never + * branches on block type. Adding support for a new block type is a new *module*, + * not an edit here. + */ +export function Home() { + const [selectedId, setSelectedId] = useState(null); + const selected = BOARD.find((b) => b.id === selectedId) ?? null; + + return ( +
+
+

Board

+

+ Select a block. The inspector rail shows every panel whose predicate matches it. +

+
+ {BOARD.map((b) => ( + + ))} + +
+
+ + +
+ ); +} diff --git a/examples/react-router/inspector-panels/shell/src/components/Layout.tsx b/examples/react-router/inspector-panels/shell/src/components/Layout.tsx new file mode 100644 index 00000000..f7af5b73 --- /dev/null +++ b/examples/react-router/inspector-panels/shell/src/components/Layout.tsx @@ -0,0 +1,27 @@ +import { Outlet } from "react-router"; + +export function Layout() { + return ( +
+
+ Subject-keyed panels — React Router shell +
+
+ +
+
+ ); +} diff --git a/examples/react-router/inspector-panels/shell/src/main.tsx b/examples/react-router/inspector-panels/shell/src/main.tsx new file mode 100644 index 00000000..9eff7fc4 --- /dev/null +++ b/examples/react-router/inspector-panels/shell/src/main.tsx @@ -0,0 +1,27 @@ +import { createRoot } from "react-dom/client"; +import { createRegistry } from "@react-router-modules/runtime"; +import inspectorCore from "@example-rr-inspector-panels/inspector-core"; +import acmeExtras from "@example-rr-inspector-panels/acme-extras"; +import type { AppDependencies, AppSlots } from "@example-rr-inspector-panels/app-shared"; + +import { Layout } from "./components/Layout.js"; +import { Home } from "./components/Home.js"; + +// Base slots declare `inspectorPanels: []`; each registered module concatenates +// its own `PanelEntry` contributions onto it. Both panel modules are headless — +// they contribute only slots, no routes — so the whole app renders on the index +// route (`Home`), which hosts the board and the `` inspector rail. +const registry = createRegistry({ + services: { auth: { userId: "demo-user" } }, + slots: { inspectorPanels: [] }, +}); + +registry.register(inspectorCore); +registry.register(acmeExtras); + +const { App } = registry.resolve({ + rootComponent: Layout, + indexComponent: Home, +}); + +createRoot(document.getElementById("root")!).render(); diff --git a/examples/react-router/inspector-panels/shell/tsconfig.json b/examples/react-router/inspector-panels/shell/tsconfig.json new file mode 100644 index 00000000..4a28ed66 --- /dev/null +++ b/examples/react-router/inspector-panels/shell/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/react-router/inspector-panels/shell/vite.config.ts b/examples/react-router/inspector-panels/shell/vite.config.ts new file mode 100644 index 00000000..b45edd0c --- /dev/null +++ b/examples/react-router/inspector-panels/shell/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + resolve: { + // Dedupe react and the modular packages so the dev server's pre-bundler + // doesn't produce two module copies of the same package (the duplicate- + // module class of regression the runtime guards against). + dedupe: [ + "react", + "react-dom", + "react/jsx-runtime", + "react-router", + "@modular-react/core", + "@modular-react/react", + ], + }, + server: { port: 5198 }, +}); diff --git a/packages/compositions/README.md b/packages/compositions/README.md index baeea27a..0b6f64ac 100644 --- a/packages/compositions/README.md +++ b/packages/compositions/README.md @@ -341,6 +341,8 @@ The framework has two distinct primitives that both use the word "zone." They ar A composition does **not** participate in `module.zones`. The shell's `useZones`/`useActiveZones` will not see anything from a ``. The two systems are orthogonal — a screen can use both at once (e.g., a route uses `module.zones` for the header chip + a `` for the multi-panel body). +> A **third** render surface, [subject-keyed panels](../../docs/subject-panels.md), is deliberately _not_ called a zone: it is render-**all** (not pick-one) and keyed on a caller-supplied subject rather than the route or composition state. Both "zone" meanings above are pick-one; the render-all primitive keeps its own name — see [Comparison with sibling primitives](#comparison-with-sibling-primitives). + Inside a composition zone, **the composition definition owns the zone name and the selector** (what renders here, driven by state); **the host owns the layout** (where the zone appears on screen). The framework wraps each zone in `` + a per-zone error boundary before handing the `ReactNode` to the host's render-prop. ### Zones @@ -1146,26 +1148,28 @@ The runtime defers disposal one microtask so React 18/19 StrictMode's mount/unmo ## Comparison with sibling primitives -Three primitives in the framework arrange modules on a screen. Pick by problem shape: extending one screen vs. coordinating several modules in parallel vs. driving a stepped flow. - -| | `module.zones` (route-level) | `@modular-react/compositions` (this package) | `@modular-react/journeys` | -| ------------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | -| **Primary use** | A foreign module contributes a single component to a named slot on the active route | Multi-module screen layout with shared state, rendered in parallel | Multi-module stepped workflow with typed transitions | -| **Cardinality** | One contribution per zone (most-recent active wins) | N panels mounted simultaneously, one per declared zone | One step rendered at a time | -| **Declared by** | `defineModule({ zones })` + route `staticData` | `defineComposition({ zones })` | `defineJourney({ start, transitions })` | -| **State model** | None — slots map id → component | Scoped store; selectors project state into zones | Step + accumulated state; transitions advance step | -| **Flow** | Static contribution | No graph — any state can produce any resolution | Directed graph of `(step, exit) → next step` | -| **Read in shell** | `useZones` / `useActiveZones` | `` render-prop with zone names | `` (leaf-walk through current step) | -| **Instance id prefix** | n/a | `ci_*` | `ji_*` | -| **Persistence** | n/a | None — keep durable coordination state in the application layer | First-class adapter (`JourneyPersistence`) with versioned blobs | -| **Panel ↔ host data flow** | n/a | Stores (`ReadableStore`/`WritableStore` via `input`) **or** hooks (`useCompositionState`/`Dispatch`/`Emit`/`Zone`) | `useJourneyState`, `useJourneyInstance`, `useJourneyCallStack` | -| **Validation** | Slot-name + route lookup | Zone contracts (spot-check) + `moduleCompat` | Reachability + transition exhaustiveness + contracts | -| **Composition with the other** | n/a | A zone can mount `` via `kind: "journey"` | A journey step can render `` like any other component | +Four primitives in the framework arrange modules on a screen. Pick by problem shape: contributing one widget to a route vs. contributing many detail panels keyed by a selection vs. coordinating several modules in parallel vs. driving a stepped flow. + +| | `module.zones` (route-level) | **panels** (`usePanels` / ``) | `@modular-react/compositions` (this package) | `@modular-react/journeys` | +| ------------------------------ | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| **Primary use** | A foreign module contributes a single component to a named slot on the active route | Modules contribute detail panels to a named region, selected by a runtime **subject**, rendered all-matching | Multi-module screen layout with shared state, rendered in parallel | Multi-module stepped workflow with typed transitions | +| **Cardinality** | One contribution per zone (most-recent active wins) | **All** matching contributions, ordered (render-all) | N panels mounted simultaneously, one per declared zone | One step rendered at a time | +| **Selection** | active route / active module | caller-supplied **subject** + per-panel `when(subject)` predicate | per-zone `select(state)` | current step in the graph | +| **Declared by** | `defineModule({ zones })` + route `staticData` | `module.slots` entries + `definePanelGroup(slotKey)` | `defineComposition({ zones })` | `defineJourney({ start, transitions })` | +| **Contributed by** | route `handle`/`meta`, module descriptor (closed) | any module — first-party **or** consumer (**open**) | the composition definition (closed) | the journey definition (closed) | +| **State model** | None — slots map id → component | None — a pure projection of the slot entries against the subject | Scoped store; selectors project state into zones | Step + accumulated state; transitions advance step | +| **Read in shell** | `useZones` / `useActiveZones` | `usePanels` / `` | `` render-prop with zone names | `` (leaf-walk through current step) | +| **Panel ↔ host data flow** | n/a | Subject injected as a `subject` prop **and** via context (`usePanelSubject`) | Stores (`ReadableStore`/`WritableStore` via `input`) **or** hooks (`useCompositionState`/`Dispatch`/`Emit`/`Zone`) | `useJourneyState`, `useJourneyInstance`, `useJourneyCallStack` | +| **Validation** | Slot-name + route lookup | Duplicate-id check (throw by default; `first-wins`/`last-wins` to shadow) | Zone contracts (spot-check) + `moduleCompat` | Reachability + transition exhaustiveness + contracts | +| **Composition with the other** | n/a | A panel is any component — it can itself host a `` / `` | A zone can mount `` via `kind: "journey"` | A journey step can render `` like any other component | Choose by problem shape: - **`module.zones`** when a route already exists and another module needs to contribute one widget (a header chip, a command, a sidebar entry) — the contribution is static and tied to the active route. +- **Panels** when a region shows **detail for a current selection** and any module (including a consumer's) should be able to add a panel for a subject type it owns — filtered by `when(subject)`, ordered, and rendered **all-matching**. See [Subject-keyed panels](../../docs/subject-panels.md). - **Compositions** when one screen layout coordinates several modules with **shared state** — multiple panels mounted in parallel, each reactive to a per-instance scoped store. - **Journeys** when the screen is a **flow problem** — do A, then B, then maybe C, with typed handoffs between steps. -The three are complementary, not competing. A screen can use all of them: a route hosting a `` whose `inspector` zone hosts a ``, while the route itself contributes a `module.zones` chip to the shell header. +The four are complementary, not competing. A screen can use all of them: a route hosting a `` whose `inspector` zone hosts a ``, an inspector rail driven by `` keyed on the selected item, while the route itself contributes a `module.zones` chip to the shell header. + +> **Why panels are not a third kind of "zone".** Panels are subject-keyed, render-all, and open to consumer contribution — different on every axis from both route/module zones (route-keyed, pick-one) and composition zones (state-keyed, pick-one, closed). Overloading "zone" a third time would make `useZones` (route-driven) and a subject-driven `useZone` one letter apart with disjoint meanings, so the render-all primitive gets its own vocabulary: **panels**. diff --git a/packages/frontend-core/README.md b/packages/frontend-core/README.md index 18bb0e14..283376cb 100644 --- a/packages/frontend-core/README.md +++ b/packages/frontend-core/README.md @@ -23,6 +23,7 @@ Most apps depend on a framework binding (`@modular-react/core`, and downstream ` - **Store**: `createStore` (a lightweight zustand-compatible store, no middleware) - **Detection**: `isStore`, `isStoreApi` (alias), `isReactiveService`, `separateDeps` - **Helpers**: `defineModule`, `defineSlots`, `buildDepsSnapshot`, `runLifecycleHooks` +- **Subject-keyed panels**: `definePanelGroup`, `resolvePanels`, and the `PanelEntry` / `PanelGroupHandle` types — the pure, framework-neutral engine for a render-**all**, predicate-gated, open-contribution slot projection (the render-all sibling of the component-pairing helpers). The thin per-binding hosts (`usePanels` / ``) live in `@modular-react/react` and `@modular-vue/vue`. See [docs/subject-panels.md](../../docs/subject-panels.md) - **Journey contracts**: type-only surfaces describing a journey runtime (implemented in `@modular-react/journeys`) - **Semver subset**: `satisfies`, `parseRange`, `parseVersion`, `compareVersions` diff --git a/packages/frontend-core/src/component-registry.ts b/packages/frontend-core/src/component-registry.ts index 0eb9a621..c8d34857 100644 --- a/packages/frontend-core/src/component-registry.ts +++ b/packages/frontend-core/src/component-registry.ts @@ -53,6 +53,40 @@ export interface ComponentRegistry { */ export type OnDuplicateComponentId = "throw" | "last-wins" | "first-wins"; +/** + * Collapse duplicate ids per {@link OnDuplicateComponentId}, preserving each + * id's first-seen position (`last-wins` replaces the entry but keeps the slot + * it first appeared in). The single implementation behind + * {@link resolveComponentRegistry} and `resolvePanels`, so the library's + * duplicate-id stance can't drift between the pick-one and render-all + * reductions. `duplicateError` supplies the caller-specific message for the + * `throw` stance. + * + * Package-internal — deliberately not exported from the package index. + */ +export function collapseEntriesById( + entries: readonly E[], + onDuplicate: OnDuplicateComponentId, + duplicateError: (id: string) => Error, +): { readonly byId: Map; readonly order: readonly string[] } { + const byId = new Map(); + const order: string[] = []; + + for (const entry of entries) { + if (byId.has(entry.id)) { + if (onDuplicate === "throw") throw duplicateError(entry.id); + if (onDuplicate === "first-wins") continue; + // last-wins: replace the entry but keep the id's first-seen position. + byId.set(entry.id, entry); + continue; + } + byId.set(entry.id, entry); + order.push(entry.id); + } + + return { byId, order }; +} + /** * Index a slot of {@link ComponentEntry} into an id → component registry. * @@ -69,33 +103,23 @@ export function resolveComponentRegistry( entries: readonly ComponentEntry[], opts?: { onDuplicate?: OnDuplicateComponentId }, ): ComponentRegistry { - const onDuplicate = opts?.onDuplicate ?? "throw"; - const byId = new Map>(); - const order: string[] = []; - - for (const entry of entries) { - if (byId.has(entry.id)) { - if (onDuplicate === "throw") { - throw new Error( - `[@modular-frontend/core] resolveComponentRegistry: duplicate component id "${entry.id}". ` + - `Two modules registered the same component id. Namespace consumer ids (e.g. "acme:security-report") ` + - `so they can't collide with first-party ones, or pass onDuplicate: "last-wins" / "first-wins" to ` + - `intentionally shadow an id.`, - ); - } - if (onDuplicate === "first-wins") continue; - // last-wins: replace the component but keep the id's first-seen position. - byId.set(entry.id, entry); - continue; - } - byId.set(entry.id, entry); - order.push(entry.id); - } + const { byId, order } = collapseEntriesById( + entries, + opts?.onDuplicate ?? "throw", + (id) => + new Error( + `[@modular-frontend/core] resolveComponentRegistry: duplicate component id "${id}". ` + + `Two modules registered the same component id. Namespace consumer ids (e.g. "acme:security-report") ` + + `so they can't collide with first-party ones, or pass onDuplicate: "last-wins" / "first-wins" to ` + + `intentionally shadow an id.`, + ), + ); // Built once: the registry is immutable after construction, so `ids` and // `entries` keep a stable identity across reads (safe to feed into memoized - // render paths) and callers can't reach the internal order array. - const ids: readonly string[] = [...order]; + // render paths). The `order` array is created fresh per collapse, so exposing + // it directly can't leak shared mutable state. + const ids: readonly string[] = order; const resolvedEntries: readonly ComponentEntry[] = order.map((id) => byId.get(id)!); return { diff --git a/packages/frontend-core/src/index.ts b/packages/frontend-core/src/index.ts index 358333d0..f7198882 100644 --- a/packages/frontend-core/src/index.ts +++ b/packages/frontend-core/src/index.ts @@ -104,6 +104,13 @@ export type { ComponentRefSpec, } from "./component-pairing-plugin.js"; +// Subject-keyed panels — the render-all, predicate-gated, open-contribution +// sibling of the pick-one component-pairing helpers. A pure resolver over a +// slot's entries plus a caller-supplied subject; the thin per-binding hosts +// (`usePanels` / ``) live in the framework packages. +export { definePanelGroup, resolvePanels } from "./panels.js"; +export type { PanelEntry, PanelGroupHandle } from "./panels.js"; + // Validation export { validateNoDuplicateIds, diff --git a/packages/frontend-core/src/panels.test-d.ts b/packages/frontend-core/src/panels.test-d.ts new file mode 100644 index 00000000..75612c64 --- /dev/null +++ b/packages/frontend-core/src/panels.test-d.ts @@ -0,0 +1,41 @@ +import { describe, it, expectTypeOf } from "vitest"; +import { + definePanelGroup, + resolvePanels, + type PanelEntry, + type PanelGroupHandle, +} from "./panels.js"; + +interface Block { + readonly level: "frame" | "leaf"; + readonly type: string; +} + +describe("panels types", () => { + it("definePanelGroup pins the subject type on the handle", () => { + const group = definePanelGroup("inspectorPanels"); + expectTypeOf(group).toEqualTypeOf>(); + // The phantom subject carrier is typed but never present at runtime. + expectTypeOf(group.__subject).toEqualTypeOf(); + }); + + it("a panel entry's `when` receives the group's subject", () => { + const entry: PanelEntry = { + id: "frontend-config", + component: () => null, + when: (subject) => { + expectTypeOf(subject).toEqualTypeOf(); + return subject.type === "frontend"; + }, + }; + expectTypeOf(entry.order).toEqualTypeOf(); + }); + + it("resolvePanels accepts a nullable subject and returns entries typed to it", () => { + const entries: PanelEntry[] = []; + const nullableSubject = null as Block | null; + expectTypeOf(resolvePanels(entries, nullableSubject)).toEqualTypeOf< + readonly PanelEntry[] + >(); + }); +}); diff --git a/packages/frontend-core/src/panels.test.ts b/packages/frontend-core/src/panels.test.ts new file mode 100644 index 00000000..af59fabe --- /dev/null +++ b/packages/frontend-core/src/panels.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; +import { definePanelGroup, resolvePanels, type PanelEntry } from "./panels.js"; + +// A stand-in for an opaque framework component. The resolver never inspects it. +type FakeComponent = { readonly name: string }; +const comp = (name: string): FakeComponent => ({ name }); + +// The subject the inspector panels key on. +interface Block { + readonly level: "frame" | "leaf"; + readonly type: string; + readonly failed?: boolean; +} + +const panel = ( + id: string, + extra?: Partial, "id" | "component">>, +): PanelEntry => ({ id, component: comp(id), ...extra }); + +describe("definePanelGroup", () => { + it("carries the slot key as its only runtime field", () => { + const group = definePanelGroup("inspectorPanels"); + expect(group.slotKey).toBe("inspectorPanels"); + expect(Object.keys(group)).toEqual(["slotKey"]); + }); +}); + +describe("resolvePanels", () => { + const frame: Block = { level: "frame", type: "frontend" }; + + it("returns empty for a null or undefined subject without running predicates", () => { + let ran = false; + const entries = [ + panel("a", { + when: () => { + ran = true; + return true; + }, + }), + ]; + expect(resolvePanels(entries, null)).toEqual([]); + expect(resolvePanels(entries, undefined)).toEqual([]); + expect(ran).toBe(false); + }); + + it("keeps panels without a predicate whenever a subject is present", () => { + const entries = [panel("a"), panel("b")]; + expect(resolvePanels(entries, frame).map((p) => p.id)).toEqual(["a", "b"]); + }); + + it("filters by when(subject), passing the non-null subject", () => { + const entries = [ + panel("frontend-config", { when: (b) => b.type === "frontend" }), + panel("leaf-only", { when: (b) => b.level === "leaf" }), + ]; + expect(resolvePanels(entries, frame).map((p) => p.id)).toEqual(["frontend-config"]); + }); + + it("renders all matching panels (not pick-one)", () => { + const entries = [ + panel("a", { when: (b) => b.level === "frame" }), + panel("b", { when: (b) => b.type === "frontend" }), + panel("c", { when: (b) => b.level === "leaf" }), + ]; + expect(resolvePanels(entries, frame).map((p) => p.id)).toEqual(["a", "b"]); + }); + + it("stable-sorts by order (ascending), preserving contribution order on ties", () => { + const entries = [ + panel("z", { order: 20 }), + panel("a", { order: 10 }), + panel("m"), // no order → 0 + panel("n"), // no order → 0, contributed after m + panel("b", { order: 10 }), // ties with a, contributed later + ]; + expect(resolvePanels(entries, frame).map((p) => p.id)).toEqual(["m", "n", "a", "b", "z"]); + }); + + it("does not mutate the input slot array", () => { + const entries = [panel("z", { order: 20 }), panel("a", { order: 10 })]; + const before = entries.map((p) => p.id); + resolvePanels(entries, frame); + expect(entries.map((p) => p.id)).toEqual(before); + }); + + it("throws on a duplicate id by default", () => { + expect(() => resolvePanels([panel("dup"), panel("dup")], frame)).toThrow( + /duplicate panel id "dup"/, + ); + }); + + it("throws on a duplicate id even when the subject hides one of them", () => { + // Dedup runs over all contributions before the `when` filter, so the throw + // is deterministic regardless of the current subject. + const entries = [ + panel("dup", { when: (b) => b.level === "frame" }), + panel("dup", { when: (b) => b.level === "leaf" }), + ]; + expect(() => resolvePanels(entries, frame)).toThrow(/duplicate panel id "dup"/); + }); + + it("throws on a duplicate id even while nothing is selected (null subject)", () => { + // Registration is validated before the null-subject guard: a duplicate id + // is a contribution bug whatever is selected, and the common initial state + // is exactly "nothing selected yet" — the bug must not hide there. + expect(() => resolvePanels([panel("dup"), panel("dup")], null)).toThrow( + /duplicate panel id "dup"/, + ); + expect(() => resolvePanels([panel("dup"), panel("dup")], undefined)).toThrow( + /duplicate panel id "dup"/, + ); + }); + + it("keeps the first contribution under onDuplicate: 'first-wins'", () => { + const first = panel("dup", { order: 1 }); + const resolved = resolvePanels([first, panel("dup", { order: 2 })], frame, { + onDuplicate: "first-wins", + }); + expect(resolved).toHaveLength(1); + expect(resolved[0]).toBe(first); + }); + + it("keeps the last contribution under onDuplicate: 'last-wins', preserving position", () => { + const last = panel("dup", { order: 5 }); + const resolved = resolvePanels( + [panel("a", { order: 1 }), panel("dup", { order: 9 }), last, panel("b", { order: 3 })], + frame, + { onDuplicate: "last-wins" }, + ); + // last-wins replaces the entry (dup takes `last`'s order 5, not 9), then + // the survivors sort by `order`: a(1), b(3), dup(5). + expect(resolved.map((p) => p.id)).toEqual(["a", "b", "dup"]); + expect(resolved.find((p) => p.id === "dup")).toBe(last); + }); +}); diff --git a/packages/frontend-core/src/panels.ts b/packages/frontend-core/src/panels.ts new file mode 100644 index 00000000..3d3f0ceb --- /dev/null +++ b/packages/frontend-core/src/panels.ts @@ -0,0 +1,194 @@ +/** + * Subject-keyed panels — a render-**all**, predicate-gated, open-contribution + * projection of a resolved slot. + * + * A *panel group* is a named region (a slot key) whose contributions are + * selected at render time by a caller-supplied **subject** — a piece of + * application state such as "the currently selected board block". Each + * contribution carries an optional `when(subject)` predicate and an optional + * `order`; the group renders **every** matching contribution, ordered, rather + * than picking one. Contributions come from first-party and consumer modules + * alike, through the existing `module.slots` path — panels introduce no new + * registration seam. + * + * This is the fourth distinct aggregation shape in the family, and it is + * deliberately *not* "zones" (that word already names two unrelated, + * pick-one primitives — route/module zones and composition zones). Where the + * component-pairing helpers ({@link resolveComponentRegistry} / `pairById`) + * are a pick-**one**-by-id lookup, panels are a filtered, ordered, + * render-**all** concatenation — a different reduction over the same slot + * substrate, and so a different primitive. + * + * Like the pairing helpers, {@link resolvePanels} is a **pure, read-side + * projection of an already-resolved slot**: it registers nothing and introduces + * no module type. Because it is pure over its inputs, a Vue `computed` (or a + * React `useMemo`) re-runs it on reactive change with no framework-specific + * glue — which is what lets the same engine function serve every binding. The + * thin per-binding hosts (`usePanels` / `` / `usePanelSubject`) + * live in `@modular-react/react` and `@modular-vue/vue`. + * + * Components stay opaque {@link UiComponent}s here (per PR-01) — the engine + * never renders, calls, or inspects them. + * + * See the "Subject-keyed panels" guide (`docs/subject-panels.md`) for the full + * pattern, both bindings' hosts, and the reactivity caveat. + */ + +import { collapseEntriesById, type OnDuplicateComponentId } from "./component-registry.js"; +import type { UiComponent } from "./ui-types.js"; + +/** + * One panel contributed to a panel group, addressed by `id`. + * + * Placed in a module's `slots` under the group's slot key — a slot entry is + * opaque by design (`SlotMap = Record`), so + * contributing typed `PanelEntry` objects is exactly what slots are for, not a + * workaround. + * + * @typeParam TSubject - the application-state value the group is keyed on. The + * {@link PanelGroupHandle} threads it into a binding's `usePanels(group, ...)` + * so `when` predicates and the outlet's injected subject are typed end to end. + */ +export interface PanelEntry { + /** + * Stable identity for this contribution. Duplicate ids across the group are + * a registration bug and throw by default (see {@link resolvePanels}); they + * also key the rendered instance in a binding's outlet. + */ + readonly id: string; + /** The component to render for this panel. Carried opaquely — never inspected. */ + readonly component: UiComponent; + /** + * Visibility predicate. Return `true` to include this panel for the given + * subject. Absent = always visible (whenever the subject itself is present). + * The predicate receives the resolved, non-null subject. + */ + readonly when?: (subject: TSubject) => boolean; + /** + * Ascending sort key among the visible panels. Absent is treated as `0`. + * Ties preserve contribution (registration) order — the sort is stable. + */ + readonly order?: number; + /** + * Extra props merged with the injected `{ subject }` by a binding's outlet. + * The engine never reads these — it only carries them for the host. The + * injected subject wins: a `subject` key placed here is overwritten by the + * outlet's own injection, so don't use `props` to try to override it. + */ + readonly props?: Record; +} + +/** + * A lightweight, phantom-typed token identifying a panel group: its slot key + * plus the subject type its entries are keyed on. Mirrors the handle + * convention of `defineJourneyHandle` / `defineCompositionHandle` — the only + * runtime field is `slotKey`; `__subject` is phantom (never read at runtime), + * carrying `TSubject` so a binding's `usePanels(group, subject)` type-checks the + * subject against the group and returns entries typed to it. + */ +export interface PanelGroupHandle { + /** The slot key modules contribute {@link PanelEntry} objects under. */ + readonly slotKey: string; + /** Phantom carrier for `TSubject` — never present at runtime. */ + readonly __subject?: TSubject; +} + +/** + * Declare a panel group over a slot key, pinning the subject type. + * + * Runtime identity is just `{ slotKey }`; the return type carries `TSubject` so + * every downstream call site (`usePanels`, ``, the module + * contributions) shares one typed handle instead of restating the subject type. + * + * @example + * ```ts + * // Shared handle — export once, import at both the host and the contributors. + * export const inspectorPanels = definePanelGroup("inspectorPanels"); + * ``` + */ +export function definePanelGroup(slotKey: string): PanelGroupHandle { + return { slotKey }; +} + +/** + * Resolve a group's raw slot entries against a subject: dedupe by id, drop + * panels whose `when(subject)` is false, and stable-sort the survivors by + * `order`. + * + * Semantics, in order: + * + * 1. **Duplicate ids throw by default.** Two modules contributing the same + * panel id is a bug, mirroring {@link resolveComponentRegistry}'s stance + * (and duplicate-module-id validation). `onDuplicate: "last-wins"` / + * `"first-wins"` opt out when a deployment intentionally shadows a + * first-party id with its own. Validation runs over *all* contributions, + * before the null-subject guard and the `when` filter, so a registration + * bug surfaces deterministically on first resolve — including the common + * initial state where nothing is selected yet. + * 2. **Null subject → empty.** A `null` / `undefined` subject (nothing + * selected) resolves to no panels — no predicate runs. + * 3. **Filter by predicate.** Panels without a `when` are always kept; those + * with one are kept iff it returns `true` for the (non-null) subject. + * 4. **Stable sort by `order`.** Ascending, `order ?? 0`; ties keep + * contribution order (the underlying sort is stable). The input is not + * mutated. + * + * Pure over its inputs — a Vue `computed` / React `useMemo` re-runs it on + * reactive change with no glue. + * + * @example + * ```ts + * const visible = resolvePanels(slots.inspectorPanels, selectedBlock) + * // → the ordered panels whose `when(selectedBlock)` matched + * ``` + */ +export function resolvePanels( + entries: readonly PanelEntry[], + subject: TSubject | null | undefined, + opts?: { onDuplicate?: OnDuplicateComponentId }, +): readonly PanelEntry[] { + // Validate registration before anything selection-dependent: duplicate ids + // are a contribution bug whatever is (or isn't) selected, so the throw (or + // collapse) happens deterministically on first resolve — not only once the + // user selects something. + const deduped = dedupeById(entries, opts?.onDuplicate ?? "throw"); + + // Nothing selected → nothing to render. Guarded before the filter so `when` + // predicates never see a null subject (their parameter is the resolved, + // non-null value). + if (subject === null || subject === undefined) return []; + + // `filter` allocates a fresh array, so the in-place sort below never touches + // the caller's slot array. + const visible = deduped.filter((entry) => (entry.when ? entry.when(subject) : true)); + + // Stable sort by `order` (ascending, absent = 0). Array.prototype.sort is + // stable, so equal `order` values keep their contribution order. + return visible.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} + +/** + * Collapse duplicate ids per {@link OnDuplicateComponentId} via the shared + * {@link collapseEntriesById}, so the duplicate stance is the same + * implementation `resolveComponentRegistry` uses — not a lookalike copy. + */ +function dedupeById( + entries: readonly PanelEntry[], + onDuplicate: OnDuplicateComponentId, +): readonly PanelEntry[] { + const { byId, order } = collapseEntriesById( + entries, + onDuplicate, + (id) => + new Error( + `[@modular-frontend/core] resolvePanels: duplicate panel id "${id}". ` + + `Two modules contributed the same panel id to one group. Namespace consumer ids ` + + `(e.g. "acme:run-state") so they can't collide with first-party ones, or pass ` + + `onDuplicate: "last-wins" / "first-wins" to intentionally shadow an id.`, + ), + ); + + // Fast path: no duplicates collapsed, so the input order is already correct. + if (order.length === entries.length) return entries; + return order.map((id) => byId.get(id)!); +} diff --git a/packages/react/README.md b/packages/react/README.md index 6d310d73..95aae694 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -19,6 +19,7 @@ npm install @modular-react/react - **Module-exit plumbing**: `ModuleExitProvider`, `useModuleExit`, `useModuleExitDispatcher`, `ModuleEvent`. The "step 0" pattern — a module entry fires an exit from outside any journey, the composition root decides what it means. - **Standalone hosts**: `ModuleRoute` renders a module entry as a route element (router-mode step 0). Pairs with `ModuleTab` from `@modular-react/journeys` for the workspace-mode variant. - **Lazy entry resolution**: `resolveEntryComponent(entry)` returns `{ Component, preload }` for either an eager (`{ component }`) or a lazy (`{ lazy: () => import(…) }`) `ModuleEntryPoint`. Memoized per entry-object identity via `WeakMap`. Used by both `JourneyOutlet` and `ModuleTab` so the lazy wrapper / import promise is shared across renders, hot reloads, and StrictMode double-mount. `preloadEntry(entry)` is the convenience wrapper for hover-prefetch UIs and other manual warm-up paths. +- **Subject-keyed panels**: `usePanels` (a `useMemo` over the slots context + subject), `` (renders every matching panel, ordered, subject injected as a prop **and** via context, each in a `ModuleErrorBoundary`), and `usePanelSubject` / `PanelSubjectContext` for reading the injected subject in panel bodies. The React host over the framework-neutral engine (`definePanelGroup` / `resolvePanels`, re-exported from `@modular-react/core`). See [docs/subject-panels.md](../../docs/subject-panels.md) - **Re-exported from `@modular-react/core`**: all types, `createStore`, `isStore`, `isStoreApi`, `isReactiveService`, `separateDeps`, `defineModule`, `defineSlots`, slot/navigation/validation functions, and runtime helpers ## Usage diff --git a/packages/react/src/error-boundary.tsx b/packages/react/src/error-boundary.tsx index 5aecad4d..729cd9e6 100644 --- a/packages/react/src/error-boundary.tsx +++ b/packages/react/src/error-boundary.tsx @@ -3,6 +3,13 @@ import type { ErrorInfo, ReactNode } from "react"; interface Props { moduleId: string; + /** + * Noun used in the failure notice and console message for what crashed + * (default `"Module"`). Hosts wrapping non-module contributions pass their + * own — e.g. `` passes `"Panel"` — so the error names the + * actual failing unit instead of mislabeling it a module. + */ + label?: string; fallback?: ReactNode; children: ReactNode; } @@ -21,7 +28,7 @@ export class ModuleErrorBoundary extends Component { override componentDidCatch(error: Error, info: ErrorInfo) { console.error( - `[@modular-react/react] Module "${this.props.moduleId}" encountered an error:`, + `[@modular-react/react] ${this.props.label ?? "Module"} "${this.props.moduleId}" encountered an error:`, error, info, ); @@ -42,7 +49,7 @@ export class ModuleErrorBoundary extends Component { }} >

- Module "{this.props.moduleId}" encountered an error + {this.props.label ?? "Module"} "{this.props.moduleId}" encountered an error

             {this.state.error?.message}
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 564eaf9c..59e96a3b 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -64,6 +64,14 @@ export { ModulesContext, useModules, getModuleMeta } from "./modules-context.js"
 // React-specific: error boundary
 export { ModuleErrorBoundary } from "./error-boundary.js";
 
+// React host for subject-keyed panels — the render-all, predicate-gated,
+// open-contribution sibling of the pick-one pairing surface. The pure resolver
+// (`resolvePanels` / `definePanelGroup`) is re-exported from `@modular-react/core`;
+// this binding adds the `usePanels` hook, the `` host, and
+// `usePanelSubject`.
+export { usePanels, PanelsOutlet, usePanelSubject, PanelSubjectContext } from "./panels.js";
+export type { PanelsOutletProps, PanelWrapArgs } from "./panels.js";
+
 // React-specific: module-exit plumbing (hosted by ModuleTab / ModuleRoute;
 // the "step 0" pattern — modules fire exits outside a journey, composition
 // root decides what they mean).
diff --git a/packages/react/src/panels.test.tsx b/packages/react/src/panels.test.tsx
new file mode 100644
index 00000000..c9207f5b
--- /dev/null
+++ b/packages/react/src/panels.test.tsx
@@ -0,0 +1,199 @@
+import { describe, it, expect, vi } from "vitest";
+import { useState } from "react";
+import { render, renderHook } from "@testing-library/react";
+import { definePanelGroup, type PanelEntry } from "@modular-react/core";
+import { PanelsOutlet, usePanels, usePanelSubject } from "./panels.js";
+import { SlotsContext } from "./slots-context.js";
+
+interface Block {
+  readonly level: "frame" | "leaf";
+  readonly type: string;
+}
+
+const group = definePanelGroup("inspectorPanels");
+
+// A panel that renders the subject it received, so tests can assert injection.
+function Probe({ subject }: { subject: Block }) {
+  return 
{subject.type}
; +} + +// Captures its first subject in state, so tests can tell a reused instance +// (stale capture survives) from a remounted one (fresh capture). +function Sticky({ subject }: { subject: Block }) { + const [initial] = useState(subject.type); + return
{initial}
; +} + +const withSlots = + (slots: object) => + ({ children }: { children: React.ReactNode }) => ( + {children} + ); + +const slotsOf = (...list: PanelEntry[]) => ({ inspectorPanels: list }); + +describe("usePanels", () => { + it("resolves and filters the group's entries against the subject", () => { + const slots = slotsOf( + { id: "frontend", component: Probe, when: (b) => b.type === "frontend" }, + { id: "leaf", component: Probe, when: (b) => b.level === "leaf" }, + ); + const { result } = renderHook( + () => usePanels(group, { level: "frame", type: "frontend" } as Block), + { wrapper: withSlots(slots) }, + ); + expect(result.current.map((p) => p.id)).toEqual(["frontend"]); + }); + + it("returns no panels for a null subject", () => { + const slots = slotsOf({ id: "frontend", component: Probe }); + const { result } = renderHook(() => usePanels(group, null), { wrapper: withSlots(slots) }); + expect(result.current).toEqual([]); + }); +}); + +describe("PanelsOutlet", () => { + const twoPanels = slotsOf( + { id: "frontend", component: Probe, order: 20, when: (b) => b.type === "frontend" }, + { id: "always", component: Probe, order: 10 }, + ); + + it("renders every matching panel, ordered, with the subject as a prop", () => { + const { container } = render( + , + { wrapper: withSlots(twoPanels) }, + ); + // order 10 (always) before order 20 (frontend); both get subject.type. + expect([...container.querySelectorAll(".panel")].map((n) => n.textContent)).toEqual([ + "frontend", + "frontend", + ]); + }); + + it("renders the empty node when nothing matches", () => { + const onlyLeaf = slotsOf({ id: "leaf", component: Probe, when: (b) => b.level === "leaf" }); + const { container } = render( + no panels} + />, + { wrapper: withSlots(onlyLeaf) }, + ); + expect(container.querySelector(".empty")).not.toBeNull(); + expect(container.querySelector(".panel")).toBeNull(); + }); + + it("renders the empty node for a null subject", () => { + const { container } = render( + nothing} />, + { wrapper: withSlots(twoPanels) }, + ); + expect(container.querySelector(".empty")).not.toBeNull(); + }); + + it("wraps each panel with the wrap render-prop chrome", () => { + const { container } = render( + ( +
+ {children} +
+ )} + />, + { wrapper: withSlots(twoPanels) }, + ); + expect(container.querySelectorAll("section.chrome")).toHaveLength(2); + }); + + it("contains a throwing panel in its error boundary", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + function Boom(): React.ReactNode { + throw new Error("panel exploded"); + } + const slots = slotsOf( + { id: "boom", component: Boom, order: 1 }, + { id: "ok", component: Probe, order: 2 }, + ); + const { container } = render( + , + { wrapper: withSlots(slots) }, + ); + // The healthy panel still renders; the boom panel shows the boundary + // notice, labeled as a panel (not mislabeled a module). + expect(container.querySelector(".panel")).not.toBeNull(); + expect(container.textContent).toContain('Panel "boom"'); + spy.mockRestore(); + }); + + it("forwards onDuplicate to the resolver", () => { + const dup = slotsOf( + { id: "dup", component: Probe, order: 1 }, + { id: "dup", component: Probe, order: 2 }, + ); + const { container } = render( + , + { wrapper: withSlots(dup) }, + ); + expect(container.querySelectorAll(".panel")).toHaveLength(1); + }); + + it("keeps a panel's instance state across subject changes without subjectKey", () => { + const slots = slotsOf({ id: "sticky", component: Sticky }); + const { container, rerender } = render( + , + { wrapper: withSlots(slots) }, + ); + rerender(); + // Keyed on entry.id alone → same instance, first capture survives. + expect(container.querySelector(".panel")?.textContent).toBe("one"); + }); + + it("remounts panel content when subjectKey changes with the subject", () => { + const slots = slotsOf({ id: "sticky", component: Sticky }); + const subjectKey = (b: Block) => b.type; + const { container, rerender } = render( + , + { wrapper: withSlots(slots) }, + ); + rerender( + , + ); + // The subject's identity is folded into the key → fresh mount, new capture. + expect(container.querySelector(".panel")?.textContent).toBe("two"); + }); + + it("exposes the subject to descendants via usePanelSubject", () => { + let injected: Block | undefined; + function Reader() { + injected = usePanelSubject(); + return
; + } + render(, { + wrapper: withSlots(slotsOf({ id: "reader", component: Reader })), + }); + expect(injected).toEqual({ level: "leaf", type: "widget" }); + }); +}); + +describe("usePanelSubject", () => { + it("throws outside a PanelsOutlet", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => renderHook(() => usePanelSubject())).toThrow(/usePanelSubject/); + spy.mockRestore(); + }); +}); diff --git a/packages/react/src/panels.tsx b/packages/react/src/panels.tsx new file mode 100644 index 00000000..1a235324 --- /dev/null +++ b/packages/react/src/panels.tsx @@ -0,0 +1,170 @@ +import { createContext, useContext, useMemo } from "react"; +import type { ComponentType, ReactNode } from "react"; +import { + resolvePanels, + type OnDuplicateComponentId, + type PanelEntry, + type PanelGroupHandle, +} from "@modular-react/core"; +import { ModuleErrorBoundary } from "./error-boundary.js"; +import { useSlots } from "./slots-context.js"; + +/** + * React host for the framework-neutral **subject-keyed panels** primitive (see + * `resolvePanels` / `definePanelGroup` in `@modular-react/core`). A panel group + * is a named region whose module-contributed panels are selected by a runtime + * *subject* and rendered **all-matching**, ordered — the render-all counterpart + * to the pick-one component-pairing surface. + * + * `usePanels` is a `useMemo` over the slots context + the subject; the engine + * resolver is pure, so the hook is thin. `` renders every + * resolved panel with the subject injected (as a `subject` prop **and** via + * context, readable with {@link usePanelSubject}), each wrapped in + * `ModuleErrorBoundary`. + */ + +// Sentinel default so a read outside a `` is a loud error rather +// than a silently-`undefined` subject that a valid `null` subject can't be told +// apart from. +const NO_SUBJECT = Symbol("modular-react.no-panel-subject"); + +/** + * Context carrying the current panel subject, set by {@link PanelsOutlet}. + * Prefer {@link usePanelSubject} — it throws outside the outlet instead of + * handing back the private sentinel default. + */ +export const PanelSubjectContext = createContext(NO_SUBJECT); + +/** + * Read the subject of the enclosing {@link PanelsOutlet}. Throws when called + * outside a `` so a missing host is a loud error. + * + * @example + * ```tsx + * const block = usePanelSubject() + * return

{block.label}

+ * ``` + */ +export function usePanelSubject(): TSubject { + const subject = useContext(PanelSubjectContext); + if (subject === NO_SUBJECT) { + throw new Error( + "[@modular-react/react] usePanelSubject must be used inside a " + + "(the outlet provides the current panel subject).", + ); + } + return subject as TSubject; +} + +/** + * Resolve a panel group against a subject. Reads the group's slot key from the + * slots context and runs the pure `resolvePanels`, memoized on the entries, the + * subject, and the duplicate stance — so it recomputes when the contributions + * or the subject change but returns a stable array otherwise. + * + * A `null` / `undefined` subject resolves to no panels. + * + * @example + * ```tsx + * const panels = usePanels(inspectorPanels, selectedBlock) + * ``` + */ +// Stable identity for "this group has no contributions", so the `useMemo` +// below doesn't see a fresh `[]` on every render (which would defeat the memo +// and hand consumers an unstable empty result). +const NO_ENTRIES: readonly PanelEntry[] = []; + +export function usePanels( + group: PanelGroupHandle, + subject: TSubject | null | undefined, + opts?: { onDuplicate?: OnDuplicateComponentId }, +): readonly PanelEntry[] { + const slots = useSlots[]>>(); + const entries = (slots[group.slotKey] ?? NO_ENTRIES) as readonly PanelEntry[]; + const onDuplicate = opts?.onDuplicate; + return useMemo( + () => resolvePanels(entries, subject, onDuplicate ? { onDuplicate } : undefined), + [entries, subject, onDuplicate], + ); +} + +/** Per-panel chrome render-prop argument for {@link PanelsOutletProps.wrap}. */ +export interface PanelWrapArgs { + readonly entry: PanelEntry; + readonly subject: TSubject; + readonly children: ReactNode; +} + +export interface PanelsOutletProps { + /** The {@link PanelGroupHandle} from `definePanelGroup`. */ + readonly group: PanelGroupHandle; + /** The value the panels key on; `null` / `undefined` renders `empty`. */ + readonly subject: TSubject | null | undefined; + /** + * Optional discriminator folded into each rendered panel's React key so + * switching subjects remounts panel content rather than reusing a stale + * instance. Re-read on every render: pass a `(subject) => string | number` to + * have the outlet derive it from the current subject, or a string you compute + * from the subject in the caller and update as the selection changes (a literal + * constant never varies, so it won't drive remounts). Absent = key on + * `entry.id` alone. + */ + readonly subjectKey?: string | ((subject: TSubject) => string | number); + /** Duplicate-id stance forwarded to `resolvePanels` (default: throw). */ + readonly onDuplicate?: OnDuplicateComponentId; + /** Rendered when no panel matches (or the subject is absent). */ + readonly empty?: ReactNode; + /** + * Optional per-panel chrome; receives `{ entry, subject, children }` and must + * render `children` somewhere (e.g. a collapsible section shell). + */ + readonly wrap?: (args: PanelWrapArgs) => ReactNode; +} + +/** + * Render every panel a group resolves for its subject, ordered, each with the + * subject supplied as a `subject` prop **and** via context (readable with + * {@link usePanelSubject}). Each panel is wrapped in `ModuleErrorBoundary` + * (keyed by the panel id) so one throwing panel can't take down the group. + * + * @example + * ```tsx + * b.id}> + * {/* empty / wrap via props *\/} + * + * ``` + */ +export function PanelsOutlet({ + group, + subject, + subjectKey, + onDuplicate, + empty, + wrap, +}: PanelsOutletProps): ReactNode { + const panels = usePanels(group, subject, onDuplicate ? { onDuplicate } : undefined); + if (panels.length === 0) return empty ?? null; + + // Past the length guard the subject is present (a null subject resolves to no + // panels), so the `as TSubject` casts below are sound. + const present = subject as TSubject; + + return ( + + {panels.map((entry) => { + const Component = entry.component as ComponentType>; + const content = ; + const inner = wrap ? wrap({ entry, subject: present, children: content }) : content; + const key = + subjectKey === undefined + ? entry.id + : `${entry.id}:${typeof subjectKey === "function" ? subjectKey(present) : subjectKey}`; + return ( + + {inner} + + ); + })} + + ); +} diff --git a/packages/vue-compositions/package.json b/packages/vue-compositions/package.json index d91f700c..71b3d0a1 100644 --- a/packages/vue-compositions/package.json +++ b/packages/vue-compositions/package.json @@ -46,7 +46,7 @@ "vue": "^3.5.40" }, "peerDependencies": { - "@modular-frontend/core": "^0.1.0", + "@modular-frontend/core": "^0.1.0 || ^0.2.0 || ^0.3.0", "@modular-vue/vue": "^1.0.0", "vue": "^3.5.40" } diff --git a/packages/vue-core/package.json b/packages/vue-core/package.json index 182f0e02..a1972d77 100644 --- a/packages/vue-core/package.json +++ b/packages/vue-core/package.json @@ -42,7 +42,7 @@ "vue-router": "^5.0.0" }, "peerDependencies": { - "@modular-frontend/core": "^0.1.0 || ^0.2.0", + "@modular-frontend/core": "^0.1.0 || ^0.2.0 || ^0.3.0", "@modular-vue/vue": "^1.0.0", "vue": "^3.5.40", "vue-router": "^5.0.0" diff --git a/packages/vue-core/src/index.ts b/packages/vue-core/src/index.ts index b28f6f4b..4056ac6f 100644 --- a/packages/vue-core/src/index.ts +++ b/packages/vue-core/src/index.ts @@ -37,6 +37,13 @@ export type { ComponentRefSpec, } from "@modular-frontend/core"; +// Subject-keyed panels — render-all, predicate-gated projection over a slot. +// The pure engine surface plus the Vue host, both surfaced here so consumers +// import panels from the Vue binding rather than reaching into the engine. +export { definePanelGroup, resolvePanels } from "@modular-frontend/core"; +export type { PanelEntry, PanelGroupHandle } from "@modular-frontend/core"; +export { usePanels, PanelsOutlet, usePanelSubject, panelSubjectKey } from "@modular-vue/vue"; + // Module definition export { defineModule } from "./define-module.js"; export { defineSlots } from "./define-slots.js"; diff --git a/packages/vue-journeys/package.json b/packages/vue-journeys/package.json index 5779522e..9e40f920 100644 --- a/packages/vue-journeys/package.json +++ b/packages/vue-journeys/package.json @@ -51,7 +51,7 @@ "vue": "^3.5.40" }, "peerDependencies": { - "@modular-frontend/core": "^0.1.0 || ^0.2.0", + "@modular-frontend/core": "^0.1.0 || ^0.2.0 || ^0.3.0", "@modular-vue/vue": "^1.0.0", "vue": "^3.5.40" } diff --git a/packages/vue-nuxt/package.json b/packages/vue-nuxt/package.json index 5ef0a6a3..592a99ab 100644 --- a/packages/vue-nuxt/package.json +++ b/packages/vue-nuxt/package.json @@ -54,7 +54,7 @@ "vue-router": "^5.0.0" }, "peerDependencies": { - "@modular-frontend/core": "^0.1.0 || ^0.2.0", + "@modular-frontend/core": "^0.1.0 || ^0.2.0 || ^0.3.0", "@modular-vue/runtime": "^1.0.0", "nuxt": "^4.4.0", "vue": "^3.5.40", diff --git a/packages/vue-runtime/package.json b/packages/vue-runtime/package.json index 3f283374..87971884 100644 --- a/packages/vue-runtime/package.json +++ b/packages/vue-runtime/package.json @@ -47,7 +47,7 @@ "vue-router": "^5.0.0" }, "peerDependencies": { - "@modular-frontend/core": "^0.1.0 || ^0.2.0", + "@modular-frontend/core": "^0.1.0 || ^0.2.0 || ^0.3.0", "@modular-vue/core": "^1.0.0", "@modular-vue/vue": "^1.0.0", "vue": "^3.5.40", diff --git a/packages/vue-testing/package.json b/packages/vue-testing/package.json index 7a6a3064..892e8396 100644 --- a/packages/vue-testing/package.json +++ b/packages/vue-testing/package.json @@ -48,7 +48,7 @@ "vue-router": "^5.0.0" }, "peerDependencies": { - "@modular-frontend/core": "^0.1.0", + "@modular-frontend/core": "^0.1.0 || ^0.2.0 || ^0.3.0", "@modular-frontend/testing": "^0.1.0", "@modular-vue/core": "^1.0.0", "@modular-vue/journeys": "^1.0.0", diff --git a/packages/vue/README.md b/packages/vue/README.md index 601b0557..7053e590 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -22,6 +22,16 @@ first package of the [Vue support initiative](../../docs/vue-support-tracker.md) navigation manifest (`useNavigation`), and slot contributions (`useSlots`, `useReactiveSlots`, `useRecalculateSlots`, `DynamicSlotsProvider`, `createSlotsSignal`). +- **Subject-keyed panels** — `usePanels` (a reactive `computed` over the slots + source + subject), `` (renders every matching panel, ordered, + subject injected as a prop **and** via `provide`, each in a + `ModuleErrorBoundary`, with `#empty` / `#wrap` slots), and `usePanelSubject` / + `panelSubjectKey` for reading the injected subject reactively in panel bodies. + This is the Vue host over the framework-neutral engine (`definePanelGroup` / + `resolvePanels`, re-exported from `@modular-vue/core`); mind the + [Vue reactivity caveat](../../docs/reactive-slots-vue.md) when a `when` + predicate reads non-reactive state. See + [docs/subject-panels.md](../../docs/subject-panels.md). ### Slot evaluation: reactive vs signal diff --git a/packages/vue/package.json b/packages/vue/package.json index 46760429..ba636f73 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -43,7 +43,7 @@ "vue": "^3.5.40" }, "peerDependencies": { - "@modular-frontend/core": "^0.1.0 || ^0.2.0", + "@modular-frontend/core": "^0.1.0 || ^0.2.0 || ^0.3.0", "vue": "^3.5.40" } } diff --git a/packages/vue/src/error-boundary.ts b/packages/vue/src/error-boundary.ts index 8f64c8af..c8e257b7 100644 --- a/packages/vue/src/error-boundary.ts +++ b/packages/vue/src/error-boundary.ts @@ -18,6 +18,13 @@ export const ModuleErrorBoundary = defineComponent({ name: "ModuleErrorBoundary", props: { moduleId: { type: String, required: true }, + /** + * Noun used in the failure notice and console message for what crashed + * (default `"Module"`). Hosts wrapping non-module contributions pass their + * own — e.g. `` passes `"Panel"` — so the error names the + * actual failing unit instead of mislabeling it a module. + */ + label: { type: String, default: "Module" }, /** * Optional replacement UI shown instead of the built-in notice. Accepts a * VNode or a zero-arg function returning one; mirrors the React boundary's @@ -30,7 +37,10 @@ export const ModuleErrorBoundary = defineComponent({ onErrorCaptured((err) => { error.value = err instanceof Error ? err : new Error(String(err)); - console.error(`[@modular-vue/vue] Module "${props.moduleId}" encountered an error:`, err); + console.error( + `[@modular-vue/vue] ${props.label} "${props.moduleId}" encountered an error:`, + err, + ); // Stop propagation — the boundary has handled it. return false; }); @@ -54,7 +64,7 @@ export const ModuleErrorBoundary = defineComponent({ h( "h3", { style: { color: "#e53e3e", margin: "0 0 0.5rem 0" } }, - `Module "${props.moduleId}" encountered an error`, + `${props.label} "${props.moduleId}" encountered an error`, ), h( "pre", diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index aa689f58..688ba112 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -87,6 +87,13 @@ export { modulesKey, provideModules, useModules, getModuleMeta } from "./modules // Vue-specific: error boundary export { ModuleErrorBoundary } from "./error-boundary.js"; +// Vue host for subject-keyed panels — the render-all, predicate-gated, +// open-contribution sibling of the pick-one pairing surface. The pure resolver +// (`resolvePanels` / `definePanelGroup`) lives in `@modular-frontend/core`; this +// binding adds the reactive `usePanels` composable, the `` host, +// and `usePanelSubject`. +export { usePanels, PanelsOutlet, usePanelSubject, panelSubjectKey } from "./panels.js"; + // Vue-specific: module-exit plumbing (hosted by ModuleRoute / tabs; the // "step 0" pattern — modules fire exits outside a journey, composition root // decides what they mean). diff --git a/packages/vue/src/panels.test.ts b/packages/vue/src/panels.test.ts new file mode 100644 index 00000000..4ccea04a --- /dev/null +++ b/packages/vue/src/panels.test.ts @@ -0,0 +1,275 @@ +import { describe, it, expect, vi } from "vitest"; +import { defineComponent, h, ref, shallowRef, computed, nextTick } from "vue"; +import { mount } from "@vue/test-utils"; +import { definePanelGroup, type PanelEntry } from "@modular-frontend/core"; +import { PanelsOutlet, usePanels, usePanelSubject } from "./panels.js"; +import { reactiveSlotsKey, slotsKey } from "./slots-context.js"; +import { renderComposable } from "./test-render.js"; + +interface Block { + readonly level: "frame" | "leaf"; + readonly type: string; +} + +const group = definePanelGroup("inspectorPanels"); + +// A panel component that records the subject it was rendered with. +const seen: Block[] = []; +const Probe = defineComponent({ + props: { subject: { type: Object, required: true } }, + setup(props) { + seen.push(props.subject as Block); + return () => h("div", { class: "panel" }, (props.subject as Block).type); + }, +}); + +const entries = (...list: PanelEntry[]): { inspectorPanels: PanelEntry[] } => ({ + inspectorPanels: list, +}); + +describe("usePanels", () => { + it("resolves the group's slot entries against the subject", () => { + const slots = entries( + { id: "frontend", component: Probe, when: (b) => b.type === "frontend" }, + { id: "leaf", component: Probe, when: (b) => b.level === "leaf" }, + ); + const { result } = renderComposable( + () => usePanels(group, ref({ level: "frame", type: "frontend" })), + { provide: { [slotsKey as symbol]: shallowRef(slots) } }, + ); + expect(result().value.map((p) => p.id)).toEqual(["frontend"]); + }); + + it("re-resolves when the subject ref changes", () => { + const slots = entries( + { id: "frontend", component: Probe, when: (b) => b.type === "frontend" }, + { id: "leaf", component: Probe, when: (b) => b.level === "leaf" }, + ); + const subject = ref({ level: "frame", type: "frontend" }); + const { result } = renderComposable(() => usePanels(group, subject), { + provide: { [slotsKey as symbol]: shallowRef(slots) }, + }); + expect(result().value.map((p) => p.id)).toEqual(["frontend"]); + subject.value = { level: "leaf", type: "widget" }; + expect(result().value.map((p) => p.id)).toEqual(["leaf"]); + }); + + it("starts from the reactive slots source when both are provided", () => { + const reactive = entries({ id: "from-reactive", component: Probe }); + const signal = entries({ id: "from-signal", component: Probe }); + const { result } = renderComposable( + () => usePanels(group, ref({ level: "frame", type: "x" })), + { + provide: { + [reactiveSlotsKey as symbol]: computed(() => reactive), + [slotsKey as symbol]: shallowRef(signal), + }, + }, + ); + expect(result().value.map((p) => p.id)).toEqual(["from-reactive"]); + }); + + it("tracks both slot sources: updates on the recalculateSlots signal path and the reactive path", () => { + // In a real app both sources evaluate the same factories; here they start + // identical and diverge so the test can see which one served each update. + const reactiveInner = shallowRef(entries({ id: "base", component: Probe })); + const signalRef = shallowRef(entries({ id: "base", component: Probe })); + const { result } = renderComposable( + () => usePanels(group, ref({ level: "frame", type: "x" })), + { + provide: { + [reactiveSlotsKey as symbol]: computed(() => reactiveInner.value), + [slotsKey as symbol]: signalRef, + }, + }, + ); + expect(result().value.map((p) => p.id)).toEqual(["base"]); + + // recalculateSlots() path: the runtime reassigns the signal Ref. A + // dynamicSlots factory over non-reactive deps only ever updates this + // source — panels must not go stale on it. + signalRef.value = entries( + { id: "base", component: Probe }, + { id: "recalculated", component: Probe }, + ); + expect(result().value.map((p) => p.id)).toEqual(["base", "recalculated"]); + + // ...and a later reactive-path evaluation is served again. + reactiveInner.value = entries({ id: "from-reactive", component: Probe }); + expect(result().value.map((p) => p.id)).toEqual(["from-reactive"]); + }); + + it("throws outside a modular app", () => { + expect(() => renderComposable(() => usePanels(group, ref(null)))).toThrow( + /usePanels must be used within a modular app/, + ); + }); +}); + +describe("PanelsOutlet", () => { + const twoPanels = entries( + { id: "frontend", component: Probe, order: 20, when: (b) => b.type === "frontend" }, + { id: "always", component: Probe, order: 10 }, + ); + + const mountOutlet = (subject: Block | null, slots?: object) => + mount(PanelsOutlet, { + props: { group, subject }, + global: { provide: { [slotsKey as symbol]: shallowRef(slots ?? twoPanels) } }, + }); + + it("renders every matching panel, ordered, with the subject as a prop", () => { + seen.length = 0; + const subject: Block = { level: "frame", type: "frontend" }; + const wrapper = mountOutlet(subject); + // order 10 (always) before order 20 (frontend) + expect(wrapper.findAll(".panel").map((n) => n.text())).toEqual(["frontend", "frontend"]); + // Vue hands each panel the subject (compared by value — props arrive as a + // reactive view of the raw object, not the same reference). + expect(seen).toHaveLength(2); + expect(seen[0]).toEqual(subject); + }); + + it("renders the #empty slot when nothing matches", () => { + const onlyLeaf = entries({ id: "leaf", component: Probe, when: (b) => b.level === "leaf" }); + const wrapper = mount(PanelsOutlet, { + props: { group, subject: { level: "frame", type: "frontend" } as Block }, + slots: { empty: () => h("div", { class: "empty" }, "no panels") }, + global: { provide: { [slotsKey as symbol]: shallowRef(onlyLeaf) } }, + }); + expect(wrapper.find(".empty").exists()).toBe(true); + expect(wrapper.find(".panel").exists()).toBe(false); + }); + + it("renders the #empty slot for a null subject without running predicates", () => { + const wrapper = mount(PanelsOutlet, { + props: { group, subject: null }, + slots: { empty: () => h("div", { class: "empty" }, "nothing selected") }, + global: { provide: { [slotsKey as symbol]: shallowRef(twoPanels) } }, + }); + expect(wrapper.find(".empty").exists()).toBe(true); + }); + + it("wraps each panel with the #wrap slot chrome", () => { + const wrapper = mount(PanelsOutlet, { + props: { group, subject: { level: "frame", type: "frontend" } as Block }, + slots: { + wrap: ({ entry, children }: { entry: PanelEntry; children: unknown }) => + h("section", { class: "chrome", "data-id": entry.id }, [children as never]), + }, + global: { provide: { [slotsKey as symbol]: shallowRef(twoPanels) } }, + }); + expect(wrapper.findAll("section.chrome")).toHaveLength(2); + }); + + it("contains a throwing panel in its error boundary", async () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const Boom = defineComponent({ + setup() { + return () => { + throw new Error("panel exploded"); + }; + }, + }); + const slots = entries( + { id: "boom", component: Boom, order: 1 }, + { id: "ok", component: Probe, order: 2 }, + ); + const wrapper = mount(PanelsOutlet, { + props: { group, subject: { level: "frame", type: "frontend" } as Block }, + global: { provide: { [slotsKey as symbol]: shallowRef(slots) } }, + }); + // The boundary swaps to its notice on the re-render its `error` ref queues. + await nextTick(); + // The healthy panel still renders; the boom panel is swapped for the + // notice, labeled as a panel (not mislabeled a module). + expect(wrapper.find(".panel").exists()).toBe(true); + expect(wrapper.text()).toContain('Panel "boom"'); + spy.mockRestore(); + }); + + it("forwards onDuplicate to the resolver and reacts to it changing after mount", async () => { + // The shadowed entry never matches, so the two stances render differently: + // first-wins keeps the always-visible entry, last-wins keeps the hidden one. + const dup = entries( + { id: "dup", component: Probe }, + { id: "dup", component: Probe, when: () => false }, + ); + const wrapper = mount(PanelsOutlet, { + props: { + group, + subject: { level: "frame", type: "frontend" } as Block, + onDuplicate: "first-wins" as const, + }, + global: { provide: { [slotsKey as symbol]: shallowRef(dup) } }, + }); + expect(wrapper.findAll(".panel")).toHaveLength(1); + + await wrapper.setProps({ onDuplicate: "last-wins" }); + expect(wrapper.findAll(".panel")).toHaveLength(0); + }); + + // Captures its first subject at setup, so tests can tell a reused instance + // (stale capture survives) from a remounted one (fresh capture). + const Sticky = defineComponent({ + props: { subject: { type: Object, required: true } }, + setup(props) { + const initial = (props.subject as Block).type; + return () => h("div", { class: "panel" }, initial); + }, + }); + + it("keeps a panel's instance state across subject changes without subjectKey", async () => { + const wrapper = mount(PanelsOutlet, { + props: { group, subject: { level: "frame", type: "one" } as Block }, + global: { + provide: { [slotsKey as symbol]: shallowRef(entries({ id: "sticky", component: Sticky })) }, + }, + }); + await wrapper.setProps({ subject: { level: "frame", type: "two" } as Block }); + // Keyed on entry.id alone → same instance, first capture survives. + expect(wrapper.find(".panel").text()).toBe("one"); + }); + + it("remounts panel content when subjectKey changes with the subject", async () => { + const wrapper = mount(PanelsOutlet, { + props: { + group, + subject: { level: "frame", type: "one" } as Block, + subjectKey: (b: unknown) => (b as Block).type, + }, + global: { + provide: { [slotsKey as symbol]: shallowRef(entries({ id: "sticky", component: Sticky })) }, + }, + }); + await wrapper.setProps({ subject: { level: "frame", type: "two" } as Block }); + // The subject's identity is folded into the key → fresh mount, new capture. + expect(wrapper.find(".panel").text()).toBe("two"); + }); + + it("exposes the subject to descendants via usePanelSubject", () => { + let injected: Block | undefined; + const Reader = defineComponent({ + setup() { + const subject = usePanelSubject(); + injected = subject.value; + return () => h("div"); + }, + }); + mount(PanelsOutlet, { + props: { group, subject: { level: "leaf", type: "widget" } as Block }, + global: { + provide: { + [slotsKey as symbol]: shallowRef(entries({ id: "reader", component: Reader })), + }, + }, + }); + expect(injected).toEqual({ level: "leaf", type: "widget" }); + }); +}); + +describe("usePanelSubject", () => { + it("throws outside a PanelsOutlet", () => { + expect(() => renderComposable(() => usePanelSubject())).toThrow(/usePanelSubject/); + }); +}); diff --git a/packages/vue/src/panels.ts b/packages/vue/src/panels.ts new file mode 100644 index 00000000..7dd19939 --- /dev/null +++ b/packages/vue/src/panels.ts @@ -0,0 +1,254 @@ +import { + computed, + defineComponent, + h, + inject, + provide, + toValue, + type Component, + type ComputedRef, + type InjectionKey, + type MaybeRefOrGetter, + type PropType, + type VNode, +} from "vue"; +import { + resolvePanels, + type OnDuplicateComponentId, + type PanelEntry, + type PanelGroupHandle, +} from "@modular-frontend/core"; +import { reactiveSlotsKey, slotsKey } from "./slots-context.js"; +import { ModuleErrorBoundary } from "./error-boundary.js"; + +/** + * Vue host for the framework-neutral **subject-keyed panels** primitive (see + * `resolvePanels` / `definePanelGroup` in `@modular-frontend/core`). A panel + * group is a named region whose module-contributed panels are selected by a + * runtime *subject* and rendered **all-matching**, ordered — the render-all + * counterpart to the pick-one component-pairing surface. + * + * `usePanels` is a `computed` over the slots context + the subject; the engine + * resolver is pure, so the composable is thin. `` renders every + * resolved panel with the subject injected (as a prop **and** via `provide`), + * each wrapped in `ModuleErrorBoundary`. + * + * ## Reactivity caveat + * + * The panels recompute when the **subject** changes — but only if the subject + * is Vue-reactive state (a `ref`/`reactive`/Pinia value, or a `computed` + * derived from one). A `when` predicate that reads a non-reactive snapshot + * tracks nothing and will not re-run when that snapshot mutates. This is the + * same source-boundary rule `useReactiveSlots` documents (see + * `docs/reactive-slots-vue.md`); pass the subject as a ref/getter over reactive + * state and mutable-run-state predicates track correctly. + * + * The **contributions** carry no such caveat: `usePanels` tracks both slot + * sources the runtime provides, so panels contributed through `dynamicSlots` + * update on either path — a reactive dependency changing *or* an imperative + * `recalculateSlots()` call (see {@link injectSlotsSource}). + */ + +/** + * Injection key holding the current panel subject, provided by + * {@link PanelsOutlet} so panel content (and its descendants) can read the + * subject without prop-drilling. Held as a `ComputedRef` so reads stay + * reactive to subject changes. + */ +export const panelSubjectKey: InjectionKey> = Symbol( + "modular-vue.panelSubject", +); + +/** + * Read the subject of the enclosing {@link PanelsOutlet}. Reactive — the + * returned `computed` updates when the outlet's subject changes. Throws when + * called outside a `` so a missing host is a loud error, not a + * silently-undefined subject. + * + * @example + * ```ts + * const block = usePanelSubject() + * const title = computed(() => block.value.label) + * ``` + */ +export function usePanelSubject(): ComputedRef { + const subject = inject(panelSubjectKey, null); + if (!subject) { + throw new Error( + "[@modular-vue/vue] usePanelSubject must be used inside a " + + "(the outlet provides the current panel subject).", + ); + } + return subject as ComputedRef; +} + +/** + * Inject the resolved-slots source. The runtime provides **two** parallel + * sources with different update semantics: the tracked `computed` + * ({@link reactiveSlotsKey}), re-evaluated when a *reactive* dependency read by + * a `dynamicSlots` factory changes, and the signal `Ref` ({@link slotsKey}), + * reassigned when `recalculateSlots()` is called (the documented path for + * factories over *non-reactive* deps). Neither is universally fresher — each + * update channel moves only its own source — so when both are present the + * returned getter reads **both** (tracking both inside {@link usePanels}' + * `computed`) and serves whichever produced the more recent evaluation. Both + * evaluate the same factories over the same deps, so the latest evaluation is + * always the correct one; panels therefore update on *either* path, matching + * the React host (whose single context carries both). + */ +function injectSlotsSource(): () => Record { + const reactive = inject(reactiveSlotsKey, null); + const signal = inject(slotsKey, null); + if (reactive && signal) { + let prevReactive: object | undefined; + let prevSignal: object | undefined; + let current!: object; + return () => { + // Read both so the enclosing computed tracks both update channels. + const r = reactive.value; + const s = signal.value; + if (prevReactive === undefined) { + // First read: both sources describe the same initial state; start from + // the tracked computed. + current = r; + } else { + // Serve the source that changed since the last read. If both changed, + // they re-evaluated over the same state — prefer the tracked computed. + if (s !== prevSignal) current = s; + if (r !== prevReactive) current = r; + } + prevReactive = r; + prevSignal = s; + return current as Record; + }; + } + if (reactive) return () => reactive.value as Record; + if (signal) return () => signal.value as Record; + throw new Error( + "[@modular-vue/vue] usePanels must be used within a modular app " + + "(install the resolved manifest so a slots source is provided).", + ); +} + +/** + * Resolve a panel group against a subject as a reactive `computed`. + * + * Every input is a `MaybeRefOrGetter`, resolved *inside* the `computed` so it + * is live: pass the subject as a `ref`, a getter, or a Pinia `computed` so the + * panels re-resolve when it changes (see the reactivity caveat above), and + * pass `group` / `onDuplicate` reactively too if they can change (plain values + * work as usual — group handles are typically module-level constants). The + * pure `resolvePanels` recomputes on the slot contributions, the subject, or + * either option changing. + * + * @example + * ```ts + * const panels = usePanels(inspectorPanels, () => board.selectedBlock) + * // panels.value → the ordered PanelEntry[] whose `when(block)` matched + * ``` + */ +export function usePanels( + group: MaybeRefOrGetter>, + subject: MaybeRefOrGetter, + opts?: { onDuplicate?: MaybeRefOrGetter }, +): ComputedRef[]> { + const readSlots = injectSlotsSource(); + return computed(() => + resolvePanels( + (readSlots()[toValue(group).slotKey] ?? []) as readonly PanelEntry[], + toValue(subject), + { onDuplicate: toValue(opts?.onDuplicate) }, + ), + ); +} + +function keyFor(entry: PanelEntry, subjectKey: unknown, subject: unknown): string { + if (subjectKey === undefined) return entry.id; + const discriminator = + typeof subjectKey === "function" + ? (subjectKey as (s: unknown) => string | number)(subject) + : (subjectKey as string | number); + return `${entry.id}:${discriminator}`; +} + +/** + * Render every panel a group resolves for its subject, ordered, each with the + * subject supplied as a `subject` prop **and** via `provide` (readable with + * {@link usePanelSubject}). + * + * - `group` — the {@link PanelGroupHandle} from `definePanelGroup`. + * - `subject` — the value the panels key on; `null` / `undefined` renders the + * `#empty` slot (or nothing). + * - `subjectKey` — optional discriminator folded into each rendered panel's key + * so switching subjects remounts panel content rather than reusing a stale + * instance. Re-read on every render: pass a `(subject) => string | number` to + * have the outlet derive it from the current subject, or a string you compute + * from the subject in the caller and update as the selection changes (a literal + * constant never varies, so it won't drive remounts). Absent = key on + * `entry.id` alone. + * + * Slots: + * - `#empty` — shown when no panel matches (or the subject is absent). + * - `#wrap` — optional per-panel chrome; receives + * `{ entry, subject, children }` and must render `children` somewhere (e.g. a + * collapsible section shell). + * + * Each panel is wrapped in `ModuleErrorBoundary` (keyed by the panel id) so one + * throwing panel can't take down the group. + * + * Authored with `defineComponent` + a render function (no SFC compiler in the + * package build; see decision D4). + */ +export const PanelsOutlet = defineComponent({ + name: "PanelsOutlet", + props: { + group: { type: Object as PropType>, required: true }, + subject: { type: null as unknown as PropType, default: null }, + subjectKey: { + type: null as unknown as PropType string | number)>, + default: undefined, + }, + onDuplicate: { + type: String as PropType, + default: undefined, + }, + }, + setup(props, { slots }) { + // Every prop flows into `usePanels` as a getter so the computed tracks it — + // reading `props.x` here in setup would freeze its mount-time value. The + // subject is additionally provided as a computed so nested + // `usePanelSubject()` stays reactive. + const panels = usePanels( + () => props.group, + () => props.subject, + { onDuplicate: () => props.onDuplicate }, + ); + const subjectRef = computed(() => props.subject); + provide(panelSubjectKey, subjectRef); + + return () => { + const resolved = panels.value; + if (resolved.length === 0) { + return slots.empty ? slots.empty() : null; + } + return resolved.map((entry) => { + const content: VNode = h(entry.component as Component, { + ...entry.props, + subject: props.subject, + }); + const inner = slots.wrap + ? slots.wrap({ entry, subject: props.subject, children: content }) + : content; + return h( + ModuleErrorBoundary, + { + key: keyFor(entry, props.subjectKey, props.subject), + moduleId: entry.id, + label: "Panel", + }, + () => inner, + ); + }); + }; + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ea94399..2b9d6b79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -469,6 +469,109 @@ importers: specifier: 8.1.5 version: 8.1.5(@types/node@25.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + examples/react-router/inspector-panels/app-shared: + dependencies: + '@modular-react/core': + specifier: workspace:* + version: link:../../../../packages/core + devDependencies: + typescript: + specifier: ^6.0.2 + version: 6.0.3 + + examples/react-router/inspector-panels/modules/acme-extras: + dependencies: + '@example-rr-inspector-panels/app-shared': + specifier: workspace:* + version: link:../../app-shared + '@react-router-modules/core': + specifier: workspace:* + version: link:../../../../../packages/react-router-core + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + react: + specifier: ^19.0.0 + version: 19.2.6 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + + examples/react-router/inspector-panels/modules/inspector-core: + dependencies: + '@example-rr-inspector-panels/app-shared': + specifier: workspace:* + version: link:../../app-shared + '@modular-react/react': + specifier: workspace:* + version: link:../../../../../packages/react + '@react-router-modules/core': + specifier: workspace:* + version: link:../../../../../packages/react-router-core + devDependencies: + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + react: + specifier: ^19.0.0 + version: 19.2.6 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + + examples/react-router/inspector-panels/shell: + dependencies: + '@example-rr-inspector-panels/acme-extras': + specifier: workspace:* + version: link:../modules/acme-extras + '@example-rr-inspector-panels/app-shared': + specifier: workspace:* + version: link:../app-shared + '@example-rr-inspector-panels/inspector-core': + specifier: workspace:* + version: link:../modules/inspector-core + '@modular-react/core': + specifier: workspace:* + version: link:../../../../packages/core + '@modular-react/react': + specifier: workspace:* + version: link:../../../../packages/react + '@react-router-modules/core': + specifier: workspace:* + version: link:../../../../packages/react-router-core + '@react-router-modules/runtime': + specifier: workspace:* + version: link:../../../../packages/react-router-runtime + react: + specifier: ^19.0.0 + version: 19.2.6 + react-dom: + specifier: ^19.0.0 + version: 19.2.6(react@19.2.6) + react-router: + specifier: ^7.0.0 + version: 7.15.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + devDependencies: + '@playwright/test': + specifier: ^1.58.2 + version: 1.59.1 + '@types/react': + specifier: ^19.0.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.0.0 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.1.5(@types/node@25.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0)) + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@25.6.2)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.49.0)(yaml@2.9.0) + examples/react-router/integration-manager/app-shared: devDependencies: '@types/react':