@opetope/react 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,30 +1,33 @@
1
1
  # `@opetope/react`
2
2
 
3
- The React binding for Opetope models and UI contributions. [The specification](../runtime/docs/spec.md) §3 sets the package vocabulary.
3
+ React hooks and slots for Opetope models. A contribution grants the models its UI may read; hooks subscribe to selected data and expose commands with local status.
4
4
 
5
5
  ## Installation
6
6
 
7
7
  ```sh
8
- npm install @opetope/core @opetope/runtime @opetope/react 'react@^19.0.0' 'react-dom@^19.0.0'
8
+ npm install --save-exact @opetope/core @opetope/runtime @opetope/react 'react@^19' 'react-dom@^19'
9
9
  ```
10
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.
11
+ Use matching exact Opetope versions; for release candidates, install every Opetope package from `@next`.
12
+ Packages are ESM-only and support Node 20.19+. React integrations support React and React DOM 19.
13
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.
14
+ ## Example
17
15
 
18
- ## Hello UI
16
+ This focused example shows the package boundary. [Start](../runtime/docs/start.md) includes a complete
17
+ feature, host, React entry and cleanup in one visible module.
18
+
19
+ <!--example id="doc-readme-react-0"
20
+ /* … */
21
+ -->
19
22
 
20
23
  ```tsx
21
24
  import { defineModel } from '@opetope/core';
22
- import type { Call, Readable } from '@opetope/core';
25
+ import type { Command, Readable } from '@opetope/core';
23
26
  import { defineSlot, requiresModels, Slot, useModel } from '@opetope/react';
24
27
 
25
28
  const CounterModel = defineModel<{
26
29
  readonly count: Readable<number>;
27
- readonly increment: Call<void, void>;
30
+ readonly increment: Command<void, void>;
28
31
  }>('example.counter.model');
29
32
  const CounterSlot = defineSlot<{ readonly label: string }>('example.counter.slot');
30
33
 
@@ -39,7 +42,7 @@ const CounterButton = requiresModels([CounterModel])(({ label }: { readonly labe
39
42
 
40
43
  return (
41
44
  <button disabled={inFlight} onClick={() => void run()}>
42
- {outcome?.status === 'failed' ? 'Retry' : `${label}: ${count}`}
45
+ {outcome?.kind === 'failed' ? 'Retry' : `${label}: ${count}`}
43
46
  </button>
44
47
  );
45
48
  });
@@ -49,364 +52,42 @@ function CounterArea() {
49
52
  }
50
53
  ```
51
54
 
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
54
- `{ run, inFlight, outcome }` and always resolves the outcome, so a normal cancellation never becomes an unhandled
55
- rejection (D116, D292).
56
-
57
- `outcome` is the last settled non-cancelled outcome of this consumer: `ok` takes the place of a previous `failed`
58
- and a `failed` the place of a previous `ok`, a `cancelled` outcome moves nothing, and starting a run clears nothing,
59
- so a failure stays readable while the retry is in flight. Before the first settle it is `undefined`. Read it through
60
- its discriminant — `outcome?.status === 'failed' ? outcome.error : null`.
61
-
62
- A command without input binds to an event through an arrow: `onClick={() => void logout.run()}`. The arrow is what
63
- keeps the React event out of the Call and the promise from hanging; `onClick={logout.run}` does not compile, because
64
- a `MouseEvent` is not a `void` input. Use `run(input, options?)` for data, an outcome, callbacks or a per-run signal.
65
- Where a lint config bans an inline arrow prop (`react-perf/jsx-no-new-function-as-prop`, `react/jsx-no-bind`), hoist
66
- it with `useCallback(() => void logout.run(), [logout.run])`: the dependency is `run`, never the hook object (D290).
67
-
68
- `run` is stable while the invoker stays the same; the returned object is a snapshot of the render it was read in and
69
- changes as `inFlight` or `outcome` changes, so an effect or a memo depends on `run` and never on the hook — the rule
70
- `opetope/no-command-in-deps` holds this for the author (D290). A `run` captured from a replaced or unmounted
71
- consumer is fenced; it does not start work on the replacement.
72
-
73
- The hook schedules nothing. Every `run` reaches the call, and the policy the call was created with decides what
74
- happens to it (D203). For an absolute value setter the model declares `policy: 'latest'` in `context.call`, and the
75
- newest input then replaces the waiting one there (D185). What the consumer keeps is its own: an already-aborted
76
- input signal is refused as `cancelled` without reaching the call, a run of an unmounted consumer is refused the same
77
- way, `inFlight` is true while any run this consumer started is unsettled, and every run carries its own callbacks
78
- and signal.
79
-
80
- To share equivalent pending or running work, the model declares `dedupe: true` or `dedupe: input => key`
81
- on `context.call`. Sharing keeps the first input and each caller's independent cancellation; neither form may
82
- be combined with `latest` (D277). These are Call options, not hook options.
83
-
84
- For several commands use an object selection (D174):
85
-
86
- ```tsx
87
- const { save, remove } = useCommands({ save: model.save, remove: other.remove });
88
-
89
- void save.run(input);
90
- const saving = save.inFlight;
91
- ```
92
-
93
- Import `useCommands` from `@opetope/react`. Each selected field returns the same `CommandHook` as `useCommand`,
94
- with its own input/output types and its own `inFlight` and `outcome`. Keys are local UI names; commands may come from
95
- different granted models. There is no second argument: each call carries its own policy. Its own case is a key set
96
- the component does not know in advance — hooks are not called in a loop; a fixed set is `useCommand` per word or one
97
- `useModel` selection. It is proven by the packaged consumer of `ci:pack` and is a candidate to leave this entry
98
- until a second application is measured (D301).
99
-
100
- An inline object needs no `useMemo`. Reordering keys or updating a sibling preserves an unchanged field's object
101
- and `.run`. Adding or removing keys does not change the number of React hooks. Removing a key or replacing its
102
- command starts a fresh local status for that field; abandoned renders leave the
103
- committed selection intact. Two aliases of one Call have independent local statuses, just like two `useCommand`
104
- 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
105
- need one semantic command. Only own enumerable properties are selected, including symbol keys.
106
-
107
- ## Selecting data and commands
108
-
109
- `useModel(Declaration, (model, { read }) => ({ ... }))` combines explicit data selection and command consumers (D205, D214).
110
-
111
- A model selection can use a named interface without an index signature (D217). Its result is a flat data record;
112
- arrays, functions, constructors and built-in collection/date/promise objects are not selection records.
113
- `read(source)` returns its snapshot; `read(source, project)` returns a projection. Authentic Calls selected as record
114
- fields become the same `CommandHook` as `useCommands`, including independent alias statuses and stable `.run`.
115
- A single selected command needs no nested hooks:
116
-
117
- ```tsx
118
- const { logout } = useModel(AuthModel, auth => ({ logout: auth.logout }));
119
- return (
120
- <button disabled={logout.inFlight} onClick={() => void logout.run()}>
121
- Log out
122
- </button>
123
- );
124
- ```
125
-
126
- The one-argument form still returns the granted model; individual hooks remain available.
127
-
128
- Only explicitly read sources are subscribed, once per distinct Readable in the selection. Returned data fields are
129
- compared with `Object.is`; select scalar fields, spread a projected record into the selection, or return stable
130
- references. A newly allocated nested object is a changed field. The callback is pure: no hooks, commands or side effects.
131
- A read or selector error reaches the nearest React error boundary. Changing the selected sources or command keys
132
- changes their subscriptions/consumers at commit; an abandoned render cannot replace committed authority.
133
-
134
- The hook neither creates a model nor acquires a feature. It leases exactly the resources its selection names as
135
- values — that field answers a `ResourceHook`, and one lease per Resource identity covers the mount even when two
136
- keys name one Resource (D321, D359) — and nothing else: a
137
- `read(resource.state)` leases nothing, and neither does the one-argument form. Combining hooks does not promise fewer
138
- source subscriptions or faster renders; a raw `useModel` import now also includes the selection implementation.
139
- There is no additional Call scheduler.
140
-
141
- ## Application host
142
-
143
- The host opens a feature graph with `openApplication`. Ready features publish their UI contributions atomically;
144
- the ordinary `Slot` mounts them into consumer-owned targets:
145
-
146
- ```tsx
147
- <Slot target={exampleFooterSlot} />
148
- <Slot target={exampleSettingsSlot} />
149
- ```
150
-
151
- The application owns feature lifetimes; each feature's UI mounts through its contributions.
152
-
153
- ## Feature demand boundary
154
-
155
- ```tsx
156
- import { Slot } from '@opetope/react';
157
- import { FeatureBoundary, useFeatureRetry } from '@opetope/react/integration';
158
-
159
- function Retry() {
160
- return <button onClick={useFeatureRetry()}>Try again</button>;
161
- }
162
-
163
- <FeatureBoundary demand={host} error={<Retry />} fallback={<Spinner />}>
164
- <Slot props={{ itemId, onConfirmed }} target={confirmActionContentSlot} />
165
- </FeatureBoundary>;
166
-
167
- // or with render callbacks, when the ready instance itself is needed (D177)
168
- <FeatureBoundary
169
- demand={host}
170
- error={({ error, retry }) => <Failure error={error} onRetry={retry} />}
171
- fallback={<Spinner />}
172
- >
173
- {({ exports }) => <TasksResourceLease resource={exports.tasks}>{children}</TasksResourceLease>}
174
- </FeatureBoundary>;
175
- ```
176
-
177
- `FeatureBoundary` consumes a `FeatureDemandSource` supplied by host integration. It holds the demand for the feature,
178
- shows the ready branch once that demand is ready and isolates `useFeatureRetry` inside the error subtree. A branch may be a node or a
179
- render callback: the callback of `children` receives the ready instance typed by the demand, the callback of `error`
180
- receives `{ error, retry }`, and only the branch that is shown runs. The callback needs no `useFeature` of its own,
181
- so a ready consumer does not acquire the demand a second time; hooks still live in child components, not in the
182
- callback. There is no separate hook for
183
- 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
184
- root, no `ui` section and no second binding API any more (D85).
185
-
186
- Both the boundary and the bare `useFeature` are exercised by the packaged consumer of `ci:pack`, which checks the
187
- law they share: one lease per mount, kept across a settled `retry` and released on unmount. `useFeature` is the
188
- hook half used where a host renders the readiness itself, and it is a candidate to leave this entry until a second
189
- application is measured; `FeatureBoundary` is not a pair with `ContributionBoundary` and stays (D301).
190
-
191
- ## Contributions
192
-
193
- ```tsx
194
- import { defineSlot, defineSwitchSlot, Slot } from '@opetope/react';
195
-
196
- const HeaderEnd = defineSlot<{ readonly mode: 'desktop' | 'phone' }>('example.headerEnd');
197
- const PageEnd = defineSwitchSlot<'home' | 'wallet', { readonly compact: boolean }>('example.pageEnd');
198
-
199
- function Header({ mode }: { readonly mode: 'desktop' | 'phone' }) {
200
- return <Slot props={{ mode }} target={HeaderEnd} />;
201
- }
202
-
203
- function HomePageEnd() {
204
- return <Slot props={{ compact: true }} target={PageEnd('home')} />;
205
- }
206
- ```
207
-
208
- `defineSlot` creates an authentic target with an ordered set of contributions; `defineSwitchSlot` lazily creates and
209
- caches a stable target per route. `Slot` reads the atomic snapshot and renders every `{ Component }` in the authority
210
- frame of the instance that gave the contribution. The order and the stable key come from the contribution entries of the core, so
211
- React introduces neither a second comparator nor a second lifetime registry. The packaged consumer of `ci:pack`
212
- exercises that caching and routes one contribution through it (D301).
213
-
214
- `useResource(resource)` answers `{ invalidate, pagination, refresh, retry, state }`: the `ResourceState` through
215
- `useSyncExternalStore`, the lease `resource.acquire()` answers taken in a commit effect for as long as the component
216
- is mounted, and the three verbs as ordinary `CommandHook` consumers (D359). An interrupted render therefore opens
217
- nothing, and when the reference changes the state follows the new Resource while the effect cleanup releases the
218
- previous lease. The number of React hooks does not depend on the Resource: a declaration without `pagination`
219
- subscribes to a constant source and keeps a `loadNext` that answers `skipped`, while a declaration that carried the
220
- capability answers `pagination` as `{ loadNext, state }` (D356). A field of a `useModel` selection whose value is a
221
- Resource answers the same hook and takes the same lease, so a model declares no `Call<void, void>` around
222
- `resource.refresh()` to give a button its `inFlight`. `useReadable(resource)` is a compile error, because a Resource
223
- is not a `Readable`; `useReadable(resource.state)` and a `read(resource.state)` in a selector stay passive and lease
224
- nothing — observing and holding are independent questions, so a selector that reads the state and a `useResource`
225
- beside it subscribe twice (D349). The compile-checked example of specification §2.13 shows both sides of that
226
- (D301, D321).
227
-
228
- `refresh.run()`, `retry.run()` and `invalidate.run()` reach the Resource, which is owned by the model that declared
229
- it: `inFlight` is true until the outcome of the operation settles, `outcome` carries that `ResourceOperationOutcome`
230
- as the `value` of an `ok` status, and `run(undefined, { signal })` cancels the wait of this consumer and not the work
231
- the model owns. Two consumers of one Resource keep separate statuses, and `run` keeps its identity while the Resource
232
- does. A render that caught up with retirement renders the terminal record — `idle` with reason `retired` for a Resource
233
- a model owns, `unleased` for a feature's facade, which is a reference that outlives the instance — and unmounts,
234
- releasing its lease (D359, D364). Nothing here refuses a read because a lifetime ended: a source whose owner retired
235
- stops and keeps answering its last value, so `useReadable`, `useSelector` with an inline selector and a selection's
236
- `read` each read on and need no memory of their own. A refusal that remains is work that failed — a state that
237
- failed, a broken pagination cursor — and it still reaches the render.
238
-
239
- Direct component props, a contribution's `props` adapter and its model's props `Readable` share the same
240
- mount-owned snapshot. Incoming slot props publish in layout before paint, so direct and adapted renders cannot
241
- mix new props with an old model snapshot. Model factory failures clean up partially created kernels.
242
- Models belong to commit: an abandoned render creates no model to clean up (D170, D188).
243
- StrictMode effect replay and Suspense hide/reveal preserve the committed model bundle and state. Only actual
244
- identity replacement or unmount releases it; a hidden unmount releases in a microtask after React has disconnected
245
- the layout effects (D209).
246
-
247
- Demand retry is scoped to the source identity. Replacing a boundary's demand source allows its new retry to run
248
- even if the previous source's retry is still pending.
55
+ ## Documentation
249
56
 
250
- Every mount is an error boundary of its own contribution (D256). A render or commit that throws stops there, is
251
- reported to the feature that published the contribution, and leaves the other mounts of the target untouched.
252
- `ContributionBoundary` states once, above every slot, what a failed mount shows:
253
-
254
- ```tsx
255
- import { ContributionBoundary } from '@opetope/react/integration';
256
-
257
- <ContributionBoundary error={({ error, retry }) => <FailedContribution error={error} onRetry={retry} />}>
258
- <Slot target={applicationSurface} />
259
- </ContributionBoundary>;
260
- ```
57
+ The [reference](../runtime/docs/reference/react.md) owns the detailed contract, options and failure semantics.
58
+ [Guides](../runtime/docs/guides/index.md) show individual tasks. Documentation is shipped with
59
+ `@opetope/runtime` in `docs/`, so an installed application can read it without access to this repository.
261
60
 
262
- `error` takes a node or a callback of `{ contribution, error, feature, retry, target }`, the failure shape of
263
- `FeatureBoundary` plus the identity of what failed: `contribution` is the published entry `<feature>.<provides key>`
264
- and `target` is the slot, so one branch can answer by surface. `retry` remounts the contribution, so its models are
265
- created again. The reporter of the publishing feature receives the same identity on a `ContributionError` whose
266
- `cause` is the original error, with code `render-failed` or `error-content-failed`. Without the provider a failed mount renders nothing — containment
267
- never depends on it. Error content that throws is contained the same way and reported, never escalated.
268
-
269
- ## Scenario tests and physical activity
270
-
271
- `createScenario(application, options)` from `@opetope/react/testing` opens the real application and its existing
272
- inspection session (D206, D215). Supply the normal `imports`/`conditions` and a test-owned
273
- `host.mount(Component)` adapter returning an `unmount()` handle. The package adds no DOM renderer or test-runner
274
- dependency. `scenario.mount(target, { props })` uses the published Slot contributions and returns
275
- `{ host, updateProps, unmount }`; `host` is the renderer's original result. Typed targets require `options.props`,
276
- while targets without props omit it, exactly as with `Slot` (D217). Fixture commands do not bypass authority.
277
-
278
- As with `openApplication`, omit `conditions` when no enabled feature requires a host-bound condition. Conditions
279
- computed through `from`/`select` bind automatically; external conditions still require their sources (D271).
280
-
281
- The synchronous constructor exposes `ready`, so a test can inspect a pending lazy body before readiness.
282
- `waitFor(snapshot => predicate, { label, timeoutMs, pollIntervalMs })` wakes on inspection changes and also polls
283
- external UI predicates; `notify()` wakes it after a controlled fixture update. The default deadline is 1000ms,
284
- with a 10ms predicate poll. A `ScenarioTimeoutError` carries the data-only snapshot, bounded history and observed
285
- conditions, feature phases, body loads, lane blockers and resource lease facts. It does not infer repository
286
- or network causes. `getSnapshot()` and `history()` use that same observation model; history defaults to 64 snapshots,
287
- activity to 256 records. Capacities accept integers from 1 to 10000. Do not replace predicates with a fixed number of ticks.
288
-
289
- `close()` fences application admission synchronously, unmounts all registered screens and joins their cleanup with
290
- physical application drain. Its deadline does not cancel cleanup: a later `close()` can await the same drain.
291
- A readiness deadline likewise leaves the application available for inspection and explicit cleanup.
292
- `ownership()` reports only registered runtime ownership, with `unknown` for missing, stale or truncated evidence;
293
- a workspace stale snapshot is complete only after the scenario witnessed successful physical cleanup. This permits
294
- a scoped zero-count assertion, without proving absence of arbitrary host, UI or GC leaks. Successful cleanup clears
295
- application imports and internal renderer references. A failed cleanup promise can retain original errors and retry
296
- capabilities; a caller that keeps `mounted.host` also keeps its own renderer result.
297
-
298
- The inspection schemas are `opetope.devtools-graph/5` and `opetope.devtools-frame/5`, with optional
299
- `opetope.runtime-activity/4` snapshots (D266, D275, D344, D358). Within one session,
300
- a frame without `activity` preserves the previous activity; a full snapshot/reset without it clears that observation
301
- (D216). Activity-bearing frames replace the previous activity in full.
302
- Use matching runtime/devtools versions: previous graph/frame and activity revisions are rejected; weak-edge presence requires graph `/5` and frame `/4`. Activity identifies the execution,
303
- actual feature generation, physical Calls, exact current lane blockers, and for every registered Resource its
304
- `kind`, `lifetime`, `epoch`, `state`, `activity` with its `operation`, `leases`, `subscribers`, `pagination` and the
305
- `epoch` of each physical load — there is no `attempt` and no numeric generation, because a Resource runs one logical
306
- attempt and recovery belongs to the transport (D358). Host demand and UI models are unknown. `freshness`
307
- and `truncated` distinguish a complete live view from a partial or detached one. A closed session is stale;
308
- `closed: true` requires successful physical application drain. No control authority or product payload is added.
309
- Activity output is bounded by record capacity. Snapshot collection still visits registered owners, executors and
310
- resources, so capacity does not bound traversal cost. Collection stops once truncation is proven;
311
- idle executors may still require traversal to establish completeness. Normal call dispatch allocates no diagnostic record with
312
- observation disabled. Graph frames remain bounded by the existing ring capacity.
313
-
314
- ## Testing
315
-
316
- `@opetope/react/testing` exports component fixtures and application scenarios, and re-exports
317
- `@opetope/runtime/testing` — which in turn re-exports `@opetope/core/testing` — so a React test names one entry
318
- (D285):
319
-
320
- - `renderSlot(target, { contribution, models, props })` mounts the published target or one fixture contribution;
321
- - `command(run)` produces an authentic `Call` for a model fixture, plus the contribution binding a mount reads;
322
- - `runCall(target, input?, options?)` executes an existing authentic `Call` without mounting React (D261);
323
- - `testReadable(initial)`, `testResourceEpoch()`, `openModel`, `settled`, `waitFor`, `yieldTurn` and `eventually`
324
- come from the entries below, for the part of a test that has no UI in it — `testResourceEpoch()` is what a
325
- component fixture writing a `ResourceState` by hand puts in its `epoch` (D362).
326
-
327
- A test that renders nothing should import `@opetope/runtime/testing` directly: `renderSlot` and the binding half of
328
- `command` are all that needs React here. The Call a fixture mints is the `command` of `@opetope/core/testing`
329
- (D300), and this word is the one name of the re-export chain that does not pass through: the React `command`
330
- shadows it with the stronger one.
331
-
332
- The `renderSlot` harness returns `Slot` and `updateProps`: there are no roots and no fixtures for them, UI enters the
333
- application as contributions (D85). It is not a test spelling of `<Slot>` — it mounts a fixture and takes a
334
- `reporter`, because a component test has no feature to report to — and the packaged consumer of `ci:pack` mounts it
335
- from the real archive (D301).
336
-
337
- A test that previously mounted `useCommand` only to invoke a Call can use `runCall` directly. The result is the
338
- Call's output, while a UI test still checks the `CommandOutcome` returned by the mounted consumer:
339
-
340
- ```ts
341
- import assert from 'node:assert/strict';
342
- import { defineModel } from '@opetope/core';
343
- import type { Call } from '@opetope/core';
344
- import { defineFeature, openFeature } from '@opetope/runtime';
345
- import { runCall } from '@opetope/react/testing';
346
-
347
- const Arithmetic = defineModel<{ readonly double: Call<number, number> }>('example.testing.model');
348
- const arithmetic = defineFeature('example.testing.arithmetic', {
349
- own: ({ model }) => ({
350
- arithmetic: model(Arithmetic, context => ({
351
- double: context.call((input: number) => input * 2),
352
- })),
353
- }),
354
- exports: ({ own }) => ({ double: own.arithmetic.double }),
355
- });
356
- const instance = openFeature(arithmetic, { imports: {}, reporter: () => undefined });
357
- try {
358
- const ready = await instance.ready;
359
- const result = await runCall(ready.exports.double, 3);
360
- assert.equal(result, 6);
361
- } finally {
362
- await instance.close();
363
- }
364
- ```
61
+ For a contributor checkout, use `npm run ci:type --workspace @opetope/react` and
62
+ `npm run ci:test --workspace @opetope/react` where provided. The root `npm run check` performs full acceptance;
63
+ [contributor commands](../runtime/docs/maintainers/contributing.md) describe the build and package checks.
365
64
 
366
- `runCall` returns `Promise<Output>` and preserves the Call's original errors and cancellation rejections. It adds no
367
- UI status or outcome wrapper, scheduling policy, model creation or cleanup lifetime. The existing Call retains its
368
- owner, lane and retirement rules; the test closes the instance it opened, including after failed readiness.
369
- A void Call can use `runCall(target)`. Options always occupy the third argument:
370
- `runCall(target, input, { signal })`, or `runCall(voidTarget, undefined, { signal })`.
371
- `RunCallOptions` contains only the optional `signal`; a pre-aborted signal rejects before the body runs.
65
+ <a id="hello-ui"></a>
66
+ [See Hello UI](../runtime/docs/reference/react.md#react-hello-ui).
372
67
 
373
- `run`, `runCall` and nested `invoke` use the same no-input rule (D273): `void` and `undefined` allow omission;
374
- other inputs, including `T | undefined`, require an argument. `never` cannot be invoked without input.
375
- Generic helpers may forward the target and its explicit input without losing their types.
68
+ <a id="selecting-data-and-commands"></a>
69
+ [See Selecting data and commands](../runtime/docs/reference/react.md).
376
70
 
377
- ## Word map and entries
71
+ <a id="application-host"></a>
72
+ [See Application host](../runtime/docs/reference/react.md).
378
73
 
379
- | Entry | What it holds |
380
- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
381
- | `@opetope/react` | `useModel`, `useCommand`, `useCommands`, `useReadable`, `useSelector`, `useResource`, `requiresModels`, `defineSlot`, `defineSwitchSlot`, `Slot`, `ContributionError`; types `SlotTarget`, `SwitchSlotTarget`, `SlotContribution`, `CommandHook`, `CommandOutcome`, `ResourceHook`, `PaginatedResourceHook`, `ResourcePaginationHook` |
382
- | `@opetope/react/integration` | `FeatureBoundary`, `ContributionBoundary`, `useFeature`, `useFeatureRetry`, `FeatureBoundaryError` for the integration layer; types `FeatureBoundaryProps`, `ContributionBoundaryProps`, `ContributionErrorContent`, `ContributionFailure`, `FeatureDemandSource`, `FeatureDemandState`, `FeatureDemandResult`, `FeatureDemandLease` |
383
- | `@opetope/react/testing` | `renderSlot`, `command`, `createScenario`, `ScenarioTimeoutError` and fixture/scenario types, plus everything `@opetope/runtime/testing` and `@opetope/core/testing` publish; tests only |
74
+ <a id="feature-demand-boundary"></a>
75
+ [See Feature demand boundary](../runtime/docs/reference/react.md).
384
76
 
385
- `Call<Input, Output>` is imported from `@opetope/core`; UI `CommandOutcome<Output>` is imported from `@opetope/react`. The former `Command` synonym and the `CommandOutcome` re-export from `@opetope/react/integration` are removed (D270).
77
+ <a id="contributions"></a>
78
+ [See Contributions](../runtime/docs/reference/react.md).
386
79
 
387
- `Model` is a typed key for a record of `Readable`, `Call` and readable factories that UI components consume.
388
- The constructors of the integration entry are not re-exported from the safe entry and cannot come back into
389
- `ui/models/data/contracts` through a local barrel of a feature.
80
+ <a id="scenario-tests-and-physical-activity"></a>
81
+ [See Scenario tests and physical activity](../runtime/docs/reference/react.md).
390
82
 
391
- ## Laws
83
+ <a id="testing"></a>
84
+ [See Testing](../runtime/docs/reference/react.md).
392
85
 
393
- - `useModel(declaration)` reads only the models declared by the contribution;
394
- - one committed contribution owns its model frame until unmount or retire;
395
- - an abandoned concurrent render holds no frame;
396
- - `useCommand` does not subscribe to the invoker: the observable state of a call lies in a `Readable` of the model;
397
- - the `cancelled` outcome does not move `outcome`, and it is only the `CallError` codes `cancelled`
398
- and `closed`; a call to a weak port with no provider (`unavailable`) and a rejected publication
399
- (`publication-rejected`) arrive as the `failed` outcome and settle in `outcome` (D138, D292);
400
- - a contribution that fails to render or to commit is contained by its own mount and reported to its feature;
401
- - `useSelector` reads its `equals` where the selected value is known, so a named non-generic comparator compiles
402
- beside a selection whose parameter is inferred, as it does in Core (D348);
403
- - `useSelector` keeps the selected reference when something else changed; its identity case is `useReadable` and its
404
- model case is a `useModel` selection, so it is proven by the memory gate and the packaged consumer rather than by a
405
- specification example, and it is a candidate to leave this entry until a second application is measured (D301);
406
- - closing and unmounting synchronously fence new calls and release the references of the frame.
86
+ <a id="word-map-and-entries"></a>
87
+ [See Word map and entries](../runtime/docs/reference/react.md).
407
88
 
408
- ## Compatibility contract
89
+ <a id="laws"></a>
90
+ [See Laws](../runtime/docs/reference/react.md).
409
91
 
410
- The ESM of the package is built for Chrome 82+, Firefox 110+, Safari/iOS 15+, Android 82+ and Node 20.19+ and requires
411
- the peer `react >=19.0.0 <20` (D251). React and ReactDOM are peers of the host, not built-in polyfills. The raw modules are
412
- executed by an HTTP import smoke test with a local bundle of the peers; historical browser builds are checked only by an external farm.
92
+ <a id="compatibility-contract"></a>
93
+ [See Compatibility contract](../runtime/docs/reference/react.md).
@@ -1,4 +1,4 @@
1
- import type { CallInputArgs, CallRunOptions } from '@opetope/core/internal';
1
+ import type { CommandInputArgs, CallRunOptions } from '@opetope/core/internal';
2
2
  import type { CommandOutcome } from './command.js';
3
3
  type CommandRunOptions<Output> = Readonly<{
4
4
  onFailure?: (error: unknown) => PromiseLike<void> | void;
@@ -7,15 +7,15 @@ type CommandRunOptions<Output> = Readonly<{
7
7
  }>;
8
8
  type CommandRun<Input, Output> = {
9
9
  (input: Input, options?: CommandRunOptions<Output>): Promise<CommandOutcome<Output>>;
10
- (...args: [...CallInputArgs<Input>, options?: CommandRunOptions<Output>]): Promise<CommandOutcome<Output>>;
10
+ (...args: [...CommandInputArgs<Input>, options?: CommandRunOptions<Output>]): Promise<CommandOutcome<Output>>;
11
11
  };
12
12
  type CommandInvoker = Readonly<{
13
13
  run: (input: never, options?: CallRunOptions<unknown>) => Promise<unknown>;
14
14
  }>;
15
15
  type CommandNotification<Output> = (inFlight: boolean, outcome?: CommandOutcome<Output>) => void;
16
16
  /**
17
- * D203: the consumer keeps a status, not a schedule. Every `run` reaches the call, and the policy the call was
18
- * created with — `queue`, `latest`, `parallel`, `once`, `dedupe` — decides what happens to it. What stays
17
+ * D203: the consumer keeps a status, not a schedule. Every `run` reaches the command, and what the command was
18
+ * declared with — `concurrency`, `memoize`, `dedupe` — decides what happens to it. What stays
19
19
  * local is what only this consumer knows: whether it is mounted, its own input signal, and the outcome it shows.
20
20
  */
21
21
  declare class CommandHookController<Input, Output> {
@@ -1,10 +1,10 @@
1
- import type { Call } from '@opetope/core';
1
+ import type { Command } from '@opetope/core';
2
2
  import type { CommandOutcome } from './command.js';
3
3
  import { CommandHookController } from './command-hook-controller.js';
4
4
  import type { CommandRun } from './command-hook-controller.js';
5
5
  /** D292: a cancellation is not an answer of the product, so it never settles into the outcome this hook shows. */
6
6
  type SettledOutcome<Output> = Exclude<CommandOutcome<Output>, {
7
- readonly status: 'cancelled';
7
+ readonly kind: 'cancelled';
8
8
  }>;
9
9
  interface CommandHookStatus<Output> {
10
10
  readonly inFlight: boolean;
@@ -19,6 +19,6 @@ type CommandHook<Input, Output> = CommandHookStatus<Output> & {
19
19
  * it, and the synchronisation of a slot that was already busy when this mount arrived.
20
20
  */
21
21
  declare function useCommandStatus<Input, Output>(currentSlot: CommandHookController<Input, Output>): CommandHook<Input, Output>;
22
- declare function useCommand<Input, Output>(command: Call<Input, Output>): CommandHook<Input, Output>;
22
+ declare function useCommand<Input, Output>(command: Command<Input, Output>): CommandHook<Input, Output>;
23
23
  export { useCommand, useCommandStatus };
24
24
  export type { CommandHook };
package/dist/command.d.ts CHANGED
@@ -1,21 +1,21 @@
1
- import type { Call } from '@opetope/core';
2
- type CommandBinding<Input, Output> = Call<Input, Output>;
1
+ import type { Command } from '@opetope/core';
2
+ type CommandBinding<Input, Output> = Command<Input, Output>;
3
3
  type CommandOutcome<Output> = {
4
4
  readonly error: unknown;
5
- readonly status: 'failed';
5
+ readonly kind: 'failed';
6
6
  } | {
7
7
  readonly reason: unknown;
8
- readonly status: 'cancelled';
8
+ readonly kind: 'cancelled';
9
9
  } | {
10
- readonly status: 'ok';
10
+ readonly kind: 'ok';
11
11
  readonly value: Output;
12
12
  };
13
13
  type AnyCommandBinding = CommandBinding<never, unknown>;
14
14
  type CommandBindingDefinition<Input, Output> = {
15
- readonly target: Call<Input, Output>;
15
+ readonly target: Command<Input, Output>;
16
16
  };
17
- declare function bindCommand<Input, Output>(target: Call<Input, Output>): CommandBinding<Input, Output>;
18
- declare function getCommandBindingDefinition<Input, Output>(binding: Call<Input, Output>): CommandBindingDefinition<Input, Output>;
17
+ declare function bindCommand<Input, Output>(target: Command<Input, Output>): CommandBinding<Input, Output>;
18
+ declare function getCommandBindingDefinition<Input, Output>(binding: Command<Input, Output>): CommandBindingDefinition<Input, Output>;
19
19
  declare function isCommandBinding(value: unknown): value is AnyCommandBinding;
20
20
  export { bindCommand, getCommandBindingDefinition, isCommandBinding };
21
21
  export type { CommandOutcome };
@@ -0,0 +1,2 @@
1
+ import{jsx as m}from"react/jsx-runtime";import{useContext as T,createContext as V,useCallback as S,useRef as O,useMemo as p,useSyncExternalStore as k,useEffect as D,useInsertionEffect as A,useLayoutEffect as w,memo as N,useState as W}from"react";import{reportDetachedError as C,compatibleAbortReason as K,isCallTarget as _,isModel as G,isReadable as j,createLifetimeController as H,createCallController as J,getContributionOwner as Q,createState as U,createAggregateError as X}from"@opetope/core/internal";import{isResourceValue as Y,reportRuntimeFailure as Z,requireContributionModelPlans as ee,openContributionModels as te}from"@opetope/runtime/internal";import{C as u,u as ne,b as re}from"./contribution-isolation-Bzfbt4iM.js";import{CommandError as ie}from"@opetope/core";const x=V(void 0);function oe(){const t=T(x);if(t===void 0)throw new u("missing","Opetope hook was called outside a contribution mount.");return t}function y(t){return Y(t)}function se(t,e){return new Promise((n,r)=>{const i=()=>{r(new ie("cancelled","Resource operation wait was cancelled.",K(e)))};if(e.aborted){i();return}e.addEventListener("abort",i,{once:!0}),t.then(o=>{e.removeEventListener("abort",i),n(o)},o=>{e.removeEventListener("abort",i),r(o)})})}function ae(t,e){var r;const n=(r=e==null?void 0:e.onSuccess)==null?void 0:r.call(e,t);return n!==void 0&&Promise.resolve(n).catch(C),t}function v(t){return{run:(e,n)=>{let r;try{r=Promise.resolve(t())}catch(o){return Promise.reject(o)}const i=n==null?void 0:n.signal;return(i===void 0?r:se(r,i)).then(o=>ae(o,n))}}}const ce=Object.freeze({kind:"skipped",reason:"resource-not-ready"});function de(t){const e=t.pagination;if(!(typeof e!="object"||e===null))return e}function E(t){return{loadNext:v(()=>{const e=t.pagination;return e===void 0?Promise.resolve(ce):e.loadNext()}),refresh:v(()=>t.refresh()),reset:v(()=>t.reset()),retry:v(()=>t.retry())}}function ue(t,e){const n=e.get(t);if(n!==void 0)return n;const r=E(t);return e.set(t,r),r}const le=t=>t;function F(t,e,n){const r=e,i=(n==null?void 0:n.equals)??Object.is,o=S(d=>t.subscribe(d),[t]),c=O(void 0);let s=c.current;s===void 0&&(s={hasValue:!1,value:void 0},c.current=s);const a=p(()=>{let d=!1,h,f;return()=>{const b=t.getSnapshot();if(d&&Object.is(h,b))return f;const g=r(b);if(!d){let R=g;return s.hasValue&&i(s.value,g)&&(R=s.value),d=!0,h=b,f=R,f}return i(f,g)?(h=b,f):(h=b,f=g,f)}},[s,i,t,r]),l=k(o,a,a);return D(()=>{s.hasValue=!0,s.value=l},[s,l]),l}function z(t){return F(t,le)}const M=new WeakMap;function I(t){if(arguments.length>1)throw new u("binding-invalid","Command follows the target it binds; binding options are forbidden.");if(!_(t))throw new u("binding-invalid","Command target is not authentic.");return M.set(t,{target:t}),t}function fe(t){const e=M.get(t);if(e===void 0)throw new u("binding-invalid","Command binding is not authentic.");return e}function $(t){return typeof t=="object"&&t!==null&&M.has(t)}const he=Symbol("opetope.model-binding"),me=Symbol("opetope.model-binding-identity"),be=Object.freeze({}),L=new WeakMap;function pe(t,e,n){if(typeof n!="string")throw new u("binding-invalid",`Model ${t.id} accepts only string fields.`);const r=Object.getOwnPropertyDescriptor(e,n);if((r==null?void 0:r.enumerable)!==!0||!("value"in r))throw new u("binding-invalid",`Model ${t.id}.${n} requires an enumerable data binding.`);return r.value}function ge(t,e){const n=[],r=Reflect.ownKeys(e);if(r.length===0)throw new u("binding-invalid",`Model ${t.id} requires at least one field.`);for(const i of r){const o=pe(t,e,i);if(!(j(o)||y(o)||typeof o=="function")){if(!$(o))throw new u("binding-invalid",`Model ${t.id}.${String(i)} requires a Readable, a Resource or a bound command.`);n.push(o)}}return Object.freeze(n)}function q(t,e){if(!G(t))throw new u("binding-invalid","Model declaration is not authentic.");if(typeof e!="object"||e===null)throw new u("binding-invalid",`Model ${t.id} requires a record binding.`);const n=ge(t,e),r=Object.freeze({[he]:be,[me]:!0});return L.set(r,{commands:n,declaration:t,model:e}),r}function we(t){const e=L.get(t);if(e===void 0)throw new u("binding-invalid","Model binding is not authentic.");return e}let Ce=0;function ve(t){const e=new Map,n=[],r=new Set;for(const i of t){const o=we(i);if(e.has(o.declaration))throw new u("duplicate",`Model ${o.declaration.id} is bound twice in one mount.`);e.set(o.declaration,o.model);for(const c of o.commands)r.has(c)||(r.add(c),n.push(c))}return{commands:Object.freeze(n),frame:{commandRecords:new Map,models:e}}}class ye{frame;authority;boundCommands;closeTicket=0;isActive;lifetime;onClose;released=!1;rendererAttached=!1;renderListeners=new Set;state="detached";constructor(e,n){const r=++Ce;this.lifetime=H({id:`contribution.${String(r)}`}),this.isActive=n==null?void 0:n.isActive,this.onClose=n==null?void 0:n.onClose;const{commands:i,frame:o}=ve(e);this.frame=o,this.boundCommands=i,this.buildCommandRecords(r)}attach(){if(this.state==="closing")return this.closeTicket+=1,this.state="active",this.rendererAttached=!0,!0;if(this.state==="closed")return!1;if(this.state==="active")throw new u("duplicate","One contribution mount cannot mount twice.");const e=this.createAuthority(this.lifetime.open());try{for(const n of this.frame.commandRecords.values())n.controller.activate()}catch(n){throw this.authority=e,this.state="active",this.close(),n}return this.authority=e,this.rendererAttached=!0,this.state="active",!0}close(){if(this.state==="closed")return;this.state="closed",this.closeTicket+=1,this.lifetime.close(),this.authority=void 0;for(const n of this.frame.commandRecords.values())n.controller.close();const e=this.onClose;this.onClose=void 0,e==null||e(),this.publishRenderable(),this.rendererAttached||this.releaseFrame()}detach(){if(!this.rendererAttached)return;if(this.rendererAttached=!1,this.state==="closed"){this.releaseFrame();return}if(this.state!=="active")return;this.state="closing";const e=++this.closeTicket;queueMicrotask(()=>{this.state==="closing"&&e===this.closeTicket&&this.close()})}diagnostics(){const e=[...this.frame.commandRecords.values()].map(n=>n.controller.diagnostics());return Object.freeze({active:this.state==="active",calls:Object.freeze(e),commandRecords:this.frame.commandRecords.size,models:this.frame.models.size})}getRenderableSnapshot=()=>this.state!=="closed"&&(this.isActive===void 0||this.isActive());releaseClosedFrame(){this.state==="closed"&&this.releaseFrame()}subscribeRenderable=e=>{if(this.state==="closed")return()=>{};const n={listener:e};this.renderListeners.add(n);let r=!0;return()=>{r&&(r=!1,this.renderListeners.delete(n))}};buildCommandRecords(e){let n=0;for(const r of this.boundCommands){fe(r);const i=J(r,{captureAuthority:this.captureAuthority,id:`contributionCommand.${String(e)}.${String(++n)}`,scheduling:{policy:"parallel"}});this.frame.commandRecords.set(r,{controller:i,invoker:Object.freeze({run:i.run})})}}captureAuthority=()=>{const e=this.authority;if(this.state!=="active"||e===void 0)throw new u("inactive","Contribution mount is not active.");return e.assertCurrent(),e};createAuthority(e){return Object.freeze({assertCurrent:()=>{if(this.state!=="active")throw new u("inactive","Contribution mount is not active.");e.assertCurrent()},isCurrent:()=>this.state==="active"&&e.isCurrent()})}publishRenderable(){for(const{listener:e}of[...this.renderListeners])try{e()}catch(n){C(n)}}releaseFrame(){this.released||(this.released=!0,this.frame.commandRecords.clear(),this.frame.models.clear(),this.boundCommands=[],this.renderListeners.clear())}}function Me({children:t,mount:e}){const n=k(e.subscribeRenderable,e.getRenderableSnapshot,e.getRenderableSnapshot);return A(()=>{if(e.attach())return()=>e.detach()},[e]),w(()=>{n||e.releaseClosedFrame()},[e,n]),n?m(x,{value:e.frame,children:t}):null}function B(t){const e=t.bundle;t.bundle=void 0,e==null||e.frame.close()}function Re(t,e){if(typeof e!="object"||e===null)return q(t,e);const n=i=>j(i)||y(i)||typeof i=="function"||$(i),r=Object.entries(e).map(([i,o])=>[i,n(o)?o:I(o)]);return q(t,Object.fromEntries(r))}function Se(t){return Q(t)}const Oe={models:[]};function P(t,e){try{t()}catch(n){throw X([e,n],"Contribution mount setup and cleanup failed.",{cause:e})}throw e}function ke(t,e,n,r){const i=U(e),{models:o,reporter:c}=r??Oe;try{const s=ee(t.models),a=s.length===0?void 0:te(s,i,c);return{bundle:a,models:[...o,...(a==null?void 0:a.models)??[],...n],props:i}}catch(s){return P(i.close,s)}}function Ae(t,e,n,r){const{bundle:i,models:o,props:c}=ke(t,e,n,r);let s=!1;const a=()=>{if(!s){s=!0;try{i==null||i.close().catch(C)}finally{c.close()}}};try{return{frame:new ye(o.map(([d,h])=>Re(d,h)),{isActive:()=>!s,onClose:a}),isOpen:()=>!s,props:c}}catch(l){return P(a,l)}}function je(t,e){return m(t,{...e})}function xe({bundle:t,contribution:e}){const n=z(t.props),r=p(()=>e.props===void 0?n:e.props(n),[n,e]);return m(Me,{mount:t.frame,children:je(e.Component,r)})}function Ee({authority:t,contribution:e,fixtures:n,slotProps:r}){const i=p(()=>({authority:t,bundle:void 0,contribution:e,fixtures:n,retired:!1}),[t,e,n]),[o,c]=W(void 0),s=(o==null?void 0:o.owner)===i?o.bundle:void 0,a=O(r);return w(()=>{a.current=r}),A(()=>()=>{i.retired=!0,queueMicrotask(()=>{try{B(i)}catch(l){C(l)}})},[i]),w(()=>{const l=i.bundle??(i.bundle=Ae(i.contribution,a.current,i.fixtures??[],i.authority));return c(d=>(d==null?void 0:d.owner)===i?d:{bundle:l,owner:i}),()=>{i.retired&&B(i)}},[i]),w(()=>{(s==null?void 0:s.isOpen())===!0&&s.props.set(r)},[s,r]),s===void 0?null:m(xe,{bundle:s,contribution:e})}function Fe(t){const{authority:e,contribution:n,contributionId:r,fixtures:i,targetId:o}=t,c=ne(),s=p(()=>({authority:e,contribution:n,fixtures:i}),[e,n,i]),a=e==null?void 0:e.feature,l=p(()=>({contribution:r,feature:a,target:o}),[r,a,o]),d=e==null?void 0:e.reporter,h=S(f=>Z(f,d),[d]);return m(re,{content:c,identity:s,report:h,source:l,children:m(Ee,{...t})})}const ze=N(Fe);export{ze as M,z as a,Se as b,E as c,F as d,I as e,y as i,de as p,ue as r,oe as u};
2
+ //# sourceMappingURL=contribution-frame-CCKnOxZR.js.map