march-hare 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,7 +28,7 @@
28
28
  1. [Global data](#global-data)
29
29
  1. [Reusable components](#reusable-components)
30
30
 
31
- For advanced topics, see the [recipes directory](./recipes/).
31
+ For advanced topics, see the [recipes directory](./recipes/). For a worked end-to-end example with the FSD layout, see [`src/example/`](./src/example/README.md).
32
32
 
33
33
  ## Benefits
34
34
 
@@ -40,7 +40,7 @@ For advanced topics, see the [recipes directory](./recipes/).
40
40
  - Reduces context proliferation – events replace many contexts.
41
41
  - No need to memoize callbacks – handlers are stable references with fresh closure access.
42
42
  - Clear separation between business logic and markup.
43
- - Complements [Feature Slice Design](https://feature-sliced.design/) architecture.
43
+ - Complements [Feature Sliced Design](https://feature-sliced.design/) architecture — **App = host, Scope = feature**; see [Reusable components](#reusable-components).
44
44
  - Strongly typed dispatches, models, payloads, etc.
45
45
  - Built-in request cancellation with `AbortController`.
46
46
  - Granular async state tracking per model field.
@@ -252,7 +252,7 @@ function useActions() {
252
252
  }
253
253
  ```
254
254
 
255
- See the [`useContext` recipe](./recipes/use-context.md) for the full pattern.
255
+ See the [`app.useContext` recipe](./recipes/use-controller.md) for the full pattern.
256
256
 
257
257
  The model defaults to `void`, so a component that only coordinates via events — forwarding broadcasts, triggering side-effects, bridging external systems — can call `context.useActions()` with no initial model:
258
258
 
@@ -393,7 +393,12 @@ Components that mount after a broadcast has already been dispatched automaticall
393
393
 
394
394
  ## Resource handling
395
395
 
396
- For remote data, declare an `app.Resource` at module scope. `resource.user(params)` is the unified call form &mdash; it returns the sync cache read (`User | null`) and primes a slot that `context.actions.resource(resource.user(params))` consumes for the fetch path (with auto-threaded abort controller and a live handle to the per-`<Boundary>` Env). Every successful fetch caches the response in a module-level slot keyed by the fetcher and the stringified params, so different param-sets are independent. Keep all resources in `resources.ts` and pull the whole module in as a namespace (`import * as resource from "./resources"`):
396
+ For remote data, declare an `app.Resource` at module scope. The resulting handle has two call forms:
397
+
398
+ - `resource.user.get(params)` &mdash; synchronous cache read, returns `User | null`. Use it in model literals, JSX, or anywhere you need the cached value without triggering a fetch.
399
+ - `resource.user(params)` &mdash; produces an `Invocation` you pass to `context.actions.resource(...)` for the fetch path (with auto-threaded abort controller and a live handle to the per-`<Boundary>` Env).
400
+
401
+ Every successful fetch caches the response in a module-level slot keyed by the fetcher and the stringified params, so different param-sets are independent. Keep all resources in `resources.ts` and pull the whole module in as a namespace (`import * as resource from "./resources"`):
397
402
 
398
403
  ```ts
399
404
  // resources.ts
@@ -430,7 +435,7 @@ function useActions() {
430
435
  const context = app.useContext<Model, typeof Actions>();
431
436
  const actions = context.useActions({
432
437
  // Sync cache read at the model literal — returns null when nothing is cached.
433
- user: resource.user(),
438
+ user: resource.user.get(),
434
439
  receipt: null,
435
440
  });
436
441
 
@@ -536,21 +541,28 @@ actions.useAction(Actions.Mount, async (context) => {
536
541
 
537
542
  See the [Resource recipe](./recipes/use-resource.md) for the three-tier error handling model, parameterised resources, and limitations.
538
543
 
539
- By default an `app.Resource`'s cache is in-memory only &ndash; it resets on every page load. To keep the most recent successful payload around between sessions, switch to `app.Resource.Cachable(cache, fetcher)`. The cache is the **first** argument &mdash; persistence is the headline of this form, the fetcher is the operation. Every successful fetch writes through to the Cache; first reads via the call form auto-seed from the Cache's adapter:
544
+ By default an `app.Resource`'s cache is in-memory only &ndash; it resets on every page load. To keep the most recent successful payload around between sessions, wire a `Cache` into `App({ cache })`. Every `app.Resource` declared on that App writes through to the shared Cache and seeds from it on the next reload; resources are namespaced internally so they don't collide on shared params keys:
540
545
 
541
546
  ```ts
542
- // resources.ts
543
- import { Cache } from "march-hare";
544
- import { app } from "./app";
547
+ // app.ts
548
+ import { App, Cache } from "march-hare";
545
549
 
546
- const cache = Cache({
547
- get: (key) => localStorage.getItem(key),
548
- set: (key, value) => localStorage.setItem(key, value),
549
- remove: (key) => localStorage.removeItem(key),
550
- clear: () => localStorage.clear(),
550
+ export const app = App({
551
+ env: { session: null as Session | null },
552
+ cache: Cache({
553
+ get: (key) => localStorage.getItem(key),
554
+ set: (key, value) => localStorage.setItem(key, value),
555
+ remove: (key) => localStorage.removeItem(key),
556
+ clear: () => localStorage.clear(),
557
+ }),
551
558
  });
559
+ ```
552
560
 
553
- export const cat = app.Resource.Cachable(cache, (context) =>
561
+ ```ts
562
+ // resources.ts
563
+ import { app } from "./app";
564
+
565
+ export const cat = app.Resource((context) =>
554
566
  fetchCat(context.controller.signal),
555
567
  );
556
568
  ```
@@ -571,7 +583,7 @@ function useActions() {
571
583
  const context = app.useContext<Model, typeof Actions>();
572
584
  const actions = context.useActions({
573
585
  // First render reads the Cache automatically.
574
- cat: resource.cat(),
586
+ cat: resource.cat.get(),
575
587
  });
576
588
 
577
589
  actions.useAction(Actions.Mount, async (context) => {
@@ -594,7 +606,9 @@ export default function CatCard(): React.ReactElement {
594
606
 
595
607
  `Cache()` with no adapter is an in-memory scope &ndash; useful in tests or when you want a holdable cache without persistence. Per-params keying via `JSON.stringify(params)` is automatic, so `user({ id: 5 })` and `user({ id: 6 })` are distinct slots.
596
608
 
597
- See the [storage recipe](./recipes/storage.md) for backend adapters (React Native MMKV, browser extension `chrome.storage`), sign-out purge, and the `unset` sentinel that keeps "nothing stored" distinct from "a legitimately stored null".
609
+ The adapter contract is **strictly synchronous** &ndash; `get` / `set` / `remove` / `clear` all return immediately, with no `Promise`. The model-literal read (`{ user: resource.user.get() }`) is evaluated during render and has no place to wait. React Native projects should use [`react-native-mmkv`](https://github.com/mrousavy/react-native-mmkv), which is sync out of the box and drops straight into the contract; `AsyncStorage` is incompatible. Truly async backends (IndexedDB, `chrome.storage.local`) need a sync facade hydrated at app entry &ndash; see the [storage recipe](./recipes/storage.md).
610
+
611
+ See the [storage recipe](./recipes/storage.md) for backend adapters (React Native `react-native-mmkv`, browser `localStorage`, browser extension `chrome.storage`), sign-out purge, and the `unset` sentinel that keeps "nothing stored" distinct from "a legitimately stored null".
598
612
 
599
613
  ## Channeled actions
600
614
 
@@ -703,7 +717,7 @@ A few rules worth knowing:
703
717
  - **No `scope.Scope()`.** The handle deliberately omits a nested factory. Open another scope by calling `app.Scope<...>()` again and rendering its `<Boundary>` &mdash; that way the multicast surface stays declared at the call site.
704
718
  - **Replay on late-mount is per-scope.** Like broadcast, multicast caches its most recent payload per action symbol; components that mount later inside the same boundary pick up the cached value through their `useAction` handler. See the [mount deduplication recipe](./recipes/mount-broadcast-deduplication.md) if you also fetch in `Lifecycle.Mount()`.
705
719
 
706
- See the [multicast recipe](./recipes/multicast-actions.md) for more details.
720
+ See the [multicast recipe](./recipes/multicast-actions.md) for more details. When the scope itself needs to be reusable across multiple hosts, reach for `shared.Scope<HostEnvs, typeof MulticastActions>()` &mdash; the standalone form covered in [Reusable components](#reusable-components). The rule of thumb: never reach for a second `App()` to get a private channel; that's what multicast scopes exist for.
707
721
 
708
722
  ## Global data
709
723
 
@@ -800,10 +814,25 @@ export const app = App();
800
814
 
801
815
  ## Reusable components
802
816
 
803
- Importing `app` from a single location is fine inside a feature, but it breaks when a component needs to run under **more than one** `App` &mdash; a shared `<Profile />` used by both a web app and a mobile shell, for example. `useApp<S>()` returns a typed handle to the **nearest `<app.Boundary>`** at runtime: same `useContext` / `useEnv` surface as an App-bound handle, with the Env shape (or union of shapes) declared at the call site.
817
+ > **App = host, Scope = feature.** One `App<HostEnv>()` per deployable; everything inside it is a component. A component that needs a private channel reaches for `shared.Scope<HostEnvs, _>()`, never another `App()`. A component that runs under more than one `App` reaches for `shared.useContext<HostEnvs, M, A>()` instead of binding to a specific `app`. That one rule keeps the dependency graph acyclic, lets cross-cutting state (session, locale, permissions) live in a single Env, and gives [Feature Sliced Design](https://feature-sliced.design/) a 1:1 runtime expression &mdash; shared layer reuses `shared.X` against a `HostEnvs` union, features open `shared.Scope`s, hosts declare the App.
818
+
819
+ Importing `app` from a single location is fine inside a feature, but it breaks when a component needs to run under **more than one** `App` &mdash; a shared `<Profile />` used by both a web app and a mobile shell, for example. For that case, every `app.X` factory has a **standalone counterpart** on the `shared` namespace that takes the Env shape `E` as its mandatory first generic:
820
+
821
+ | Bound to an App | Standalone (`shared.X`) |
822
+ | --------------------------- | --------------------------------- |
823
+ | `app.useContext<M, A, D>()` | `shared.useContext<E, M, A, D>()` |
824
+ | `app.useEnv()` | `shared.useEnv<E>()` |
825
+ | `app.Resource<T, P>(...)` | `shared.Resource<E, T, P>(...)` |
826
+ | `app.Scope<A>()` | `shared.Scope<E, A>()` |
827
+
828
+ The standalone forms take the same runtime path as the App-bound ones &mdash; `E` is purely a type-level binding the caller supplies so reusable code stays App-agnostic.
804
829
 
805
830
  ```tsx
806
- import { useApp, Action } from "march-hare";
831
+ import { Action, shared } from "march-hare";
832
+
833
+ type WebEnv = { session: Session | null; locale: string };
834
+ type MobileEnv = { session: Session | null; platform: "ios" | "android" };
835
+ type Envs = WebEnv | MobileEnv;
807
836
 
808
837
  type Model = { name: string | null };
809
838
  const model: Model = { name: null };
@@ -813,8 +842,7 @@ class Actions {
813
842
  }
814
843
 
815
844
  function useProfileActions() {
816
- const app = useApp<{ session: Session | null }>();
817
- const context = app.useContext<Model, typeof Actions>();
845
+ const context = shared.useContext<Envs, Model, typeof Actions>();
818
846
  const actions = context.useActions(model);
819
847
 
820
848
  actions.useAction(Actions.Sign, (context, name) =>
@@ -837,22 +865,24 @@ export default function Profile(): React.ReactElement {
837
865
 
838
866
  Drop `<Profile />` inside `<web.Boundary>` and it reads the web app's env; drop it inside `<mobile.Boundary>` and it reads the mobile app's env. The component never references either `App()` handle.
839
867
 
840
- When more than one `App` lives in your repo, declare a union of every Env once and parameterise every reusable component against it. TypeScript narrows `app.useEnv()` to the union &mdash; keys present on every member resolve directly, keys on a subset need an `in` / `typeof` guard:
868
+ When more than one `App` lives in your repo, declare a union of every Env once and parameterise every reusable component against it. Keys present on every member resolve directly; keys on a subset need an `in` / `typeof` guard:
841
869
 
842
870
  ```ts
843
- // shared/apps.ts
871
+ // shared/envs.ts
844
872
  export type WebEnv = { session: Session | null; locale: string };
845
873
  export type MobileEnv = {
846
874
  session: Session | null;
847
875
  platform: "ios" | "android";
848
876
  };
849
- export type Apps = WebEnv | MobileEnv;
877
+ export type Envs = WebEnv | MobileEnv;
850
878
  ```
851
879
 
852
880
  ```tsx
881
+ import { shared } from "march-hare";
882
+ import type { Envs } from "../shared/envs";
883
+
853
884
  function Where(): React.ReactElement {
854
- const app = useApp<Apps>();
855
- const env = app.useEnv();
885
+ const env = shared.useEnv<Envs>();
856
886
 
857
887
  const signedIn = env.session !== null;
858
888
  const where = "locale" in env ? env.locale : env.platform;
@@ -861,6 +891,8 @@ function Where(): React.ReactElement {
861
891
  }
862
892
  ```
863
893
 
864
- The handle exposes `useContext` and `useEnv` &mdash; the same surface as `App<S>` minus `Boundary` (rendered once at the App declaration site) and `Scope` / `Resource` (module-scope, not per-render). See the [reusable components recipe](./recipes/reusable-components.md) for the full pattern including discriminator-keyed switches and the `App()`-with-no-env case.
894
+ `shared.Resource<E, T, P>` is the same story for shared resources &mdash; declare them at module scope, pass the Env union as the first generic, and the fetcher's `context.env` is typed against it. Shared resources always use an isolated in-memory cache; reach for `app.Resource` when persistence is required, since the cache is wired into the App via `App({ cache })`. `shared.Scope<E, A>()` opens a multicast scope without going through an App handle. See the [reusable components recipe](./recipes/reusable-components.md) for the full pattern including discriminator-keyed switches and the `App()`-with-no-env case.
895
+
896
+ When a reusable component or resource is genuinely Env-agnostic &mdash; the fetcher never touches `context.env`, the hook never calls `shared.useEnv` &mdash; pass `Envless` as `E` instead of spelling out `Record<never, never>`: `shared.Resource<Envless, T>`, `shared.useContext<Envless, M, A>()`. It's a named alias for the empty-record shape exported from `march-hare`, kept around purely for legibility at the call site.
865
897
 
866
898
  For one-line handler binding &mdash; flipping a boolean, assigning a payload to a leaf, pinning a field to a fixed value &mdash; reach for `context.with.{update,invert,always}`. See the [`With` helpers recipe](./recipes/with-helpers.md) for the full surface.
@@ -1,67 +1,13 @@
1
1
  import { Env } from '../boundary/components/env/index';
2
+ import { Cache } from '../cache/index';
2
3
  import { Actions, Model, Props } from '../types/index';
3
- import { Scope } from '../scope/index';
4
- import { AppContextHandle, AppResource, UseAppHandle } from './types';
4
+ import { AppHandle, AppContextHandle } from './types';
5
5
  import { Tap } from '../boundary/components/tap/types';
6
- import * as React from "react";
7
- export type { AppArgs, AppContextHandle, AppFetcher, AppResource, UseAppHandle, } from './types';
6
+ export type { AppArgs, AppContextHandle, AppFetcher, AppResource, } from './types';
7
+ export type { AppHandle } from './types';
8
8
  /**
9
- * Returned from {@link App}. Bundles the Boundary, hooks, and Resource
10
- * factory bound to a single typed Env shape `S`.
11
- */
12
- export type App<S extends object> = {
13
- /**
14
- * Boundary component for this App. Wraps the subtree with the `env`
15
- * and `tap` declared on {@link App} &mdash; both are fixed at App
16
- * construction time and cannot be overridden at the render site.
17
- * Runtime mutations to the Env flow through
18
- * `context.actions.produce(({ env }) => { ... })`; if a test or
19
- * storybook needs a different initial Env, declare a separate `App`.
20
- */
21
- readonly Boundary: React.FC<{
22
- children: React.ReactNode;
23
- }>;
24
- /**
25
- * Hook returning a stable `Context` handle. The handle's
26
- * `context.useActions(model?, getData?)` materialises the
27
- * component's `[model, actions, data]` tuple. Every handler's
28
- * `context.env` is typed as `S`.
29
- */
30
- readonly useContext: <M extends Model | void = void, AC extends Actions | void = void, D extends Props = Props>() => AppContextHandle<M, AC, D, S>;
31
- /**
32
- * Read-only Proxy over the per-Boundary Env, typed as `S`. Reads use
33
- * dot notation (`env.session`) and always reflect the latest value
34
- * across `await` boundaries. Writes flow through
35
- * `context.actions.produce(({ env }) => { ... })`.
36
- */
37
- readonly useEnv: () => Readonly<S>;
38
- /**
39
- * `Resource` factory bound to this App's Env. Same shape as the
40
- * top-level `Resource`: call directly for an in-memory cache, or use
41
- * `app.Resource.Cachable(cache, fetcher)` for persistence.
42
- */
43
- readonly Resource: AppResource<S>;
44
- /**
45
- * Opens a typed multicast scope. The generic `MulticastActions` declares
46
- * the `Distribution.Multicast` action class (or union of classes)
47
- * whose dispatches are routed through this scope &mdash; the
48
- * returned handle mirrors the App surface but widens
49
- * `useContext().actions.dispatch` to accept actions from `MulticastActions`
50
- * on top of the local `AC` class.
51
- *
52
- * Render `<scope.Boundary>` to open the scope at runtime; nesting
53
- * multiple boundaries from different `app.Scope()` calls is fine,
54
- * each runs as an independent emitter shadowed for its subtree.
55
- *
56
- * The Scope handle deliberately does NOT expose a further `Scope`
57
- * method &mdash; the multicast surface must be declared at the
58
- * `app.Scope<MulticastActions>()` call site so the type union is explicit.
59
- */
60
- readonly Scope: <MulticastActions>() => Scope<S, MulticastActions>;
61
- };
62
- /**
63
- * Creates an `App` &mdash; the entrypoint for a typed Env shape `S`,
64
- * inferred from `config.env`. `App<S>` exposes `Boundary`, hooks, and
9
+ * Creates an `App` &mdash; the entrypoint for a typed Env shape `E`,
10
+ * inferred from `config.env`. `App<E>` exposes `Boundary`, hooks, and
65
11
  * a `Resource` factory all wired against the same shape.
66
12
  *
67
13
  * Each `<app.Boundary>` instance owns its own Env, so different `App`s
@@ -69,15 +15,22 @@ export type App<S extends object> = {
69
15
  *
70
16
  * Pass `tap` to subscribe to every action handler's dispatch / settle /
71
17
  * error inside the boundary &mdash; useful for analytics, audit logging,
72
- * Sentry breadcrumbs. See `recipes/tap.md`. Both `env` and `tap` are
73
- * fixed at `App()` time; `<app.Boundary>` does not accept overrides.
74
- * Mutate the live Env through `context.actions.produce(({ env }) => …)`,
75
- * and declare a separate `App` when a test or storybook needs a
76
- * different initial value.
18
+ * Sentry breadcrumbs. See `recipes/tap.md`. Pass `cache` to persist
19
+ * every `app.Resource(fetcher)` declaration through a single
20
+ * {@link Cache} &mdash; each resource is namespaced inside the cache by
21
+ * its declaration order, so reloads seed from storage automatically and
22
+ * resources do not collide on shared params keys. Omit `cache` to keep
23
+ * each resource's payloads in an isolated in-memory slot.
24
+ *
25
+ * `env`, `tap`, and `cache` are all fixed at `App()` time;
26
+ * `<app.Boundary>` does not accept overrides. Mutate the live Env
27
+ * through `context.actions.produce(({ env }) => …)`, and declare a
28
+ * separate `App` when a test or storybook needs a different initial
29
+ * value.
77
30
  *
78
31
  * @example
79
32
  * ```tsx
80
- * import { App, type Taps } from "march-hare";
33
+ * import { App, Cache, type Taps } from "march-hare";
81
34
  *
82
35
  * type Session = { accessToken: string };
83
36
  *
@@ -93,6 +46,12 @@ export type App<S extends object> = {
93
46
  * operating: "idle" as "idle" | "signing-out",
94
47
  * },
95
48
  * tap,
49
+ * cache: Cache({
50
+ * get: (key) => localStorage.getItem(key),
51
+ * set: (key, value) => localStorage.setItem(key, value),
52
+ * remove: (key) => localStorage.removeItem(key),
53
+ * clear: () => localStorage.clear(),
54
+ * }),
96
55
  * });
97
56
  *
98
57
  * // Root render.
@@ -100,21 +59,7 @@ export type App<S extends object> = {
100
59
  * <Root />
101
60
  * </app.Boundary>;
102
61
  *
103
- * // In a feature's actions.ts:
104
- * export function useAuthActions() {
105
- * const context = app.useContext<void, typeof Actions>();
106
- * const actions = context.useActions();
107
- *
108
- * actions.useAction(Actions.SignOut, async (context) => {
109
- * context.actions.produce(({ env }) => {
110
- * env.session = null;
111
- * });
112
- * });
113
- *
114
- * return actions;
115
- * }
116
- *
117
- * // In resources.ts:
62
+ * // In resources.ts &mdash; persisted via the App's cache.
118
63
  * export const user = app.Resource<User>((context) =>
119
64
  * ky
120
65
  * .get("/api/user", {
@@ -127,37 +72,40 @@ export type App<S extends object> = {
127
72
  * );
128
73
  * ```
129
74
  */
130
- export declare function App<S extends object = Env>(config?: {
131
- env?: S;
75
+ export declare function App<E extends object = Env>(config?: {
76
+ env?: E;
132
77
  tap?: Tap;
133
- }): App<S>;
78
+ cache?: Cache;
79
+ }): AppHandle<E>;
134
80
  /**
135
- * Returns a typed handle to the nearest `<app.Boundary>`, for use
136
- * inside reusable components that aren't tied to a single `App` import.
137
- * The generic `S` declares the Env shape (or union of shapes) the
138
- * component expects &mdash; `useApp<WebEnv | MobileEnv>()` lets the
139
- * component run under either App and read keys common to both. For
140
- * fields that only exist on a subset, narrow with an `in` check on
141
- * the value returned from `app.useEnv()`.
142
- *
143
- * The returned handle exposes `useContext` and `useEnv` &mdash; the
144
- * same surface as `App<S>` minus `Boundary` (rendered once at the App
145
- * declaration site) and `Scope` (declared at module scope so its
146
- * multicast surface stays explicit).
147
- *
148
- * Recommended monorepo pattern: declare a `type Apps = WebEnv |
149
- * MobileEnv | AdminEnv` union of every Env shape your reusable
150
- * components might run under, and have shared components reach for
151
- * `useApp<Apps>()`. Library code can then read keys present on every
152
- * member directly, with type-guards bridging the rest.
81
+ * Standalone counterpart to `app.useContext`, exported as
82
+ * `shared.useContext` &mdash; same call shape, but takes the **Env
83
+ * shape `E` as a mandatory first generic** so the caller can be a
84
+ * reusable component that isn't tied to a single `App` import.
85
+ *
86
+ * `E` is the Env type your component expects to see &mdash; usually
87
+ * a union of every App's Env shape it might run under. Inside the
88
+ * handler, `context.env` is typed as `E`; reach for `in` / `typeof`
89
+ * narrowing for keys present on only a subset.
90
+ *
91
+ * Pass `app` directly if you only need to talk to one App &mdash;
92
+ * `app.useContext<Model, typeof Actions>()` is shorter and infers the
93
+ * Env from the value. Reach for the standalone form only when a
94
+ * component must support more than one App.
95
+ *
96
+ * @template E The Env shape (or union) the component supports.
97
+ * @template M The model type, or `void`.
98
+ * @template AC The Actions class, or `void`.
99
+ * @template D The reactive data type returned from the `useActions`
100
+ * data callback.
153
101
  *
154
102
  * @example
155
103
  * ```tsx
156
- * import { useApp, Action } from "march-hare";
104
+ * import { Action, shared } from "march-hare";
157
105
  *
158
106
  * type WebEnv = { session: Session | null; locale: string };
159
107
  * type MobileEnv = { session: Session | null; platform: "ios" | "android" };
160
- * type Apps = WebEnv | MobileEnv;
108
+ * type Envs = WebEnv | MobileEnv;
161
109
  *
162
110
  * type Model = { name: string | null };
163
111
  * const model: Model = { name: null };
@@ -166,20 +114,39 @@ export declare function App<S extends object = Env>(config?: {
166
114
  * static Sign = Action<string>("Sign");
167
115
  * }
168
116
  *
169
- * export default function Profile() {
170
- * const app = useApp<Apps>();
171
- * const env = app.useEnv();
172
- * const context = app.useContext<Model, typeof Actions>();
173
- * const [view, actions] = context.useActions(model);
174
- *
175
- * const where = "locale" in env ? env.locale : env.platform;
117
+ * function useProfileActions() {
118
+ * const context = shared.useContext<Envs, Model, typeof Actions>();
119
+ * const actions = context.useActions(model);
176
120
  *
177
- * return (
178
- * <button onClick={() => actions.dispatch(Actions.Sign, "Adam")}>
179
- * Hey {view.name} ({where})
180
- * </button>
121
+ * actions.useAction(Actions.Sign, (context, name) =>
122
+ * context.actions.produce(({ model }) => void (model.name = name)),
181
123
  * );
124
+ *
125
+ * return actions;
126
+ * }
127
+ * ```
128
+ */
129
+ export declare function useContext<E extends object, M extends Model | void = void, A extends Actions | void = void, D extends Props = Props>(): AppContextHandle<M, A, D, E>;
130
+ /**
131
+ * Standalone counterpart to `app.useEnv`, exported as `shared.useEnv`
132
+ * &mdash; reads the nearest `<app.Boundary>`'s Env, typed against the
133
+ * Env shape `E` supplied at the call site. For reusable components
134
+ * that need an Env read outside any action handler (e.g. to hand a
135
+ * closure to an external library at module bridge time).
136
+ *
137
+ * @template E The Env shape (or union) the component supports.
138
+ *
139
+ * @example
140
+ * ```tsx
141
+ * import { shared } from "march-hare";
142
+ *
143
+ * type WebEnv = { session: Session | null };
144
+ * type MobileEnv = { session: Session | null };
145
+ *
146
+ * function SessionBadge() {
147
+ * const env = shared.useEnv<WebEnv | MobileEnv>();
148
+ * return <span>{env.session ? env.session.user.name : "Signed out"}</span>;
182
149
  * }
183
150
  * ```
184
151
  */
185
- export declare function useApp<S extends object = Env>(): UseAppHandle<S>;
152
+ export declare function useEnv<E extends object>(): Readonly<E>;
@@ -1,72 +1,100 @@
1
1
  import { Args, ResourceHandle } from '../resource/types';
2
- import { Cache } from '../cache/index';
3
2
  import { Actions, Context, Model, Props, UseActions } from '../types/index';
4
3
  import { Data } from '../actions/types';
5
4
  import { Env } from '../boundary/components/env/index';
6
- import { WithHandle } from '../with/index';
5
+ import { WithHandle } from '../with/types';
7
6
  /**
8
7
  * Args object passed to an `app.Resource` fetcher. Same shape as the
9
- * base `Resource` fetcher's args but with `env` typed as `S`.
8
+ * base `Resource` fetcher's args but with `env` typed as `E`.
10
9
  */
11
- export type AppArgs<S, P extends object = Record<never, never>> = Omit<Args<P>, "env"> & {
12
- readonly env: Readonly<S>;
10
+ export type AppArgs<E, P extends object = Record<never, never>> = Omit<Args<P>, "env"> & {
11
+ readonly env: Readonly<E>;
13
12
  };
14
13
  /**
15
14
  * Fetcher signature for an `app.Resource` declaration. The fetcher's
16
- * `context.env` is typed against the App's inferred Env shape `S`.
15
+ * `context.env` is typed against the App's inferred Env shape `E`.
17
16
  */
18
- export type AppFetcher<S, T, P extends object = Record<never, never>> = (context: AppArgs<S, P>) => Promise<T>;
17
+ export type AppFetcher<E, T, P extends object = Record<never, never>> = (context: AppArgs<E, P>) => Promise<T>;
19
18
  /**
20
- * `app.Resource(fetcher)` &mdash; in-memory cache, no persistence.
21
- * `app.Resource.Cachable(cache, fetcher)` &mdash; persistent cache wired
22
- * to the supplied `Cache` adapter. Both forms type `context.env` as
23
- * the App's Env shape.
19
+ * `app.Resource(fetcher)` declares a remote interaction bound to the
20
+ * App's Env shape. Cache behaviour is decided at App construction:
21
+ * pass `App({ cache })` to share a single {@link Cache} (typically
22
+ * backed by `localStorage`/MMKV) across every resource on the App, or
23
+ * omit it to keep each resource's payloads in an isolated in-memory
24
+ * slot.
24
25
  */
25
- export type AppResource<S> = {
26
- <T, P extends object = Record<never, never>>(fetcher: AppFetcher<S, T, P>): ResourceHandle<T, P>;
27
- readonly Cachable: <T, P extends object = Record<never, never>>(cache: Cache, fetcher: AppFetcher<S, T, P>) => ResourceHandle<T, P>;
28
- };
26
+ export type AppResource<E> = <T, P extends object = Record<never, never>>(fetcher: AppFetcher<E, T, P>) => ResourceHandle<T, P>;
29
27
  /**
30
28
  * Tuple shape returned by `context.useActions(...)` on an App-bound
31
- * Context. Re-exports the base {@link UseActions} with the App's `S`
29
+ * Context. Re-exports the base {@link UseActions} with the App's `E`
32
30
  * threaded through every `HandlerContext` and produce draft.
33
31
  */
34
- type AppActionsResult<M, AC, D, S> = UseActions<M extends Model | void ? M : void, AC extends Actions | void ? AC : void, D extends Props ? D : Props, S extends Env ? S : Env>;
32
+ type AppActionsResult<M, AC, D, E> = UseActions<M extends Model | void ? M : void, AC extends Actions | void ? AC : void, D extends Props ? D : Props, E extends Env ? E : Env>;
35
33
  /**
36
34
  * `useActions(...)` signature on the App-bound Context. Has two forms:
37
35
  * void-model components omit the model argument entirely; everyone else
38
36
  * passes their initial model as the first argument and an optional data
39
37
  * callback as the second.
40
38
  */
41
- type AppUseActions<M, AC, D, S> = M extends void ? (getData?: Data<D & Props>) => AppActionsResult<M, AC, D, S> : (model: M, getData?: Data<D & Props>) => AppActionsResult<M, AC, D, S>;
39
+ type AppUseActions<M, AC, D, E> = M extends void ? (getData?: Data<D & Props>) => AppActionsResult<M, AC, D, E> : (model: M, getData?: Data<D & Props>) => AppActionsResult<M, AC, D, E>;
42
40
  /**
43
- * Handle returned by {@link useApp}. Same surface as {@link App} minus
44
- * `Boundary` (which can only be rendered from the App declaration site)
45
- * and `Scope` (which is module-level, not per-render). Use this inside
46
- * reusable components that need to run under more than one App.
47
- *
48
- * The generic `S` is the Env shape (or union of shapes) the component
49
- * expects. Pass a union of every App's Env type in your monorepo and
50
- * read keys that exist on every member directly; reach for `in` /
51
- * `typeof` type-guards when accessing keys that only exist on a subset.
52
- *
53
- * @template S The Env shape (or union) the caller expects to be in scope.
41
+ * Returned from {@link App}. Bundles the Boundary, hooks, and Resource
42
+ * factory bound to a single typed Env shape `E`.
54
43
  */
55
- export type UseAppHandle<S extends object> = {
44
+ export type AppHandle<E extends object> = {
45
+ /**
46
+ * Boundary component for this App. Wraps the subtree with the `env`
47
+ * and `tap` declared on {@link App} &mdash; both are fixed at App
48
+ * construction time and cannot be overridden at the render site.
49
+ * Runtime mutations to the Env flow through
50
+ * `context.actions.produce(({ env }) => { ... })`; if a test or
51
+ * storybook needs a different initial Env, declare a separate `App`.
52
+ */
53
+ readonly Boundary: import('react').FC<{
54
+ children: import('react').ReactNode;
55
+ }>;
56
+ /**
57
+ * Hook returning a stable `Context` handle. The handle's
58
+ * `context.useActions(model?, getData?)` materialises the
59
+ * component's `[model, actions, data]` tuple. Every handler's
60
+ * `context.env` is typed as `E`.
61
+ */
62
+ readonly useContext: <M extends Model | void = void, AC extends Actions | void = void, D extends Props = Props>() => AppContextHandle<M, AC, D, E>;
63
+ /**
64
+ * Read-only Proxy over the per-Boundary Env, typed as `E`. Reads use
65
+ * dot notation (`env.session`) and always reflect the latest value
66
+ * across `await` boundaries. Writes flow through
67
+ * `context.actions.produce(({ env }) => { ... })`.
68
+ */
69
+ readonly useEnv: () => Readonly<E>;
56
70
  /**
57
- * Same as `app.useContext` &mdash; reads the nearest `<app.Boundary>`'s
58
- * dispatch surface, with `context.env` typed against `S`.
71
+ * `Resource` factory bound to this App's Env. Resources declared
72
+ * through this factory share the cache passed to `App({ cache })`
73
+ * &mdash; or fall back to a per-resource in-memory slot when no
74
+ * cache is configured on the App.
59
75
  */
60
- readonly useContext: <M extends Model | void = void, AC extends Actions | void = void, D extends Props = Props>() => AppContextHandle<M, AC, D, S>;
76
+ readonly Resource: AppResource<E>;
61
77
  /**
62
- * Same as `app.useEnv` &mdash; read-only Proxy over the nearest
63
- * `<app.Boundary>`'s Env, typed as `S`.
78
+ * Opens a typed multicast scope. The generic `MulticastActions` declares
79
+ * the `Distribution.Multicast` action class (or union of classes)
80
+ * whose dispatches are routed through this scope &mdash; the
81
+ * returned handle mirrors the App surface but widens
82
+ * `useContext().actions.dispatch` to accept actions from `MulticastActions`
83
+ * on top of the local `AC` class.
84
+ *
85
+ * Render `<scope.Boundary>` to open the scope at runtime; nesting
86
+ * multiple boundaries from different `app.Scope()` calls is fine,
87
+ * each runs as an independent emitter shadowed for its subtree.
88
+ *
89
+ * The Scope handle deliberately does NOT expose a further `Scope`
90
+ * method &mdash; the multicast surface must be declared at the
91
+ * `app.Scope<MulticastActions>()` call site so the type union is explicit.
64
92
  */
65
- readonly useEnv: () => Readonly<S>;
93
+ readonly Scope: <MulticastActions>() => import('../scope/types.ts').ScopeHandle<E, MulticastActions>;
66
94
  };
67
95
  /**
68
96
  * `Context` handle returned by `app.useContext()`. Mirrors the base
69
- * {@link Context} but threads the App's Env shape `S` through every
97
+ * {@link Context} but threads the App's Env shape `E` through every
70
98
  * handler's `context.env` and produce draft.
71
99
  *
72
100
  * @template M The model type for the component's state, or `void`.
@@ -74,9 +102,9 @@ export type UseAppHandle<S extends object> = {
74
102
  * definitions, or `void` for actions-only consumers.
75
103
  * @template D The reactive data type returned from the `useActions(...)`
76
104
  * data callback.
77
- * @template S The App's Env shape, supplied at `App({env})` time.
105
+ * @template E The App's Env shape, supplied at `App({env})` time.
78
106
  */
79
- export type AppContextHandle<M, AC, D, S> = {
107
+ export type AppContextHandle<M, AC, D, E> = {
80
108
  /**
81
109
  * Stable dispatch surface available before `useActions(...)` runs.
82
110
  * Exposes only `dispatch(action, payload?)` &mdash; useful when an
@@ -102,6 +130,6 @@ export type AppContextHandle<M, AC, D, S> = {
102
130
  * second &mdash; the callback re-runs every render so handlers reading
103
131
  * `context.data` always see fresh values across `await` boundaries.
104
132
  */
105
- readonly useActions: AppUseActions<M, AC, D, S>;
133
+ readonly useActions: AppUseActions<M, AC, D, E>;
106
134
  };
107
135
  export {};