@opetope/react 0.1.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/CHANGELOG.md +5 -0
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/README.ru.md +266 -0
- package/dist/command-hook-controller.d.ts +33 -0
- package/dist/command-hook.d.ts +13 -0
- package/dist/command.d.ts +26 -0
- package/dist/commands-hook.d.ts +10 -0
- package/dist/contribution-frame-1td5XTES.js +2 -0
- package/dist/contribution-frame-1td5XTES.js.map +1 -0
- package/dist/contribution-frame.d.ts +38 -0
- package/dist/errors.d.ts +9 -0
- package/dist/feature-boundary.d.ts +29 -0
- package/dist/feature-demand.d.ts +21 -0
- package/dist/idle-subscription.d.ts +3 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/integration.d.ts +5 -0
- package/dist/integration.js +2 -0
- package/dist/integration.js.map +1 -0
- package/dist/model-binding.d.ts +21 -0
- package/dist/model-hook.d.ts +12 -0
- package/dist/model-selection-snapshot.d.ts +32 -0
- package/dist/model-selection-store.d.ts +17 -0
- package/dist/mount-context.d.ts +4 -0
- package/dist/mount-frame.d.ts +56 -0
- package/dist/mount-provider.d.ts +6 -0
- package/dist/readable-hooks.d.ts +3 -0
- package/dist/requires-models.d.ts +17 -0
- package/dist/resource-hook.d.ts +4 -0
- package/dist/scenario-diagnostics.d.ts +13 -0
- package/dist/scenario-mount.d.ts +17 -0
- package/dist/scenario-slot.d.ts +29 -0
- package/dist/scenario-types.d.ts +57 -0
- package/dist/scenario-wait.d.ts +20 -0
- package/dist/scenario.d.ts +5 -0
- package/dist/slot.d.ts +40 -0
- package/dist/testing.d.ts +5 -0
- package/dist/testing.js +4 -0
- package/dist/testing.js.map +1 -0
- package/package.json +77 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aleksei Berezin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# `@opetope/react`
|
|
2
|
+
|
|
3
|
+
The React binding for Opetope models and UI contributions. [The specification](../runtime/docs/spec.md) §3 sets the package vocabulary.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @opetope/core @opetope/runtime @opetope/react 'react@^19.0.0' 'react-dom@^19.0.0'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Use matching Opetope versions. For release candidates, append `@next` to every `@opetope/*` package in the command.
|
|
12
|
+
The API is ESM-only; Node 20.19+ is required. Development check commands below apply to a contributor checkout.
|
|
13
|
+
|
|
14
|
+
The normative EN/RU guides are shipped in `@opetope/runtime`: after installing it, open
|
|
15
|
+
`node_modules/@opetope/runtime/docs/spec.md` or `spec.ru.md`; recipes are in `cookbook.md` and `cookbook.ru.md`.
|
|
16
|
+
No GitHub access is needed to read those installed guides.
|
|
17
|
+
|
|
18
|
+
## Hello UI
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { defineModel } from '@opetope/core';
|
|
22
|
+
import type { Call, Readable } from '@opetope/core';
|
|
23
|
+
import { defineSlot, requiresModels, Slot, useModel } from '@opetope/react';
|
|
24
|
+
|
|
25
|
+
const CounterModel = defineModel<{
|
|
26
|
+
readonly count: Readable<number>;
|
|
27
|
+
readonly increment: Call<void, void>;
|
|
28
|
+
}>('example.counter.model');
|
|
29
|
+
const CounterSlot = defineSlot<{ readonly label: string }>({ id: 'example.counter.slot' });
|
|
30
|
+
|
|
31
|
+
const CounterButton = requiresModels([CounterModel])(({ label }: { readonly label: string }) => {
|
|
32
|
+
const {
|
|
33
|
+
count,
|
|
34
|
+
increment: { inFlight, lastError, run },
|
|
35
|
+
} = useModel(CounterModel, (model, { read }) => ({
|
|
36
|
+
count: read(model.count),
|
|
37
|
+
increment: model.increment,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<button disabled={inFlight} onClick={() => void run()}>
|
|
42
|
+
{lastError ? 'Retry' : `${label}: ${count}`}
|
|
43
|
+
</button>
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function CounterArea() {
|
|
48
|
+
return <Slot props={{ label: 'Count' }} target={CounterSlot} />;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
A component gets neither a service, nor a class, nor a runtime ref. The contribution mount grants its feature's `own` models implicitly; the component or hook that reads a per-mount
|
|
53
|
+
model declares it with `requiresModels`. `useModel` reads exactly that authority frame. `useCommand` returns `{ run, inFlight, result, lastError }`
|
|
54
|
+
and always resolves the outcome, so a normal cancellation never becomes an unhandled rejection (D116).
|
|
55
|
+
|
|
56
|
+
`run` is stable while the invoker stays the same; the returned object changes as `inFlight`, `result` or
|
|
57
|
+
`lastError` changes. Destructure `run` for effect dependencies.
|
|
58
|
+
|
|
59
|
+
The hook schedules nothing. Every `run` reaches the call, and the policy the call was created with decides what
|
|
60
|
+
happens to it (D203). For an absolute value setter the model declares `policy: 'latest'` in `context.call`, and the
|
|
61
|
+
newest input then replaces the waiting one there (D185). What the consumer keeps is its own: an already-aborted
|
|
62
|
+
input signal is refused as `cancelled` without reaching the call, a run of an unmounted consumer is refused the same
|
|
63
|
+
way, `inFlight` is true while any run this consumer started is unsettled, and every run carries its own callbacks
|
|
64
|
+
and signal.
|
|
65
|
+
|
|
66
|
+
For several commands use an object selection (D174):
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
const { save, remove } = useCommands({ save: model.save, remove: other.remove });
|
|
70
|
+
|
|
71
|
+
void save.run(input);
|
|
72
|
+
const saving = save.inFlight;
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Import `useCommands` from `@opetope/react`. Each selected field returns the same `CommandHook` as `useCommand`,
|
|
76
|
+
with its own input/output types, status, result and error. Keys are local UI names; commands may come from
|
|
77
|
+
different granted models. There is no second argument: each call carries its own policy.
|
|
78
|
+
|
|
79
|
+
An inline object needs no `useMemo`. Reordering keys or updating a sibling preserves an unchanged field's object
|
|
80
|
+
and `.run`. Adding or removing keys does not change the number of React hooks. Removing a key or replacing its
|
|
81
|
+
command starts a fresh local status for that field; abandoned renders leave the
|
|
82
|
+
committed selection intact. Two aliases of one Call have independent local statuses, just like two `useCommand`
|
|
83
|
+
consumers; they use the same Call policy and mount command record (D197, D203). A group introduces no shared busy state, queue or transaction; controls that overwrite one value still
|
|
84
|
+
need one semantic command. Only own enumerable properties are selected, including symbol keys.
|
|
85
|
+
|
|
86
|
+
## Selecting data and commands
|
|
87
|
+
|
|
88
|
+
`useModel(Declaration, (model, { read }) => ({ ... }))` combines explicit data selection and command consumers (D205, D214).
|
|
89
|
+
|
|
90
|
+
A model selection can use a named interface without an index signature (D217). Its result is a flat data record;
|
|
91
|
+
arrays, functions, constructors and built-in collection/date/promise objects are not selection records.
|
|
92
|
+
`read(source)` returns its snapshot; `read(source, project)` returns a projection. Authentic Calls selected as record
|
|
93
|
+
fields become the same `CommandHook` as `useCommands`, including independent alias statuses and stable `.run`.
|
|
94
|
+
The one-argument form still returns the granted model; individual hooks remain available.
|
|
95
|
+
|
|
96
|
+
Only explicitly read sources are subscribed, once per distinct Readable in the selection. Returned data fields are
|
|
97
|
+
compared with `Object.is`; select scalar fields, spread a projected record into the selection, or return stable
|
|
98
|
+
references. A newly allocated nested object is a changed field. The callback is pure: no hooks, commands or side effects.
|
|
99
|
+
A read or selector error reaches the nearest React error boundary. Changing the selected sources or command keys
|
|
100
|
+
changes their subscriptions/consumers at commit; an abandoned render cannot replace committed authority.
|
|
101
|
+
|
|
102
|
+
The hook neither creates a model nor acquires a feature or retains a Resource. Use `useResource` when a consumer
|
|
103
|
+
must keep a resource active. Combining hooks does not promise fewer source subscriptions or faster renders; a raw
|
|
104
|
+
`useModel` import now also includes the selection implementation. There is no additional Call scheduler.
|
|
105
|
+
|
|
106
|
+
## Application host
|
|
107
|
+
|
|
108
|
+
The host opens a feature graph with `openApplication`. Ready features publish their UI contributions atomically;
|
|
109
|
+
the ordinary `Slot` mounts them into consumer-owned targets:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
<Slot target={exampleFooterSlot} />
|
|
113
|
+
<Slot target={exampleSettingsSlot} />
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The application owns feature lifetimes; each feature's UI mounts through its contributions.
|
|
117
|
+
|
|
118
|
+
## Feature demand boundary
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
import { Slot } from '@opetope/react';
|
|
122
|
+
import { FeatureBoundary, useFeatureRetry } from '@opetope/react/integration';
|
|
123
|
+
|
|
124
|
+
function Retry() {
|
|
125
|
+
return <button onClick={useFeatureRetry()}>Try again</button>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
<FeatureBoundary demand={host} error={<Retry />} fallback={<Spinner />}>
|
|
129
|
+
<Slot props={{ itemId, onConfirmed }} target={confirmActionContentSlot} />
|
|
130
|
+
</FeatureBoundary>;
|
|
131
|
+
|
|
132
|
+
// or with render callbacks, when the ready instance itself is needed (D177)
|
|
133
|
+
<FeatureBoundary
|
|
134
|
+
demand={host}
|
|
135
|
+
error={({ error, retry }) => <Failure error={error} onRetry={retry} />}
|
|
136
|
+
fallback={<Spinner />}
|
|
137
|
+
>
|
|
138
|
+
{({ exports }) => <TasksResourceLease resource={exports.tasks}>{children}</TasksResourceLease>}
|
|
139
|
+
</FeatureBoundary>;
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`FeatureBoundary` consumes a `FeatureDemandSource` supplied by host integration. It holds the demand for the feature,
|
|
143
|
+
shows the ready branch once that demand is ready and isolates `useFeatureRetry` inside the error subtree. A branch may be a node or a
|
|
144
|
+
render callback: the callback of `children` receives the ready instance typed by the demand, the callback of `error`
|
|
145
|
+
receives `{ error, retry }`, and only the branch that is shown runs. The callback needs no `useFeature` of its own,
|
|
146
|
+
so a ready consumer does not acquire the demand a second time; hooks still live in child components, not in the
|
|
147
|
+
callback. There is no separate hook for
|
|
148
|
+
reading the error: the host passed it into `error` itself, so it knows it without a second word (D142). UI enters a feature through `Slot`: there is no
|
|
149
|
+
root, no `ui` section and no second binding API any more (D85).
|
|
150
|
+
|
|
151
|
+
## Contributions
|
|
152
|
+
|
|
153
|
+
```tsx
|
|
154
|
+
import { defineSlot, defineSwitchSlot, Slot } from '@opetope/react';
|
|
155
|
+
|
|
156
|
+
const HeaderEnd = defineSlot<{ readonly mode: 'desktop' | 'phone' }>({ id: 'example.headerEnd' });
|
|
157
|
+
const PageEnd = defineSwitchSlot<'home' | 'wallet', { readonly compact: boolean }>({
|
|
158
|
+
id: 'example.pageEnd',
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
function Header({ mode }: { readonly mode: 'desktop' | 'phone' }) {
|
|
162
|
+
return <Slot props={{ mode }} target={HeaderEnd} />;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function HomePageEnd() {
|
|
166
|
+
return <Slot props={{ compact: true }} target={PageEnd('home')} />;
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`defineSlot` creates an authentic target with an ordered set of contributions; `defineSwitchSlot` lazily creates and
|
|
171
|
+
caches a stable target per route. `Slot` reads the atomic snapshot and renders every `{ Component }` in the authority
|
|
172
|
+
frame of the instance that gave the contribution. The order and the stable key come from the contribution entries of the core, so
|
|
173
|
+
React introduces neither a second comparator nor a second lifetime registry.
|
|
174
|
+
|
|
175
|
+
`useResource(resource)` reads the atomic snapshot of a resource through `useSyncExternalStore` and holds one lease per
|
|
176
|
+
mounted consumer. When the reference changes, the snapshot follows the new resource and effect cleanup releases the previous lease.
|
|
177
|
+
|
|
178
|
+
Direct component props, a contribution's `props` adapter and its model's props `Readable` share the same
|
|
179
|
+
mount-owned snapshot. Incoming slot props publish in layout before paint, so direct and adapted renders cannot
|
|
180
|
+
mix new props with an old model snapshot. Model factory failures clean up partially created kernels.
|
|
181
|
+
Models belong to commit: an abandoned render creates no model to clean up (D170, D188).
|
|
182
|
+
StrictMode effect replay and Suspense hide/reveal preserve the committed model bundle and state. Only actual
|
|
183
|
+
identity replacement or unmount releases it; a hidden unmount releases in a microtask after React has disconnected
|
|
184
|
+
the layout effects (D209).
|
|
185
|
+
|
|
186
|
+
Demand retry is scoped to the source identity. Replacing a boundary's demand source allows its new retry to run
|
|
187
|
+
even if the previous source's retry is still pending.
|
|
188
|
+
|
|
189
|
+
## Scenario tests and physical activity
|
|
190
|
+
|
|
191
|
+
`createScenario(application, options)` from `@opetope/react/testing` opens the real application and its existing
|
|
192
|
+
inspection session (D206, D215). Supply the normal `imports`/`conditions` and a test-owned
|
|
193
|
+
`host.mount(Component)` adapter returning an `unmount()` handle. The package adds no DOM renderer or test-runner
|
|
194
|
+
dependency. `scenario.mount(target, { props })` uses the published Slot contributions and returns
|
|
195
|
+
`{ host, updateProps, unmount }`; `host` is the renderer's original result. Typed targets require `options.props`,
|
|
196
|
+
while targets without props omit it, exactly as with `Slot` (D217). Fixture commands do not bypass authority.
|
|
197
|
+
|
|
198
|
+
The synchronous constructor exposes `ready`, so a test can inspect a pending lazy body before readiness.
|
|
199
|
+
`waitFor(snapshot => predicate, { label, timeoutMs, pollIntervalMs })` wakes on inspection changes and also polls
|
|
200
|
+
external UI predicates; `notify()` wakes it after a controlled fixture update. The default deadline is 1000ms,
|
|
201
|
+
with a 10ms predicate poll. A `ScenarioTimeoutError` carries the data-only snapshot, bounded history and observed
|
|
202
|
+
conditions, feature phases, body loads, lane blockers and resource retention facts. It does not infer repository
|
|
203
|
+
or network causes. `getSnapshot()` and `history()` use that same observation model; history defaults to 64 snapshots,
|
|
204
|
+
activity to 256 records. Capacities accept integers from 1 to 10000. Do not replace predicates with a fixed number of ticks.
|
|
205
|
+
|
|
206
|
+
`close()` fences application admission synchronously, unmounts all registered screens and joins their cleanup with
|
|
207
|
+
physical application drain. Its deadline does not cancel cleanup: a later `close()` can await the same drain.
|
|
208
|
+
A readiness deadline likewise leaves the application available for inspection and explicit cleanup.
|
|
209
|
+
`ownership()` reports only registered runtime ownership, with `unknown` for missing, stale or truncated evidence;
|
|
210
|
+
a workspace stale snapshot is complete only after the scenario witnessed successful physical cleanup. This permits
|
|
211
|
+
a scoped zero-count assertion, without proving absence of arbitrary host, UI or GC leaks. Successful cleanup clears
|
|
212
|
+
application imports and internal renderer references. A failed cleanup promise can retain original errors and retry
|
|
213
|
+
capabilities; a caller that keeps `mounted.host` also keeps its own renderer result.
|
|
214
|
+
|
|
215
|
+
The inspection graph/frame schema is `/3`, with optional `opetope.runtime-activity/1` snapshots. Within one session,
|
|
216
|
+
a frame without `activity` preserves the previous activity; a full snapshot/reset without it clears that observation
|
|
217
|
+
(D216). Activity-bearing frames replace the previous activity in full.
|
|
218
|
+
Use matching runtime/devtools versions: `/2` readers reject the new revision. Activity identifies the execution,
|
|
219
|
+
actual feature generation, physical Calls, exact current lane blockers, registered resource leases and load attempts.
|
|
220
|
+
Host demand and UI models are unknown; stream observation covers state, not physical load identities. `freshness`
|
|
221
|
+
and `truncated` distinguish a complete live view from a partial or detached one. A closed session is stale;
|
|
222
|
+
`closed: true` requires successful physical application drain. No control authority or product payload is added.
|
|
223
|
+
Activity output is bounded by record capacity. Snapshot collection still visits registered owners, executors and
|
|
224
|
+
resources, so capacity does not bound traversal cost. Collection stops once truncation is proven;
|
|
225
|
+
idle executors may still require traversal to establish completeness. Normal call dispatch allocates no diagnostic record with
|
|
226
|
+
observation disabled. Graph frames remain bounded by the existing ring capacity.
|
|
227
|
+
|
|
228
|
+
## Testing
|
|
229
|
+
|
|
230
|
+
`@opetope/react/testing` exports component fixtures and application scenarios:
|
|
231
|
+
|
|
232
|
+
- `renderSlot(target, { contribution, models, props })` mounts the published target or one fixture contribution;
|
|
233
|
+
- `command(run)` produces an authentic `Call` for a model fixture.
|
|
234
|
+
|
|
235
|
+
The `renderSlot` harness returns `Slot` and `updateProps`: there are no roots and no fixtures for them, UI enters the
|
|
236
|
+
application as contributions (D85).
|
|
237
|
+
|
|
238
|
+
## Word map and entries
|
|
239
|
+
|
|
240
|
+
| Entry | What it holds |
|
|
241
|
+
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
242
|
+
| `@opetope/react` | `useModel`, `useCommand`, `useCommands`, `useReadable`, `useSelector`, `useResource`, `requiresModels`, `defineSlot`, `defineSwitchSlot`, `Slot`, `ContributionError`; types `SlotTarget`, `SwitchSlotTarget`, `SlotContribution`, `CommandHook`, `CommandOutcome` |
|
|
243
|
+
| `@opetope/react/integration` | `FeatureBoundary`, `useFeature`, `useFeatureRetry`, `FeatureBoundaryError` for the integration layer; types `FeatureBoundaryProps`, `FeatureDemandSource`, `FeatureDemandState`, `FeatureDemandResult`, `FeatureDemandLease` |
|
|
244
|
+
| `@opetope/react/testing` | `renderSlot`, `command`, `createScenario`, `ScenarioTimeoutError` and fixture/scenario types; tests only |
|
|
245
|
+
|
|
246
|
+
`Model` is a typed key for a record of `Readable`, `Call` and readable factories that UI components consume.
|
|
247
|
+
The constructors of the integration entry are not re-exported from the safe entry and cannot come back into
|
|
248
|
+
`ui/models/data/contracts` through a local barrel of a feature.
|
|
249
|
+
|
|
250
|
+
## Laws
|
|
251
|
+
|
|
252
|
+
- `useModel(declaration)` reads only the models declared by the contribution;
|
|
253
|
+
- one committed contribution owns its model frame until unmount or retire;
|
|
254
|
+
- an abandoned concurrent render holds no frame;
|
|
255
|
+
- `useCommand` does not subscribe to the invoker: the observable state of a call lies in a `Readable` of the model;
|
|
256
|
+
- the `cancelled` outcome moves neither `result` nor `lastError`, and it is only the `CallError` codes `cancelled`
|
|
257
|
+
and `closed`; a call to a weak port with no provider (`unavailable`) and a rejected publication
|
|
258
|
+
(`publication-rejected`) arrive as the `failed` outcome and settle in `lastError` (D138);
|
|
259
|
+
- `useSelector` keeps the selected reference when something else changed;
|
|
260
|
+
- closing and unmounting synchronously fence new calls and release the references of the frame.
|
|
261
|
+
|
|
262
|
+
## Compatibility contract
|
|
263
|
+
|
|
264
|
+
The ESM of the package is built for Chrome 82+, Firefox 110+, Safari/iOS 15+, Android 82+ and Node 20.19+ and requires
|
|
265
|
+
the peer `react >=19.0.0 <20` (D251). React and ReactDOM are peers of the host, not built-in polyfills. The raw modules are
|
|
266
|
+
executed by an HTTP import smoke test with a local bundle of the peers; historical browser builds are checked only by an external farm.
|
package/README.ru.md
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# `@opetope/react`
|
|
2
|
+
|
|
3
|
+
React-привязка для моделей и UI-вкладов Opetope. Словарь пакета задаёт §3 [спецификации](../runtime/docs/spec.ru.md).
|
|
4
|
+
|
|
5
|
+
## Установка
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install @opetope/core @opetope/runtime @opetope/react 'react@^19.0.0' 'react-dom@^19.0.0'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Используйте согласованные версии Opetope. Для release candidate добавьте `@next` каждому пакету `@opetope/*` в команде.
|
|
12
|
+
API поставляется только в ESM; требуется Node 20.19+. Команды разработки ниже относятся к contributor checkout.
|
|
13
|
+
|
|
14
|
+
Нормативные руководства EN/RU поставляются в `@opetope/runtime`: после его установки откройте
|
|
15
|
+
`node_modules/@opetope/runtime/docs/spec.md` или `spec.ru.md`; рецепты находятся в `cookbook.md` и `cookbook.ru.md`.
|
|
16
|
+
Для чтения установленных руководств доступ к GitHub не нужен.
|
|
17
|
+
|
|
18
|
+
## Hello UI
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { defineModel } from '@opetope/core';
|
|
22
|
+
import type { Call, Readable } from '@opetope/core';
|
|
23
|
+
import { defineSlot, requiresModels, Slot, useModel } from '@opetope/react';
|
|
24
|
+
|
|
25
|
+
const CounterModel = defineModel<{
|
|
26
|
+
readonly count: Readable<number>;
|
|
27
|
+
readonly increment: Call<void, void>;
|
|
28
|
+
}>('example.counter.model');
|
|
29
|
+
const CounterSlot = defineSlot<{ readonly label: string }>({ id: 'example.counter.slot' });
|
|
30
|
+
|
|
31
|
+
const CounterButton = requiresModels([CounterModel])(({ label }: { readonly label: string }) => {
|
|
32
|
+
const {
|
|
33
|
+
count,
|
|
34
|
+
increment: { inFlight, lastError, run },
|
|
35
|
+
} = useModel(CounterModel, (model, { read }) => ({
|
|
36
|
+
count: read(model.count),
|
|
37
|
+
increment: model.increment,
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<button disabled={inFlight} onClick={() => void run()}>
|
|
42
|
+
{lastError ? 'Retry' : `${label}: ${count}`}
|
|
43
|
+
</button>
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function CounterArea() {
|
|
48
|
+
return <Slot props={{ label: 'Count' }} target={CounterSlot} />;
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Компонент не получает ни сервис, ни класс, ни ref рантайма. Монтирование вклада неявно выдаёт модели `own` его фичи;
|
|
53
|
+
компонент или хук, читающий per-mount модель, объявляет её через `requiresModels`. `useModel` читает ровно этот кадр
|
|
54
|
+
авторитета. `useCommand` отдаёт `{ run, inFlight, result, lastError }`
|
|
55
|
+
и всегда resolve-ит исход, поэтому штатная отмена не становится unhandled rejection (D116).
|
|
56
|
+
|
|
57
|
+
`run` стабилен, пока invoker тот же; возвращённый объект меняется вместе с `inFlight`, `result` или `lastError`.
|
|
58
|
+
Для зависимостей эффекта берите `run` деструктуризацией.
|
|
59
|
+
|
|
60
|
+
Хук ничего не планирует. Каждый `run` доходит до вызова, и решает политика, с которой вызов создан (D203). Для
|
|
61
|
+
setter абсолютного значения модель объявляет `policy: 'latest'` в `context.call`, и новый вход заменяет ожидающий
|
|
62
|
+
именно там (D185). За потребителем остаётся своё: уже отменённый входной signal отвергается как `cancelled`, не
|
|
63
|
+
доходя до вызова, `run` размонтированного потребителя отвергается так же, `inFlight` истинен, пока не завершился
|
|
64
|
+
хоть один начатый им прогон, и у каждого прогона свои callbacks и signal.
|
|
65
|
+
|
|
66
|
+
Для нескольких команд используйте объектный выбор (D174):
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
const { save, remove } = useCommands({ save: model.save, remove: other.remove });
|
|
70
|
+
|
|
71
|
+
void save.run(input);
|
|
72
|
+
const saving = save.inFlight;
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Импортируйте `useCommands` из `@opetope/react`. Каждое поле возвращает прежний `CommandHook`, как `useCommand`,
|
|
76
|
+
со своими типами входа/выхода, статусом, результатом и ошибкой. Ключи — локальные имена UI; команды могут быть
|
|
77
|
+
из разных разрешённых моделей. Второго аргумента нет: политика есть у каждого вызова.
|
|
78
|
+
|
|
79
|
+
Объекту прямо в рендере не нужен `useMemo`. Перестановка ключей и обновление соседнего поля сохраняют объект
|
|
80
|
+
неизменившегося поля и `.run`. Добавление и удаление ключей не меняет число React hooks. Удаление ключа или
|
|
81
|
+
замена его команды начинает новый локальный статус этого поля; отброшенный
|
|
82
|
+
рендер не меняет текущий выбор. Два псевдонима одного Call имеют независимые локальные статусы, как два потребителя
|
|
83
|
+
`useCommand`; они используют одну политику Call и одну command-запись монтирования (D197, D203). Группа не вводит общий busy, очередь или транзакцию; контролам, перезаписывающим одно значение,
|
|
84
|
+
по-прежнему нужна одна смысловая команда. Выбираются только собственные enumerable-поля, включая symbol-ключи.
|
|
85
|
+
|
|
86
|
+
## Выбор данных и команд
|
|
87
|
+
|
|
88
|
+
`useModel(Declaration, (model, { read }) => ({ ... }))` объединяет явный выбор данных и command consumers (D205, D214).
|
|
89
|
+
|
|
90
|
+
Результат выбора модели может быть именованным interface без index signature (D217). Результат — плоская запись данных;
|
|
91
|
+
arrays, functions, constructors и встроенные объекты коллекций, дат и promises не являются selection records.
|
|
92
|
+
`read(source)` возвращает снимок; `read(source, project)` — проекцию. Authentic Calls, выбранные полями record,
|
|
93
|
+
превращаются в тот же `CommandHook`, что у `useCommands`, с независимыми статусами aliases и стабильным `.run`.
|
|
94
|
+
Форма с одним аргументом по-прежнему возвращает выданную модель; отдельные hooks сохраняются.
|
|
95
|
+
|
|
96
|
+
Подписка создаётся только на явно прочитанные источники, одна на каждый различный Readable внутри selection.
|
|
97
|
+
Поля данных сравниваются через `Object.is`: выбирайте скаляры, раскрывайте проекцию record в selection или
|
|
98
|
+
возвращайте стабильные ссылки. Новый вложенный объект считается изменившимся полем. Callback чистый:
|
|
99
|
+
без hooks, команд и side effects. Ошибка read/selector попадает в ближайший React error boundary.
|
|
100
|
+
Изменение источников и ключей команд применяется в commit; abandoned render не меняет действующие полномочия.
|
|
101
|
+
|
|
102
|
+
Hook не создаёт модель, не приобретает feature и не удерживает Resource. Для активного удержания ресурса
|
|
103
|
+
используйте `useResource`. Объединение hooks не обещает меньше подписок или более быстрый render;
|
|
104
|
+
импорт обычного `useModel` теперь также включает реализацию selection. Дополнительного scheduler для Call нет.
|
|
105
|
+
|
|
106
|
+
## Application host
|
|
107
|
+
|
|
108
|
+
Хост открывает граф фич через `openApplication`. Готовые фичи атомарно публикуют UI-вклады;
|
|
109
|
+
обычный `Slot` монтирует их в consumer-owned целях:
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
<Slot target={exampleFooterSlot} />
|
|
113
|
+
<Slot target={exampleSettingsSlot} />
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Приложение владеет временем жизни фич; UI каждой фичи монтируется через её вклады.
|
|
117
|
+
|
|
118
|
+
## Граница спроса на фичу
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
import { Slot } from '@opetope/react';
|
|
122
|
+
import { FeatureBoundary, useFeatureRetry } from '@opetope/react/integration';
|
|
123
|
+
|
|
124
|
+
function Retry() {
|
|
125
|
+
return <button onClick={useFeatureRetry()}>Try again</button>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
<FeatureBoundary demand={host} error={<Retry />} fallback={<Spinner />}>
|
|
129
|
+
<Slot props={{ itemId, onConfirmed }} target={confirmActionContentSlot} />
|
|
130
|
+
</FeatureBoundary>;
|
|
131
|
+
|
|
132
|
+
// или с render-колбэками, когда нужен сам готовый экземпляр (D177)
|
|
133
|
+
<FeatureBoundary
|
|
134
|
+
demand={host}
|
|
135
|
+
error={({ error, retry }) => <Failure error={error} onRetry={retry} />}
|
|
136
|
+
fallback={<Spinner />}
|
|
137
|
+
>
|
|
138
|
+
{({ exports }) => <TasksResourceLease resource={exports.tasks}>{children}</TasksResourceLease>}
|
|
139
|
+
</FeatureBoundary>;
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`FeatureBoundary` принимает `FeatureDemandSource` от интеграции с хостом. Он держит спрос на фичу,
|
|
143
|
+
показывает готовую ветвь после готовности спроса и изолирует `useFeatureRetry` в поддереве ошибки. Ветвь может быть узлом или
|
|
144
|
+
render-колбэком: колбэк `children` получает готовый экземпляр, типизированный по `demand`, колбэк `error` получает
|
|
145
|
+
`{ error, retry }`, и выполняется только показанная ветвь. Своего `useFeature` колбэку не нужно, поэтому готовый
|
|
146
|
+
потребитель не берёт спрос второй раз; хуки по-прежнему живут в дочерних компонентах, а не в колбэке. Отдельного хука для чтения ошибки
|
|
147
|
+
нет: хост сам передал её в `error`, поэтому знает её без второго слова (D142). UI входит в фичу через `Slot`: ни
|
|
148
|
+
корня, ни секции `ui`, ни второго API привязки больше нет (D85).
|
|
149
|
+
|
|
150
|
+
## Contributions
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
import { defineSlot, defineSwitchSlot, Slot } from '@opetope/react';
|
|
154
|
+
|
|
155
|
+
const HeaderEnd = defineSlot<{ readonly mode: 'desktop' | 'phone' }>({ id: 'example.headerEnd' });
|
|
156
|
+
const PageEnd = defineSwitchSlot<'home' | 'wallet', { readonly compact: boolean }>({
|
|
157
|
+
id: 'example.pageEnd',
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
function Header({ mode }: { readonly mode: 'desktop' | 'phone' }) {
|
|
161
|
+
return <Slot props={{ mode }} target={HeaderEnd} />;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function HomePageEnd() {
|
|
165
|
+
return <Slot props={{ compact: true }} target={PageEnd('home')} />;
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`defineSlot` создаёт подлинную цель с упорядоченным множеством вкладов; `defineSwitchSlot` лениво создаёт и
|
|
170
|
+
кеширует стабильную цель на каждый маршрут. `Slot` читает атомарный снимок и рендерит каждый `{ Component }` в кадре
|
|
171
|
+
авторитета того экземпляра, который вклад дал. Порядок и стабильный ключ приходят из записей вкладов ядра, поэтому
|
|
172
|
+
React не вводит второй компаратор и второй реестр времени жизни.
|
|
173
|
+
|
|
174
|
+
`useResource(resource)` читает атомарный снимок ресурса через `useSyncExternalStore` и держит один lease на
|
|
175
|
+
смонтированного потребителя. При смене ссылки snapshot следует новому ресурсу, а cleanup эффекта освобождает предыдущий lease.
|
|
176
|
+
|
|
177
|
+
Прямые пропсы компонента, `props`-адаптер вклада и props-`Readable` его модели используют один снимок
|
|
178
|
+
монтирования. Входящие пропсы слота публикуются в layout до paint, поэтому прямой и адаптированный рендеры
|
|
179
|
+
не смешивают новые пропсы со старым снимком модели. Отказ фабрики модели убирает частично созданный kernel.
|
|
180
|
+
Модели принадлежат коммиту: брошенный рендер не создаёт модель, которую пришлось бы убирать (D170, D188).
|
|
181
|
+
Повтор эффектов StrictMode и скрытие/раскрытие Suspense сохраняют закоммиченный набор моделей и состояние.
|
|
182
|
+
Только реальная замена идентичности или размонтирование освобождает его; при скрытом размонтировании это делает
|
|
183
|
+
микрозадача после того, как React отключил layout-эффекты (D209).
|
|
184
|
+
|
|
185
|
+
Retry спроса привязан к источнику. После замены источника boundary новый retry может начаться, даже если retry
|
|
186
|
+
старого ещё не завершён.
|
|
187
|
+
|
|
188
|
+
## Сценарные тесты и физическая активность
|
|
189
|
+
|
|
190
|
+
`createScenario(application, options)` из `@opetope/react/testing` открывает настоящее приложение и его существующую
|
|
191
|
+
inspection session (D206, D215). Передайте обычные `imports`/`conditions` и принадлежащий тесту адаптер
|
|
192
|
+
`host.mount(Component)`, возвращающий handle с `unmount()`. Пакет не добавляет зависимость от DOM renderer или test runner.
|
|
193
|
+
`scenario.mount(target, { props })` использует опубликованные Slot contributions и возвращает
|
|
194
|
+
`{ host, updateProps, unmount }`; `host` — исходный результат renderer. Typed targets требуют `options.props`,
|
|
195
|
+
а targets без props опускают его, как в `Slot` (D217). Fixtures не обходят authority команд.
|
|
196
|
+
|
|
197
|
+
Синхронный конструктор возвращает `ready`, поэтому pending lazy body можно исследовать до готовности.
|
|
198
|
+
`waitFor(snapshot => predicate, { label, timeoutMs, pollIntervalMs })` просыпается от inspection и дополнительно
|
|
199
|
+
опрашивает predicates внешнего UI; `notify()` будит его после изменения управляемой fixture. По умолчанию deadline
|
|
200
|
+
равен 1000ms, polling predicate — 10ms. `ScenarioTimeoutError` содержит data-only snapshot, ограниченную историю и
|
|
201
|
+
наблюдаемые conditions, фазы feature, body load, lane blockers и удержания ресурсов. Причины внутри repository
|
|
202
|
+
или сети не выводятся из догадок. `getSnapshot()` и `history()` используют ту же модель наблюдения; по умолчанию
|
|
203
|
+
хранятся 64 снимка, activity ограничена 256 записями. Capacities — целые от 1 до 10000.
|
|
204
|
+
Не заменяйте predicates фиксированным числом ticks.
|
|
205
|
+
|
|
206
|
+
`close()` синхронно ставит fence admission приложения, размонтирует зарегистрированные экраны и ждёт их cleanup
|
|
207
|
+
вместе с physical application drain. Deadline не отменяет cleanup: последующий `close()` может дождаться того же drain.
|
|
208
|
+
Deadline готовности также оставляет приложение доступным для inspection и явного закрытия.
|
|
209
|
+
`ownership()` описывает только зарегистрированное владение runtime; при отсутствующих, stale или усечённых данных
|
|
210
|
+
возвращается `unknown`. Терминальный stale-снимок считается полным лишь после подтверждённого сценарием успешного
|
|
211
|
+
физического cleanup. Это допускает проверку нулевых счётчиков в данном scope, но не доказывает отсутствие произвольных
|
|
212
|
+
host/UI/GC-утечек. Успешный cleanup очищает imports приложения и внутренние ссылки на renderer. Promise отказавшего
|
|
213
|
+
cleanup может удерживать исходные ошибки и retry capabilities; сохранённый пользователем `mounted.host` удерживает его renderer result.
|
|
214
|
+
|
|
215
|
+
Inspection graph/frame имеют схему `/3` и optional snapshots `opetope.runtime-activity/1`. В пределах одной session
|
|
216
|
+
frame без `activity` сохраняет предыдущую activity; полный snapshot/reset без этого поля очищает наблюдение (D216).
|
|
217
|
+
Frame с `activity` заменяет предыдущую activity целиком.
|
|
218
|
+
Используйте согласованные версии runtime/devtools: readers `/2` отвергают новую revision. Activity указывает execution,
|
|
219
|
+
фактическое поколение feature, физические Calls, точных текущих lane blockers, зарегистрированные leases и попытки загрузок.
|
|
220
|
+
Спрос хоста и UI-модели неизвестны; у stream наблюдается state, но не физические identities загрузок. `freshness`
|
|
221
|
+
и `truncated` отличают полное live-наблюдение от усечённого или отключённого. Закрытая session имеет stale-снимок;
|
|
222
|
+
`closed: true` требует успешного физического drain приложения. Control authority и продуктовые payload не добавляются.
|
|
223
|
+
Размер activity ограничен capacity записей. Сбор снимка обходит зарегистрированных owners, executors и resources,
|
|
224
|
+
поэтому capacity не ограничивает стоимость обхода. Сбор останавливается после доказанного truncation;
|
|
225
|
+
idle executors могут требовать обхода, чтобы подтвердить полноту данных. Обычный call dispatch не создаёт диагностических записей при
|
|
226
|
+
выключенном наблюдении. Frames ограничены существующей ring capacity.
|
|
227
|
+
|
|
228
|
+
## Testing
|
|
229
|
+
|
|
230
|
+
`@opetope/react/testing` экспортирует fixtures компонентов и сценарии приложения:
|
|
231
|
+
|
|
232
|
+
- `renderSlot(target, { contribution, models, props })` монтирует опубликованную цель либо один вклад-фикстуру;
|
|
233
|
+
- `command(run)` выдаёт подлинный `Call` для фикстуры модели.
|
|
234
|
+
|
|
235
|
+
Харнесс `renderSlot` отдаёт `Slot` и `updateProps`: корней и их фикстур больше нет, UI входит в приложение
|
|
236
|
+
вкладами (D85).
|
|
237
|
+
|
|
238
|
+
## Карта слов и входы
|
|
239
|
+
|
|
240
|
+
| Вход | Что содержит |
|
|
241
|
+
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
242
|
+
| `@opetope/react` | `useModel`, `useCommand`, `useCommands`, `useReadable`, `useSelector`, `useResource`, `requiresModels`, `defineSlot`, `defineSwitchSlot`, `Slot`, `ContributionError`; типы `SlotTarget`, `SwitchSlotTarget`, `SlotContribution`, `CommandHook`, `CommandOutcome` |
|
|
243
|
+
| `@opetope/react/integration` | `FeatureBoundary`, `useFeature`, `useFeatureRetry`, `FeatureBoundaryError` для integration-слоя; типы `FeatureBoundaryProps`, `FeatureDemandSource`, `FeatureDemandState`, `FeatureDemandResult`, `FeatureDemandLease` |
|
|
244
|
+
| `@opetope/react/testing` | `renderSlot`, `command`, `createScenario`, `ScenarioTimeoutError` и типы fixtures/scenarios; только для тестов |
|
|
245
|
+
|
|
246
|
+
`Model` — типизированный ключ записи из `Readable`, `Call` и фабрик readable, которую используют UI-компоненты.
|
|
247
|
+
Конструкторы integration-входа не переэкспортируются из безопасного входа и не могут вернуться в
|
|
248
|
+
`ui/models/data/contracts` через локальный barrel фичи.
|
|
249
|
+
|
|
250
|
+
## Законы
|
|
251
|
+
|
|
252
|
+
- `useModel(declaration)` читает только модели, объявленные вкладом;
|
|
253
|
+
- один закоммиченный вклад владеет своим кадром моделей до размонтирования или retire;
|
|
254
|
+
- брошенный concurrent-рендер кадр не удерживает;
|
|
255
|
+
- `useCommand` не подписывается на invoker: наблюдаемое состояние вызова лежит в `Readable` модели;
|
|
256
|
+
- исход `cancelled` не двигает ни `result`, ни `lastError`, и это только коды `CallError` `cancelled`
|
|
257
|
+
и `closed`; вызов слабого порта без провайдера (`unavailable`) и отклонённая публикация
|
|
258
|
+
(`publication-rejected`) приходят исходом `failed` и оседают в `lastError` (D138);
|
|
259
|
+
- `useSelector` сохраняет выбранную ссылку, когда изменилось что-то другое;
|
|
260
|
+
- закрытие и размонтирование синхронно фенсят новые вызовы и освобождают ссылки кадра.
|
|
261
|
+
|
|
262
|
+
## Compatibility contract
|
|
263
|
+
|
|
264
|
+
ESM пакета собирается для Chrome 82+, Firefox 110+, Safari/iOS 15+, Android 82+ и Node 20.19+ и требует peer
|
|
265
|
+
`react >=19.0.0 <20` (D251). React и ReactDOM это peers хоста, а не встроенные полифилы. Сырые модули исполняет smoke импорта
|
|
266
|
+
по HTTP с локальным бандлом peer-ов; исторические сборки браузеров проверяет только внешняя ферма.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CallRunOptions } from '@opetope/core/internal';
|
|
2
|
+
import type { CommandOutcome } from './command.js';
|
|
3
|
+
type CommandRunOptions<Output> = Readonly<{
|
|
4
|
+
onFailure?: (error: unknown) => PromiseLike<void> | void;
|
|
5
|
+
onSuccess?: (value: Output) => PromiseLike<void> | void;
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
}>;
|
|
8
|
+
type CommandRun<Input, Output> = [Input] extends [void] ? (input?: Input, options?: CommandRunOptions<Output>) => Promise<CommandOutcome<Output>> : (input: Input, options?: CommandRunOptions<Output>) => Promise<CommandOutcome<Output>>;
|
|
9
|
+
type CommandInvoker = Readonly<{
|
|
10
|
+
run: (input: never, options?: CallRunOptions<unknown>) => Promise<unknown>;
|
|
11
|
+
}>;
|
|
12
|
+
type CommandNotification<Output> = (inFlight: boolean, outcome?: CommandOutcome<Output>) => void;
|
|
13
|
+
/**
|
|
14
|
+
* D203: the consumer keeps a status, not a schedule. Every `run` reaches the call, and the policy the call was
|
|
15
|
+
* created with — `queue`, `latest`, `parallel`, `once`, `singleFlight` — decides what happens to it. What stays
|
|
16
|
+
* local is what only this consumer knows: whether it is mounted, its own input signal, and the outcome it shows.
|
|
17
|
+
*/
|
|
18
|
+
declare class CommandHookController<Input, Output> {
|
|
19
|
+
private readonly invoker;
|
|
20
|
+
readonly run: CommandRun<Input, Output>;
|
|
21
|
+
private inFlight;
|
|
22
|
+
private notify;
|
|
23
|
+
constructor(invoker: CommandInvoker);
|
|
24
|
+
attach(notify: CommandNotification<Output>): () => void;
|
|
25
|
+
synchronize(): void;
|
|
26
|
+
private aborted;
|
|
27
|
+
private cancelledInput;
|
|
28
|
+
private finish;
|
|
29
|
+
private invoke;
|
|
30
|
+
private start;
|
|
31
|
+
}
|
|
32
|
+
export { CommandHookController };
|
|
33
|
+
export type { CommandInvoker, CommandRun };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Call } from '@opetope/core';
|
|
2
|
+
import type { CommandRun } from './command-hook-controller.js';
|
|
3
|
+
interface CommandHookStatus<Output> {
|
|
4
|
+
readonly inFlight: boolean;
|
|
5
|
+
readonly lastError: unknown | null;
|
|
6
|
+
readonly result: Output | undefined;
|
|
7
|
+
}
|
|
8
|
+
type CommandHook<Input, Output> = CommandHookStatus<Output> & {
|
|
9
|
+
readonly run: CommandRun<Input, Output>;
|
|
10
|
+
};
|
|
11
|
+
declare function useCommand<Input, Output>(command: Call<Input, Output>): CommandHook<Input, Output>;
|
|
12
|
+
export { useCommand };
|
|
13
|
+
export type { CommandHook };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Call } from '@opetope/core';
|
|
2
|
+
/**
|
|
3
|
+
* A bound command is the authentic `Call` the model field declares, so the common path adds no wrapper. Observable
|
|
4
|
+
* call state belongs to the model as a `Readable` field (D71), so a binding carries no mode.
|
|
5
|
+
*/
|
|
6
|
+
type Command<Input, Output> = Call<Input, Output>;
|
|
7
|
+
type CommandBinding<Input, Output> = Command<Input, Output>;
|
|
8
|
+
type CommandOutcome<Output> = {
|
|
9
|
+
readonly error: unknown;
|
|
10
|
+
readonly status: 'failed';
|
|
11
|
+
} | {
|
|
12
|
+
readonly reason: unknown;
|
|
13
|
+
readonly status: 'cancelled';
|
|
14
|
+
} | {
|
|
15
|
+
readonly status: 'ok';
|
|
16
|
+
readonly value: Output;
|
|
17
|
+
};
|
|
18
|
+
type AnyCommandBinding = CommandBinding<never, unknown>;
|
|
19
|
+
type CommandBindingDefinition<Input, Output> = {
|
|
20
|
+
readonly target: Call<Input, Output>;
|
|
21
|
+
};
|
|
22
|
+
declare function bindCommand<Input, Output>(target: Call<Input, Output>): CommandBinding<Input, Output>;
|
|
23
|
+
declare function getCommandBindingDefinition<Input, Output>(binding: Call<Input, Output>): CommandBindingDefinition<Input, Output>;
|
|
24
|
+
declare function isCommandBinding(value: unknown): value is AnyCommandBinding;
|
|
25
|
+
export { bindCommand, getCommandBindingDefinition, isCommandBinding };
|
|
26
|
+
export type { Command, CommandOutcome };
|