@async/framework 0.11.4 → 0.11.6

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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.6 - 2026-06-18
4
+
5
+ - Owned async-signal runs with private execution tokens so canceled, restored,
6
+ disposed, unregistered, or superseded work cannot commit late values, errors,
7
+ or subscriber notifications.
8
+ - Captured per-run `this.abort` and `this.server` context so older async work
9
+ cannot observe or cancel a newer run's abort signal after an `await`.
10
+ - Settled cancellation state immediately for non-cooperative loaders and
11
+ canceled queued initial async scheduler work during disposal.
12
+ - Bundle size from bundled TypeScript source: `browser.ts` 183,459 B raw /
13
+ 34,391 B gzip -> `browser.min.js` 77,987 B raw / 23,148 B gzip
14
+ (-105,472 B raw, -11,243 B gzip).
15
+
16
+ ## 0.11.5 - 2026-06-18
17
+
18
+ - Treated materialized signals and lazy async-signal descriptors as one
19
+ logical namespace so duplicate IDs are rejected consistently.
20
+ - Fixed lazy async-signal `unregister(...)` before and after materialization so
21
+ removed descriptors cannot rematerialize.
22
+ - Preserved reusable app async-signal declarations when materialized runtime
23
+ async state is destroyed.
24
+ - Bundle size from bundled TypeScript source: `browser.ts` 181,266 B raw /
25
+ 33,853 B gzip -> `browser.min.js` 77,285 B raw / 22,868 B gzip
26
+ (-103,981 B raw, -10,985 B gzip).
27
+
3
28
  ## 0.11.4 - 2026-06-18
4
29
 
5
30
  - Restored snapshot signal keys as exact IDs so dotted plain, async, and
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
+ }
251
303
  }
252
304
 
253
- function syncDependencies(dependencies) {
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
+ }
331
+ }
332
+
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
  });
@@ -1218,10 +1311,16 @@ const __signalsModule = (() => {
1218
1311
  let subscriptionCounter = 0;
1219
1312
  let effectCounter = 0;
1220
1313
 
1314
+ for (const id of entries.keys()) {
1315
+ if (asyncDescriptors.has(id)) {
1316
+ throw new Error(`Signal "${id}" is already registered.`);
1317
+ }
1318
+ }
1319
+
1221
1320
  const registry = attachRegistryInspection({
1222
1321
  register(id, signalLike) {
1223
1322
  assertId(id);
1224
- if (entries.has(id)) {
1323
+ if (entries.has(id) || asyncDescriptors.has(id)) {
1225
1324
  throw new Error(`Signal "${id}" is already registered.`);
1226
1325
  }
1227
1326
  const entry = normalizeSignal(signalLike);
@@ -1239,14 +1338,19 @@ const __signalsModule = (() => {
1239
1338
 
1240
1339
  unregister(id) {
1241
1340
  assertId(id);
1242
- if (!entries.has(id)) {
1341
+ const hadEntry = entries.has(id);
1342
+ const hadDescriptor = asyncDescriptors.has(id);
1343
+ if (!hadEntry && !hadDescriptor) {
1243
1344
  return false;
1244
1345
  }
1245
- registryCleanups.get(id)?.();
1246
- registryCleanups.delete(id);
1247
- entries.get(id)?._dispose?.();
1248
- entries.delete(id);
1249
- boundEntries.delete(id);
1346
+ if (hadEntry) {
1347
+ registryCleanups.get(id)?.();
1348
+ registryCleanups.delete(id);
1349
+ entries.get(id)?._dispose?.();
1350
+ entries.delete(id);
1351
+ boundEntries.delete(id);
1352
+ }
1353
+ asyncDescriptors.delete(id);
1250
1354
  return true;
1251
1355
  },
1252
1356
 
@@ -1438,6 +1542,9 @@ const __signalsModule = (() => {
1438
1542
  _adoptMany(map = {}) {
1439
1543
  for (const [id, signalLike] of Object.entries(map ?? {})) {
1440
1544
  if (!entries.has(id)) {
1545
+ if (asyncDescriptors.has(id)) {
1546
+ throw new Error(`Signal "${id}" is already registered.`);
1547
+ }
1441
1548
  const entry = cloneSignalDeclaration(signalLike);
1442
1549
  entries.set(id, entry);
1443
1550
  bindEntry(id, entry);
@@ -5338,6 +5445,9 @@ const __appModule = (() => {
5338
5445
  return;
5339
5446
  }
5340
5447
  for (const [id, value] of Object.entries(entries)) {
5448
+ if (type === "asyncSignal" && runtime.signals?.has?.(id) && !runtime.registry.has(type, id)) {
5449
+ throw new Error(`Signal "${id}" is already registered.`);
5450
+ }
5341
5451
  registerSnapshotEntry(runtime.registry, type, id, value, options);
5342
5452
  }
5343
5453
  concreteRegistry?._adoptMany?.(entries);