@octanejs/xstate 0.0.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/LICENSE +49 -0
- package/README.md +83 -0
- package/UPSTREAM.md +140 -0
- package/package.json +79 -0
- package/src/ActorProvider.tsrx +31 -0
- package/src/ActorProvider.tsrx.d.ts +14 -0
- package/src/createActorContext.ts +111 -0
- package/src/index.ts +14 -0
- package/src/internal.ts +38 -0
- package/src/isDevelopment.ts +17 -0
- package/src/shallowEqual.ts +36 -0
- package/src/stopRootWithRehydration.ts +47 -0
- package/src/useActor.ts +94 -0
- package/src/useActorRef.ts +140 -0
- package/src/useIsomorphicLayoutEffect.ts +21 -0
- package/src/useMachine.ts +44 -0
- package/src/useSelector.ts +69 -0
- package/src/useSyncExternalStoreWithSelector.ts +113 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Octane XState binding
|
|
2
|
+
=====================
|
|
3
|
+
|
|
4
|
+
MIT License
|
|
5
|
+
|
|
6
|
+
Copyright (c) 2026 Dominic Gannaway
|
|
7
|
+
|
|
8
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
10
|
+
in the Software without restriction, including without limitation the rights
|
|
11
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
12
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
13
|
+
furnished to do so, subject to the following conditions:
|
|
14
|
+
|
|
15
|
+
The above copyright notice and this permission notice shall be included in all
|
|
16
|
+
copies or substantial portions of the Software.
|
|
17
|
+
|
|
18
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
21
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
22
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
23
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
24
|
+
SOFTWARE.
|
|
25
|
+
|
|
26
|
+
@xstate/react-derived implementation and types
|
|
27
|
+
==============================================
|
|
28
|
+
|
|
29
|
+
MIT License
|
|
30
|
+
|
|
31
|
+
Copyright (c) 2015 David Khourshid
|
|
32
|
+
|
|
33
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
34
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
35
|
+
in the Software without restriction, including without limitation the rights
|
|
36
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
37
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
38
|
+
furnished to do so, subject to the following conditions:
|
|
39
|
+
|
|
40
|
+
The above copyright notice and this permission notice shall be included in all
|
|
41
|
+
copies or substantial portions of the Software.
|
|
42
|
+
|
|
43
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
44
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
45
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
46
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
47
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
48
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
49
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @octanejs/xstate
|
|
2
|
+
|
|
3
|
+
XState for Octane — a port of [`@xstate/react@6.1.0`](https://github.com/statelyai/xstate/tree/main/packages/xstate-react).
|
|
4
|
+
|
|
5
|
+
The `xstate` actor core is framework-neutral and has no runtime dependencies, so
|
|
6
|
+
it is reused **unmodified** as a peer dependency. Only the React binding is
|
|
7
|
+
ported. See [`UPSTREAM.md`](./UPSTREAM.md) for the pin, the module and export
|
|
8
|
+
crosswalk, and the disposition of every upstream test.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add @octanejs/xstate xstate
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```tsx
|
|
17
|
+
import { createMachine } from 'xstate';
|
|
18
|
+
import { useMachine } from '@octanejs/xstate';
|
|
19
|
+
|
|
20
|
+
const toggleMachine = createMachine({
|
|
21
|
+
id: 'toggle',
|
|
22
|
+
initial: 'inactive',
|
|
23
|
+
states: {
|
|
24
|
+
inactive: { on: { TOGGLE: 'active' } },
|
|
25
|
+
active: { on: { TOGGLE: 'inactive' } },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export function Toggle() @{
|
|
30
|
+
const [state, send] = useMachine(toggleMachine);
|
|
31
|
+
|
|
32
|
+
<button onClick={() => send({ type: 'TOGGLE' })}>
|
|
33
|
+
{state.value as string}
|
|
34
|
+
</button>
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`createActorContext` works exactly as upstream, including the member-call form:
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
import { createActorContext } from '@octanejs/xstate';
|
|
42
|
+
|
|
43
|
+
const ToggleContext = createActorContext(toggleMachine);
|
|
44
|
+
|
|
45
|
+
function Display() @{
|
|
46
|
+
const value = ToggleContext.useSelector((state) => state.value as string);
|
|
47
|
+
<span>{value}</span>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function App() @{
|
|
51
|
+
<ToggleContext.Provider>
|
|
52
|
+
<Display />
|
|
53
|
+
</ToggleContext.Provider>
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Exports
|
|
58
|
+
|
|
59
|
+
`useActor`, `useActorRef`, `useSelector`, `useMachine` (deprecated upstream alias
|
|
60
|
+
of `useActor`), `createActorContext`, `shallowEqual` — the complete
|
|
61
|
+
`@xstate/react@6.1.0` surface.
|
|
62
|
+
|
|
63
|
+
## Intentional differences from `@xstate/react` on React
|
|
64
|
+
|
|
65
|
+
- **No StrictMode double-invoke.** Octane has no StrictMode, so effects, renders,
|
|
66
|
+
and observer notifications fire once where React's development StrictMode fires
|
|
67
|
+
them twice. Production counts are identical. Upstream's own suite parametrizes
|
|
68
|
+
eight assertions over both modes; the non-strict values are this port's
|
|
69
|
+
contract.
|
|
70
|
+
- **`useSyncExternalStore` skips React's commit-time `getSnapshot` re-read** when
|
|
71
|
+
the rendered value was unchanged, because Octane's synchronous renderer closes
|
|
72
|
+
the concurrent-interleaving window React guards there. An actor always
|
|
73
|
+
notifies, so this is not reachable through this binding's public API.
|
|
74
|
+
- **Error boundaries are `@try`/`@catch` blocks**, not class components. An actor
|
|
75
|
+
whose snapshot enters the `error` status still throws from render and is caught
|
|
76
|
+
by the nearest boundary; only the way you write the boundary changes.
|
|
77
|
+
- **Server snapshots.** `getServerSnapshot` is optional in Octane and falls back
|
|
78
|
+
to `getSnapshot`; React throws when it is missing. Both ported hooks always
|
|
79
|
+
supply one.
|
|
80
|
+
|
|
81
|
+
`stopRootWithRehydration` is kept verbatim even though its motivating case
|
|
82
|
+
(React Strict Effects) cannot occur here, because it also governs
|
|
83
|
+
unmount-then-remount, which stays observable to consumers.
|
package/UPSTREAM.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# Upstream
|
|
2
|
+
|
|
3
|
+
## Pin and oracle boundary
|
|
4
|
+
|
|
5
|
+
| Field | Value |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| Repository | https://github.com/statelyai/xstate |
|
|
8
|
+
| Release tag | `@xstate/react@6.1.0` |
|
|
9
|
+
| Commit | `d4f8c5b709291d44f70139a7f9ff333abd7c615c` |
|
|
10
|
+
| Supported upstream range | exactly `@xstate/react@6.1.0` |
|
|
11
|
+
| Source root | `packages/xstate-react/src` |
|
|
12
|
+
| Test root | `packages/xstate-react/test` |
|
|
13
|
+
| Actor-core peer | `xstate@^5.28.0` (oracle `xstate@5.32.5`) |
|
|
14
|
+
| React oracle | `react@19.2.3`, `react-dom@19.2.3`, `@types/react@19.2.17` |
|
|
15
|
+
| License | MIT |
|
|
16
|
+
|
|
17
|
+
The vendored tree under [`upstream/`](./upstream) is byte-exact at that commit and
|
|
18
|
+
carries upstream's `LICENSE`. It is prettier-ignored and excluded from the
|
|
19
|
+
published `files`, so it is development evidence rather than shipped code.
|
|
20
|
+
[`upstream/SHA256SUMS`](./upstream/SHA256SUMS) pins every vendored byte;
|
|
21
|
+
`pnpm --dir packages/xstate upstream:verify` re-hashes the tree and fails on any
|
|
22
|
+
drift or on an added/removed file.
|
|
23
|
+
|
|
24
|
+
## Source boundary
|
|
25
|
+
|
|
26
|
+
`xstate` itself is **not** ported and **not** vendored. It has no React import
|
|
27
|
+
and no runtime dependencies, so the actor core, machine interpreter, and every
|
|
28
|
+
`xstate` export are consumed unmodified from npm as a peer dependency. Only the
|
|
29
|
+
React binding in `packages/xstate-react/src` is ported, module for module, and
|
|
30
|
+
`src/` mirrors that layout so the two trees read side by side.
|
|
31
|
+
|
|
32
|
+
Two of upstream's npm dependencies are React-specific and are replaced by
|
|
33
|
+
in-repo ports rather than consumed:
|
|
34
|
+
|
|
35
|
+
| Upstream dependency | Replacement | Why |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| `use-sync-external-store/shim/with-selector` | [`src/useSyncExternalStoreWithSelector.ts`](./src/useSyncExternalStoreWithSelector.ts) | React's shim closes over React's concurrent-render model. The port keeps the exact memoization algorithm on Octane's native `useSyncExternalStore`. |
|
|
38
|
+
| `use-isomorphic-layout-effect` | [`src/useIsomorphicLayoutEffect.ts`](./src/useIsomorphicLayoutEffect.ts) | The package is a bare hook alias. Octane hooks take a trailing compiler-assigned slot, so the helper has to be a function that forwards one. The DOM probe is unchanged. |
|
|
39
|
+
| `#is-development` (package `imports` condition) | [`src/isDevelopment.ts`](./src/isDevelopment.ts) | Upstream publishes a prebuilt `dist/` and resolves the condition at build time. This package publishes raw `src/`, so the equivalent is the `NODE_ENV` probe every bundler constant-folds. |
|
|
40
|
+
|
|
41
|
+
React and `@xstate/react` are development-only differential oracles; neither is a
|
|
42
|
+
runtime dependency of the port.
|
|
43
|
+
|
|
44
|
+
## Module crosswalk
|
|
45
|
+
|
|
46
|
+
| Upstream module | Octane module | Disposition |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `src/index.ts` | [`src/index.ts`](./src/index.ts) | Ported; same six exports, same deprecation comment |
|
|
49
|
+
| `src/useActor.ts` | [`src/useActor.ts`](./src/useActor.ts) | Ported; plain uSES shim replaced by Octane's native hook |
|
|
50
|
+
| `src/useActorRef.ts` | [`src/useActorRef.ts`](./src/useActorRef.ts) | Ported; `useIdleActorRef` takes a positional internal signature (see below) |
|
|
51
|
+
| `src/useMachine.ts` | [`src/useMachine.ts`](./src/useMachine.ts) | Ported; deprecated alias retained |
|
|
52
|
+
| `src/useSelector.ts` | [`src/useSelector.ts`](./src/useSelector.ts) | Ported |
|
|
53
|
+
| `src/createActorContext.ts` | [`src/createActorContext.ts`](./src/createActorContext.ts) | Ported. Stays plain `.ts`: the hooks it returns must forward their CALLER's compiler-assigned slot, and a compiled `.tsrx` appends its own symbol after any forwarded one, which would collapse every consumer call site onto a single hook cell. |
|
|
54
|
+
| — | [`src/ActorProvider.tsrx`](./src/ActorProvider.tsrx) + [`.tsrx.d.ts`](./src/ActorProvider.tsrx.d.ts) | Octane-only split of the above: the provider is a component, so it lives in a compiled `.tsrx` and owns its hooks instead of forwarding them. Replaces upstream's `React.createElement(ReactContext.Provider, …)`. |
|
|
55
|
+
| `src/shallowEqual.ts` | [`src/shallowEqual.ts`](./src/shallowEqual.ts) | Reused verbatim — pure comparator, no framework surface |
|
|
56
|
+
| `src/stopRootWithRehydration.ts` | [`src/stopRootWithRehydration.ts`](./src/stopRootWithRehydration.ts) | Reused verbatim — touches xstate internals only |
|
|
57
|
+
| `src/true.ts`, `src/false.ts` | [`src/isDevelopment.ts`](./src/isDevelopment.ts) | Dependency substitution; see the table above |
|
|
58
|
+
| — | [`src/internal.ts`](./src/internal.ts) | Octane-only: `splitSlot`/`subSlot` hook-slot plumbing |
|
|
59
|
+
|
|
60
|
+
## Export crosswalk
|
|
61
|
+
|
|
62
|
+
Every export of the pinned upstream entry point `@xstate/react`:
|
|
63
|
+
|
|
64
|
+
| Upstream export | Octane disposition | Evidence |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `useActor` | Ported | Types: `xstate-adapted-types`. Runtime: `xstate-adapted-upstream`, 26 upstream cases |
|
|
67
|
+
| `useActorRef` | Ported | Types: `xstate-adapted-types`. Runtime: `xstate-adapted-upstream`, 18 upstream cases |
|
|
68
|
+
| `useSelector` | Ported | Types: `xstate-adapted-types`. Runtime: `xstate-adapted-upstream`, 18 upstream cases |
|
|
69
|
+
| `useMachine` | Ported (deprecated alias of `useActor`, as upstream) | Types: `xstate-adapted-types`. Runtime: `xstate-adapted-upstream` (upstream exercises it through the `useActor` suite) and `xstate-runtime-differential` |
|
|
70
|
+
| `createActorContext` | Ported | Runtime: `xstate-adapted-upstream`, 13 upstream cases, plus `xstate-runtime-differential`. Not covered by the upstream type suite. |
|
|
71
|
+
| `shallowEqual` | Reused verbatim | Runtime: exercised by the `xstate-adapted-upstream` `useSelector` cases. Not covered by the upstream type suite. |
|
|
72
|
+
| `useIdleActorRef` (module-level, not exported from `index.ts`) | Ported with a positional `(logic, options, slot)` signature instead of upstream's `ConditionalRequired` variadic tuple. Unreachable for consumers: the package `exports` map exposes only the barrel, and upstream's `index.ts` does not re-export it either. | [`src/useActorRef.ts`](./src/useActorRef.ts) |
|
|
73
|
+
|
|
74
|
+
## Sibling xstate packages
|
|
75
|
+
|
|
76
|
+
The community request covered "xstate", so the rest of the org's React-facing
|
|
77
|
+
surface is accounted for here rather than silently omitted:
|
|
78
|
+
|
|
79
|
+
| Package | Disposition |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| `xstate` | Framework-neutral. Reused unmodified as a peer dependency; no binding needed. |
|
|
82
|
+
| `@xstate/store`, `@xstate/store-react` | Ported separately as [`@octanejs/xstate-store`](../xstate-store). |
|
|
83
|
+
| `@xstate/immer` | Framework-neutral (one module, no React import). Consume directly alongside this binding; no Octane binding needed. |
|
|
84
|
+
| Stately inspection tooling (`@statelyai/inspect`) | Framework-neutral and published from a different repository. Consume directly; not applicable to this port. |
|
|
85
|
+
| `@xstate/solid`, `@xstate/svelte`, `@xstate/vue`, `@xstate/store-{angular,preact,solid,svelte,vue}` | Not React bindings. Out of scope. |
|
|
86
|
+
|
|
87
|
+
## React-parity lanes
|
|
88
|
+
|
|
89
|
+
| Lane | Status |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `xstate-pristine` | **Landed.** Runs the byte-exact `test/*.test.tsx` suite against `@xstate/react@6.1.0` and `react@19.2.3` after re-hashing every vendored byte, in upstream's own `happy-dom` environment with `globals: true`. 144 of 144 cases pass; the passing identities are pinned in [`audit/pristine-runtime.json`](./audit/pristine-runtime.json). |
|
|
92
|
+
| `xstate-adapted-upstream` | **Landed.** Runs the one-for-one adapted `useActor`, `useActorRef`, `useSelector`, and `createActorContext` suites on Octane: 75 cases whose full names match the pristine lane's identities exactly. Inventory: [`audit/adapted-runtime.json`](./audit/adapted-runtime.json); permitted transformations are listed in the lane's `notes` in [`audit/react-parity.json`](./audit/react-parity.json). |
|
|
93
|
+
| `xstate-pristine-types` | **Landed.** Compiles the vendored `test/types.test.tsx` together with the vendored `src` using plain `tsc` against `react@19.2.3` / `@types/react@19.2.17`, reproducing upstream's `allowImportingTsExtensions`. Config: [`audit/upstream-typetests/tsconfig.pristine.json`](./audit/upstream-typetests/tsconfig.pristine.json). |
|
|
94
|
+
| `xstate-adapted-types` | **Landed.** Compiles [`typetests/types.test-d.tsx`](./typetests/types.test-d.tsx) with `tsrx-tsc`. The file is byte-identical to the vendored upstream suite below its header apart from one line (`@testing-library/react` → `@octanejs/testing-library`), and that is machine-checked rather than asserted: `react-parity:check` strips the header, undoes the transformation ledger in [`audit/type-parity.json`](./audit/type-parity.json), and requires the result to equal the vendored bytes. Any other edit — a deleted case, a softened type, a dropped `@ts-expect-error` — fails. |
|
|
95
|
+
| `xstate-runtime-differential` | **Landed.** Runs one `.tsrx` fixture through this binding on Octane AND the real `@xstate/react@6.1.0` on `react@19.2.3`, asserting byte-identical `innerHTML` after every click. Covers `useMachine` transitions, `createActorContext` provider + selectors + `assign`, a final-state transition, a post-final no-op send, and an unbound `useSelector` over a context actor. `xstate@5.32.5` is shared by both sides and deliberately not rewritten, so any difference is attributable to the binding. |
|
|
96
|
+
|
|
97
|
+
## Type suite evidence
|
|
98
|
+
|
|
99
|
+
Both type suites are inventoried at file and assertion-group granularity in
|
|
100
|
+
[`audit/pristine-types.json`](./audit/pristine-types.json) and
|
|
101
|
+
[`audit/adapted-types.json`](./audit/adapted-types.json): 14 assertion groups
|
|
102
|
+
each, of which 3 are `@ts-expect-error` negative assertions. The inventories are
|
|
103
|
+
regenerated with `node scripts/react-parity/xstate-types-inventory.mjs` and a
|
|
104
|
+
stale one fails the audit, so an inventory cannot stand in for a suite it no
|
|
105
|
+
longer describes.
|
|
106
|
+
|
|
107
|
+
The suites are compiled, never executed, so a compiler exit code alone is weak
|
|
108
|
+
evidence: a gutted suite also compiles clean. Negative controls in
|
|
109
|
+
[`scripts/react-parity/xstate-parity-controls.test.mjs`](../../scripts/react-parity/xstate-parity-controls.test.mjs)
|
|
110
|
+
delete an assertion, drop a `@ts-expect-error`, empty the file, retarget an
|
|
111
|
+
undeclared import, and stale the inventory, and require each to be rejected.
|
|
112
|
+
|
|
113
|
+
## Runtime suite disposition
|
|
114
|
+
|
|
115
|
+
Both columns are evidence today: the pristine lane runs the vendored suite
|
|
116
|
+
unchanged against React, and the adapted lane runs the same cases on Octane.
|
|
117
|
+
|
|
118
|
+
| Upstream artifact | Cases | Pristine | Adapted |
|
|
119
|
+
| --- | --- | --- | --- |
|
|
120
|
+
| `test/useActor.test.tsx` | 26 × 2 modes | Runs unchanged, 52 passing | One-for-one, non-strict mode only |
|
|
121
|
+
| `test/useActorRef.test.tsx` | 18 × 2 modes | Runs unchanged, 36 passing | One-for-one, non-strict mode only |
|
|
122
|
+
| `test/useSelector.test.tsx` | 18 × 2 modes | Runs unchanged, 36 passing | One-for-one, non-strict mode only |
|
|
123
|
+
| `test/createActorContext.test.tsx` | 13 | Runs unchanged, 13 passing | One-for-one |
|
|
124
|
+
| `test/types.test.tsx` | 7 | Runs unchanged, 7 passing | Pristine and adapted type lanes |
|
|
125
|
+
| `test/utils.tsx` | — | Support only | `describeEachReactMode` parametrizes three files over `non-strict` and `strict`; the adapted helper runs the single applicable mode and the strict pass is not applicable (Octane has no StrictMode double-invoke). |
|
|
126
|
+
| `package.json` | — | Support only: its `imports` map is what resolves `#is-development` for the vendored source, so the pristine runner reproduces that field verbatim in the run root. | Replaced by [`src/isDevelopment.ts`](./src/isDevelopment.ts). |
|
|
127
|
+
| `vitest.config.mts` | — | Support only; [`tests/upstream-vitest.config.ts`](./tests/upstream-vitest.config.ts) reuses its `happy-dom` environment and `globals` setting. | Not applicable. |
|
|
128
|
+
|
|
129
|
+
## Intentional divergences
|
|
130
|
+
|
|
131
|
+
Every row is declared in [`audit/react-parity.json`](./audit/react-parity.json)
|
|
132
|
+
with the case ids that pin it, and each is bound to a structured
|
|
133
|
+
`OCTANE DIVERGENCE[id][caseId]` marker at the code it describes.
|
|
134
|
+
|
|
135
|
+
| Divergence | Consumer impact | Pinned by |
|
|
136
|
+
| --- | --- | --- |
|
|
137
|
+
| No StrictMode double-invoke, so upstream's eight `suiteKey === 'strict'` render/effect/observer counts collapse to their non-strict values | Fewer renders and effect invocations in development than React StrictMode; production counts are identical | The eight adapted cases themselves (`xstate-no-strictmode-double-invoke`) |
|
|
138
|
+
| `useSyncExternalStore` skips React's commit-time `getSnapshot` re-read when the rendered value was unchanged | Only observable for a store that mutates without notifying between render and commit; xstate always notifies | [`tests/conformance/divergences.test.ts`](./tests/conformance/divergences.test.ts), with a notifying control proving the reconciliation path is live at that exact point |
|
|
139
|
+
| Server `getServerSnapshot` is optional and falls back to `getSnapshot` where React throws | Only reachable through a hand-rolled actor-like object; both ported hooks always supply one. Two layers contribute: Octane's server runtime defaults the argument, and [`src/useSyncExternalStoreWithSelector.ts`](./src/useSyncExternalStoreWithSelector.ts) passes `getServerSelection ?? getSelection` where upstream's replaced shim forwards it unchanged. | [`tests/ssr/server.test.ts`](./tests/ssr/server.test.ts), with a control proving the fallback is a real fallback. React throws the same error on the client while hydrating; this package has no hydration lane, so that half is untested here and belongs in `packages/octane/tests/hydration/`. |
|
|
140
|
+
| Error boundaries are `@try`/`@catch` template blocks, not class components | Upstream's error tests use a class `ErrorBoundary`; the adapted fixtures use `@try`/`@catch`. The assertion — a thrown actor error reaching the nearest boundary — is unchanged. | The two adapted error-boundary cases (`xstate-error-boundary-try-catch`) |
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/xstate",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "XState tools for Octane — reuses the framework-agnostic xstate actor core unchanged and ports the @xstate/react 6.1.0 binding onto Octane hooks.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Dominic Gannaway",
|
|
7
|
+
"email": "dg@domgan.com"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
15
|
+
"directory": "packages/xstate"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"sideEffects": false,
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=22.22.2"
|
|
22
|
+
},
|
|
23
|
+
"octane": {
|
|
24
|
+
"hookSlots": {
|
|
25
|
+
"manual": [
|
|
26
|
+
"src"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"main": "src/index.ts",
|
|
31
|
+
"module": "src/index.ts",
|
|
32
|
+
"types": "src/index.ts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": "./src/index.ts",
|
|
35
|
+
"./package.json": "./package.json"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"src",
|
|
39
|
+
"README.md",
|
|
40
|
+
"UPSTREAM.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"xstate": "^5.28.0",
|
|
45
|
+
"octane": "0.1.43"
|
|
46
|
+
},
|
|
47
|
+
"peerDependenciesMeta": {
|
|
48
|
+
"xstate": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
54
|
+
"@testing-library/react": "16.3.1",
|
|
55
|
+
"@tsrx/react": "^0.2.58",
|
|
56
|
+
"@types/react": "19.2.17",
|
|
57
|
+
"@types/react-dom": "19.2.3",
|
|
58
|
+
"@types/use-sync-external-store": "^1.5.0",
|
|
59
|
+
"@xstate/react": "6.1.0",
|
|
60
|
+
"esbuild": "^0.28.1",
|
|
61
|
+
"happy-dom": "^20.11.0",
|
|
62
|
+
"react": "19.2.3",
|
|
63
|
+
"react-dom": "19.2.3",
|
|
64
|
+
"rxjs": "^7.8.2",
|
|
65
|
+
"use-isomorphic-layout-effect": "1.2.1",
|
|
66
|
+
"use-sync-external-store": "1.6.0",
|
|
67
|
+
"vitest": "^4.1.10",
|
|
68
|
+
"xstate": "5.32.5",
|
|
69
|
+
"@octanejs/testing-library": "0.1.40",
|
|
70
|
+
"octane": "0.1.43"
|
|
71
|
+
},
|
|
72
|
+
"scripts": {
|
|
73
|
+
"test": "vitest run",
|
|
74
|
+
"test:upstream": "node scripts/run-pristine-upstream.mjs",
|
|
75
|
+
"typecheck": "tsrx-tsc --noEmit -p tsconfig.json && tsrx-tsc --noEmit -p typetests/tsconfig.adapted.json",
|
|
76
|
+
"typecheck:pristine": "tsc --noEmit -p audit/upstream-typetests/tsconfig.pristine.json",
|
|
77
|
+
"upstream:verify": "node scripts/verify-upstream.mjs"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Octane-only: the component half of `createActorContext`.
|
|
2
|
+
//
|
|
3
|
+
// Upstream builds its provider with `React.createElement(ReactContext.Provider,
|
|
4
|
+
// { value, children })` inside `createActorContext.ts`. Octane splits the two
|
|
5
|
+
// halves by file type, because that is what makes slots work: a `.tsrx` module
|
|
6
|
+
// is fully compiled and every hook call inside it receives a compiler-assigned
|
|
7
|
+
// call-site slot, while a plain `.ts` module in a package that declares
|
|
8
|
+
// `octane.hookSlots.manual` is left alone so it can forward slots by hand.
|
|
9
|
+
//
|
|
10
|
+
// The factory's hooks therefore stay in `createActorContext.ts` — a hook there
|
|
11
|
+
// must forward its CALLER's slot, which is impossible in a compiled module
|
|
12
|
+
// because the compiler appends its own symbol after the forwarded one — and only
|
|
13
|
+
// this component, which owns its hooks rather than forwarding them, lives here.
|
|
14
|
+
import { useActorRef } from './useActorRef.ts';
|
|
15
|
+
import type { Context, OctaneNode } from 'octane';
|
|
16
|
+
import type { Actor, ActorOptions, AnyActorLogic } from 'xstate';
|
|
17
|
+
|
|
18
|
+
export function ActorProvider(props: {
|
|
19
|
+
context: Context<Actor<AnyActorLogic> | null>;
|
|
20
|
+
logic: AnyActorLogic;
|
|
21
|
+
options?: ActorOptions<AnyActorLogic>;
|
|
22
|
+
children?: OctaneNode;
|
|
23
|
+
}) @{
|
|
24
|
+
// Held in a local so the template sees a stable component identity, the same
|
|
25
|
+
// way @octanejs/redux's Provider resolves its context prop.
|
|
26
|
+
const ActorContext = props.context;
|
|
27
|
+
|
|
28
|
+
const actor = useActorRef(props.logic, props.options as never);
|
|
29
|
+
|
|
30
|
+
<ActorContext.Provider value={actor}>{props.children}</ActorContext.Provider>
|
|
31
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Type declaration for the .tsrx provider component (ActorProvider.tsrx).
|
|
2
|
+
//
|
|
3
|
+
// A SPECIFIC module declaration resolved by relative path, never an ambient
|
|
4
|
+
// `declare module '*.tsrx'` — so it types only this module and cannot silence
|
|
5
|
+
// `.tsrx` resolution in a consumer's own program.
|
|
6
|
+
import type { ComponentBody, Context, OctaneNode } from 'octane';
|
|
7
|
+
import type { Actor, ActorOptions, AnyActorLogic } from 'xstate';
|
|
8
|
+
|
|
9
|
+
export declare const ActorProvider: ComponentBody<{
|
|
10
|
+
context: Context<Actor<AnyActorLogic> | null>;
|
|
11
|
+
logic: AnyActorLogic;
|
|
12
|
+
options?: ActorOptions<AnyActorLogic>;
|
|
13
|
+
children?: OctaneNode;
|
|
14
|
+
}>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Ported from @xstate/react@6.1.0 src/createActorContext.ts
|
|
2
|
+
// (statelyai/xstate @ d4f8c5b709291d44f70139a7f9ff333abd7c615c).
|
|
3
|
+
//
|
|
4
|
+
// This module stays plain `.ts` on purpose. The two hooks it returns must
|
|
5
|
+
// forward their CALLER's compiler-assigned slot to the unbound hooks they
|
|
6
|
+
// delegate to, and only an uncompiled module can do that: in a compiled `.tsrx`
|
|
7
|
+
// the compiler appends its own symbol AFTER any forwarded one, so every consumer
|
|
8
|
+
// call site would collapse onto a single hook cell — the selected values would
|
|
9
|
+
// still be right (the selector memo recomputes per component) but one
|
|
10
|
+
// subscriber's update would re-render all of them.
|
|
11
|
+
//
|
|
12
|
+
// The provider component, which owns its hooks instead of forwarding them, lives
|
|
13
|
+
// in ./ActorProvider.tsrx.
|
|
14
|
+
import { createContext, createElement, useContext } from 'octane';
|
|
15
|
+
import { Actor, ActorOptions, AnyActorLogic, SnapshotFrom } from 'xstate';
|
|
16
|
+
import { ActorProvider } from './ActorProvider.tsrx';
|
|
17
|
+
import { splitSlot, subSlot } from './internal.ts';
|
|
18
|
+
import { useSelector as useSelectorUnbound } from './useSelector.ts';
|
|
19
|
+
import type { Context, OctaneNode } from 'octane';
|
|
20
|
+
|
|
21
|
+
export function createActorContext<TLogic extends AnyActorLogic>(
|
|
22
|
+
actorLogic: TLogic,
|
|
23
|
+
actorOptions?: ActorOptions<TLogic>,
|
|
24
|
+
): {
|
|
25
|
+
useSelector: <T>(
|
|
26
|
+
selector: (snapshot: SnapshotFrom<TLogic>) => T,
|
|
27
|
+
...rest: [compare?: (a: T, b: T) => boolean, slot?: symbol]
|
|
28
|
+
) => T;
|
|
29
|
+
useActorRef: () => Actor<TLogic>;
|
|
30
|
+
Provider: (props: {
|
|
31
|
+
children?: OctaneNode;
|
|
32
|
+
options?: ActorOptions<TLogic>;
|
|
33
|
+
/** @deprecated Use `logic` instead. */
|
|
34
|
+
machine?: never;
|
|
35
|
+
logic?: TLogic;
|
|
36
|
+
}) => unknown;
|
|
37
|
+
} {
|
|
38
|
+
const ActorContext = createContext<Actor<TLogic> | null>(null);
|
|
39
|
+
|
|
40
|
+
function Provider(props: {
|
|
41
|
+
children?: OctaneNode;
|
|
42
|
+
options?: ActorOptions<TLogic>;
|
|
43
|
+
/** @deprecated Use `logic` instead. */
|
|
44
|
+
machine?: never;
|
|
45
|
+
logic?: TLogic;
|
|
46
|
+
}) {
|
|
47
|
+
if (props.machine) {
|
|
48
|
+
throw new Error(`The "machine" prop has been deprecated. Please use "logic" instead.`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return createElement(ActorProvider, {
|
|
52
|
+
context: ActorContext as unknown as Context<Actor<AnyActorLogic> | null>,
|
|
53
|
+
logic: props.logic ?? actorLogic,
|
|
54
|
+
options: { ...actorOptions, ...props.options } as ActorOptions<AnyActorLogic>,
|
|
55
|
+
children: props.children,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// TODO: add properties to actor ref to make more descriptive
|
|
60
|
+
//
|
|
61
|
+
// Upstream reads `Provider.displayName` back out when building the
|
|
62
|
+
// missing-provider error. The name is held in a local as well so the message
|
|
63
|
+
// is identical without depending on the property's declared type.
|
|
64
|
+
const displayName = `ActorProvider`;
|
|
65
|
+
(Provider as typeof Provider & { displayName?: string }).displayName = displayName;
|
|
66
|
+
|
|
67
|
+
function useActorContext(): Actor<TLogic> {
|
|
68
|
+
// `useContext` is keyed by context identity rather than by a call-site
|
|
69
|
+
// slot, so this hook needs none of the forwarding below. A consumer's
|
|
70
|
+
// `SomeContext.useActorRef()` still compiles to a call with a trailing
|
|
71
|
+
// symbol; the extra argument is simply ignored here.
|
|
72
|
+
const actor = useContext(ActorContext);
|
|
73
|
+
|
|
74
|
+
if (!actor) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`You used a hook from "${displayName}" but it's not inside a <${displayName}> component.`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return actor;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function useSelector<T>(
|
|
84
|
+
selector: (snapshot: SnapshotFrom<TLogic>) => T,
|
|
85
|
+
...rest: [compare?: (a: T, b: T) => boolean, slot?: symbol]
|
|
86
|
+
): T {
|
|
87
|
+
// Consumers reach this through the member form the upstream README and test
|
|
88
|
+
// suite use — `SomeContext.useSelector(fn)` — which the compiler rewrites to
|
|
89
|
+
// `withSlot(sym, () => SomeContext.useSelector(fn, sym))`. `sym` identifies
|
|
90
|
+
// the CONSUMER's call site, so deriving the unbound hook's slot from it is
|
|
91
|
+
// what keeps two selectors in one component, and the same selector in two
|
|
92
|
+
// components, on independent hook cells.
|
|
93
|
+
const [userArgs, slot] = splitSlot(rest as unknown[]);
|
|
94
|
+
const compare = userArgs[0] as ((a: T, b: T) => boolean) | undefined;
|
|
95
|
+
|
|
96
|
+
const actor = useActorContext();
|
|
97
|
+
|
|
98
|
+
return useSelectorUnbound(
|
|
99
|
+
actor,
|
|
100
|
+
selector as never,
|
|
101
|
+
compare as never,
|
|
102
|
+
subSlot(slot, 'context:selector'),
|
|
103
|
+
) as T;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
Provider,
|
|
108
|
+
useActorRef: useActorContext,
|
|
109
|
+
useSelector,
|
|
110
|
+
};
|
|
111
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Barrel mirroring @xstate/react@6.1.0 src/index.ts
|
|
2
|
+
// (statelyai/xstate @ d4f8c5b709291d44f70139a7f9ff333abd7c615c).
|
|
3
|
+
//
|
|
4
|
+
// The framework-agnostic `xstate` core is NOT re-exported here, exactly as
|
|
5
|
+
// upstream does not re-export it: consumers import machines, actors, and logic
|
|
6
|
+
// creators straight from `xstate`, which this package takes as a peer.
|
|
7
|
+
export { createActorContext } from './createActorContext.ts';
|
|
8
|
+
export { shallowEqual } from './shallowEqual.ts';
|
|
9
|
+
export { useActor } from './useActor.ts';
|
|
10
|
+
export { useActorRef } from './useActorRef.ts';
|
|
11
|
+
export { useSelector } from './useSelector.ts';
|
|
12
|
+
|
|
13
|
+
// deprecated
|
|
14
|
+
export { useMachine } from './useMachine.ts';
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Slot mechanics for the binding's plain-`.ts` hooks. Octane injects a
|
|
2
|
+
// per-call-site Symbol into calls made by compiled components; the binding
|
|
3
|
+
// forwards that symbol and derives a distinct sub-slot for each hook it
|
|
4
|
+
// composes. This package declares `octane.hookSlots.manual` for `src`, so the
|
|
5
|
+
// compiler's plain-`.ts` slotting pass skips these modules and the forwarding
|
|
6
|
+
// below is the only thing that keeps two call sites of the same hook apart.
|
|
7
|
+
const subSlotCache = new Map<symbol, Map<string, symbol>>();
|
|
8
|
+
const bareTagCache = new Map<string, symbol>();
|
|
9
|
+
|
|
10
|
+
export function subSlot(slot: symbol | undefined, tag: string): symbol {
|
|
11
|
+
if (slot === undefined) {
|
|
12
|
+
let bare = bareTagCache.get(tag);
|
|
13
|
+
if (bare === undefined) {
|
|
14
|
+
bare = Symbol.for(`@octanejs/xstate:${tag}`);
|
|
15
|
+
bareTagCache.set(tag, bare);
|
|
16
|
+
}
|
|
17
|
+
return bare;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let byTag = subSlotCache.get(slot);
|
|
21
|
+
if (byTag === undefined) {
|
|
22
|
+
byTag = new Map();
|
|
23
|
+
subSlotCache.set(slot, byTag);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let child = byTag.get(tag);
|
|
27
|
+
if (child === undefined) {
|
|
28
|
+
child = Symbol.for(`${slot.description ?? ''}:${tag}`);
|
|
29
|
+
byTag.set(tag, child);
|
|
30
|
+
}
|
|
31
|
+
return child;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function splitSlot(args: unknown[]): [unknown[], symbol | undefined] {
|
|
35
|
+
const tail = args[args.length - 1];
|
|
36
|
+
const slot = typeof tail === 'symbol' ? tail : undefined;
|
|
37
|
+
return [slot === undefined ? args : args.slice(0, -1), slot];
|
|
38
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Replaces @xstate/react@6.1.0's `#is-development` subpath import.
|
|
2
|
+
//
|
|
3
|
+
// Upstream resolves `#is-development` to `src/true.ts` or `src/false.ts` through
|
|
4
|
+
// a package-`imports` map keyed on the `development` export condition, because it
|
|
5
|
+
// publishes a prebuilt `dist/`. This package publishes raw `src/`, so the
|
|
6
|
+
// condition never gets a chance to run in the consumer's bundler for our
|
|
7
|
+
// modules; the equivalent, and the thing every bundler already constant-folds, is
|
|
8
|
+
// the NODE_ENV probe.
|
|
9
|
+
//
|
|
10
|
+
// `process` is declared locally rather than pulled from @types/node: this module
|
|
11
|
+
// ships to consumers, and their program must not be forced to include Node types
|
|
12
|
+
// to typecheck it. The same idiom is used by @octanejs/jotai.
|
|
13
|
+
declare const process: { env: { NODE_ENV?: string } };
|
|
14
|
+
|
|
15
|
+
const isDevelopment = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production';
|
|
16
|
+
|
|
17
|
+
export default isDevelopment;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Ported verbatim from @xstate/react@6.1.0 src/shallowEqual.ts
|
|
2
|
+
// (statelyai/xstate @ d4f8c5b709291d44f70139a7f9ff333abd7c615c). Pure
|
|
3
|
+
// comparator with no React surface, so the upstream implementation is the port.
|
|
4
|
+
//
|
|
5
|
+
// From https://github.com/reduxjs/react-redux/blob/720f0ba79236cdc3e1115f4ef9a7760a21784b48/src/utils/shallowEqual.ts
|
|
6
|
+
function is(x: unknown, y: unknown) {
|
|
7
|
+
if (x === y) {
|
|
8
|
+
return x !== 0 || y !== 0 || 1 / (x as number) === 1 / (y as number);
|
|
9
|
+
} else {
|
|
10
|
+
return x !== x && y !== y;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function shallowEqual(objA: any, objB: any) {
|
|
15
|
+
if (is(objA, objB)) return true;
|
|
16
|
+
|
|
17
|
+
if (typeof objA !== 'object' || objA === null || typeof objB !== 'object' || objB === null) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const keysA = Object.keys(objA);
|
|
22
|
+
const keysB = Object.keys(objB);
|
|
23
|
+
|
|
24
|
+
if (keysA.length !== keysB.length) return false;
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < keysA.length; i++) {
|
|
27
|
+
if (
|
|
28
|
+
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
|
|
29
|
+
!is(objA[keysA[i]], objB[keysA[i]])
|
|
30
|
+
) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Ported verbatim from @xstate/react@6.1.0 src/stopRootWithRehydration.ts
|
|
2
|
+
// (statelyai/xstate @ d4f8c5b709291d44f70139a7f9ff333abd7c615c). It touches only
|
|
3
|
+
// xstate internals — no React or Octane surface — so the upstream implementation
|
|
4
|
+
// is the port.
|
|
5
|
+
//
|
|
6
|
+
// Upstream's motivating case (React Strict Effects double-invoking the
|
|
7
|
+
// start/stop effect) does not exist on Octane, which never double-invokes
|
|
8
|
+
// effects. The behavior is retained regardless because it also governs a real
|
|
9
|
+
// unmount followed by a remount: the actor tree is persisted and rehydrated
|
|
10
|
+
// rather than cold-restarted, which is observable to consumers and is what the
|
|
11
|
+
// pinned upstream release does.
|
|
12
|
+
import { AnyActorRef, Snapshot } from 'xstate';
|
|
13
|
+
|
|
14
|
+
const forEachActor = (actorRef: AnyActorRef, callback: (ref: AnyActorRef) => void) => {
|
|
15
|
+
callback(actorRef);
|
|
16
|
+
const children = actorRef.getSnapshot().children;
|
|
17
|
+
if (children) {
|
|
18
|
+
Object.values(children).forEach((child) => {
|
|
19
|
+
forEachActor(child as AnyActorRef, callback);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function stopRootWithRehydration(actorRef: AnyActorRef) {
|
|
25
|
+
// persist snapshot here in a custom way allows us to persist inline actors and to preserve actor references
|
|
26
|
+
// we do it to avoid setState in useEffect when the effect gets "reconnected"
|
|
27
|
+
// this currently only happens in Strict Effects but it simulates the Offscreen aka Activity API
|
|
28
|
+
// it also just allows us to end up with a somewhat more predictable behavior for the users
|
|
29
|
+
const persistedSnapshots: Array<[AnyActorRef, Snapshot<unknown>]> = [];
|
|
30
|
+
forEachActor(actorRef, (ref) => {
|
|
31
|
+
persistedSnapshots.push([ref, ref.getSnapshot()]);
|
|
32
|
+
// muting observers allow us to avoid `useSelector` from being notified about the stopped snapshot
|
|
33
|
+
// React reconnects its subscribers (from the useSyncExternalStore) on its own
|
|
34
|
+
// and userland subscribers should basically always do the same anyway
|
|
35
|
+
// as each subscription should have its own cleanup logic and that should be called each such reconnect
|
|
36
|
+
(ref as any).observers = new Set();
|
|
37
|
+
});
|
|
38
|
+
const systemSnapshot = actorRef.system.getSnapshot?.();
|
|
39
|
+
|
|
40
|
+
actorRef.stop();
|
|
41
|
+
|
|
42
|
+
(actorRef.system as any)._snapshot = systemSnapshot;
|
|
43
|
+
persistedSnapshots.forEach(([ref, snapshot]) => {
|
|
44
|
+
(ref as any)._processingStatus = 0;
|
|
45
|
+
(ref as any)._snapshot = snapshot;
|
|
46
|
+
});
|
|
47
|
+
}
|
package/src/useActor.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Ported from @xstate/react@6.1.0 src/useActor.ts
|
|
2
|
+
// (statelyai/xstate @ d4f8c5b709291d44f70139a7f9ff333abd7c615c).
|
|
3
|
+
//
|
|
4
|
+
// Upstream imports the plain `use-sync-external-store/shim`. Octane's
|
|
5
|
+
// `useSyncExternalStore` is a first-class runtime hook, so the shim is dropped
|
|
6
|
+
// and the native hook is called directly.
|
|
7
|
+
import { useCallback, useEffect, useSyncExternalStore } from 'octane';
|
|
8
|
+
import {
|
|
9
|
+
Actor,
|
|
10
|
+
ActorOptions,
|
|
11
|
+
AnyActorLogic,
|
|
12
|
+
Snapshot,
|
|
13
|
+
SnapshotFrom,
|
|
14
|
+
type ConditionalRequired,
|
|
15
|
+
type IsNotNever,
|
|
16
|
+
type RequiredActorOptionsKeys,
|
|
17
|
+
} from 'xstate';
|
|
18
|
+
import isDevelopment from './isDevelopment.ts';
|
|
19
|
+
import { splitSlot, subSlot } from './internal.ts';
|
|
20
|
+
import { stopRootWithRehydration } from './stopRootWithRehydration.ts';
|
|
21
|
+
import { useIdleActorRef } from './useActorRef.ts';
|
|
22
|
+
|
|
23
|
+
export function useActor<TLogic extends AnyActorLogic>(
|
|
24
|
+
logic: TLogic,
|
|
25
|
+
...rest: [
|
|
26
|
+
...ConditionalRequired<
|
|
27
|
+
[
|
|
28
|
+
options?: ActorOptions<TLogic> & {
|
|
29
|
+
[K in RequiredActorOptionsKeys<TLogic>]: unknown;
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
IsNotNever<RequiredActorOptionsKeys<TLogic>>
|
|
33
|
+
>,
|
|
34
|
+
slot?: symbol,
|
|
35
|
+
]
|
|
36
|
+
): [SnapshotFrom<TLogic>, Actor<TLogic>['send'], Actor<TLogic>] {
|
|
37
|
+
const [userArgs, slot] = splitSlot(rest as unknown[]);
|
|
38
|
+
const options = userArgs[0] as ActorOptions<TLogic> | undefined;
|
|
39
|
+
|
|
40
|
+
if (isDevelopment && !!logic && 'send' in logic && typeof logic.send === 'function') {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`useActor() expects actor logic (e.g. a machine), but received an ActorRef. Use the useSelector(actorRef, ...) hook instead to read the ActorRef's snapshot.`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const actorRef = useIdleActorRef(logic, options, subSlot(slot, 'actor:idle'));
|
|
47
|
+
|
|
48
|
+
const getSnapshot = useCallback(
|
|
49
|
+
() => {
|
|
50
|
+
return actorRef.getSnapshot();
|
|
51
|
+
},
|
|
52
|
+
[actorRef],
|
|
53
|
+
subSlot(slot, 'actor:snapshot'),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const subscribe = useCallback(
|
|
57
|
+
(handleStoreChange: () => void) => {
|
|
58
|
+
const { unsubscribe } = actorRef.subscribe({
|
|
59
|
+
next: handleStoreChange,
|
|
60
|
+
error: handleStoreChange,
|
|
61
|
+
});
|
|
62
|
+
return unsubscribe;
|
|
63
|
+
},
|
|
64
|
+
[actorRef],
|
|
65
|
+
subSlot(slot, 'actor:subscribe'),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const actorSnapshot = useSyncExternalStore(
|
|
69
|
+
subscribe,
|
|
70
|
+
getSnapshot,
|
|
71
|
+
getSnapshot,
|
|
72
|
+
subSlot(slot, 'actor:external-store'),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const snapshotWithStatus =
|
|
76
|
+
'status' in (actorSnapshot as object) ? (actorSnapshot as Snapshot<unknown>) : undefined;
|
|
77
|
+
if (snapshotWithStatus?.status === 'error') {
|
|
78
|
+
throw snapshotWithStatus.error;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
useEffect(
|
|
82
|
+
() => {
|
|
83
|
+
actorRef.start();
|
|
84
|
+
|
|
85
|
+
return () => {
|
|
86
|
+
stopRootWithRehydration(actorRef);
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
[actorRef],
|
|
90
|
+
subSlot(slot, 'actor:lifecycle'),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
return [actorSnapshot as SnapshotFrom<TLogic>, actorRef.send, actorRef];
|
|
94
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Ported from @xstate/react@6.1.0 src/useActorRef.ts
|
|
2
|
+
// (statelyai/xstate @ d4f8c5b709291d44f70139a7f9ff333abd7c615c).
|
|
3
|
+
import { useEffect, useState } from 'octane';
|
|
4
|
+
import {
|
|
5
|
+
Actor,
|
|
6
|
+
ActorOptions,
|
|
7
|
+
AnyActorLogic,
|
|
8
|
+
AnyStateMachine,
|
|
9
|
+
Observer,
|
|
10
|
+
SnapshotFrom,
|
|
11
|
+
createActor,
|
|
12
|
+
toObserver,
|
|
13
|
+
type IsNotNever,
|
|
14
|
+
type RequiredActorOptionsKeys,
|
|
15
|
+
} from 'xstate';
|
|
16
|
+
import { splitSlot, subSlot } from './internal.ts';
|
|
17
|
+
import { stopRootWithRehydration } from './stopRootWithRehydration.ts';
|
|
18
|
+
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect.ts';
|
|
19
|
+
|
|
20
|
+
// Upstream exports `useIdleActorRef` from this module but not from `index.ts`,
|
|
21
|
+
// and this package's `exports` map exposes only the barrel, so it is reachable
|
|
22
|
+
// solely from `useActorRef` and `useActor` here. It therefore takes a plain
|
|
23
|
+
// positional `(logic, options, slot)` signature instead of mirroring upstream's
|
|
24
|
+
// `ConditionalRequired` variadic tuple: the public hooks below already enforce
|
|
25
|
+
// that contract for consumers, and re-deriving it on an internal call would buy
|
|
26
|
+
// no type safety.
|
|
27
|
+
export function useIdleActorRef<TLogic extends AnyActorLogic>(
|
|
28
|
+
logic: TLogic,
|
|
29
|
+
options: ActorOptions<TLogic> | undefined,
|
|
30
|
+
slot: symbol | undefined,
|
|
31
|
+
): Actor<TLogic> {
|
|
32
|
+
// The "derive state from props" pattern: a render-phase update. When the
|
|
33
|
+
// logic's config identity changes, a replacement actor is created and the
|
|
34
|
+
// state setter is called from the render body, so this render is replayed
|
|
35
|
+
// with the new actor before it commits. Octane implements React's semantics
|
|
36
|
+
// here (render-phase updates render in the current pass, under the same
|
|
37
|
+
// 25-attempt cap), which is what keeps the upstream re-render counts intact.
|
|
38
|
+
//
|
|
39
|
+
// Do NOT reach for Octane's `useLinkedState` here. It is the native
|
|
40
|
+
// replacement for exactly this pattern, but it resolves the new value without
|
|
41
|
+
// the replay, which would change the observable render count and break parity
|
|
42
|
+
// with the pinned upstream release.
|
|
43
|
+
let [[currentConfig, actorRef], setCurrent] = useState<[unknown, Actor<TLogic>]>(
|
|
44
|
+
() => {
|
|
45
|
+
const actorRef = createActor(logic, options);
|
|
46
|
+
return [logic.config, actorRef];
|
|
47
|
+
},
|
|
48
|
+
subSlot(slot, 'idle:current'),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (logic.config !== currentConfig) {
|
|
52
|
+
const newActorRef = createActor(logic, {
|
|
53
|
+
...options,
|
|
54
|
+
snapshot: (actorRef.getPersistedSnapshot as any)({
|
|
55
|
+
__unsafeAllowInlineActors: true,
|
|
56
|
+
}),
|
|
57
|
+
} as ActorOptions<TLogic>);
|
|
58
|
+
setCurrent([logic.config, newActorRef]);
|
|
59
|
+
actorRef = newActorRef;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// TODO: consider using `useAsapEffect` that would do this in `useInsertionEffect` is that's available
|
|
63
|
+
//
|
|
64
|
+
// Upstream passes no dependency array, which in React means "run after every
|
|
65
|
+
// render". This package declares manual hook slots, so the compiler never
|
|
66
|
+
// infers a dependency array for it; `null` is Octane's explicit spelling of
|
|
67
|
+
// the same every-render behavior.
|
|
68
|
+
useIsomorphicLayoutEffect(
|
|
69
|
+
() => {
|
|
70
|
+
(actorRef.logic as any as AnyStateMachine).implementations = (
|
|
71
|
+
logic as any as AnyStateMachine
|
|
72
|
+
).implementations;
|
|
73
|
+
},
|
|
74
|
+
null,
|
|
75
|
+
subSlot(slot, 'idle:implementations'),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return actorRef;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function useActorRef<TLogic extends AnyActorLogic>(
|
|
82
|
+
machine: TLogic,
|
|
83
|
+
...rest: [
|
|
84
|
+
...(IsNotNever<RequiredActorOptionsKeys<TLogic>> extends true
|
|
85
|
+
? [
|
|
86
|
+
options: ActorOptions<TLogic> & {
|
|
87
|
+
[K in RequiredActorOptionsKeys<TLogic>]: unknown;
|
|
88
|
+
},
|
|
89
|
+
observerOrListener?:
|
|
90
|
+
Observer<SnapshotFrom<TLogic>> | ((value: SnapshotFrom<TLogic>) => void),
|
|
91
|
+
]
|
|
92
|
+
: [
|
|
93
|
+
options?: ActorOptions<TLogic>,
|
|
94
|
+
observerOrListener?:
|
|
95
|
+
Observer<SnapshotFrom<TLogic>> | ((value: SnapshotFrom<TLogic>) => void),
|
|
96
|
+
]),
|
|
97
|
+
slot?: symbol,
|
|
98
|
+
]
|
|
99
|
+
): Actor<TLogic> {
|
|
100
|
+
// Both user arguments are optional in the common case, so the compiler-owned
|
|
101
|
+
// trailing symbol cannot be located positionally.
|
|
102
|
+
const [userArgs, slot] = splitSlot(rest as unknown[]);
|
|
103
|
+
const options = userArgs[0] as ActorOptions<TLogic> | undefined;
|
|
104
|
+
const observerOrListener = userArgs[1] as
|
|
105
|
+
Observer<SnapshotFrom<TLogic>> | ((value: SnapshotFrom<TLogic>) => void) | undefined;
|
|
106
|
+
|
|
107
|
+
const actorRef = useIdleActorRef(machine, options, subSlot(slot, 'ref:idle'));
|
|
108
|
+
|
|
109
|
+
// Upstream's dependency array is `[observerOrListener]` even though the effect
|
|
110
|
+
// also closes over `actorRef`. Kept verbatim: an explicit array is never
|
|
111
|
+
// rewritten by Octane, so this preserves the pinned release's subscription
|
|
112
|
+
// lifetime exactly.
|
|
113
|
+
useEffect(
|
|
114
|
+
() => {
|
|
115
|
+
if (!observerOrListener) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const sub = actorRef.subscribe(toObserver(observerOrListener));
|
|
119
|
+
return () => {
|
|
120
|
+
sub.unsubscribe();
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
[observerOrListener],
|
|
124
|
+
subSlot(slot, 'ref:observer'),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
useEffect(
|
|
128
|
+
() => {
|
|
129
|
+
actorRef.start();
|
|
130
|
+
|
|
131
|
+
return () => {
|
|
132
|
+
stopRootWithRehydration(actorRef);
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
[actorRef],
|
|
136
|
+
subSlot(slot, 'ref:lifecycle'),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return actorRef;
|
|
140
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Replaces @xstate/react@6.1.0's `use-isomorphic-layout-effect` dependency.
|
|
2
|
+
//
|
|
3
|
+
// The npm package is a bare `typeof document !== 'undefined' ? useLayoutEffect :
|
|
4
|
+
// useEffect` alias. That shape cannot be reused here: Octane hooks take a
|
|
5
|
+
// trailing compiler-assigned slot, and this package hand-forwards slots, so the
|
|
6
|
+
// helper has to be a real function that passes `(effect, deps, slot)` through.
|
|
7
|
+
// The DOM probe itself is upstream's, unchanged.
|
|
8
|
+
//
|
|
9
|
+
// A conditional hook call is legal in Octane — hooks are keyed by slot, not by
|
|
10
|
+
// call order — and `typeof document` is constant for the lifetime of a program,
|
|
11
|
+
// so a given call site resolves to one hook kind for every render.
|
|
12
|
+
import { useEffect, useLayoutEffect } from 'octane';
|
|
13
|
+
|
|
14
|
+
export function useIsomorphicLayoutEffect(
|
|
15
|
+
effect: () => void | (() => void),
|
|
16
|
+
deps: unknown[] | null | undefined,
|
|
17
|
+
slot?: symbol,
|
|
18
|
+
): void {
|
|
19
|
+
const hook = typeof document !== 'undefined' ? useLayoutEffect : useEffect;
|
|
20
|
+
hook(effect as never, deps as never, slot);
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Ported from @xstate/react@6.1.0 src/useMachine.ts
|
|
2
|
+
// (statelyai/xstate @ d4f8c5b709291d44f70139a7f9ff333abd7c615c).
|
|
3
|
+
//
|
|
4
|
+
// Deprecated upstream and retained here for the same reason: it is still a
|
|
5
|
+
// published export of the pinned release.
|
|
6
|
+
import {
|
|
7
|
+
Actor,
|
|
8
|
+
ActorOptions,
|
|
9
|
+
AnyStateMachine,
|
|
10
|
+
StateFrom,
|
|
11
|
+
type ConditionalRequired,
|
|
12
|
+
type IsNotNever,
|
|
13
|
+
type RequiredActorOptionsKeys,
|
|
14
|
+
} from 'xstate';
|
|
15
|
+
import { splitSlot } from './internal.ts';
|
|
16
|
+
import { useActor } from './useActor.ts';
|
|
17
|
+
|
|
18
|
+
/** @alias useActor */
|
|
19
|
+
export function useMachine<TMachine extends AnyStateMachine>(
|
|
20
|
+
machine: TMachine,
|
|
21
|
+
...rest: [
|
|
22
|
+
...ConditionalRequired<
|
|
23
|
+
[
|
|
24
|
+
options?: ActorOptions<TMachine> & {
|
|
25
|
+
[K in RequiredActorOptionsKeys<TMachine>]: unknown;
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
IsNotNever<RequiredActorOptionsKeys<TMachine>>
|
|
29
|
+
>,
|
|
30
|
+
slot?: symbol,
|
|
31
|
+
]
|
|
32
|
+
): [StateFrom<TMachine>, Actor<TMachine>['send'], Actor<TMachine>] {
|
|
33
|
+
// This hook composes no hook cells of its own, so the caller's slot is
|
|
34
|
+
// forwarded unchanged: `useMachine(m)` and `useActor(m)` at one call site must
|
|
35
|
+
// resolve to the same hook identities, exactly as the upstream alias does.
|
|
36
|
+
const [userArgs, slot] = splitSlot(rest as unknown[]);
|
|
37
|
+
const options = userArgs[0] as ActorOptions<TMachine> | undefined;
|
|
38
|
+
|
|
39
|
+
return useActor(machine as AnyStateMachine, options as never, slot) as [
|
|
40
|
+
StateFrom<TMachine>,
|
|
41
|
+
Actor<TMachine>['send'],
|
|
42
|
+
Actor<TMachine>,
|
|
43
|
+
];
|
|
44
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Ported from @xstate/react@6.1.0 src/useSelector.ts
|
|
2
|
+
// (statelyai/xstate @ d4f8c5b709291d44f70139a7f9ff333abd7c615c).
|
|
3
|
+
//
|
|
4
|
+
// Upstream imports `useSyncExternalStoreWithSelector` from
|
|
5
|
+
// `use-sync-external-store/shim/with-selector`. That shim is React-specific, so
|
|
6
|
+
// this package carries a local port of the same algorithm; see
|
|
7
|
+
// ./useSyncExternalStoreWithSelector.ts.
|
|
8
|
+
import { useCallback } from 'octane';
|
|
9
|
+
import { AnyActorRef } from 'xstate';
|
|
10
|
+
import { splitSlot, subSlot } from './internal.ts';
|
|
11
|
+
import { useSyncExternalStoreWithSelector } from './useSyncExternalStoreWithSelector.ts';
|
|
12
|
+
|
|
13
|
+
type SyncExternalStoreSubscribe = (onStoreChange: () => void) => () => void;
|
|
14
|
+
|
|
15
|
+
function defaultCompare<T>(a: T, b: T) {
|
|
16
|
+
return a === b;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function useSelector<
|
|
20
|
+
TActor extends Pick<AnyActorRef, 'subscribe' | 'getSnapshot'> | undefined,
|
|
21
|
+
T,
|
|
22
|
+
>(
|
|
23
|
+
actor: TActor,
|
|
24
|
+
selector: (
|
|
25
|
+
snapshot: TActor extends { getSnapshot(): infer TSnapshot } ? TSnapshot : undefined,
|
|
26
|
+
) => T,
|
|
27
|
+
...rest: [compare?: (a: T, b: T) => boolean, slot?: symbol]
|
|
28
|
+
): T {
|
|
29
|
+
const [userArgs, slot] = splitSlot(rest as unknown[]);
|
|
30
|
+
const compare = (userArgs[0] as ((a: T, b: T) => boolean) | undefined) ?? defaultCompare;
|
|
31
|
+
|
|
32
|
+
const subscribe: SyncExternalStoreSubscribe = useCallback(
|
|
33
|
+
(handleStoreChange) => {
|
|
34
|
+
if (!actor) {
|
|
35
|
+
return () => {};
|
|
36
|
+
}
|
|
37
|
+
const { unsubscribe } = actor.subscribe({
|
|
38
|
+
next: handleStoreChange,
|
|
39
|
+
error: handleStoreChange,
|
|
40
|
+
});
|
|
41
|
+
return unsubscribe;
|
|
42
|
+
},
|
|
43
|
+
[actor],
|
|
44
|
+
subSlot(slot, 'selector:subscribe'),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const boundGetSnapshot = useCallback(
|
|
48
|
+
() => {
|
|
49
|
+
const snapshot = actor?.getSnapshot();
|
|
50
|
+
if (snapshot && 'status' in snapshot && snapshot.status === 'error') {
|
|
51
|
+
throw snapshot.error;
|
|
52
|
+
}
|
|
53
|
+
return snapshot;
|
|
54
|
+
},
|
|
55
|
+
[actor],
|
|
56
|
+
subSlot(slot, 'selector:snapshot'),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
const selectedSnapshot = useSyncExternalStoreWithSelector(
|
|
60
|
+
subscribe,
|
|
61
|
+
boundGetSnapshot,
|
|
62
|
+
boundGetSnapshot,
|
|
63
|
+
selector as (snapshot: unknown) => T,
|
|
64
|
+
compare,
|
|
65
|
+
subSlot(slot, 'selector:external-store'),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return selectedSnapshot;
|
|
69
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Replaces @xstate/react@6.1.0's `use-sync-external-store/shim/with-selector`
|
|
2
|
+
// dependency: React's shim, reimplemented on Octane's native
|
|
3
|
+
// useSyncExternalStore with the exact same memoization algorithm. The selection
|
|
4
|
+
// is recomputed only when the SNAPSHOT changes; when `isEqual` says the new
|
|
5
|
+
// selection matches the previous one, the previous REFERENCE is kept so the
|
|
6
|
+
// store's uSES bails the re-render out.
|
|
7
|
+
//
|
|
8
|
+
// Byte-compatible with the copies in @octanejs/redux and
|
|
9
|
+
// @octanejs/tanstack-store; kept local so this package's published source has no
|
|
10
|
+
// cross-binding runtime dependency.
|
|
11
|
+
import { useSyncExternalStore, useRef, useMemo, useEffect } from 'octane';
|
|
12
|
+
import { splitSlot, subSlot } from './internal.ts';
|
|
13
|
+
|
|
14
|
+
const objectIs: (x: unknown, y: unknown) => boolean =
|
|
15
|
+
typeof Object.is === 'function'
|
|
16
|
+
? Object.is
|
|
17
|
+
: (x: any, y: any) => (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y);
|
|
18
|
+
|
|
19
|
+
export function useSyncExternalStoreWithSelector<Snapshot, Selection>(
|
|
20
|
+
subscribe: (onStoreChange: () => void) => () => void,
|
|
21
|
+
getSnapshot: () => Snapshot,
|
|
22
|
+
getServerSnapshot: undefined | null | (() => Snapshot),
|
|
23
|
+
selector: (snapshot: Snapshot) => Selection,
|
|
24
|
+
...rest: [isEqual?: (a: Selection, b: Selection) => boolean, slot?: symbol]
|
|
25
|
+
): Selection {
|
|
26
|
+
// A compiled direct call that omits the optional equality function arrives as
|
|
27
|
+
// `[slot]`, while binding-internal calls arrive as `[isEqual, slot]`. Split the
|
|
28
|
+
// compiler-owned tail before interpreting the user argument so a Symbol can
|
|
29
|
+
// never be mistaken for an equality function.
|
|
30
|
+
const [userArgs, slot] = splitSlot(rest);
|
|
31
|
+
const isEqual = userArgs[0] as ((a: Selection, b: Selection) => boolean) | undefined;
|
|
32
|
+
const instRef = useRef<{ hasValue: boolean; value: Selection | null } | null>(
|
|
33
|
+
null,
|
|
34
|
+
subSlot(slot, 'ws:inst'),
|
|
35
|
+
);
|
|
36
|
+
let inst: { hasValue: boolean; value: Selection | null };
|
|
37
|
+
if (instRef.current === null) {
|
|
38
|
+
inst = { hasValue: false, value: null };
|
|
39
|
+
instRef.current = inst;
|
|
40
|
+
} else {
|
|
41
|
+
inst = instRef.current;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const [getSelection, getServerSelection] = useMemo(
|
|
45
|
+
() => {
|
|
46
|
+
let hasMemo = false;
|
|
47
|
+
let memoizedSnapshot: Snapshot;
|
|
48
|
+
let memoizedSelection: Selection;
|
|
49
|
+
const memoizedSelector = (nextSnapshot: Snapshot): Selection => {
|
|
50
|
+
if (!hasMemo) {
|
|
51
|
+
hasMemo = true;
|
|
52
|
+
memoizedSnapshot = nextSnapshot;
|
|
53
|
+
const nextSelection = selector(nextSnapshot);
|
|
54
|
+
if (isEqual !== undefined && inst.hasValue) {
|
|
55
|
+
const currentSelection = inst.value as Selection;
|
|
56
|
+
if (isEqual(currentSelection, nextSelection)) {
|
|
57
|
+
return (memoizedSelection = currentSelection);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return (memoizedSelection = nextSelection);
|
|
61
|
+
}
|
|
62
|
+
const currentSelection = memoizedSelection;
|
|
63
|
+
if (objectIs(memoizedSnapshot, nextSnapshot)) return currentSelection;
|
|
64
|
+
const nextSelection = selector(nextSnapshot);
|
|
65
|
+
if (isEqual !== undefined && isEqual(currentSelection, nextSelection)) {
|
|
66
|
+
memoizedSnapshot = nextSnapshot;
|
|
67
|
+
return currentSelection;
|
|
68
|
+
}
|
|
69
|
+
memoizedSnapshot = nextSnapshot;
|
|
70
|
+
return (memoizedSelection = nextSelection);
|
|
71
|
+
};
|
|
72
|
+
const maybeGetServerSnapshot = getServerSnapshot === undefined ? null : getServerSnapshot;
|
|
73
|
+
return [
|
|
74
|
+
() => memoizedSelector(getSnapshot()),
|
|
75
|
+
maybeGetServerSnapshot === null
|
|
76
|
+
? undefined
|
|
77
|
+
: () => memoizedSelector(maybeGetServerSnapshot!()),
|
|
78
|
+
] as const;
|
|
79
|
+
},
|
|
80
|
+
[getSnapshot, getServerSnapshot, selector, isEqual],
|
|
81
|
+
subSlot(slot, 'ws:memo'),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Octane gates its commit-time store sync on the VALUE rather than re-reading
|
|
85
|
+
// `getSnapshot` at commit the way React does, so a store that mutates without
|
|
86
|
+
// notifying between render and commit is not reconciled until its next notify.
|
|
87
|
+
// Unreachable through this binding, whose actors always notify.
|
|
88
|
+
// OCTANE DIVERGENCE[xstate-sync-external-store-commit-reread][ordinary:xstate-sync-external-store-skips-commit-reread]
|
|
89
|
+
//
|
|
90
|
+
// The server snapshot is defaulted rather than forwarded as `undefined`.
|
|
91
|
+
// Upstream's `use-sync-external-store` shim passes `getServerSelection`
|
|
92
|
+
// through, which is what makes React throw when a caller supplies no server
|
|
93
|
+
// snapshot; Octane's hook already falls back to `getSnapshot`, and this
|
|
94
|
+
// default keeps the memoized selection on that same path.
|
|
95
|
+
// OCTANE DIVERGENCE[xstate-optional-server-snapshot][ordinary:xstate-server-snapshot-fallback]
|
|
96
|
+
const value = useSyncExternalStore(
|
|
97
|
+
subscribe,
|
|
98
|
+
getSelection,
|
|
99
|
+
getServerSelection ?? getSelection,
|
|
100
|
+
subSlot(slot, 'ws:uses'),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
useEffect(
|
|
104
|
+
() => {
|
|
105
|
+
inst.hasValue = true;
|
|
106
|
+
inst.value = value;
|
|
107
|
+
},
|
|
108
|
+
[value],
|
|
109
|
+
subSlot(slot, 'ws:eff'),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
return value;
|
|
113
|
+
}
|