@async/framework 0.3.0 → 0.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-06-17
4
+
5
+ - Added `this.suspense(signalRef, views)` for component-owned async boundary
6
+ templates without adding a wrapper, rerender loop, hydration, or promise
7
+ throwing.
8
+ - Added `signal:prop:*` property bindings and tests for inline signal refs in
9
+ `signal:text`, `signal:attr:*`, and `signal:prop:*`.
10
+ - Added explicit `unregister(id)` APIs to runtime registries and component
11
+ cleanup for scoped signals, async signals, computed signals, and handlers.
12
+ - Added boundary swap cleanup for mounted component fragments and old DOM
13
+ bindings.
14
+ - Added server-call normalization for async signals, including returned signal
15
+ effects, proxy abort propagation, and stable server error messages.
16
+ - Added `prevent` as a command-event alias for `preventDefault`.
17
+
18
+ ## 0.4.0 - 2026-06-17
19
+
20
+ - Added a generated root `framework.js` ESM bundle for UNPKG browser imports.
21
+ - Expanded the README with Async layer definitions and an htmx comparison.
22
+ - Added `on:attach` as the canonical component attach lifecycle pseudo-event
23
+ with `on:mount` kept as a compatibility alias.
24
+ - Added top-level `class:*` bindings, including aggregate `class:`
25
+ string/object/array class sets.
26
+ - Added inline `html` template bindings for signal refs, class arrays/objects,
27
+ `value="${signalRef}"`, and generated component handlers via
28
+ `this.handler(fn)`.
29
+ - Added generated component-local signals via `this.signal(initial)`.
30
+
3
31
  ## 0.3.0 - 2026-06-17
4
32
 
5
33
  - Added a shared registry store behind `Async`, app runtimes, and concrete
package/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # @async/framework
2
2
 
3
- Layer 1 AsyncLoader plus small Layer 2 app, routing, server, cache, and SSR
4
- primitives for no-build web apps: signals, async signals, delegated command
5
- events, scoped fragment components, server calls, route partials, and
6
- out-of-order boundary swaps without a virtual DOM.
3
+ Async is a layered framework plan that starts as a no-build browser bootloader:
4
+ signals, async signals, delegated command events, scoped fragment components,
5
+ server calls, route partials, and out-of-order boundary swaps without a virtual
6
+ DOM.
7
7
 
8
8
  ```bash
9
9
  pnpm add @async/framework
@@ -43,10 +43,10 @@ Async.start({ root: document });
43
43
 
44
44
  ## What It Is
45
45
 
46
- `@async/framework` is the browser bootloader layer for Async apps. It keeps the
47
- runtime small and explicit:
46
+ `@async/framework` is the Layer 1 runtime plus the first Layer 2 app/server
47
+ primitives. It keeps the runtime small and explicit:
48
48
 
49
- - No build step for consumers.
49
+ - No build step for Layer 1 consumers.
50
50
  - No virtual DOM, diff path, hydration runtime, or component rerender loop.
51
51
  - Signals are the state boundary.
52
52
  - `Async.use(...)` registers app declarations before or after startup.
@@ -57,8 +57,27 @@ runtime small and explicit:
57
57
  - Boundaries can be swapped out of order and rescanned, which keeps server
58
58
  streaming and partial HTML replacement simple.
59
59
 
60
- Higher layers can still add JSX lowering, chunk manifests, server compilation,
61
- or resumability metadata later. Layer 1 stays plain HTML plus ESM.
60
+ Higher layers can add JSX lowering, TypeScript, chunk manifests, compiler-owned
61
+ server/client splits, and intent-first authoring later. They should compile down
62
+ to the same runtime registries and HTML protocol.
63
+
64
+ ## Layers
65
+
66
+ Async is designed as layers, so each level can stay useful without forcing the
67
+ next level on every app.
68
+
69
+ | Layer | Name | Requirement | Purpose |
70
+ | --- | --- | --- | --- |
71
+ | 1 | Runtime bootloader | No build. CDN or direct ESM import. | Signals, async signals, handlers, command events, lifecycle pseudo-events, scoped fragments, and boundary swaps. |
72
+ | 2 | App/server layer | Light server integration. No app compiler required. | `Async.use(...)`, router modes, server function proxy, partial registry, SSR output, browser activation, and split browser/server cache. |
73
+ | 3 | Authoring build | Build step required. | JSX, ESM, and TypeScript authoring that lowers into Layer 1 HTML attributes and Layer 2 registries. |
74
+ | 4 | Chunk and resumability metadata | Build metadata required. | Lazy module manifests, visibility/prefetch hints, resource graphs, and resumability records that the bootloader can consume. |
75
+ | 5 | Framework compiler | Compiler required. | Server/client partitioning, code motion, optimized registry generation, serialized closures, and deeper resumability transforms. |
76
+ | 6 | TSRX and intent layer | Higher-level compiler required. | More declarative author intent, AI/compiler-friendly metadata, and source forms that generate lower-layer Async apps. |
77
+
78
+ The package in this repository intentionally focuses on Layers 1 and 2. Layers
79
+ 3 through 6 are higher authoring surfaces, not extra runtime requirements for
80
+ plain HTML apps.
62
81
 
63
82
  ## Install
64
83
 
@@ -71,9 +90,9 @@ and package lifecycle tooling. Browser consumers import ESM directly.
71
90
 
72
91
  ## CDN
73
92
 
74
- The package is browser-ready ESM and can be loaded from UNPKG without a build
75
- step. Use `@latest` for quick prototypes, and pin an exact version in
76
- production:
93
+ The package ships a root `framework.js` ESM bundle for UNPKG and can be loaded
94
+ without a build step. Use `@latest` for quick prototypes, and pin an exact
95
+ version in production:
77
96
 
78
97
  ```html
79
98
  <main async:container>
@@ -85,7 +104,7 @@ production:
85
104
  import {
86
105
  Async,
87
106
  createSignal
88
- } from "https://unpkg.com/@async/framework@latest";
107
+ } from "https://unpkg.com/@async/framework@latest/framework.js";
89
108
 
90
109
  Async.use({
91
110
  signal: {
@@ -108,7 +127,7 @@ You can also use an import map so app code imports `@async/framework` by name:
108
127
  <script type="importmap">
109
128
  {
110
129
  "imports": {
111
- "@async/framework": "https://unpkg.com/@async/framework@latest"
130
+ "@async/framework": "https://unpkg.com/@async/framework@latest/framework.js"
112
131
  }
113
132
  }
114
133
  </script>
@@ -234,6 +253,7 @@ Naming rules:
234
253
  | `create*` | Runtime instance or mutable runtime primitive |
235
254
  | `Async.use(...)` | App-level declaration registration |
236
255
  | `registry.register(...)` | Low-level registration on a concrete runtime registry |
256
+ | `registry.unregister(...)` | Low-level removal from a concrete runtime registry |
237
257
 
238
258
  Singular registry keys are canonical: `signal`, `handler`, `server`,
239
259
  `partial`, `route`, `component`, and nested `cache.browser` / `cache.server`.
@@ -295,6 +315,7 @@ signals.set("count", 1);
295
315
  signals.update("count", (count) => count + 1);
296
316
  signals.subscribe("count", (count) => console.log(count));
297
317
  signals.ref("count").value;
318
+ signals.unregister("count");
298
319
  ```
299
320
 
300
321
  Initializer maps are supported:
@@ -365,10 +386,14 @@ AsyncLoader scans regular HTML attributes:
365
386
  | `on:click="selectProduct"` | Delegated command event |
366
387
  | `on:submit="preventDefault; save"` | Sequential command chain |
367
388
  | `on:click="server.cart.add(productId)"` | Server command with signal args |
389
+ | `on:attach="setup"` | Component root attach lifecycle pseudo-event |
390
+ | `on:visible="trackView"` | Component root visible lifecycle pseudo-event |
368
391
  | `signal:text="product.title"` | Text binding |
369
392
  | `signal:value="productId"` | Form value binding with writeback |
370
393
  | `signal:attr:disabled="product.$loading"` | Attribute binding |
371
- | `signal:class:selected="selected"` | Class toggle |
394
+ | `signal:prop:checked="selected"` | DOM property binding |
395
+ | `class:selected="selected"` | Class toggle from a signal path |
396
+ | `signal:class="buttonClasses"` | Class set from a signal value: string, object, or array |
372
397
  | `async:boundary="product"` | Async or streamed replacement boundary |
373
398
  | `async:loading="product"` | Boundary loading template |
374
399
  | `async:ready="product"` | Boundary ready template |
@@ -396,19 +421,110 @@ Async.start({
396
421
  root: document,
397
422
  attributes: {
398
423
  async: "data-async-",
424
+ class: "data-class-",
399
425
  signal: "data-signal-",
400
426
  on: "data-on-"
401
427
  }
402
428
  });
403
429
  ```
404
430
 
405
- That maps to `data-async-container`, `data-on-click="save"`, and
406
- `data-signal-text="product.title"`.
431
+ That maps to `data-async-container`, `data-on-click="save"`,
432
+ `data-signal-text="product.title"`, and `data-class-selected="selected"`.
433
+
434
+ Inside `html` templates, signal refs can be passed directly to binding
435
+ attributes:
436
+
437
+ ```js
438
+ const title = this.signal("Keyboard");
439
+ const disabled = this.signal(false);
440
+ const checked = this.signal(true);
441
+
442
+ return html`
443
+ <h1 signal:text="${title}"></h1>
444
+ <button signal:attr:disabled="${disabled}">Save</button>
445
+ <input type="checkbox" signal:prop:checked="${checked}">
446
+ `;
447
+ ```
448
+
449
+ Use `signal:value` for form value binding with writeback. Use `signal:prop:*`
450
+ when you only need one-way DOM property updates.
451
+
452
+ Named class toggles use their own top-level namespace:
453
+
454
+ ```html
455
+ <button
456
+ class="button"
457
+ class:selected="selected"
458
+ >
459
+ Add
460
+ </button>
461
+ ```
462
+
463
+ Aggregate class binding uses `signal:class`. It reads the current signal value
464
+ and accepts strings, objects, and arrays:
465
+
466
+ ```js
467
+ Async.use({
468
+ signal: {
469
+ buttonClasses: createSignal([
470
+ "button-primary",
471
+ { selected: true, disabled: false },
472
+ ["compact"]
473
+ ])
474
+ }
475
+ });
476
+ ```
477
+
478
+ ```html
479
+ <button signal:class="buttonClasses">Add</button>
480
+ ```
481
+
482
+ Inside `html` templates, `signal:class` can also receive objects or arrays
483
+ directly. Signal refs inside the object or array are tracked:
484
+
485
+ ```js
486
+ const selected = this.signal("selected", false);
487
+ const tone = this.signal("tone", "primary");
488
+
489
+ return html`
490
+ <article signal:class="${["card", tone, { selected }]}"}>
491
+ ...
492
+ </article>
493
+ `;
494
+ ```
495
+
496
+ For component-local state that does not need a stable public id, omit the name.
497
+ The signal is still registered under the component scope:
498
+
499
+ ```js
500
+ const selected = this.signal(false);
501
+ const tone = this.signal("primary");
502
+
503
+ return html`
504
+ <article signal:class="${["card", selected, tone]}">
505
+ ...
506
+ </article>
507
+ `;
508
+ ```
509
+
510
+ `value="${signalRef}"` in an `html` template is equivalent to adding
511
+ `signal:value` for that signal. It writes back on input/change:
512
+
513
+ ```js
514
+ const productId = this.signal("productId", "sku-1");
515
+
516
+ return html`<input value="${productId}">`;
517
+ ```
518
+
519
+ `signal:class:selected="selected"` remains supported as a compatibility alias,
520
+ but new examples should use `class:selected`. The parser-safe top-level
521
+ aggregate form `class:="buttonClasses"` also remains supported.
407
522
 
408
523
  ### Command Events
409
524
 
410
- `on:*` works with any native DOM event name. `on:mount` and `on:visible` are
411
- reserved pseudo-events with cleanup support.
525
+ `on:*` works with any native DOM event name. `on:attach` and `on:visible` are
526
+ reserved component lifecycle pseudo-events with cleanup support. `on:mount`
527
+ remains as a compatibility alias for `on:attach`.
412
528
 
413
529
  Command chains use semicolons and are awaited sequentially:
414
530
 
@@ -423,6 +539,7 @@ Plain commands resolve through the handler registry. Built-ins are registered by
423
539
  default:
424
540
 
425
541
  ```txt
542
+ prevent
426
543
  preventDefault
427
544
  stopPropagation
428
545
  stopImmediatePropagation
@@ -489,6 +606,12 @@ await server.cart.add("sku-1", 2);
489
606
 
490
607
  Server responses can include `value`, `signals`, `boundary`, `html`, `redirect`,
491
608
  or `error`. Signal patches are applied before boundary swaps and redirects.
609
+ Namespace calls such as `server.cart.add(...)` return the unwrapped `value`.
610
+
611
+ When an async signal calls a server namespace function, the framework passes the
612
+ active abort signal through proxy calls. Returned server effects such as
613
+ `signals`, `cache.browser`, `boundary/html`, and `redirect` are applied before
614
+ the async signal stores the unwrapped `value`.
492
615
 
493
616
  ### Router And Partials
494
617
 
@@ -674,21 +797,25 @@ type and no rerender loop.
674
797
 
675
798
  ```js
676
799
  const Toggle = defineComponent(function Toggle() {
677
- const selected = this.signal("selected", false);
678
- const toggle = this.handler("toggle", function () {
679
- selected.update((value) => !value);
800
+ const selected = this.signal(false);
801
+ const attach = this.handler("attach", function ({ element }) {
802
+ element.dataset.attached = "true";
680
803
  });
681
-
682
- this.onMount((target) => {
683
- target.dataset.mounted = "true";
804
+ const visible = this.handler("visible", function ({ element }) {
805
+ element.dataset.visible = "true";
684
806
  });
685
807
 
686
808
  return html`
687
809
  <button
688
810
  type="button"
689
- on:click="${toggle}"
690
- signal:class:selected="${selected.id}"
691
- signal:attr:aria-pressed="${selected.id}"
811
+ on:attach="${attach}"
812
+ on:visible="${visible}"
813
+ on:click="${this.handler(function () {
814
+ selected.update((value) => !value);
815
+ })}"
816
+ class:selected="${selected}"
817
+ signal:class="${["toggle", { active: selected }]}"
818
+ signal:attr:aria-pressed="${selected}"
692
819
  >
693
820
  Toggle
694
821
  </button>
@@ -705,17 +832,91 @@ Component helpers:
705
832
 
706
833
  | Helper | Behavior |
707
834
  | --- | --- |
708
- | `this.signal(name, initial)` | Scoped get-or-create signal |
835
+ | `this.signal(name, initial)` | Scoped named get-or-create signal |
836
+ | `this.signal(initial)` | Generated scoped local signal |
709
837
  | `this.computed(name, fn)` | Scoped computed signal |
710
838
  | `this.asyncSignal(name, fn)` | Scoped async signal |
711
839
  | `this.effect(fn)` | Scoped effect with cleanup |
712
- | `this.handler(name, fn)` | Scoped handler registry entry |
840
+ | `this.handler(name, fn)` | Scoped named handler registry entry |
841
+ | `this.handler(fn)` | Generated scoped handler registry entry |
713
842
  | `this.render(Component, props)` | Child fragment rendering |
714
- | `this.onMount(fn)` | One-shot mount hook |
715
- | `this.onVisible(fn)` | One-shot visibility hook |
843
+ | `this.suspense(signalRef, views)` | Async boundary template helper |
844
+ | `this.on(event, fn)` | Fragment lifecycle fallback for `attach`, `visible`, and `destroy` |
845
+ | `this.onMount(fn)` | Compatibility alias for `this.on("attach", fn)` |
846
+ | `this.onVisible(fn)` | Compatibility alias for `this.on("visible", fn)` |
847
+
848
+ `this.suspense(...)` is sugar for AsyncLoader boundaries:
849
+ `asyncSignal + async:boundary + async:* templates`. It emits only templates. The
850
+ caller owns the boundary element, and the loader chooses the loading, ready, or
851
+ error template from the async signal status.
852
+
853
+ ```js
854
+ const Product = defineComponent(function Product() {
855
+ const product = this.asyncSignal("product", async function () {
856
+ return this.server.products.get("sku-1");
857
+ });
858
+
859
+ return html`
860
+ <article async:boundary="${product.id}">
861
+ ${this.suspense(product, {
862
+ loading() {
863
+ return html`<p>Loading...</p>`;
864
+ },
865
+ ready(product) {
866
+ return html`<h1 signal:text="${product.id}.title"></h1>`;
867
+ },
868
+ error(product) {
869
+ return html`<p signal:text="${product.id}.$error.message"></p>`;
870
+ }
871
+ })}
872
+ </article>
873
+ `;
874
+ });
875
+ ```
876
+
877
+ The shorthand form treats the callback as the ready template:
878
+
879
+ ```js
880
+ this.suspense(product, (product) => html`
881
+ <h1 signal:text="${product.id}.title"></h1>
882
+ `);
883
+ ```
884
+
885
+ `this.suspense(...)` is not React Suspense. It does not throw promises,
886
+ hydrate, diff, rerender a component tree, or emit a wrapper element.
716
887
 
717
- `on:mount` and `on:visible` are loader pseudo-events with cleanup support. They
718
- do not drive component rerenders.
888
+ Component-scoped signals and handlers are unregistered when the mounted
889
+ fragment is destroyed. `loader.swap(...)` cleans up old DOM bindings and mounted
890
+ component fragments under the swapped boundary before inserting the new HTML.
891
+
892
+ Put component lifecycle on the component root element when there is one:
893
+
894
+ ```js
895
+ const attach = this.handler("attach", function ({ element }) {
896
+ element.dataset.attached = "true";
897
+ });
898
+ const visible = this.handler("visible", function ({ element }) {
899
+ element.dataset.visible = "true";
900
+ });
901
+
902
+ return html`<article on:attach="${attach}" on:visible="${visible}">...</article>`;
903
+ ```
904
+
905
+ If a component returns text or multiple root nodes, use the scoped fallback:
906
+
907
+ ```js
908
+ this.on("attach", (target) => {
909
+ target.dataset.attached = "true";
910
+ });
911
+
912
+ this.on("destroy", () => {
913
+ // Clean up fragment-scoped resources.
914
+ });
915
+ ```
916
+
917
+ `on:visible` is defined as a component lifecycle pseudo-event. It runs once when
918
+ the component root first becomes visible. Lifecycle events do not drive
919
+ component rerenders.
719
920
 
720
921
  ## Streaming
721
922
 
@@ -782,3 +983,22 @@ then runs release doctor.
782
983
  The core runtime is intentionally small. Bundling, lazy chunk manifests, JSX
783
984
  lowering, TSRX lowering, server resource compilation, and higher-level
784
985
  resumability metadata are deferred to later layers.
986
+
987
+ ## Async And htmx
988
+
989
+ Async and htmx are both HTML-first and avoid a virtual DOM, but they optimize
990
+ for different boundaries.
991
+
992
+ | Area | htmx | Async |
993
+ | --- | --- | --- |
994
+ | Primary model | HTML attributes issue HTTP requests and swap server responses. | HTML attributes bind signals, command events, server calls, and route boundaries. |
995
+ | State | Server-owned hypermedia state; browser state is intentionally minimal. | Browser signal registry plus server signal patches and cache snapshots. |
996
+ | Server interaction | DOM attributes describe HTTP verbs, targets, and swaps. | `server.*(...)` commands call registered server functions and apply returned effects. |
997
+ | Routing | Usually server navigation or htmx-boosted navigation. | CSR, SPA, SSR, SSR-SPA, and MPA router modes built around partial boundaries. |
998
+ | Components | Server-rendered HTML fragments. | Scoped fragment functions today; higher layers can compile JSX/TSRX later. |
999
+ | Build story | No build by default. | Layer 1 is no-build/CDN; higher layers can add build or compiler steps. |
1000
+
1001
+ Use htmx when the server should own most interaction through hypermedia and
1002
+ HTTP swaps. Use Async when you want an HTML-first runtime that also has local
1003
+ signals, async resources, registered browser/server handlers, route partials,
1004
+ and a path to higher compiler layers without changing the Layer 1 protocol.
@@ -1,21 +1,21 @@
1
1
  import { AsyncLoader, defineComponent, html } from "../../src/index.js";
2
2
 
3
3
  const Toggle = defineComponent(function Toggle() {
4
- const selected = this.signal("selected", false);
5
- const toggle = this.handler("toggle", function () {
6
- selected.update((value) => !value);
7
- });
8
-
9
- this.onMount(() => {
10
- document.body.dataset.toggleMounted = "true";
4
+ const selected = this.signal(false);
5
+ const attach = this.handler("attach", function ({ element }) {
6
+ element.dataset.attached = "true";
11
7
  });
12
8
 
13
9
  return html`
14
10
  <button
15
11
  type="button"
16
- on:click="${toggle}"
17
- signal:class:selected="${selected.id}"
18
- signal:attr:aria-pressed="${selected.id}"
12
+ on:attach="${attach}"
13
+ on:click="${this.handler(function () {
14
+ selected.update((value) => !value);
15
+ })}"
16
+ class:selected="${selected}"
17
+ signal:class="${["toggle", { active: selected }]}"
18
+ signal:attr:aria-pressed="${selected}"
19
19
  >
20
20
  Toggle
21
21
  </button>
@@ -56,7 +56,7 @@ serverApp.use({
56
56
  <article>
57
57
  <h1>${product.title}</h1>
58
58
  <p>${product.id}</p>
59
- <button type="button" on:click="ssrDemo.selectProduct" signal:class:selected="ssrDemo.selected">
59
+ <button type="button" on:click="ssrDemo.selectProduct" class:selected="ssrDemo.selected">
60
60
  Select
61
61
  </button>
62
62
  </article>
@@ -14,7 +14,7 @@ Async.use({
14
14
  `
15
15
  <article>
16
16
  <h1 signal:text="streamingDemo.title"></h1>
17
- <button type="button" on:click="streamingDemo.select" signal:class:selected="streamingDemo.selected">
17
+ <button type="button" on:click="streamingDemo.select" class:selected="streamingDemo.selected">
18
18
  Select
19
19
  </button>
20
20
  </article>