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
22 changes: 18 additions & 4 deletions docs/vue-support-tracker.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 `<JourneyProvider>`, it does not compose over `<ModuleExitProvider>` — 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 <CompositionOutlet> 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 `<CompositionsProvider>` 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<TState | U>` 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. 32 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` (7: mint-once, disposal on unmount, no re-mint across re-renders, runtime-from-options, no-runtime throw, options-brand disambiguation both ways), `plugin.test.ts` (8: 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.
Expand Down Expand Up @@ -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 (#72) |
| 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 |
Expand Down
53 changes: 53 additions & 0 deletions packages/vue-compositions/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
62 changes: 62 additions & 0 deletions packages/vue-compositions/src/hooks.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<EditorState> => useCompositionState<EditorState>();
expectTypeOf(full).returns.toEqualTypeOf<ShallowRef<EditorState>>();

const slice = (): ShallowRef<string> =>
useCompositionState<EditorState, string>((s) => s.documentId);
expectTypeOf(slice).returns.toEqualTypeOf<ShallowRef<string>>();
});

it("useCompositionDispatch is caller-asserted over TState", () => {
const dispatch = useCompositionDispatch<EditorState>;
expectTypeOf(dispatch).returns.toEqualTypeOf<
(
updater: Partial<EditorState> | ((prev: EditorState) => Partial<EditorState> | 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<EditorState>();
expectTypeOf(useState()).toEqualTypeOf<ShallowRef<EditorState>>();
expectTypeOf(useState((s) => s.dirty)).toEqualTypeOf<ShallowRef<boolean>>();
expectTypeOf(useDispatch()).toEqualTypeOf<
(
updater: Partial<EditorState> | ((prev: EditorState) => Partial<EditorState> | EditorState),
) => void
>();
});
});
Loading
Loading