@async/framework 0.2.2 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@async/framework",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "description": "No-build AsyncLoader app runtime with signals, command events, server calls, route partials, cache split, SSR activation, and streaming boundaries.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.1.0",
@@ -28,12 +28,21 @@
28
28
  "web-framework"
29
29
  ],
30
30
  "license": "MIT",
31
+ "unpkg": "./framework.js",
31
32
  "exports": {
32
- ".": "./src/index.js"
33
+ ".": {
34
+ "unpkg": "./framework.js",
35
+ "browser": "./framework.js",
36
+ "import": "./src/index.js",
37
+ "default": "./src/index.js"
38
+ },
39
+ "./framework.js": "./framework.js",
40
+ "./package.json": "./package.json"
33
41
  },
34
42
  "files": [
35
43
  "CHANGELOG.md",
36
44
  "README.md",
45
+ "framework.js",
37
46
  "src",
38
47
  "examples",
39
48
  "LICENSE",
@@ -41,10 +50,12 @@
41
50
  ],
42
51
  "scripts": {
43
52
  "async-pipeline": "async-pipeline",
53
+ "bundle": "node scripts/build-framework-bundle.js",
54
+ "bundle:check": "node scripts/build-framework-bundle.js --check",
44
55
  "docs:build": "node scripts/build-pages.js",
45
56
  "examples": "node --test tests/examples.test.js",
46
57
  "examples:check": "node --test tests/examples.test.js",
47
- "pack:check": "npm pack --dry-run --ignore-scripts",
58
+ "pack:check": "pnpm run bundle:check && npm pack --dry-run --ignore-scripts",
48
59
  "pipeline:github:check": "async-pipeline github check",
49
60
  "pipeline:github:generate": "async-pipeline github generate",
50
61
  "pipeline:pages": "async-pipeline run pages",
package/src/app.js CHANGED
@@ -6,17 +6,21 @@ import { createPartialRegistry } from "./partials.js";
6
6
  import { createRouteRegistry, createRouter } from "./router.js";
7
7
  import { createServerRegistry } from "./server.js";
8
8
  import { createSignal, createSignalRegistry } from "./signals.js";
9
+ import { createRegistryStore } from "./registry-store.js";
10
+ import { attributeName, normalizeAttributeConfig } from "./attributes.js";
9
11
 
10
12
  const registryTypes = new Set(["signal", "handler", "server", "partial", "route", "component"]);
11
13
 
12
14
  export function defineApp(initial) {
13
- const declarations = emptyDeclarations();
15
+ const registry = createRegistryStore(undefined, { target: "browser" });
14
16
  const runtimes = new Set();
15
17
 
16
18
  const app = {
19
+ registry,
20
+
17
21
  use(typeOrModule, entries) {
18
22
  const normalized = normalizeUse(typeOrModule, entries);
19
- appendDeclarations(declarations, normalized);
23
+ appendDeclarations(registry, normalized);
20
24
  for (const runtime of runtimes) {
21
25
  runtime._applyUse(normalized);
22
26
  }
@@ -24,7 +28,7 @@ export function defineApp(initial) {
24
28
  },
25
29
 
26
30
  snapshot() {
27
- return cloneDeclarations(declarations);
31
+ return registry.rawSnapshot();
28
32
  },
29
33
 
30
34
  start(options = {}) {
@@ -53,15 +57,16 @@ export function defineApp(initial) {
53
57
  export function createApp(appOrDefinition = Async, options = {}) {
54
58
  const app = isAppHub(appOrDefinition) ? appOrDefinition : defineApp(appOrDefinition ?? {});
55
59
  const target = options.target ?? "browser";
56
- const snapshot = app.snapshot();
57
- const signals = options.signals ?? createSignalRegistry(snapshot.signal);
58
- const handlers = options.handlers ?? createHandlerRegistry(snapshot.handler);
59
- const serverCache = createCacheRegistry(snapshot.cache.server);
60
- const browserCache = createCacheRegistry(snapshot.cache.browser);
61
- const server = options.server ?? createServerRegistry(snapshot.server);
62
- const partials = options.partials ?? createPartialRegistry(snapshot.partial);
63
- const routes = options.routes ?? createRouteRegistry(snapshot.route);
64
- const components = options.components ?? createComponentRegistry(snapshot.component);
60
+ const attributes = normalizeAttributeConfig(options.attributes);
61
+ const registry = options.registry ?? app.registry.view({ target });
62
+ const signals = options.signals ?? createSignalRegistry(undefined, { registry, type: "signal" });
63
+ const handlers = options.handlers ?? createHandlerRegistry(undefined, { registry, type: "handler" });
64
+ const serverCache = createCacheRegistry(undefined, { registry, type: "cache.server" });
65
+ const browserCache = createCacheRegistry(undefined, { registry, type: "cache.browser" });
66
+ const server = options.server ?? createServerRegistry(undefined, { registry, type: "server" });
67
+ const partials = options.partials ?? createPartialRegistry(undefined, { registry, type: "partial" });
68
+ const routes = options.routes ?? createRouteRegistry(undefined, { registry, type: "route" });
69
+ const components = options.components ?? createComponentRegistry(undefined, { registry, type: "component" });
65
70
  let loader = options.loader;
66
71
  let router = options.router;
67
72
  let detach = () => {};
@@ -73,6 +78,7 @@ export function createApp(appOrDefinition = Async, options = {}) {
73
78
 
74
79
  const runtime = {
75
80
  app,
81
+ registry,
76
82
  target,
77
83
  signals,
78
84
  handlers,
@@ -85,6 +91,7 @@ export function createApp(appOrDefinition = Async, options = {}) {
85
91
  },
86
92
  loader,
87
93
  router,
94
+ attributes,
88
95
 
89
96
  start() {
90
97
  assertActive();
@@ -99,7 +106,8 @@ export function createApp(appOrDefinition = Async, options = {}) {
99
106
  signals,
100
107
  handlers,
101
108
  server,
102
- cache: browserCache
109
+ cache: browserCache,
110
+ attributes
103
111
  });
104
112
  runtime.loader = loader;
105
113
 
@@ -121,7 +129,8 @@ export function createApp(appOrDefinition = Async, options = {}) {
121
129
  cache: browserCache,
122
130
  partials,
123
131
  fetch: options.fetch,
124
- routeEndpoint: options.routeEndpoint
132
+ routeEndpoint: options.routeEndpoint,
133
+ attributes
125
134
  });
126
135
  runtime.router = router;
127
136
  loader.router = router;
@@ -148,7 +157,7 @@ export function createApp(appOrDefinition = Async, options = {}) {
148
157
  const matched = routes.match(url);
149
158
  if (!matched) {
150
159
  return {
151
- html: renderDocument("", { status: 404, signals, browserCache, boundary: options.boundary ?? "route" }),
160
+ html: renderDocument("", { status: 404, signals, browserCache, boundary: options.boundary ?? "route", attributes }),
152
161
  status: 404,
153
162
  signals: signals.snapshot(),
154
163
  cache: { browser: browserCache.snapshot() }
@@ -182,7 +191,7 @@ export function createApp(appOrDefinition = Async, options = {}) {
182
191
 
183
192
  const status = result.status ?? 200;
184
193
  return {
185
- html: renderDocument(result.html, { status, signals, browserCache, boundary: result.boundary ?? options.boundary ?? "route" }),
194
+ html: renderDocument(result.html, { status, signals, browserCache, boundary: result.boundary ?? options.boundary ?? "route", attributes }),
186
195
  status,
187
196
  signals: signals.snapshot(),
188
197
  cache: { browser: browserCache.snapshot() }
@@ -233,16 +242,25 @@ export function createApp(appOrDefinition = Async, options = {}) {
233
242
  export const Async = defineApp();
234
243
 
235
244
  function applyUseToRuntime(runtime, normalized) {
236
- runtime.signals.registerMany(normalized.signal);
237
- runtime.handlers.registerMany(normalized.handler);
238
- if (typeof runtime.server.registerMany === "function") {
239
- runtime.server.registerMany(normalized.server);
245
+ applyRegistryUse(runtime.signals, runtime.registry, normalized.signal);
246
+ applyRegistryUse(runtime.handlers, runtime.registry, normalized.handler);
247
+ applyRegistryUse(runtime.server, runtime.registry, normalized.server);
248
+ applyRegistryUse(runtime.partials, runtime.registry, normalized.partial);
249
+ applyRegistryUse(runtime.routes, runtime.registry, normalized.route);
250
+ applyRegistryUse(runtime.components, runtime.registry, normalized.component);
251
+ applyRegistryUse(runtime.browser.cache, runtime.registry, normalized.cache.browser);
252
+ applyRegistryUse(runtime.server.cache, runtime.registry, normalized.cache.server);
253
+ }
254
+
255
+ function applyRegistryUse(registry, runtimeRegistry, entries) {
256
+ if (!entries || Object.keys(entries).length === 0) {
257
+ return;
258
+ }
259
+ if (registry?.registry === runtimeRegistry) {
260
+ registry._adoptMany?.(entries);
261
+ return;
240
262
  }
241
- runtime.partials.registerMany(normalized.partial);
242
- runtime.routes.registerMany(normalized.route);
243
- runtime.components.registerMany(normalized.component);
244
- runtime.browser.cache.registerMany(normalized.cache.browser);
245
- runtime.server.cache.registerMany(normalized.cache.server);
263
+ registry?.registerMany?.(entries);
246
264
  }
247
265
 
248
266
  function emptyDeclarations() {
@@ -267,7 +285,7 @@ function normalizeUse(typeOrModule, entries) {
267
285
  if (!registryTypes.has(typeOrModule)) {
268
286
  throw new Error(`Unknown Async registry type "${typeOrModule}".`);
269
287
  }
270
- normalized[typeOrModule] = { ...(entries ?? {}) };
288
+ normalized[typeOrModule] = normalizeEntries(typeOrModule, entries);
271
289
  return normalized;
272
290
  }
273
291
 
@@ -284,7 +302,7 @@ function normalizeUse(typeOrModule, entries) {
284
302
  if (!registryTypes.has(type)) {
285
303
  throw new Error(`Unknown Async registry type "${type}".`);
286
304
  }
287
- normalized[type] = { ...(value ?? {}) };
305
+ normalized[type] = normalizeEntries(type, value);
288
306
  }
289
307
 
290
308
  return normalized;
@@ -292,38 +310,20 @@ function normalizeUse(typeOrModule, entries) {
292
310
 
293
311
  function appendDeclarations(target, source) {
294
312
  for (const type of registryTypes) {
295
- addEntries(target[type], source[type], type);
313
+ addEntries(target, type, source[type]);
296
314
  }
297
- addEntries(target.cache.browser, source.cache.browser, "cache.browser");
298
- addEntries(target.cache.server, source.cache.server, "cache.server");
315
+ addEntries(target, "cache.browser", source.cache.browser);
316
+ addEntries(target, "cache.server", source.cache.server);
299
317
  }
300
318
 
301
- function addEntries(target, source, label) {
319
+ function addEntries(registry, type, source) {
302
320
  for (const [id, value] of Object.entries(source ?? {})) {
303
- if (Object.hasOwn(target, id)) {
304
- throw new Error(`${label} "${id}" is already registered.`);
305
- }
306
- target[id] = value;
321
+ registry.register(type, id, value);
307
322
  }
308
323
  }
309
324
 
310
- function cloneDeclarations(source) {
311
- return {
312
- signal: { ...source.signal },
313
- handler: { ...source.handler },
314
- server: { ...source.server },
315
- partial: { ...source.partial },
316
- route: { ...source.route },
317
- component: { ...source.component },
318
- cache: {
319
- browser: { ...source.cache.browser },
320
- server: { ...source.cache.server }
321
- }
322
- };
323
- }
324
-
325
325
  function isAppHub(value) {
326
- return Boolean(value && typeof value.use === "function" && typeof value.snapshot === "function");
326
+ return Boolean(value && typeof value.use === "function" && typeof value.snapshot === "function" && value.registry);
327
327
  }
328
328
 
329
329
  function applySnapshot(signals, browserCache, snapshot = {}) {
@@ -353,6 +353,24 @@ function attachServerCache(server, cache) {
353
353
  }
354
354
  }
355
355
 
356
+ function normalizeEntries(type, entries = {}) {
357
+ if (type !== "signal") {
358
+ return { ...(entries ?? {}) };
359
+ }
360
+ const normalized = {};
361
+ for (const [id, value] of Object.entries(entries ?? {})) {
362
+ normalized[id] = normalizeSignalDeclaration(value);
363
+ }
364
+ return normalized;
365
+ }
366
+
367
+ function normalizeSignalDeclaration(value) {
368
+ if (value && typeof value === "object" && typeof value.subscribe === "function") {
369
+ return value;
370
+ }
371
+ return createSignal(value);
372
+ }
373
+
356
374
  function isLocalServerRegistry(server) {
357
375
  return typeof server?.registerMany === "function";
358
376
  }
@@ -361,14 +379,16 @@ function shouldStartRouter(routes, options) {
361
379
  return Boolean(options.routerOptions || options.mode || routes.entries().length > 0);
362
380
  }
363
381
 
364
- function renderDocument(routeHtml, { signals, browserCache, boundary }) {
382
+ function renderDocument(routeHtml, { signals, browserCache, boundary, attributes }) {
365
383
  const snapshot = {
366
384
  signals: signals.snapshot(),
367
385
  cache: {
368
386
  browser: browserCache.snapshot()
369
387
  }
370
388
  };
371
- return `<section data-async-boundary="${escapeAttribute(boundary)}">${routeHtml ?? ""}</section><script type="application/json" data-async-snapshot>${escapeScriptJson(snapshot)}</script>`;
389
+ const boundaryAttr = attributeName(attributes, "async", "boundary");
390
+ const snapshotAttr = attributeName(attributes, "async", "snapshot");
391
+ return `<section ${boundaryAttr}="${escapeAttribute(boundary)}">${routeHtml ?? ""}</section><script type="application/json" ${snapshotAttr}>${escapeScriptJson(snapshot)}</script>`;
372
392
  }
373
393
 
374
394
  function escapeAttribute(value) {
@@ -0,0 +1,52 @@
1
+ const defaultPrefixes = Object.freeze({
2
+ async: ["async:"],
3
+ class: ["class:"],
4
+ signal: ["signal:"],
5
+ on: ["on:"]
6
+ });
7
+
8
+ export function defineAttributeConfig(config = {}) {
9
+ return normalizeAttributeConfig(config);
10
+ }
11
+
12
+ export function normalizeAttributeConfig(config = {}) {
13
+ return {
14
+ async: normalizePrefixes(config.async, defaultPrefixes.async),
15
+ class: normalizePrefixes(config.class, defaultPrefixes.class),
16
+ signal: normalizePrefixes(config.signal, defaultPrefixes.signal),
17
+ on: normalizePrefixes(config.on, defaultPrefixes.on)
18
+ };
19
+ }
20
+
21
+ export function attributeName(attributes, type, name) {
22
+ return normalizeAttributeConfig(attributes)[type][0] + name;
23
+ }
24
+
25
+ export function readAttribute(element, attributes, type, name) {
26
+ for (const prefix of normalizeAttributeConfig(attributes)[type]) {
27
+ const attr = `${prefix}${name}`;
28
+ if (element.hasAttribute?.(attr)) {
29
+ return element.getAttribute(attr);
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
35
+ export function matchAttribute(name, attributes, type) {
36
+ for (const prefix of normalizeAttributeConfig(attributes)[type]) {
37
+ if (name.startsWith(prefix)) {
38
+ return name.slice(prefix.length);
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function normalizePrefixes(value, fallback) {
45
+ const prefixes = value == null ? fallback : Array.isArray(value) ? value : [value];
46
+ return prefixes.map((prefix) => {
47
+ if (typeof prefix !== "string" || prefix.length === 0) {
48
+ throw new TypeError("Attribute prefixes must be non-empty strings.");
49
+ }
50
+ return prefix;
51
+ });
52
+ }
package/src/cache.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
2
+
1
3
  const cacheDefinitionKind = Symbol.for("@async/framework.cacheDefinition");
2
4
 
3
5
  export function defineCache(options = {}) {
@@ -9,11 +11,12 @@ export function defineCache(options = {}) {
9
11
  };
10
12
  }
11
13
 
12
- export function createCacheRegistry(initialMap = {}, { now = () => Date.now() } = {}) {
13
- const definitions = new Map();
14
- const entries = new Map();
14
+ export function createCacheRegistry(initialMap = {}, { now = () => Date.now(), registry, type = "cache.browser" } = {}) {
15
+ const registryStore = registry ?? createRegistryStore();
16
+ const definitions = registryStore._map(type);
17
+ const entries = registryStore._map(`${type}.entries`);
15
18
 
16
- const registry = {
19
+ const registryApi = attachRegistryInspection({
17
20
  register(id, definition = defineCache()) {
18
21
  assertId(id);
19
22
  const normalized = normalizeDefinition(definition);
@@ -26,9 +29,9 @@ export function createCacheRegistry(initialMap = {}, { now = () => Date.now() }
26
29
 
27
30
  registerMany(map) {
28
31
  for (const [id, definition] of Object.entries(map ?? {})) {
29
- registry.register(id, definition);
32
+ registryApi.register(id, definition);
30
33
  }
31
- return registry;
34
+ return registryApi;
32
35
  },
33
36
 
34
37
  resolve(id) {
@@ -64,12 +67,12 @@ export function createCacheRegistry(initialMap = {}, { now = () => Date.now() }
64
67
  if (typeof fn !== "function") {
65
68
  throw new TypeError("cache.getOrSet(key, fn) requires a function.");
66
69
  }
67
- const cached = registry.get(key);
70
+ const cached = registryApi.get(key);
68
71
  if (cached !== undefined) {
69
72
  return cached;
70
73
  }
71
74
  const value = await fn();
72
- registry.set(key, value, options);
75
+ registryApi.set(key, value, options);
73
76
  return value;
74
77
  },
75
78
 
@@ -81,20 +84,20 @@ export function createCacheRegistry(initialMap = {}, { now = () => Date.now() }
81
84
  clear(prefix) {
82
85
  if (prefix === undefined) {
83
86
  entries.clear();
84
- return registry;
87
+ return registryApi;
85
88
  }
86
89
  for (const key of [...entries.keys()]) {
87
90
  if (key.startsWith(prefix)) {
88
91
  entries.delete(key);
89
92
  }
90
93
  }
91
- return registry;
94
+ return registryApi;
92
95
  },
93
96
 
94
97
  snapshot() {
95
98
  const snapshot = {};
96
99
  for (const [key] of entries) {
97
- const value = registry.get(key);
100
+ const value = registryApi.get(key);
98
101
  if (value !== undefined) {
99
102
  snapshot[key] = value;
100
103
  }
@@ -104,14 +107,26 @@ export function createCacheRegistry(initialMap = {}, { now = () => Date.now() }
104
107
 
105
108
  restore(snapshot = {}) {
106
109
  for (const [key, value] of Object.entries(snapshot ?? {})) {
107
- registry.set(key, value);
110
+ registryApi.set(key, value);
108
111
  }
109
- return registry;
112
+ return registryApi;
113
+ },
114
+
115
+ entryKeys() {
116
+ return [...entries.keys()];
117
+ },
118
+
119
+ entryEntries() {
120
+ return registryStore.entries(`${type}.entries`);
121
+ },
122
+
123
+ _adoptMany() {
124
+ return registryApi;
110
125
  }
111
- };
126
+ }, registryStore, type);
112
127
 
113
- registry.registerMany(initialMap);
114
- return registry;
128
+ registryApi.registerMany(initialMap);
129
+ return registryApi;
115
130
 
116
131
  function resolvePolicy(key, explicitId) {
117
132
  if (explicitId !== undefined) {
package/src/component.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { rawHtml, renderTemplate } from "./html.js";
2
+ import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
2
3
 
3
4
  const componentKind = Symbol.for("@async/framework.component");
4
5
  let componentCounter = 0;
@@ -16,10 +17,12 @@ export function defineComponent(fn) {
16
17
 
17
18
  export const component = defineComponent;
18
19
 
19
- export function createComponentRegistry(initialMap = {}) {
20
- const entries = new Map();
20
+ export function createComponentRegistry(initialMap = {}, options = {}) {
21
+ const registryStore = options.registry ?? createRegistryStore();
22
+ const type = options.type ?? "component";
23
+ const entries = registryStore._map(type);
21
24
 
22
- const registry = {
25
+ const registry = attachRegistryInspection({
23
26
  register(id, Component) {
24
27
  if (typeof id !== "string" || id.length === 0) {
25
28
  throw new TypeError("Component id must be a non-empty string.");
@@ -46,8 +49,12 @@ export function createComponentRegistry(initialMap = {}) {
46
49
  throw new TypeError("Component id must be a non-empty string.");
47
50
  }
48
51
  return entries.get(id);
52
+ },
53
+
54
+ _adoptMany() {
55
+ return registry;
49
56
  }
50
- };
57
+ }, registryStore, type);
51
58
 
52
59
  registry.registerMany(initialMap);
53
60
  return registry;
@@ -64,29 +71,46 @@ export function renderComponent(Component, props = {}, runtime, parentScope = "c
64
71
 
65
72
  const scope = `${parentScope}.${componentName(Component)}.${++componentCounter}`;
66
73
  const cleanups = [];
67
- const mountHooks = [];
74
+ const attachHooks = [];
68
75
  const visibleHooks = [];
76
+ const destroyHooks = [];
77
+ const bindingIds = [];
69
78
  const context = createComponentContext({
70
79
  runtime,
71
80
  scope,
72
81
  cleanups,
73
- mountHooks,
74
- visibleHooks
82
+ attachHooks,
83
+ visibleHooks,
84
+ destroyHooks
75
85
  });
76
86
 
77
87
  const output = Component.call(context, props);
78
- const html = renderTemplate(output);
88
+ const html = renderTemplate(output, {
89
+ attributes: runtime.attributes,
90
+ signals: runtime.signals,
91
+ bind(value) {
92
+ const id = runtime.loader?._registerBinding?.(value);
93
+ if (!id) {
94
+ throw new Error("Inline template bindings require an AsyncLoader.");
95
+ }
96
+ bindingIds.push(id);
97
+ return id;
98
+ }
99
+ });
79
100
 
80
101
  return {
81
102
  html,
82
- mount(target) {
83
- for (const hook of mountHooks) {
103
+ attach(target) {
104
+ for (const hook of attachHooks) {
84
105
  const cleanup = hook(target);
85
106
  if (typeof cleanup === "function") {
86
107
  cleanups.push(cleanup);
87
108
  }
88
109
  }
89
110
  },
111
+ mount(target) {
112
+ this.attach(target);
113
+ },
90
114
  visible(target, observeVisible) {
91
115
  for (const hook of visibleHooks) {
92
116
  const cleanup = observeVisible(target, hook);
@@ -96,15 +120,24 @@ export function renderComponent(Component, props = {}, runtime, parentScope = "c
96
120
  }
97
121
  },
98
122
  cleanup() {
123
+ while (destroyHooks.length > 0) {
124
+ destroyHooks.pop()?.();
125
+ }
99
126
  while (cleanups.length > 0) {
100
127
  cleanups.pop()?.();
101
128
  }
129
+ while (bindingIds.length > 0) {
130
+ runtime.loader?._releaseBinding?.(bindingIds.pop());
131
+ }
102
132
  }
103
133
  };
104
134
  }
105
135
 
106
- function createComponentContext({ runtime, scope, cleanups, mountHooks, visibleHooks }) {
136
+ function createComponentContext({ runtime, scope, cleanups, attachHooks, visibleHooks, destroyHooks }) {
107
137
  const { signals, handlers, loader, server, router, cache } = runtime;
138
+ const generatedHandlers = new WeakMap();
139
+ let generatedHandlerCounter = 0;
140
+ let generatedSignalCounter = 0;
108
141
  const context = {
109
142
  scope,
110
143
  signals,
@@ -115,6 +148,9 @@ function createComponentContext({ runtime, scope, cleanups, mountHooks, visibleH
115
148
  cache,
116
149
 
117
150
  signal(name, initial) {
151
+ if (arguments.length === 1) {
152
+ return signals.ensure(scoped(scope, `signal.${++generatedSignalCounter}`), name);
153
+ }
118
154
  return signals.ensure(scoped(scope, name), initial);
119
155
  },
120
156
 
@@ -143,31 +179,70 @@ function createComponentContext({ runtime, scope, cleanups, mountHooks, visibleH
143
179
  },
144
180
 
145
181
  handler(name, fn) {
146
- const id = scoped(scope, name);
147
- handlers.register(id, function runComponentHandler(handlerContext) {
148
- return fn.call({ ...context, ...handlerContext }, handlerContext);
149
- });
150
- return id;
182
+ if (typeof name === "function" && fn === undefined) {
183
+ const inlineFn = name;
184
+ if (generatedHandlers.has(inlineFn)) {
185
+ return generatedHandlers.get(inlineFn);
186
+ }
187
+ const id = registerScopedHandler(`handler.${++generatedHandlerCounter}`, inlineFn);
188
+ generatedHandlers.set(inlineFn, id);
189
+ return id;
190
+ }
191
+ if (typeof fn !== "function") {
192
+ throw new TypeError("this.handler(name, fn) or this.handler(fn) requires a function.");
193
+ }
194
+ return registerScopedHandler(name, fn);
151
195
  },
152
196
 
153
197
  render(Child, childProps = {}) {
154
198
  const child = renderComponent(Child, childProps, runtime, scope);
155
199
  cleanups.push(child.cleanup);
156
- mountHooks.push((target) => child.mount(target));
200
+ attachHooks.push((target) => child.attach(target));
157
201
  visibleHooks.push((target) => child.visible(target, loader._observeVisible));
158
202
  return rawHtml(child.html);
159
203
  },
160
204
 
205
+ on(eventName, fn) {
206
+ if (typeof eventName !== "string" || eventName.length === 0) {
207
+ throw new TypeError("Component lifecycle event must be a non-empty string.");
208
+ }
209
+ if (typeof fn !== "function") {
210
+ throw new TypeError(`Component lifecycle "${eventName}" requires a function.`);
211
+ }
212
+ const event = eventName === "mount" ? "attach" : eventName;
213
+ if (event === "attach") {
214
+ attachHooks.push((target) => fn.call(context, target));
215
+ return;
216
+ }
217
+ if (event === "visible") {
218
+ visibleHooks.push((target) => fn.call(context, target));
219
+ return;
220
+ }
221
+ if (event === "destroy") {
222
+ destroyHooks.push(() => fn.call(context));
223
+ return;
224
+ }
225
+ throw new Error(`Unsupported component lifecycle event "${eventName}".`);
226
+ },
227
+
161
228
  onMount(fn) {
162
- mountHooks.push((target) => fn.call(context, target));
229
+ context.on("attach", fn);
163
230
  },
164
231
 
165
232
  onVisible(fn) {
166
- visibleHooks.push((target) => fn.call(context, target));
233
+ context.on("visible", fn);
167
234
  }
168
235
  };
169
236
 
170
237
  return context;
238
+
239
+ function registerScopedHandler(name, fn) {
240
+ const id = scoped(scope, name);
241
+ handlers.register(id, function runComponentHandler(handlerContext) {
242
+ return fn.call({ ...context, ...handlerContext }, handlerContext);
243
+ });
244
+ return id;
245
+ }
171
246
  }
172
247
 
173
248
  function scoped(scope, name) {