diff --git a/packages/journeys/README.md b/packages/journeys/README.md index a8d8fa7..a359311 100644 --- a/packages/journeys/README.md +++ b/packages/journeys/README.md @@ -1614,6 +1614,70 @@ registry.registerJourney(journey, { A plain object matching `JourneyPersistence` still works if you'd rather not use the helper. +### Registering multiple journeys + +A shell registering more than one journey has two safe shapes for persistence and one tempting trap. + +**Safe — one typed adapter per journey.** Declare each `createWebStoragePersistence` (or `defineJourneyPersistence`) call against its own journey's types and pass it to that journey's `registerJourney`. This is what `examples/react-router/journey-invoke` and `examples/tanstack-router/journey-invoke` do — separate `checkoutPersistence` and `verifyIdentityPersistence` exports, each typed to its own journey's state. Use this shape when the setup expression is short and you don't mind repeating it. + +**Safe — a generic factory.** Wrap the setup in a `makeJourneyPersistence()` helper and invoke it per `registerJourney`. Use this shape when the setup expression is long (custom SSR guards, lazy `storage` getters, shared `keyFor` scheme) and you want a single source of truth without per-call repetition: + +```ts +import { createWebStoragePersistence, type SyncJourneyPersistence } from "@modular-react/journeys"; + +interface AppJourneyKey { + readonly userId: string; + readonly tenantId: string; +} + +const sessionStorageOrNull: Storage | null = + typeof window !== "undefined" && typeof window.sessionStorage !== "undefined" + ? window.sessionStorage + : null; + +function makeJourneyPersistence(): SyncJourneyPersistence< + TState, + TInput +> { + return createWebStoragePersistence({ + keyFor: ({ journeyId, input }) => `journey:${journeyId}:${input.tenantId}:${input.userId}`, + storage: sessionStorageOrNull, + }); +} + +registry.registerJourney(onboardingJourney, { + persistence: makeJourneyPersistence(), +}); +registry.registerJourney(checkoutJourney, { + persistence: makeJourneyPersistence(), +}); +``` + +Each factory call binds the calling journey's concrete `TInput`/`TState`, so the variance check passes without an `as` cast on either the definition or the adapter. The factory composes with `defineJourneyPersistence` for hand-rolled backends — same per-journey call shape, the helper just types `keyFor`/`load`/`save`/`remove` against the journey's types. + +**Trap — one shared adapter typed ``.** The shape that DRY instinct lands on if you're not careful: one `persistence` object reused across every `registerJourney` call, declared once with `TState` widened to `unknown` so it "fits everywhere". It doesn't. `JourneyDefinition.start(state: TState, ...)` puts `TState` in a contravariant position; the variance check rejects `unknown` against any concrete state. The compiler surfaces this as `Type 'unknown' is not assignable to type 'YourJourneyState'` pointing at the `persistence` arg of `registerJourney`. Reach for one of the two safe shapes above instead — both are zero-cost at runtime. + +```ts +import { createWebStoragePersistence, type SyncJourneyPersistence } from "@modular-react/journeys"; + +// One persistence to rule them all... or so you think. +export const sharedJourneyPersistence: SyncJourneyPersistence = + createWebStoragePersistence({ + keyFor: ({ journeyId, input }) => `journey:${journeyId}:${input.tenantId}:${input.userId}`, + }); + +// Both calls fail at the `persistence` arg, not at the declaration above: +// the variance check rejects `unknown` against the journey's concrete TState. +registry.registerJourney(onboardingJourney, { + persistence: sharedJourneyPersistence, + // Error: Type 'unknown' is not assignable to type 'OnboardingState'. +}); +registry.registerJourney(checkoutJourney, { + persistence: sharedJourneyPersistence, + // Error: Type 'unknown' is not assignable to type 'CheckoutState'. +}); +``` + ### Stock adapters: `createWebStoragePersistence` and `createMemoryPersistence` Two factories ship with the package so common setups don't have to reimplement the same 20 lines of SSR guards and JSON handling. Both return values satisfying `JourneyPersistence` - pass them directly to `registerJourney({ persistence })`.