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 +60 -28
- package/dist/app/index.d.ts +83 -116
- package/dist/app/types.d.ts +68 -40
- package/dist/boundary/components/env/index.d.ts +14 -3
- package/dist/boundary/components/sharing/index.d.ts +3 -3
- package/dist/boundary/index.d.ts +2 -2
- package/dist/boundary/types.d.ts +1 -1
- package/dist/cache/index.d.ts +66 -10
- package/dist/cache/types.d.ts +32 -18
- package/dist/context/index.d.ts +1 -1
- package/dist/error/index.d.ts +18 -1
- package/dist/error/types.d.ts +0 -17
- package/dist/index.d.ts +4 -2
- package/dist/march-hare.js +8 -7
- package/dist/march-hare.js.map +1 -0
- package/dist/march-hare.umd.cjs +2 -1
- package/dist/march-hare.umd.cjs.map +1 -0
- package/dist/resource/index.d.ts +49 -51
- package/dist/resource/types.d.ts +42 -19
- package/dist/resource/utils.d.ts +29 -1
- package/dist/scope/index.d.ts +33 -58
- package/dist/scope/types.d.ts +6 -6
- package/dist/scope/utils.d.ts +12 -0
- package/dist/shared/index.d.ts +15 -0
- package/dist/types/index.d.ts +116 -28
- package/dist/utils/types.d.ts +0 -2
- package/dist/utils/utils.d.ts +0 -2
- package/dist/with/index.d.ts +16 -61
- package/dist/with/types.d.ts +66 -0
- package/dist/with/utils.d.ts +61 -0
- package/package.json +4 -1
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
|
|
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-
|
|
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.
|
|
396
|
+
For remote data, declare an `app.Resource` at module scope. The resulting handle has two call forms:
|
|
397
|
+
|
|
398
|
+
- `resource.user.get(params)` — 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)` — 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 – it resets on every page load. To keep the most recent successful payload around between sessions,
|
|
544
|
+
By default an `app.Resource`'s cache is in-memory only – 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
|
-
//
|
|
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
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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 – 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
|
-
|
|
609
|
+
The adapter contract is **strictly synchronous** – `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 – 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>` — 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>()` — 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
|
-
|
|
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 — 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` — 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 — `E` is purely a type-level binding the caller supplies so reusable code stays App-agnostic.
|
|
804
829
|
|
|
805
830
|
```tsx
|
|
806
|
-
import {
|
|
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
|
|
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.
|
|
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/
|
|
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
|
|
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
|
|
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
|
-
|
|
894
|
+
`shared.Resource<E, T, P>` is the same story for shared resources — 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 — the fetcher never touches `context.env`, the hook never calls `shared.useEnv` — 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 — flipping a boolean, assigning a payload to a leaf, pinning a field to a fixed value — reach for `context.with.{update,invert,always}`. See the [`With` helpers recipe](./recipes/with-helpers.md) for the full surface.
|
package/dist/app/index.d.ts
CHANGED
|
@@ -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 {
|
|
4
|
-
import { AppContextHandle, AppResource, UseAppHandle } from './types';
|
|
4
|
+
import { AppHandle, AppContextHandle } from './types';
|
|
5
5
|
import { Tap } from '../boundary/components/tap/types';
|
|
6
|
-
|
|
7
|
-
export type {
|
|
6
|
+
export type { AppArgs, AppContextHandle, AppFetcher, AppResource, } from './types';
|
|
7
|
+
export type { AppHandle } from './types';
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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} — 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 — 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 — 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` — the entrypoint for a typed Env shape `S`,
|
|
64
|
-
* inferred from `config.env`. `App<S>` exposes `Boundary`, hooks, and
|
|
9
|
+
* Creates an `App` — 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 — useful for analytics, audit logging,
|
|
72
|
-
* Sentry breadcrumbs. See `recipes/tap.md`.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
18
|
+
* Sentry breadcrumbs. See `recipes/tap.md`. Pass `cache` to persist
|
|
19
|
+
* every `app.Resource(fetcher)` declaration through a single
|
|
20
|
+
* {@link Cache} — 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
|
|
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 — 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<
|
|
131
|
-
env?:
|
|
75
|
+
export declare function App<E extends object = Env>(config?: {
|
|
76
|
+
env?: E;
|
|
132
77
|
tap?: Tap;
|
|
133
|
-
|
|
78
|
+
cache?: Cache;
|
|
79
|
+
}): AppHandle<E>;
|
|
134
80
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
* component
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
81
|
+
* Standalone counterpart to `app.useContext`, exported as
|
|
82
|
+
* `shared.useContext` — 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 — 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 —
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
170
|
-
* const
|
|
171
|
-
* const
|
|
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
|
-
*
|
|
178
|
-
*
|
|
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
|
+
* — 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
|
|
152
|
+
export declare function useEnv<E extends object>(): Readonly<E>;
|
package/dist/app/types.d.ts
CHANGED
|
@@ -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/
|
|
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 `
|
|
8
|
+
* base `Resource` fetcher's args but with `env` typed as `E`.
|
|
10
9
|
*/
|
|
11
|
-
export type AppArgs<
|
|
12
|
-
readonly env: Readonly<
|
|
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 `
|
|
15
|
+
* `context.env` is typed against the App's inferred Env shape `E`.
|
|
17
16
|
*/
|
|
18
|
-
export type AppFetcher<
|
|
17
|
+
export type AppFetcher<E, T, P extends object = Record<never, never>> = (context: AppArgs<E, P>) => Promise<T>;
|
|
19
18
|
/**
|
|
20
|
-
* `app.Resource(fetcher)`
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* the App
|
|
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<
|
|
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 `
|
|
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,
|
|
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,
|
|
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
|
-
*
|
|
44
|
-
*
|
|
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
|
|
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} — 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
|
-
*
|
|
58
|
-
*
|
|
71
|
+
* `Resource` factory bound to this App's Env. Resources declared
|
|
72
|
+
* through this factory share the cache passed to `App({ cache })`
|
|
73
|
+
* — or fall back to a per-resource in-memory slot when no
|
|
74
|
+
* cache is configured on the App.
|
|
59
75
|
*/
|
|
60
|
-
readonly
|
|
76
|
+
readonly Resource: AppResource<E>;
|
|
61
77
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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 — 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 — the multicast surface must be declared at the
|
|
91
|
+
* `app.Scope<MulticastActions>()` call site so the type union is explicit.
|
|
64
92
|
*/
|
|
65
|
-
readonly
|
|
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 `
|
|
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
|
|
105
|
+
* @template E The App's Env shape, supplied at `App({env})` time.
|
|
78
106
|
*/
|
|
79
|
-
export type AppContextHandle<M, AC, D,
|
|
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?)` — useful when an
|
|
@@ -102,6 +130,6 @@ export type AppContextHandle<M, AC, D, S> = {
|
|
|
102
130
|
* second — 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,
|
|
133
|
+
readonly useActions: AppUseActions<M, AC, D, E>;
|
|
106
134
|
};
|
|
107
135
|
export {};
|