march-hare 0.12.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 +42 -25
- package/dist/app/index.d.ts +29 -83
- package/dist/app/types.d.ts +75 -22
- 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 +2 -0
- package/dist/march-hare.js +7 -6
- 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 +30 -59
- package/dist/resource/types.d.ts +42 -19
- package/dist/resource/utils.d.ts +29 -1
- package/dist/scope/index.d.ts +4 -64
- package/dist/scope/types.d.ts +6 -6
- package/dist/scope/utils.d.ts +12 -0
- package/dist/shared/index.d.ts +12 -21
- package/dist/types/index.d.ts +111 -26
- 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.
|
|
@@ -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,15 +814,16 @@ export const app = App();
|
|
|
800
814
|
|
|
801
815
|
## Reusable components
|
|
802
816
|
|
|
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
|
+
|
|
803
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:
|
|
804
820
|
|
|
805
|
-
| Bound to an App
|
|
806
|
-
|
|
|
807
|
-
| `app.useContext<M, A, D>()`
|
|
808
|
-
| `app.useEnv()`
|
|
809
|
-
| `app.Resource<T, P>(...)`
|
|
810
|
-
| `app.
|
|
811
|
-
| `app.Scope<A>()` | `shared.Scope<E, A>()` |
|
|
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>()` |
|
|
812
827
|
|
|
813
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.
|
|
814
829
|
|
|
@@ -876,6 +891,8 @@ function Where(): React.ReactElement {
|
|
|
876
891
|
}
|
|
877
892
|
```
|
|
878
893
|
|
|
879
|
-
`shared.Resource<E, T, P>`
|
|
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.
|
|
880
897
|
|
|
881
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 } from './types';
|
|
4
|
+
import { AppHandle, AppContextHandle } from './types';
|
|
5
5
|
import { Tap } from '../boundary/components/tap/types';
|
|
6
|
-
import * as React from "react";
|
|
7
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,10 +72,11 @@ 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
81
|
* Standalone counterpart to `app.useContext`, exported as
|
|
136
82
|
* `shared.useContext` — same call shape, but takes the **Env
|
package/dist/app/types.d.ts
CHANGED
|
@@ -1,47 +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>;
|
|
40
|
+
/**
|
|
41
|
+
* Returned from {@link App}. Bundles the Boundary, hooks, and Resource
|
|
42
|
+
* factory bound to a single typed Env shape `E`.
|
|
43
|
+
*/
|
|
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>;
|
|
70
|
+
/**
|
|
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.
|
|
75
|
+
*/
|
|
76
|
+
readonly Resource: AppResource<E>;
|
|
77
|
+
/**
|
|
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.
|
|
92
|
+
*/
|
|
93
|
+
readonly Scope: <MulticastActions>() => import('../scope/types.ts').ScopeHandle<E, MulticastActions>;
|
|
94
|
+
};
|
|
42
95
|
/**
|
|
43
96
|
* `Context` handle returned by `app.useContext()`. Mirrors the base
|
|
44
|
-
* {@link Context} but threads the App's Env shape `
|
|
97
|
+
* {@link Context} but threads the App's Env shape `E` through every
|
|
45
98
|
* handler's `context.env` and produce draft.
|
|
46
99
|
*
|
|
47
100
|
* @template M The model type for the component's state, or `void`.
|
|
@@ -49,9 +102,9 @@ type AppUseActions<M, AC, D, S> = M extends void ? (getData?: Data<D & Props>) =
|
|
|
49
102
|
* definitions, or `void` for actions-only consumers.
|
|
50
103
|
* @template D The reactive data type returned from the `useActions(...)`
|
|
51
104
|
* data callback.
|
|
52
|
-
* @template
|
|
105
|
+
* @template E The App's Env shape, supplied at `App({env})` time.
|
|
53
106
|
*/
|
|
54
|
-
export type AppContextHandle<M, AC, D,
|
|
107
|
+
export type AppContextHandle<M, AC, D, E> = {
|
|
55
108
|
/**
|
|
56
109
|
* Stable dispatch surface available before `useActions(...)` runs.
|
|
57
110
|
* Exposes only `dispatch(action, payload?)` — useful when an
|
|
@@ -77,6 +130,6 @@ export type AppContextHandle<M, AC, D, S> = {
|
|
|
77
130
|
* second — the callback re-runs every render so handlers reading
|
|
78
131
|
* `context.data` always see fresh values across `await` boundaries.
|
|
79
132
|
*/
|
|
80
|
-
readonly useActions: AppUseActions<M, AC, D,
|
|
133
|
+
readonly useActions: AppUseActions<M, AC, D, E>;
|
|
81
134
|
};
|
|
82
135
|
export {};
|
|
@@ -3,17 +3,28 @@ import * as React from "react";
|
|
|
3
3
|
export { useEnv } from './utils';
|
|
4
4
|
/**
|
|
5
5
|
* Loose runtime shape for the per-`<Boundary>` Env. Each {@link App}
|
|
6
|
-
* narrows this to its own typed env via `App<
|
|
6
|
+
* narrows this to its own typed env via `App<E>({ env })`; the
|
|
7
7
|
* loose type exists so the framework's internal plumbing
|
|
8
8
|
* (`<Boundary>`, `useEnv`, handler `context.env`, Resource
|
|
9
|
-
* fetcher `context.env`) does not need to be parametric over
|
|
9
|
+
* fetcher `context.env`) does not need to be parametric over E.
|
|
10
10
|
*
|
|
11
11
|
* Consumers should declare their Env shape inline via `App({ env })`
|
|
12
|
-
* — the inferred `
|
|
12
|
+
* — the inferred `E` is what flows through `app.useContext`,
|
|
13
13
|
* `app.useEnv`, and `app.Resource`. Module augmentation of `Env`
|
|
14
14
|
* is no longer required.
|
|
15
15
|
*/
|
|
16
16
|
export type Env = Record<string, unknown>;
|
|
17
|
+
/**
|
|
18
|
+
* `E` generic for `shared.X<E, ...>` factories whose callers don't read
|
|
19
|
+
* anything off the Env. Equivalent to `Record<never, never>` — the
|
|
20
|
+
* named alias keeps consumer sites legible (`shared.Resource<Envless, T>`
|
|
21
|
+
* over `shared.Resource<Record<never, never>, T>`) and signals intent.
|
|
22
|
+
*
|
|
23
|
+
* Reach for `Envless` only when the component or resource is genuinely
|
|
24
|
+
* Env-agnostic. Anything that reads `context.env.x` should declare the
|
|
25
|
+
* required shape (or a union of host Envs) as `E` instead.
|
|
26
|
+
*/
|
|
27
|
+
export type Envless = Record<never, never>;
|
|
17
28
|
/**
|
|
18
29
|
* Provides a per-Boundary {@link Env} value to every component inside
|
|
19
30
|
* the boundary. Usually wired in via the `<Boundary env={initial}>`
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Invocation } from '../../../resource/index';
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
/**
|
|
4
4
|
* Per-`<Boundary>` registry for `.coalesce(token)` sharing. Outer map
|
|
5
|
-
* keys on the `
|
|
5
|
+
* keys on the `Invocation.run` function identity (stable per Resource
|
|
6
6
|
* via the `build()` closure); inner map keys on
|
|
7
7
|
* `${paramsKey}|${coalesceKey(token)}`. While an entry exists every
|
|
8
8
|
* caller awaiting `.coalesce(token)` for the same Resource + params +
|
|
@@ -14,7 +14,7 @@ import * as React from "react";
|
|
|
14
14
|
*
|
|
15
15
|
* @internal
|
|
16
16
|
*/
|
|
17
|
-
export type Sharing = WeakMap<
|
|
17
|
+
export type Sharing = WeakMap<Invocation<unknown, object>["run"], Map<string, Promise<unknown>>>;
|
|
18
18
|
/**
|
|
19
19
|
* React context exposing the per-Boundary sharing registry. The
|
|
20
20
|
* fallback is a fresh `WeakMap` used when `useSharing()` is read
|
package/dist/boundary/index.d.ts
CHANGED
|
@@ -5,9 +5,9 @@ import * as React from "react";
|
|
|
5
5
|
* Env, and Tasks providers required by every March Hare hook.
|
|
6
6
|
*
|
|
7
7
|
* Most applications should reach for {@link App} instead —
|
|
8
|
-
* `App<
|
|
8
|
+
* `App<E>({ env })` returns a typed `app.Boundary` along with
|
|
9
9
|
* matching `useContext` / `useEnv` / `Resource` factories that all
|
|
10
|
-
* close over the App's inferred env shape `
|
|
10
|
+
* close over the App's inferred env shape `E`. The bare `Boundary`
|
|
11
11
|
* is exposed for advanced or library-internal use where the loose
|
|
12
12
|
* Env record type is sufficient.
|
|
13
13
|
*
|
package/dist/boundary/types.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ import type * as React from "react";
|
|
|
18
18
|
export type Props = {
|
|
19
19
|
/**
|
|
20
20
|
* Initial value of the per-Boundary {@link Env}. Prefer `App({ env })`
|
|
21
|
-
* — it infers the Env shape `
|
|
21
|
+
* — it infers the Env shape `E` and threads it through
|
|
22
22
|
* `app.useContext`, `app.useEnv`, and `app.Resource`, so handler
|
|
23
23
|
* `context.env` is typed accordingly. Pass `env` directly here only
|
|
24
24
|
* for advanced cases where the loose record type is sufficient.
|
package/dist/cache/index.d.ts
CHANGED
|
@@ -2,18 +2,28 @@ import { Adapter, Stored } from './types';
|
|
|
2
2
|
export type { Adapter, Encoded } from './types';
|
|
3
3
|
/**
|
|
4
4
|
* Persistence-aware cache for a single {@link Resource}. Wraps a
|
|
5
|
-
* synchronous {@link Adapter} (localStorage, MMKV,
|
|
6
|
-
* sync facade, etc.) and traffics in {@link
|
|
7
|
-
* storage entries serialise as {@link
|
|
8
|
-
* `Temporal.Instant` timestamp survives the
|
|
9
|
-
* `.exceeds({...})` can short-circuit on the
|
|
10
|
-
* a reload.
|
|
5
|
+
* **strictly synchronous** {@link Adapter} (localStorage, MMKV,
|
|
6
|
+
* chrome.storage with a sync facade, etc.) and traffics in {@link
|
|
7
|
+
* Stored} envelopes — storage entries serialise as {@link
|
|
8
|
+
* Encoded}`<T>` so the `Temporal.Instant` timestamp survives the
|
|
9
|
+
* string round-trip and `.exceeds({...})` can short-circuit on the
|
|
10
|
+
* persisted timestamp after a reload.
|
|
11
|
+
*
|
|
12
|
+
* Every method on the Cache is sync — the model-literal sync
|
|
13
|
+
* read has no place to wait, so the adapter contract foregoes
|
|
14
|
+
* `Promise` entirely. Async backends (IndexedDB, AsyncStorage,
|
|
15
|
+
* `chrome.storage.local`) need a sync facade hydrated at app entry;
|
|
16
|
+
* see `recipes/storage.md` for the pattern. React Native projects
|
|
17
|
+
* should reach for {@link https://github.com/mrousavy/react-native-mmkv
|
|
18
|
+
* `react-native-mmkv`} — it's synchronous out of the box and
|
|
19
|
+
* drops straight into the Adapter contract.
|
|
11
20
|
*
|
|
12
21
|
* Call with no arguments for an in-memory cache scoped to this
|
|
13
|
-
* instance — useful for tests, ephemeral state, or when you
|
|
14
|
-
* first-class cache object to share between Resources without
|
|
22
|
+
* instance — useful for tests, ephemeral state, or when you
|
|
23
|
+
* want a first-class cache object to share between Resources without
|
|
15
24
|
* persistence. Pass an {@link Adapter} to back the cache with a
|
|
16
|
-
* persistent store
|
|
25
|
+
* persistent store; when supplied, the adapter is the **only** tier
|
|
26
|
+
* — the Cache does not maintain a separate in-memory mirror.
|
|
17
27
|
*
|
|
18
28
|
* @example
|
|
19
29
|
* ```ts
|
|
@@ -36,9 +46,55 @@ export type { Adapter, Encoded } from './types';
|
|
|
36
46
|
* ```
|
|
37
47
|
*/
|
|
38
48
|
export type Cache = {
|
|
49
|
+
/**
|
|
50
|
+
* Returns the {@link Stored} envelope for `key`. The envelope is
|
|
51
|
+
* `empty()` when nothing is persisted; otherwise it carries the
|
|
52
|
+
* decoded payload and the timestamp recorded at write-time.
|
|
53
|
+
*
|
|
54
|
+
* @template T The payload type expected at `key`.
|
|
55
|
+
* @param key Cache slot identifier — usually the JSON-stringified
|
|
56
|
+
* call-site params, prefixed by the Resource's namespace.
|
|
57
|
+
*/
|
|
39
58
|
get<T>(key: string): Stored<T>;
|
|
40
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Writes `value` to `key`. Skipped when the envelope has no concrete
|
|
61
|
+
* payload (e.g. an `empty()` slot), since there is nothing meaningful
|
|
62
|
+
* to persist. Serialisation, quota errors, and unserialisable payloads
|
|
63
|
+
* are swallowed — writes are best-effort.
|
|
64
|
+
*
|
|
65
|
+
* @template T The payload type contained in `value`.
|
|
66
|
+
* @param key Cache slot identifier — usually the JSON-stringified
|
|
67
|
+
* call-site params, prefixed by the Resource's namespace.
|
|
68
|
+
* @param value Stored envelope carrying the payload and its
|
|
69
|
+
* write-time `Temporal.Instant`.
|
|
70
|
+
*/
|
|
71
|
+
set<T>(key: string, value: Stored<T>): void;
|
|
72
|
+
/**
|
|
73
|
+
* Drops a single cache slot. Best-effort — backing-store errors
|
|
74
|
+
* are swallowed.
|
|
75
|
+
*
|
|
76
|
+
* @param key Cache slot identifier.
|
|
77
|
+
*/
|
|
41
78
|
remove(key: string): void;
|
|
79
|
+
/**
|
|
80
|
+
* Drops every cache slot in the backing store. Best-effort —
|
|
81
|
+
* backing-store errors are swallowed.
|
|
82
|
+
*/
|
|
42
83
|
clear(): void;
|
|
84
|
+
/**
|
|
85
|
+
* Returns every key currently held by the backing store. Used by
|
|
86
|
+
* partial-match eviction (`evict(where)`) to iterate slots whose
|
|
87
|
+
* stored params satisfy a `where` pattern.
|
|
88
|
+
*/
|
|
89
|
+
keys(): Iterable<string>;
|
|
43
90
|
};
|
|
91
|
+
/**
|
|
92
|
+
* Constructs a {@link Cache} backed by `adapter`, or by an in-memory
|
|
93
|
+
* `Map` when none is supplied. The returned object is the same shape
|
|
94
|
+
* regardless — only the durability differs.
|
|
95
|
+
*
|
|
96
|
+
* @param adapter Optional synchronous backing store (localStorage, MMKV,
|
|
97
|
+
* or a custom sync facade). Omit for an in-memory cache scoped to
|
|
98
|
+
* this instance.
|
|
99
|
+
*/
|
|
44
100
|
export declare function Cache(adapter?: Adapter): Cache;
|