diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d6bf2c..d2fd6632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Per-package detail lives in the GitHub release tagged `@`. ## Unreleased +### Changed — downstream feedback: types, plugins, and the cancel affordance + +A second consumer feedback round (Vue + Nuxt this time), triaged critically — some items were already solved and only needed verification or docs; the rest are focused, backward-compatible changes. + +- **`@modular-vue/nuxt`** (`installModularApp`) and **`@modular-vue/runtime`** (`createModularApp`) — both no longer erase plugin-extension types. Previously each took `ModuleRegistry<…, any>` and returned `ApplicationManifest`, collapsing `manifest.extensions` to `Record` and `manifest.journeys` to `unknown` — forcing a `manifest.journeys as JourneyRuntime` cast at the Nuxt binding. They now infer the manifest's extension map from the registry's `resolve()` return (the registry's plugin _tuple_ can't be recovered by inference — it hides behind `PluginRuntimesOf` — but the resolved extensions ride plainly on `ApplicationManifest`'s third type argument), so a registry carrying `journeysPlugin()` yields `manifest.journeys: JourneyRuntime` with no cast. Existing plugin-less callers are unaffected. (The direct `registry.resolve()` path already preserved this; only the two wrapper helpers erased it.) +- **`@modular-frontend/journeys-engine`**, **`@modular-frontend/core`** — new `JourneyRuntime.discard(id, reason?)`. Cancel-and-discard in one call: `end(id, reason, { force: true })` + `forget(id)`, addressable by `instanceId` so a "Cancel" button never re-derives `persistence.keyFor(input)` to call `persistence.remove` by hand. Ending is what deletes persistence — any terminal transition removes the blob — so `discard` names the "throw the flow away" intent, the deliberate counterpart to a _soft_ close that keeps the blob for resume by not ending the instance at all. Runs `onAbandon` (telemetry preserved) and forces the outcome terminal so a non-terminal `onAbandon` can't strand the instance. No-op on unknown / already-terminal ids. +- **`@modular-vue/journeys`** (``) — the default scoped slot's `outlet` is now a **functional component** instead of a raw `VNode`, so `` (which the docs recommended, but which never rendered a raw VNode) works in a template; its identity is stable across host re-renders, so `:is` patches the outlet in place rather than remounting it on every step. The slot also now hands out `runtime`, so `` is a fully-correct idiomatic-Vue alternative that resolves the same runtime the host pinned. `JourneyHostSlotProps.outlet` changes type from `VNode` to `FunctionalComponent` — a render-function consumer that embedded the VNode directly now calls `h(outlet)` (or uses ``). +- **Verified, no code change — inline `buildInput` detection.** Building on #83's `defineModule` literal preservation (the `TDescriptor` generic) plus `defineEntry`'s existing buildInput-present overloads, an entry authored as an **inline object literal inside `defineModule`** (no `defineEntry` wrapper) now has its `buildInput` presence flow into `typeof mod`, so `StepSpec`'s `input` goes optional exactly as with the wrapped form. This was the specific end-to-end behavior a consumer flagged to re-check; it holds, and is now locked in with regression tests in `step-spec.test-d.ts`. +- **Docs & example** — `docs/journeys-vue.md` gains the close-vs-cancel contract with the `runtime.discard(id)` recipe and documents the functional-component `outlet` plus the `instanceId`/`runtime` alternative; `docs/framework-mode-nuxt.md` and the Nuxt/vue-journeys/React-journeys READMEs cover the extension threading, the `appProvides` auto-wiring (so `provideJourneyRuntime` is unnecessary in the plugin path), and the `defineNuxtPlugin` TS7022 annotation footgun. The `examples/vue/nuxt-modal-journey` app gains a "Cancel (discard progress)" button next to "Close (progress kept)", with an e2e case asserting cancel removes the persisted blob. + ### Added — remote-manifest × locally-registered-component pairing Formalizes the "backend data selects a locally-installed view" join: a remote manifest carries a string **discriminator** id, the component ships as code and registers through the normal module → slot path, and the host pairs the two by id at render time. The helpers are **read-side projections of an already-resolved slot** — pure functions, peers of `mergeRemoteManifests` / `buildSlotsManifest`, not a registration path — so they add no module type and no new ingress, and a Vue `computed` (or a React `useMemo`) re-runs them on reactive change with no library-specific glue. diff --git a/docs/framework-mode-nuxt.md b/docs/framework-mode-nuxt.md index 1dbaa62b..0b667fd2 100644 --- a/docs/framework-mode-nuxt.md +++ b/docs/framework-mode-nuxt.md @@ -146,6 +146,46 @@ export default defineNuxtPlugin((nuxtApp) => { Everything but `router` is forwarded to `registry.resolve()` — `router` comes from `nuxtApp.$router`. +### Plugin extensions survive (and thread themselves app-wide) + +The returned manifest keeps its plugin extensions typed. A registry built with +`createRegistry({}).use(journeysPlugin())` hands back a manifest whose +`extensions.journeys` — and the `manifest.journeys` convenience alias — is typed +as `JourneyRuntime`, **no cast**. (`installModularApp` infers the extension map +from the registry's `resolve()` return rather than the opaque plugin tuple, which +can't be recovered by inference.) + +You also usually don't need to touch that runtime by hand. Because the install +calls `resolve()`, any plugin that implements the Vue `appProvides` hook — the +journeys plugin does — is threaded **app-wide** via `app.provide` during +`nuxtApp.vueApp.use(manifest)`. So `` / `` and +`useJourneyContext()` resolve the runtime from context with **no +`` wrapper and no manual `provideJourneyRuntime`**. (Call +`provideJourneyRuntime` only for the escape-hatch cases it documents — a +hand-built runtime with no plugin, or installing the same runtime on a second +app.) + +### Typing `manifest` under `defineNuxtPlugin` + +Returning `{ provide: { modular: manifest } }` makes Nuxt infer the plugin's +provide types from `manifest`. When the manifest carries a large plugin runtime +(the journeys runtime is large), that structural inference can trip TypeScript's +self-reference guard — **TS7022, "referenced directly or indirectly in its own +initializer."** Annotate the binding to break the cycle; the precise type is +nameable thanks to the extension threading above: + +```ts +import type { ApplicationManifest } from "@modular-vue/nuxt/runtime"; +import type { JourneyRuntime } from "@modular-vue/journeys"; + +export default defineNuxtPlugin((nuxtApp) => { + const registry = buildRegistry(); + const manifest: ApplicationManifest = + installModularApp(nuxtApp, registry); + return { provide: { modular: manifest } }; +}); +``` + ## Routing: runtime `addRoute` and the first paint Module routes are added at runtime, when the plugin runs, via diff --git a/docs/journeys-vue.md b/docs/journeys-vue.md index 35ddc7ed..2dd73349 100644 --- a/docs/journeys-vue.md +++ b/docs/journeys-vue.md @@ -134,6 +134,13 @@ import { envSetupHandle } from "~/journeys/env-setup"; ``` +The slot's `outlet` is a **functional component** (not a raw VNode), so +`` mounts it cleanly and patches — not remounts — it +across steps. If you'd rather spell the outlet yourself, the slot also hands you +`instanceId` and `runtime`, so `` is an equivalent, idiomatic alternative (pass `runtime` +so the outlet resolves the same runtime the host pinned). + Outlet props (`onFinished`, `onStepError`, `errorComponent`, `preload`, `leafOnly`, `retryLimit`, …) pass straight through `` to the inner ``. @@ -232,10 +239,32 @@ persistence: the fresh boot's `start()` rehydrates the blob — provided the backing store is durable (the Pinia adapter is in-memory unless you also persist the store to `localStorage`). -**Cancel semantics.** `runtime.goBack(id)` rewinds a step. To discard, drop the -subscription and let the outlet end the instance (or call `runtime.end(id, …)`) -— the terminal removes the persisted blob. Finishing (a terminal exit) does the -same and fires `onFinished`. +**Close vs. cancel — the load-bearing distinction.** These look identical (both +unmount the outlet) but mean opposite things for the persisted blob: + +- **Soft close** ("resume later"): do **nothing** to the runtime. The keep-alive + subscription holds the instance active, so the blob survives and reopening + resumes. This is the whole point of the keep-alive above. +- **Hard cancel** ("throw it away"): call **`runtime.discard(ui.instanceId)`**. + It force-ends the instance — which removes the persisted blob, as any terminal + transition does — and forgets the record, in one call. No need to re-derive + `persistence.keyFor(input)` and call `persistence.remove` by hand; `discard` + is addressable by `instanceId`. + +```ts +// A "Cancel" button on the modal: drop the flow and its saved progress. +function cancel() { + if (ui.instanceId) ctx!.runtime.discard(ui.instanceId); + ui.isOpen = false; + ui.instanceId = null; +} +``` + +`runtime.goBack(id)` rewinds a single step (not a cancel). Finishing (a terminal +exit) also removes the blob and additionally fires `onFinished`. `discard` runs +the journey's `onAbandon` first (so its telemetry still fires) and forces the +outcome terminal, so a non-terminal `onAbandon` can't leave the instance +dangling. > A complete, runnable version — a real Nuxt app with Pinia persistence and > `appProvides` threading — is in diff --git a/examples/vue/nuxt-modal-journey/README.md b/examples/vue/nuxt-modal-journey/README.md index 2f454182..dbea23cd 100644 --- a/examples/vue/nuxt-modal-journey/README.md +++ b/examples/vue/nuxt-modal-journey/README.md @@ -28,6 +28,12 @@ demonstrates end-to-end: subscription so the outlet's abandon-on-unmount is skipped (`record.listeners.size > 0`). Closing the modal keeps the journey; reopening resumes it. +4. **Close vs. cancel.** Two buttons make the contract concrete. **Close** + (`ui.close()`) is a soft close — it only hides the modal, the keep-alive holds + the instance, and the blob survives for resume. **Cancel** + (`runtime.discard(id)`, in `useWizardControls`) is a hard cancel — it ends the + instance and removes the persisted blob in one call, so reopening starts + fresh. Only a genuine complete (confirm) or a discard removes the blob. ## Layout diff --git a/examples/vue/nuxt-modal-journey/shell/app/components/SetupWizardModal.vue b/examples/vue/nuxt-modal-journey/shell/app/components/SetupWizardModal.vue index 4a777ad9..2ea2d3c3 100644 --- a/examples/vue/nuxt-modal-journey/shell/app/components/SetupWizardModal.vue +++ b/examples/vue/nuxt-modal-journey/shell/app/components/SetupWizardModal.vue @@ -2,9 +2,11 @@ import { storeToRefs } from "pinia"; import { JourneyOutlet } from "@modular-vue/journeys"; import { useUiStore } from "../stores/ui"; +import { useWizardControls } from "../composables/useWizardControls"; const ui = useUiStore(); const { isOpen, instanceId } = storeToRefs(ui); +const { cancel } = useWizardControls(); // Terminal exit (confirm → complete) — clear the instance and close. function onFinished() { @@ -49,6 +51,12 @@ function onFinished() { + + + diff --git a/examples/vue/nuxt-modal-journey/shell/app/composables/useWizardControls.ts b/examples/vue/nuxt-modal-journey/shell/app/composables/useWizardControls.ts index 1dd3fa52..b550e9bc 100644 --- a/examples/vue/nuxt-modal-journey/shell/app/composables/useWizardControls.ts +++ b/examples/vue/nuxt-modal-journey/shell/app/composables/useWizardControls.ts @@ -29,5 +29,18 @@ export function useWizardControls() { ui.isOpen = true; } - return { open }; + /** + * Hard cancel: throw the flow away. `runtime.discard(id)` ends the instance + * (which removes the persisted blob, as any terminal does) and forgets the + * record — no re-deriving `keyFor(input)` and calling `persistence.remove` + * by hand. Contrast `ui.close()`, the *soft* close that keeps the blob for + * resume by leaving the instance alive. + */ + function cancel() { + if (ctx && ui.instanceId) ctx.runtime.discard(ui.instanceId); + ui.isOpen = false; + ui.instanceId = null; + } + + return { open, cancel }; } diff --git a/examples/vue/nuxt-modal-journey/shell/e2e/smoke.spec.ts b/examples/vue/nuxt-modal-journey/shell/e2e/smoke.spec.ts index 767a1d74..424bd2bd 100644 --- a/examples/vue/nuxt-modal-journey/shell/e2e/smoke.spec.ts +++ b/examples/vue/nuxt-modal-journey/shell/e2e/smoke.spec.ts @@ -49,6 +49,27 @@ test("modal journey: appProvides threading, Pinia persistence, in-session + relo await expect(page.getByTestId("step-choose")).toBeVisible(); }); +test("cancel discards the flow and its persisted blob", async ({ page }) => { + await page.goto("/"); + + // Open frame A and advance so a blob is persisted. + await page.getByTestId("open-frame-a").click(); + await page.getByTestId("plan-pro").check(); + await page.getByTestId("wizard-continue").click(); + await expect(page.getByTestId("step-confirm")).toBeVisible(); + await expect(page.getByTestId("persisted-keys")).toHaveText("journey:A:setup-wizard"); + + // Cancel = runtime.discard(id): ends the instance AND removes the blob, unlike + // Close (which keeps it). The persisted key set goes empty. + await page.getByTestId("wizard-cancel").click(); + await expect(page.getByTestId("wizard-modal")).toBeHidden(); + await expect(page.getByTestId("persisted-keys")).toHaveText(""); + + // Reopen → fresh step 1, not a resume: the flow was thrown away. + await page.getByTestId("open-frame-a").click(); + await expect(page.getByTestId("step-choose")).toBeVisible(); +}); + test("a different frame runs an independent instance", async ({ page }) => { await page.goto("/"); diff --git a/packages/frontend-core/src/journey-contracts.ts b/packages/frontend-core/src/journey-contracts.ts index 55c74133..a731cef6 100644 --- a/packages/frontend-core/src/journey-contracts.ts +++ b/packages/frontend-core/src/journey-contracts.ts @@ -979,6 +979,35 @@ export interface JourneyRuntime { * shells from leaking terminal records over time. */ forget(id: InstanceId): void; + /** + * Cancel an instance **and discard its persisted blob** — the "throw the + * flow away" affordance a "Cancel" button wants, addressable by + * `instanceId` so a shell never re-derives `persistence.keyFor(input)` to + * call `persistence.remove` by hand. + * + * Equivalent to `end(id, reason, { force: true })` followed by + * `forget(id)`: the instance is force-terminated (its `onAbandon` still + * runs, and any terminal choice it returns is honoured; a non-terminal one + * is coerced to an abort so teardown is guaranteed), which removes the + * persisted blob — any terminal transition does, persistence tracks only + * *active* instances — and the terminal record is then dropped. + * + * The force-end **cascades to an active child** — the child is + * force-terminated and its blob removed too. `forget`, though, drops only + * *this* instance's record; a cascaded child is left as a (blob-less) + * terminal record for the normal terminal cleanup — `forget(childId)` or + * `forgetTerminal()` — exactly as a plain `end` leaves it. + * + * This is the deliberate counterpart to a **soft close**, which keeps the + * blob for resume by *not* ending the instance at all (e.g. a modal that + * hides its host while a subscription holds the instance alive). Ending is + * what deletes persistence; `discard` names that intent in one call. + * + * No-op for unknown ids. Safe on an already-terminal instance: `end` + * no-ops (the blob was removed when it first went terminal) and the record + * is still forgotten. + */ + discard(id: InstanceId, reason?: unknown): void; /** * Drop every terminal (completed / aborted) instance in one call. Returns * the number of records dropped. Useful hygiene for long-running shells diff --git a/packages/frontend-core/src/step-spec.test-d.ts b/packages/frontend-core/src/step-spec.test-d.ts index b244ab7a..3c144d66 100644 --- a/packages/frontend-core/src/step-spec.test-d.ts +++ b/packages/frontend-core/src/step-spec.test-d.ts @@ -48,11 +48,48 @@ const selfBuilding = defineModule({ }, }); +// The same two entries, but authored as INLINE object literals inside +// `defineModule` — no `defineEntry` wrapper. This is the path a downstream +// consumer flagged as not detecting `buildInput` (so `input` stayed required) +// before #83 taught `defineModule` to preserve the literal `entryPoints` shape. +// With that preservation in place, an inline `buildInput` member survives into +// `typeof mod`, so `EntryDeclaresBuildInput` sees it and `input` goes optional — +// exactly as with the wrapped form. These cases lock that equivalence in. +const inlinePlain = defineModule({ + id: "inline-plain", + version: "1.0.0", + exitPoints: { next: defineExit() } as const, + entryPoints: { + enter: { + component: (() => null) as never, + input: schema<{ readonly seed: string }>(), + }, + }, +}); + +const inlineSelfBuilding = defineModule({ + id: "inline-self-building", + version: "1.0.0", + exitPoints: { next: defineExit() } as const, + entryPoints: { + enter: { + component: (() => null) as never, + input: schema<{ readonly previousName: string }>(), + buildInput: buildInputFor()((state) => ({ previousName: state.draftName })), + }, + }, +}); + type Modules = { readonly plain: typeof plain; readonly "self-building": typeof selfBuilding; }; +type InlineModules = { + readonly "inline-plain": typeof inlinePlain; + readonly "inline-self-building": typeof inlineSelfBuilding; +}; + // ----------------------------------------------------------------------------- // Entry WITHOUT buildInput — `input` stays required. // ----------------------------------------------------------------------------- @@ -123,3 +160,26 @@ test("StepSpec falls back to a loose shape, not never", () => { const loose: StepSpec = { module: "anything", entry: "atall" }; void loose; }); + +// ----------------------------------------------------------------------------- +// INLINE-authored entries (no `defineEntry` wrapper) behave identically — the +// regression the consumer feedback specifically called out to verify. +// ----------------------------------------------------------------------------- + +test("INLINE buildInput entry → StepSpec `input` is optional", () => { + const omitted: StepSpec = { module: "inline-self-building", entry: "enter" }; + void omitted; + // A stamped value is still shape-checked. + const stamped: StepSpec = { + module: "inline-self-building", + entry: "enter", + input: { previousName: "p" }, + }; + void stamped; +}); + +test("INLINE plain entry → StepSpec `input` stays required", () => { + // @ts-expect-error — no `buildInput` on the inline literal, so `input` is required. + const bad: StepSpec = { module: "inline-plain", entry: "enter" }; + void bad; +}); diff --git a/packages/journeys-engine/src/persistence.test.ts b/packages/journeys-engine/src/persistence.test.ts index df142d84..aa31ab1c 100644 --- a/packages/journeys-engine/src/persistence.test.ts +++ b/packages/journeys-engine/src/persistence.test.ts @@ -404,3 +404,133 @@ describe("stock adapters end-to-end with createJourneyRuntime", () => { expect(idB).toBe(idA); }); }); + +// --------------------------------------------------------------------------- +// runtime.discard — the "Cancel = throw the flow away" affordance. Ends the +// instance (which removes the persisted blob, as any terminal transition does) +// and forgets the record, addressable by instanceId so shells never re-derive +// the persistence key by hand. +// --------------------------------------------------------------------------- + +describe("runtime.discard", () => { + const stepModule = defineModule({ + id: "step", + version: "1.0.0", + exitPoints: { done: defineExit() }, + entryPoints: { + view: defineEntry({ + component: (() => null) as any, + input: schema<{ customerId: string }>(), + }), + }, + }); + type Modules = { readonly step: typeof stepModule }; + interface State { + readonly customerId: string; + } + interface Input { + readonly customerId: string; + } + + const journey = defineJourney()({ + id: "cancelable", + version: "1.0.0", + initialState: ({ customerId }: Input) => ({ customerId }), + start: (s) => ({ module: "step", entry: "view", input: { customerId: s.customerId } }), + transitions: { step: { view: { done: () => ({ complete: { ok: true } }) } } }, + }); + + const drain = async () => { + await Promise.resolve(); + await Promise.resolve(); + }; + + it("removes the persisted blob and drops the record", async () => { + const persistence = createMemoryPersistence({ + keyFor: ({ journeyId, input }) => `${journeyId}:${input.customerId}`, + }); + const rt = createJourneyRuntime([{ definition: journey, options: { persistence } }], { + modules: { step: stepModule }, + debug: false, + }); + + const id = rt.start("cancelable", { customerId: "C-1" }); + await drain(); + expect(persistence.size()).toBe(1); + expect(rt.getInstance(id)?.status).toBe("active"); + + rt.discard(id); + await drain(); + + // Blob gone (hard cancel), record gone — no manual keyFor + remove needed. + expect(persistence.size()).toBe(0); + expect(rt.getInstance(id)).toBeNull(); + expect(rt.listInstances()).toHaveLength(0); + }); + + it("does NOT remove the blob when the instance is merely left active (soft close)", async () => { + // The contrast case that makes the contract legible: a soft close keeps the + // instance active (never ends it), so the blob survives for resume. Only + // `discard` (or any other terminal) deletes it. + const persistence = createMemoryPersistence({ + keyFor: ({ journeyId, input }) => `${journeyId}:${input.customerId}`, + }); + const rt = createJourneyRuntime([{ definition: journey, options: { persistence } }], { + modules: { step: stepModule }, + debug: false, + }); + + const id = rt.start("cancelable", { customerId: "C-2" }); + await drain(); + expect(persistence.size()).toBe(1); + + // Soft close = do nothing to the runtime. Blob is still there to resume. + await drain(); + expect(persistence.size()).toBe(1); + expect(rt.getInstance(id)?.status).toBe("active"); + }); + + it("is a no-op for an unknown id", () => { + const rt = createJourneyRuntime([{ definition: journey, options: undefined }], { + modules: { step: stepModule }, + debug: false, + }); + expect(() => rt.discard("does-not-exist")).not.toThrow(); + }); + + it("still fires onAbandon and forces termination when it returns a non-terminal result", async () => { + // A registration-level onAbandon that returns `{ next }` (non-terminal). + // `discard` force-ends, so this cannot leave the instance active (which + // would strand the blob and make forget a no-op) — it is coerced to abort. + const persistence = createMemoryPersistence({ + keyFor: ({ journeyId, input }) => `${journeyId}:${input.customerId}`, + }); + let abandoned = false; + const rt = createJourneyRuntime( + [ + { + definition: journey, + options: { + persistence, + onAbandon: () => { + abandoned = true; + return { next: { module: "step", entry: "view", input: { customerId: "again" } } }; + }, + }, + }, + ], + { modules: { step: stepModule }, debug: false }, + ); + + const id = rt.start("cancelable", { customerId: "C-3" }); + await drain(); + expect(persistence.size()).toBe(1); + + rt.discard(id); + await drain(); + + expect(abandoned).toBe(true); + expect(persistence.size()).toBe(0); + expect(rt.getInstance(id)).toBeNull(); + }); +}); diff --git a/packages/journeys-engine/src/runtime.ts b/packages/journeys-engine/src/runtime.ts index 7d57c3a0..f2f4068a 100644 --- a/packages/journeys-engine/src/runtime.ts +++ b/packages/journeys-engine/src/runtime.ts @@ -2872,6 +2872,19 @@ export function createJourneyRuntime( instances.delete(id); }, + discard(id, reason) { + // Cancel-and-discard in one call: `end` (forced) drives the instance + // terminal, which removes the persisted blob via the standard + // terminal-transition path (`applyTransition` → `removePersisted`), and + // `forget` then drops the now-terminal record. `force` guarantees a + // non-terminal `onAbandon` result can't leave the instance active (which + // would strand the blob and make `forget` a no-op). Both underlying calls + // no-op safely on unknown / already-terminal ids, so `discard` is + // idempotent and needs no extra guarding here. + runtime.end(id, reason ?? { reason: "discarded" }, { force: true }); + runtime.forget(id); + }, + forgetTerminal() { let removed = 0; for (const [id, record] of instances) { diff --git a/packages/journeys/README.md b/packages/journeys/README.md index 91cc147b..323822b9 100644 --- a/packages/journeys/README.md +++ b/packages/journeys/README.md @@ -1695,6 +1695,16 @@ interface JourneyRuntime { /** Drop a terminal instance from memory. No-op on active/loading. */ forget(id: InstanceId): void; + /** + * Cancel an instance AND discard its persisted blob — `end(id, reason, + * { force: true })` + `forget(id)` in one call. The "throw the flow away" + * affordance a Cancel button wants, addressable by id so a shell never + * re-derives `persistence.keyFor(input)` to remove the blob by hand. (Ending + * is what deletes persistence: any terminal transition removes the blob, and + * a *soft* close that keeps it for resume simply does not end the instance.) + */ + discard(id: InstanceId, reason?: unknown): void; + /** Drop every terminal instance in one call. Returns the drop count. */ forgetTerminal(): number; } @@ -1714,6 +1724,7 @@ Both `start` overloads resolve to the same runtime call; the handle form only ex | Shell wants to react to state changes (tab title, breadcrumb). | `runtime.subscribe(id, listener)` | | User closes a journey tab before it completes. | Let `` unmount - it calls `end()`. | | Shell explicitly cancels (e.g. "end shift"). | `runtime.end(id, { reason: 'end-of-shift' })` | +| A "Cancel" button that throws the flow away AND its saved progress. | `runtime.discard(id)` - end + forget + remove blob, in one call. | | Long-running workspace accumulated finished journeys; free memory. | `runtime.forgetTerminal()` | | After `onFinished`, prune this specific terminal instance. | `runtime.forget(id)` | diff --git a/packages/vue-journeys/README.md b/packages/vue-journeys/README.md index fa51e720..ea77f37c 100644 --- a/packages/vue-journeys/README.md +++ b/packages/vue-journeys/README.md @@ -33,7 +33,9 @@ npm install @modular-vue/journeys starts it on mount, renders its step, ends + forgets the instance on unmount. Outlet props pass through as attrs, in either spelling (`:on-finished` and `:onFinished` both reach the outlet); the default scoped slot receives - `{ instanceId, instance, stepIndex, outlet }` for chrome. + `{ instanceId, instance, runtime, stepIndex, outlet }` for chrome. `outlet` is + a functional component — render it with ``, or spell + it yourself as ``. - **`useJourneyHost(handle, input, options?)`** — the lifecycle without the rendering. Returns `{ instanceId, instance, stepIndex }` as refs, plus the plain `runtime` it resolved at setup — the one `instanceId` is valid on. diff --git a/packages/vue-journeys/src/journey-host.test.ts b/packages/vue-journeys/src/journey-host.test.ts index f95e914c..f5d8897b 100644 --- a/packages/vue-journeys/src/journey-host.test.ts +++ b/packages/vue-journeys/src/journey-host.test.ts @@ -1,4 +1,4 @@ -import { defineComponent, h, ref, type PropType } from "vue"; +import { defineComponent, h, ref, type Component, type PropType } from "vue"; import { flushPromises, mount } from "@vue/test-utils"; import { describe, expect, it, vi } from "vitest"; import { defineEntry, defineExit, defineModule, schema } from "@modular-frontend/core"; @@ -130,15 +130,17 @@ describe("", () => { expect(wrapper.find('[data-testid="step-b"]').exists()).toBe(true); }); - it("hands chrome the live step index and a ready-built outlet", async () => { + it("hands chrome the live step index and a ready-built outlet component", async () => { const runtime = setup(); const wrapper = mountUnderProvider(runtime, () => h( JourneyHost, { handle }, { - default: ({ stepIndex, outlet }: { stepIndex: number; outlet: ReturnType }) => - h("div", [h("span", { "data-testid": "progress" }, `step ${stepIndex}`), outlet]), + // `outlet` is a functional component — render it with `h(outlet)` + // (the render-function analog of ``). + default: ({ stepIndex, outlet }: { stepIndex: number; outlet: Component }) => + h("div", [h("span", { "data-testid": "progress" }, `step ${stepIndex}`), h(outlet)]), }, ), ); @@ -149,6 +151,42 @@ describe("", () => { await wrapper.get('[data-testid="step-a"]').trigger("click"); expect(wrapper.get('[data-testid="progress"]').text()).toBe("step 1"); + expect(wrapper.find('[data-testid="step-b"]').exists()).toBe(true); + }); + + it("renders the slot `outlet` via `` and patches (not remounts) across steps", async () => { + const runtime = setup(); + // A template consumer: ``. This is the shape the + // docstring documents and the raw-VNode form could not satisfy. + const Consumer = defineComponent({ + components: { JourneyHost }, + setup() { + return { handle }; + }, + template: ` + + + + `, + }); + const wrapper = mountUnderProvider(runtime, () => h(Consumer)); + await flushPromises(); + + expect(wrapper.find('[data-testid="step-a"]').exists()).toBe(true); + const id = runtime.listInstances()[0]!; + + await wrapper.get('[data-testid="step-a"]').trigger("click"); + await flushPromises(); + // Advancing patches the same outlet in place — the instance is unchanged, + // not torn down and restarted by a remount. + expect(wrapper.find('[data-testid="step-b"]').exists()).toBe(true); + expect(runtime.listInstances()).toEqual([id]); + expect(wrapper.get('[data-testid="progress"]').text()).toBe("step 1"); }); it("ends and forgets the instance on unmount", async () => { diff --git a/packages/vue-journeys/src/journey-host.ts b/packages/vue-journeys/src/journey-host.ts index cf0c2b36..41c3629e 100644 --- a/packages/vue-journeys/src/journey-host.ts +++ b/packages/vue-journeys/src/journey-host.ts @@ -7,6 +7,7 @@ import { shallowRef, toRaw, type ComputedRef, + type FunctionalComponent, type PropType, type ShallowRef, type VNode, @@ -231,10 +232,27 @@ export interface JourneyHostSlotProps { /** See {@link JourneyHostState.stepIndex}. */ readonly stepIndex: number; /** - * The `` for this instance, already built with every outlet - * attribute passed to ``. Place it inside your chrome. + * The runtime the host started the instance on — the same value + * {@link JourneyHostState.runtime} exposes. Pass it to a hand-placed + * `` when you'd + * rather render the outlet yourself than use {@link JourneyHostSlotProps.outlet} + * (the idiomatic-Vue path), so the outlet resolves the *same* runtime the host + * pinned rather than whatever a nearer provider hands out. */ - readonly outlet: VNode; + readonly runtime: JourneyRuntime; + /** + * The `` for this instance as a **functional component**, + * already bound to the host's pinned runtime and every outlet attribute passed + * to ``. Render it with `` in a template + * (or `h(outlet)` in a render function) — it is a component, not a raw VNode, + * so `:is` mounts it cleanly. Its identity is stable across host re-renders, so + * `:is` patches the outlet in place instead of remounting it on every step. + * + * Equivalent to spelling `` yourself with the slot's `instanceId` / `runtime` — + * use whichever reads better in your chrome. + */ + readonly outlet: FunctionalComponent; } /** @@ -253,7 +271,8 @@ export interface JourneyHostSlotProps { * reads itself, to cover the render before the instance exists — which is why * it has to accept both spellings; see {@link readLoadingFallback}. * - * For chrome around the step, use the default scoped slot: + * For chrome around the step, use the default scoped slot. The slot's `outlet` + * is a functional component, so `` renders it: * * ```vue * @@ -265,6 +284,17 @@ export interface JourneyHostSlotProps { * * ``` * + * Or render the outlet yourself from the slot's `instanceId` + `runtime` — the + * more idiomatic-Vue spelling, equivalent to the `outlet` above: + * + * ```vue + * + * ``` + * * To deep-link the steps, call `useJourneySync` in the same component — the * host owns the instance, the sync owns the URL, and neither knows about the * other. @@ -298,6 +328,26 @@ export const JourneyHost = defineComponent({ runtime: props.runtime, }); + // Build the outlet vnode for the currently-owned instance. Reads + // `instanceId.value` / `attrs` at call time, so it tracks the live step. + // The resolved runtime, not `props.runtime`: `instanceId` only means + // anything on the runtime the host started it on, so forwarding a later + // prop value would point the outlet at a runtime that has never heard of + // this instance. + const renderOutlet = (): VNode | null => { + const id = instanceId.value; + if (!id) return null; + return h(JourneyOutlet, { ...attrs, runtime, instanceId: id }); + }; + + // A STABLE functional-component identity wrapping the outlet, handed to the + // slot as `outlet`. Stable across host re-renders on purpose: a fresh + // component identity each render would make `` + // remount the outlet (and restart the step component / start-on-mount) on + // every step, instead of patching it in place. Exposing a component (rather + // than a raw VNode) is what lets `:is` render it cleanly in a template. + const OutletComponent: FunctionalComponent = () => renderOutlet(); + return () => { const id = instanceId.value; const inst = instance.value; @@ -307,18 +357,14 @@ export const JourneyHost = defineComponent({ return typeof fallback === "function" ? fallback() : fallback; } - // The resolved runtime, not `props.runtime`: `instanceId` only means - // anything on the runtime the host started it on, so forwarding a later - // prop value would point the outlet at a runtime that has never heard of - // this instance. - const outlet = h(JourneyOutlet, { ...attrs, runtime, instanceId: id }); const slot = slots.default; - if (!slot) return outlet; + if (!slot) return renderOutlet(); return slot({ instanceId: id, instance: inst, + runtime, stepIndex: stepIndex.value, - outlet, + outlet: OutletComponent, } satisfies JourneyHostSlotProps); }; }, diff --git a/packages/vue-nuxt/README.md b/packages/vue-nuxt/README.md index 14b668fa..92258bc3 100644 --- a/packages/vue-nuxt/README.md +++ b/packages/vue-nuxt/README.md @@ -100,7 +100,19 @@ export default defineNuxtPlugin((nuxtApp) => { `nuxtApp.$router`). Returns the `ApplicationManifest` (`router`, `navigation`, `slots`, `modules`, -`recalculateSlots`, …). +`recalculateSlots`, …). Plugin extensions are preserved: a registry carrying +`journeysPlugin()` yields `manifest.extensions.journeys` (and the +`manifest.journeys` alias) typed as `JourneyRuntime` with **no cast** — the +extension map is inferred from the registry's `resolve()` return. That same +install threads the journey runtime app-wide via the plugin's `appProvides` hook, +so `` resolves it from context without a `` (or a +manual `provideJourneyRuntime`). + +> **Typing under `defineNuxtPlugin`.** `return { provide: { modular: manifest } }` +> can trip TS7022 ("referenced directly or indirectly in its own initializer") +> when the manifest carries a large plugin runtime. Annotate the binding — +> `const manifest: ApplicationManifest = installModularApp(…)` +> — to break the cycle. See the [Nuxt framework-mode guide](../../docs/framework-mode-nuxt.md). ## SSR and per-request state diff --git a/packages/vue-nuxt/src/install.test-d.ts b/packages/vue-nuxt/src/install.test-d.ts index bb8e164b..a5a7d8af 100644 --- a/packages/vue-nuxt/src/install.test-d.ts +++ b/packages/vue-nuxt/src/install.test-d.ts @@ -1,7 +1,7 @@ import { describe, it, expectTypeOf } from "vitest"; import { createApp } from "vue"; import { createMemoryHistory, createRouter } from "vue-router"; -import { createStore } from "@modular-frontend/core"; +import { createStore, type RegistryPlugin } from "@modular-frontend/core"; import { createRegistry, type ApplicationManifest } from "@modular-vue/runtime"; import { installModularApp, type InstallModularAppOptions, type NuxtAppLike } from "./install.js"; @@ -13,6 +13,19 @@ interface Slots { [key: string]: readonly unknown[]; } +// A minimal stand-in for a runtime-contributing plugin (shaped like the real +// journeys plugin) so this package can assert extension threading without a +// dependency on `@modular-vue/journeys`. +interface FakeJourneyRuntime { + start(id: string): string; +} +type FakeJourneysPlugin = RegistryPlugin< + "journeys", + { registerJourney(def: unknown): void }, + FakeJourneyRuntime +>; +declare const fakeJourneysPlugin: () => FakeJourneysPlugin; + describe("installModularApp types", () => { it("returns an ApplicationManifest typed by the registry generics", () => { const registry = createRegistry({ @@ -32,4 +45,26 @@ describe("installModularApp types", () => { ((slots: Slots, deps: Deps) => Slots) | undefined >(); }); + + it("carries plugin extensions through to the manifest without a cast", () => { + // The plugin tuple is inferred from the registry, so `manifest.extensions` + // and the `manifest.journeys` convenience alias are typed against the + // plugin's runtime rather than collapsing to `Record` / + // `unknown`. This is the fix for the "installModularApp erases plugin + // extension types" feedback. + const registry = createRegistry({ + stores: { auth: createStore({ user: null }) }, + slots: { commands: [] }, + }).use(fakeJourneysPlugin()); + const router = createRouter({ history: createMemoryHistory(), routes: [] }); + const nuxtApp: NuxtAppLike = { vueApp: createApp({}), $router: router }; + + const manifest = installModularApp(nuxtApp, registry); + + expectTypeOf(manifest.journeys).toEqualTypeOf(); + expectTypeOf(manifest.extensions.journeys).toEqualTypeOf(); + // Not `unknown` — the regression the feedback hit was `manifest.journeys` + // widening to `unknown` and forcing `manifest.journeys as JourneyRuntime`. + expectTypeOf(manifest.journeys).not.toBeUnknown(); + }); }); diff --git a/packages/vue-nuxt/src/install.ts b/packages/vue-nuxt/src/install.ts index 987458b3..bec66ce9 100644 --- a/packages/vue-nuxt/src/install.ts +++ b/packages/vue-nuxt/src/install.ts @@ -6,7 +6,11 @@ import type { SlotMap, SlotMapOf, } from "@modular-frontend/core"; -import type { ApplicationManifest, ModuleExitEvent, ModuleRegistry } from "@modular-vue/runtime"; +import type { + ApplicationManifest, + InstallableRegistry, + ModuleExitEvent, +} from "@modular-vue/runtime"; /** * The minimal slice of Nuxt's `NuxtApp` the installer needs: the Vue app to @@ -95,17 +99,42 @@ export interface InstallModularAppOptions< * and a module-level singleton registry would throw on the second request. For * a client-only app (`ssr: false`) a singleton is fine because the plugin runs * once. + * + * **Plugin extensions survive.** The manifest's extension map (`TExtensions`) is + * inferred from the registry's `resolve()` return, so the returned manifest keeps + * `extensions.journeys: JourneyRuntime` — and the `manifest.journeys` convenience + * alias — with no cast when the registry carries `journeysPlugin()`. A registry + * with no plugins yields an empty extension map, exactly as before. (The plugin + * *tuple* itself can't be recovered by inference — it hides behind + * `PluginRuntimesOf` — so the extensions are read off the manifest, + * where they sit plainly; see {@link InstallableRegistry}.) + * + * **Typing `manifest` under `defineNuxtPlugin`.** Returning + * `{ provide: { modular: manifest } }` makes Nuxt infer the plugin's provide + * types from `manifest`. When the manifest carries a large plugin runtime (e.g. + * the journeys runtime), that structural inference can trip TypeScript's + * self-reference guard (TS7022, "referenced directly or indirectly in its own + * initializer"). Annotate the binding to break the cycle — the precise type is + * now nameable thanks to the `TPlugins` threading above: + * + * ```ts + * import type { ApplicationManifest } from "@modular-vue/nuxt/runtime"; + * const manifest: ApplicationManifest = + * installModularApp(nuxtApp, registry); + * return { provide: { modular: manifest } }; + * ``` */ export function installModularApp< TSharedDependencies extends Record, TSlots extends SlotMapOf = SlotMap, TNavItem extends NavigationItemBase = NavigationItem, + TExtensions extends Record = Record, >( nuxtApp: NuxtAppLike, - registry: ModuleRegistry, + registry: InstallableRegistry, options?: InstallModularAppOptions, -): ApplicationManifest { - const manifest = registry.resolve({ +): ApplicationManifest { + const manifest: ApplicationManifest = registry.resolve({ router: nuxtApp.$router, parentRouteName: options?.parentRouteName, authGuard: options?.authGuard, diff --git a/packages/vue-runtime/src/app.ts b/packages/vue-runtime/src/app.ts index a97bcd52..4adfd941 100644 --- a/packages/vue-runtime/src/app.ts +++ b/packages/vue-runtime/src/app.ts @@ -1,12 +1,54 @@ import type { NavigationItem, NavigationItemBase, + RegistryPlugin, SlotMap, SlotMapOf, } from "@modular-frontend/core"; import type { ModuleRegistry } from "./registry.js"; import type { ApplicationManifest, ResolveOptions } from "./types.js"; +/** + * A registry whose `resolve()` return exposes its plugin-extension map as an + * inferable `TExtensions`. The registry's plugin *tuple* can't be recovered by + * inference — it hides behind `PluginRuntimesOf` — but the resolved + * extensions ride plainly on {@link ApplicationManifest}'s third type argument, + * so matching the `resolve` return recovers them. This is what lets an installer + * wrapper — {@link createModularApp} here, `installModularApp` in + * `@modular-vue/nuxt` — hand back a manifest that keeps `extensions.journeys` + * (and the `manifest.journeys` alias) typed against the plugin's runtime rather + * than collapsing to `Record` / `unknown`. + * + * The plugin position is left as the wide constraint (`readonly RegistryPlugin[]`) + * so any registry — plugin-carrying or not — satisfies it; the concrete + * extension shape flows through `TExtensions` off the `resolve` signature. The + * base `resolve` is `Omit`ted and re-declared so this is the single call + * signature — `TExtensions` is then inferred, and a wrapper's `registry.resolve()` + * returns it, unambiguously (no intersection-overload ordering to pick the wide + * base return). + * + * Exported so the Nuxt installer (and any downstream installer wrapper) shares + * this one definition rather than re-declaring it. + */ +export type InstallableRegistry< + TSharedDependencies extends Record, + TSlots extends SlotMapOf, + TNavItem extends NavigationItemBase, + TExtensions extends Record, +> = Omit< + ModuleRegistry< + TSharedDependencies, + TSlots, + TNavItem, + readonly RegistryPlugin[] + >, + "resolve" +> & { + resolve( + options: ResolveOptions, + ): ApplicationManifest; +}; + /** * Resolves a registry and returns its application manifest — a Vue plugin that * installs the modular contexts, plus the router (with module routes grafted) @@ -24,14 +66,20 @@ import type { ApplicationManifest, ResolveOptions } from "./types.js"; * * Thin convenience over {@link ModuleRegistry.resolve}; call `registry.resolve` * directly if you need to name the manifest before installing it. + * + * Plugin extensions survive: the manifest's `TExtensions` is inferred from the + * registry's `resolve()` return, so `manifest.extensions.journeys` / + * `manifest.journeys` stay typed against the plugin runtime when the registry + * carries `journeysPlugin()` — no cast. See {@link InstallableRegistry}. */ export function createModularApp< TSharedDependencies extends Record, TSlots extends SlotMapOf = SlotMap, TNavItem extends NavigationItemBase = NavigationItem, + TExtensions extends Record = Record, >( - registry: ModuleRegistry, + registry: InstallableRegistry, options: ResolveOptions, -): ApplicationManifest { +): ApplicationManifest { return registry.resolve(options); } diff --git a/packages/vue-runtime/src/index.ts b/packages/vue-runtime/src/index.ts index f5f6b1bb..2f6cdfb2 100644 --- a/packages/vue-runtime/src/index.ts +++ b/packages/vue-runtime/src/index.ts @@ -4,6 +4,7 @@ export type { ModuleRegistry } from "./registry.js"; // App shell — router-owning resolve() convenience export { createModularApp } from "./app.js"; +export type { InstallableRegistry } from "./app.js"; // Route builder (graft module routes onto a live vue-router instance) export { graftModuleRoutes, createLazyModuleRoute } from "./route-builder.js";