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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ Per-package detail lives in the GitHub release tagged `<npm-name>@<version>`.

## 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<TSlots, TNavItem>`, collapsing `manifest.extensions` to `Record<string, unknown>` 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<TPlugins>` — 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`** (`<JourneyHost>`) — the default scoped slot's `outlet` is now a **functional component** instead of a raw `VNode`, so `<component :is="outlet" />` (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 `<JourneyOutlet :instance-id="instanceId" :runtime="runtime" />` 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 `<component :is>`).
- **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.
Expand Down
40 changes: 40 additions & 0 deletions docs/framework-mode-nuxt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<JourneyHost>` / `<JourneyOutlet>` and
`useJourneyContext()` resolve the runtime from context with **no
`<JourneyProvider>` 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<AppSlots, AppNavItem, { journeys: JourneyRuntime }> =
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
Expand Down
37 changes: 33 additions & 4 deletions docs/journeys-vue.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ import { envSetupHandle } from "~/journeys/env-setup";
</template>
```

The slot's `outlet` is a **functional component** (not a raw VNode), so
`<component :is="outlet" />` 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 `<JourneyOutlet :instance-id="instanceId"
:runtime="runtime" />` 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 `<JourneyHost>` to the inner
`<JourneyOutlet>`.
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions examples/vue/nuxt-modal-journey/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -49,6 +51,12 @@ function onFinished() {
<button type="button" data-testid="wizard-close" @click="ui.close()">
Close (progress kept)
</button>

<!-- Cancel throws the flow away: runtime.discard(id) ends the instance
and removes its persisted blob in one call. Reopening starts fresh. -->
<button type="button" data-testid="wizard-cancel" @click="cancel()">
Cancel (discard progress)
</button>
</div>
</div>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
21 changes: 21 additions & 0 deletions examples/vue/nuxt-modal-journey/shell/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("/");

Expand Down
29 changes: 29 additions & 0 deletions packages/frontend-core/src/journey-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import type {
EntryPointMap,
ExitContract,

Check warning on line 16 in packages/frontend-core/src/journey-contracts.ts

View workflow job for this annotation

GitHub Actions / Lint

eslint(no-unused-vars)

Type 'ExitContract' is imported but never used.
ExitPointMap,
ExitPointSchema,
ModuleDescriptor,
Expand Down Expand Up @@ -979,6 +979,35 @@
* 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
Expand Down
60 changes: 60 additions & 0 deletions packages/frontend-core/src/step-spec.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormState>()((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.
// -----------------------------------------------------------------------------
Expand Down Expand Up @@ -123,3 +160,26 @@ test("StepSpec<any> falls back to a loose shape, not never", () => {
const loose: StepSpec<any> = { 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<InlineModules> = { module: "inline-self-building", entry: "enter" };
void omitted;
// A stamped value is still shape-checked.
const stamped: StepSpec<InlineModules> = {
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<InlineModules> = { module: "inline-plain", entry: "enter" };
void bad;
});
Loading
Loading