From 2b1ca273929b8b0b9a5996fd48a694bce00d6ad6 Mon Sep 17 00:00:00 2001 From: Igor Savin Date: Sat, 18 Jul 2026 17:49:21 +0300 Subject: [PATCH 1/3] =?UTF-8?q?feat(vue):=20add=20useReactiveSlots=20?= =?UTF-8?q?=E2=80=94=20Vue-reactive=20slot=20evaluation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `useReactiveSlots()` to `@modular-vue/vue`: the resolved slot manifest as a Vue `computed`, re-evaluated automatically when the reactive state its `dynamicSlots` factories / `slotFilter` read changes — no `recalculateSlots()` call required. It rebuilds the deps snapshot inside a tracked `computed`, so a factory/filter that reads a reactive source live (a service object with getters over refs, a reactive service, a reactive store proxy) makes it a tracked dependency. This is the Vue-idiomatic counterpart to the existing framework-neutral signal path (`useSlots()` + `useRecalculateSlots()`), which stays unchanged. The two coexist and are chosen per source: reactive when the gating inputs are reactive Vue state the host owns (RBAC permissions, availability flags); signal for non-reactive/external sources, transactional recompute, or event-driven invalidation. The runtime provides a new `reactiveSlotsConfigKey` (base slots + factories + filter) alongside the existing slots context, in both the plugin and framework-mode component install forms. Driven by the cat-factory nav/command-manifest adoption (the production consumer exercising the layer-extends consumer story). Full tradeoffs + the host-owned RBAC-gating shape: docs/reactive-slots-vue.md. React source for intent: packages/react/src/slots-context.tsx (signal-only; Vue adds the reactive path on top). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/framework-mode-nuxt.md | 3 + docs/reactive-slots-vue.md | 136 ++++++++++++++++++ docs/vue-support-tracker.md | 14 ++ packages/vue-runtime/src/index.ts | 4 +- packages/vue-runtime/src/providers.ts | 10 ++ .../vue-runtime/src/reactive-slots.test.ts | 72 ++++++++++ packages/vue/README.md | 19 ++- packages/vue/src/index.ts | 4 +- packages/vue/src/slots-context.test.ts | 80 ++++++++++- packages/vue/src/slots-context.ts | 101 +++++++++++++ 10 files changed, 439 insertions(+), 4 deletions(-) create mode 100644 docs/reactive-slots-vue.md create mode 100644 packages/vue-runtime/src/reactive-slots.test.ts diff --git a/docs/framework-mode-nuxt.md b/docs/framework-mode-nuxt.md index e5a5e608..dcd71ff6 100644 --- a/docs/framework-mode-nuxt.md +++ b/docs/framework-mode-nuxt.md @@ -314,6 +314,9 @@ want. ## See also +- [Reactive slots in Vue](reactive-slots-vue.md) — `useReactiveSlots` vs the + `recalculateSlots()` signal path, the tradeoffs, and the host-owned RBAC-gating + shape a layer's nav/command shells use. - [`@modular-vue/nuxt`](../packages/vue-nuxt/README.md) — package reference. - [Getting started with Vue Router](getting-started-vue-router.md) — modules, registry, stores, the manual SPA setup. diff --git a/docs/reactive-slots-vue.md b/docs/reactive-slots-vue.md new file mode 100644 index 00000000..efd954e4 --- /dev/null +++ b/docs/reactive-slots-vue.md @@ -0,0 +1,136 @@ +# Reactive slots in Vue (`useReactiveSlots`) + +Vue hosts have two ways to read the resolved slot manifest. They coexist, read +the same underlying config, and you pick per source: + +- **`useReactiveSlots()`** — the resolved slots as a Vue `computed`, re-evaluated + automatically when the reactive state its factories/filter read changes. +- **`useSlots()` + `useRecalculateSlots()`** — the framework-neutral signal path: + a `Ref` that only re-evaluates when application code calls `recalculateSlots()`. + +The React binding has only the signal path, because React has no ambient +reactivity to track. Vue does, so `useReactiveSlots` is added as the idiomatic +option for gating logic whose inputs are already reactive Vue state. + +## The one thing to understand first + +Dynamic slot evaluation reads its inputs through a **deps snapshot** +(`buildDepsSnapshot`): `store.getState()` per store, `getSnapshot()` per reactive +service, plain services passed by reference. That snapshot is a plain object. + +`useReactiveSlots` rebuilds that snapshot **inside a `computed`** on every +recompute and runs the factories/filter there. So a factory or filter that reads +a **reactive** source _live_ during evaluation makes it a tracked dependency of +the computed: + +- a plain service object with getters over `ref`/`reactive`/`computed` (read at + evaluation time), +- a reactive service whose `getSnapshot()` reads reactive state, +- a store adapter whose `getState()` returns a reactive proxy. + +A factory that reads a **non-reactive** snapshot (a value already read out of the +reactive system, e.g. a vanilla `createStore().getState()`) tracks nothing, so the +computed will never recompute for it. That is not a bug; it is the boundary +between the two paths. + +## When to use which + +| Situation | Path | Why | +| --- | --- | --- | +| Gating on Vue-reactive state the host owns (RBAC permissions, connection-availability flags, feature toggles in `ref`/`reactive`/Pinia) | `useReactiveSlots` | No invalidation call sites to maintain, so it can't go stale by omission; fine-grained tracking recomputes only on the state that actually changed | +| Gating on a non-reactive `Store`/zustand snapshot or an external `subscribe`/`getSnapshot` source read via `getState()`/`getSnapshot()` | signal | A `computed` reading a plain snapshot tracks nothing. Either bridge the source into a ref first, or invalidate explicitly with `recalculateSlots()` | +| A framework-neutral module used in both a React and a Vue host | signal | The shared contract is `dynamicSlots(deps)` + `recalculateSlots()`. (The factory itself is agnostic to how it's invoked, so a portable factory still runs fine under the reactive path in the Vue host — but the invalidation contract you author against is the signal.) | +| Several async-staged changes that should recompute the manifest **once** at the end, not on each intermediate tick | signal | An explicit `recalculateSlots()` after the transaction gives one clean recompute. Vue already batches synchronous writes within a tick, so this only matters for async multi-step changes | +| The trigger is an imperative event, not persisted reactive state ("reload config" button, a websocket message) | signal | The signal is the direct expression of "recompute now" | + +Rule of thumb: **reactive path when the gating inputs are reactive state the host +owns; signal path when they are non-reactive/external, or when you need +transactional or event-driven control.** It is a property of the _source_, not of +React-vs-Vue. + +## Is the reactive path over-eager? No + +A common worry is that a reactive slots value recomputes on every tick of +anything. A Vue `computed` does not behave that way: + +- **Lazy + cached.** It only recomputes when it is _read_ and a tracked dep + actually changed. Between changes, reads return the memoized value. If no + mounted component reads it, it does not run. +- **Fine-grained.** It tracks exactly the reactive sources touched during + evaluation. If the filter reads two permission flags, it invalidates on those + two, not on unrelated store mutations. This is more precise than + `recalculateSlots()`, which is coarse: one call rebuilds the whole manifest + regardless of what changed. +- **Value-gated downstream.** Wrapped in a computed/`shallowRef`, watchers only + wake when the produced array reference actually changes. + +So the recompute edge is `reactive source -> computed slots -> render`. The +source change is the trigger, the same producer-driven model as a manual +`recalculateSlots()`, except Vue wires that edge declaratively instead of by +hand. + +## Host-owned RBAC gating (the canonical shape) + +Register the gating state as a **reactive service** (a plain service object whose +getters read your reactive permission/availability state), then express the +gating as a `slotFilter` that reads it. Modules contribute their nav/command +items as plain data, each tagging the permission it needs. The shell reads the +already-gated manifest and stays a dumb renderer. + +```ts +// In the host plugin, where Pinia/composables are available: +const access = useWorkspaceAccess() // reactive computeds +const gates = { + get 'board.write'() { return access.canWriteBoard.value }, + get 'integrations.manage'() { return access.canManageIntegrations.value }, + // ... +} + +const registry = createRegistry({ + services: { gates }, // passed by reference, read live inside the computed + slots: { nav: [] }, +}) +// register modules that contribute nav items as data, each with a `gate` key... + +const manifest = installModularApp(nuxtApp, registry, { + slotFilter: (slots, deps) => ({ + ...slots, + nav: slots.nav.filter((i) => i.gate == null || deps.gates[i.gate]), + }), +}) +``` + +```ts +// In the SideBar / CommandBar / Toolbar shells: +const slots = useReactiveSlots() +const navItems = computed(() => slots.value.nav) // updates when a permission flips +``` + +When a permission flips (the snapshot the auth layer attached changes), the +`gates` getter returns a new value, the `slotFilter` reads it inside the +`useReactiveSlots` computed, and every shell reading `slots.value.nav` +recomputes. No `recalculateSlots()` call anywhere. + +## API + +```ts +function useReactiveSlots(): ComputedRef +``` + +Injects the reactive-slots config the runtime provides at install time (base +slots, collected `dynamicSlots` factories, the global `slotFilter`) plus the +shared-dependency buckets, and returns a `computed` that re-runs +`evaluateDynamicSlots` on each recompute. Throws if called outside an installed +modular app. + +The signal path is unchanged: `useSlots()` returns the `Ref`, and +`useRecalculateSlots()` returns the invalidation trigger. Both remain available; +`useReactiveSlots` does not replace them. + +## See also + +- [Framework-mode (Nuxt)](framework-mode-nuxt.md) — the `ssr: false` layer setup + and the consumer-contribution seam these shells build on. +- [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. diff --git a/docs/vue-support-tracker.md b/docs/vue-support-tracker.md index 0c099b81..fb7c3f91 100644 --- a/docs/vue-support-tracker.md +++ b/docs/vue-support-tracker.md @@ -636,6 +636,20 @@ Update the Status column as PRs move: `todo` → `in progress` → `in review` | PR-51 | Catalog Vue support | S | PR-40 | done | | PR-52 | Nuxt module (stretch) | L | D6, 1.0 | done | +## Post-parity additions (consumer-driven) + +- **`useReactiveSlots` (`@modular-vue/vue`).** A Vue-reactive alternative to the + `useSlots()` + `recalculateSlots()` signal path: evaluates the `dynamicSlots` + factories + `slotFilter` inside a `computed`, so gating that reads reactive Vue + state (RBAC permissions, availability flags) recomputes automatically with no + invalidation call. Additive — both paths coexist and are chosen per source. The + runtime provides a `reactiveSlotsConfigKey` alongside the existing slots + context. Driven by the cat-factory nav/command-manifest adoption (the + production consumer exercising the layer-extends story). Docs: + [reactive-slots-vue.md](./reactive-slots-vue.md). Partially advances **D3**: the + reactive path is what a Pinia-store host reads, so the Pinia-interop guide + section should reference it. + ## Working agreements - One PR per row above; if a PR grows past its size class, split it and add a row rather than letting it balloon. diff --git a/packages/vue-runtime/src/index.ts b/packages/vue-runtime/src/index.ts index 45b6592f..f83fc29e 100644 --- a/packages/vue-runtime/src/index.ts +++ b/packages/vue-runtime/src/index.ts @@ -54,9 +54,11 @@ export { createSlotsSignal, useNavigation, useSlots, + useReactiveSlots, useRecalculateSlots, + reactiveSlotsConfigKey, useModules, getModuleMeta, ModuleErrorBoundary, } from "@modular-vue/vue"; -export type { SlotsSignal } from "@modular-vue/vue"; +export type { SlotsSignal, ReactiveSlotsConfig } from "@modular-vue/vue"; diff --git a/packages/vue-runtime/src/providers.ts b/packages/vue-runtime/src/providers.ts index f4ea344c..9ee9e6f0 100644 --- a/packages/vue-runtime/src/providers.ts +++ b/packages/vue-runtime/src/providers.ts @@ -22,6 +22,7 @@ import { DynamicSlotsProvider, modulesKey, navigationKey, + reactiveSlotsConfigKey, recalculateSlotsKey, sharedDependenciesKey, slotsKey, @@ -92,6 +93,15 @@ function provideModularContexts( provideFn(navigationKey, config.navigation); provideFn(modulesKey, config.modules); provideFn(recalculateSlotsKey, config.recalculateSlots); + // The config `useReactiveSlots()` needs to re-evaluate the manifest itself + // inside a Vue `computed` (the reactive alternative to the recalculate signal). + // Provided in both the plugin and component forms so either install path + // supports the reactive path. + provideFn(reactiveSlotsConfigKey, { + baseSlots: config.slots, + factories: config.dynamicSlotFactories, + filter: config.slotFilter, + }); } /** diff --git a/packages/vue-runtime/src/reactive-slots.test.ts b/packages/vue-runtime/src/reactive-slots.test.ts new file mode 100644 index 00000000..532aff6e --- /dev/null +++ b/packages/vue-runtime/src/reactive-slots.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { defineComponent, h, nextTick, ref } from "vue"; +import { flushPromises, mount } from "@vue/test-utils"; +import { + createMemoryHistory, + createRouter, + RouterView, + type RouteRecordRaw, +} from "vue-router"; +import { useReactiveSlots } from "@modular-vue/vue"; +import { createRegistry } from "./registry.js"; +import { createModularApp } from "./app.js"; + +interface Slots { + nav: { id: string; gate?: string }[]; + [key: string]: readonly unknown[]; +} + +// End-to-end proof that the reactive-slots config the runtime provides at +// install time (providers.ts) reaches `useReactiveSlots`, and that a host +// slotFilter reading a reactive service recomputes on a reactive change with no +// recalculateSlots() call — the RBAC-gating shape a shell relies on. +describe("useReactiveSlots via resolve()", () => { + it("recomputes on a reactive change installed through the manifest", async () => { + const canWrite = ref(true); + // A plain service whose getter reads a reactive ref (the reactive-source + // pattern the reactive path supports; a snapshot store would not track). + const gates = { + get canWrite() { + return canWrite.value; + }, + }; + + const registry = createRegistry<{ gates: typeof gates }, Slots>({ + services: { gates }, + slots: { nav: [{ id: "always" }, { id: "guarded", gate: "canWrite" }] }, + }); + + const home: RouteRecordRaw = { + path: "/", + name: "home", + component: defineComponent({ + name: "HomePage", + setup() { + const slots = useReactiveSlots(); + return () => h("p", { class: "nav" }, slots.value.nav.map((i) => i.id).join(",")); + }, + }), + }; + const router = createRouter({ history: createMemoryHistory(), routes: [home] }); + const app = createModularApp(registry, { + router, + slotFilter: (slots, deps) => ({ + nav: slots.nav.filter((i) => i.gate == null || (deps.gates as Record)?.[i.gate]), + }), + }); + + const wrapper = mount( + defineComponent({ setup: () => () => h(RouterView) }), + { global: { plugins: [router, app] } }, + ); + await router.isReady(); + await flushPromises(); + + expect(wrapper.find(".nav").text()).toBe("always,guarded"); + + // Revoke write access — the guarded item drops with no signal fired. + canWrite.value = false; + await nextTick(); + expect(wrapper.find(".nav").text()).toBe("always"); + }); +}); diff --git a/packages/vue/README.md b/packages/vue/README.md index a9771419..81a97535 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -20,7 +20,24 @@ first package of the [Vue support initiative](../../docs/vue-support-tracker.md) - **Contexts** — typed `InjectionKey`s plus `provide*` helpers and `use*` composables for the modules list (`useModules`, `getModuleMeta`), the navigation manifest (`useNavigation`), and slot contributions (`useSlots`, - `useRecalculateSlots`, `DynamicSlotsProvider`, `createSlotsSignal`). + `useReactiveSlots`, `useRecalculateSlots`, `DynamicSlotsProvider`, + `createSlotsSignal`). + +### Slot evaluation: reactive vs signal + +Two ways to read the resolved slots, chosen per source: + +- `useReactiveSlots()` returns the slots as a `computed`, re-evaluated + automatically when the reactive state its factories/filter read changes. Use it + when the gating inputs are Vue-reactive state the host owns (RBAC permissions, + availability flags). +- `useSlots()` + `useRecalculateSlots()` is the framework-neutral signal path: a + `Ref` that re-evaluates only on an explicit `recalculateSlots()`. Use it for + non-reactive/external sources, transactional recompute, or event-driven + invalidation. + +Full tradeoffs and the RBAC-gating shape: +[Reactive slots in Vue](../../docs/reactive-slots-vue.md). Rendering pieces (lazy entry resolution, module host/exit, error capture) land in PR-11; the runtime plugin that installs these contexts lands with the diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 46e047a2..f3da88e8 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -54,13 +54,15 @@ export type { ScopedStore } from "./scoped-store.js"; export { slotsKey, recalculateSlotsKey, + reactiveSlotsConfigKey, provideSlots, useSlots, + useReactiveSlots, useRecalculateSlots, DynamicSlotsProvider, createSlotsSignal, } from "./slots-context.js"; -export type { SlotsSignal } from "./slots-context.js"; +export type { SlotsSignal, ReactiveSlotsConfig } from "./slots-context.js"; // Vue-specific: navigation context + composable export { navigationKey, provideNavigation, useNavigation } from "./navigation-context.js"; diff --git a/packages/vue/src/slots-context.test.ts b/packages/vue/src/slots-context.test.ts index dfb13240..1ebb7014 100644 --- a/packages/vue/src/slots-context.test.ts +++ b/packages/vue/src/slots-context.test.ts @@ -1,14 +1,17 @@ import { describe, it, expect } from "vitest"; -import { defineComponent, h, shallowRef, type Ref } from "vue"; +import { defineComponent, h, ref, shallowRef, type Ref } from "vue"; import { mount } from "@vue/test-utils"; import { createStore } from "@modular-frontend/core"; import { createSlotsSignal, DynamicSlotsProvider, + reactiveSlotsConfigKey, slotsKey, + useReactiveSlots, useRecalculateSlots, useSlots, } from "./slots-context.js"; +import { sharedDependenciesKey } from "./context.js"; import { renderComposable } from "./test-render.js"; describe("useSlots", () => { @@ -80,3 +83,78 @@ describe("DynamicSlotsProvider", () => { expect(captured.value.commands).toEqual([{ id: "static" }, { id: "admin" }]); }); }); + +describe("useReactiveSlots", () => { + it("throws outside a modular app", () => { + expect(() => renderComposable(() => useReactiveSlots())).toThrow(/useReactiveSlots/); + }); + + it("re-evaluates dynamic slots on a reactive change with no recalculate signal", () => { + const isAdmin = ref(false); + // A plain service whose getter reads a reactive ref: reading it inside the + // evaluation `computed` tracks the ref, so no recalculateSlots() is needed. + const gates = { + get isAdmin() { + return isAdmin.value; + }, + }; + const factory = (deps: any) => + deps.gates?.isAdmin ? { commands: [{ id: "admin" }] } : { commands: [] }; + + const { result } = renderComposable(() => useReactiveSlots<{ commands: { id: string }[] }>(), { + provide: { + [reactiveSlotsConfigKey as symbol]: { + baseSlots: { commands: [{ id: "static" }] }, + factories: [factory], + filter: undefined, + }, + [sharedDependenciesKey as symbol]: { + stores: {}, + services: { gates }, + reactiveServices: {}, + }, + }, + }); + + expect(result().value.commands).toEqual([{ id: "static" }]); + + // Flip the reactive source only — the computed recomputes on next read. + isAdmin.value = true; + expect(result().value.commands).toEqual([{ id: "static" }, { id: "admin" }]); + }); + + it("applies a reactive slotFilter (RBAC-style gating)", () => { + const canWrite = ref(true); + const gates = { + get canWrite() { + return canWrite.value; + }, + }; + const filter = (slots: any, deps: any) => ({ + nav: (slots.nav as { id: string; gate?: string }[]).filter( + (i) => i.gate == null || deps.gates?.[i.gate], + ), + }); + + const { result } = renderComposable(() => useReactiveSlots<{ nav: { id: string }[] }>(), { + provide: { + [reactiveSlotsConfigKey as symbol]: { + baseSlots: { nav: [{ id: "always" }, { id: "guarded", gate: "canWrite" }] }, + factories: [], + filter, + }, + [sharedDependenciesKey as symbol]: { + stores: {}, + services: { gates }, + reactiveServices: {}, + }, + }, + }); + + expect(result().value.nav.map((i) => i.id)).toEqual(["always", "guarded"]); + + // Revoke the permission — the guarded item drops with no signal fired. + canWrite.value = false; + expect(result().value.nav.map((i) => i.id)).toEqual(["always"]); + }); +}); diff --git a/packages/vue/src/slots-context.ts b/packages/vue/src/slots-context.ts index 73da9eaf..9d3c5229 100644 --- a/packages/vue/src/slots-context.ts +++ b/packages/vue/src/slots-context.ts @@ -1,10 +1,12 @@ import { + computed, defineComponent, inject, isRef, onScopeDispose, provide, shallowRef, + type ComputedRef, type InjectionKey, type PropType, type Ref, @@ -16,6 +18,7 @@ import type { Store, } from "@modular-frontend/core"; import { buildDepsSnapshot, evaluateDynamicSlots } from "@modular-frontend/core"; +import { sharedDependenciesKey } from "./context.js"; /** * Injection key holding the resolved slot contributions. Always a `Ref` so @@ -28,6 +31,27 @@ const noop = () => {}; /** Injection key holding the imperative "recalculate dynamic slots" trigger. */ export const recalculateSlotsKey: InjectionKey<() => void> = Symbol("modular-vue.recalculateSlots"); +/** + * The pieces {@link useReactiveSlots} needs to re-evaluate the slot manifest on + * its own: the static base slots, the collected `dynamicSlots` factories, and + * the optional global `slotFilter`. The runtime provides this alongside the + * resolved `slotsKey` ref so a component can opt into Vue-reactive evaluation + * (evaluate inside a `computed`) instead of the imperative signal path. + */ +export interface ReactiveSlotsConfig { + baseSlots: object; + factories: readonly DynamicSlotFactory[]; + filter?: SlotFilter; +} + +/** + * Injection key holding the {@link ReactiveSlotsConfig}. Provided by the runtime + * at install time; consumed only by {@link useReactiveSlots}. + */ +export const reactiveSlotsConfigKey: InjectionKey = Symbol( + "modular-vue.reactiveSlotsConfig", +); + /** * Provide a static set of slot contributions. Accepts a plain object or an * existing `Ref`; a plain object is wrapped in a `shallowRef` so consumers @@ -63,6 +87,83 @@ export function useSlots< return slots as Ref; } +/** + * Access the resolved slot contributions as a Vue-reactive `computed`, evaluated + * on read and re-evaluated automatically whenever the reactive state its + * `dynamicSlots` factories / `slotFilter` touch changes — no `recalculateSlots()` + * call required. + * + * This is the Vue-idiomatic alternative to {@link useSlots} + the imperative + * {@link useRecalculateSlots} signal. The factories and filter run inside a + * `computed`, so any reactive source they read *live* during evaluation (a + * reactive service object, a `ref`/`reactive` closed over by a factory, a store + * whose `getState()` returns a reactive proxy) becomes a tracked dependency: + * Vue recomputes lazily on next read after a relevant change, tracking exactly + * the state actually read. + * + * ## When to use which + * + * Choose the reactive path (this) when the gating inputs are **Vue-reactive + * state the host owns** — e.g. RBAC permissions, connection-availability flags, + * feature toggles held in `ref`/`reactive`/Pinia. It needs no invalidation call + * sites, so it can't go stale by omission, and its fine-grained tracking only + * recomputes on the specific state that changed. + * + * Choose the signal path ({@link useSlots} + {@link useRecalculateSlots}) when: + * - the gating inputs are **not Vue-reactive** — a plain `Store`/zustand snapshot, + * or an external `subscribe`/`getSnapshot` source read via `getState()` / + * `getSnapshot()` (a `computed` reading a plain snapshot tracks nothing, so it + * would never recompute); either bridge those into refs first or invalidate + * explicitly; + * - you need **transactional** recompute — apply several async-staged changes and + * recompute once at the end rather than on each intermediate reactive tick; + * - the trigger is an **imperative event** that is not persisted reactive state. + * + * The two paths coexist and read the same underlying config; pick per source. + * + * @remarks + * `dynamicSlots(deps)` factories themselves stay framework-neutral — they receive + * a plain deps snapshot either way. Reactivity is the host's concern here: this + * composable rebuilds the snapshot inside the tracked `computed` on every + * recompute, so a factory/filter that reads a reactive dep tracks it. A factory + * that only reads non-reactive deps simply never triggers a recompute (same + * result the signal path would give without a `recalculateSlots()` call). + * + * @example + * // Host-owned RBAC gating, expressed as a reactive slotFilter reading a + * // reactive `gates` service registered on the registry: + * const slots = useReactiveSlots() + * const navItems = computed(() => slots.value.nav) // updates when a permission flips + */ +export function useReactiveSlots< + TSlots extends { [K in keyof TSlots]: readonly unknown[] }, +>(): ComputedRef { + const config = inject(reactiveSlotsConfigKey, null); + const deps = inject(sharedDependenciesKey, null); + if (!config || !deps) { + throw new Error( + "[@modular-vue/vue] useReactiveSlots must be used within a modular app " + + "(install the resolved manifest so the reactive-slots config is provided).", + ); + } + return computed(() => { + // Rebuild the snapshot INSIDE the computed so reactive reads performed by the + // factories / filter (through reactive service objects or reactive stores) + // are tracked as dependencies of this computed. + const snapshot = buildDepsSnapshot>({ + stores: deps.stores, + services: deps.services, + reactiveServices: deps.reactiveServices, + }); + return evaluateDynamicSlots( + config.baseSlots as TSlots, + config.factories, + snapshot, + config.filter, + ); + }); +} + /** * Returns a function that triggers re-evaluation of dynamic slots. * From 6bf7d018a62f3ed36e3ade9f9f0df74d72c898d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 14:59:23 +0000 Subject: [PATCH 2/3] chore(vue): fix slot-doc formatting and address review findings - Fix oxfmt formatting that failed the Lint CI job (reactive-slots-vue.md, framework-mode-nuxt.md, reactive-slots.test.ts). - Docs: clarify the "value-gated downstream" note (useReactiveSlots returns a plain computed producing a fresh manifest per recompute; value-gating pays off one level down on stable derived values), and add a "cost scales per consumer" caveat with the share-once-high-in-the-tree remedy. - Tests: cover the reactiveService getSnapshot() reactive-tracking path so the buildDepsSnapshot-through-getSnapshot boundary is locked in alongside the existing plain-service and slotFilter cases. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Py3ZFw7u2Qnw9N4X9MRsBi --- docs/framework-mode-nuxt.md | 4 +- docs/reactive-slots-vue.md | 57 +++++++++++++------ .../vue-runtime/src/reactive-slots.test.ts | 18 +++--- packages/vue/src/slots-context.test.ts | 35 ++++++++++++ 4 files changed, 84 insertions(+), 30 deletions(-) diff --git a/docs/framework-mode-nuxt.md b/docs/framework-mode-nuxt.md index dcd71ff6..f65cd317 100644 --- a/docs/framework-mode-nuxt.md +++ b/docs/framework-mode-nuxt.md @@ -241,7 +241,9 @@ for consumers: import { createRegistry } from "@modular-vue/runtime"; import type { AnyModuleDescriptor } from "@modular-vue/core"; -const firstParty: readonly AnyModuleDescriptor[] = [/* the layer's own modules */]; +const firstParty: readonly AnyModuleDescriptor[] = [ + /* the layer's own modules */ +]; const contributed: AnyModuleDescriptor[] = []; /** Consumers call this from their own plugin, before the layer resolves. */ diff --git a/docs/reactive-slots-vue.md b/docs/reactive-slots-vue.md index efd954e4..ec35b53c 100644 --- a/docs/reactive-slots-vue.md +++ b/docs/reactive-slots-vue.md @@ -35,13 +35,13 @@ between the two paths. ## When to use which -| Situation | Path | Why | -| --- | --- | --- | -| Gating on Vue-reactive state the host owns (RBAC permissions, connection-availability flags, feature toggles in `ref`/`reactive`/Pinia) | `useReactiveSlots` | No invalidation call sites to maintain, so it can't go stale by omission; fine-grained tracking recomputes only on the state that actually changed | -| Gating on a non-reactive `Store`/zustand snapshot or an external `subscribe`/`getSnapshot` source read via `getState()`/`getSnapshot()` | signal | A `computed` reading a plain snapshot tracks nothing. Either bridge the source into a ref first, or invalidate explicitly with `recalculateSlots()` | -| A framework-neutral module used in both a React and a Vue host | signal | The shared contract is `dynamicSlots(deps)` + `recalculateSlots()`. (The factory itself is agnostic to how it's invoked, so a portable factory still runs fine under the reactive path in the Vue host — but the invalidation contract you author against is the signal.) | -| Several async-staged changes that should recompute the manifest **once** at the end, not on each intermediate tick | signal | An explicit `recalculateSlots()` after the transaction gives one clean recompute. Vue already batches synchronous writes within a tick, so this only matters for async multi-step changes | -| The trigger is an imperative event, not persisted reactive state ("reload config" button, a websocket message) | signal | The signal is the direct expression of "recompute now" | +| Situation | Path | Why | +| --------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Gating on Vue-reactive state the host owns (RBAC permissions, connection-availability flags, feature toggles in `ref`/`reactive`/Pinia) | `useReactiveSlots` | No invalidation call sites to maintain, so it can't go stale by omission; fine-grained tracking recomputes only on the state that actually changed | +| Gating on a non-reactive `Store`/zustand snapshot or an external `subscribe`/`getSnapshot` source read via `getState()`/`getSnapshot()` | signal | A `computed` reading a plain snapshot tracks nothing. Either bridge the source into a ref first, or invalidate explicitly with `recalculateSlots()` | +| A framework-neutral module used in both a React and a Vue host | signal | The shared contract is `dynamicSlots(deps)` + `recalculateSlots()`. (The factory itself is agnostic to how it's invoked, so a portable factory still runs fine under the reactive path in the Vue host — but the invalidation contract you author against is the signal.) | +| Several async-staged changes that should recompute the manifest **once** at the end, not on each intermediate tick | signal | An explicit `recalculateSlots()` after the transaction gives one clean recompute. Vue already batches synchronous writes within a tick, so this only matters for async multi-step changes | +| The trigger is an imperative event, not persisted reactive state ("reload config" button, a websocket message) | signal | The signal is the direct expression of "recompute now" | Rule of thumb: **reactive path when the gating inputs are reactive state the host owns; signal path when they are non-reactive/external, or when you need @@ -61,14 +61,31 @@ anything. A Vue `computed` does not behave that way: two, not on unrelated store mutations. This is more precise than `recalculateSlots()`, which is coarse: one call rebuilds the whole manifest regardless of what changed. -- **Value-gated downstream.** Wrapped in a computed/`shallowRef`, watchers only - wake when the produced array reference actually changes. +- **Value-gated downstream.** `useReactiveSlots` returns a plain `computed`, and + each recompute produces a fresh manifest object, so `slots.value` itself + changes reference on every recompute. The value-gating pays off one level + down: derive what a component actually consumes (`computed(() => slots.value.nav)`) + and, where the selected value is a stable primitive rather than a new array, + the downstream computed/watcher only wakes when that value truly changes. So the recompute edge is `reactive source -> computed slots -> render`. The source change is the trigger, the same producer-driven model as a manual `recalculateSlots()`, except Vue wires that edge declaratively instead of by hand. +### Cost scales per consumer, not per app + +`useReactiveSlots()` returns a **new `computed` per call**, so each component +that calls it re-evaluates the factories/filter independently on a relevant +change (whereas the signal path shares a single resolved `Ref` across all +consumers, recomputing once per `recalculateSlots()`). Evaluation is a cheap +merge + filter and each computed is lazy and cached, so for the handful of +shells that read the manifest (a sidebar, a command bar, a toolbar) this is +negligible. If you ever have many concurrent consumers and evaluation is +expensive, read it **once** high in the tree and hand the result down — +`const slots = useReactiveSlots(); provide(appSlotsKey, slots)` — so +the single computed is shared instead of duplicated per consumer. + ## Host-owned RBAC gating (the canonical shape) Register the gating state as a **reactive service** (a plain service object whose @@ -79,17 +96,21 @@ already-gated manifest and stays a dumb renderer. ```ts // In the host plugin, where Pinia/composables are available: -const access = useWorkspaceAccess() // reactive computeds +const access = useWorkspaceAccess(); // reactive computeds const gates = { - get 'board.write'() { return access.canWriteBoard.value }, - get 'integrations.manage'() { return access.canManageIntegrations.value }, + get "board.write"() { + return access.canWriteBoard.value; + }, + get "integrations.manage"() { + return access.canManageIntegrations.value; + }, // ... -} +}; const registry = createRegistry({ services: { gates }, // passed by reference, read live inside the computed slots: { nav: [] }, -}) +}); // register modules that contribute nav items as data, each with a `gate` key... const manifest = installModularApp(nuxtApp, registry, { @@ -97,13 +118,13 @@ const manifest = installModularApp(nuxtApp, registry, { ...slots, nav: slots.nav.filter((i) => i.gate == null || deps.gates[i.gate]), }), -}) +}); ``` ```ts // In the SideBar / CommandBar / Toolbar shells: -const slots = useReactiveSlots() -const navItems = computed(() => slots.value.nav) // updates when a permission flips +const slots = useReactiveSlots(); +const navItems = computed(() => slots.value.nav); // updates when a permission flips ``` When a permission flips (the snapshot the auth layer attached changes), the @@ -114,7 +135,7 @@ recomputes. No `recalculateSlots()` call anywhere. ## API ```ts -function useReactiveSlots(): ComputedRef +function useReactiveSlots(): ComputedRef; ``` Injects the reactive-slots config the runtime provides at install time (base diff --git a/packages/vue-runtime/src/reactive-slots.test.ts b/packages/vue-runtime/src/reactive-slots.test.ts index 532aff6e..06fd14eb 100644 --- a/packages/vue-runtime/src/reactive-slots.test.ts +++ b/packages/vue-runtime/src/reactive-slots.test.ts @@ -1,12 +1,7 @@ import { describe, it, expect } from "vitest"; import { defineComponent, h, nextTick, ref } from "vue"; import { flushPromises, mount } from "@vue/test-utils"; -import { - createMemoryHistory, - createRouter, - RouterView, - type RouteRecordRaw, -} from "vue-router"; +import { createMemoryHistory, createRouter, RouterView, type RouteRecordRaw } from "vue-router"; import { useReactiveSlots } from "@modular-vue/vue"; import { createRegistry } from "./registry.js"; import { createModularApp } from "./app.js"; @@ -51,14 +46,15 @@ describe("useReactiveSlots via resolve()", () => { const app = createModularApp(registry, { router, slotFilter: (slots, deps) => ({ - nav: slots.nav.filter((i) => i.gate == null || (deps.gates as Record)?.[i.gate]), + nav: slots.nav.filter( + (i) => i.gate == null || (deps.gates as Record)?.[i.gate], + ), }), }); - const wrapper = mount( - defineComponent({ setup: () => () => h(RouterView) }), - { global: { plugins: [router, app] } }, - ); + const wrapper = mount(defineComponent({ setup: () => () => h(RouterView) }), { + global: { plugins: [router, app] }, + }); await router.isReady(); await flushPromises(); diff --git a/packages/vue/src/slots-context.test.ts b/packages/vue/src/slots-context.test.ts index 1ebb7014..ea177959 100644 --- a/packages/vue/src/slots-context.test.ts +++ b/packages/vue/src/slots-context.test.ts @@ -157,4 +157,39 @@ describe("useReactiveSlots", () => { canWrite.value = false; expect(result().value.nav.map((i) => i.id)).toEqual(["always"]); }); + + it("tracks a reactive source read through a reactiveService getSnapshot", () => { + const isAdmin = ref(false); + // A reactiveService whose getSnapshot() reads reactive state: the snapshot is + // rebuilt inside the computed (buildDepsSnapshot calls getSnapshot there), so + // the ref read is tracked even though the deps arrive through the + // reactiveServices bucket rather than a plain service reference. + const gates = { + subscribe: () => () => {}, + getSnapshot: () => ({ isAdmin: isAdmin.value }), + }; + const factory = (deps: any) => + deps.gates?.isAdmin ? { commands: [{ id: "admin" }] } : { commands: [] }; + + const { result } = renderComposable(() => useReactiveSlots<{ commands: { id: string }[] }>(), { + provide: { + [reactiveSlotsConfigKey as symbol]: { + baseSlots: { commands: [{ id: "static" }] }, + factories: [factory], + filter: undefined, + }, + [sharedDependenciesKey as symbol]: { + stores: {}, + services: {}, + reactiveServices: { gates }, + }, + }, + }); + + expect(result().value.commands).toEqual([{ id: "static" }]); + + // Flip the reactive source the snapshot reads — the computed recomputes. + isAdmin.value = true; + expect(result().value.commands).toEqual([{ id: "static" }, { id: "admin" }]); + }); }); From 8a601054f2999def932a0ad8471d86fa5ac48f75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 15:15:55 +0000 Subject: [PATCH 3/3] refactor(vue): resolve reactive slots once at install as a shared source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the reactive path mirror the signal path's architecture: the runtime resolves the manifest once at install into a single shared `computed` and provides it via `reactiveSlotsKey`; `useReactiveSlots()` becomes a thin reader over that one source. Previously each `useReactiveSlots()` call built its own `computed`, so N consumers re-evaluated the factories/filter N times per change; now evaluation happens once per change regardless of consumer count, and every consumer sees the same manifest object. - slots-context.ts: replace `reactiveSlotsConfigKey`/`ReactiveSlotsConfig` with `reactiveSlotsKey` (holds the resolved `ComputedRef`) and a runtime-facing `resolveReactiveSlots(input)` building block; `useReactiveSlots` injects and returns the shared computed. Drops the composable's dependency on `sharedDependenciesKey`. - providers.ts: build the source once per install — inside a detached `effectScope` stopped on `app.onUnmount` (plugin form) or the ModularProviders `setup` scope (framework-mode) — so the computed's effect is disposed with the app, matching how the signal subscription is disposed. - Tests: cover `resolveReactiveSlots` evaluation directly (reactive factory, reactive slotFilter, reactiveService getSnapshot tracking) and assert `useReactiveSlots` hands every consumer the same shared computed instance. - Docs: describe the single-shared-source model; address review feedback on the RBAC section — `gates` is a plain service passed by reference, tracked because its getters read reactive state inside the computed, not via a snapshot swap. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Py3ZFw7u2Qnw9N4X9MRsBi --- docs/reactive-slots-vue.md | 57 ++++++----- docs/vue-support-tracker.md | 6 +- packages/vue-runtime/src/index.ts | 4 +- packages/vue-runtime/src/providers.ts | 50 ++++++++-- packages/vue/src/index.ts | 5 +- packages/vue/src/slots-context.test.ts | 132 ++++++++++++++----------- packages/vue/src/slots-context.ts | 99 ++++++++++++------- 7 files changed, 219 insertions(+), 134 deletions(-) diff --git a/docs/reactive-slots-vue.md b/docs/reactive-slots-vue.md index ec35b53c..8787e6c7 100644 --- a/docs/reactive-slots-vue.md +++ b/docs/reactive-slots-vue.md @@ -73,26 +73,25 @@ source change is the trigger, the same producer-driven model as a manual `recalculateSlots()`, except Vue wires that edge declaratively instead of by hand. -### Cost scales per consumer, not per app - -`useReactiveSlots()` returns a **new `computed` per call**, so each component -that calls it re-evaluates the factories/filter independently on a relevant -change (whereas the signal path shares a single resolved `Ref` across all -consumers, recomputing once per `recalculateSlots()`). Evaluation is a cheap -merge + filter and each computed is lazy and cached, so for the handful of -shells that read the manifest (a sidebar, a command bar, a toolbar) this is -negligible. If you ever have many concurrent consumers and evaluation is -expensive, read it **once** high in the tree and hand the result down — -`const slots = useReactiveSlots(); provide(appSlotsKey, slots)` — so -the single computed is shared instead of duplicated per consumer. +### One shared source, evaluated once per change + +The runtime resolves the reactive manifest **once** at install +(`resolveReactiveSlots`) into a single `computed` and provides it; +`useReactiveSlots()` is a thin reader that injects that same `computed`. So no +matter how many shells read it (a sidebar, a command bar, a toolbar), a relevant +change re-evaluates the factories/filter **once**, and every consumer sees the +same manifest object. This mirrors the signal path, which likewise resolves one +shared `Ref` that all `useSlots()` consumers read — both paths are "runtime +resolves once → provide → composable injects", differing only in what triggers a +recompute (a tracked reactive read here, an explicit `recalculateSlots()` there). ## Host-owned RBAC gating (the canonical shape) -Register the gating state as a **reactive service** (a plain service object whose -getters read your reactive permission/availability state), then express the -gating as a `slotFilter` that reads it. Modules contribute their nav/command -items as plain data, each tagging the permission it needs. The shell reads the -already-gated manifest and stays a dumb renderer. +Register the gating state as a **plain service** (a service object whose getters +read your reactive permission/availability state), then express the gating as a +`slotFilter` that reads it. Modules contribute their nav/command items as plain +data, each tagging the permission it needs. The shell reads the already-gated +manifest and stays a dumb renderer. ```ts // In the host plugin, where Pinia/composables are available: @@ -127,10 +126,13 @@ const slots = useReactiveSlots(); const navItems = computed(() => slots.value.nav); // updates when a permission flips ``` -When a permission flips (the snapshot the auth layer attached changes), the -`gates` getter returns a new value, the `slotFilter` reads it inside the -`useReactiveSlots` computed, and every shell reading `slots.value.nav` -recomputes. No `recalculateSlots()` call anywhere. +`gates` is a **plain service passed by reference** — nothing about it is +re-attached, refreshed, or replaced. When a permission flips, only the reactive +state its getters read (`access.canWriteBoard`, …) changes. Because those getters +are invoked _inside_ the shared `useReactiveSlots` computed — the `slotFilter` +reads `deps.gates[i.gate]` during evaluation — that read is registered as a +tracked dependency, so the computed re-evaluates and every shell reading +`slots.value.nav` recomputes. No `recalculateSlots()` call anywhere. ## API @@ -138,11 +140,14 @@ recomputes. No `recalculateSlots()` call anywhere. function useReactiveSlots(): ComputedRef; ``` -Injects the reactive-slots config the runtime provides at install time (base -slots, collected `dynamicSlots` factories, the global `slotFilter`) plus the -shared-dependency buckets, and returns a `computed` that re-runs -`evaluateDynamicSlots` on each recompute. Throws if called outside an installed -modular app. +Injects the single resolved reactive-slots `computed` the runtime builds once at +install time and returns it. Throws if called outside an installed modular app. +The runtime builds that source with `resolveReactiveSlots(input)` — which closes +over the base slots, collected `dynamicSlots` factories, the global `slotFilter`, +and the shared-dependency buckets, and returns a `computed` that rebuilds the +deps snapshot and re-runs `evaluateDynamicSlots` inside its getter (so reactive +reads are tracked). Consumers never touch `resolveReactiveSlots` directly; it is +the runtime-facing building block behind `useReactiveSlots`. The signal path is unchanged: `useSlots()` returns the `Ref`, and `useRecalculateSlots()` returns the invalidation trigger. Both remain available; diff --git a/docs/vue-support-tracker.md b/docs/vue-support-tracker.md index fb7c3f91..20bce5c6 100644 --- a/docs/vue-support-tracker.md +++ b/docs/vue-support-tracker.md @@ -643,8 +643,10 @@ Update the Status column as PRs move: `todo` → `in progress` → `in review` factories + `slotFilter` inside a `computed`, so gating that reads reactive Vue state (RBAC permissions, availability flags) recomputes automatically with no invalidation call. Additive — both paths coexist and are chosen per source. The - runtime provides a `reactiveSlotsConfigKey` alongside the existing slots - context. Driven by the cat-factory nav/command-manifest adoption (the + runtime resolves the source once at install (`resolveReactiveSlots`) and + provides the single shared `computed` via `reactiveSlotsKey`, mirroring how the + signal path resolves one shared `Ref` — `useReactiveSlots` is a thin reader + over it. Driven by the cat-factory nav/command-manifest adoption (the production consumer exercising the layer-extends story). Docs: [reactive-slots-vue.md](./reactive-slots-vue.md). Partially advances **D3**: the reactive path is what a Pinia-store host reads, so the Pinia-interop guide diff --git a/packages/vue-runtime/src/index.ts b/packages/vue-runtime/src/index.ts index f83fc29e..f5f6b1bb 100644 --- a/packages/vue-runtime/src/index.ts +++ b/packages/vue-runtime/src/index.ts @@ -56,9 +56,9 @@ export { useSlots, useReactiveSlots, useRecalculateSlots, - reactiveSlotsConfigKey, + reactiveSlotsKey, useModules, getModuleMeta, ModuleErrorBoundary, } from "@modular-vue/vue"; -export type { SlotsSignal, ReactiveSlotsConfig } from "@modular-vue/vue"; +export type { SlotsSignal } from "@modular-vue/vue"; diff --git a/packages/vue-runtime/src/providers.ts b/packages/vue-runtime/src/providers.ts index 9ee9e6f0..46e1c8c0 100644 --- a/packages/vue-runtime/src/providers.ts +++ b/packages/vue-runtime/src/providers.ts @@ -1,5 +1,6 @@ import { defineComponent, + effectScope, h, provide, shallowRef, @@ -22,10 +23,12 @@ import { DynamicSlotsProvider, modulesKey, navigationKey, - reactiveSlotsConfigKey, + reactiveSlotsKey, recalculateSlotsKey, + resolveReactiveSlots, sharedDependenciesKey, slotsKey, + type ReactiveSlotsInput, type SlotsSignal, } from "@modular-vue/vue"; @@ -75,11 +78,32 @@ function computeDynamicSlots(config: ModularProvidersConfig): object { ); } +/** + * The evaluation inputs the reactive slots source ({@link resolveReactiveSlots}) + * needs: the base slots, factories and filter, plus the three dependency buckets + * the snapshot is rebuilt from inside the tracked `computed`. + */ +function reactiveSlotsInput(config: ModularProvidersConfig): ReactiveSlotsInput { + return { + baseSlots: config.slots, + factories: config.dynamicSlotFactories, + filter: config.slotFilter, + stores: config.stores, + services: config.services, + reactiveServices: config.reactiveServices, + }; +} + /** * Provide the four always-present modular contexts through either the app-level * `app.provide` (plugin form) or the component-scoped `provide` (component * form). Keeps the context set enumerated in one place so both forms expose an * identical injection surface. + * + * The reactive-slots source ({@link reactiveSlotsKey}) is provided separately by + * each form: its resolved `computed` needs an owning effect scope, which differs + * between the app-level plugin (an explicit `effectScope`) and the component + * (its own `setup` scope). */ function provideModularContexts( provideFn: (key: InjectionKey, value: T) => void, @@ -93,15 +117,6 @@ function provideModularContexts( provideFn(navigationKey, config.navigation); provideFn(modulesKey, config.modules); provideFn(recalculateSlotsKey, config.recalculateSlots); - // The config `useReactiveSlots()` needs to re-evaluate the manifest itself - // inside a Vue `computed` (the reactive alternative to the recalculate signal). - // Provided in both the plugin and component forms so either install path - // supports the reactive path. - provideFn(reactiveSlotsConfigKey, { - baseSlots: config.slots, - factories: config.dynamicSlotFactories, - filter: config.slotFilter, - }); } /** @@ -140,6 +155,17 @@ export function createModularProvidersPlugin( app.provide(slotsKey, shallowRef(config.slots)); } + // Reactive path: one shared `computed` resolved once, read by every + // `useReactiveSlots()` consumer. Owned by a detached `effectScope` stopped + // on app unmount so the computed's effect doesn't outlive the app (same + // multi-install hygiene as the signal subscription above). + const reactiveScope = effectScope(true); + const reactiveSlots = reactiveScope.run(() => + resolveReactiveSlots(reactiveSlotsInput(config)), + )!; + app.onUnmount(() => reactiveScope.stop()); + app.provide(reactiveSlotsKey, reactiveSlots); + if (userPlugins) { for (const plugin of userPlugins) app.use(plugin); } @@ -172,6 +198,10 @@ export function createModularProvidersComponent( // Static slots are provided here; dynamic slots are provided by the // nested DynamicSlotsProvider in the render function below. if (!dynamic) provide(slotsKey, shallowRef(config.slots)); + // Reactive path: the one shared `computed` every `useReactiveSlots()` + // consumer reads. Created in this component's `setup` scope, so its effect + // is disposed when ModularProviders unmounts (no explicit scope needed). + provide(reactiveSlotsKey, resolveReactiveSlots(reactiveSlotsInput(config))); return () => { const children = () => renderSlots.default?.(); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index f3da88e8..f9fe5ce3 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -54,15 +54,16 @@ export type { ScopedStore } from "./scoped-store.js"; export { slotsKey, recalculateSlotsKey, - reactiveSlotsConfigKey, + reactiveSlotsKey, provideSlots, useSlots, useReactiveSlots, + resolveReactiveSlots, useRecalculateSlots, DynamicSlotsProvider, createSlotsSignal, } from "./slots-context.js"; -export type { SlotsSignal, ReactiveSlotsConfig } from "./slots-context.js"; +export type { SlotsSignal, ReactiveSlotsInput } from "./slots-context.js"; // Vue-specific: navigation context + composable export { navigationKey, provideNavigation, useNavigation } from "./navigation-context.js"; diff --git a/packages/vue/src/slots-context.test.ts b/packages/vue/src/slots-context.test.ts index ea177959..1922acf2 100644 --- a/packages/vue/src/slots-context.test.ts +++ b/packages/vue/src/slots-context.test.ts @@ -1,17 +1,17 @@ import { describe, it, expect } from "vitest"; -import { defineComponent, h, ref, shallowRef, type Ref } from "vue"; +import { defineComponent, h, ref, shallowRef, type ComputedRef, type Ref } from "vue"; import { mount } from "@vue/test-utils"; import { createStore } from "@modular-frontend/core"; import { createSlotsSignal, DynamicSlotsProvider, - reactiveSlotsConfigKey, + reactiveSlotsKey, + resolveReactiveSlots, slotsKey, useReactiveSlots, useRecalculateSlots, useSlots, } from "./slots-context.js"; -import { sharedDependenciesKey } from "./context.js"; import { renderComposable } from "./test-render.js"; describe("useSlots", () => { @@ -84,11 +84,7 @@ describe("DynamicSlotsProvider", () => { }); }); -describe("useReactiveSlots", () => { - it("throws outside a modular app", () => { - expect(() => renderComposable(() => useReactiveSlots())).toThrow(/useReactiveSlots/); - }); - +describe("resolveReactiveSlots", () => { it("re-evaluates dynamic slots on a reactive change with no recalculate signal", () => { const isAdmin = ref(false); // A plain service whose getter reads a reactive ref: reading it inside the @@ -101,26 +97,20 @@ describe("useReactiveSlots", () => { const factory = (deps: any) => deps.gates?.isAdmin ? { commands: [{ id: "admin" }] } : { commands: [] }; - const { result } = renderComposable(() => useReactiveSlots<{ commands: { id: string }[] }>(), { - provide: { - [reactiveSlotsConfigKey as symbol]: { - baseSlots: { commands: [{ id: "static" }] }, - factories: [factory], - filter: undefined, - }, - [sharedDependenciesKey as symbol]: { - stores: {}, - services: { gates }, - reactiveServices: {}, - }, - }, - }); + const slots = resolveReactiveSlots({ + baseSlots: { commands: [{ id: "static" }] }, + factories: [factory], + filter: undefined, + stores: {}, + services: { gates }, + reactiveServices: {}, + }) as ComputedRef<{ commands: { id: string }[] }>; - expect(result().value.commands).toEqual([{ id: "static" }]); + expect(slots.value.commands).toEqual([{ id: "static" }]); // Flip the reactive source only — the computed recomputes on next read. isAdmin.value = true; - expect(result().value.commands).toEqual([{ id: "static" }, { id: "admin" }]); + expect(slots.value.commands).toEqual([{ id: "static" }, { id: "admin" }]); }); it("applies a reactive slotFilter (RBAC-style gating)", () => { @@ -136,26 +126,20 @@ describe("useReactiveSlots", () => { ), }); - const { result } = renderComposable(() => useReactiveSlots<{ nav: { id: string }[] }>(), { - provide: { - [reactiveSlotsConfigKey as symbol]: { - baseSlots: { nav: [{ id: "always" }, { id: "guarded", gate: "canWrite" }] }, - factories: [], - filter, - }, - [sharedDependenciesKey as symbol]: { - stores: {}, - services: { gates }, - reactiveServices: {}, - }, - }, - }); + const slots = resolveReactiveSlots({ + baseSlots: { nav: [{ id: "always" }, { id: "guarded", gate: "canWrite" }] }, + factories: [], + filter, + stores: {}, + services: { gates }, + reactiveServices: {}, + }) as ComputedRef<{ nav: { id: string }[] }>; - expect(result().value.nav.map((i) => i.id)).toEqual(["always", "guarded"]); + expect(slots.value.nav.map((i) => i.id)).toEqual(["always", "guarded"]); // Revoke the permission — the guarded item drops with no signal fired. canWrite.value = false; - expect(result().value.nav.map((i) => i.id)).toEqual(["always"]); + expect(slots.value.nav.map((i) => i.id)).toEqual(["always"]); }); it("tracks a reactive source read through a reactiveService getSnapshot", () => { @@ -171,25 +155,63 @@ describe("useReactiveSlots", () => { const factory = (deps: any) => deps.gates?.isAdmin ? { commands: [{ id: "admin" }] } : { commands: [] }; - const { result } = renderComposable(() => useReactiveSlots<{ commands: { id: string }[] }>(), { - provide: { - [reactiveSlotsConfigKey as symbol]: { - baseSlots: { commands: [{ id: "static" }] }, - factories: [factory], - filter: undefined, - }, - [sharedDependenciesKey as symbol]: { - stores: {}, - services: {}, - reactiveServices: { gates }, - }, + const slots = resolveReactiveSlots({ + baseSlots: { commands: [{ id: "static" }] }, + factories: [factory], + filter: undefined, + stores: {}, + services: {}, + reactiveServices: { gates }, + }) as ComputedRef<{ commands: { id: string }[] }>; + + expect(slots.value.commands).toEqual([{ id: "static" }]); + + // Flip the reactive source the snapshot reads — the computed recomputes. + isAdmin.value = true; + expect(slots.value.commands).toEqual([{ id: "static" }, { id: "admin" }]); + }); +}); + +describe("useReactiveSlots", () => { + it("throws outside a modular app", () => { + expect(() => renderComposable(() => useReactiveSlots())).toThrow(/useReactiveSlots/); + }); + + it("returns the single shared reactive source the runtime provides", () => { + const isAdmin = ref(false); + const gates = { + get isAdmin() { + return isAdmin.value; }, + }; + const factory = (deps: any) => + deps.gates?.isAdmin ? { commands: [{ id: "admin" }] } : { commands: [] }; + // The runtime resolves the source once and provides it; the composable is a + // thin reader over that one `computed`. + const shared = resolveReactiveSlots({ + baseSlots: { commands: [{ id: "static" }] }, + factories: [factory], + filter: undefined, + stores: {}, + services: { gates }, + reactiveServices: {}, }); - expect(result().value.commands).toEqual([{ id: "static" }]); + const a = renderComposable(() => useReactiveSlots<{ commands: { id: string }[] }>(), { + provide: { [reactiveSlotsKey as symbol]: shared }, + }); + const b = renderComposable(() => useReactiveSlots<{ commands: { id: string }[] }>(), { + provide: { [reactiveSlotsKey as symbol]: shared }, + }); - // Flip the reactive source the snapshot reads — the computed recomputes. + // Every consumer injects the *same* computed instance — no per-consumer rebuild. + expect(a.result()).toBe(shared); + expect(b.result()).toBe(shared); + expect(a.result().value.commands).toEqual([{ id: "static" }]); + + // One reactive change updates the shared source both consumers read. isAdmin.value = true; - expect(result().value.commands).toEqual([{ id: "static" }, { id: "admin" }]); + expect(a.result().value.commands).toEqual([{ id: "static" }, { id: "admin" }]); + expect(b.result().value.commands).toEqual([{ id: "static" }, { id: "admin" }]); }); }); diff --git a/packages/vue/src/slots-context.ts b/packages/vue/src/slots-context.ts index 9d3c5229..6d000050 100644 --- a/packages/vue/src/slots-context.ts +++ b/packages/vue/src/slots-context.ts @@ -18,7 +18,6 @@ import type { Store, } from "@modular-frontend/core"; import { buildDepsSnapshot, evaluateDynamicSlots } from "@modular-frontend/core"; -import { sharedDependenciesKey } from "./context.js"; /** * Injection key holding the resolved slot contributions. Always a `Ref` so @@ -32,25 +31,61 @@ const noop = () => {}; export const recalculateSlotsKey: InjectionKey<() => void> = Symbol("modular-vue.recalculateSlots"); /** - * The pieces {@link useReactiveSlots} needs to re-evaluate the slot manifest on - * its own: the static base slots, the collected `dynamicSlots` factories, and - * the optional global `slotFilter`. The runtime provides this alongside the - * resolved `slotsKey` ref so a component can opt into Vue-reactive evaluation - * (evaluate inside a `computed`) instead of the imperative signal path. + * Injection key holding the single resolved reactive-slots source — one shared + * `computed` the runtime builds once at install time and every + * {@link useReactiveSlots} consumer reads. The reactive analog of {@link slotsKey} + * (which holds the one shared `Ref` the signal path resolves once): both paths + * are "runtime resolves once → provide → composable injects", differing only in + * evaluation mode (tracked `computed` here, imperatively-updated `Ref` there). */ -export interface ReactiveSlotsConfig { +export const reactiveSlotsKey: InjectionKey> = Symbol( + "modular-vue.reactiveSlots", +); + +/** + * Everything {@link resolveReactiveSlots} needs to evaluate the slot manifest: + * the static base slots, the collected `dynamicSlots` factories, the optional + * global `slotFilter`, and the three shared-dependency buckets the snapshot is + * rebuilt from. The runtime assembles this once and hands it to + * {@link resolveReactiveSlots}. + */ +export interface ReactiveSlotsInput { baseSlots: object; factories: readonly DynamicSlotFactory[]; filter?: SlotFilter; + stores: Record>; + services: Record; + reactiveServices: Record>; } /** - * Injection key holding the {@link ReactiveSlotsConfig}. Provided by the runtime - * at install time; consumed only by {@link useReactiveSlots}. + * Build the single resolved reactive-slots source: one `computed` that rebuilds + * the deps snapshot and re-evaluates the factories/filter **inside its getter**, + * so any reactive source read *live* during evaluation (a reactive service + * object, a `ref`/`reactive` closed over by a factory, a store whose + * `getState()` returns a reactive proxy) becomes a tracked dependency of the + * computed. Vue then recomputes lazily on next read after a relevant change, + * tracking exactly the state actually read — no `recalculateSlots()` call. + * + * The runtime calls this **once** at install time and provides the result via + * {@link reactiveSlotsKey}; {@link useReactiveSlots} is a thin reader over it, so + * evaluation happens at most once per change regardless of how many components + * read it. Create it inside an `effectScope` (plugin install) or a component + * `setup` (framework-mode) so the computed's effect is disposed with the app. */ -export const reactiveSlotsConfigKey: InjectionKey = Symbol( - "modular-vue.reactiveSlotsConfig", -); +export function resolveReactiveSlots(input: ReactiveSlotsInput): ComputedRef { + return computed(() => { + // Rebuild the snapshot INSIDE the computed so reactive reads performed by the + // factories / filter (through reactive service objects or reactive stores) + // are tracked as dependencies of this computed. + const snapshot = buildDepsSnapshot>({ + stores: input.stores, + services: input.services, + reactiveServices: input.reactiveServices, + }); + return evaluateDynamicSlots(input.baseSlots as any, input.factories, snapshot, input.filter); + }); +} /** * Provide a static set of slot contributions. Accepts a plain object or an @@ -123,11 +158,17 @@ export function useSlots< * * @remarks * `dynamicSlots(deps)` factories themselves stay framework-neutral — they receive - * a plain deps snapshot either way. Reactivity is the host's concern here: this - * composable rebuilds the snapshot inside the tracked `computed` on every - * recompute, so a factory/filter that reads a reactive dep tracks it. A factory - * that only reads non-reactive deps simply never triggers a recompute (same - * result the signal path would give without a `recalculateSlots()` call). + * a plain deps snapshot either way. Reactivity is the host's concern here: the + * runtime resolves one shared `computed` ({@link resolveReactiveSlots}) that + * rebuilds the snapshot inside its getter, so a factory/filter that reads a + * reactive dep tracks it. A factory that only reads non-reactive deps simply + * never triggers a recompute (same result the signal path would give without a + * `recalculateSlots()` call). + * + * This composable is a thin reader over that single shared source — every + * consumer injects the *same* `computed`, so evaluation happens at most once per + * change no matter how many components read it (the reactive analog of + * {@link useSlots} reading the one shared signal `Ref`). * * @example * // Host-owned RBAC gating, expressed as a reactive slotFilter reading a @@ -138,30 +179,14 @@ export function useSlots< export function useReactiveSlots< TSlots extends { [K in keyof TSlots]: readonly unknown[] }, >(): ComputedRef { - const config = inject(reactiveSlotsConfigKey, null); - const deps = inject(sharedDependenciesKey, null); - if (!config || !deps) { + const slots = inject(reactiveSlotsKey, null); + if (!slots) { throw new Error( "[@modular-vue/vue] useReactiveSlots must be used within a modular app " + - "(install the resolved manifest so the reactive-slots config is provided).", + "(install the resolved manifest so the reactive-slots source is provided).", ); } - return computed(() => { - // Rebuild the snapshot INSIDE the computed so reactive reads performed by the - // factories / filter (through reactive service objects or reactive stores) - // are tracked as dependencies of this computed. - const snapshot = buildDepsSnapshot>({ - stores: deps.stores, - services: deps.services, - reactiveServices: deps.reactiveServices, - }); - return evaluateDynamicSlots( - config.baseSlots as TSlots, - config.factories, - snapshot, - config.filter, - ); - }); + return slots as ComputedRef; } /**