Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/framework-mode-nuxt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -314,6 +316,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.
Expand Down
162 changes: 162 additions & 0 deletions docs/reactive-slots-vue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# 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.** `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.

### 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 **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:
const access = useWorkspaceAccess(); // reactive computeds
const gates = {
get "board.write"() {
return access.canWriteBoard.value;
},
get "integrations.manage"() {
return access.canManageIntegrations.value;
},
// ...
};

const registry = createRegistry<AppDeps, AppSlots>({
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<AppSlots>();
const navItems = computed(() => slots.value.nav); // updates when a permission flips
```

`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

```ts
function useReactiveSlots<TSlots>(): ComputedRef<TSlots>;
```

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;
`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.
16 changes: 16 additions & 0 deletions docs/vue-support-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,22 @@ 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 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
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.
Expand Down
2 changes: 2 additions & 0 deletions packages/vue-runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ export {
createSlotsSignal,
useNavigation,
useSlots,
useReactiveSlots,
useRecalculateSlots,
reactiveSlotsKey,
useModules,
getModuleMeta,
ModuleErrorBoundary,
Expand Down
40 changes: 40 additions & 0 deletions packages/vue-runtime/src/providers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
defineComponent,
effectScope,
h,
provide,
shallowRef,
Expand All @@ -22,9 +23,12 @@ import {
DynamicSlotsProvider,
modulesKey,
navigationKey,
reactiveSlotsKey,
recalculateSlotsKey,
resolveReactiveSlots,
sharedDependenciesKey,
slotsKey,
type ReactiveSlotsInput,
type SlotsSignal,
} from "@modular-vue/vue";

Expand Down Expand Up @@ -74,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: <T>(key: InjectionKey<T>, value: T) => void,
Expand Down Expand Up @@ -130,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);
}
Expand Down Expand Up @@ -162,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?.();
Expand Down
68 changes: 68 additions & 0 deletions packages/vue-runtime/src/reactive-slots.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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<Slots>();
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<string, boolean>)?.[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");
});
});
19 changes: 18 additions & 1 deletion packages/vue/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading