statewire 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +2 -2
  2. package/dist/client/connection.d.ts +23 -19
  3. package/dist/client/connection.d.ts.map +1 -1
  4. package/dist/client/connection.js +143 -103
  5. package/dist/client/connection.js.map +1 -1
  6. package/dist/client/core.d.ts +30 -3
  7. package/dist/client/core.d.ts.map +1 -1
  8. package/dist/client/core.js +222 -260
  9. package/dist/client/core.js.map +1 -1
  10. package/dist/client/session.d.ts +34 -39
  11. package/dist/client/session.d.ts.map +1 -1
  12. package/dist/client/session.js +380 -373
  13. package/dist/client/session.js.map +1 -1
  14. package/dist/client/sessionStore.d.ts +1 -0
  15. package/dist/client/sessionStore.d.ts.map +1 -1
  16. package/dist/client/sessionStore.js.map +1 -1
  17. package/dist/client/timing.d.ts +1 -2
  18. package/dist/client/timing.d.ts.map +1 -1
  19. package/dist/client/timing.js +1 -5
  20. package/dist/client/timing.js.map +1 -1
  21. package/dist/host/draft.d.ts +2 -5
  22. package/dist/host/draft.d.ts.map +1 -1
  23. package/dist/host/draft.js.map +1 -1
  24. package/dist/host/host.d.ts +5 -8
  25. package/dist/host/host.d.ts.map +1 -1
  26. package/dist/host/host.js +23 -32
  27. package/dist/host/host.js.map +1 -1
  28. package/dist/host/socket.d.ts +3 -2
  29. package/dist/host/socket.d.ts.map +1 -1
  30. package/dist/host/socket.js +484 -424
  31. package/dist/host/socket.js.map +1 -1
  32. package/dist/host-internal.d.ts +4 -0
  33. package/dist/host-internal.d.ts.map +1 -1
  34. package/dist/host.d.ts +10 -10
  35. package/dist/host.d.ts.map +1 -1
  36. package/dist/host.js +2 -2
  37. package/dist/index.d.ts +1 -2
  38. package/dist/index.js +1 -2
  39. package/dist/statewire.d.ts +29 -24
  40. package/dist/statewire.d.ts.map +1 -1
  41. package/dist/statewire.js +8 -2
  42. package/dist/statewire.js.map +1 -1
  43. package/dist/transports/fake-sse.d.ts +1 -1
  44. package/dist/transports/fake-sse.d.ts.map +1 -1
  45. package/dist/transports/fake-sse.js +5 -2
  46. package/dist/transports/fake-sse.js.map +1 -1
  47. package/dist/transports/fixtures.d.ts +1 -2
  48. package/dist/transports/fixtures.d.ts.map +1 -1
  49. package/dist/transports/fixtures.js +5 -7
  50. package/dist/transports/fixtures.js.map +1 -1
  51. package/dist/transports/remote.d.ts +2 -1
  52. package/dist/transports/remote.d.ts.map +1 -1
  53. package/dist/transports/remote.js +10 -4
  54. package/dist/transports/remote.js.map +1 -1
  55. package/dist/transports/sse.d.ts +32 -46
  56. package/dist/transports/sse.d.ts.map +1 -1
  57. package/dist/transports/sse.js +56 -59
  58. package/dist/transports/sse.js.map +1 -1
  59. package/dist/transports/testing.d.ts +7 -5
  60. package/dist/transports/testing.d.ts.map +1 -1
  61. package/dist/transports/testing.js +41 -14
  62. package/dist/transports/testing.js.map +1 -1
  63. package/dist/transports/ws.d.ts +2 -3
  64. package/dist/transports/ws.d.ts.map +1 -1
  65. package/dist/transports/ws.js +25 -80
  66. package/dist/transports/ws.js.map +1 -1
  67. package/dist/types.d.ts +8 -8
  68. package/dist/types.d.ts.map +1 -1
  69. package/dist/types.js +3 -4
  70. package/dist/types.js.map +1 -1
  71. package/dist/wire/ops.d.ts +1 -5
  72. package/dist/wire/ops.d.ts.map +1 -1
  73. package/dist/wire/ops.js +1 -4
  74. package/dist/wire/ops.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/client/connection.ts +175 -128
  77. package/src/client/core.ts +287 -278
  78. package/src/client/session.ts +474 -480
  79. package/src/client/sessionStore.ts +1 -0
  80. package/src/client/timing.ts +0 -6
  81. package/src/host/draft.ts +1 -3
  82. package/src/host/host.ts +41 -58
  83. package/src/host/socket.ts +653 -448
  84. package/src/host-internal.ts +4 -0
  85. package/src/host.ts +13 -10
  86. package/src/index.ts +0 -1
  87. package/src/statewire.ts +38 -32
  88. package/src/transports/fake-sse.ts +2 -1
  89. package/src/transports/fixtures.ts +4 -9
  90. package/src/transports/remote.ts +12 -5
  91. package/src/transports/sse.ts +99 -115
  92. package/src/transports/testing.ts +47 -19
  93. package/src/transports/ws.ts +44 -115
  94. package/src/types.ts +15 -14
  95. package/src/wire/ops.ts +9 -16
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { createChanges } from "./changes";
3
- import { getCommandsHost, getEmitHost, getStateHost } from "../host/host";
3
+ import { getCommandsHost, getStateHost } from "../host/host";
4
4
  import type { CommandsInvocation, CommandsListener } from "../host/host";
5
5
  import { recordMutations } from "../util/recordMutations";
6
6
  import { createMacrotaskPoster } from "../util/macrotask";
@@ -26,319 +26,328 @@ const failureOf = (error: unknown): StatewireClient.SendFailure =>
26
26
 
27
27
  type Overlay = { inv: CommandsInvocation; dropped: boolean };
28
28
 
29
- const createCore = (
30
- getOptions: () => StatewireClient.Options<any, any, any>,
31
- ) => {
32
- let value: Statewire<unknown> | null = null;
33
- let source: Statewire.StateSource | null = null;
34
- let overlays: Overlay[] = [];
35
- let updates: Statewire.CommandUpdate[] = [];
36
- let events: unknown[] = [];
37
- let pendingChanged: StatewirePath[] = [];
38
- let canonical: unknown;
39
- let published: unknown;
40
- let overlayPaths: readonly StatewirePath[] = [];
41
- let notifiedPublished: unknown;
42
- let notifiedOverlayPaths: readonly StatewirePath[] = [];
43
- let notifiedConnection: StatewireClient.Connection | null = null;
44
- let stale = false;
45
- let everAttached = false;
46
- let overlaysDirty = false;
47
- let flushing = false;
48
- const listeners = new Set<() => void>();
29
+ type Core = {
30
+ getOptions: () => StatewireClient.Options<any, any>;
31
+ value: Statewire<unknown> | null;
32
+ source: Statewire.StateSource | null;
33
+ overlays: Overlay[];
34
+ updates: Statewire.CommandUpdate[];
35
+ pendingChanged: StatewirePath[];
36
+ canonical: unknown;
37
+ published: unknown;
38
+ overlayPaths: readonly StatewirePath[];
39
+ notifiedPublished: unknown;
40
+ notifiedOverlayPaths: readonly StatewirePath[];
41
+ notifiedConnection: StatewireClient.Connection | null;
42
+ notifiedBusy: boolean;
43
+ stale: boolean;
44
+ everAttached: boolean;
45
+ overlaysDirty: boolean;
46
+ flushing: boolean;
47
+ scheduled: boolean;
48
+ listeners: Set<() => void>;
49
+ post: () => void;
50
+ commandsListener: CommandsListener;
51
+ getState: () => unknown;
52
+ getBusy: () => boolean;
53
+ commands: Statewire.CommandsProxy<any>;
54
+ subscribe: (listener: () => void) => () => void;
55
+ };
49
56
 
50
- const post = createMacrotaskPoster(() => {
51
- scheduled = false;
52
- flush();
53
- });
54
- let scheduled = false;
55
- const schedule = () => {
56
- if (scheduled) return;
57
- scheduled = true;
58
- post();
59
- };
57
+ const schedule = (c: Core) => {
58
+ if (c.scheduled) return;
59
+ c.scheduled = true;
60
+ c.post();
61
+ };
60
62
 
61
- const markStale = () => {
62
- stale = true;
63
- schedule();
64
- };
63
+ const markStale = (c: Core) => {
64
+ c.stale = true;
65
+ schedule(c);
66
+ };
65
67
 
66
- // initialState stands in for the canonical state only before the first
67
- // attach snapshot; replay boundaries after that read as loading, not preload.
68
- const drainCanonical = () => {
69
- if (source) {
70
- pendingChanged.push(...source.takeChanged());
71
- const snapshot = source.snapshot();
72
- if (snapshot !== undefined) {
73
- everAttached = true;
74
- canonical = snapshot;
75
- return;
76
- }
77
- const next = everAttached ? undefined : getOptions().initialState;
78
- if (next !== canonical) pendingChanged.push([]);
79
- canonical = next;
80
- } else if (value) {
81
- if (value.state !== undefined) everAttached = true;
82
- const next = everAttached ? value.state : getOptions().initialState;
83
- if (next !== canonical) {
84
- pendingChanged.push([]);
85
- canonical = next;
86
- }
68
+ // initialState stands in for the canonical state only before the first
69
+ // attach snapshot; replay boundaries after that read as loading, not preload.
70
+ const drainCanonical = (c: Core) => {
71
+ if (c.source) {
72
+ c.pendingChanged.push(...c.source.takeChanged());
73
+ const snapshot = c.source.snapshot();
74
+ if (snapshot !== undefined) {
75
+ c.everAttached = true;
76
+ c.canonical = snapshot;
77
+ return;
87
78
  }
88
- };
89
-
90
- const recompute = () => {
91
- if (!stale || !value) return;
92
- stale = false;
93
- const prevCanonical = canonical;
94
- drainCanonical();
95
- if (canonical === prevCanonical && !overlaysDirty) return;
96
- overlaysDirty = false;
97
- const { optimistic, materialize } = getOptions();
98
- let next = canonical;
99
- let paths: readonly StatewirePath[] = [];
100
- if (optimistic && canonical !== undefined) {
101
- for (const overlay of overlays) {
102
- if (overlay.dropped) continue;
103
- const { inv } = overlay;
104
- const ctx: StatewireClient.OptimisticContext = {
105
- ts: inv.ts,
106
- seq: inv.seq,
107
- ...(inv.clientId !== undefined && { clientId: inv.clientId }),
108
- };
109
- try {
110
- const recorded = recordMutations(next, (draft) =>
111
- optimistic(
112
- draft as never,
113
- { method: inv.method, params: inv.params } as Statewire.Command,
114
- ctx,
115
- ),
116
- );
117
- next = recorded.state;
118
- paths = [...paths, ...recorded.changedPaths];
119
- } catch (error) {
120
- overlay.dropped = true;
121
- console.error(
122
- `statewire: optimistic reducer threw for "${inv.method}"; dropping its optimistic update`,
123
- error,
124
- );
125
- }
126
- }
79
+ const next = c.everAttached ? undefined : c.getOptions().initialState;
80
+ if (next !== c.canonical) c.pendingChanged.push([]);
81
+ c.canonical = next;
82
+ } else if (c.value) {
83
+ if (c.value.state !== undefined) c.everAttached = true;
84
+ const next = c.everAttached ? c.value.state : c.getOptions().initialState;
85
+ if (next !== c.canonical) {
86
+ c.pendingChanged.push([]);
87
+ c.canonical = next;
127
88
  }
128
- if (materialize && next !== undefined) {
89
+ }
90
+ };
91
+
92
+ const recompute = (c: Core) => {
93
+ if (!c.stale || !c.value) return;
94
+ c.stale = false;
95
+ const prevCanonical = c.canonical;
96
+ drainCanonical(c);
97
+ if (c.canonical === prevCanonical && !c.overlaysDirty) return;
98
+ c.overlaysDirty = false;
99
+ const { optimistic, materialize } = c.getOptions();
100
+ let next = c.canonical;
101
+ let paths: readonly StatewirePath[] = [];
102
+ if (optimistic && c.canonical !== undefined) {
103
+ for (const overlay of c.overlays) {
104
+ if (overlay.dropped) continue;
105
+ const { inv } = overlay;
106
+ const ctx: StatewireClient.OptimisticContext = {
107
+ ts: inv.ts,
108
+ seq: inv.seq,
109
+ ...(inv.clientId !== undefined && { clientId: inv.clientId }),
110
+ };
129
111
  try {
130
112
  const recorded = recordMutations(next, (draft) =>
131
- materialize(draft as never),
113
+ optimistic(
114
+ draft as never,
115
+ { method: inv.method, params: inv.params } as Statewire.Command,
116
+ ctx,
117
+ ),
132
118
  );
133
119
  next = recorded.state;
134
120
  paths = [...paths, ...recorded.changedPaths];
135
121
  } catch (error) {
122
+ overlay.dropped = true;
136
123
  console.error(
137
- "statewire: materialize threw; publishing without its update",
124
+ `statewire: optimistic reducer threw for "${inv.method}"; dropping its optimistic update`,
138
125
  error,
139
126
  );
140
127
  }
141
128
  }
142
- published = next;
143
- overlayPaths = paths;
144
- };
145
-
146
- const getConnection = (): StatewireClient.Connection =>
147
- value?.connection ?? LIVE_CONNECTION;
148
-
149
- const flush = () => {
150
- if (!value) return;
151
- if (flushing) {
152
- schedule();
153
- return;
154
- }
155
- flushing = true;
129
+ }
130
+ if (materialize && next !== undefined) {
156
131
  try {
157
- recompute();
158
- const options = getOptions();
159
- const stateChanged = published !== notifiedPublished;
160
- if (stateChanged) {
161
- const prev = notifiedPublished;
162
- const changed = [
163
- ...pendingChanged,
164
- ...notifiedOverlayPaths,
165
- ...overlayPaths,
166
- ];
167
- pendingChanged = [];
168
- notifiedPublished = published;
169
- notifiedOverlayPaths = overlayPaths;
170
- options.onStateChange?.(published as never, {
171
- changes: createChanges(prev, published, changed),
172
- });
173
- }
174
- if (updates.length > 0 && published !== undefined) {
175
- const batch = updates;
176
- updates = [];
177
- for (const update of batch) {
178
- options.onCommandChange?.(update, { state: published as never });
179
- }
180
- }
181
- if (events.length > 0 && published !== undefined) {
182
- const batch = events;
183
- events = [];
184
- for (const event of batch) {
185
- options.onEvent?.(event, { state: published as never });
186
- }
187
- }
188
- const connection = getConnection();
189
- const connectionChanged = connection !== notifiedConnection;
190
- notifiedConnection = connection;
191
- if (connectionChanged) options.onConnectionChange?.(connection);
192
- if (stateChanged || connectionChanged) {
193
- for (const listener of [...listeners]) listener();
194
- }
195
- } finally {
196
- flushing = false;
197
- }
198
- };
199
-
200
- const note = (
201
- inv: CommandsInvocation,
202
- status: Statewire.CommandUpdate["status"],
203
- extra?: { result?: unknown; failure?: StatewireClient.SendFailure },
204
- ) => {
205
- if (!getOptions().onCommandChange) return;
206
- updates.push({
207
- method: inv.method,
208
- params: inv.params as never[],
209
- seq: inv.seq,
210
- status,
211
- ...extra,
212
- });
213
- schedule();
214
- };
215
-
216
- const commandsListener: CommandsListener = {
217
- onStart: (inv) => {
218
- overlays.push({ inv, dropped: false });
219
- overlaysDirty = true;
220
- markStale();
221
- note(inv, "queued");
222
- },
223
- onPosted: (inv) => note(inv, "posted"),
224
- onAck: (inv) => note(inv, "acked"),
225
- onCovered: (inv) => {
226
- if (!overlays.some((o) => o.inv === inv)) return;
227
- overlays = overlays.filter((o) => o.inv !== inv);
228
- overlaysDirty = true;
229
- markStale();
230
- },
231
- onSettle: (inv, outcome) => {
232
- note(
233
- inv,
234
- "settled",
235
- outcome.type === "accepted"
236
- ? { result: outcome.payload }
237
- : { failure: failureOf(outcome.error) },
132
+ const recorded = recordMutations(next, (draft) =>
133
+ materialize(draft as never),
134
+ );
135
+ next = recorded.state;
136
+ paths = [...paths, ...recorded.changedPaths];
137
+ } catch (error) {
138
+ console.error(
139
+ "statewire: materialize threw; publishing without its update",
140
+ error,
238
141
  );
239
- flush();
240
- },
241
- };
142
+ }
143
+ }
144
+ c.published = next;
145
+ c.overlayPaths = paths;
146
+ };
242
147
 
243
- const eventSink = (event: unknown) => {
244
- if (!getOptions().onEvent) return;
245
- events.push(event);
246
- schedule();
247
- };
148
+ const getConnection = (c: Core): StatewireClient.Connection =>
149
+ c.value?.connection ?? LIVE_CONNECTION;
248
150
 
249
- const attach = (next: Statewire<unknown>) => {
250
- if (next === value) return;
251
- const prev = value;
252
- value = next;
253
- const nextSource =
254
- next.source ??
255
- (typeof next.state === "object" && next.state !== null
256
- ? getStateHost(next.state)
257
- : undefined) ??
258
- null;
259
- if (nextSource !== source) {
260
- source?.setOnChange(null);
261
- source = nextSource;
262
- source?.setOnChange(markStale);
263
- // A new source is a new attach lifecycle: initialState preloads again.
264
- everAttached = false;
265
- markStale();
151
+ const flush = (c: Core) => {
152
+ if (!c.value) return;
153
+ if (c.flushing) {
154
+ schedule(c);
155
+ return;
156
+ }
157
+ c.flushing = true;
158
+ try {
159
+ recompute(c);
160
+ const options = c.getOptions();
161
+ const stateChanged = c.published !== c.notifiedPublished;
162
+ if (stateChanged) {
163
+ const prev = c.notifiedPublished;
164
+ const changed = [
165
+ ...c.pendingChanged,
166
+ ...c.notifiedOverlayPaths,
167
+ ...c.overlayPaths,
168
+ ];
169
+ c.pendingChanged = [];
170
+ c.notifiedPublished = c.published;
171
+ c.notifiedOverlayPaths = c.overlayPaths;
172
+ options.onStateChange?.(c.published as never, {
173
+ changes: createChanges(prev, c.published, changed),
174
+ });
266
175
  }
267
- if (next.commands !== prev?.commands) {
268
- if (prev) getCommandsHost(prev.commands)?.setListener(null);
269
- getCommandsHost(next.commands)?.setListener(commandsListener);
270
- if (overlays.length > 0) {
271
- overlays = [];
272
- overlaysDirty = true;
176
+ if (c.updates.length > 0 && c.published !== undefined) {
177
+ const batch = c.updates;
178
+ c.updates = [];
179
+ for (const update of batch) {
180
+ options.onCommandChange?.(update, { state: c.published as never });
273
181
  }
274
- markStale();
275
182
  }
276
- if (next.emit !== prev?.emit) {
277
- if (prev) getEmitHost(prev.emit)?.setSink(null);
278
- getEmitHost(next.emit)?.setSink(eventSink);
183
+ const connection = getConnection(c);
184
+ const connectionChanged = connection !== c.notifiedConnection;
185
+ c.notifiedConnection = connection;
186
+ if (connectionChanged) options.onConnectionChange?.(connection);
187
+ const busy = c.getBusy();
188
+ const busyChanged = busy !== c.notifiedBusy;
189
+ c.notifiedBusy = busy;
190
+ if (stateChanged || connectionChanged || busyChanged) {
191
+ for (const listener of [...c.listeners]) listener();
279
192
  }
280
- if (!source) markStale();
281
- schedule();
282
- };
193
+ } finally {
194
+ c.flushing = false;
195
+ }
196
+ };
283
197
 
284
- const detach = () => {
285
- source?.setOnChange(null);
286
- source = null;
287
- if (value) {
288
- getCommandsHost(value.commands)?.setListener(null);
289
- getEmitHost(value.emit)?.setSink(null);
290
- value = null;
291
- }
292
- };
198
+ const note = (
199
+ c: Core,
200
+ inv: CommandsInvocation,
201
+ status: Statewire.CommandUpdate["status"],
202
+ extra?: { result?: unknown; failure?: StatewireClient.SendFailure },
203
+ ) => {
204
+ if (!c.getOptions().onCommandChange) return;
205
+ c.updates.push({
206
+ method: inv.method,
207
+ params: inv.params as never[],
208
+ seq: inv.seq,
209
+ status,
210
+ ...extra,
211
+ });
212
+ schedule(c);
213
+ };
293
214
 
294
- const commands = createMethodProxy<Statewire.CommandsProxy<any>>(
295
- (method, params) => {
296
- if (!value) {
297
- return Promise.reject(
298
- new StatewireSendError({ fate: "aborted", cause: "unmount" }),
299
- );
300
- }
301
- const send = (
302
- value.commands as Record<
303
- string,
304
- (...args: unknown[]) => Promise<unknown>
305
- >
306
- )[method]!;
307
- const promise = send(...params);
308
- flush();
309
- return promise;
310
- },
311
- );
215
+ const attach = (c: Core, next: Statewire<unknown>) => {
216
+ if (next === c.value) return;
217
+ const prev = c.value;
218
+ c.value = next;
219
+ const nextSource =
220
+ next.source ??
221
+ (typeof next.state === "object" && next.state !== null
222
+ ? getStateHost(next.state)
223
+ : undefined) ??
224
+ null;
225
+ if (nextSource !== c.source) {
226
+ c.source?.setOnChange(null);
227
+ c.source = nextSource;
228
+ c.source?.setOnChange(() => markStale(c));
229
+ // A new source is a new attach lifecycle: initialState preloads again.
230
+ c.everAttached = false;
231
+ markStale(c);
232
+ }
233
+ if (next.commands !== prev?.commands) {
234
+ if (prev) getCommandsHost(prev.commands)?.setListener(null);
235
+ getCommandsHost(next.commands)?.setListener(c.commandsListener);
236
+ if (c.overlays.length > 0) {
237
+ c.overlays = [];
238
+ c.overlaysDirty = true;
239
+ }
240
+ markStale(c);
241
+ }
242
+ if (!c.source) markStale(c);
243
+ schedule(c);
244
+ };
312
245
 
313
- return {
314
- attach,
315
- detach,
316
- getState: () => {
317
- recompute();
318
- return published;
319
- },
320
- commands,
321
- subscribe: (listener: () => void) => {
322
- listeners.add(listener);
323
- return () => {
324
- listeners.delete(listener);
325
- };
326
- },
327
- };
246
+ const detach = (c: Core) => {
247
+ c.source?.setOnChange(null);
248
+ c.source = null;
249
+ if (c.value) {
250
+ getCommandsHost(c.value.commands)?.setListener(null);
251
+ c.value = null;
252
+ }
328
253
  };
329
254
 
330
255
  export const useStatewireCore = (
331
- getOptions: () => StatewireClient.Options<any, any, any>,
256
+ getOptions: () => StatewireClient.Options<any, any>,
332
257
  value: Statewire<unknown>,
333
258
  ) => {
334
- const [core] = useState(() => createCore(getOptions));
259
+ const [core] = useState((): Core => {
260
+ const c: Core = {
261
+ getOptions,
262
+ value: null,
263
+ source: null,
264
+ overlays: [],
265
+ updates: [],
266
+ pendingChanged: [],
267
+ canonical: undefined,
268
+ published: undefined,
269
+ overlayPaths: [],
270
+ notifiedPublished: undefined,
271
+ notifiedOverlayPaths: [],
272
+ notifiedConnection: null,
273
+ notifiedBusy: true,
274
+ stale: false,
275
+ everAttached: false,
276
+ overlaysDirty: false,
277
+ flushing: false,
278
+ scheduled: false,
279
+ listeners: new Set(),
280
+ post: createMacrotaskPoster(() => {
281
+ c.scheduled = false;
282
+ flush(c);
283
+ }),
284
+ commandsListener: {
285
+ onStart: (inv) => {
286
+ c.overlays.push({ inv, dropped: false });
287
+ c.overlaysDirty = true;
288
+ markStale(c);
289
+ note(c, inv, "queued");
290
+ },
291
+ onPosted: (inv) => note(c, inv, "posted"),
292
+ onApplied: (inv) => note(c, inv, "applied"),
293
+ onCovered: (inv) => {
294
+ if (!c.overlays.some((o) => o.inv === inv)) return;
295
+ c.overlays = c.overlays.filter((o) => o.inv !== inv);
296
+ c.overlaysDirty = true;
297
+ markStale(c);
298
+ },
299
+ onSettle: (inv, outcome) => {
300
+ note(
301
+ c,
302
+ inv,
303
+ "settled",
304
+ outcome.type === "accepted"
305
+ ? { result: outcome.payload }
306
+ : { failure: failureOf(outcome.error) },
307
+ );
308
+ flush(c);
309
+ },
310
+ },
311
+ getState: () => {
312
+ recompute(c);
313
+ return c.published;
314
+ },
315
+ getBusy: () => c.value?.isBusy ?? true,
316
+ commands: createMethodProxy<Statewire.CommandsProxy<any>>(
317
+ (method, params) => {
318
+ if (!c.value) {
319
+ return Promise.reject(
320
+ new StatewireSendError({ fate: "aborted", cause: "unmount" }),
321
+ );
322
+ }
323
+ const send = (
324
+ c.value.commands as Record<
325
+ string,
326
+ (...args: unknown[]) => Promise<unknown>
327
+ >
328
+ )[method]!;
329
+ const promise = send(...params);
330
+ flush(c);
331
+ return promise;
332
+ },
333
+ ),
334
+ subscribe: (listener) => {
335
+ c.listeners.add(listener);
336
+ return () => {
337
+ c.listeners.delete(listener);
338
+ };
339
+ },
340
+ };
341
+ return c;
342
+ });
343
+ core.getOptions = getOptions;
335
344
  // Attach during render so the first snapshot reads the transport's state;
336
345
  // re-attaching the same transport is a no-op and the single-slot host
337
346
  // listeners make a swap land on the last writer.
338
- core.attach(value);
347
+ attach(core, value);
339
348
  useEffect(() => {
340
- core.attach(value);
349
+ attach(core, value);
341
350
  });
342
- useEffect(() => () => core.detach(), [core]);
351
+ useEffect(() => () => detach(core), [core]);
343
352
  return core;
344
353
  };