Skip to content
Closed
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@ Closes items 1 and 2 of `docs/consumer-feedback-production-app.md`: the two larg

A URL cannot navigate a journey to an arbitrary step, and the API does not pretend otherwise. A step is derived from state and `JourneyStep` carries no identity (`{ moduleId, entry, input }` and nothing more), so the only positions a location can select are ones the journey has already been to (`history` → `rewindTo`) or just rewound from (`future` → `goForward`). Anything else fires `onUnresolved` and navigates nothing — the sync will not drag a user back who clicked a link out of the flow on purpose. A frame the journey refuses to leave (no `allowBack` opt-in, or a child in flight) re-asserts the current step's URL — the standard "block the Back button" shape — and fires `onBlocked`; with `go` it walks the browser forward by the rejected distance so a refused multi-entry jump keeps the intermediate entries, falling back to a (truncating) `push` only when the port has no `go`. A sync tracks the one instance it is given and does not follow child journeys — a child in flight simply reads as a blocked parent frame.

### Added — `defineModule` literal inference + function-form `nav.to`

Closes item 3 of `docs/consumer-feedback-production-app.md`: the production consumer abandoned `defineModule` entirely — every module carried a copy-pasted apology explaining why a plain `as const` object literal was used instead — because the helper widened its literal shape and its default nav-item generic rejected function-form `to`. Both defects are now fixed, across every variant.

- **`@modular-frontend/core`**, **`@tanstack-react-modules/core`**, **`@react-router-modules/core`**, **`@modular-vue/core`**, **`@angular-router-modules/core`** — `defineModule` now infers a trailing `TDescriptor` from its argument and returns it verbatim, so `entryPoints` / `exitPoints` keep their **literal** keys instead of widening to `EntryPointMap` / `ExitPointMap`. The acceptance test the item asked for is met: `typeof someModule` drops into a journey `TransitionMap` (and `StepSpec`) as a `TModules` member with **zero casts**, and an exit handler's `output` narrows per exit. (The neutral core already inferred `TDescriptor`; the four router cores did not — they returned the widened `ModuleDescriptor<…>`.) The four router `defineModule`s keep their router-narrowed `createRoutes`, so this is a pure return-type refinement, not a behavior change.
- **(same packages)** — `TNavItem` is now **inferred from the `navigation` array** (the parameter is typed `descriptor & { navigation?: readonly TNavItem[] }`), defaulting to `NavigationItem` only when there is no navigation. A module that resolves its href at render time (`to: (ctx) => "/x/" + ctx.id`) type-checks with **zero generics** — the old fixed `NavigationItem` default narrowed `to` to a plain `string` and rejected the resolver form. Inference (rather than defaulting the slot to the wide `NavigationItemBase` bound) is deliberate: a fixed wide default made the returned descriptor unassignable to a `NavigationItem`-typed `register()`, so the inferred-narrow item keeps registration working while still admitting function-form `to`. Explicitly spelling `TNavItem` (typed i18n labels, dynamic-href context, a typed `meta` bag) still narrows exactly as before.

### Added — derive step ordering and progress from the transition graph

Closes item 4 of `docs/consumer-feedback-production-app.md`: the consumer encoded each journey's flow twice — once as the transition-map graph, and again as a ~170-line hand-maintained file of ordered step arrays (in three branch-variant copies) for URL segments and "Step X of N", kept in sync only by discipline. The ordering and progress primitives now live in the library, derived from the one place the flow is already encoded.

- **`@modular-frontend/journeys-engine`** — `resolveStepSequence(definition, options?)`. Walks the transition graph statically, following the `targets` each `defineTransition` handler already declares, and returns the ordered step list (`ResolvedJourneyStep[]`, each carrying the step's `path` / `progressLabel`) from the start step forward. Linear flows resolve on their own; a forking flow takes an `options.branch` resolver to choose the path at each fork, and `options.input` / `options.start` seed the first step. Seeding is **required** — not optional — for a journey whose `initialState` consumes a non-void input: the options type demands `input` or `start` there, so `resolveStepSequence(definition)` is a compile error instead of silently calling `initialState(undefined)` (void-input journeys still take no options). It returns the linear-with-branches spine — enough to delete the hand-maintained arrays — and is the runtime companion to the catalog harvester's build-time destination extraction. **Requires handlers annotated with `defineTransition`**: a step whose transitions are all bare functions has no statically-known next step, so the walk stops there. Cycle-guarded and length-capped (`maxSteps`, default 256). New public types: `ResolvedJourneyStep`, `ResolveStepSequenceOptions`, `StepSequenceWalkOptions`, `StepSequenceOptionsArg`, `StepSequenceRef`.
- **`@modular-frontend/core`** — `JourneyDefinition.steps?: JourneyStepMetaMap<TModules>`. Per-step presentation metadata keyed by `[moduleId][entry]` exactly like `transitions` (entry keys filtered to journey-mountable entries, so a typo is a compile error), each leaf a `JourneyStepMeta` (`{ path?, progressLabel? }`). `path` overrides the URL sync's default `"moduleId/entry"` segment; `progressLabel` feeds progress UIs. One source of truth beside the transitions, not re-encoded at each `next:`. New public types: `JourneyStepMeta`, `JourneyStepMetaMap`.
- **`@modular-react/journeys`**, **`@modular-vue/journeys`** — `useJourneyProgress(instanceId, definition, options?)`. Returns `{ index, total, label, steps }`: `index` from the live instance (`history.length`, so it rewinds when the journey does), `total` / `label` / `steps` from `resolveStepSequence`. This is the `stepCount` that the journey-hosting work (item 2) deferred with "deriving it from the graph is tracked separately" — now derivable because the total comes from the graph rather than a hand-passed number. `total` is `null` when the sequence can't be walked (unannotated transitions). The React hook returns a plain object; the Vue composable returns `ComputedRef`s. `options.sequence` forwards to `resolveStepSequence` (chiefly `branch`, to make the total reflect a chosen path) and, mirroring it, is optional for void-input journeys but required (carrying `input` / `start`) when the definition needs a non-void input. `resolveStepSequence` and its types are re-exported from both bindings.

### Added — journey runtime additions (EXP-1848 adoption follow-up)

- **`@modular-react/core`** — `ModuleEntryPoint.buildInput?: (state) => TInput`. When declared on an entry, the journey runtime calls it on every step entry (initial start, forward push, `goBack` pop, resume-into-step) AND on any same-step state change (an `{ invoke }` carrying `state`, or a resume that bumps state without advancing) and uses the result as the step's `input`. Lets a back-navigated form re-render against the journey state accumulated by earlier exits instead of the input frozen at first push. Opt-in — entries without `buildInput` keep the current cache-on-push behaviour. Throws abort the instance with a typed `JourneySystemAbortReason` (`"build-input-threw"`); in `debug` mode a one-time warning fires when the handler's `next.input` would have been overridden by a differing `buildInput` result. Authors annotate the `state` parameter with the hosting journey's `TState` (the module surface stays journey-agnostic).
Expand Down
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ Once a workspace has accumulated more modules than any one team can keep in thei

## Project status

- `@react-router-modules/*`: **v2.x**, considered stable for the APIs documented in the guides below.
- `@tanstack-react-modules/*`: **v1.x**, considered stable for the APIs documented in the guides below.
- `@modular-react/{core,react,testing}`: the shared foundation, stable at `1.x`. The router-integration packages depend on these and version independently.
- `@react-router-modules/*`: `core` / `runtime` at **v2.x**, `cli` / `testing` at **v3.x** — considered stable for the APIs documented in the guides below.
- `@tanstack-react-modules/*`: the same spread — `core` / `runtime` at **v2.x**, `cli` / `testing` at **v3.x** — considered stable for the APIs documented in the guides below.
- `@modular-react/{core,react,testing}`: the shared foundation — `core` / `react` at **v2.x**, `testing` at **v1.x**. The router-integration packages depend on these and version independently.
- `@modular-react/compositions`: **v0.1.x**, the surface and behavior are documented in [its README](packages/compositions/README.md) but breaking changes between 0.x minor versions are still possible.
- `@modular-vue/*` (`core`, `runtime`, `vue`, `testing`, `journeys`, `compositions`): the Vue 3 + vue-router family, **v1.0**, at full feature parity with `@react-router-modules/*` (see the [parity audit](docs/vue-support-tracker.md#parity-audit-pr-42)). Start with [Getting started with Vue Router](docs/getting-started-vue-router.md). The `@modular-vue/cli` scaffolder (binary `modular-vue`) ships alongside the family — `modular-vue init` bootstraps a workspace and `modular-vue create module|store|journey` extends it; the getting-started guide also shows the equivalent manual setup.
- `@modular-vue/nuxt`: the Nuxt 4 integration, **v0.1.x, experimental**. Grafts module routes onto Nuxt's vue-router and installs the modular contexts on the Nuxt Vue app, either via a Nuxt module or the `installModularApp` helper in your own plugin. See [Framework-mode (Nuxt 4)](docs/framework-mode-nuxt.md).
- `@angular-router-modules/core` + `@modular-angular/angular`: an Angular Router family, **v0.1.x, experimental / early**. Core-only so far — `defineModule`, shared injectors, and scoped stores that track the React and Vue core packages case-for-case; there is no runtime, CLI, or journeys binding yet, so it is not a peer of the router integrations above. Part of the [Angular support initiative](docs/angular-support-tracker.md).
- `@modular-frontend/*` (`core`, `testing`, `journeys-engine`, `compositions-engine`): the framework-neutral shared engine and core the React and Vue families both build on. `journeys-engine` carries the **1.x** version of the package it was extracted from; `core`, `testing`, and `compositions-engine` are **0.1.x**. The binding families peer-depend on these with tight (`^0.1.0`-style) ranges, so any `@modular-frontend/*` bump ships with coordinated peer-range bumps and releases of every dependent binding package in the same batch — see the [versioning policy](docs/vue-support-tracker.md#versioning-and-release).

The React families target **React 19**; the Vue family targets **Vue ^3.5** and **vue-router ^5.0**. All target **Node 22+**. The docs and CLI scaffolder assume **pnpm workspaces**, but nothing in the runtime or CLI is pnpm-specific; any local package resolution that understands the `workspace:*` protocol (Yarn Berry, Bun) will work after scaffolding with a few script edits. See each getting-started guide for the full pinned version set.
Expand Down Expand Up @@ -234,7 +235,7 @@ See [Getting started with Vue Router](docs/getting-started-vue-router.md) and [S

## Examples

Runnable examples live under [`examples/`](examples/), split by router integration. Each is a self-contained pnpm workspace that resolves the library packages from this repo, so changes in `packages/*` are reflected the next time you run the example (some examples pin `workspace:*` on every dep, others declare library deps with semver ranges and rely on the repo's `.npmrc` `link-workspace-packages=true` — either way the local source wins):
Runnable examples live under [`examples/`](examples/), split by router integration. Each is a self-contained pnpm workspace that resolves the library packages from this repo, so changes in `packages/*` are reflected the next time you run the example (examples pin `workspace:*` on their library deps, so the local source always wins):

- [`examples/react-router/integration-manager/`](examples/react-router/integration-manager) — sibling modules sharing a screen (React Router)
- [`examples/tanstack-router/integration-manager/`](examples/tanstack-router/integration-manager) — sibling modules sharing a screen (TanStack Router)
Expand Down Expand Up @@ -294,6 +295,13 @@ See [`examples/README.md`](examples/README.md) for how to run them and how to ad
| [`@modular-vue/compositions`](packages/vue-compositions) | Vue composition provider, panel/host composables, `<CompositionOutlet>` (scoped-slot), registry plugin. |
| [`@modular-vue/nuxt`](packages/vue-nuxt) | Nuxt 4 integration (experimental, `0.1.x`): a Nuxt module plus the `installModularApp` runtime installer that grafts module routes onto Nuxt's vue-router and installs the modular contexts. |

### Angular (experimental)

| Package | Description |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`@angular-router-modules/core`](packages/angular-router-core) | Angular Router module definition (`defineModule`) and shared types — the Angular analog of the React/Vue `core` packages. Experimental, `0.1.x`; core-only (no runtime/CLI yet). |
| [`@modular-angular/angular`](packages/angular) | Angular runtime primitives the core builds on: shared injectors and scoped stores. Experimental, `0.1.x`. |

### Framework-neutral engine (shared by React and Vue)

| Package | Description |
Expand Down
52 changes: 52 additions & 0 deletions docs/consumer-feedback-production-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,29 @@ becomes mountable in one line.

## 3. Fix `defineModule` so real apps can use it (literal inference + function-form `nav.to`)

> **Shipped** — both defects are fixed across every `defineModule` variant
> (`@modular-frontend/core` plus the four router cores:
> `@tanstack-react-modules/core`, `@react-router-modules/core`,
> `@modular-vue/core`, `@angular-router-modules/core`).
>
> **(a) Literal inference.** Each `defineModule` now infers a trailing
> `TDescriptor` from its argument and returns it verbatim, so `entryPoints` /
> `exitPoints` keep their literal keys instead of widening to
> `EntryPointMap` / `ExitPointMap`. The acceptance test is met:
> `typeof someModule` drops into a journey `TransitionMap` (and `StepSpec`) with
> zero casts, and the exit handler's `output` narrows per exit — see
> `packages/tanstack-router-core/src/define-module.test-d.ts`.
>
> **(b) Function-form `to`.** `TNavItem` is now inferred from the `navigation`
> array (the parameter is typed `TDescriptor & { navigation?: readonly TNavItem[] }`),
> defaulting to `NavigationItem` only when there is no
> navigation. A module that resolves its href at render time (`to: (ctx) => …`)
> type-checks with zero generics. Inference — rather than defaulting the slot to
> the wide `NavigationItemBase` bound — was deliberate: a fixed wide default
> made the returned descriptor unassignable to a `NavigationItem`-typed
> `register()`, so the inferred-narrow item keeps registration working while
> still admitting the resolver form.

**Evidence.** The app does not use `defineModule` at all. Four of its modules
carry a near-verbatim copy of a justification comment explaining that a plain
object literal with `as const` is used instead, because (a) `defineModule`
Expand All @@ -110,6 +133,35 @@ as a `TModules` member in a journey `TransitionMap` with zero casts.

## 4. Derive step ordering and progress from the transition graph

> **Shipped** — the ordering and progress primitives now live in the engine and
> both bindings.
>
> - **`resolveStepSequence(definition, options?)`** (`@modular-frontend/journeys-engine`)
> walks the transition graph statically — following the `targets` each
> `defineTransition` handler already declares — and returns the ordered step
> list from the start step forward. Linear flows resolve on their own; a
> forking flow takes an `options.branch` resolver to pick the path at each
> fork (`options.input` / `options.start` seed the first step). Returns the
> linear-with-branches spine, which is all it takes to delete the
> hand-maintained ordered-step arrays.
> - **Per-step metadata** rides on a new journey-level `steps` map
> (`steps[module][entry] = { path?, progressLabel? }`), keyed against the real
> modules/entries so a typo is a compile error. `path` overrides the URL sync's
> default `"moduleId/entry"` segment; `progressLabel` feeds progress UIs. One
> source of truth beside the transitions, not re-encoded at each `next:`.
> - **`useJourneyProgress()`** ships on both React (`@modular-react/journeys`)
> and Vue (`@modular-vue/journeys`), returning `{ index, total, label, steps }`:
> `index` from the live instance (`history.length`), `total` / `label` /
> `steps` from `resolveStepSequence`. This is the `stepCount` that item 2
> deferred — now derivable because the total comes from the graph, not a
> hand-passed number.
>
> **Scope.** The walk requires annotated (`defineTransition`) handlers — a step
> whose transitions are all bare functions has no statically-known next step, so
> the sequence stops there (`total` reflects what is resolvable). Deriving an
> arbitrary DAG ordering is out of scope; linear-with-branches is what the
> evidence called for.

**Evidence.** Each journey's flow is encoded twice: once as the real
transition-map graph, and again in a ~170-line hand-maintained file of ordered
step arrays for URL segments and "Step X of N" — in _three_ branch-variant
Expand Down
2 changes: 1 addition & 1 deletion docs/framework-mode-nuxt.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ import { defineModule } from "@modular-vue/core";
import type { RouteRecordRaw } from "vue-router";
import BillingPage from "./BillingPage.vue";

export default defineModule<AppDependencies, AppSlots>({
export default defineModule<AppDependencies, AppSlots>()({
id: "billing",
version: "1.0.0",
createRoutes: (): RouteRecordRaw => ({
Expand Down
2 changes: 1 addition & 1 deletion docs/framework-mode-tanstack-router.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ For a module whose route structure is known at build time, wrap its component wi
import { defineModule } from "@tanstack-react-modules/core";
import { createRoute, lazyRouteComponent } from "@tanstack/react-router";

export default defineModule<AppDependencies, AppSlots>({
export default defineModule<AppDependencies, AppSlots>()({
id: "billing",
version: "1.0.0",
createRoutes: (parent) =>
Expand Down
Loading
Loading