From a519d3823308367376d3f127d39c59865bd57b3e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 17:19:04 +0000 Subject: [PATCH 1/3] feat(vue): add @modular-vue/compositions provider, composables, plugin (PR-33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New packages/vue-compositions (@modular-vue/compositions, 0.1.0): the Vue 3 compositions binding, part 1 of Phase 3's compositions work. Ports the React provider.tsx, hooks.ts, and plugin.tsx over @modular-frontend/compositions-engine; the composition outlet stays with PR-34. - provider.ts: CompositionsProvider / useCompositionsContext / compositionsKey. Provides { runtime } by identity at setup (un-proxied, so identity checks against manifest.extensions.compositions hold), which also gives the React binding's memo-on-runtime fanout guarantee for free. Unlike JourneyProvider it does not compose over ModuleExitProvider. - hooks.ts: panel-side composables reading a per-mount compositionInstanceKey the outlet (PR-34) installs — useCompositionState (reactive ShallowRef), useCompositionDispatch, useCompositionEmit, useCompositionZone, the pre-typed createCompositionContext bundle, and CompositionContextValue. - use-composition.ts: host-side useComposition + useCompositionOptions, with the Symbol.for options brand ported verbatim. Mints once in setup and registers a no-op subscription torn down on scope dispose, so a held id disposes via the runtime's refcount gate without an outlet. - plugin.ts: compositionsPlugin() with extend/validate/onResolve and a providers() contributing a Vue CompositionsProvider; resolved-twice + register-after-resolve guards match the React source. Framework-forced deviations: useCompositionState returns a ShallowRef (reactive- source convention, PR-10/PR-23) instead of the selected value, and drops React's getSnapshot-cache since Vue's setup runs once and the store push is event-driven. Tests: 30 across provider/hooks/use-composition/plugin suites + a hooks.test-d.ts, porting the use-composition.test.tsx and selector-dispatch.test.tsx intent (outlet- dependent and engine-only cases deferred/already covered). Full workspace typecheck (124 tasks) and vite build (JS + dts) pass. Tracker updated: PR-33 marked done. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QCgNTenPyTRTU6S8DgovNB --- docs/vue-support-tracker.md | 22 +- packages/vue-compositions/package.json | 53 ++++ packages/vue-compositions/src/hooks.test-d.ts | 62 +++++ packages/vue-compositions/src/hooks.test.ts | 261 ++++++++++++++++++ packages/vue-compositions/src/hooks.ts | 209 ++++++++++++++ packages/vue-compositions/src/index.ts | 90 ++++++ packages/vue-compositions/src/plugin.test.ts | 120 ++++++++ packages/vue-compositions/src/plugin.ts | 151 ++++++++++ .../vue-compositions/src/provider.test.ts | 88 ++++++ packages/vue-compositions/src/provider.ts | 62 +++++ .../src/use-composition.test.ts | 128 +++++++++ .../vue-compositions/src/use-composition.ts | 134 +++++++++ packages/vue-compositions/tsconfig.json | 9 + packages/vue-compositions/vite.config.ts | 24 ++ packages/vue-compositions/vitest.config.ts | 18 ++ pnpm-lock.yaml | 57 ++-- 16 files changed, 1468 insertions(+), 20 deletions(-) create mode 100644 packages/vue-compositions/package.json create mode 100644 packages/vue-compositions/src/hooks.test-d.ts create mode 100644 packages/vue-compositions/src/hooks.test.ts create mode 100644 packages/vue-compositions/src/hooks.ts create mode 100644 packages/vue-compositions/src/index.ts create mode 100644 packages/vue-compositions/src/plugin.test.ts create mode 100644 packages/vue-compositions/src/plugin.ts create mode 100644 packages/vue-compositions/src/provider.test.ts create mode 100644 packages/vue-compositions/src/provider.ts create mode 100644 packages/vue-compositions/src/use-composition.test.ts create mode 100644 packages/vue-compositions/src/use-composition.ts create mode 100644 packages/vue-compositions/tsconfig.json create mode 100644 packages/vue-compositions/vite.config.ts create mode 100644 packages/vue-compositions/vitest.config.ts diff --git a/docs/vue-support-tracker.md b/docs/vue-support-tracker.md index 8af173a9..a9873f18 100644 --- a/docs/vue-support-tracker.md +++ b/docs/vue-support-tracker.md @@ -1,6 +1,6 @@ # Vue support initiative: plan and tracker -Status: **Phase 3 in progress** (Phase 0: PR-01, PR-02, PR-03 landed; Phase 1: PR-10, PR-11, PR-12 landed; Phase 2: PR-20, PR-21, PR-22, PR-23, PR-24 landed; Phase 3: PR-30, PR-31, PR-32 landed). Last updated: 2026-07-16. +Status: **Phase 3 in progress** (Phase 0: PR-01, PR-02, PR-03 landed; Phase 1: PR-10, PR-11, PR-12 landed; Phase 2: PR-20, PR-21, PR-22, PR-23, PR-24 landed; Phase 3: PR-30, PR-31, PR-32, PR-33 landed). Last updated: 2026-07-16. Background and feasibility reasoning: [vue-port-analysis.md](./vue-port-analysis.md). This document is the single source of truth for the multi-PR effort to bring the framework to Vue 3, including full Journeys and Compositions support. Update the status board and per-PR checkboxes as PRs land; record decision outcomes in the Decisions section. @@ -269,8 +269,22 @@ Deviations from the plan / React source, all forced by the framework: Acceptance: met. The branching multi-module journey scenario passes as an integration test through both resolve paths. Package totals: `@modular-vue/runtime` 124 tests (110 + 11 `registry-journeys` + 3 integration), `@modular-vue/testing` 20 (18 + 2 `render-journey`). Full workspace typecheck (123 tasks) and `vite build` (JS + dts) pass; the runtime source takes no journeys dependency (dev-only, tests) and the testing build externalizes `@modular-vue/journeys`. -**PR-33 (M): `@modular-vue/compositions` part 1: provider, composables, store glue.** -Analogs of `provider.tsx`, `hooks.ts`, `use-composition`, `plugin.tsx` over the compositions engine. Port `use-composition.test.tsx`, `selector-dispatch.test.tsx` intent. +**PR-33 (M): `@modular-vue/compositions` part 1: provider, composables, store glue.** Done. +New `packages/vue-compositions` (`@modular-vue/compositions`, `0.1.0`) with the repo's standard skeleton (`vite build` + `rolldown-plugin-dts`, `vitest` + `happy-dom` + `@vue/test-utils`, `tsc --noEmit`). Depends on `@modular-frontend/compositions-engine`, with `@modular-frontend/core`, `@modular-vue/vue`, and a `vue ^3.5` peer. Ports `provider.tsx`, `hooks.ts`, and `plugin.tsx`, and re-exports the engine authoring surface (mirroring the React `@modular-react/compositions` index). The composition outlet stays with PR-34. + +- `provider.ts` — `CompositionsProvider` (`defineComponent` + render fn per D4), `useCompositionsContext`, `compositionsKey`. Provides `{ runtime }` by identity at setup — like the modules / navigation / journey contexts, the runtime is resolved once from the manifest and does not swap on the same mount, and is left un-proxied so identity checks against `manifest.extensions.compositions` hold. This also gives the React binding's memo-on-`runtime` fanout guarantee for free (the value object is captured once). Unlike ``, it does not compose over `` — composition panels emit via `useCompositionEmit`, not the global module-exit dispatcher (parity with the React provider's explicit note). +- `hooks.ts` — the panel-side composables read from a per-mount `compositionInstanceKey` injection the outlet (PR-34) installs above each zone panel: `useCompositionState` (reactive), `useCompositionDispatch`, `useCompositionEmit`, `useCompositionZone`, plus the pre-typed `createCompositionContext` bundle and the `CompositionContextValue` interface. `useRequiredContext` throws `[@modular-vue/compositions] … inside a zone panel` when used outside one. +- `use-composition.ts` — the host-side `useComposition` (mint an instance for the calling component) + `useCompositionOptions` + `UseCompositionOptions`, ported field-for-field including the `Symbol.for` options brand that disambiguates `options` from an `input` of shape `{ runtime: … }`. +- `plugin.ts` — `compositionsPlugin()` (real plugin object) field-for-field with the React `plugin.tsx`: `extend` (`registerComposition`, structural validation + resolved-guard), `validate` (contract validation), `onResolve` (produces the `CompositionRuntime`, resolved-twice guard), and `providers()` returning a Vue `` bound component instead of a React one. No `contributeNavigation` (compositions contribute no nav, matching React). + +Deviations from the React source, all forced by the framework: + +- **`useCompositionState` returns a `ShallowRef`, not the selected value.** The React hook returns the value directly (React re-renders on store change); the Vue port returns a `ShallowRef` so it stays reactive in templates / `watch`, matching the PR-10 (`useStore`) / PR-23 (`useZones`) convention. Callers read `.value`. +- **No bespoke selector-result cache.** The React `useCompositionState` caches the selector result keyed on the state reference to dodge React's "getSnapshot should be cached" warning (React invokes `getSnapshot` every render). Vue's `setup` runs once and the store push is event-driven, so a small `shallowRef` + `store.subscribe` bridge (the same shape as `@modular-vue/vue`'s internal `store-ref.ts`, reimplemented locally as journeys' `instance-hooks.ts` did rather than exporting it from the binding) is the faithful analog: `Object.is` dedupe gives selector equality, and a fresh-object selection simply re-publishes when state actually changes. The React "derived-object doesn't tear" test becomes a "fresh-object selection updates on state change" test. +- **`useComposition` mints once in `setup` with an `onScopeDispose` unsubscribe** instead of React's `useRef` lazy-init + `useEffect`. Vue's single `setup` invocation removes the StrictMode double-invoke hazard the React `useRef` dance defends against; the no-op subscription still participates in the runtime's disposal gate, so an id held without an outlet disposes on unmount (the disposal test mounts `useComposition` with no outlet and asserts teardown after the microtask gate). +- **`selector-dispatch.test.tsx` intent** is ported against a manually-provided `compositionInstanceKey` context (the outlet lands in PR-34): a panel-invoked `useCompositionDispatch` callback updates state that `useCompositionState` reflects, and `useCompositionDispatch` returns an identity-stable reference across accesses. **`use-composition.test.tsx` intent** (the binding-level parts) is ported as `use-composition.test.ts` + `plugin.test.ts`; the engine-only cases (disposed/unknown no-ops, adapter-overwrite warning, direct-construction contract validation, journey-zone cache rollover, hashInput stability, module-entry selectionKey remount) already live in the engine suite and outlet-dependent cases stay with PR-34. + +Error-message prefixes are `[@modular-vue/compositions]`. Acceptance: met. 30 tests across `provider.test.ts` (3: runtime exposure through context, null without a provider, value identity stable across re-renders), `hooks.test.ts` (9: reactive state + dispatch round-trip, selector equality short-circuit, full-state form, fresh-object selection, dispatch identity stability, emit routing, zone identity, outside-panel throw, typed bundle), `use-composition.test.ts` (5: mint-once, disposal on unmount, no re-mint across re-renders, runtime-from-options, no-runtime throw), `plugin.test.ts` (7: name, definition validation, contract validate pass/fail, `onResolve` runtime, resolved-twice guard, register-after-resolve guard, provider runtime injection), and `hooks.test-d.ts` (5: `ShallowRef` return types + typed-bundle inference). Full workspace typecheck (124 tasks) and `vite build` (JS + dts) pass; externals (`vue`, `@modular-frontend/core`, `@modular-frontend/compositions-engine`, `@modular-vue/vue`) stay unbundled. **PR-34 (L): `@modular-vue/compositions` part 2: outlet.** Analog of the 1,070-LOC `outlet.tsx`. Port `outlet.test.tsx`, `outlet.behaviors.test.tsx`, `outlet.advanced-behaviors.test.tsx`, `mount-kinds-runtime.test.tsx`, and the runtime lifecycle rendering suites. @@ -359,7 +373,7 @@ Update the Status column as PRs move: `todo` → `in progress` → `in review` | PR-30 | vue journeys: provider and composables | M | PR-02, PR-10 | done (#69) | | PR-31 | vue journeys: outlet | L | PR-30 | done | | PR-32 | journeys wired into runtime + renderJourney | M | PR-22, PR-31 | done | -| PR-33 | vue compositions: provider and composables | M | PR-03, PR-10 | todo | +| PR-33 | vue compositions: provider and composables | M | PR-03, PR-10 | done | | PR-34 | vue compositions: outlet | L | PR-33 | todo | | PR-40 | examples/vue-router | L | PR-23, PR-32, PR-34 | todo | | PR-41 | Documentation | M | PR-40 | todo | diff --git a/packages/vue-compositions/package.json b/packages/vue-compositions/package.json new file mode 100644 index 00000000..ac91298d --- /dev/null +++ b/packages/vue-compositions/package.json @@ -0,0 +1,53 @@ +{ + "name": "@modular-vue/compositions", + "version": "0.1.0", + "description": "Vue 3 compositions for @modular-vue: CompositionsProvider, panel composables (useCompositionState / useCompositionDispatch / useCompositionEmit), the host useComposition hook, and the compositions registry plugin over @modular-frontend/compositions-engine.", + "repository": { + "type": "git", + "url": "git://github.com/kibertoad/modular-react.git", + "directory": "packages/vue-compositions" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "test": "vitest run", + "prepublishOnly": "pnpm build", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modular-frontend/compositions-engine": "workspace:*" + }, + "devDependencies": { + "@modular-frontend/core": "workspace:*", + "@modular-vue/vue": "workspace:*", + "@vue/test-utils": "^2.4.6", + "happy-dom": "^20.9.0", + "oxfmt": "^0.51.0", + "oxlint": "^1.66.0", + "rolldown-plugin-dts": "^0.26.0", + "typescript": "^6.0.3", + "vite": "^8.1.3", + "vitest": "^4.1.10", + "vue": "^3.5.13" + }, + "peerDependencies": { + "@modular-frontend/core": "^0.1.0", + "@modular-vue/vue": "^0.1.0", + "vue": "^3.5.0" + } +} diff --git a/packages/vue-compositions/src/hooks.test-d.ts b/packages/vue-compositions/src/hooks.test-d.ts new file mode 100644 index 00000000..7d5714ce --- /dev/null +++ b/packages/vue-compositions/src/hooks.test-d.ts @@ -0,0 +1,62 @@ +import { describe, expectTypeOf, it } from "vitest"; +import type { ShallowRef } from "vue"; +import type { CompositionZoneEvent } from "@modular-frontend/compositions-engine"; + +import { + createCompositionContext, + useCompositionDispatch, + useCompositionEmit, + useCompositionState, + useCompositionZone, +} from "./hooks.js"; + +interface EditorState { + readonly documentId: string; + readonly dirty: boolean; +} + +// These assertions only need to type-check — they are never invoked (calling a +// composable outside setup would throw at runtime). The `.test-d.ts` include +// glob picks them up under `vitest --typecheck`. +describe("panel composable return types", () => { + it("useCompositionState returns a ShallowRef of the full state or the selected slice", () => { + const full = (): ShallowRef => useCompositionState(); + expectTypeOf(full).returns.toEqualTypeOf>(); + + const slice = (): ShallowRef => + useCompositionState((s) => s.documentId); + expectTypeOf(slice).returns.toEqualTypeOf>(); + }); + + it("useCompositionDispatch is caller-asserted over TState", () => { + const dispatch = useCompositionDispatch; + expectTypeOf(dispatch).returns.toEqualTypeOf< + ( + updater: Partial | ((prev: EditorState) => Partial | EditorState), + ) => void + >(); + }); + + it("useCompositionEmit accepts a CompositionZoneEvent", () => { + expectTypeOf(useCompositionEmit).returns.toEqualTypeOf<(event: CompositionZoneEvent) => void>(); + }); + + it("useCompositionZone exposes the composition/instance/zone identity", () => { + expectTypeOf(useCompositionZone).returns.toEqualTypeOf<{ + readonly compositionId: string; + readonly instanceId: string; + readonly zone: string; + }>(); + }); + + it("createCompositionContext yields a pre-typed bundle whose useState returns refs", () => { + const { useState, useDispatch } = createCompositionContext(); + expectTypeOf(useState()).toEqualTypeOf>(); + expectTypeOf(useState((s) => s.dirty)).toEqualTypeOf>(); + expectTypeOf(useDispatch()).toEqualTypeOf< + ( + updater: Partial | ((prev: EditorState) => Partial | EditorState), + ) => void + >(); + }); +}); diff --git a/packages/vue-compositions/src/hooks.test.ts b/packages/vue-compositions/src/hooks.test.ts new file mode 100644 index 00000000..d1a5af12 --- /dev/null +++ b/packages/vue-compositions/src/hooks.test.ts @@ -0,0 +1,261 @@ +import { defineComponent, h, provide, type ShallowRef } from "vue"; +import { mount } from "@vue/test-utils"; +import { describe, expect, it, vi } from "vitest"; +import { createStore } from "@modular-frontend/core"; +import type { + CompositionRuntime, + CompositionZoneEvent, +} from "@modular-frontend/compositions-engine"; + +import { + compositionInstanceKey, + createCompositionContext, + useCompositionDispatch, + useCompositionEmit, + useCompositionState, + useCompositionZone, +} from "./hooks.js"; +import type { CompositionContextValue } from "./hooks.js"; + +interface CounterState { + readonly count: number; + readonly label: string; +} + +/** + * Fabricate the per-mount context the outlet (PR-34) installs above a zone + * panel, backed by a real core store. `dispatch` shallow-merges through the + * store (as the runtime's dispatch does), so a panel-invoked callback flows + * back into the `useCompositionState` ref. `runtime` is only carried for + * identity — the panel composables don't touch it. + */ +function makeContext(initial: CounterState) { + const store = createStore(initial); + const emit = vi.fn<(event: CompositionZoneEvent) => void>(); + const dispatch = ( + updater: Partial | ((prev: CounterState) => Partial | CounterState), + ): void => { + store.setState(updater as never); + }; + const ctx: CompositionContextValue = { + runtime: {} as CompositionRuntime, + compositionId: "counter-composition", + instanceId: "ci_test" as never, + zone: "main", + store, + dispatch: dispatch as never, + emit, + }; + return { ctx, store, emit }; +} + +/** Mount `Panel` under a component that provides `ctx` on `compositionInstanceKey`. */ +function mountPanel(ctx: CompositionContextValue, Panel: ReturnType) { + const Host = defineComponent({ + setup() { + provide(compositionInstanceKey, ctx); + return () => h(Panel); + }, + }); + return mount(Host); +} + +describe("useCompositionState", () => { + it("reads the scoped state reactively and re-publishes on dispatch", async () => { + const { ctx } = makeContext({ count: 0, label: "a" }); + let count!: ShallowRef; + const Panel = defineComponent({ + setup() { + count = useCompositionState((s) => s.count); + const dispatch = useCompositionDispatch(); + return () => + h("button", { onClick: () => dispatch((prev) => ({ count: prev.count + 1 })) }, "+"); + }, + }); + const wrapper = mountPanel(ctx, Panel); + + expect(count.value).toBe(0); + await wrapper.get("button").trigger("click"); + expect(count.value).toBe(1); + await wrapper.get("button").trigger("click"); + await wrapper.get("button").trigger("click"); + expect(count.value).toBe(3); + }); + + it("selector equality short-circuits an unrelated state update", async () => { + const { ctx, store } = makeContext({ count: 0, label: "a" }); + const observed: number[] = []; + let count!: ShallowRef; + const Panel = defineComponent({ + setup() { + count = useCompositionState((s) => s.count); + return () => { + observed.push(count.value); + return h("span", count.value); + }; + }, + }); + mountPanel(ctx, Panel); + observed.length = 0; + + // Change only `label` — the `count` selection is unchanged, so the + // `shallowRef` dedupes and the panel does not re-render. + store.setState({ label: "b" }); + await Promise.resolve(); + expect(observed).toEqual([]); + expect(count.value).toBe(0); + + // Change `count` — the selection updates and the panel re-renders. + store.setState({ count: 5 }); + await Promise.resolve(); + expect(count.value).toBe(5); + }); + + it("full-state form (no selector) tracks the whole state object", async () => { + const { ctx, store } = makeContext({ count: 1, label: "x" }); + let state!: ShallowRef; + const Panel = defineComponent({ + setup() { + state = useCompositionState(); + return () => null; + }, + }); + mountPanel(ctx, Panel); + + expect(state.value).toEqual({ count: 1, label: "x" }); + store.setState({ count: 2 }); + await Promise.resolve(); + expect(state.value).toEqual({ count: 2, label: "x" }); + }); + + it("does not tear when the selector returns a fresh object each call", async () => { + // The React binding needs bespoke state-keyed caching to keep this from + // tripping the "getSnapshot should be cached" warning (React calls the + // selector on every render). Vue's setup runs once and the store push is + // event-driven, so a fresh-object selection simply re-publishes when the + // underlying state actually changes — no warning path exists. + const { ctx, store } = makeContext({ count: 1, label: "y" }); + let slice!: ShallowRef<{ readonly sum: number }>; + const Panel = defineComponent({ + setup() { + slice = useCompositionState( + (s) => ({ sum: (s as { a?: number }).a ?? s.count }), + ); + return () => null; + }, + }); + mountPanel(ctx, Panel); + + expect(slice.value).toEqual({ sum: 1 }); + store.setState({ count: 10 }); + await Promise.resolve(); + expect(slice.value).toEqual({ sum: 10 }); + }); +}); + +describe("useCompositionDispatch", () => { + it("returns a stable dispatch reference across accesses in a single mount", () => { + const { ctx } = makeContext({ count: 0, label: "a" }); + const sightings: Array<(u: unknown) => void> = []; + const Panel = defineComponent({ + setup() { + sightings.push(useCompositionDispatch() as (u: unknown) => void); + sightings.push(useCompositionDispatch() as (u: unknown) => void); + return () => null; + }, + }); + mountPanel(ctx, Panel); + + expect(sightings.length).toBe(2); + expect(sightings[0]).toBe(sightings[1]); + expect(sightings[0]).toBe(ctx.dispatch); + }); +}); + +describe("useCompositionEmit", () => { + it("routes zone events to the context emit callback", async () => { + const { ctx, emit } = makeContext({ count: 0, label: "a" }); + const Panel = defineComponent({ + setup() { + const fire = useCompositionEmit(); + return () => h("button", { onClick: () => fire({ kind: "open-modal", payload: 1 }) }, "go"); + }, + }); + const wrapper = mountPanel(ctx, Panel); + + await wrapper.get("button").trigger("click"); + expect(emit).toHaveBeenCalledWith({ kind: "open-modal", payload: 1 }); + }); +}); + +describe("useCompositionZone", () => { + it("exposes compositionId, instanceId, and the active zone", () => { + const { ctx } = makeContext({ count: 0, label: "a" }); + let zone: ReturnType | undefined; + const Panel = defineComponent({ + setup() { + zone = useCompositionZone(); + return () => null; + }, + }); + mountPanel(ctx, Panel); + + expect(zone).toEqual({ + compositionId: "counter-composition", + instanceId: "ci_test", + zone: "main", + }); + }); +}); + +describe("panel composables require a zone context", () => { + it("throw a clear error when used outside a zone panel", () => { + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const Panel = defineComponent({ + setup() { + useCompositionState(); + return () => null; + }, + }); + try { + expect(() => mount(Panel)).toThrow(/inside a zone panel/); + } finally { + consoleWarn.mockRestore(); + } + }); +}); + +describe("createCompositionContext", () => { + it("builds a pre-typed bundle that threads through the same context", async () => { + const { ctx } = makeContext({ count: 2, label: "z" }); + const { useState, useDispatch, useEmit, useZone } = createCompositionContext(); + let count!: ShallowRef; + let zone: ReturnType | undefined; + const Panel = defineComponent({ + setup() { + count = useState((s) => s.count); + zone = useZone(); + const dispatch = useDispatch(); + const fire = useEmit(); + return () => + h( + "button", + { + onClick: () => { + dispatch({ count: 9 }); + fire({ kind: "ping" }); + }, + }, + "x", + ); + }, + }); + const wrapper = mountPanel(ctx, Panel); + + expect(count.value).toBe(2); + expect(zone!.zone).toBe("main"); + await wrapper.get("button").trigger("click"); + expect(count.value).toBe(9); + expect(ctx.emit as ReturnType).toHaveBeenCalledWith({ kind: "ping" }); + }); +}); diff --git a/packages/vue-compositions/src/hooks.ts b/packages/vue-compositions/src/hooks.ts new file mode 100644 index 00000000..36ebb7d3 --- /dev/null +++ b/packages/vue-compositions/src/hooks.ts @@ -0,0 +1,209 @@ +import { inject, onScopeDispose, shallowRef, type InjectionKey, type ShallowRef } from "vue"; +import type { Store } from "@modular-frontend/core"; +import type { + CompositionInstanceId, + CompositionRuntime, + CompositionZoneEvent, +} from "@modular-frontend/compositions-engine"; + +/** + * Per-mount context value the `` (PR-34) installs above each + * zone's panel. Foreign panel components — components living in integration + * modules that know nothing about the composition — read the active + * composition state through this context, never via a global hook. Multiple + * compositions can mount concurrently; the context binds each panel to its host + * instance unambiguously. The Vue analog of the React `CompositionContextValue`. + */ +export interface CompositionContextValue { + readonly runtime: CompositionRuntime; + readonly compositionId: string; + readonly instanceId: CompositionInstanceId; + readonly zone: string; + readonly store: Store; + readonly dispatch: ( + updater: Partial | ((prev: TState) => Partial | TState), + ) => void; + readonly emit: (event: CompositionZoneEvent) => void; +} + +/** + * Injection key holding the active {@link CompositionContextValue}, or `null` + * when a panel composable is used outside a `` zone. The + * outlet (PR-34) provides this per zone panel; exported so tests and advanced + * hosts can provide it directly. The Vue analog of the React + * `CompositionInstanceContext`. + */ +export const compositionInstanceKey: InjectionKey = Symbol( + "modular-vue.composition-instance", +); + +/** + * Internal helper that reads the active per-mount context. Throws with a + * pointed message when called outside a zone panel, matching the React + * `useRequiredContext` guard. + */ +function useRequiredContext(): CompositionContextValue { + const ctx = inject(compositionInstanceKey, null); + if (!ctx) { + throw new Error( + "[@modular-vue/compositions] useCompositionState/Dispatch/Emit/Zone must be called from inside a zone panel.", + ); + } + return ctx; +} + +/** + * Bridge the per-mount composition `Store` into Vue reactivity: seed a + * `shallowRef` with the current (optionally selected) snapshot, push a fresh + * read on every store change, and tear the subscription down on scope dispose + * (panel unmount). The Vue analog of the React hook's `useSyncExternalStore` + * wiring. + * + * The React binding caches the selector result keyed on the state reference so + * a fresh-object-returning selector doesn't trip React's "getSnapshot should be + * cached" warning (React invokes `getSnapshot` on every render). Vue's `setup` + * runs once and the store push is event-driven, not render-driven, so that + * caching is not needed here: `shallowRef`'s `Object.is` dedupe already + * short-circuits an unchanged primitive selection, and a fresh-object selection + * simply re-publishes when the underlying state actually changes. + */ +function subscribeStore(store: Store, selector?: (state: T) => U): ShallowRef { + const read = (): T | U => { + const state = store.getState(); + return selector ? selector(state) : (state as unknown as U); + }; + const state = shallowRef(read()); + const unsubscribe = store.subscribe(() => { + state.value = read(); + }); + onScopeDispose(unsubscribe); + return state; +} + +/** + * Read the composition's scoped state as a reactive `ShallowRef`. Pass a + * selector to only re-publish when the selected slice changes — under the hood + * the `shallowRef` bridge short-circuits on `Object.is` equality between + * snapshots (selector equality for free), the reactive-source convention + * established in PR-10 (`useStore`) and PR-23 (`useZones`). + * + * ```ts + * const docId = useCompositionState((s) => s.documentId); + * // docId is a ShallowRef; read docId.value in templates / watch. + * ``` + * + * The React analog returns the selected value directly (React re-renders on + * store change); the Vue port returns a `ShallowRef` so it stays reactive in + * templates and `watch`. Read `.value` at the call site. + * + * Note: TypeScript can't infer `TState` when this composable is called without + * a selector — explicit `useCompositionState()` is required. For a + * fully-typed API per-composition, use {@link createCompositionContext}. + */ +export function useCompositionState(): ShallowRef; +export function useCompositionState(selector: (state: TState) => U): ShallowRef; +export function useCompositionState( + selector?: (state: TState) => U, +): ShallowRef { + const ctx = useRequiredContext(); + const store = ctx.store as Store; + return subscribeStore(store, selector); +} + +/** + * Imperatively mutate the composition's state. Accepts either a partial object + * (shallow-merged via `Store.setState`) or an updater function. Returns the + * dispatch function directly (identity-stable for the panel mount, like the + * modules / navigation contexts) — not a ref. + * + * **`TState` is caller-asserted.** The composable does not see the active + * composition's declared state shape — it widens to whatever `TState` the + * caller spells. Use {@link createCompositionContext} to get a pre-typed + * `useDispatch` that fixes `TState` once at module scope. + */ +export function useCompositionDispatch(): ( + updater: Partial | ((prev: TState) => Partial | TState), +) => void { + return useRequiredContext().dispatch as ( + updater: Partial | ((prev: TState) => Partial | TState), + ) => void; +} + +/** + * Emit a zone event. Routed to the outlet's `onZoneEvent` prop with the zone + * name attached. Use this for cross-zone hand-offs that can't be expressed + * through state alone (e.g. "open the diff modal"). + */ +export function useCompositionEmit(): (event: CompositionZoneEvent) => void { + return useRequiredContext().emit; +} + +/** + * Read the composition id, instance id, and active zone name of the panel + * currently being rendered. Useful for analytics, scoped logging, and panels + * that branch behavior on which zone they're filling. Returns a plain object — + * the fields are fixed for the panel mount (the outlet keys each panel by + * zone). + */ +export function useCompositionZone(): { + readonly compositionId: string; + readonly instanceId: CompositionInstanceId; + readonly zone: string; +} { + const ctx = useRequiredContext(); + return { + compositionId: ctx.compositionId, + instanceId: ctx.instanceId, + zone: ctx.zone, + }; +} + +/** + * Per-composition typed composable bundle returned by + * {@link createCompositionContext}. + */ +export interface TypedCompositionHooks { + readonly useState: { + (): ShallowRef; + (selector: (state: TState) => U): ShallowRef; + }; + readonly useDispatch: () => ( + updater: Partial | ((prev: TState) => Partial | TState), + ) => void; + readonly useEmit: () => (event: CompositionZoneEvent) => void; + readonly useZone: () => { + readonly compositionId: string; + readonly instanceId: CompositionInstanceId; + readonly zone: string; + }; +} + +/** + * Build a pre-typed bundle of composition composables for a single + * composition's state shape. Composition authors call this once and export the + * result so panels don't have to spell `` at every call site: + * + * ```ts + * // editor-composition/hooks.ts + * export const { useState, useDispatch } = createCompositionContext(); + * + * // some-panel.vue