@async/framework 0.8.0 → 0.10.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.
@@ -0,0 +1,204 @@
1
+ const descriptorTypes = new Set(["handler", "component", "asyncSignal", "partial", "route"]);
2
+ const defaultBaseUrl = "_async";
3
+
4
+ export function defineRegistrySnapshot(snapshot = {}) {
5
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
6
+ throw new TypeError("defineRegistrySnapshot(snapshot) requires an object.");
7
+ }
8
+ return snapshot;
9
+ }
10
+
11
+ export function createLazyRegistry(options = {}) {
12
+ const registryAssets = normalizeRegistryAssets(options.registryAssets ?? options.assets);
13
+ const importModule = options.importModule ?? ((url) => import(url));
14
+ const moduleCache = new Map();
15
+ const exportCache = new Map();
16
+
17
+ return {
18
+ registryAssets,
19
+
20
+ resolveUrl(type, id, descriptor) {
21
+ return resolveDescriptorUrl(type, id, descriptor, registryAssets);
22
+ },
23
+
24
+ async resolve(type, id, descriptor) {
25
+ if (!isLazyDescriptor(descriptor)) {
26
+ return descriptor;
27
+ }
28
+ const cacheKey = `${type}:${id}`;
29
+ if (exportCache.has(cacheKey)) {
30
+ return exportCache.get(cacheKey);
31
+ }
32
+
33
+ const resolved = resolveDescriptorUrl(type, id, descriptor, registryAssets);
34
+ let modulePromise = moduleCache.get(resolved.moduleUrl);
35
+ if (!modulePromise) {
36
+ modulePromise = Promise.resolve(importModule(resolved.moduleUrl));
37
+ moduleCache.set(resolved.moduleUrl, modulePromise);
38
+ }
39
+ const module = await modulePromise;
40
+ const value = resolveExport(module, resolved.exportNames, type, id);
41
+ exportCache.set(cacheKey, value);
42
+ return value;
43
+ },
44
+
45
+ inspect() {
46
+ return {
47
+ registryAssets,
48
+ modules: [...moduleCache.keys()],
49
+ exports: [...exportCache.keys()]
50
+ };
51
+ }
52
+ };
53
+ }
54
+
55
+ export function normalizeRegistryAssets(options = {}) {
56
+ const baseUrl = normalizeBaseUrl(options.baseUrl ?? defaultBaseUrl);
57
+ const paths = {
58
+ component: "component",
59
+ handler: "handler",
60
+ asyncSignal: "asyncSignal",
61
+ partial: "partial",
62
+ route: "route",
63
+ ...(options.paths ?? {})
64
+ };
65
+
66
+ for (const [type, value] of Object.entries(paths)) {
67
+ if (!descriptorTypes.has(type)) {
68
+ continue;
69
+ }
70
+ if (typeof value !== "string" || value.length === 0) {
71
+ throw new TypeError(`Registry asset path for "${type}" must be a non-empty string.`);
72
+ }
73
+ }
74
+
75
+ return {
76
+ baseUrl,
77
+ paths
78
+ };
79
+ }
80
+
81
+ export function isLazyDescriptor(value) {
82
+ return Boolean(
83
+ value &&
84
+ typeof value === "object" &&
85
+ !Array.isArray(value) &&
86
+ typeof value.url === "string"
87
+ );
88
+ }
89
+
90
+ export function sameRegistryValue(left, right) {
91
+ if (left === right) {
92
+ return true;
93
+ }
94
+ if (isLazyDescriptor(left) && isLazyDescriptor(right)) {
95
+ return stableStringify(left) === stableStringify(right);
96
+ }
97
+ return false;
98
+ }
99
+
100
+ export function publicRegistryValue(value, id) {
101
+ if (isLazyDescriptor(value)) {
102
+ return { ...value };
103
+ }
104
+ return { id };
105
+ }
106
+
107
+ function resolveDescriptorUrl(type, id, descriptor, registryAssets) {
108
+ if (!descriptorTypes.has(type)) {
109
+ throw new Error(`Registry type "${type}" does not support lazy descriptors.`);
110
+ }
111
+ if (!isLazyDescriptor(descriptor)) {
112
+ throw new TypeError(`Registry descriptor for "${type}:${id}" requires a url.`);
113
+ }
114
+
115
+ const { path, hash } = splitHash(descriptor.url);
116
+ const moduleUrl = resolveModuleUrl(type, path, registryAssets);
117
+ const exportNames = hash
118
+ ? [hash]
119
+ : inferredExportNames(id, path);
120
+
121
+ return {
122
+ moduleUrl,
123
+ exportNames,
124
+ url: hash ? `${moduleUrl}#${hash}` : moduleUrl
125
+ };
126
+ }
127
+
128
+ function resolveModuleUrl(type, path, registryAssets) {
129
+ if (isAbsoluteUrl(path) || path.startsWith("/") || path.startsWith("./") || path.startsWith("../")) {
130
+ return path;
131
+ }
132
+ const typePath = registryAssets.paths[type] ?? type;
133
+ return joinUrl(registryAssets.baseUrl, typePath, path);
134
+ }
135
+
136
+ function resolveExport(module, exportNames, type, id) {
137
+ for (const name of exportNames) {
138
+ if (name in module) {
139
+ return module[name];
140
+ }
141
+ }
142
+ throw new Error(`Lazy ${type} "${id}" did not export ${exportNames.map((name) => `"${name}"`).join(", ")}.`);
143
+ }
144
+
145
+ function inferredExportNames(id, path) {
146
+ const names = [];
147
+ const leaf = id.split(".").filter(Boolean).at(-1);
148
+ const basename = path
149
+ .split("/")
150
+ .filter(Boolean)
151
+ .at(-1)
152
+ ?.replace(/\.[^.]+$/, "");
153
+ for (const name of [leaf, basename, "default"]) {
154
+ if (name && !names.includes(name)) {
155
+ names.push(name);
156
+ }
157
+ }
158
+ return names;
159
+ }
160
+
161
+ function splitHash(url) {
162
+ const index = url.indexOf("#");
163
+ if (index === -1) {
164
+ return { path: url, hash: "" };
165
+ }
166
+ return {
167
+ path: url.slice(0, index),
168
+ hash: url.slice(index + 1)
169
+ };
170
+ }
171
+
172
+ function normalizeBaseUrl(baseUrl) {
173
+ if (typeof baseUrl !== "string" || baseUrl.length === 0) {
174
+ throw new TypeError("registryAssets.baseUrl must be a non-empty string.");
175
+ }
176
+ if (isAbsoluteUrl(baseUrl) || baseUrl.startsWith("/") || baseUrl.startsWith("./") || baseUrl.startsWith("../")) {
177
+ return stripTrailingSlash(baseUrl);
178
+ }
179
+ return `/${stripSlashes(baseUrl)}`;
180
+ }
181
+
182
+ function joinUrl(...parts) {
183
+ const [first, ...rest] = parts;
184
+ return [stripTrailingSlash(first), ...rest.map(stripSlashes)].filter(Boolean).join("/");
185
+ }
186
+
187
+ function stripSlashes(value) {
188
+ return String(value).replace(/^\/+|\/+$/g, "");
189
+ }
190
+
191
+ function stripTrailingSlash(value) {
192
+ return String(value).replace(/\/+$/g, "");
193
+ }
194
+
195
+ function isAbsoluteUrl(value) {
196
+ return /^[A-Za-z][A-Za-z\d+.-]*:/.test(value);
197
+ }
198
+
199
+ function stableStringify(value) {
200
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
201
+ return JSON.stringify(value);
202
+ }
203
+ return JSON.stringify(Object.keys(value).sort().map((key) => [key, value[key]]));
204
+ }
package/src/loader.js CHANGED
@@ -335,7 +335,7 @@ export function Loader({ root, signals, handlers, server, router, cache, attribu
335
335
  if (renderingBoundaries.has(boundary)) {
336
336
  continue;
337
337
  }
338
- const id = readAttribute(boundary, attributeConfig, "async", "boundary");
338
+ const id = boundaryIdFor(boundary, attributeConfig);
339
339
  if (id == null) {
340
340
  continue;
341
341
  }
@@ -652,19 +652,26 @@ function writeClassTokens(element, tokens) {
652
652
  function collectBoundaryTemplates(boundary, id, attributeConfig) {
653
653
  const templates = {};
654
654
  for (const template of [...boundary.children].filter((child) => child.tagName === "TEMPLATE")) {
655
- if (readAttribute(template, attributeConfig, "async", "loading") === id) {
655
+ if (templateMatchesState(template, "loading", id, boundary, attributeConfig)) {
656
656
  templates.loading = template;
657
657
  }
658
- if (readAttribute(template, attributeConfig, "async", "ready") === id) {
658
+ if (templateMatchesState(template, "ready", id, boundary, attributeConfig)) {
659
659
  templates.ready = template;
660
660
  }
661
- if (readAttribute(template, attributeConfig, "async", "error") === id) {
661
+ if (templateMatchesState(template, "error", id, boundary, attributeConfig)) {
662
662
  templates.error = template;
663
663
  }
664
664
  }
665
665
  return templates;
666
666
  }
667
667
 
668
+ function templateMatchesState(template, state, id, boundary, attributeConfig) {
669
+ if (readAttribute(template, attributeConfig, "async", state) === id) {
670
+ return true;
671
+ }
672
+ return isAsyncSuspense(boundary) && template.hasAttribute?.(state);
673
+ }
674
+
668
675
  function chooseBoundaryTemplate(templates, status) {
669
676
  if (status === "ready") {
670
677
  return templates.ready ?? templates.loading ?? templates.error;
@@ -712,13 +719,24 @@ function elementsIn(scope) {
712
719
 
713
720
  function findBoundary(root, boundaryId, attributeConfig) {
714
721
  for (const element of elementsIn(root)) {
715
- if (readAttribute(element, attributeConfig, "async", "boundary") === String(boundaryId)) {
722
+ if (boundaryIdFor(element, attributeConfig) === String(boundaryId)) {
716
723
  return element;
717
724
  }
718
725
  }
719
726
  return null;
720
727
  }
721
728
 
729
+ function boundaryIdFor(element, attributeConfig) {
730
+ if (isAsyncSuspense(element) && element.hasAttribute?.("for")) {
731
+ return element.getAttribute("for");
732
+ }
733
+ return readAttribute(element, attributeConfig, "async", "boundary");
734
+ }
735
+
736
+ function isAsyncSuspense(element) {
737
+ return element?.tagName === "ASYNC-SUSPENSE";
738
+ }
739
+
722
740
  function toFragment(value, documentRef) {
723
741
  if (value?.nodeType === 11) {
724
742
  return value;
package/src/partials.js CHANGED
@@ -1,15 +1,18 @@
1
1
  import { isTemplateResult, renderTemplate } from "./html.js";
2
2
  import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
3
+ import { createLazyRegistry, isLazyDescriptor } from "./lazy-registry.js";
3
4
 
4
5
  export function createPartialRegistry(initialMap = {}, options = {}) {
5
6
  const registryStore = options.registry ?? createRegistryStore();
6
7
  const type = options.type ?? "partial";
7
8
  const entries = registryStore._map(type);
9
+ const lazyRegistry = options.lazyRegistry ?? createLazyRegistry(options);
10
+ const lazyPartials = new Map();
8
11
 
9
12
  const registry = attachRegistryInspection({
10
13
  register(id, fn) {
11
14
  assertId(id);
12
- if (typeof fn !== "function") {
15
+ if (typeof fn !== "function" && !isLazyDescriptor(fn)) {
13
16
  throw new TypeError(`Partial "${id}" must be a function.`);
14
17
  }
15
18
  if (entries.has(id)) {
@@ -28,12 +31,26 @@ export function createPartialRegistry(initialMap = {}, options = {}) {
28
31
 
29
32
  unregister(id) {
30
33
  assertId(id);
34
+ lazyPartials.delete(id);
31
35
  return entries.delete(id);
32
36
  },
33
37
 
34
38
  resolve(id) {
35
39
  assertId(id);
36
- return entries.get(id);
40
+ const partial = entries.get(id);
41
+ if (!isLazyDescriptor(partial)) {
42
+ return partial;
43
+ }
44
+ if (!lazyPartials.has(id)) {
45
+ lazyPartials.set(id, async function runLazyPartial(...args) {
46
+ const resolved = await lazyRegistry.resolve(type, id, partial);
47
+ if (typeof resolved !== "function") {
48
+ throw new TypeError(`Partial "${id}" did not resolve to a function.`);
49
+ }
50
+ return resolved.apply(this, args);
51
+ });
52
+ }
53
+ return lazyPartials.get(id);
37
54
  },
38
55
 
39
56
  async render(id, props = {}, context = {}) {
@@ -1,4 +1,6 @@
1
- const declarationTypes = new Set(["signal", "handler", "server", "partial", "route", "component"]);
1
+ import { publicRegistryValue } from "./lazy-registry.js";
2
+
3
+ const declarationTypes = new Set(["signal", "handler", "server", "partial", "route", "component", "asyncSignal"]);
2
4
  const cacheTypes = new Set(["cache.browser", "cache.server"]);
3
5
  const cacheEntryTypes = new Set(["cache.browser.entries", "cache.server.entries"]);
4
6
  const allTypes = new Set([...declarationTypes, ...cacheTypes, ...cacheEntryTypes]);
@@ -85,11 +87,12 @@ export function createRegistryStore(initial = {}, options = {}) {
85
87
  const snapshotTarget = snapshotOptions.target ?? target;
86
88
  return {
87
89
  signal: snapshotSignals(backing.signal),
88
- handler: snapshotDescriptors(backing.handler, "handler"),
89
- server: snapshotDescriptors(backing.server, "server"),
90
- partial: snapshotDescriptors(backing.partial, "partial"),
90
+ handler: snapshotDescriptors(backing.handler),
91
+ server: snapshotDescriptors(backing.server),
92
+ partial: snapshotDescriptors(backing.partial),
91
93
  route: snapshotPlain(backing.route),
92
- component: snapshotDescriptors(backing.component, "component"),
94
+ component: snapshotDescriptors(backing.component),
95
+ asyncSignal: snapshotDescriptors(backing.asyncSignal),
93
96
  cache: {
94
97
  browser: snapshotPlain(backing.cache.browser),
95
98
  server: snapshotPlain(backing.cache.server)
@@ -109,6 +112,7 @@ export function createRegistryStore(initial = {}, options = {}) {
109
112
  partial: Object.fromEntries(backing.partial),
110
113
  route: Object.fromEntries(backing.route),
111
114
  component: Object.fromEntries(backing.component),
115
+ asyncSignal: Object.fromEntries(backing.asyncSignal),
112
116
  cache: {
113
117
  browser: Object.fromEntries(backing.cache.browser),
114
118
  server: Object.fromEntries(backing.cache.server)
@@ -168,6 +172,7 @@ function createBacking() {
168
172
  partial: new Map(),
169
173
  route: new Map(),
170
174
  component: new Map(),
175
+ asyncSignal: new Map(),
171
176
  cache: {
172
177
  browser: new Map(),
173
178
  server: new Map()
@@ -186,6 +191,7 @@ function applyInitial(registry, initial = {}) {
186
191
  registry.registerMany("partial", initial.partial);
187
192
  registry.registerMany("route", initial.route);
188
193
  registry.registerMany("component", initial.component);
194
+ registry.registerMany("asyncSignal", initial.asyncSignal);
189
195
  registry.registerMany("cache.browser", initial.cache?.browser);
190
196
  registry.registerMany("cache.server", initial.cache?.server);
191
197
 
@@ -213,7 +219,7 @@ function assertId(type, id) {
213
219
 
214
220
  function publicValue(type, id, value, options) {
215
221
  if (type === "server" && options.target === "browser") {
216
- return { id, kind: "server" };
222
+ return publicRegistryValue(value, id);
217
223
  }
218
224
  if (cacheEntryTypes.has(type)) {
219
225
  return value?.value;
@@ -233,10 +239,10 @@ function snapshotSignals(map) {
233
239
  return snapshot;
234
240
  }
235
241
 
236
- function snapshotDescriptors(map, kind) {
242
+ function snapshotDescriptors(map) {
237
243
  const snapshot = {};
238
- for (const id of map.keys()) {
239
- snapshot[id] = { id, kind };
244
+ for (const [id, value] of map) {
245
+ snapshot[id] = publicRegistryValue(value, id);
240
246
  }
241
247
  return snapshot;
242
248
  }
package/src/scheduler.js CHANGED
@@ -162,6 +162,10 @@ export function createScheduler(options = {}) {
162
162
  return api;
163
163
  },
164
164
 
165
+ isScopeDestroyed(scope) {
166
+ return scope !== undefined && destroyedScopes.has(scope);
167
+ },
168
+
165
169
  inspect() {
166
170
  const counts = {};
167
171
  for (const [phase, queue] of queues) {
package/src/signals.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { asyncSignal as createAsyncSignal, isAsyncSignal } from "./async-signal.js";
2
2
  import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
3
+ import { createLazyRegistry, isLazyDescriptor } from "./lazy-registry.js";
3
4
 
4
5
  const signalKind = Symbol.for("@async/framework.signal");
5
6
  const computedKind = Symbol.for("@async/framework.computed");
@@ -122,6 +123,8 @@ export function createSignalRegistry(initialMap = {}, options = {}) {
122
123
  const registryStore = options.registry ?? createRegistryStore();
123
124
  const type = options.type ?? "signal";
124
125
  const entries = registryStore._map(type);
126
+ const asyncDescriptors = registryStore._map("asyncSignal");
127
+ const lazyRegistry = options.lazyRegistry ?? createLazyRegistry(options);
125
128
  const registryCleanups = new Map();
126
129
  const runtimeContext = {};
127
130
  const boundEntries = new Set();
@@ -162,6 +165,7 @@ export function createSignalRegistry(initialMap = {}, options = {}) {
162
165
 
163
166
  ensure(id, initial) {
164
167
  assertId(id);
168
+ materializeAsyncSignal(id);
165
169
  if (!entries.has(id)) {
166
170
  registry.register(id, createSignal(initial));
167
171
  }
@@ -169,18 +173,18 @@ export function createSignalRegistry(initialMap = {}, options = {}) {
169
173
  },
170
174
 
171
175
  has(id) {
172
- return entries.has(id);
176
+ return entries.has(id) || asyncDescriptors.has(id);
173
177
  },
174
178
 
175
179
  get(path) {
176
- const parsed = parsePath(path, entries);
180
+ const parsed = parseRegistryPath(path);
177
181
  track(parsed.path);
178
182
  const entry = requireEntry(entries, parsed.id);
179
183
  return readEntry(entry, parsed.parts);
180
184
  },
181
185
 
182
186
  set(path, value) {
183
- const parsed = parsePath(path, entries);
187
+ const parsed = parseRegistryPath(path);
184
188
  const entry = requireEntry(entries, parsed.id);
185
189
  if (parsed.parts.length === 0) {
186
190
  return entry.set(value);
@@ -199,6 +203,7 @@ export function createSignalRegistry(initialMap = {}, options = {}) {
199
203
 
200
204
  ref(id) {
201
205
  assertId(id);
206
+ materializeAsyncSignal(id);
202
207
  return createRef(registry, id);
203
208
  },
204
209
 
@@ -206,7 +211,7 @@ export function createSignalRegistry(initialMap = {}, options = {}) {
206
211
  if (typeof fn !== "function") {
207
212
  throw new TypeError("subscribe(path, fn) requires a function.");
208
213
  }
209
- const parsed = parsePath(path, entries);
214
+ const parsed = parseRegistryPath(path);
210
215
  const entry = requireEntry(entries, parsed.id);
211
216
  const subscriptionId = ++subscriptionCounter;
212
217
  return entry.subscribe(() => {
@@ -312,6 +317,7 @@ export function createSignalRegistry(initialMap = {}, options = {}) {
312
317
  },
313
318
 
314
319
  _entry(id) {
320
+ materializeAsyncSignal(id);
315
321
  return requireEntry(entries, id);
316
322
  },
317
323
 
@@ -351,6 +357,42 @@ export function createSignalRegistry(initialMap = {}, options = {}) {
351
357
  }
352
358
  }
353
359
 
360
+ function parseRegistryPath(path) {
361
+ if (typeof path !== "string" || path.length === 0) {
362
+ throw new TypeError("Signal path must be a non-empty string.");
363
+ }
364
+ const segments = path.split(".");
365
+ for (let end = segments.length; end > 0; end -= 1) {
366
+ const id = segments.slice(0, end).join(".");
367
+ if (entries.has(id) || asyncDescriptors.has(id)) {
368
+ materializeAsyncSignal(id);
369
+ return { id, parts: segments.slice(end), path };
370
+ }
371
+ }
372
+ const [id, ...parts] = segments;
373
+ return { id, parts, path };
374
+ }
375
+
376
+ function materializeAsyncSignal(id) {
377
+ if (entries.has(id) || !asyncDescriptors.has(id)) {
378
+ return;
379
+ }
380
+ const descriptor = asyncDescriptors.get(id);
381
+ if (!isLazyDescriptor(descriptor) && typeof descriptor !== "function") {
382
+ throw new TypeError(`Async signal "${id}" must be a function or lazy descriptor.`);
383
+ }
384
+ const loader = async function runLazyAsyncSignal(...args) {
385
+ const resolved = await lazyRegistry.resolve("asyncSignal", id, descriptor);
386
+ if (typeof resolved !== "function") {
387
+ throw new TypeError(`Async signal "${id}" did not resolve to a function.`);
388
+ }
389
+ return resolved.apply(this, args);
390
+ };
391
+ const entry = createAsyncSignal(id, loader);
392
+ entries.set(id, entry);
393
+ bindEntry(id, entry);
394
+ }
395
+
354
396
  function scheduleCallback(fn, options = {}) {
355
397
  const scheduler = options.scheduler;
356
398
  if (!scheduler || options.phase === "sync") {