From aa751e5aae3bdf958d44ec286417ca8d897564c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 08:22:32 +0000 Subject: [PATCH 1/4] refactor(compositions): split composition definition into dedicated package + add ZoneSpec strong typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the editor composition's runtime definition out of `app-shared/` into a dedicated `compositions/editor/` package per example, mirroring the `journeys//` layout used by journey examples. Result: - `app-shared/` — shell-team contract (`AppDependencies`, `AppSlots`) only; no composition-specific code. - `compositions/editor/` — composition team's package: state types, typed hook factory, runtime definition, handle. - `modules//` — depend on `compositions/editor/`, not on `app-shared/`. Ownership is structural, not just by convention. Adds `examples/*/*/compositions/*` to the pnpm-workspace globs so the new packages are picked up alongside `journeys/*` siblings. In the framework, introduces `ZoneSpec` — a per-`(module, entry)` discriminated union mirroring `StepSpec` from journeys, with `input` narrowed to the target entry's declared schema. `ZoneSelector` now returns `ZoneSpec` so selectors with concrete module-map typing get compile-time input checking. Includes an `IsAny` fallback so the framework's own `ZoneSelector` paths still typecheck (without the fallback, mapped-type expansion collapses `module-entry` to `never` with `any`). Documents the disambiguation between `module.zones` (existing slot extension on a route's active module) and composition zones (this package's per-zone selector projection) in the README — they share the word but are unrelated primitives. Both react-router and tanstack-router editor-composition examples typecheck; all 71 compositions tests pass. --- .../react-router/editor-composition/README.md | 14 +++- .../app-shared/package.json | 9 --- .../app-shared/src/app-types.ts | 19 ++--- .../app-shared/src/index.ts | 3 +- .../compositions/editor/package.json | 24 ++++++ .../editor}/src/composition.ts | 23 +++--- .../compositions/editor/src/hooks.ts | 10 +++ .../compositions/editor/src/index.ts | 3 + .../compositions/editor/src/state.ts | 17 ++++ .../compositions/editor/tsconfig.json | 7 ++ .../modules/contentful/package.json | 2 +- .../modules/contentful/src/index.tsx | 5 +- .../modules/editor/package.json | 2 +- .../modules/editor/src/index.tsx | 2 +- .../modules/strapi/package.json | 2 +- .../modules/strapi/src/index.tsx | 5 +- .../editor-composition/shell/package.json | 1 + .../editor-composition/shell/src/main.tsx | 2 +- .../editor-composition/README.md | 13 +++ .../app-shared/package.json | 9 --- .../app-shared/src/app-types.ts | 13 +-- .../app-shared/src/index.ts | 3 +- .../compositions/editor/package.json | 24 ++++++ .../editor}/src/composition.ts | 10 +-- .../compositions/editor/src/hooks.ts | 5 ++ .../compositions/editor/src/index.ts | 3 + .../compositions/editor/src/state.ts | 8 ++ .../compositions/editor/tsconfig.json | 7 ++ .../modules/contentful/package.json | 2 +- .../modules/contentful/src/index.tsx | 5 +- .../modules/editor/package.json | 2 +- .../modules/editor/src/index.tsx | 2 +- .../modules/strapi/package.json | 2 +- .../modules/strapi/src/index.tsx | 5 +- .../editor-composition/shell/package.json | 1 + .../editor-composition/shell/src/main.tsx | 2 +- packages/compositions/README.md | 17 ++++ packages/compositions/src/types.ts | 81 +++++++++++++++++-- pnpm-lock.yaml | 62 +++++++------- pnpm-workspace.yaml | 1 + 40 files changed, 309 insertions(+), 118 deletions(-) create mode 100644 examples/react-router/editor-composition/compositions/editor/package.json rename examples/react-router/editor-composition/{app-shared => compositions/editor}/src/composition.ts (76%) create mode 100644 examples/react-router/editor-composition/compositions/editor/src/hooks.ts create mode 100644 examples/react-router/editor-composition/compositions/editor/src/index.ts create mode 100644 examples/react-router/editor-composition/compositions/editor/src/state.ts create mode 100644 examples/react-router/editor-composition/compositions/editor/tsconfig.json create mode 100644 examples/tanstack-router/editor-composition/compositions/editor/package.json rename examples/tanstack-router/editor-composition/{app-shared => compositions/editor}/src/composition.ts (84%) create mode 100644 examples/tanstack-router/editor-composition/compositions/editor/src/hooks.ts create mode 100644 examples/tanstack-router/editor-composition/compositions/editor/src/index.ts create mode 100644 examples/tanstack-router/editor-composition/compositions/editor/src/state.ts create mode 100644 examples/tanstack-router/editor-composition/compositions/editor/tsconfig.json diff --git a/examples/react-router/editor-composition/README.md b/examples/react-router/editor-composition/README.md index 81dbd420..abdc339c 100644 --- a/examples/react-router/editor-composition/README.md +++ b/examples/react-router/editor-composition/README.md @@ -33,7 +33,15 @@ Then open `http://localhost:5197`. ## Layout ```text -app-shared/ — types + the composition definition -modules/ — editor / contentful / strapi panel modules -shell/ — registry, root route, CompositionOutlet wiring, e2e +app-shared/ — contract panels consume (state types, branded ids, typed hooks) +compositions/ + editor/ — composition definition + typed handle (depends on app-shared) +modules/ — editor / contentful / strapi panel modules (depend on app-shared) +shell/ — registry, root route, CompositionOutlet wiring, e2e ``` + +Mirrors how journey examples place each journey under `journeys//`. Panel +modules depend on `app-shared` only — never on `compositions/editor` — so the +composition's runtime definition is not transitively pulled into a panel's bundle and +the boundary "modules don't know which composition hosts them" is structural, not just +a convention. diff --git a/examples/react-router/editor-composition/app-shared/package.json b/examples/react-router/editor-composition/app-shared/package.json index 045402f7..bbb837ba 100644 --- a/examples/react-router/editor-composition/app-shared/package.json +++ b/examples/react-router/editor-composition/app-shared/package.json @@ -14,16 +14,7 @@ "scripts": { "typecheck": "tsc --noEmit" }, - "dependencies": { - "@modular-react/compositions": "workspace:*", - "@modular-react/core": "workspace:*" - }, "devDependencies": { - "@types/react": "^19.0.0", - "react": "^19.0.0", "typescript": "^6.0.2" - }, - "peerDependencies": { - "react": "^19.0.0" } } diff --git a/examples/react-router/editor-composition/app-shared/src/app-types.ts b/examples/react-router/editor-composition/app-shared/src/app-types.ts index 5b286a40..5d9270a5 100644 --- a/examples/react-router/editor-composition/app-shared/src/app-types.ts +++ b/examples/react-router/editor-composition/app-shared/src/app-types.ts @@ -2,6 +2,11 @@ * Shared registry dependencies for the editor-composition example. Modules * declare which keys they need via `requires` — this app's modules are * dependency-free, so it stays minimal. + * + * Owned by the shell team. The composition-specific state shape and hooks + * live in `compositions/editor/` so the composition team owns its + * contract independently. Mirrors how journey examples keep journey state + * out of `app-shared`. */ export interface AppDependencies { readonly auth: { readonly userId: string }; @@ -11,17 +16,3 @@ export interface AppDependencies { export interface AppSlots { readonly commands: readonly { readonly id: string; readonly label: string }[]; } - -/** Id of a source-integration panel hosted in the composition's `source` zone. */ -export type SourceId = "contentful" | "strapi"; - -/** - * The composition's scoped store. The `main` zone always renders the - * editor; the `source` zone projects `activeSource` → Contentful / Strapi - * / empty; the `inspector` zone projects `selectedSourceItem` → details. - */ -export interface EditorState { - readonly documentId: string; - readonly activeSource: SourceId | null; - readonly selectedSourceItem: string | null; -} diff --git a/examples/react-router/editor-composition/app-shared/src/index.ts b/examples/react-router/editor-composition/app-shared/src/index.ts index e24bff4c..84bf110a 100644 --- a/examples/react-router/editor-composition/app-shared/src/index.ts +++ b/examples/react-router/editor-composition/app-shared/src/index.ts @@ -1,2 +1 @@ -export type { AppDependencies, AppSlots, EditorState, SourceId } from "./app-types.js"; -export { editorComposition, editorCompositionHandle, createEditorHooks } from "./composition.js"; +export type { AppDependencies, AppSlots } from "./app-types.js"; diff --git a/examples/react-router/editor-composition/compositions/editor/package.json b/examples/react-router/editor-composition/compositions/editor/package.json new file mode 100644 index 00000000..f742c8a2 --- /dev/null +++ b/examples/react-router/editor-composition/compositions/editor/package.json @@ -0,0 +1,24 @@ +{ + "name": "@example-rr-editor-composition/editor-composition", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modular-react/compositions": "workspace:*", + "@modular-react/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^6.0.2" + } +} diff --git a/examples/react-router/editor-composition/app-shared/src/composition.ts b/examples/react-router/editor-composition/compositions/editor/src/composition.ts similarity index 76% rename from examples/react-router/editor-composition/app-shared/src/composition.ts rename to examples/react-router/editor-composition/compositions/editor/src/composition.ts index 9f6c6320..46af8f7a 100644 --- a/examples/react-router/editor-composition/app-shared/src/composition.ts +++ b/examples/react-router/editor-composition/compositions/editor/src/composition.ts @@ -1,10 +1,6 @@ -import { - createCompositionContext, - defineComposition, - defineCompositionHandle, -} from "@modular-react/compositions"; +import { defineComposition, defineCompositionHandle } from "@modular-react/compositions"; import type { ModuleDescriptor } from "@modular-react/core"; -import type { EditorState } from "./app-types.js"; +import type { EditorState } from "./state.js"; /** * Typed module map the composition references in its selectors. Modeled @@ -12,6 +8,14 @@ import type { EditorState } from "./app-types.js"; * `Record>` shape that `ModuleTypeMap` * declares — an `interface` with concrete keys is missing the implicit * string index signature `Record` requires. + * + * Kept loose (`ModuleDescriptor`) on purpose so the composition + * package does not import panel-module types — modules depend on this + * package for typed hooks; importing them back would create a cycle. For + * strong per-`(module, entry)` input type-checking, the composition can + * declare a concrete `import type` map (mirroring how journey definitions + * import each module). See the package README's "Composition zones vs + * `module.zones`" section for the trade-off. */ type EditorModuleMap = { readonly editor: ModuleDescriptor; @@ -68,10 +72,3 @@ export const editorComposition = defineComposition export const editorCompositionHandle = defineCompositionHandle<"editor", { documentId: string }>({ id: "editor", }); - -/** - * Pre-typed hook bundle so foreign panel modules don't have to spell - * `` at every call site. Export from this single place so - * panels stay consistent across the codebase. - */ -export const createEditorHooks = () => createCompositionContext(); diff --git a/examples/react-router/editor-composition/compositions/editor/src/hooks.ts b/examples/react-router/editor-composition/compositions/editor/src/hooks.ts new file mode 100644 index 00000000..a2130266 --- /dev/null +++ b/examples/react-router/editor-composition/compositions/editor/src/hooks.ts @@ -0,0 +1,10 @@ +import { createCompositionContext } from "@modular-react/compositions"; +import type { EditorState } from "./state.js"; + +/** + * Pre-typed hook bundle so foreign panel modules don't have to spell + * `` at every call site. Co-located with the composition + * definition (not in `app-shared`) so the composition team owns its full + * contract — state shape + hooks + runtime definition — in one package. + */ +export const createEditorHooks = () => createCompositionContext(); diff --git a/examples/react-router/editor-composition/compositions/editor/src/index.ts b/examples/react-router/editor-composition/compositions/editor/src/index.ts new file mode 100644 index 00000000..be0b3aac --- /dev/null +++ b/examples/react-router/editor-composition/compositions/editor/src/index.ts @@ -0,0 +1,3 @@ +export { editorComposition, editorCompositionHandle } from "./composition.js"; +export type { EditorState, SourceId } from "./state.js"; +export { createEditorHooks } from "./hooks.js"; diff --git a/examples/react-router/editor-composition/compositions/editor/src/state.ts b/examples/react-router/editor-composition/compositions/editor/src/state.ts new file mode 100644 index 00000000..2aec943c --- /dev/null +++ b/examples/react-router/editor-composition/compositions/editor/src/state.ts @@ -0,0 +1,17 @@ +/** Id of a source-integration panel hosted in the composition's `source` zone. */ +export type SourceId = "contentful" | "strapi"; + +/** + * The composition's scoped store. The `main` zone always renders the + * editor; the `source` zone projects `activeSource` → Contentful / Strapi + * / empty; the `inspector` zone projects `selectedSourceItem` → details. + * + * Lives in the composition package — owned by the composition team, not + * the shell team. Panel modules depend on this package when they + * participate in the composition; they do not see it through `app-shared`. + */ +export interface EditorState { + readonly documentId: string; + readonly activeSource: SourceId | null; + readonly selectedSourceItem: string | null; +} diff --git a/examples/react-router/editor-composition/compositions/editor/tsconfig.json b/examples/react-router/editor-composition/compositions/editor/tsconfig.json new file mode 100644 index 00000000..8a223dcd --- /dev/null +++ b/examples/react-router/editor-composition/compositions/editor/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/react-router/editor-composition/modules/contentful/package.json b/examples/react-router/editor-composition/modules/contentful/package.json index 745c1f0c..9f7524c5 100644 --- a/examples/react-router/editor-composition/modules/contentful/package.json +++ b/examples/react-router/editor-composition/modules/contentful/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@example-rr-editor-composition/app-shared": "workspace:*", + "@example-rr-editor-composition/editor-composition": "workspace:*", "@modular-react/compositions": "workspace:*", "@modular-react/core": "workspace:*" }, diff --git a/examples/react-router/editor-composition/modules/contentful/src/index.tsx b/examples/react-router/editor-composition/modules/contentful/src/index.tsx index 8bab4200..1147ae4d 100644 --- a/examples/react-router/editor-composition/modules/contentful/src/index.tsx +++ b/examples/react-router/editor-composition/modules/contentful/src/index.tsx @@ -1,5 +1,8 @@ import { defineEntry, defineModule, schema } from "@modular-react/core"; -import { createEditorHooks, type EditorState } from "@example-rr-editor-composition/app-shared"; +import { + createEditorHooks, + type EditorState, +} from "@example-rr-editor-composition/editor-composition"; const { useState: useEditorState, useDispatch: useEditorDispatch } = createEditorHooks(); diff --git a/examples/react-router/editor-composition/modules/editor/package.json b/examples/react-router/editor-composition/modules/editor/package.json index 41c7fdce..2426dba2 100644 --- a/examples/react-router/editor-composition/modules/editor/package.json +++ b/examples/react-router/editor-composition/modules/editor/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@example-rr-editor-composition/app-shared": "workspace:*", + "@example-rr-editor-composition/editor-composition": "workspace:*", "@modular-react/compositions": "workspace:*", "@modular-react/core": "workspace:*" }, diff --git a/examples/react-router/editor-composition/modules/editor/src/index.tsx b/examples/react-router/editor-composition/modules/editor/src/index.tsx index b3f77eea..daad886c 100644 --- a/examples/react-router/editor-composition/modules/editor/src/index.tsx +++ b/examples/react-router/editor-composition/modules/editor/src/index.tsx @@ -3,7 +3,7 @@ import { createEditorHooks, type EditorState, type SourceId, -} from "@example-rr-editor-composition/app-shared"; +} from "@example-rr-editor-composition/editor-composition"; const { useState: useEditorState, useDispatch: useEditorDispatch } = createEditorHooks(); diff --git a/examples/react-router/editor-composition/modules/strapi/package.json b/examples/react-router/editor-composition/modules/strapi/package.json index f0b643f9..27027a86 100644 --- a/examples/react-router/editor-composition/modules/strapi/package.json +++ b/examples/react-router/editor-composition/modules/strapi/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@example-rr-editor-composition/app-shared": "workspace:*", + "@example-rr-editor-composition/editor-composition": "workspace:*", "@modular-react/compositions": "workspace:*", "@modular-react/core": "workspace:*" }, diff --git a/examples/react-router/editor-composition/modules/strapi/src/index.tsx b/examples/react-router/editor-composition/modules/strapi/src/index.tsx index d97672e3..2b2f4353 100644 --- a/examples/react-router/editor-composition/modules/strapi/src/index.tsx +++ b/examples/react-router/editor-composition/modules/strapi/src/index.tsx @@ -1,5 +1,8 @@ import { defineEntry, defineModule, schema } from "@modular-react/core"; -import { createEditorHooks, type EditorState } from "@example-rr-editor-composition/app-shared"; +import { + createEditorHooks, + type EditorState, +} from "@example-rr-editor-composition/editor-composition"; const { useState: useEditorState, useDispatch: useEditorDispatch } = createEditorHooks(); diff --git a/examples/react-router/editor-composition/shell/package.json b/examples/react-router/editor-composition/shell/package.json index 194efad4..cc34703b 100644 --- a/examples/react-router/editor-composition/shell/package.json +++ b/examples/react-router/editor-composition/shell/package.json @@ -14,6 +14,7 @@ "@example-rr-editor-composition/app-shared": "workspace:*", "@example-rr-editor-composition/contentful": "workspace:*", "@example-rr-editor-composition/editor": "workspace:*", + "@example-rr-editor-composition/editor-composition": "workspace:*", "@example-rr-editor-composition/strapi": "workspace:*", "@modular-react/compositions": "workspace:*", "@modular-react/core": "workspace:*", diff --git a/examples/react-router/editor-composition/shell/src/main.tsx b/examples/react-router/editor-composition/shell/src/main.tsx index 1bc6b35e..e014af32 100644 --- a/examples/react-router/editor-composition/shell/src/main.tsx +++ b/examples/react-router/editor-composition/shell/src/main.tsx @@ -4,7 +4,7 @@ import { compositionsPlugin } from "@modular-react/compositions"; import editorModule from "@example-rr-editor-composition/editor"; import contentfulModule from "@example-rr-editor-composition/contentful"; import strapiModule from "@example-rr-editor-composition/strapi"; -import { editorComposition } from "@example-rr-editor-composition/app-shared"; +import { editorComposition } from "@example-rr-editor-composition/editor-composition"; import type { AppDependencies, AppSlots } from "@example-rr-editor-composition/app-shared"; import { Layout } from "./components/Layout.js"; diff --git a/examples/tanstack-router/editor-composition/README.md b/examples/tanstack-router/editor-composition/README.md index 224ba2e3..a3c85654 100644 --- a/examples/tanstack-router/editor-composition/README.md +++ b/examples/tanstack-router/editor-composition/README.md @@ -7,3 +7,16 @@ pnpm --filter @example-tsr-editor-composition/shell dev ``` Opens `http://localhost:5196`. + +## Layout + +```text +app-shared/ — contract panels consume (state types, branded ids, typed hooks) +compositions/ + editor/ — composition definition + typed handle (depends on app-shared) +modules/ — editor / contentful / strapi panel modules (depend on app-shared) +shell/ — registry, root route, CompositionOutlet wiring, e2e +``` + +Same module-layout argument as the RR sibling: panel modules depend on `app-shared` +only, not on the composition definition package. diff --git a/examples/tanstack-router/editor-composition/app-shared/package.json b/examples/tanstack-router/editor-composition/app-shared/package.json index 97e0763f..28aa67c5 100644 --- a/examples/tanstack-router/editor-composition/app-shared/package.json +++ b/examples/tanstack-router/editor-composition/app-shared/package.json @@ -14,16 +14,7 @@ "scripts": { "typecheck": "tsc --noEmit" }, - "dependencies": { - "@modular-react/compositions": "workspace:*", - "@modular-react/core": "workspace:*" - }, "devDependencies": { - "@types/react": "^19.0.0", - "react": "^19.0.0", "typescript": "^6.0.2" - }, - "peerDependencies": { - "react": "^19.0.0" } } diff --git a/examples/tanstack-router/editor-composition/app-shared/src/app-types.ts b/examples/tanstack-router/editor-composition/app-shared/src/app-types.ts index 72388355..99fa5ec1 100644 --- a/examples/tanstack-router/editor-composition/app-shared/src/app-types.ts +++ b/examples/tanstack-router/editor-composition/app-shared/src/app-types.ts @@ -1,7 +1,8 @@ /** * Shared registry dependencies for the editor-composition example. Mirror * of the React Router sibling — no router-specific types live here, so the - * file can stay identical between the two shells. + * file can stay identical between the two shells. Composition-specific + * state lives in `compositions/editor/`. */ export interface AppDependencies { readonly auth: { readonly userId: string }; @@ -11,13 +12,3 @@ export interface AppDependencies { export interface AppSlots { readonly commands: readonly { readonly id: string; readonly label: string }[]; } - -/** Id of a source-integration panel hosted in the composition's `source` zone. */ -export type SourceId = "contentful" | "strapi"; - -/** Composition's scoped store — see RR sibling's README for the layout. */ -export interface EditorState { - readonly documentId: string; - readonly activeSource: SourceId | null; - readonly selectedSourceItem: string | null; -} diff --git a/examples/tanstack-router/editor-composition/app-shared/src/index.ts b/examples/tanstack-router/editor-composition/app-shared/src/index.ts index e24bff4c..84bf110a 100644 --- a/examples/tanstack-router/editor-composition/app-shared/src/index.ts +++ b/examples/tanstack-router/editor-composition/app-shared/src/index.ts @@ -1,2 +1 @@ -export type { AppDependencies, AppSlots, EditorState, SourceId } from "./app-types.js"; -export { editorComposition, editorCompositionHandle, createEditorHooks } from "./composition.js"; +export type { AppDependencies, AppSlots } from "./app-types.js"; diff --git a/examples/tanstack-router/editor-composition/compositions/editor/package.json b/examples/tanstack-router/editor-composition/compositions/editor/package.json new file mode 100644 index 00000000..007b4fe0 --- /dev/null +++ b/examples/tanstack-router/editor-composition/compositions/editor/package.json @@ -0,0 +1,24 @@ +{ + "name": "@example-tsr-editor-composition/editor-composition", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modular-react/compositions": "workspace:*", + "@modular-react/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^6.0.2" + } +} diff --git a/examples/tanstack-router/editor-composition/app-shared/src/composition.ts b/examples/tanstack-router/editor-composition/compositions/editor/src/composition.ts similarity index 84% rename from examples/tanstack-router/editor-composition/app-shared/src/composition.ts rename to examples/tanstack-router/editor-composition/compositions/editor/src/composition.ts index c4da61b2..df6b85a3 100644 --- a/examples/tanstack-router/editor-composition/app-shared/src/composition.ts +++ b/examples/tanstack-router/editor-composition/compositions/editor/src/composition.ts @@ -1,10 +1,6 @@ -import { - createCompositionContext, - defineComposition, - defineCompositionHandle, -} from "@modular-react/compositions"; +import { defineComposition, defineCompositionHandle } from "@modular-react/compositions"; import type { ModuleDescriptor } from "@modular-react/core"; -import type { EditorState } from "./app-types.js"; +import type { EditorState } from "./state.js"; /** See RR sibling for rationale on `type` vs `interface`. */ type EditorModuleMap = { @@ -55,5 +51,3 @@ export const editorComposition = defineComposition export const editorCompositionHandle = defineCompositionHandle<"editor", { documentId: string }>({ id: "editor", }); - -export const createEditorHooks = () => createCompositionContext(); diff --git a/examples/tanstack-router/editor-composition/compositions/editor/src/hooks.ts b/examples/tanstack-router/editor-composition/compositions/editor/src/hooks.ts new file mode 100644 index 00000000..9fd2493b --- /dev/null +++ b/examples/tanstack-router/editor-composition/compositions/editor/src/hooks.ts @@ -0,0 +1,5 @@ +import { createCompositionContext } from "@modular-react/compositions"; +import type { EditorState } from "./state.js"; + +/** See RR sibling for the contract-co-location rationale. */ +export const createEditorHooks = () => createCompositionContext(); diff --git a/examples/tanstack-router/editor-composition/compositions/editor/src/index.ts b/examples/tanstack-router/editor-composition/compositions/editor/src/index.ts new file mode 100644 index 00000000..be0b3aac --- /dev/null +++ b/examples/tanstack-router/editor-composition/compositions/editor/src/index.ts @@ -0,0 +1,3 @@ +export { editorComposition, editorCompositionHandle } from "./composition.js"; +export type { EditorState, SourceId } from "./state.js"; +export { createEditorHooks } from "./hooks.js"; diff --git a/examples/tanstack-router/editor-composition/compositions/editor/src/state.ts b/examples/tanstack-router/editor-composition/compositions/editor/src/state.ts new file mode 100644 index 00000000..e94481df --- /dev/null +++ b/examples/tanstack-router/editor-composition/compositions/editor/src/state.ts @@ -0,0 +1,8 @@ +export type SourceId = "contentful" | "strapi"; + +/** See RR sibling for ownership-split rationale. */ +export interface EditorState { + readonly documentId: string; + readonly activeSource: SourceId | null; + readonly selectedSourceItem: string | null; +} diff --git a/examples/tanstack-router/editor-composition/compositions/editor/tsconfig.json b/examples/tanstack-router/editor-composition/compositions/editor/tsconfig.json new file mode 100644 index 00000000..8a223dcd --- /dev/null +++ b/examples/tanstack-router/editor-composition/compositions/editor/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["src"] +} diff --git a/examples/tanstack-router/editor-composition/modules/contentful/package.json b/examples/tanstack-router/editor-composition/modules/contentful/package.json index 1b754c35..df7b282e 100644 --- a/examples/tanstack-router/editor-composition/modules/contentful/package.json +++ b/examples/tanstack-router/editor-composition/modules/contentful/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@example-tsr-editor-composition/app-shared": "workspace:*", + "@example-tsr-editor-composition/editor-composition": "workspace:*", "@modular-react/compositions": "workspace:*", "@modular-react/core": "workspace:*" }, diff --git a/examples/tanstack-router/editor-composition/modules/contentful/src/index.tsx b/examples/tanstack-router/editor-composition/modules/contentful/src/index.tsx index f94879d8..a05da57f 100644 --- a/examples/tanstack-router/editor-composition/modules/contentful/src/index.tsx +++ b/examples/tanstack-router/editor-composition/modules/contentful/src/index.tsx @@ -1,5 +1,8 @@ import { defineEntry, defineModule, schema } from "@modular-react/core"; -import { createEditorHooks, type EditorState } from "@example-tsr-editor-composition/app-shared"; +import { + createEditorHooks, + type EditorState, +} from "@example-tsr-editor-composition/editor-composition"; const { useState: useEditorState, useDispatch: useEditorDispatch } = createEditorHooks(); diff --git a/examples/tanstack-router/editor-composition/modules/editor/package.json b/examples/tanstack-router/editor-composition/modules/editor/package.json index a9de8a59..2bd663aa 100644 --- a/examples/tanstack-router/editor-composition/modules/editor/package.json +++ b/examples/tanstack-router/editor-composition/modules/editor/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@example-tsr-editor-composition/app-shared": "workspace:*", + "@example-tsr-editor-composition/editor-composition": "workspace:*", "@modular-react/compositions": "workspace:*", "@modular-react/core": "workspace:*" }, diff --git a/examples/tanstack-router/editor-composition/modules/editor/src/index.tsx b/examples/tanstack-router/editor-composition/modules/editor/src/index.tsx index 1f1d1fb9..e78b0190 100644 --- a/examples/tanstack-router/editor-composition/modules/editor/src/index.tsx +++ b/examples/tanstack-router/editor-composition/modules/editor/src/index.tsx @@ -3,7 +3,7 @@ import { createEditorHooks, type EditorState, type SourceId, -} from "@example-tsr-editor-composition/app-shared"; +} from "@example-tsr-editor-composition/editor-composition"; const { useState: useEditorState, useDispatch: useEditorDispatch } = createEditorHooks(); diff --git a/examples/tanstack-router/editor-composition/modules/strapi/package.json b/examples/tanstack-router/editor-composition/modules/strapi/package.json index 4ead7a32..89d01520 100644 --- a/examples/tanstack-router/editor-composition/modules/strapi/package.json +++ b/examples/tanstack-router/editor-composition/modules/strapi/package.json @@ -15,7 +15,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@example-tsr-editor-composition/app-shared": "workspace:*", + "@example-tsr-editor-composition/editor-composition": "workspace:*", "@modular-react/compositions": "workspace:*", "@modular-react/core": "workspace:*" }, diff --git a/examples/tanstack-router/editor-composition/modules/strapi/src/index.tsx b/examples/tanstack-router/editor-composition/modules/strapi/src/index.tsx index bc599323..4fa6edc0 100644 --- a/examples/tanstack-router/editor-composition/modules/strapi/src/index.tsx +++ b/examples/tanstack-router/editor-composition/modules/strapi/src/index.tsx @@ -1,5 +1,8 @@ import { defineEntry, defineModule, schema } from "@modular-react/core"; -import { createEditorHooks, type EditorState } from "@example-tsr-editor-composition/app-shared"; +import { + createEditorHooks, + type EditorState, +} from "@example-tsr-editor-composition/editor-composition"; const { useState: useEditorState, useDispatch: useEditorDispatch } = createEditorHooks(); diff --git a/examples/tanstack-router/editor-composition/shell/package.json b/examples/tanstack-router/editor-composition/shell/package.json index 3c346d35..d5190b9b 100644 --- a/examples/tanstack-router/editor-composition/shell/package.json +++ b/examples/tanstack-router/editor-composition/shell/package.json @@ -14,6 +14,7 @@ "@example-tsr-editor-composition/app-shared": "workspace:*", "@example-tsr-editor-composition/contentful": "workspace:*", "@example-tsr-editor-composition/editor": "workspace:*", + "@example-tsr-editor-composition/editor-composition": "workspace:*", "@example-tsr-editor-composition/strapi": "workspace:*", "@modular-react/compositions": "workspace:*", "@modular-react/core": "workspace:*", diff --git a/examples/tanstack-router/editor-composition/shell/src/main.tsx b/examples/tanstack-router/editor-composition/shell/src/main.tsx index 1c493e03..9ce878d9 100644 --- a/examples/tanstack-router/editor-composition/shell/src/main.tsx +++ b/examples/tanstack-router/editor-composition/shell/src/main.tsx @@ -4,7 +4,7 @@ import { compositionsPlugin } from "@modular-react/compositions"; import editorModule from "@example-tsr-editor-composition/editor"; import contentfulModule from "@example-tsr-editor-composition/contentful"; import strapiModule from "@example-tsr-editor-composition/strapi"; -import { editorComposition } from "@example-tsr-editor-composition/app-shared"; +import { editorComposition } from "@example-tsr-editor-composition/editor-composition"; import type { AppDependencies, AppSlots } from "@example-tsr-editor-composition/app-shared"; import { Layout } from "./components/Layout.js"; diff --git a/packages/compositions/README.md b/packages/compositions/README.md index bbac9036..2e556ddc 100644 --- a/packages/compositions/README.md +++ b/packages/compositions/README.md @@ -268,6 +268,23 @@ Pass typed hooks down per-composition with the `createCompositionContext ## Core concepts +### Composition zones vs `module.zones` + +The framework has two distinct primitives that both use the word "zone." They are unrelated. + +| | `module.zones` (existing) | composition zones (this package) | +|---|---|---| +| Declared by | `defineModule({ zones: { ... } })` and the router's route `staticData` | `defineComposition({ zones: { ... } })` | +| Populated by | The *active route's* module — at most one component per zone | The composition's per-zone `select(ctx)`, which can target any registered module | +| Cardinality | One contribution per zone at a time (most-recent active wins) | Many panels mounted in parallel, one per declared zone | +| Layout owner | The shell — `useZones` / `useActiveZones` read the merged map | The host — ``'s render-prop arranges zones however it wants | +| Authoring shape | String → React component | String → `select(ctx) → ZoneResolution` | +| Use when | A module needs to contribute a header chip, command, or one-shot slot to the active screen | Several modules need to render side-by-side on a single screen with shared coordination state | + +A composition does **not** participate in `module.zones`. The shell's `useZones`/`useActiveZones` will not see anything from a ``. The two systems are orthogonal — a screen can use both at once (e.g., a route uses `module.zones` for the header chip + a `` for the multi-panel body). + +Inside a composition zone, **the composition definition owns the zone name and the selector** (what renders here, driven by state); **the host owns the layout** (where the zone appears on screen). The framework wraps each zone in `` + a per-zone error boundary before handing the `ReactNode` to the host's render-prop. + ### Zones A zone is a named projection of state into one of three resolutions, declared per-render by the zone's `select(ctx)`: diff --git a/packages/compositions/src/types.ts b/packages/compositions/src/types.ts index ed587335..fbfc7b50 100644 --- a/packages/compositions/src/types.ts +++ b/packages/compositions/src/types.ts @@ -1,5 +1,7 @@ import type { CatalogMeta, + EntryInputOf, + EntryNamesOf, ExitContract, JourneyHandleRef, ModuleTypeMap, @@ -21,8 +23,7 @@ export type CompositionStatus = "active" | "disposed"; // --------------------------------------------------------------------------- /** - * The discriminated union a zone's selector returns on every state change. - * Three arms: + * Runtime form of what a zone selector returns. Three arms: * * - `module-entry`: render the named module's entry point. The runtime * looks the entry up in the registry-supplied module map and feeds @@ -38,7 +39,9 @@ export type CompositionStatus = "active" | "disposed"; * * `TModules` keeps `module` constrained to ids that participate in the * composition's typed module map, so a typo or a module that isn't - * registered fails at compile time. + * registered fails at compile time. For *strong* per-`(module, entry)` + * input checking — i.e. selectors that fail compile when `input` doesn't + * match the target entry's declared schema — see {@link ZoneSpec}. */ export type ZoneResolution = | { @@ -55,6 +58,69 @@ export type ZoneResolution = } | { readonly kind: "empty" }; +/** + * Internal helper: detects `any` at the type level so {@link ZoneSpec} can + * fall back to the loose {@link ZoneResolution} shape when no concrete + * `TModules` is supplied. Without this gate, the mapped-type expansion + * collapses `module-entry` to `never` (a known TS quirk with `any` in + * mapped-key positions) and breaks the framework's own + * `ZoneSelector` paths. + */ +type IsAny = 0 extends 1 & T ? true : false; + +/** + * Strong `module-entry` arm — discriminated union over every reachable + * `(module, entry)` pair in `TModules`, with `input` narrowed to the + * target entry's declared schema. Falls back to the loose runtime shape + * when `TModules` is `any` so internal generic-erased paths + * (`ZoneSelector` etc.) keep type-checking. + */ +type ModuleEntryArm = + IsAny extends true + ? { + readonly kind: "module-entry"; + readonly module: string; + readonly entry: string; + readonly input?: unknown; + } + : { + [M in keyof TModules & string]: { + [E in EntryNamesOf & string]: { + readonly kind: "module-entry"; + readonly module: M; + readonly entry: E; + readonly input: EntryInputOf; + }; + }[EntryNamesOf & string]; + }[keyof TModules & string]; + +/** + * Author-facing zone specification — strong per-`(module, entry)` + * discriminated union for the `module-entry` arm. Mirrors + * `StepSpec` from `@modular-react/journeys`: narrowing on + * `module` + `entry` picks the corresponding `input` type, so a selector + * that returns a wrong-shaped input fails at compile time. + * + * Selectors authored against {@link ZoneSelector} return this form; + * the runtime stores resolutions in the looser {@link ZoneResolution} + * shape so internal paths don't pay per-generic mapped-type cost. + * + * Authors who want input type-checking must supply concrete module + * descriptors in their `TModules` type — typically via `import type + * editorModule from "@my-org/editor"` and a `typeof` map. With a wide + * `TModules` (default or `any`), `ZoneSpec` falls back to the loose + * resolution shape via the {@link ModuleEntryArm} fallback. + */ +export type ZoneSpec = + | ModuleEntryArm + | { + readonly kind: "journey"; + readonly handle: JourneyHandleRef; + readonly input?: unknown; + readonly instanceId?: CompositionInstanceId; + } + | { readonly kind: "empty" }; + /** * Snapshot of the runtime context passed to every zone selector. `state` * is the composition's current state; `deps` is the shared-dependency @@ -68,10 +134,15 @@ export interface ZoneSelectorCtx { readonly deps: Readonly>; } -/** Pure projection of composition state into a zone resolution. */ +/** + * Pure projection of composition state into a zone resolution. Returns + * {@link ZoneSpec} (strong per-`(module, entry)` typing) so wrong-shaped + * `input` fails at compile time when `TModules` is concrete; assignable + * to {@link ZoneResolution} for runtime storage. + */ export type ZoneSelector = ( ctx: ZoneSelectorCtx, -) => ZoneResolution; +) => ZoneSpec; /** * Author-facing zone descriptor. The author registers `select` (mandatory) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10764ac3..9d2ff17d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -308,29 +308,29 @@ importers: version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0) examples/react-router/editor-composition/app-shared: + devDependencies: + typescript: + specifier: ^6.0.2 + version: 6.0.3 + + examples/react-router/editor-composition/compositions/editor: dependencies: '@modular-react/compositions': specifier: workspace:* - version: link:../../../../packages/compositions + version: link:../../../../../packages/compositions '@modular-react/core': specifier: workspace:* - version: link:../../../../packages/core + version: link:../../../../../packages/core devDependencies: - '@types/react': - specifier: ^19.0.0 - version: 19.2.14 - react: - specifier: ^19.0.0 - version: 19.2.6 typescript: specifier: ^6.0.2 version: 6.0.3 examples/react-router/editor-composition/modules/contentful: dependencies: - '@example-rr-editor-composition/app-shared': + '@example-rr-editor-composition/editor-composition': specifier: workspace:* - version: link:../../app-shared + version: link:../../compositions/editor '@modular-react/compositions': specifier: workspace:* version: link:../../../../../packages/compositions @@ -350,9 +350,9 @@ importers: examples/react-router/editor-composition/modules/editor: dependencies: - '@example-rr-editor-composition/app-shared': + '@example-rr-editor-composition/editor-composition': specifier: workspace:* - version: link:../../app-shared + version: link:../../compositions/editor '@modular-react/compositions': specifier: workspace:* version: link:../../../../../packages/compositions @@ -372,9 +372,9 @@ importers: examples/react-router/editor-composition/modules/strapi: dependencies: - '@example-rr-editor-composition/app-shared': + '@example-rr-editor-composition/editor-composition': specifier: workspace:* - version: link:../../app-shared + version: link:../../compositions/editor '@modular-react/compositions': specifier: workspace:* version: link:../../../../../packages/compositions @@ -403,6 +403,9 @@ importers: '@example-rr-editor-composition/editor': specifier: workspace:* version: link:../modules/editor + '@example-rr-editor-composition/editor-composition': + specifier: workspace:* + version: link:../compositions/editor '@example-rr-editor-composition/strapi': specifier: workspace:* version: link:../modules/strapi @@ -1236,29 +1239,29 @@ importers: version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(jiti@2.7.0) examples/tanstack-router/editor-composition/app-shared: + devDependencies: + typescript: + specifier: ^6.0.2 + version: 6.0.3 + + examples/tanstack-router/editor-composition/compositions/editor: dependencies: '@modular-react/compositions': specifier: workspace:* - version: link:../../../../packages/compositions + version: link:../../../../../packages/compositions '@modular-react/core': specifier: workspace:* - version: link:../../../../packages/core + version: link:../../../../../packages/core devDependencies: - '@types/react': - specifier: ^19.0.0 - version: 19.2.14 - react: - specifier: ^19.0.0 - version: 19.2.6 typescript: specifier: ^6.0.2 version: 6.0.3 examples/tanstack-router/editor-composition/modules/contentful: dependencies: - '@example-tsr-editor-composition/app-shared': + '@example-tsr-editor-composition/editor-composition': specifier: workspace:* - version: link:../../app-shared + version: link:../../compositions/editor '@modular-react/compositions': specifier: workspace:* version: link:../../../../../packages/compositions @@ -1278,9 +1281,9 @@ importers: examples/tanstack-router/editor-composition/modules/editor: dependencies: - '@example-tsr-editor-composition/app-shared': + '@example-tsr-editor-composition/editor-composition': specifier: workspace:* - version: link:../../app-shared + version: link:../../compositions/editor '@modular-react/compositions': specifier: workspace:* version: link:../../../../../packages/compositions @@ -1300,9 +1303,9 @@ importers: examples/tanstack-router/editor-composition/modules/strapi: dependencies: - '@example-tsr-editor-composition/app-shared': + '@example-tsr-editor-composition/editor-composition': specifier: workspace:* - version: link:../../app-shared + version: link:../../compositions/editor '@modular-react/compositions': specifier: workspace:* version: link:../../../../../packages/compositions @@ -1331,6 +1334,9 @@ importers: '@example-tsr-editor-composition/editor': specifier: workspace:* version: link:../modules/editor + '@example-tsr-editor-composition/editor-composition': + specifier: workspace:* + version: link:../compositions/editor '@example-tsr-editor-composition/strapi': specifier: workspace:* version: link:../modules/strapi diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b2f293a6..3bd9fcbe 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ packages: - "examples/*/*/shell" - "examples/*/*/modules/*" - "examples/*/*/journeys/*" + - "examples/*/*/compositions/*" allowBuilds: esbuild: false msw: false From 97b5b28c831899ea7281aca424565f06342d7c3c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 08:36:29 +0000 Subject: [PATCH 2/4] feat(compositions,react): add dispatch to ZoneSelectorCtx; support React.memo on entry components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two framework additions that together let compositions cleanly route state changes through selector-built callbacks while keeping panels performant: 1. `ZoneSelectorCtx.dispatch` — bound to the active composition instance, referentially stable per instance. Selectors that want to keep panel modules composition-unaware can mix dispatch-based callbacks into the panel's `input` (`onSelect: (id) => dispatch({ ... })`) instead of requiring panels to call `useCompositionDispatch`. Three call sites in the outlet are updated: the render path passes the memoized per-instance dispatch (line 479 useCallback); the two preload paths pass a shared `noopDispatch` since preload only inspects `module`/`entry`. 2. `resolveEntryComponent` accepts `React.memo` and `React.forwardRef` wrappers. The previous `typeof === "function"` gate forced panels into a re-render storm whenever the composition's parent re-rendered (input ref churns even when slice values don't). With memo support, panel authors can pair a custom prop-comparator with selector-built callbacks (stable per instance) to get slice-level update granularity without using composition hooks. Disambiguation between an eager `component` and a lazy `lazy` importer is now by field presence, not by `typeof`, since memo'd components are objects. Documents both in the package README. The "Comparison with journeys" section expands into a 3-way comparison covering `module.zones` as a sibling primitive — addresses the conflation between composition-zones (this package, parallel rendering driven by per-zone selectors) and `module.zones` (existing single-active-route slot extension read via `useZones`/`useActiveZones`). Drops the obsolete "React.memo and forwardRef'd entry components are not supported" limitation. Adds `src/selector-dispatch.test.tsx` covering the new ctx field — verifies dispatch reference stability across selector re-runs and that a panel-invoked callback drives state through the next render. 73/73 compositions tests pass; 68/68 react tests pass. --- packages/compositions/README.md | 41 +++-- packages/compositions/src/outlet.tsx | 36 +++- .../src/selector-dispatch.test.tsx | 167 ++++++++++++++++++ packages/compositions/src/types.ts | 21 ++- packages/react/src/resolve-entry.ts | 12 +- 5 files changed, 252 insertions(+), 25 deletions(-) create mode 100644 packages/compositions/src/selector-dispatch.test.tsx diff --git a/packages/compositions/README.md b/packages/compositions/README.md index 2e556ddc..84c407f6 100644 --- a/packages/compositions/README.md +++ b/packages/compositions/README.md @@ -884,22 +884,29 @@ The runtime defers disposal one microtask so React 18/19 StrictMode's mount/unmo - **No outlet-level error boundary.** The framework wraps each zone in its own boundary, not the entire outlet. A throw outside the zone-render path (e.g. from the host's render-prop body) is the host's responsibility. - **No built-in persistence.** Coordination state lives in memory; durable storage belongs in the application (URL params, app-level store). See the [persistence note](#a-note-on-persistence--there-is-none). - **Composition panels' `exit` prop is a no-op stub.** Foreign panels rendered inside a composition zone cannot deliver exits to the host's exit dispatcher. Use `dispatch` for state changes and `emit` for cross-zone events instead. (Journeys hosted inside a zone deliver exits through the journey runtime normally.) -- **`React.memo` and `forwardRef`'d entry components are not supported.** `resolveEntryComponent` in `@modular-react/react` requires `typeof entry.component === "function"`. Panels can still memoize internally via `useMemo` / `useCallback`. - **Cycle detection is partial across the journey ↔ composition boundary.** Same-instance recursion is caught; recursion through two different instances of the same definition is not. See [Cycle safety](#cycle-safety). -## Comparison with journeys - -| | `@modular-react/compositions` | `@modular-react/journeys` | -| ------------------------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------ | -| **Primary use** | Multi-module screen layout with shared state | Multi-module stepped workflow with typed transitions | -| **State model** | Scoped store; selectors project state into zones | Step + accumulated state; transitions advance step | -| **Flow** | No graph — any state can produce any resolution | Directed graph of `(step, exit) → next step` | -| **Authoring shape** | `defineComposition({ zones, initialState })` | `defineJourney({ start, transitions })` | -| **Instance id prefix** | `ci_*` | `ji_*` | -| **Persistence** | None — keep durable coordination state in the application layer | First-class adapter (`JourneyPersistence`) with versioned blobs | -| **Hooks inside panels** | `useCompositionState/Dispatch/Emit/Zone` | `useJourneyState`, `useJourneyInstance`, `useJourneyCallStack` | -| **Outlet** | `CompositionOutlet` (render-prop, multi-zone) | `JourneyOutlet` (single step, leaf-walk) | -| **Validation** | Zone contracts (spot-check) + `moduleCompat` | Reachability + transition exhaustiveness + contracts | -| **Composition with the other** | A zone can mount `` via `kind: "journey"` | A journey step can render `` like any other component | - -Choose **compositions** when the screen is a layout problem (which modules go where, sharing state). Choose **journeys** when the screen is a flow problem (do A, then B, then maybe C). They're complementary, not competing. +## Comparison with sibling primitives + +Three primitives in the framework arrange modules on a screen. Pick by problem shape: extending one screen vs. coordinating several modules in parallel vs. driving a stepped flow. + +| | `module.zones` (route-level) | `@modular-react/compositions` (this package) | `@modular-react/journeys` | +| ------------------------------ | --------------------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------ | +| **Primary use** | A foreign module contributes a single component to a named slot on the active route | Multi-module screen layout with shared state, rendered in parallel | Multi-module stepped workflow with typed transitions | +| **Cardinality** | One contribution per zone (most-recent active wins) | N panels mounted simultaneously, one per declared zone | One step rendered at a time | +| **Declared by** | `defineModule({ zones })` + route `staticData` | `defineComposition({ zones })` | `defineJourney({ start, transitions })` | +| **State model** | None — slots map id → component | Scoped store; selectors project state into zones | Step + accumulated state; transitions advance step | +| **Flow** | Static contribution | No graph — any state can produce any resolution | Directed graph of `(step, exit) → next step` | +| **Read in shell** | `useZones` / `useActiveZones` | `` render-prop with zone names | `` (leaf-walk through current step) | +| **Instance id prefix** | n/a | `ci_*` | `ji_*` | +| **Persistence** | n/a | None — keep durable coordination state in the application layer | First-class adapter (`JourneyPersistence`) with versioned blobs | +| **Hooks inside panels** | n/a | `useCompositionState/Dispatch/Emit/Zone` | `useJourneyState`, `useJourneyInstance`, `useJourneyCallStack` | +| **Validation** | Slot-name + route lookup | Zone contracts (spot-check) + `moduleCompat` | Reachability + transition exhaustiveness + contracts | +| **Composition with the other** | n/a | A zone can mount `` via `kind: "journey"` | A journey step can render `` like any other component | + +Choose by problem shape: +- **`module.zones`** when a route already exists and another module needs to contribute one widget (a header chip, a command, a sidebar entry) — the contribution is static and tied to the active route. +- **Compositions** when one screen layout coordinates several modules with **shared state** — multiple panels mounted in parallel, each reactive to a per-instance scoped store. +- **Journeys** when the screen is a **flow problem** — do A, then B, then maybe C, with typed handoffs between steps. + +The three are complementary, not competing. A screen can use all of them: a route hosting a `` whose `inspector` zone hosts a ``, while the route itself contributes a `module.zones` chip to the shell header. diff --git a/packages/compositions/src/outlet.tsx b/packages/compositions/src/outlet.tsx index bc79f5d3..af359472 100644 --- a/packages/compositions/src/outlet.tsx +++ b/packages/compositions/src/outlet.tsx @@ -135,6 +135,15 @@ function useInstanceSnapshot( * - Unhashable input (final catch-all) falls back to a typeof tag — * duplicates land in the same bucket, the conservative choice. */ +/** + * Dispatch placeholder for selector invocations on the preload path. + * Preload only reads `module`/`entry` off the resulting resolution; any + * `dispatch`-driven callbacks the selector bakes into `input` are never + * invoked from preload, so a stable no-op is correct here. Shared (not + * inlined) so identity-equality across preload runs doesn't fluctuate. + */ +const noopDispatch: (updater: unknown) => void = () => {}; + function hashInput(input: unknown): string { if (input === undefined) return "u"; try { @@ -270,7 +279,15 @@ export function CompositionOutlet( if (!descriptor) continue; let selection: ZoneResolution; try { - selection = descriptor.select({ state: instance.state, deps: internals.__deps }); + selection = descriptor.select({ + state: instance.state, + deps: internals.__deps, + // Preload paths only inspect `module`/`entry` on the + // resolution — they never invoke any callback the selector + // may have baked into `input`. A no-op dispatch is correct + // here. + dispatch: noopDispatch, + }); } catch { parts.push(`${zoneName}:err`); continue; @@ -297,7 +314,11 @@ export function CompositionOutlet( for (const { zoneName, descriptor } of eagerZones) { let selection: ZoneResolution; try { - selection = descriptor.select({ state: instance.state, deps: internals.__deps }); + selection = descriptor.select({ + state: instance.state, + deps: internals.__deps, + dispatch: noopDispatch, + }); } catch { // Selector errors are surfaced at render time; preload is // best-effort, so we swallow here. @@ -522,7 +543,16 @@ function ZoneRenderer(props: ZoneRendererProps): ReactNode { let selectorError: unknown = null; if (record && store && state !== null) { try { - selection = descriptor.select({ state: state as unknown, deps: internals.__deps }); + selection = descriptor.select({ + state: state as unknown, + deps: internals.__deps, + // Stable dispatch reference (memoized at line 479 keyed on + // `[runtime, instanceId]`). Callbacks the selector closes over + // this with stay referentially stable across re-renders, so + // `React.memo`'d panels can compare `input` deeply via a custom + // comparator without thrashing on identity churn. + dispatch, + }); } catch (err) { selectorError = err; } diff --git a/packages/compositions/src/selector-dispatch.test.tsx b/packages/compositions/src/selector-dispatch.test.tsx new file mode 100644 index 00000000..2ddefd9d --- /dev/null +++ b/packages/compositions/src/selector-dispatch.test.tsx @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { defineEntry, defineModule, schema } from "@modular-react/core"; +import type { ModuleEntryProps } from "@modular-react/core"; + +import { defineComposition } from "./define-composition.js"; +import { createCompositionRuntime } from "./runtime.js"; +import { CompositionOutlet } from "./outlet.js"; +import { CompositionsProvider } from "./provider.js"; +import type { RegisteredComposition } from "./types.js"; + +afterEach(() => { + cleanup(); +}); + +/** + * Tests for the prop-driven panel pattern: panels receive callbacks via + * `input` instead of using `useCompositionState`/`useCompositionDispatch`. + * The composition's selector closes over `ctx.dispatch` to wire panel + * callbacks back into composition state. Verifies: + * + * - `ctx.dispatch` is referentially stable across re-renders so + * `React.memo`'d panels can compare `input.onX` by identity. + * - Invoking the callback updates state; the next selector pass picks + * it up; the zone re-renders with the new value. + */ + +interface CounterState { + readonly count: number; +} + +interface CounterInput { + readonly count: number; + readonly onIncrement: () => void; +} + +function CounterPanel({ input }: ModuleEntryProps) { + return ( +
+ {input.count} + +
+ ); +} + +const counterModule = defineModule({ + id: "counter", + version: "1.0.0", + entryPoints: { + main: defineEntry({ + component: CounterPanel, + input: schema(), + }), + }, +}); + +type CounterModules = { readonly counter: typeof counterModule }; + +describe("ZoneSelectorCtx.dispatch", () => { + it("threads dispatch into panel input; panel-invoked callback updates state", async () => { + const composition = defineComposition()({ + id: "counter-composition", + version: "1.0.0", + initialState: () => ({ count: 0 }), + zones: { + main: { + select: ({ state, dispatch }) => ({ + kind: "module-entry", + module: "counter", + entry: "main", + input: { + count: state.count, + onIncrement: () => dispatch((prev) => ({ count: prev.count + 1 })), + }, + }), + }, + }, + }); + + const runtime = createCompositionRuntime( + [{ definition: composition, options: undefined } satisfies RegisteredComposition], + { modules: { counter: counterModule } }, + ); + const instanceId = runtime.start(composition.id, undefined); + + render( + + + {(zones) =>
{zones.main}
} +
+
, + ); + + expect((await screen.findByTestId("count")).textContent).toBe("0"); + + await act(async () => { + screen.getByTestId("increment").click(); + }); + + expect(screen.getByTestId("count").textContent).toBe("1"); + + await act(async () => { + screen.getByTestId("increment").click(); + screen.getByTestId("increment").click(); + }); + + expect(screen.getByTestId("count").textContent).toBe("3"); + }); + + it("dispatch reference is stable across selector calls in a single instance", () => { + // Capture dispatch references seen by the selector across multiple + // invocations triggered by state changes. + const dispatchSightings: Array<(updater: unknown) => void> = []; + + const composition = defineComposition()({ + id: "stable-dispatch", + version: "1.0.0", + initialState: () => ({ count: 0 }), + zones: { + main: { + select: ({ state, dispatch }) => { + dispatchSightings.push(dispatch as (updater: unknown) => void); + return { + kind: "module-entry", + module: "counter", + entry: "main", + input: { count: state.count, onIncrement: () => dispatch({ count: state.count + 1 }) }, + }; + }, + }, + }, + }); + + const runtime = createCompositionRuntime( + [{ definition: composition, options: undefined } satisfies RegisteredComposition], + { modules: { counter: counterModule } }, + ); + const instanceId = runtime.start(composition.id, undefined); + + render( + + + {(zones) =>
{zones.main}
} +
+
, + ); + + // Drive a few state changes — each one triggers a selector re-run. + act(() => { + runtime.dispatch(instanceId, { count: 1 }); + }); + act(() => { + runtime.dispatch(instanceId, { count: 2 }); + }); + + expect(dispatchSightings.length).toBeGreaterThanOrEqual(2); + // Every render-path selector invocation receives the same dispatch + // reference. (Preload-path invocations would see a no-op dispatch + // and are not exercised in this test because no zone is `eager`.) + const first = dispatchSightings[0]; + for (const seen of dispatchSightings) { + expect(seen).toBe(first); + } + }); +}); diff --git a/packages/compositions/src/types.ts b/packages/compositions/src/types.ts index fbfc7b50..88d52100 100644 --- a/packages/compositions/src/types.ts +++ b/packages/compositions/src/types.ts @@ -122,16 +122,29 @@ export type ZoneSpec = | { readonly kind: "empty" }; /** - * Snapshot of the runtime context passed to every zone selector. `state` - * is the composition's current state; `deps` is the shared-dependency - * snapshot captured from the registry at resolve time. + * Snapshot of the runtime context passed to every zone selector. + * + * - `state` — the composition's current state. + * - `deps` — the shared-dependency snapshot captured from the registry + * at resolve time. + * - `dispatch` — bound to the active instance, stable across renders. + * Selectors that build *prop-driven panel inputs* use this to wire + * callbacks into the panel's `input` (e.g. + * `onSelect: (id) => dispatch({ selectedItem: id })`) so the panel + * module stays composition-unaware. See the package README's + * "Authoring patterns — Prop-driven panels vs panel hooks" section. * * Selectors are pure functions — they MUST NOT mutate `state` or fire - * side effects. The runtime re-runs them on every state change. + * side effects. Calling `dispatch` *synchronously* from the selector + * body is a side effect and unsupported; only place callbacks that the + * panel may later invoke into the returned resolution's `input`. */ export interface ZoneSelectorCtx { readonly state: TState; readonly deps: Readonly>; + readonly dispatch: ( + updater: Partial | ((prev: TState) => Partial | TState), + ) => void; } /** diff --git a/packages/react/src/resolve-entry.ts b/packages/react/src/resolve-entry.ts index c3f809ea..cf24950e 100644 --- a/packages/react/src/resolve-entry.ts +++ b/packages/react/src/resolve-entry.ts @@ -55,7 +55,17 @@ export function resolveEntryComponent(entry: ModuleEntryPoint): ResolvedEnt const eager = (entry as EagerModuleEntryPoint).component; const importer = (entry as LazyModuleEntryPoint).lazy; - if (typeof eager === "function") { + // Accept any non-`undefined` component value here — plain function + // components (`typeof === "function"`), `React.memo(...)` wrappers + // (`typeof === "object"`, `$$typeof === REACT_MEMO_TYPE`), and + // `React.forwardRef(...)` (`typeof === "object"`, `$$typeof === + // REACT_FORWARD_REF_TYPE`) all live in the `component` field. The + // lazy branch below is the only place a function value can also be + // a *non*-component (an importer), so disambiguation is by field + // presence, not by `typeof`. `React.lazy(...)` is also accepted as + // an eager component because its result is a renderable React node; + // the framework will not call `.preload()` on it. + if (eager !== undefined) { resolved = { Component: eager as ComponentType>, preload: () => Promise.resolve(), From a94f7404c7758e2ed8ec4d7758208408d91ed643 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 08:59:55 +0000 Subject: [PATCH 3/4] feat(core,compositions): typed-store primitive for composition-unaware panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `ReadableStore` / `WritableStore` interfaces to `@modular-react/core` (aliasing the existing `ReactiveService` shape; `WritableStore` adds `set(value)`). Modules now have a way to declare "I need a subscribable T" without coupling to *whose* store provides it — composition, shell-level Zustand/Redux, test mock, etc. The structural interface is the contract. Adds `stores` to `ZoneSelectorCtx` — a per-instance store factory with two methods: - `stores.readable(key, get)` → `ReadableStore` - `stores.writable(key, { get, set })` → `WritableStore` Stores are stable per `(instance, key)`, so repeated calls with the same key — across selector re-runs or zones — return the same object. `useSyncExternalStore` in panels relies on this for subscription stability. A single shared snapshot + listener-fan-out is centralized per store; subscribers fire only on slice-level changes (`Object.is`), not on every composition state mutation. The runtime subscription is lazy (set up on first `subscribe` call) and torn down when the listener set drains. The outlet wires a real provider via `useMemo([runtime, instanceId])` on the render path and a `noopZoneStores` stub on the two preload paths (preload inspects `module`/`entry` only and never invokes anything baked into `input`). Refactors both editor-composition examples to demonstrate the pattern: - Panel modules import only `@modular-react/core` (for the store interfaces) and read via `useSyncExternalStore(store.subscribe, store.getSnapshot)` / write via `store.set(...)`. They have zero workspace deps on `compositions/editor`. - The composition imports panel module types (`import type ...`) and projects state into typed stores in its selectors: `stores.writable("activeSource", { get: s => s.activeSource, set: v => ({ activeSource: v }) })`. Dependency direction is now strictly one-way: `composition → modules`. No cycle — modules don't import the composition package. Adds `src/stores.test.ts` (5 tests) covering identity stability, slice-level filtering, multi-subscriber fan-out, and dispatch round-tripping. 78/78 compositions tests + 68/68 react tests pass. --- .../react-router/editor-composition/README.md | 35 ++-- .../compositions/editor/package.json | 3 + .../compositions/editor/src/composition.ts | 79 +++++--- .../compositions/editor/src/hooks.ts | 10 - .../compositions/editor/src/index.ts | 1 - .../modules/contentful/package.json | 2 - .../modules/contentful/src/index.tsx | 35 ++-- .../modules/editor/package.json | 2 - .../modules/editor/src/index.tsx | 74 ++++--- .../modules/strapi/package.json | 2 - .../modules/strapi/src/index.tsx | 28 +-- .../editor-composition/README.md | 9 +- .../compositions/editor/package.json | 3 + .../compositions/editor/src/composition.ts | 45 +++-- .../compositions/editor/src/hooks.ts | 5 - .../compositions/editor/src/index.ts | 1 - .../modules/contentful/package.json | 2 - .../modules/contentful/src/index.tsx | 28 +-- .../modules/editor/package.json | 2 - .../modules/editor/src/index.tsx | 64 +++--- .../modules/strapi/package.json | 2 - .../modules/strapi/src/index.tsx | 28 +-- packages/compositions/README.md | 125 ++++++++++++ packages/compositions/src/outlet.tsx | 38 +++- packages/compositions/src/stores.test.ts | 158 +++++++++++++++ packages/compositions/src/stores.ts | 187 ++++++++++++++++++ packages/compositions/src/types.ts | 25 ++- packages/core/src/index.ts | 2 + packages/core/src/types.ts | 32 +++ pnpm-lock.yaml | 54 ++--- 30 files changed, 841 insertions(+), 240 deletions(-) delete mode 100644 examples/react-router/editor-composition/compositions/editor/src/hooks.ts delete mode 100644 examples/tanstack-router/editor-composition/compositions/editor/src/hooks.ts create mode 100644 packages/compositions/src/stores.test.ts create mode 100644 packages/compositions/src/stores.ts diff --git a/examples/react-router/editor-composition/README.md b/examples/react-router/editor-composition/README.md index abdc339c..18526066 100644 --- a/examples/react-router/editor-composition/README.md +++ b/examples/react-router/editor-composition/README.md @@ -1,12 +1,12 @@ # Editor composition — React Router example -A multi-zone editor screen wired with [`@modular-react/compositions`](../../../packages/compositions/README.md). The composition owns a small scoped store (`documentId`, `activeIntegrationId`, `selectedSourceItem`) and projects it into three named zones: +A multi-zone editor screen wired with [`@modular-react/compositions`](../../../packages/compositions/README.md). The composition owns a small scoped store (`documentId`, `activeSource`, `selectedSourceItem`) and projects it into three named zones, exposing typed `WritableStore` / `ReadableStore` contracts to the panels: -- **`main`** — always renders the editor panel. -- **`source`** — toggles between Contentful, Strapi, or empty based on state. Foreign panels mutate the composition state via `useCompositionDispatch`. -- **`inspector`** — reads `selectedSourceItem` from the composition state and shows details about it. +- **`main`** — always renders the editor canvas. Receives `activeSource: WritableStore` so the editor can switch which integration mounts in the side panel. +- **`source`** — mounts Contentful, Strapi, or empty based on `activeSource`. Receives `selectedItem: WritableStore` so the panel can publish selections back to sibling zones. +- **`inspector`** — receives readable views of both stores and renders details about the current selection. -The three panel modules know nothing about the composition. Each is a regular `defineModule` with `entryPoints`; the composition wires them into zones at the layout level. +The three panel modules know **nothing** about the composition — they import only the structural `ReadableStore` / `WritableStore` interfaces from `@modular-react/core` and read state via `useSyncExternalStore`. Strict shell/composition/panel-team separation is structural: a panel module has zero workspace deps on `compositions/editor`. ```text ┌────────────────────────────────────────────────────────────────┐ @@ -33,15 +33,22 @@ Then open `http://localhost:5197`. ## Layout ```text -app-shared/ — contract panels consume (state types, branded ids, typed hooks) +app-shared/ — shell-team contract: AppDependencies, AppSlots compositions/ - editor/ — composition definition + typed handle (depends on app-shared) -modules/ — editor / contentful / strapi panel modules (depend on app-shared) -shell/ — registry, root route, CompositionOutlet wiring, e2e + editor/ — composition team: state, runtime definition, handle. + Imports panel module types (one-way: composition → modules). +modules/ — panel teams: pure modules that read `WritableStore` / + `ReadableStore` via their `input`. Depend on + @modular-react/core ONLY — no workspace dep on either + `app-shared` or `compositions/editor`. +shell/ — registry, root route, CompositionOutlet wiring, e2e. ``` -Mirrors how journey examples place each journey under `journeys//`. Panel -modules depend on `app-shared` only — never on `compositions/editor` — so the -composition's runtime definition is not transitively pulled into a panel's bundle and -the boundary "modules don't know which composition hosts them" is structural, not just -a convention. +Dependency direction is one-way: `composition → modules`. Modules import the +generic store interfaces from `@modular-react/core`; the composition's selector +projects state into those contracts via `stores.writable(key, { get, set })` +and `stores.readable(key, get)`. Identity is stable per `(instance, key)`, so +`useSyncExternalStore` in the panels doesn't re-subscribe across renders. + +See [the package README's "typed store projections" pattern](../../../packages/compositions/README.md#pattern--typed-store-projections-composition-unaware-panels) +for the full design rationale. diff --git a/examples/react-router/editor-composition/compositions/editor/package.json b/examples/react-router/editor-composition/compositions/editor/package.json index f742c8a2..df8826bc 100644 --- a/examples/react-router/editor-composition/compositions/editor/package.json +++ b/examples/react-router/editor-composition/compositions/editor/package.json @@ -15,6 +15,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@example-rr-editor-composition/contentful": "workspace:*", + "@example-rr-editor-composition/editor": "workspace:*", + "@example-rr-editor-composition/strapi": "workspace:*", "@modular-react/compositions": "workspace:*", "@modular-react/core": "workspace:*" }, diff --git a/examples/react-router/editor-composition/compositions/editor/src/composition.ts b/examples/react-router/editor-composition/compositions/editor/src/composition.ts index 46af8f7a..fd3af11f 100644 --- a/examples/react-router/editor-composition/compositions/editor/src/composition.ts +++ b/examples/react-router/editor-composition/compositions/editor/src/composition.ts @@ -1,26 +1,27 @@ import { defineComposition, defineCompositionHandle } from "@modular-react/compositions"; -import type { ModuleDescriptor } from "@modular-react/core"; -import type { EditorState } from "./state.js"; +import type editorModule from "@example-rr-editor-composition/editor"; +import type contentfulModule from "@example-rr-editor-composition/contentful"; +import type strapiModule from "@example-rr-editor-composition/strapi"; +import type { EditorState, SourceId } from "./state.js"; /** - * Typed module map the composition references in its selectors. Modeled - * as a `type` (not an `interface`) so it satisfies the - * `Record>` shape that `ModuleTypeMap` - * declares — an `interface` with concrete keys is missing the implicit - * string index signature `Record` requires. + * Strongly-typed module map. Imports are `import type` only — the panel + * modules are not pulled into this package's bundle. * - * Kept loose (`ModuleDescriptor`) on purpose so the composition - * package does not import panel-module types — modules depend on this - * package for typed hooks; importing them back would create a cycle. For - * strong per-`(module, entry)` input type-checking, the composition can - * declare a concrete `import type` map (mirroring how journey definitions - * import each module). See the package README's "Composition zones vs - * `module.zones`" section for the trade-off. + * **Dependency direction**: composition → modules (one-way). Panel modules + * depend only on `@modular-react/core` (for `ReadableStore` / + * `WritableStore` interfaces); they do NOT import this package, so no + * cycle. With each module's `entryPoints` typed via `defineModule`, + * `ZoneSpec` checks `input` against the target entry's + * declared schema at compile time — a wrong-shaped input or a typo'd + * entry name fails to typecheck. + * + * Mirrors how journey examples type their module map. */ type EditorModuleMap = { - readonly editor: ModuleDescriptor; - readonly contentful: ModuleDescriptor; - readonly strapi: ModuleDescriptor; + readonly editor: typeof editorModule; + readonly contentful: typeof contentfulModule; + readonly strapi: typeof strapiModule; }; export const editorComposition = defineComposition()({ @@ -33,33 +34,59 @@ export const editorComposition = defineComposition }), zones: { main: { - // Always render the editor canvas with the current document id. - select: ({ state }) => ({ + // Project composition state into a `WritableStore` + // and hand it to the editor canvas via `input`. The panel reads + // with `useSyncExternalStore(store.subscribe, store.getSnapshot)` + // and writes with `store.set(...)`. Identity is stable per + // `(instance, "activeSource")` so the panel doesn't re-subscribe + // across selector re-runs. + select: ({ state, stores }) => ({ kind: "module-entry", module: "editor", entry: "main", - input: { documentId: state.documentId }, + input: { + documentId: state.documentId, + activeSource: stores.writable("activeSource", { + get: (s) => s.activeSource, + set: (value) => ({ activeSource: value }), + }), + }, }), }, source: { - // Project `activeSource` → a panel module / entry. Selectors are pure; - // dispatching a new `activeSource` is what causes this zone to flip. - select: ({ state }) => + // Project `activeSource` → a source-integration panel. Selectors + // are pure; the editor panel's `activeSource.set(...)` is what + // causes this zone to flip on the next render pass. + select: ({ state, stores }) => state.activeSource ? { kind: "module-entry", module: state.activeSource, entry: "sourcePanel", - input: { documentId: state.documentId }, + input: { + documentId: state.documentId, + selectedItem: stores.writable("selectedSourceItem", { + get: (s) => s.selectedSourceItem, + set: (value) => ({ selectedSourceItem: value }), + }), + }, } : { kind: "empty" }, }, inspector: { - select: ({ state }) => ({ + // Inspector reads only — readable stores are sufficient. + select: ({ state, stores }) => ({ kind: "module-entry", module: "editor", entry: "inspector", - input: { documentId: state.documentId }, + input: { + documentId: state.documentId, + activeSource: stores.readable("activeSource:r", (s) => s.activeSource), + selectedItem: stores.readable( + "selectedSourceItem:r", + (s) => s.selectedSourceItem, + ), + }, }), }, }, diff --git a/examples/react-router/editor-composition/compositions/editor/src/hooks.ts b/examples/react-router/editor-composition/compositions/editor/src/hooks.ts deleted file mode 100644 index a2130266..00000000 --- a/examples/react-router/editor-composition/compositions/editor/src/hooks.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createCompositionContext } from "@modular-react/compositions"; -import type { EditorState } from "./state.js"; - -/** - * Pre-typed hook bundle so foreign panel modules don't have to spell - * `` at every call site. Co-located with the composition - * definition (not in `app-shared`) so the composition team owns its full - * contract — state shape + hooks + runtime definition — in one package. - */ -export const createEditorHooks = () => createCompositionContext(); diff --git a/examples/react-router/editor-composition/compositions/editor/src/index.ts b/examples/react-router/editor-composition/compositions/editor/src/index.ts index be0b3aac..05fa0ed9 100644 --- a/examples/react-router/editor-composition/compositions/editor/src/index.ts +++ b/examples/react-router/editor-composition/compositions/editor/src/index.ts @@ -1,3 +1,2 @@ export { editorComposition, editorCompositionHandle } from "./composition.js"; export type { EditorState, SourceId } from "./state.js"; -export { createEditorHooks } from "./hooks.js"; diff --git a/examples/react-router/editor-composition/modules/contentful/package.json b/examples/react-router/editor-composition/modules/contentful/package.json index 9f7524c5..70aa26ae 100644 --- a/examples/react-router/editor-composition/modules/contentful/package.json +++ b/examples/react-router/editor-composition/modules/contentful/package.json @@ -15,8 +15,6 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@example-rr-editor-composition/editor-composition": "workspace:*", - "@modular-react/compositions": "workspace:*", "@modular-react/core": "workspace:*" }, "devDependencies": { diff --git a/examples/react-router/editor-composition/modules/contentful/src/index.tsx b/examples/react-router/editor-composition/modules/contentful/src/index.tsx index 1147ae4d..546ab89c 100644 --- a/examples/react-router/editor-composition/modules/contentful/src/index.tsx +++ b/examples/react-router/editor-composition/modules/contentful/src/index.tsx @@ -1,10 +1,18 @@ +import { useSyncExternalStore } from "react"; import { defineEntry, defineModule, schema } from "@modular-react/core"; -import { - createEditorHooks, - type EditorState, -} from "@example-rr-editor-composition/editor-composition"; +import type { WritableStore } from "@modular-react/core"; -const { useState: useEditorState, useDispatch: useEditorDispatch } = createEditorHooks(); +interface ContentfulSourceInput { + readonly documentId: string; + /** + * Selected source item id. The composition provides a writable store + * projection; this module reads via `useSyncExternalStore` and writes + * via `selectedItem.set(...)`. The module does not know the + * composition's state shape — only that a `WritableStore` + * is wired in. + */ + readonly selectedItem: WritableStore; +} const SAMPLE_ENTRIES = [ { id: "entry-12", title: "Homepage hero copy" }, @@ -12,21 +20,24 @@ const SAMPLE_ENTRIES = [ { id: "entry-42", title: "Release notes — v3" }, ]; -function ContentfulSourcePanel({ input }: { input: { documentId: string } }) { - const selected = useEditorState((s: EditorState) => s.selectedSourceItem); - const dispatch = useEditorDispatch(); +function ContentfulSourcePanel({ input }: { input: ContentfulSourceInput }) { + const { documentId } = input; + const selectedItem = useSyncExternalStore( + input.selectedItem.subscribe, + input.selectedItem.getSnapshot, + ); return (

Contentful

-

Source items for {input.documentId}

+

Source items for {documentId}

    {SAMPLE_ENTRIES.map((e) => (
); } ``` -Pass typed hooks down per-composition with the `createCompositionContext()` factory if you don't want to spell `` at every call site — see [Hooks for foreign panels](#hooks-for-foreign-panels). +The panel team and composition team share only the structural `WritableStore` contract. The composition's `TState` shape can change without touching the panel; a different host can supply a different `WritableStore` (test mock, shell-level Zustand store, etc.). + +See [Pattern — typed store projections](#pattern--typed-store-projections-composition-unaware-panels) for the full design. + +**Alternative for same-team scenarios — composition hooks.** When one team owns both the composition and the panels, calling `useCompositionState` / `useCompositionDispatch` directly is slightly less ceremony — the panel imports the composition's `TState` and subscribes to slices through the hook: + +```typescript +import { useCompositionState, useCompositionDispatch } from "@modular-react/compositions"; +import type { EditorState } from "@myorg/editor-composition"; + +export function ContentfulSourcePanel({ input }: ModuleEntryProps<{ documentId: string }>) { + const selected = useCompositionState( + (s) => s.selectedSourceItem, + ); + const dispatch = useCompositionDispatch(); + return (/* same UI as above, calling dispatch({ selectedSourceItem: it.id }) */); +} +``` + +See [Hooks for foreign panels](#hooks-for-foreign-panels) for the typed-hooks factory. + +> **Which pattern do I pick?** See [Hooks vs stores — which to use](#hooks-vs-stores--which-to-use). ## Core concepts @@ -351,6 +396,24 @@ Every `start()` call mints a fresh instance — the runtime does not dedupe by i ## Authoring patterns +### Hooks vs stores — which to use + +Both patterns subscribe at slice level (via `useSyncExternalStore` under the hood) and have equivalent re-render behavior — the choice is **ownership**, not performance. + +| | **Stores** (typed `ReadableStore` / `WritableStore` via `input`) | **Hooks** (`useCompositionState` / `useCompositionDispatch`) | +| ---------------------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| Panel imports | `@modular-react/core` only (for `ReadableStore` / `WritableStore`) | `@modular-react/compositions` + the composition's `TState` type | +| Panel workspace deps | Zero on the composition package | Yes — module depends on the composition package for `TState` | +| Coupling between panel & composition | Structural — `WritableStore` interface only | Nominal — the composition's `TState` shape | +| Reuse in other hosts | Easy — any `WritableStore` works (test mock, shell-level store, …) | Panel only renders inside *this* composition | +| Composition's selector | Calls `stores.writable("key", { get, set })` | Selector returns plain `input`; panel reads from context | +| Ceremony at the panel | Two lines of `useSyncExternalStore` per slice | One line of `useCompositionState(s => ...)` per slice | +| Best for | Panels owned by a different team than the composition; reusable panels | Panels and composition owned by the same team; one-off internal screens | + +**Default to stores** when modules and compositions are owned by different teams, or when you want a panel to be reusable outside this composition. **Reach for hooks** when the same team owns both and you want minimum ceremony — they're a fully-supported alternative, not a deprecated path. + +The two patterns can coexist in one composition (e.g., the editor panel uses hooks because it's owned by the composition team; integration panels use stores because they're owned by integration teams). + ### Pattern — typed store projections (composition-unaware panels) For strict separation between the **composition team** (owns coordination state) and the **panel teams** (own panel modules), project composition state into typed store contracts that panels consume via their `input`. Panels then depend only on the structural store interface — not on the composition's `TState` shape — so they import nothing composition-specific. @@ -540,6 +603,8 @@ const emit = useCompositionEmit(); ### Pattern — typed hooks per composition +> Hook-based pattern — pairs with [Hooks for foreign panels](#hooks-for-foreign-panels). See [Hooks vs stores — which to use](#hooks-vs-stores--which-to-use) for when to reach for this vs. the store-projection pattern above. + Avoid spelling `` at every call site by exporting pre-typed hooks from the composition package: ```typescript @@ -857,6 +922,8 @@ const instanceId = useComposition( ## Hooks for foreign panels +> The alternative to the [typed store projections](#pattern--typed-store-projections-composition-unaware-panels) pattern, suited to in-team panels. The hook-based path is fully supported — neither pattern is deprecated. See [Hooks vs stores — which to use](#hooks-vs-stores--which-to-use) for the trade-off. + Panels inside a composition zone — and only those panels — can read the active instance via four hooks. They throw if called outside a `` zone. ```typescript @@ -1025,7 +1092,7 @@ Three primitives in the framework arrange modules on a screen. Pick by problem s | **Read in shell** | `useZones` / `useActiveZones` | `` render-prop with zone names | `` (leaf-walk through current step) | | **Instance id prefix** | n/a | `ci_*` | `ji_*` | | **Persistence** | n/a | None — keep durable coordination state in the application layer | First-class adapter (`JourneyPersistence`) with versioned blobs | -| **Hooks inside panels** | n/a | `useCompositionState/Dispatch/Emit/Zone` | `useJourneyState`, `useJourneyInstance`, `useJourneyCallStack` | +| **Panel ↔ host data flow** | n/a | Stores (`ReadableStore`/`WritableStore` via `input`) **or** hooks (`useCompositionState`/`Dispatch`/`Emit`/`Zone`) | `useJourneyState`, `useJourneyInstance`, `useJourneyCallStack` | | **Validation** | Slot-name + route lookup | Zone contracts (spot-check) + `moduleCompat` | Reachability + transition exhaustiveness + contracts | | **Composition with the other** | n/a | A zone can mount `` via `kind: "journey"` | A journey step can render `` like any other component | diff --git a/packages/compositions/src/selector-dispatch.test.tsx b/packages/compositions/src/selector-dispatch.test.tsx index 2ddefd9d..8684cb77 100644 --- a/packages/compositions/src/selector-dispatch.test.tsx +++ b/packages/compositions/src/selector-dispatch.test.tsx @@ -14,11 +14,16 @@ afterEach(() => { }); /** - * Tests for the prop-driven panel pattern: panels receive callbacks via - * `input` instead of using `useCompositionState`/`useCompositionDispatch`. - * The composition's selector closes over `ctx.dispatch` to wire panel - * callbacks back into composition state. Verifies: + * Tests `ZoneSelectorCtx.dispatch` — the framework field selectors close + * over when they bake imperative callbacks into a panel's `input` + * (`onClose`, `onSubmit`, emit-style fire-and-forget triggers, etc.). * + * Note: state-coordination across panels is usually expressed via + * `ctx.stores.writable(...)` projections (see `stores.test.ts`), which + * give slice-level subscription. `ctx.dispatch` remains useful for + * one-off callbacks that don't warrant a full store contract. + * + * Verifies: * - `ctx.dispatch` is referentially stable across re-renders so * `React.memo`'d panels can compare `input.onX` by identity. * - Invoking the callback updates state; the next selector pass picks