@async/framework 0.11.5 → 0.11.7

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,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.7 - 2026-06-18
4
+
5
+ - Introduced an explicit `__async_server_result__: 1` marker for framework
6
+ server-result envelopes so ordinary domain objects with fields such as
7
+ `value`, `signals`, `cache`, `html`, `boundary`, `redirect`, or `error`
8
+ remain application values.
9
+ - Centralized server-result effect application and unwrapping across local
10
+ registry, remote proxy, and handler command paths so `.run(...)` and
11
+ namespaced calls return equivalent values without double-applying effects.
12
+ - Kept cache-only and other effect-only server envelopes representable through
13
+ the explicit marker and updated server-call, router, partial, and SSR
14
+ examples to use the protocol.
15
+ - Bundle size from bundled TypeScript source: `browser.ts` 183,176 B raw /
16
+ 34,360 B gzip -> `browser.min.js` 77,854 B raw / 23,100 B gzip
17
+ (-105,322 B raw, -11,260 B gzip).
18
+
19
+ ## 0.11.6 - 2026-06-18
20
+
21
+ - Owned async-signal runs with private execution tokens so canceled, restored,
22
+ disposed, unregistered, or superseded work cannot commit late values, errors,
23
+ or subscriber notifications.
24
+ - Captured per-run `this.abort` and `this.server` context so older async work
25
+ cannot observe or cancel a newer run's abort signal after an `await`.
26
+ - Settled cancellation state immediately for non-cooperative loaders and
27
+ canceled queued initial async scheduler work during disposal.
28
+ - Bundle size from bundled TypeScript source: `browser.ts` 183,459 B raw /
29
+ 34,391 B gzip -> `browser.min.js` 77,987 B raw / 23,148 B gzip
30
+ (-105,472 B raw, -11,243 B gzip).
31
+
3
32
  ## 0.11.5 - 2026-06-18
4
33
 
5
34
  - Treated materialized signals and lazy async-signal descriptors as one
package/README.md CHANGED
@@ -806,6 +806,7 @@ import {
806
806
  const server = createServerRegistry({
807
807
  "cart.add"(productId, quantity) {
808
808
  return {
809
+ __async_server_result__: 1,
809
810
  value: { ok: true },
810
811
  signals: {
811
812
  cartCount: 3
package/browser.js CHANGED
@@ -19,8 +19,9 @@ const __asyncSignalModule = (() => {
19
19
  let version = 0;
20
20
  let registry;
21
21
  let registeredId = id;
22
- let activeController;
23
- let activeAbort;
22
+ let activeRun;
23
+ let executionToken = 0;
24
+ let disposed = false;
24
25
  const subscribers = new Set();
25
26
  const dependencyCleanups = new Set();
26
27
 
@@ -62,109 +63,62 @@ const __asyncSignalModule = (() => {
62
63
  },
63
64
 
64
65
  refresh() {
65
- if (!registry) {
66
+ if (!registry || disposed) {
66
67
  throw new Error(`Async signal "${registeredId}" is not registered.`);
67
68
  }
68
69
 
69
- if (activeAbort && !activeAbort.aborted) {
70
- activeAbort.cancel(new Error(`Async signal "${registeredId}" refreshed.`));
71
- }
70
+ cancelRun(activeRun, new Error(`Async signal "${registeredId}" refreshed.`));
72
71
 
72
+ const runRegistry = registry;
73
+ const runId = registeredId;
73
74
  const runVersion = version + 1;
74
75
  version = runVersion;
75
76
  loading = true;
76
77
  error = null;
77
78
  status = "loading";
78
79
 
79
- const controller = new AbortController();
80
- activeController = controller;
81
- activeAbort = controller.signal;
82
- attachCancel(activeAbort, controller);
80
+ const run = createRun(runRegistry, runId, runVersion);
81
+ activeRun = run;
83
82
  notify();
84
83
 
85
- const context = {
86
- signals: registry,
87
- id: registeredId,
88
- get server() {
89
- const context = registry._context?.() ?? {};
90
- const server = context.server;
91
- if (typeof server?._withContext === "function") {
92
- return server._withContext({
93
- signals: registry,
94
- router: context.router,
95
- loader: context.loader,
96
- cache: context.cache,
97
- abort: activeAbort,
98
- scheduler: context.scheduler
99
- });
100
- }
101
- return server;
102
- },
103
- get router() {
104
- return registry._context?.().router;
105
- },
106
- get loader() {
107
- return registry._context?.().loader;
108
- },
109
- get cache() {
110
- return registry._context?.().cache;
111
- },
112
- get scheduler() {
113
- return registry._context?.().scheduler;
114
- },
115
- get version() {
116
- return runVersion;
117
- },
118
- get abort() {
119
- return activeAbort;
120
- },
121
- refresh() {
122
- return state.refresh();
123
- }
124
- };
84
+ const context = createRunContext(run);
125
85
 
126
86
  let outcome;
127
87
  try {
128
- outcome = registry._collectDependencies(() => fn.call(context));
88
+ outcome = runRegistry._collectDependencies(() => fn.call(context));
129
89
  } catch (cause) {
130
- finishError(runVersion, cause);
90
+ finishError(run, cause);
131
91
  return Promise.reject(cause);
132
92
  }
133
93
 
134
- syncDependencies(outcome.dependencies);
94
+ syncDependencies(outcome.dependencies, run);
135
95
 
136
96
  return Promise.resolve(outcome.value).then(
137
97
  (nextValue) => {
138
- if (!isCurrent(runVersion)) {
98
+ if (!isRunCurrent(run)) {
139
99
  return value;
140
100
  }
141
101
  value = nextValue;
142
102
  loading = false;
143
103
  error = null;
144
104
  status = "ready";
105
+ activeRun = undefined;
145
106
  notify();
146
107
  return value;
147
108
  },
148
109
  (cause) => {
149
- if (!isCurrent(runVersion)) {
110
+ if (!isRunCurrent(run)) {
150
111
  return value;
151
112
  }
152
- if (activeAbort?.aborted) {
153
- loading = false;
154
- status = value === undefined ? "idle" : "ready";
155
- notify();
156
- return value;
157
- }
158
- finishError(runVersion, cause);
113
+ finishError(run, cause);
159
114
  return value;
160
115
  }
161
116
  );
162
117
  },
163
118
 
164
119
  cancel(reason) {
165
- if (activeAbort && !activeAbort.aborted) {
166
- activeAbort.cancel(reason);
167
- }
120
+ cancelCurrentRun(reason, { settle: true, notifyChange: true });
121
+ return value;
168
122
  },
169
123
 
170
124
  subscribe(fn) {
@@ -193,9 +147,7 @@ const __asyncSignalModule = (() => {
193
147
  if (!isAsyncSignalSnapshot(snapshot)) {
194
148
  return state.set(snapshot);
195
149
  }
196
- if (activeAbort && !activeAbort.aborted) {
197
- activeAbort.cancel(new Error(`Async signal "${registeredId}" restored from snapshot.`));
198
- }
150
+ cancelCurrentRun(new Error(`Async signal "${registeredId}" restored from snapshot.`));
199
151
  value = snapshot.value;
200
152
  loading = Boolean(snapshot.loading);
201
153
  error = snapshot.error ?? null;
@@ -211,7 +163,7 @@ const __asyncSignalModule = (() => {
211
163
  registry = nextRegistry;
212
164
  registeredId = nextId;
213
165
  const start = () => {
214
- if (registry === nextRegistry && status === "idle") {
166
+ if (!disposed && registry === nextRegistry && status === "idle") {
215
167
  state.refresh();
216
168
  }
217
169
  };
@@ -227,30 +179,161 @@ const __asyncSignalModule = (() => {
227
179
  },
228
180
 
229
181
  _dispose() {
230
- state.cancel(new Error(`Async signal "${registeredId}" disposed.`));
182
+ if (disposed) {
183
+ return;
184
+ }
185
+ disposed = true;
186
+ cancelQueuedWork();
187
+ cancelCurrentRun(new Error(`Async signal "${registeredId}" disposed.`));
231
188
  for (const cleanup of dependencyCleanups) {
232
189
  cleanup();
233
190
  }
234
191
  dependencyCleanups.clear();
235
192
  subscribers.clear();
193
+ registry = undefined;
236
194
  }
237
195
  };
238
196
 
239
- function finishError(runVersion, cause) {
240
- if (!isCurrent(runVersion)) {
197
+ function createRun(runRegistry, runId, runVersion) {
198
+ const controller = new AbortController();
199
+ const abort = controller.signal;
200
+ const runContext = captureRunContext(runRegistry, abort);
201
+ const run = {
202
+ token: ++executionToken,
203
+ version: runVersion,
204
+ registry: runRegistry,
205
+ id: runId,
206
+ controller,
207
+ abort,
208
+ ...runContext
209
+ };
210
+ attachCancel(abort, controller, (reason) => {
211
+ cancelRun(run, reason, { settle: true, notifyChange: true });
212
+ });
213
+ return run;
214
+ }
215
+
216
+ function captureRunContext(runRegistry, abort) {
217
+ const context = runRegistry._context?.() ?? {};
218
+ const serverContext = {
219
+ signals: runRegistry,
220
+ router: context.router,
221
+ loader: context.loader,
222
+ cache: context.cache,
223
+ abort,
224
+ scheduler: context.scheduler
225
+ };
226
+ const server = typeof context.server?._withContext === "function"
227
+ ? context.server._withContext(serverContext)
228
+ : context.server;
229
+
230
+ return {
231
+ signals: runRegistry,
232
+ server,
233
+ router: context.router,
234
+ loader: context.loader,
235
+ cache: context.cache,
236
+ scheduler: context.scheduler
237
+ };
238
+ }
239
+
240
+ function createRunContext(run) {
241
+ return {
242
+ signals: run.signals,
243
+ id: run.id,
244
+ get server() {
245
+ return run.server;
246
+ },
247
+ get router() {
248
+ return run.router;
249
+ },
250
+ get loader() {
251
+ return run.loader;
252
+ },
253
+ get cache() {
254
+ return run.cache;
255
+ },
256
+ get scheduler() {
257
+ return run.scheduler;
258
+ },
259
+ get version() {
260
+ return run.version;
261
+ },
262
+ get abort() {
263
+ return run.abort;
264
+ },
265
+ refresh() {
266
+ return state.refresh();
267
+ }
268
+ };
269
+ }
270
+
271
+ function finishError(run, cause) {
272
+ if (!isRunCurrent(run)) {
241
273
  return;
242
274
  }
243
275
  loading = false;
244
276
  error = cause;
245
277
  status = "error";
278
+ activeRun = undefined;
246
279
  notify();
247
280
  }
248
281
 
249
- function isCurrent(runVersion) {
250
- return runVersion === version && activeController?.signal === activeAbort;
282
+ function isRunCurrent(run) {
283
+ return Boolean(run)
284
+ && !disposed
285
+ && activeRun === run
286
+ && run.token === executionToken
287
+ && run.registry === registry
288
+ && run.id === registeredId
289
+ && !run.abort.aborted;
290
+ }
291
+
292
+ function cancelCurrentRun(reason, options = {}) {
293
+ const run = activeRun;
294
+ const shouldSettle = Boolean(run) || loading;
295
+ if (run) {
296
+ cancelRun(run, reason);
297
+ } else if (shouldSettle) {
298
+ executionToken += 1;
299
+ }
300
+ if (options.settle && shouldSettle && !disposed) {
301
+ settleCanceled(options.notifyChange);
302
+ }
303
+ }
304
+
305
+ function cancelRun(run, reason, options = {}) {
306
+ if (!run) {
307
+ return;
308
+ }
309
+ const wasActive = activeRun === run;
310
+ if (wasActive) {
311
+ executionToken += 1;
312
+ activeRun = undefined;
313
+ }
314
+ if (!run.abort.aborted) {
315
+ run.controller.abort(reason);
316
+ }
317
+ if (wasActive && options.settle && !disposed) {
318
+ settleCanceled(options.notifyChange);
319
+ }
320
+ }
321
+
322
+ function settleCanceled(notifyChange = false) {
323
+ const nextStatus = value === undefined ? "idle" : "ready";
324
+ const changed = loading || error !== null || status !== nextStatus;
325
+ loading = false;
326
+ error = null;
327
+ status = nextStatus;
328
+ if (notifyChange && changed) {
329
+ notify();
330
+ }
251
331
  }
252
332
 
253
- function syncDependencies(dependencies) {
333
+ function syncDependencies(dependencies, run) {
334
+ if (!isRunCurrent(run)) {
335
+ return;
336
+ }
254
337
  for (const cleanup of dependencyCleanups) {
255
338
  cleanup();
256
339
  }
@@ -259,15 +342,16 @@ const __asyncSignalModule = (() => {
259
342
  for (const dependency of dependencies) {
260
343
  const dependencyId = String(dependency).split(".")[0];
261
344
  if (dependencyId && dependencyId !== registeredId) {
262
- dependencyCleanups.add(registry.subscribe(dependency, () => scheduleRefresh()));
345
+ dependencyCleanups.add(run.registry.subscribe(dependency, () => scheduleRefresh()));
263
346
  }
264
347
  }
265
348
  }
266
349
 
267
350
  function scheduleRefresh() {
268
- if (activeAbort && !activeAbort.aborted) {
269
- activeAbort.cancel(new Error(`Async signal "${registeredId}" dependency changed.`));
351
+ if (disposed || !registry) {
352
+ return;
270
353
  }
354
+ cancelRun(activeRun, new Error(`Async signal "${registeredId}" dependency changed.`));
271
355
  const scheduler = registry?._context?.().scheduler;
272
356
  if (!scheduler) {
273
357
  state.refresh();
@@ -279,6 +363,11 @@ const __asyncSignalModule = (() => {
279
363
  });
280
364
  }
281
365
 
366
+ function cancelQueuedWork() {
367
+ const scheduler = registry?._context?.().scheduler;
368
+ scheduler?.cancelScope?.(registeredId);
369
+ }
370
+
282
371
  function notify() {
283
372
  for (const subscriber of [...subscribers]) {
284
373
  subscriber(state);
@@ -313,11 +402,15 @@ const __asyncSignalModule = (() => {
313
402
  return value === undefined ? "idle" : "ready";
314
403
  }
315
404
 
316
- function attachCancel(signal, controller) {
405
+ function attachCancel(signal, controller, onCancel) {
317
406
  Object.defineProperty(signal, "cancel", {
318
407
  configurable: true,
319
408
  enumerable: false,
320
409
  value(reason) {
410
+ if (typeof onCancel === "function") {
411
+ onCancel(reason);
412
+ return;
413
+ }
321
414
  controller.abort(reason);
322
415
  }
323
416
  });
@@ -2285,9 +2378,10 @@ const __componentModule = (() => {
2285
2378
  })();
2286
2379
 
2287
2380
  const __serverModule = (() => {
2288
- const serverEnvelopeKeys = new Set(["value", "signals", "boundary", "html", "redirect", "error"]);
2381
+ const serverEnvelopeKind = Symbol.for("@async/framework.serverResult");
2382
+ const serverEnvelopeWireKey = "__async_server_result__";
2383
+ const serverEnvelopeWireVersion = 1;
2289
2384
  const appliedServerResult = Symbol.for("@async/framework.appliedServerResult");
2290
- const appliedServerValues = new WeakSet();
2291
2385
 
2292
2386
  function createServerProxy({
2293
2387
  endpoint = "/__async/server",
@@ -2330,9 +2424,7 @@ const __serverModule = (() => {
2330
2424
  throw new Error(`Server function "${id}" failed with ${response.status}.`);
2331
2425
  }
2332
2426
 
2333
- const result = await readServerResponse(id, response);
2334
- await applyServerResult(result, runContext);
2335
- return markAppliedServerValue(unwrapServerResult(result));
2427
+ return consumeServerResult(await readServerResponse(id, response), runContext);
2336
2428
  }
2337
2429
 
2338
2430
  return createServerNamespace(run, {
@@ -2367,7 +2459,7 @@ const __serverModule = (() => {
2367
2459
  if (!isServerEnvelope(result)) {
2368
2460
  return result;
2369
2461
  }
2370
- if (result[appliedServerResult] || appliedServerValues.has(result)) {
2462
+ if (result[appliedServerResult]) {
2371
2463
  return result;
2372
2464
  }
2373
2465
 
@@ -2399,18 +2491,16 @@ const __serverModule = (() => {
2399
2491
  return result;
2400
2492
  }
2401
2493
 
2402
- function unwrapServerResult(result) {
2403
- if (isServerEnvelope(result) && Object.hasOwn(result, "value")) {
2404
- return result.value;
2405
- }
2406
- return result;
2494
+ async function consumeServerResult(result, context = {}) {
2495
+ await applyServerResult(result, context);
2496
+ return unwrapServerResult(result);
2407
2497
  }
2408
2498
 
2409
- function markAppliedServerValue(value) {
2410
- if (value && typeof value === "object") {
2411
- appliedServerValues.add(value);
2499
+ function unwrapServerResult(result) {
2500
+ if (isServerEnvelope(result)) {
2501
+ return Object.hasOwn(result, "value") ? result.value : undefined;
2412
2502
  }
2413
- return value;
2503
+ return result;
2414
2504
  }
2415
2505
 
2416
2506
  function markAppliedServerResult(result) {
@@ -2454,9 +2544,7 @@ const __serverModule = (() => {
2454
2544
  throw new Error("Server namespace is not directly callable.");
2455
2545
  }
2456
2546
  const context = contextProvider() ?? {};
2457
- const result = await run(parts.join("."), args, context);
2458
- await applyServerResult(result, context);
2459
- return unwrapServerResult(result);
2547
+ return run(parts.join("."), args, context);
2460
2548
  };
2461
2549
 
2462
2550
  const proxy = new Proxy(callable, {
@@ -2513,7 +2601,7 @@ const __serverModule = (() => {
2513
2601
 
2514
2602
  async function readServerResponse(id, response) {
2515
2603
  if (response.status === 204) {
2516
- return { value: undefined };
2604
+ return undefined;
2517
2605
  }
2518
2606
  const type = response.headers.get("content-type") ?? "";
2519
2607
  if (type.includes("application/json")) {
@@ -2531,7 +2619,7 @@ const __serverModule = (() => {
2531
2619
  if (typeof response.text !== "function") {
2532
2620
  throw new Error(`Server function "${id}" transport returned an invalid response: missing text().`);
2533
2621
  }
2534
- return { value: await response.text() };
2622
+ return response.text();
2535
2623
  }
2536
2624
 
2537
2625
  function snapshotSignalPaths(paths = [], signals) {
@@ -2676,7 +2764,8 @@ const __serverModule = (() => {
2676
2764
  if (!value || typeof value !== "object" || Array.isArray(value)) {
2677
2765
  return false;
2678
2766
  }
2679
- return Object.keys(value).some((key) => serverEnvelopeKeys.has(key));
2767
+ return value[serverEnvelopeKind] === true
2768
+ || value[serverEnvelopeWireKey] === serverEnvelopeWireVersion;
2680
2769
  }
2681
2770
 
2682
2771
  function toError(value) {
@@ -2698,11 +2787,11 @@ const __serverModule = (() => {
2698
2787
  throw new TypeError("Server function id must be a non-empty string.");
2699
2788
  }
2700
2789
  }
2701
- return { createServerProxy, resolveServerCommandArguments, applyServerResult, unwrapServerResult, defaultInput, createServerNamespace, createSignalReader, assertServerId };
2790
+ return { createServerProxy, resolveServerCommandArguments, applyServerResult, consumeServerResult, unwrapServerResult, defaultInput, createServerNamespace, createSignalReader, assertServerId };
2702
2791
  })();
2703
2792
 
2704
2793
  const __handlersModule = (() => {
2705
- const { applyServerResult, defaultInput, resolveServerCommandArguments, unwrapServerResult } = __serverModule;
2794
+ const { defaultInput, resolveServerCommandArguments } = __serverModule;
2706
2795
  const { attachRegistryInspection, createRegistryStore } = __registryStoreModule;
2707
2796
  const { createLazyRegistry, isLazyDescriptor } = __lazyRegistryModule;
2708
2797
  const builtInTokens = new Set(["prevent", "preventDefault", "stopPropagation", "stopImmediatePropagation"]);
@@ -2800,8 +2889,7 @@ const __handlersModule = (() => {
2800
2889
  signalPaths: resolved.signalPaths,
2801
2890
  signalValues: resolved.signalValues
2802
2891
  });
2803
- await applyServerResult(result, runContext);
2804
- results.push(unwrapServerResult(result));
2892
+ results.push(result);
2805
2893
  continue;
2806
2894
  }
2807
2895