@opetope/react 0.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,149 @@
1
1
  # @opetope/react
2
2
 
3
+ ## 0.12.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 997137b: **Documentation only: four statements that had drifted from the tree are corrected, and the copies they drifted
8
+ out of are replaced by the one place each law is written (D460).** No source file changes, no public name moves,
9
+ no gate budget moves and no compiled example is edited. The root README gains a map, «Where each contract is
10
+ written once», naming for every subject the one normative section, the worked examples over it and nothing else.
11
+
12
+ - **The runtime word map promised two Resource names and the entry publishes nineteen.** `Resource` and
13
+ `ResourceState` were described as «the two words this entry publishes for a materialization», with the rest of
14
+ the family «named from `@opetope/core`». The entry re-exports the family whole — every `Resource*` word plus
15
+ `EventDelivery`, `LiveDelivery`, `PaginationOutcome` and `PaginationState` — and the comment above that block
16
+ names the decision that made it so. The row now says what the file does, which is what lets a feature author
17
+ write one import line for a declaration and for the `ResourceData<Data>` its `apply.change` annotates.
18
+ - **`@opetope/core` said it stays `private`, and its manifest carries no such field.** The manifest has
19
+ `publishConfig.access: public` and no `private`. The sentence keeps what was true — the API is experimental
20
+ while the design is under review — and says what that costs an upgrader: an alpha removes a superseded name
21
+ instead of keeping it beside its replacement, so an upgrade is read through the release notes. It makes no claim
22
+ about what is or is not on npm today.
23
+ - **Three pages still taught the pre-`LostWrite` law.** The runtime README said a dropped write is lost «in
24
+ silence rather than thrown at the caller or recorded by the reporter»; the primitives reference said «nothing is
25
+ reported»; and the spec's own model-layer section said «dropped without a reporter record». The law changed two
26
+ decisions ago and the normative sections that state it — §2.11 and §4 «Failures after abort» — were already
27
+ right: the first write an authority loses after ending inside a write into a cell its source reads, its own or
28
+ one of a command it invoked, reaches the reporter as `LostWrite`, once per run, and every other drop is silent.
29
+ All three now say that and defer to §4 for which is which. `update` is still `void`: nothing here promises a
30
+ throw at the caller. The primitives tables that listed a dropped write as always silent are corrected with them.
31
+ - **Two code tuples had already lost codes, and both are replaced by a link.** The authoring guide listed the
32
+ codes of every error class and was missing two of the six of `ContributionError`; the core README's boundary
33
+ list was missing the `CancellationError` its own word map names one paragraph above. Which class carries which
34
+ code, and which codes are cancellations rather than product answers, is the spec's «Errors»; which class is
35
+ thrown by whom is the how-it-works error table. The React README's outcome bullet, a third copy of the same law
36
+ plus the spec's «Command outcomes», goes the same way.
37
+ - **The rule that looked stale is not, and its reason was.** The authoring guide requires a result type on a model
38
+ factory, because an inline arrow handed straight to `openModel(Decl, ctx => …)` is not instantiated when its
39
+ body is checked. A probe over the current sources confirms the rule and refutes the explanation: a body that
40
+ names no input is inferred as `Command<void, void>`, taking the `void` default instead of the `never` the guide
41
+ claimed — so the declaration's input never arrives, a body that reads `input` is refused on the read with the
42
+ four-position instruction, and a body that reads nothing is refused against the record the factory returns. The
43
+ reason is rewritten to that; the rule stands.
44
+ - **The repetitions removed, and what was left in their place.** The primitives reference's shared-laws section
45
+ stated the fence-and-drain, writer, cancellation and reporter laws in full while declaring the spec normative
46
+ for all four; it now states what a primitive owes each law and links. The runtime README restated the reason
47
+ behind `opetope/no-snapshot-in-update`, which that rule's own README writes out, and restated the model
48
+ context's lanes and its `invoke`-from-an-effect verbatim from the core README, which declares them. Each task
49
+ page keeps its short explanation and its warnings.
50
+
51
+ - 8aec982: **A command's one waiting place is written as the record that names it: `concurrency: { pending: 'latest' }`
52
+ (D459).** The scalar `concurrency: 'latest'` named the input that wins and never named what it wins — «the latest»
53
+ reads as a place in a queue, as the body already running and as a cached answer, and only the first was true. The
54
+ shared form of the same fact never had that gap: `{ lane, pending: 'latest' }` says the place out loud, with the
55
+ `pending` an event and a live Resource already write for their own one waiting place. One fact was being written in
56
+ two vocabularies, and the shorter one left out the part that mattered. The word is removed rather than kept beside
57
+ the record, which makes this a breaking change to the authoring surface of a model command, of a model selection
58
+ and of a feature command.
59
+
60
+ - **Migrate by writing the place.** `concurrency: 'latest'` becomes `concurrency: { pending: 'latest' }` in
61
+ `ctx.command`, `ctx.select` and `own.command`. `concurrency: { lane, pending: 'latest' }` is unchanged: the lane
62
+ is the one parameter this record takes, and it is optional now instead of required. Nothing else about the option
63
+ moves — the same record, the same siblings, the same `dedupe` prohibition beside it.
64
+ - **No law moved with the spelling.** While call 1 runs, calls 2 and 3 take one waiting place: the body of 2 never
65
+ starts, its wait ends as `CommandError('cancelled', 'Command <id> replaced a waiting input.')`, and 3 runs when 1
66
+ finishes. A newer request never reaches the call in flight. The regression that holds this reads `signal.aborted`
67
+ inside the running body — after both newer requests were admitted and before the body returns — rather than
68
+ counting abort events, because the abort that follows a call's own end belongs to its cleanup and says nothing
69
+ about the policy. It was proved by substitution: with the kernel changed to cancel whatever is in flight, the
70
+ test fails on that line.
71
+ - **The retired word is answered with its replacement, not with a list of everything else.** The runtime throws
72
+ `Model command concurrency latest is now a record: write { pending: 'latest' }, or { lane, pending: 'latest' }.`
73
+ — and `Model select`, `Feature command` the same. Two neighbouring messages move with it: the choice sentence
74
+ loses the word — `concurrency must be queue, parallel, a lane of this surface or a record.` — and the record's own
75
+ refusal now says the lane is optional. A record that names `lane` and hands it `undefined` is still refused, by
76
+ the type under `exactOptionalPropertyTypes` and by the runtime, which reads the shape of the record by its keys.
77
+ - **The compiler names the replacement too, because the literal stays in the union.** The options record of a
78
+ command gained a third member — the retired word beside a key an author cannot write — so a refusal arrives as a
79
+ missing property carrying the instruction. Without it the union held no string at all, every word written there
80
+ widened to `string`, and one sentence answered a typo and a retired word alike. What the two now print, the same
81
+ on a model and on a feature: `{ concurrency: 'quee' }` is a `TS2820` that names the word and suggests `'queue'`,
82
+ which is better than it was before this change, and `{ concurrency: 'latest' }` is a `TS2345` whose elaboration
83
+ carries «`concurrency: "latest"` is now a record: write `{ pending: "latest" }`, or `{ lane, pending: "latest" }`».
84
+ A selection is the one surface where the compiler names the word written and not its replacement — its options
85
+ record is not a union — and there the runtime and the lint rule carry it.
86
+ - **`opetope/no-retired-vocabulary` reports it.** `concurrency: 'latest'` is a retired _value_ of a live key, the
87
+ shape `overflow: 'reject'` already had, so the rule now reads the values of a command's options record as well as
88
+ its keys and reports on the word that was written. A project that pins the rule's output by its own fixtures gains
89
+ one row.
90
+ - **What the gates read.** Instantiations are unchanged — 11 756, 87 710 and 160 268 against ceilings of 12 200,
91
+ 99 062 and 181 237 — because a scalar became a record that already existed. The tightest size row, the headless
92
+ session consumer of `@opetope/devtools`, stays at 6.95 kB of 7.1 kB. The two runtime rows carry the new branch and
93
+ its message: the application graph consumer moves 48.56 → 48.66 kB of 49 kB and the public feature consumer
94
+ 39.88 → 39.92 kB of 41 kB, measured by rebuilding the validator both ways. No budget moves.
95
+
96
+ - 4e65393: **The published archive of every package loses `README.ru.md`, and `@opetope/runtime` loses the Russian pages of
97
+ its `docs/` as well (D456).** The Russian half of the documentation is removed: nineteen `*.ru.md` files,
98
+ **2 027 997 bytes**, which is 39.0 % of what `ci:docs` counts as a document and 35.1 % of every `*.md` in the tree.
99
+ Documentation is written once, in English, from here on. No public name, no `exports` entry and no subpath moves —
100
+ a `.ru.md` was never a resolvable specifier, only a file read by its path — and no line of shipped JavaScript
101
+ changes, which is why this is a patch. The composition of what is published does change, and this is the line that
102
+ says so.
103
+
104
+ - **What goes.** `CONTRIBUTING.ru.md`, `README.ru.md`, the README of the minimal React example, the nine guides of
105
+ `docs/` — agent guide, cookbook, devtools, how-it-works, primitives, releases, both migration guides and the
106
+ spec — and the `README.ru.md` of all seven packages. `docs/decisions.md` stays Russian and keeps no pair, as it
107
+ never had one. The nine copies under `packages/runtime/docs/` are generated by packaging and leave on their own.
108
+ - **The rule that required a pair is cancelled, with its gate.** `CLAUDE.md` said «README and user guides have
109
+ English/Russian pairs, updated together» with four exceptions; it now says a document is written once, in
110
+ English, and gets no second copy. `ci:docs` no longer looks for a `*.ru.md` beside a document and no longer
111
+ compares the heading skeleton of a pair, and its report reads «links and example section order passed».
112
+ - **One gate is lost outright, and nothing replaces it.** The fixture generator held every English example against
113
+ its Russian twin as the same program — syntax without comments and without JSX text — which is how D441 caught a
114
+ real drift in a spec §2 block, a string literal that differed in one copy. The defect class that stops being
115
+ caught is a silent edit to a detail of an example that still compiles: a string literal, a number, the order of
116
+ two arguments of the same type. `ci:type` accepts any literal of the right type, `ci:docs` reads section order
117
+ and links rather than values, `ci:asserted-refusals` holds pragmas, and the generator's own `--check` compares a
118
+ fixture with the very document it was lifted from, so one `fixtures:generate` makes any such edit the new
119
+ baseline. The twin was the only place in this repository where one program was written twice and the two
120
+ writings were compared. It also never knew which copy was right — in the one case it fired, the wrong copy was
121
+ the Russian one — and it protected above all the copy that nothing compiled.
122
+ - **Where the archive change is written down, so it cannot drift from its gate.** The composition of an archive is
123
+ stated in six places and all six are rewritten: the `files` of the seven manifests; the required-file list and
124
+ the archive-member allowlist of the pack archive check; the fixture of that check's own unit test; the
125
+ allowlists of the two real pack smoke tests; and what packaging demands of each package before it packs. The
126
+ requirement that `@opetope/runtime` ship `docs/spec.ru.md` is gone; `docs/spec.md` is required exactly as before.
127
+ - **The numbers the gates print.** `ci:docs` reads 28 documents instead of 46 — nineteen leave and this changeset
128
+ is itself a document — with 4 pending changesets instead of 3 and 424 decision identifiers instead of 423. Its
129
+ feature examples halve, 64 to 32: that gate lints every fenced example that declares a feature, and it was
130
+ linting both copies of each one. Packaging prepares 10 documents instead of 19, and the merge-marker scan reads
131
+ 1101 text files instead of 1117 — seventeen of the nineteen removed files live under a scanned root, and this
132
+ changeset is one file back. The document examples do not move at all: 38 spec §2 blocks and 132 blocks of 11
133
+ other documents, 2 fragments, 14 asserted refusals, exactly as before, because only the English block was ever
134
+ lifted into a fixture.
135
+
136
+ - Updated dependencies [997137b]
137
+ - Updated dependencies [ba75469]
138
+ - Updated dependencies [d09568e]
139
+ - Updated dependencies [8aa4d85]
140
+ - Updated dependencies [8aec982]
141
+ - Updated dependencies [be0613e]
142
+ - Updated dependencies [4e65393]
143
+ - Updated dependencies [e12b0b6]
144
+ - @opetope/core@0.12.1
145
+ - @opetope/runtime@0.12.1
146
+
3
147
  ## 0.12.0
4
148
 
5
149
  ### Minor Changes
package/README.md CHANGED
@@ -1,21 +1,24 @@
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';
@@ -49,404 +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?.kind === '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 Command 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 command, and the order the command was declared with decides
74
- what happens to it (D203). For an absolute value setter the model declares `concurrency: 'latest'` in
75
- `context.command`, and the newest input then replaces the waiting one there (D185, D387). What the consumer keeps
76
- is its own: an already-aborted input signal is refused as `cancelled` without reaching the command, a run of an
77
- unmounted consumer is refused the same way, `inFlight` is true while any run this consumer started is unsettled,
78
- and every run carries its own callbacks and signal.
79
-
80
- To share equivalent pending or running work, the model declares `dedupe: true` or `dedupe: input => key`
81
- on `context.command`. Sharing keeps the first input and each caller's independent cancellation; neither form may
82
- be combined with `latest` (D277). These are Command options, not hook options.
83
-
84
- For several commands a component writes `useCommand` per command, or one `useModel` selection, which already
85
- answers ready hooks. `useCommands` is gone: a record over the one `CommandHook` was sugar that saved hook calls,
86
- not a second mechanism, and the set that is really wanted is a selection in the model, where `ctx.select` gives it
87
- an owner and a lifetime (D388).
88
-
89
- Two aliases of one Command have independent local statuses, just like two `useCommand` consumers; they use the same
90
- Command concurrency and mount command record (D197, D203). A selection introduces no shared busy state, queue or
91
- transaction; controls that overwrite one value still need one semantic command.
92
-
93
- ## Selecting data and commands
94
-
95
- `useModel(Declaration, (model, { read }) => ({ ... }))` combines explicit data selection and command consumers (D205, D214).
96
-
97
- A model selection can use a named interface without an index signature (D217). Its result is a flat data record;
98
- arrays, functions, constructors and built-in collection/date/promise objects are not selection records.
99
- `read(source)` returns its snapshot; `read(source, project)` returns a projection. Authentic Commands selected as
100
- record fields become the same `CommandHook` as `useCommand`, including independent alias statuses and stable `.run`.
101
- A single selected command needs no nested hooks:
102
-
103
- <!--example
104
- import type { Command } from '@opetope/core';
105
- import { defineModel } from '@opetope/core';
106
- import { useModel } from '@opetope/react';
107
-
108
- const AuthModel = defineModel<{ readonly logout: Command<void, void> }>('example.auth.model');
109
-
110
- const LogOutButton = () => {
111
- /* … */
112
- };
113
- -->
114
-
115
- ```tsx
116
- const { logout } = useModel(AuthModel, auth => ({ logout: auth.logout }));
117
- return (
118
- <button disabled={logout.inFlight} onClick={() => void logout.run()}>
119
- Log out
120
- </button>
121
- );
122
- ```
123
-
124
- The one-argument form still returns the granted model; individual hooks remain available.
125
-
126
- Only explicitly read sources are subscribed, once per distinct Readable in the selection. Returned data fields are
127
- compared with `Object.is`; select scalar fields, spread a projected record into the selection, or return stable
128
- references. A newly allocated nested object is a changed field. The callback is pure: no hooks, commands or side effects.
129
- A read or selector error reaches the nearest React error boundary. Changing the selected sources or command keys
130
- changes their subscriptions/consumers at commit; an abandoned render cannot replace committed authority.
131
-
132
- The hook neither creates a model nor acquires a feature. It leases exactly the resources its selection names as
133
- values — that field answers a `ResourceHook`, and one lease per Resource identity covers the mount even when two
134
- keys name one Resource (D321, D359) — and nothing else: a
135
- `read(resource.state)` leases nothing, and neither does the one-argument form. Combining hooks does not promise fewer
136
- source subscriptions or faster renders; a raw `useModel` import now also includes the selection implementation.
137
- There is no additional Command scheduler.
138
-
139
- ## Application host
140
-
141
- The host opens a feature graph with `openApplication`. Ready features publish their UI contributions atomically;
142
- the ordinary `Slot` mounts them into consumer-owned targets:
143
-
144
- <!--example
145
- import { defineSlot, Slot } from '@opetope/react';
146
-
147
- const exampleFooterSlot = defineSlot('example.footer');
148
- const exampleSettingsSlot = defineSlot('example.settings');
149
-
150
- const ExampleShell = () => (
151
- <>
152
- /* … */
153
- </>
154
- );
155
- -->
156
-
157
- ```tsx
158
- <Slot target={exampleFooterSlot} />
159
- <Slot target={exampleSettingsSlot} />
160
- ```
161
-
162
- The application owns feature lifetimes; each feature's UI mounts through its contributions.
163
-
164
- ## Feature demand boundary
165
-
166
- <!--example
167
- import type { ReactNode } from 'react';
168
- import type { Resource } from '@opetope/core';
169
- import { defineSlot } from '@opetope/react';
170
- import type { FeatureDemandSource } from '@opetope/react/integration';
171
-
172
- type Tasks = Readonly<{ id: string }>;
173
- type ConfirmActionProps = Readonly<{ itemId: string; onConfirmed: () => void }>;
174
-
175
- declare const host: FeatureDemandSource<Readonly<{ exports: Readonly<{ tasks: Resource<Tasks, 'none'> }> }>>;
176
- declare const itemId: string;
177
- declare const onConfirmed: () => void;
178
- declare const children: ReactNode;
179
- declare const Spinner: () => null;
180
- declare const Failure: (props: Readonly<{ error: unknown; onRetry: () => void }>) => null;
181
- declare const TasksResourceLease: (props: Readonly<{ children: ReactNode; resource: Resource<Tasks, 'none'> }>) => null;
182
-
183
- const confirmActionContentSlot = defineSlot<ConfirmActionProps>('example.confirmAction.content');
184
- -->
185
-
186
- ```tsx
187
- import { Slot } from '@opetope/react';
188
- import { FeatureBoundary, useFeatureRetry } from '@opetope/react/integration';
189
-
190
- function Retry() {
191
- return <button onClick={useFeatureRetry()}>Try again</button>;
192
- }
55
+ ## Documentation
193
56
 
194
- <FeatureBoundary demand={host} error={<Retry />} fallback={<Spinner />}>
195
- <Slot props={{ itemId, onConfirmed }} target={confirmActionContentSlot} />
196
- </FeatureBoundary>;
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.
197
60
 
198
- // or with render callbacks, when the ready instance itself is needed (D177)
199
- <FeatureBoundary
200
- demand={host}
201
- error={({ error, retry }) => <Failure error={error} onRetry={retry} />}
202
- fallback={<Spinner />}
203
- >
204
- {({ exports }) => <TasksResourceLease resource={exports.tasks}>{children}</TasksResourceLease>}
205
- </FeatureBoundary>;
206
- ```
207
-
208
- `FeatureBoundary` consumes a `FeatureDemandSource` supplied by host integration. It holds the demand for the feature,
209
- shows the ready branch once that demand is ready and isolates `useFeatureRetry` inside the error subtree. A branch may be a node or a
210
- render callback: the callback of `children` receives the ready instance typed by the demand, the callback of `error`
211
- receives `{ error, retry }`, and only the branch that is shown runs. The callback needs no `useFeature` of its own,
212
- so a ready consumer does not acquire the demand a second time; hooks still live in child components, not in the
213
- callback. There is no separate hook for
214
- 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
215
- root, no `ui` section and no second binding API any more (D85).
216
-
217
- Both the boundary and the bare `useFeature` are exercised by the packaged consumer of `ci:pack`, which checks the
218
- law they share: one lease per mount, kept across a settled `retry` and released on unmount. `useFeature` is the
219
- hook half used where a host renders the readiness itself, and it is a candidate to leave this entry until a second
220
- application is measured; `FeatureBoundary` is not a pair with `ContributionBoundary` and stays (D301).
221
-
222
- ## Contributions
223
-
224
- ```tsx
225
- import { defineSlot, defineSwitchSlot, Slot } from '@opetope/react';
226
-
227
- const HeaderEnd = defineSlot<{ readonly mode: 'desktop' | 'phone' }>('example.headerEnd');
228
- const PageEnd = defineSwitchSlot<'home' | 'wallet', { readonly compact: boolean }>('example.pageEnd');
229
-
230
- function Header({ mode }: { readonly mode: 'desktop' | 'phone' }) {
231
- return <Slot props={{ mode }} target={HeaderEnd} />;
232
- }
233
-
234
- function HomePageEnd() {
235
- return <Slot props={{ compact: true }} target={PageEnd('home')} />;
236
- }
237
- ```
238
-
239
- `defineSlot` creates an authentic target with an ordered set of contributions; `defineSwitchSlot` lazily creates and
240
- caches a stable target per route. `Slot` reads the atomic snapshot and renders every `{ Component }` in the authority
241
- frame of the instance that gave the contribution. The order and the stable key come from the contribution entries of the core, so
242
- React introduces neither a second comparator nor a second lifetime registry. The packaged consumer of `ci:pack`
243
- exercises that caching and routes one contribution through it (D301).
244
-
245
- `useResource(resource)` answers `{ pagination, refresh, reset, retry, state }`: the `ResourceState` through
246
- `useSyncExternalStore`, the lease `resource.acquire()` answers taken in a commit effect for as long as the component
247
- is mounted, and the three verbs as ordinary `CommandHook` consumers (D359). An interrupted render therefore opens
248
- nothing, and when the reference changes the state follows the new Resource while the effect cleanup releases the
249
- previous lease. The number of React hooks does not depend on the Resource: a declaration without `pagination`
250
- subscribes to a constant source and keeps a `loadNext` that answers `skipped`, while a declaration that carried the
251
- capability answers `pagination` as `{ loadNext, state }` (D356). A field of a `useModel` selection whose value is a
252
- Resource answers the same hook and takes the same lease, so a model declares no `Command<void, void>` around
253
- `resource.refresh()` to give a button its `inFlight`. `useReadable(resource)` is a compile error, because a Resource
254
- is not a `Readable`; `useReadable(resource.state)` and a `read(resource.state)` in a selector stay passive and lease
255
- nothing — observing and holding are independent questions, so a selector that reads the state and a `useResource`
256
- beside it subscribe twice (D349). The compile-checked example of specification §2.13 shows both sides of that
257
- (D301, D321).
258
-
259
- `refresh.run()`, `retry.run()` and `reset.run()` reach the Resource, which is owned by the model that declared
260
- it: `inFlight` is true until the outcome of the operation settles, `outcome` carries that `ResourceOperationOutcome`
261
- as the `value` of an `ok` status, and `run(undefined, { signal })` cancels the wait of this consumer and not the work
262
- the model owns. Two consumers of one Resource keep separate statuses, and `run` keeps its identity while the Resource
263
- does. A render that caught up with retirement renders the terminal record — `idle` with reason `retired` for a Resource
264
- a model owns, `unleased` for a feature's facade, which is a reference that outlives the instance — and unmounts,
265
- releasing its lease (D359, D364). Nothing here refuses a read because a lifetime ended: a source whose owner retired
266
- stops and keeps answering its last value, so `useReadable`, `useSelector` with an inline selector and a selection's
267
- `read` each read on and need no memory of their own. A refusal that remains is work that failed — a state that
268
- failed, a broken pagination cursor — and it still reaches the render.
269
-
270
- Direct component props, a contribution's `props` adapter and its model's props `Readable` share the same
271
- mount-owned snapshot. Incoming slot props publish in layout before paint, so direct and adapted renders cannot
272
- mix new props with an old model snapshot. Model factory failures clean up partially created kernels.
273
- Models belong to commit: an abandoned render creates no model to clean up (D170, D188).
274
- StrictMode effect replay and Suspense hide/reveal preserve the committed model bundle and state. Only actual
275
- identity replacement or unmount releases it; a hidden unmount releases in a microtask after React has disconnected
276
- the layout effects (D209).
277
-
278
- Demand retry is scoped to the source identity. Replacing a boundary's demand source allows its new retry to run
279
- even if the previous source's retry is still pending.
280
-
281
- Every mount is an error boundary of its own contribution (D256). A render or commit that throws stops there, is
282
- reported to the feature that published the contribution, and leaves the other mounts of the target untouched.
283
- `ContributionBoundary` states once, above every slot, what a failed mount shows:
284
-
285
- <!--example
286
- import { defineSlot, Slot } from '@opetope/react';
287
-
288
- declare const FailedContribution: (props: Readonly<{ error: unknown; onRetry: () => void }>) => null;
289
-
290
- const applicationSurface = defineSlot('example.surface');
291
- -->
292
-
293
- ```tsx
294
- import { ContributionBoundary } from '@opetope/react/integration';
295
-
296
- <ContributionBoundary error={({ error, retry }) => <FailedContribution error={error} onRetry={retry} />}>
297
- <Slot target={applicationSurface} />
298
- </ContributionBoundary>;
299
- ```
300
-
301
- `error` takes a node or a callback of `{ contribution, error, feature, retry, target }`, the failure shape of
302
- `FeatureBoundary` plus the identity of what failed: `contribution` is the published entry `<feature>.<provides key>`
303
- and `target` is the slot, so one branch can answer by surface. `retry` remounts the contribution, so its models are
304
- created again. The reporter of the publishing feature receives the same identity on a `ContributionError` whose
305
- `cause` is the original error, with code `render-failed` or `error-content-failed`. Without the provider a failed mount renders nothing — containment
306
- never depends on it. Error content that throws is contained the same way and reported, never escalated.
307
-
308
- ## Scenario tests and physical activity
309
-
310
- `createScenario(application, options)` from `@opetope/react/testing` opens the real application and its existing
311
- inspection session (D206, D215). Supply the normal `imports`/`conditions` and a test-owned
312
- `host.mount(Component)` adapter returning an `unmount()` handle. The package adds no DOM renderer or test-runner
313
- dependency. `scenario.mount(target, { props })` uses the published Slot contributions and returns
314
- `{ host, updateProps, unmount }`; `host` is the renderer's original result. Typed targets require `options.props`,
315
- while targets without props omit it, exactly as with `Slot` (D217). Fixture commands do not bypass authority.
316
-
317
- As with `openApplication`, omit `conditions` when no enabled feature requires a host-bound condition. Conditions
318
- computed through `source`/`select` bind automatically; external conditions still require their sources (D271).
319
-
320
- The synchronous constructor exposes `ready`, so a test can inspect a pending lazy body before readiness.
321
- `waitFor(snapshot => predicate, { label, timeoutMs, pollIntervalMs })` wakes on inspection changes and also polls
322
- external UI predicates; `notify()` wakes it after a controlled fixture update. The default deadline is 1000ms,
323
- with a 10ms predicate poll. A `ScenarioTimeoutError` carries the data-only snapshot, bounded history and observed
324
- conditions, feature phases, body loads, lane blockers and resource lease facts. It does not infer repository
325
- or network causes. `getSnapshot()` and `history()` use that same observation model; history defaults to 64 snapshots,
326
- activity to 256 records. Capacities accept integers from 1 to 10000. Do not replace predicates with a fixed number of ticks.
327
-
328
- `close()` fences application admission synchronously, unmounts all registered screens and joins their cleanup with
329
- physical application drain. Its deadline does not cancel cleanup: a later `close()` can await the same drain.
330
- A readiness deadline likewise leaves the application available for inspection and explicit cleanup.
331
- `ownership()` reports only registered runtime ownership, with `unknown` for missing, stale or truncated evidence;
332
- a workspace stale snapshot is complete only after the scenario witnessed successful physical cleanup. This permits
333
- a scoped zero-count assertion, without proving absence of arbitrary host, UI or GC leaks. Successful cleanup clears
334
- application imports and internal renderer references. A failed cleanup promise can retain original errors and retry
335
- capabilities; a caller that keeps `mounted.host` also keeps its own renderer result.
336
-
337
- The inspection schemas are `opetope.devtools-graph/5` and `opetope.devtools-frame/5`, with optional
338
- `opetope.runtime-activity/4` snapshots (D266, D275, D344, D358). Within one session,
339
- a frame without `activity` preserves the previous activity; a full snapshot/reset without it clears that observation
340
- (D216). Activity-bearing frames replace the previous activity in full.
341
- 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,
342
- actual feature generation, physical calls, exact current lane blockers, and for every registered Resource its
343
- `kind`, `lifetime`, `epoch`, `state`, `activity` with its `operation`, `leases`, `subscribers`, `pagination` and the
344
- `epoch` of each physical load — there is no `attempt` and no numeric generation, because a Resource runs one logical
345
- attempt and recovery belongs to the transport (D358). Host demand and UI models are unknown. `freshness`
346
- and `truncated` distinguish a complete live view from a partial or detached one. A closed session is stale;
347
- `closed: true` says that the close of the application finished, whether its physical drain succeeded or refused, and
348
- the snapshot of a closed application names no feature and no Resource (D427). No control authority or product payload is added.
349
- Activity output is bounded by record capacity. Snapshot collection still visits registered owners, executors and
350
- resources, so capacity does not bound traversal cost. Collection stops once truncation is proven;
351
- idle executors may still require traversal to establish completeness. Normal call dispatch allocates no diagnostic record with
352
- observation disabled. Graph frames remain bounded by the existing ring capacity.
353
-
354
- ## Testing
355
-
356
- `@opetope/react/testing` exports component fixtures and application scenarios, and re-exports
357
- `@opetope/runtime/testing` — which in turn re-exports `@opetope/core/testing` — so a React test names one entry
358
- (D285):
359
-
360
- - `renderSlot(target, { contribution, models, props })` mounts the published target or one fixture contribution;
361
- - `command(run)` produces an authentic `Command` for a model fixture, plus the contribution binding a mount reads;
362
- - `runCommand(target, input?, options?)` executes an existing authentic `Command` without mounting React (D261);
363
- - `testReadable(initial)`, `testResourceEpoch()`, `openModel`, `settled`, `waitFor`, `yieldTurn` and `eventually`
364
- come from the entries below, for the part of a test that has no UI in it — `testResourceEpoch()` is what a
365
- component fixture writing a `ResourceState` by hand puts in its `epoch` (D362).
366
-
367
- A test that renders nothing should import `@opetope/runtime/testing` directly: `renderSlot` and the binding half of
368
- `command` are all that needs React here. The Command a fixture mints is the `command` of `@opetope/core/testing`
369
- (D300), and this word is the one name of the re-export chain that does not pass through: the React `command`
370
- shadows it with the stronger one.
371
-
372
- The `renderSlot` harness returns `Slot` and `updateProps`: there are no roots and no fixtures for them, UI enters the
373
- application as contributions (D85). It is not a test spelling of `<Slot>` — it mounts a fixture and takes a
374
- `reporter`, because a component test has no feature to report to — and the packaged consumer of `ci:pack` mounts it
375
- from the real archive (D301).
376
-
377
- A test that previously mounted `useCommand` only to invoke a Command can use `runCommand` directly. The result is the
378
- Command's output, while a UI test still checks the `CommandOutcome` returned by the mounted consumer:
379
-
380
- ```ts
381
- import assert from 'node:assert/strict';
382
- import { defineModel } from '@opetope/core';
383
- import type { Command, ModelCommandContext } from '@opetope/core';
384
- import { defineFeature, openFeature } from '@opetope/runtime';
385
- import { runCommand } from '@opetope/react/testing';
386
-
387
- const Arithmetic = defineModel<{ readonly double: Command<number, number> }>('example.testing.model');
388
- const arithmetic = defineFeature('example.testing.arithmetic', {
389
- own: ({ model }) => ({
390
- arithmetic: model(Arithmetic, context => ({
391
- double: context.command(({ input }: ModelCommandContext<number>) => input * 2),
392
- })),
393
- }),
394
- exports: ({ own }) => ({ double: own.arithmetic.double }),
395
- });
396
- const instance = openFeature(arithmetic, { imports: {}, reporter: () => undefined });
397
- try {
398
- const ready = await instance.ready;
399
- const result = await runCommand(ready.exports.double, 3);
400
- assert.equal(result, 6);
401
- } finally {
402
- await instance.close();
403
- }
404
- ```
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.
405
64
 
406
- `runCommand` returns `Promise<Output>` and preserves the Command's original errors and cancellation rejections. It adds no
407
- UI status or outcome wrapper, scheduling policy, model creation or cleanup lifetime. The existing Command retains its
408
- owner, lane and retirement rules; the test closes the instance it opened, including after failed readiness.
409
- A void Command can use `runCommand(target)`. Options always occupy the third argument:
410
- `runCommand(target, input, { signal })`, or `runCommand(voidTarget, undefined, { signal })`.
411
- `RunCommandOptions` 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).
412
67
 
413
- `run`, `runCommand` and nested `invoke` use the same no-input rule (D273): `void` and `undefined` allow omission;
414
- other inputs, including `T | undefined`, require an argument. `never` cannot be invoked without input.
415
- 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).
416
70
 
417
- ## Word map and entries
71
+ <a id="application-host"></a>
72
+ [See Application host](../runtime/docs/reference/react.md).
418
73
 
419
- | Entry | What it holds |
420
- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
421
- | `@opetope/react` | `useModel`, `useCommand`, `useReadable`, `useSelector`, `useResource`, `requiresModels`, `defineSlot`, `defineSwitchSlot`, `Slot`, `ContributionError`; types `SlotTarget`, `SwitchSlotTarget`, `SlotContribution`, `CommandHook`, `CommandOutcome`, `ResourceHook`, `PaginatedResourceHook`, `ResourcePaginationHook` |
422
- | `@opetope/react/integration` | `FeatureBoundary`, `ContributionBoundary`, `useFeature`, `useFeatureRetry`, `FeatureBoundaryError` for the integration layer; types `FeatureBoundaryProps`, `ContributionBoundaryProps`, `ContributionErrorContent`, `ContributionFailure`, `FeatureDemandSource`, `FeatureDemandState`, `FeatureDemandResult`, `FeatureDemandLease` |
423
- | `@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).
424
76
 
425
- `Command<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).
426
79
 
427
- `Model` is a typed key for a record of `Readable`, `Command` and readable factories that UI components consume.
428
- The constructors of the integration entry are not re-exported from the safe entry and cannot come back into
429
- `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).
430
82
 
431
- ## Laws
83
+ <a id="testing"></a>
84
+ [See Testing](../runtime/docs/reference/react.md).
432
85
 
433
- - `useModel(declaration)` reads only the models declared by the contribution;
434
- - one committed contribution owns its model frame until unmount or retire;
435
- - an abandoned concurrent render holds no frame;
436
- - `useCommand` does not subscribe to the invoker: the observable state of a call lies in a `Readable` of the model;
437
- - the `cancelled` outcome does not move `outcome`, and it is only the `CommandError` codes `cancelled`
438
- and `closed`; a call to a weak port with no provider (`unavailable`) and a rejected publication
439
- (`publication-rejected`) arrive as the `failed` outcome and settle in `outcome` (D138, D292);
440
- - a contribution that fails to render or to commit is contained by its own mount and reported to its feature;
441
- - `useSelector` reads its `equals` where the selected value is known, so a named non-generic comparator compiles
442
- beside a selection whose parameter is inferred, as it does in Core (D348);
443
- - `useSelector` keeps the selected reference when something else changed; its identity case is `useReadable` and its
444
- model case is a `useModel` selection, so it is proven by the memory gate and the packaged consumer rather than by a
445
- specification example, and it is a candidate to leave this entry until a second application is measured (D301);
446
- - 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).
447
88
 
448
- ## Compatibility contract
89
+ <a id="laws"></a>
90
+ [See Laws](../runtime/docs/reference/react.md).
449
91
 
450
- The ESM of the package is built for Chrome 82+, Firefox 110+, Safari/iOS 15+, Android 82+ and Node 20.19+ and requires
451
- the peer `react >=19.0.0 <20` (D251). React and ReactDOM are peers of the host, not built-in polyfills. The raw modules are
452
- 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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opetope/react",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "engines": {
5
5
  "node": ">=20.19.0"
6
6
  },
@@ -15,7 +15,6 @@
15
15
  "files": [
16
16
  "dist",
17
17
  "README.md",
18
- "README.ru.md",
19
18
  "LICENSE",
20
19
  "CHANGELOG.md"
21
20
  ],
@@ -50,16 +49,16 @@
50
49
  }
51
50
  ],
52
51
  "devDependencies": {
53
- "@opetope/core": "0.12.0",
54
- "@opetope/runtime": "0.12.0",
52
+ "@opetope/core": "0.12.1",
53
+ "@opetope/runtime": "0.12.1",
55
54
  "@testing-library/react": "16.3.3",
56
55
  "@types/react": "19.2.18",
57
56
  "react": "19.2.8",
58
57
  "react-dom": "19.2.8"
59
58
  },
60
59
  "peerDependencies": {
61
- "@opetope/core": "0.12.0",
62
- "@opetope/runtime": "0.12.0",
60
+ "@opetope/core": "0.12.1",
61
+ "@opetope/runtime": "0.12.1",
63
62
  "react": ">=19.0.0 <20"
64
63
  },
65
64
  "sideEffects": false,
package/README.ru.md DELETED
@@ -1,401 +0,0 @@
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 { Command, 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: Command<void, void>;
28
- }>('example.counter.model');
29
- const CounterSlot = defineSlot<{ readonly label: string }>('example.counter.slot');
30
-
31
- const CounterButton = requiresModels([CounterModel])(({ label }: { readonly label: string }) => {
32
- const {
33
- count,
34
- increment: { run, inFlight, outcome },
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
- {outcome?.kind === 'failed' ? '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, outcome }`
55
- и всегда resolve-ит исход, поэтому штатная отмена не становится unhandled rejection (D116, D292).
56
-
57
- `outcome` — последний завершившийся не-отменённый исход этого потребителя: `ok` встаёт на место прежнего `failed`,
58
- `failed` — на место прежнего `ok`, исход `cancelled` не двигает ничего, и старт прогона ничего не чистит, поэтому
59
- отказ читается, пока идёт повтор. До первого завершения он `undefined`. Читают его по дискриминанту —
60
- `outcome?.kind === 'failed' ? outcome.error : null`.
61
-
62
- Команда без входа привязывается к событию стрелкой: `onClick={() => void logout.run()}`. Именно стрелка не пускает
63
- React-событие в Command и не оставляет промис висеть; `onClick={logout.run}` не компилируется, потому что `MouseEvent`
64
- не ложится в `void`. Для данных, исхода, callbacks или сигнала отдельного вызова используйте `run(input, options?)`.
65
- Там, где конфиг линтера запрещает стрелку прямо в пропсе (`react-perf/jsx-no-new-function-as-prop`,
66
- `react/jsx-no-bind`), её поднимают через `useCallback(() => void logout.run(), [logout.run])`: зависимость — `run`,
67
- а не объект хука (D290).
68
-
69
- `run` стабилен, пока invoker тот же; возвращённый объект — снимок того рендера, в котором его прочитали, и меняется
70
- вместе с `inFlight` или `outcome`, поэтому эффект или memo зависят от `run`, а не от хука, — правило
71
- `opetope/no-command-in-deps` держит это за автора (D290). Сохранённый `run` заменённого или
72
- размонтированного потребителя закрыт для новых вызовов и не перенаправляет работу на замену.
73
-
74
- Хук ничего не планирует. Каждый `run` доходит до команды, и решает порядок, с которым команда объявлена (D203).
75
- Для setter абсолютного значения модель объявляет `concurrency: 'latest'` в `context.command`, и новый вход
76
- заменяет ожидающий именно там (D185, D387). За потребителем остаётся своё: уже отменённый входной signal
77
- отвергается как `cancelled`, не доходя до команды, `run` размонтированного потребителя отвергается так же,
78
- `inFlight` истинен, пока не завершился хоть один начатый им прогон, и у каждого прогона свои callbacks и signal.
79
-
80
- Чтобы объединить эквивалентную ожидающую или исполняющуюся работу, модель объявляет `dedupe: true` либо
81
- `dedupe: input => key` у `context.command`. Сохраняются первый вход и независимая отмена каждого вызывающего;
82
- обе формы запрещены с `latest` (D277). Это опции Command, а не хука.
83
-
84
- Для нескольких команд компонент пишет `useCommand` на команду либо одну выборку `useModel`, которая и так
85
- отдаёт готовые хуки. `useCommands` удалён: запись поверх единственного `CommandHook` была сахаром, экономившим
86
- вызовы хуков, а не вторым механизмом, и нужный набор — это выбор в модели, где у него есть владелец и время
87
- жизни, которые даёт `ctx.select` (D388).
88
-
89
- Два псевдонима одного Command имеют независимые локальные статусы, как два потребителя `useCommand`; они
90
- используют один порядок Command и одну command-запись монтирования (D197, D203). Выборка не вводит общий busy,
91
- очередь или транзакцию; контролам, перезаписывающим одно значение, по-прежнему нужна одна смысловая команда.
92
-
93
- ## Выбор данных и команд
94
-
95
- `useModel(Declaration, (model, { read }) => ({ ... }))` объединяет явный выбор данных и command consumers (D205, D214).
96
-
97
- Результат выбора модели может быть именованным interface без index signature (D217). Результат — плоская запись данных;
98
- arrays, functions, constructors и встроенные объекты коллекций, дат и promises не являются selection records.
99
- `read(source)` возвращает снимок; `read(source, project)` — проекцию. Authentic Commands, выбранные полями record,
100
- превращаются в тот же `CommandHook`, что у `useCommand`, с независимыми статусами aliases и стабильным `.run`.
101
- Одну команду можно выбрать без вложенных hooks:
102
-
103
- ```tsx
104
- const { logout } = useModel(AuthModel, auth => ({ logout: auth.logout }));
105
- return (
106
- <button disabled={logout.inFlight} onClick={() => void logout.run()}>
107
- Log out
108
- </button>
109
- );
110
- ```
111
-
112
- Форма с одним аргументом по-прежнему возвращает выданную модель; отдельные hooks сохраняются.
113
-
114
- Подписка создаётся только на явно прочитанные источники, одна на каждый различный Readable внутри selection.
115
- Поля данных сравниваются через `Object.is`: выбирайте скаляры, раскрывайте проекцию record в selection или
116
- возвращайте стабильные ссылки. Новый вложенный объект считается изменившимся полем. Callback чистый:
117
- без hooks, команд и side effects. Ошибка read/selector попадает в ближайший React error boundary.
118
- Изменение источников и ключей команд применяется в commit; abandoned render не меняет действующие полномочия.
119
-
120
- Hook не создаёт модель и не приобретает feature. Он арендует ровно те ресурсы, которые выборка назвала
121
- значениями: такое поле отвечает `ResourceHook`, и одна аренда на identity Resource покрывает всё монтирование, даже
122
- когда один Resource назвали два ключа (D321, D359). Больше ничего:
123
- `read(resource.state)` не арендует, и однопараметрическая форма тоже. Объединение hooks не обещает меньше подписок или
124
- более быстрый render; импорт обычного `useModel` теперь также включает реализацию selection. Дополнительного
125
- scheduler для Command нет.
126
-
127
- ## Application host
128
-
129
- Хост открывает граф фич через `openApplication`. Готовые фичи атомарно публикуют UI-вклады;
130
- обычный `Slot` монтирует их в consumer-owned целях:
131
-
132
- ```tsx
133
- <Slot target={exampleFooterSlot} />
134
- <Slot target={exampleSettingsSlot} />
135
- ```
136
-
137
- Приложение владеет временем жизни фич; UI каждой фичи монтируется через её вклады.
138
-
139
- ## Граница спроса на фичу
140
-
141
- ```tsx
142
- import { Slot } from '@opetope/react';
143
- import { FeatureBoundary, useFeatureRetry } from '@opetope/react/integration';
144
-
145
- function Retry() {
146
- return <button onClick={useFeatureRetry()}>Try again</button>;
147
- }
148
-
149
- <FeatureBoundary demand={host} error={<Retry />} fallback={<Spinner />}>
150
- <Slot props={{ itemId, onConfirmed }} target={confirmActionContentSlot} />
151
- </FeatureBoundary>;
152
-
153
- // или с render-колбэками, когда нужен сам готовый экземпляр (D177)
154
- <FeatureBoundary
155
- demand={host}
156
- error={({ error, retry }) => <Failure error={error} onRetry={retry} />}
157
- fallback={<Spinner />}
158
- >
159
- {({ exports }) => <TasksResourceLease resource={exports.tasks}>{children}</TasksResourceLease>}
160
- </FeatureBoundary>;
161
- ```
162
-
163
- `FeatureBoundary` принимает `FeatureDemandSource` от интеграции с хостом. Он держит спрос на фичу,
164
- показывает готовую ветвь после готовности спроса и изолирует `useFeatureRetry` в поддереве ошибки. Ветвь может быть узлом или
165
- render-колбэком: колбэк `children` получает готовый экземпляр, типизированный по `demand`, колбэк `error` получает
166
- `{ error, retry }`, и выполняется только показанная ветвь. Своего `useFeature` колбэку не нужно, поэтому готовый
167
- потребитель не берёт спрос второй раз; хуки по-прежнему живут в дочерних компонентах, а не в колбэке. Отдельного хука для чтения ошибки
168
- нет: хост сам передал её в `error`, поэтому знает её без второго слова (D142). UI входит в фичу через `Slot`: ни
169
- корня, ни секции `ui`, ни второго API привязки больше нет (D85).
170
-
171
- И boundary, и голый `useFeature` проверяет упакованный потребитель `ci:pack` — на общем для них законе: одна аренда
172
- на монтирование, сохраняется через севший `retry` и отпускается на размонтировании. `useFeature` это хуковая
173
- половина для случая, когда готовность рендерит сам хост, и он кандидат на вывод с этого входа до второго
174
- измеренного приложения; `FeatureBoundary` не пара `ContributionBoundary` и остаётся (D301).
175
-
176
- ## Contributions
177
-
178
- ```tsx
179
- import { defineSlot, defineSwitchSlot, Slot } from '@opetope/react';
180
-
181
- const HeaderEnd = defineSlot<{ readonly mode: 'desktop' | 'phone' }>('example.headerEnd');
182
- const PageEnd = defineSwitchSlot<'home' | 'wallet', { readonly compact: boolean }>('example.pageEnd');
183
-
184
- function Header({ mode }: { readonly mode: 'desktop' | 'phone' }) {
185
- return <Slot props={{ mode }} target={HeaderEnd} />;
186
- }
187
-
188
- function HomePageEnd() {
189
- return <Slot props={{ compact: true }} target={PageEnd('home')} />;
190
- }
191
- ```
192
-
193
- `defineSlot` создаёт подлинную цель с упорядоченным множеством вкладов; `defineSwitchSlot` лениво создаёт и
194
- кеширует стабильную цель на каждый маршрут. `Slot` читает атомарный снимок и рендерит каждый `{ Component }` в кадре
195
- авторитета того экземпляра, который вклад дал. Порядок и стабильный ключ приходят из записей вкладов ядра, поэтому
196
- React не вводит второй компаратор и второй реестр времени жизни. Упакованный потребитель `ci:pack` проверяет это
197
- кеширование и проводит через него один вклад (D301).
198
-
199
- `useResource(resource)` отвечает `{ pagination, refresh, reset, retry, state }`: `ResourceState` через
200
- `useSyncExternalStore`, арендой, которую отдаёт `resource.acquire()`, взятой в commit-эффекте на всё время
201
- монтирования компонента, и тремя глаголами как обычными потребителями `CommandHook` (D359). Поэтому прерванный
202
- render не открывает ничего, а при смене ссылки состояние следует новому Resource, и cleanup эффекта освобождает
203
- предыдущую аренду. Число React-хуков от Resource не зависит: объявление без `pagination` подписывается на
204
- постоянный источник и держит `loadNext`, который отвечает `skipped`, а объявление, несшее эту capability, отвечает
205
- `pagination` записью `{ loadNext, state }` (D356). Поле выборки `useModel`, значением которого является Resource,
206
- отвечает тем же хуком и берёт ту же аренду, поэтому модель не объявляет `Command<void, void>` вокруг
207
- `resource.refresh()` ради `inFlight` в кнопке. `useReadable(resource)` — это ошибка компиляции, потому что Resource
208
- не `Readable`; `useReadable(resource.state)` и `read(resource.state)` в селекторе остаются пассивными и не арендуют
209
- ничего: наблюдать и держать — независимые вопросы, поэтому селектор, читающий состояние, и стоящий рядом
210
- `useResource` подписываются дважды (D349). Обе стороны показывает компилируемый пример спецификации §2.13
211
- (D301, D321).
212
-
213
- `refresh.run()`, `retry.run()` и `reset.run()` доходят до Resource, которым владеет объявившая его модель:
214
- `inFlight` истинен, пока не осел исход операции, `outcome` несёт этот `ResourceOperationOutcome` значением `value`
215
- при статусе `ok`, а `run(undefined, { signal })` отменяет ожидание этого потребителя, а не работу, которой владеет
216
- модель. Два потребителя одного Resource держат раздельные статусы, а `run` сохраняет ссылку, пока та же
217
- идентичность Resource. Render, догнавший retirement, рисует терминальную запись — `idle` с причиной `retired` у Resource, которым
218
- владеет модель, и `unleased` у фасада фичи, потому что эта ссылка переживает экземпляр, — и размонтируется, отпустив
219
- аренду (D359, D364). Ничто здесь не отказывает в чтении из-за того, что кончилась чья-то жизнь: источник, у которого
220
- ушёл владелец, останавливается и продолжает отвечать последним значением, поэтому `useReadable`, `useSelector` с
221
- inline-селектором и `read` выборки просто читают дальше, и собственная память им не нужна. Оставшийся отказ — это
222
- упавшая работа: упавшее состояние, сломанный курсор пагинации, — и он по-прежнему доходит до рендера.
223
-
224
- Прямые пропсы компонента, `props`-адаптер вклада и props-`Readable` его модели используют один снимок
225
- монтирования. Входящие пропсы слота публикуются в layout до paint, поэтому прямой и адаптированный рендеры
226
- не смешивают новые пропсы со старым снимком модели. Отказ фабрики модели убирает частично созданный kernel.
227
- Модели принадлежат коммиту: брошенный рендер не создаёт модель, которую пришлось бы убирать (D170, D188).
228
- Повтор эффектов StrictMode и скрытие/раскрытие Suspense сохраняют закоммиченный набор моделей и состояние.
229
- Только реальная замена идентичности или размонтирование освобождает его; при скрытом размонтировании это делает
230
- микрозадача после того, как React отключил layout-эффекты (D209).
231
-
232
- Retry спроса привязан к источнику. После замены источника boundary новый retry может начаться, даже если retry
233
- старого ещё не завершён.
234
-
235
- Каждое монтирование — граница ошибок своего вклада (D256). Сбой рендера или коммита останавливается на нём, уходит в
236
- reporter опубликовавшей вклад фичи и не трогает остальные монтирования цели. `ContributionBoundary` один раз, над
237
- всеми слотами, говорит, что показывает упавшее монтирование:
238
-
239
- ```tsx
240
- import { ContributionBoundary } from '@opetope/react/integration';
241
-
242
- <ContributionBoundary error={({ error, retry }) => <FailedContribution error={error} onRetry={retry} />}>
243
- <Slot target={applicationSurface} />
244
- </ContributionBoundary>;
245
- ```
246
-
247
- `error` принимает узел или колбэк `{ contribution, error, feature, retry, target }` — форму отказа `FeatureBoundary`
248
- плюс идентичность упавшего: `contribution` — опубликованная запись `<фича>.<ключ provides>`, `target` — слот, поэтому
249
- одна ветвь может отвечать по поверхности. `retry` перемонтирует вклад, поэтому его модели создаются заново. Reporter
250
- опубликовавшей фичи получает ту же идентичность на `ContributionError`, у которого в `cause` исходная ошибка, с кодом
251
- `render-failed` или `error-content-failed`. Без провайдера упавшее монтирование рендерит пустоту:
252
- изоляция от него не зависит. Упавшее содержимое ошибки изолируется так же и сообщается, а не эскалируется.
253
-
254
- ## Сценарные тесты и физическая активность
255
-
256
- `createScenario(application, options)` из `@opetope/react/testing` открывает настоящее приложение и его существующую
257
- inspection session (D206, D215). Передайте обычные `imports`/`conditions` и принадлежащий тесту адаптер
258
- `host.mount(Component)`, возвращающий handle с `unmount()`. Пакет не добавляет зависимость от DOM renderer или test runner.
259
- `scenario.mount(target, { props })` использует опубликованные Slot contributions и возвращает
260
- `{ host, updateProps, unmount }`; `host` — исходный результат renderer. Typed targets требуют `options.props`,
261
- а targets без props опускают его, как в `Slot` (D217). Fixtures не обходят authority команд.
262
-
263
- Как и в `openApplication`, `conditions` можно опустить, если включённые фичи не требуют условий от хоста.
264
- Условия с `source`/`select` связываются автоматически; внешним условиям по-прежнему нужны источники (D271).
265
-
266
- Синхронный конструктор возвращает `ready`, поэтому pending lazy body можно исследовать до готовности.
267
- `waitFor(snapshot => predicate, { label, timeoutMs, pollIntervalMs })` просыпается от inspection и дополнительно
268
- опрашивает predicates внешнего UI; `notify()` будит его после изменения управляемой fixture. По умолчанию deadline
269
- равен 1000ms, polling predicate — 10ms. `ScenarioTimeoutError` содержит data-only snapshot, ограниченную историю и
270
- наблюдаемые conditions, фазы feature, body load, lane blockers и аренды ресурсов. Причины внутри repository
271
- или сети не выводятся из догадок. `getSnapshot()` и `history()` используют ту же модель наблюдения; по умолчанию
272
- хранятся 64 снимка, activity ограничена 256 записями. Capacities — целые от 1 до 10000.
273
- Не заменяйте predicates фиксированным числом ticks.
274
-
275
- `close()` синхронно ставит fence admission приложения, размонтирует зарегистрированные экраны и ждёт их cleanup
276
- вместе с physical application drain. Deadline не отменяет cleanup: последующий `close()` может дождаться того же drain.
277
- Deadline готовности также оставляет приложение доступным для inspection и явного закрытия.
278
- `ownership()` описывает только зарегистрированное владение runtime; при отсутствующих, stale или усечённых данных
279
- возвращается `unknown`. Терминальный stale-снимок считается полным лишь после подтверждённого сценарием успешного
280
- физического cleanup. Это допускает проверку нулевых счётчиков в данном scope, но не доказывает отсутствие произвольных
281
- host/UI/GC-утечек. Успешный cleanup очищает imports приложения и внутренние ссылки на renderer. Promise отказавшего
282
- cleanup может удерживать исходные ошибки и retry capabilities; сохранённый пользователем `mounted.host` удерживает его renderer result.
283
-
284
- Inspection использует схемы `opetope.devtools-graph/5` и `opetope.devtools-frame/5`, а также optional snapshots
285
- `opetope.runtime-activity/4` (D266, D275, D344, D358). В пределах одной session
286
- frame без `activity` сохраняет предыдущую activity; полный snapshot/reset без этого поля очищает наблюдение (D216).
287
- Frame с `activity` заменяет предыдущую activity целиком.
288
- Используйте согласованные версии runtime/devtools: предыдущие ревизии графа, кадров и activity отвергаются; presence слабого ребра требует graph `/5` и frame `/4`. Activity указывает execution,
289
- фактическое поколение feature, физические вызовы, точных текущих lane blockers, а для каждого зарегистрированного
290
- Resource — его `kind`, `lifetime`, `epoch`, `state`, `activity` вместе с `operation`, `leases`, `subscribers`,
291
- `pagination` и `epoch` каждой физической загрузки: ни `attempt`, ни числового generation здесь нет, потому что
292
- Resource делает одну логическую попытку, а восстановление принадлежит транспорту (D358). Спрос хоста и UI-модели
293
- неизвестны. `freshness`
294
- и `truncated` отличают полное live-наблюдение от усечённого или отключённого. Закрытая session имеет stale-снимок;
295
- `closed: true` говорит, что закрытие приложения завершилось, успешным был его физический drain или отказавшим, и
296
- снимок закрытого приложения не называет ни одной фичи и ни одного Resource (D427). Control authority и продуктовые payload не добавляются.
297
- Размер activity ограничен capacity записей. Сбор снимка обходит зарегистрированных owners, executors и resources,
298
- поэтому capacity не ограничивает стоимость обхода. Сбор останавливается после доказанного truncation;
299
- idle executors могут требовать обхода, чтобы подтвердить полноту данных. Обычный call dispatch не создаёт диагностических записей при
300
- выключенном наблюдении. Frames ограничены существующей ring capacity.
301
-
302
- ## Testing
303
-
304
- `@opetope/react/testing` экспортирует fixtures компонентов и сценарии приложения, а также реэкспортирует
305
- `@opetope/runtime/testing`, который, в свою очередь, реэкспортирует `@opetope/core/testing`, поэтому React-тест
306
- называет один вход (D285):
307
-
308
- - `renderSlot(target, { contribution, models, props })` монтирует опубликованную цель либо один вклад-фикстуру;
309
- - `command(run)` выдаёт подлинный `Command` для фикстуры модели плюс привязку вклада, которую читает монтирование;
310
- - `runCommand(target, input?, options?)` запускает существующий подлинный `Command` без монтирования React (D261);
311
- - `testReadable(initial)`, `testResourceEpoch()`, `openModel`, `settled`, `waitFor`, `yieldTurn` и `eventually`
312
- приходят из входов ниже — для той части теста, в которой UI нет; `testResourceEpoch()` это то, что фикстура
313
- компонента, собирающая `ResourceState` руками, кладёт в её `epoch` (D362).
314
-
315
- Тест, который ничего не рендерит, должен импортировать `@opetope/runtime/testing` напрямую: React здесь нужен
316
- только `renderSlot` и привязочной половине `command`. Сам Command фикстуры чеканит `command` из
317
- `@opetope/core/testing` (D300), и это единственное имя цепочки реэкспортов, которое насквозь не проходит:
318
- React-`command` заслоняет его своим, более сильным.
319
-
320
- Харнесс `renderSlot` отдаёт `Slot` и `updateProps`: корней и их фикстур больше нет, UI входит в приложение
321
- вкладами (D85). Это не тестовое написание `<Slot>`: он монтирует фикстуру и принимает `reporter`, потому что у
322
- компонентного теста нет фичи, которой можно сообщить, — и упакованный потребитель `ci:pack` монтирует его из
323
- настоящего архива (D301).
324
-
325
- Тест, который раньше монтировал `useCommand` только ради вызова Command, может использовать `runCommand` напрямую.
326
- Результат — выход Command; тест UI по-прежнему проверяет `CommandOutcome`, возвращённый смонтированным consumer:
327
-
328
- ```ts
329
- import assert from 'node:assert/strict';
330
- import { defineModel } from '@opetope/core';
331
- import type { Command, ModelCommandContext } from '@opetope/core';
332
- import { defineFeature, openFeature } from '@opetope/runtime';
333
- import { runCommand } from '@opetope/react/testing';
334
-
335
- const Arithmetic = defineModel<{ readonly double: Command<number, number> }>('example.testing.model');
336
- const arithmetic = defineFeature('example.testing.arithmetic', {
337
- own: ({ model }) => ({
338
- arithmetic: model(Arithmetic, context => ({
339
- double: context.command(({ input }: ModelCommandContext<number>) => input * 2),
340
- })),
341
- }),
342
- exports: ({ own }) => ({ double: own.arithmetic.double }),
343
- });
344
- const instance = openFeature(arithmetic, { imports: {}, reporter: () => undefined });
345
- try {
346
- const ready = await instance.ready;
347
- const result = await runCommand(ready.exports.double, 3);
348
- assert.equal(result, 6);
349
- } finally {
350
- await instance.close();
351
- }
352
- ```
353
-
354
- `runCommand` возвращает `Promise<Output>` и сохраняет исходные ошибки и причины отклонения при отмене Command. Он не добавляет
355
- UI-статус, обёртку outcome, политику выполнения, создание модели или отдельное время жизни для cleanup. У Command остаются
356
- его владелец, lane и правила retirement; тест закрывает открытый экземпляр, в том числе при отказе readiness.
357
- Для void Command достаточно `runCommand(target)`. Options всегда передаются третьим аргументом:
358
- `runCommand(target, input, { signal })` или `runCommand(voidTarget, undefined, { signal })`.
359
- `RunCommandOptions` содержит только необязательный `signal`; уже отменённый сигнал отклоняет вызов до входа в его тело.
360
-
361
- `run`, `runCommand` и вложенный `invoke` используют одно правило входа (D273): `void` и `undefined` можно опустить;
362
- остальные входы, включая `T | undefined`, требуют аргумент. `never` нельзя вызвать без входа.
363
- Обобщённые функции могут передавать цель и её явный вход с сохранением типов.
364
-
365
- ## Карта слов и входы
366
-
367
- | Вход | Что содержит |
368
- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
369
- | `@opetope/react` | `useModel`, `useCommand`, `useReadable`, `useSelector`, `useResource`, `requiresModels`, `defineSlot`, `defineSwitchSlot`, `Slot`, `ContributionError`; типы `SlotTarget`, `SwitchSlotTarget`, `SlotContribution`, `CommandHook`, `CommandOutcome`, `ResourceHook`, `PaginatedResourceHook`, `ResourcePaginationHook` |
370
- | `@opetope/react/integration` | `FeatureBoundary`, `ContributionBoundary`, `useFeature`, `useFeatureRetry`, `FeatureBoundaryError` для integration-слоя; типы `FeatureBoundaryProps`, `ContributionBoundaryProps`, `ContributionErrorContent`, `ContributionFailure`, `FeatureDemandSource`, `FeatureDemandState`, `FeatureDemandResult`, `FeatureDemandLease` |
371
- | `@opetope/react/testing` | `renderSlot`, `command`, `createScenario`, `ScenarioTimeoutError` и типы fixtures/scenarios плюс всё, что публикуют `@opetope/runtime/testing` и `@opetope/core/testing`; только для тестов |
372
-
373
- `Command<Input, Output>` импортируется из `@opetope/core`, а UI-результат `CommandOutcome<Output>` — из `@opetope/react`. Прежний синоним `Command` и переэкспорт `CommandOutcome` из `@opetope/react/integration` удалены (D270).
374
-
375
- `Model` — типизированный ключ записи из `Readable`, `Command` и фабрик readable, которую используют UI-компоненты.
376
- Конструкторы integration-входа не переэкспортируются из безопасного входа и не могут вернуться в
377
- `ui/models/data/contracts` через локальный barrel фичи.
378
-
379
- ## Законы
380
-
381
- - `useModel(declaration)` читает только модели, объявленные вкладом;
382
- - один закоммиченный вклад владеет своим кадром моделей до размонтирования или retire;
383
- - брошенный concurrent-рендер кадр не удерживает;
384
- - `useCommand` не подписывается на invoker: наблюдаемое состояние вызова лежит в `Readable` модели;
385
- - исход `cancelled` не двигает `outcome`, и это только коды `CommandError` `cancelled`
386
- и `closed`; вызов слабого порта без провайдера (`unavailable`) и отклонённая публикация
387
- (`publication-rejected`) приходят исходом `failed` и оседают в `outcome` (D138, D292);
388
- - вклад, упавший в рендере или коммите, изолируется своим монтированием и сообщается своей фиче;
389
- - `useSelector` читает свой `equals` там, где выбранное значение уже известно, поэтому именованная необобщённая
390
- функция компилируется рядом с выборкой, параметр которой выводится, — как в Core (D348);
391
- - `useSelector` сохраняет выбранную ссылку, когда изменилось что-то другое; его тождественный случай это
392
- `useReadable`, а случай поля модели — выборка `useModel`, поэтому доказывают его гейт памяти и упакованный
393
- потребитель, а не пример спецификации, и он кандидат на вывод с этого входа до второго измеренного
394
- приложения (D301);
395
- - закрытие и размонтирование синхронно фенсят новые вызовы и освобождают ссылки кадра.
396
-
397
- ## Compatibility contract
398
-
399
- ESM пакета собирается для Chrome 82+, Firefox 110+, Safari/iOS 15+, Android 82+ и Node 20.19+ и требует peer
400
- `react >=19.0.0 <20` (D251). React и ReactDOM это peers хоста, а не встроенные полифилы. Сырые модули исполняет smoke импорта
401
- по HTTP с локальным бандлом peer-ов; исторические сборки браузеров проверяет только внешняя ферма.