@async/framework 0.3.0 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-06-17
4
+
5
+ - Added a generated root `framework.js` ESM bundle for UNPKG browser imports.
6
+ - Expanded the README with Async layer definitions and an htmx comparison.
7
+ - Added `on:attach` as the canonical component attach lifecycle pseudo-event
8
+ with `on:mount` kept as a compatibility alias.
9
+ - Added top-level `class:*` bindings, including aggregate `class:`
10
+ string/object/array class sets.
11
+ - Added inline `html` template bindings for signal refs, class arrays/objects,
12
+ `value="${signalRef}"`, and generated component handlers via
13
+ `this.handler(fn)`.
14
+ - Added generated component-local signals via `this.signal(initial)`.
15
+
3
16
  ## 0.3.0 - 2026-06-17
4
17
 
5
18
  - 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>
@@ -365,10 +384,13 @@ AsyncLoader scans regular HTML attributes:
365
384
  | `on:click="selectProduct"` | Delegated command event |
366
385
  | `on:submit="preventDefault; save"` | Sequential command chain |
367
386
  | `on:click="server.cart.add(productId)"` | Server command with signal args |
387
+ | `on:attach="setup"` | Component root attach lifecycle pseudo-event |
388
+ | `on:visible="trackView"` | Component root visible lifecycle pseudo-event |
368
389
  | `signal:text="product.title"` | Text binding |
369
390
  | `signal:value="productId"` | Form value binding with writeback |
370
391
  | `signal:attr:disabled="product.$loading"` | Attribute binding |
371
- | `signal:class:selected="selected"` | Class toggle |
392
+ | `class:selected="selected"` | Class toggle from a signal path |
393
+ | `signal:class="buttonClasses"` | Class set from a signal value: string, object, or array |
372
394
  | `async:boundary="product"` | Async or streamed replacement boundary |
373
395
  | `async:loading="product"` | Boundary loading template |
374
396
  | `async:ready="product"` | Boundary ready template |
@@ -396,19 +418,92 @@ Async.start({
396
418
  root: document,
397
419
  attributes: {
398
420
  async: "data-async-",
421
+ class: "data-class-",
399
422
  signal: "data-signal-",
400
423
  on: "data-on-"
401
424
  }
402
425
  });
403
426
  ```
404
427
 
405
- That maps to `data-async-container`, `data-on-click="save"`, and
406
- `data-signal-text="product.title"`.
428
+ That maps to `data-async-container`, `data-on-click="save"`,
429
+ `data-signal-text="product.title"`, and `data-class-selected="selected"`.
430
+
431
+ Named class toggles use their own top-level namespace:
432
+
433
+ ```html
434
+ <button
435
+ class="button"
436
+ class:selected="selected"
437
+ >
438
+ Add
439
+ </button>
440
+ ```
441
+
442
+ Aggregate class binding uses `signal:class`. It reads the current signal value
443
+ and accepts strings, objects, and arrays:
444
+
445
+ ```js
446
+ Async.use({
447
+ signal: {
448
+ buttonClasses: createSignal([
449
+ "button-primary",
450
+ { selected: true, disabled: false },
451
+ ["compact"]
452
+ ])
453
+ }
454
+ });
455
+ ```
456
+
457
+ ```html
458
+ <button signal:class="buttonClasses">Add</button>
459
+ ```
460
+
461
+ Inside `html` templates, `signal:class` can also receive objects or arrays
462
+ directly. Signal refs inside the object or array are tracked:
463
+
464
+ ```js
465
+ const selected = this.signal("selected", false);
466
+ const tone = this.signal("tone", "primary");
467
+
468
+ return html`
469
+ <article signal:class="${["card", tone, { selected }]}"}>
470
+ ...
471
+ </article>
472
+ `;
473
+ ```
474
+
475
+ For component-local state that does not need a stable public id, omit the name.
476
+ The signal is still registered under the component scope:
477
+
478
+ ```js
479
+ const selected = this.signal(false);
480
+ const tone = this.signal("primary");
481
+
482
+ return html`
483
+ <article signal:class="${["card", selected, tone]}">
484
+ ...
485
+ </article>
486
+ `;
487
+ ```
488
+
489
+ `value="${signalRef}"` in an `html` template is equivalent to adding
490
+ `signal:value` for that signal. It writes back on input/change:
491
+
492
+ ```js
493
+ const productId = this.signal("productId", "sku-1");
494
+
495
+ return html`<input value="${productId}">`;
496
+ ```
497
+
498
+ `signal:class:selected="selected"` remains supported as a compatibility alias,
499
+ but new examples should use `class:selected`. The parser-safe top-level
500
+ aggregate form `class:="buttonClasses"` also remains supported.
407
501
 
408
502
  ### Command Events
409
503
 
410
- `on:*` works with any native DOM event name. `on:mount` and `on:visible` are
411
- reserved pseudo-events with cleanup support.
504
+ `on:*` works with any native DOM event name. `on:attach` and `on:visible` are
505
+ reserved component lifecycle pseudo-events with cleanup support. `on:mount`
506
+ remains as a compatibility alias for `on:attach`.
412
507
 
413
508
  Command chains use semicolons and are awaited sequentially:
414
509
 
@@ -674,21 +769,25 @@ type and no rerender loop.
674
769
 
675
770
  ```js
676
771
  const Toggle = defineComponent(function Toggle() {
677
- const selected = this.signal("selected", false);
678
- const toggle = this.handler("toggle", function () {
679
- selected.update((value) => !value);
772
+ const selected = this.signal(false);
773
+ const attach = this.handler("attach", function ({ element }) {
774
+ element.dataset.attached = "true";
680
775
  });
681
-
682
- this.onMount((target) => {
683
- target.dataset.mounted = "true";
776
+ const visible = this.handler("visible", function ({ element }) {
777
+ element.dataset.visible = "true";
684
778
  });
685
779
 
686
780
  return html`
687
781
  <button
688
782
  type="button"
689
- on:click="${toggle}"
690
- signal:class:selected="${selected.id}"
691
- signal:attr:aria-pressed="${selected.id}"
783
+ on:attach="${attach}"
784
+ on:visible="${visible}"
785
+ on:click="${this.handler(function () {
786
+ selected.update((value) => !value);
787
+ })}"
788
+ class:selected="${selected}"
789
+ signal:class="${["toggle", { active: selected }]}"
790
+ signal:attr:aria-pressed="${selected}"
692
791
  >
693
792
  Toggle
694
793
  </button>
@@ -705,17 +804,46 @@ Component helpers:
705
804
 
706
805
  | Helper | Behavior |
707
806
  | --- | --- |
708
- | `this.signal(name, initial)` | Scoped get-or-create signal |
807
+ | `this.signal(name, initial)` | Scoped named get-or-create signal |
808
+ | `this.signal(initial)` | Generated scoped local signal |
709
809
  | `this.computed(name, fn)` | Scoped computed signal |
710
810
  | `this.asyncSignal(name, fn)` | Scoped async signal |
711
811
  | `this.effect(fn)` | Scoped effect with cleanup |
712
- | `this.handler(name, fn)` | Scoped handler registry entry |
812
+ | `this.handler(name, fn)` | Scoped named handler registry entry |
813
+ | `this.handler(fn)` | Generated scoped handler registry entry |
713
814
  | `this.render(Component, props)` | Child fragment rendering |
714
- | `this.onMount(fn)` | One-shot mount hook |
715
- | `this.onVisible(fn)` | One-shot visibility hook |
815
+ | `this.on(event, fn)` | Fragment lifecycle fallback for `attach`, `visible`, and `destroy` |
816
+ | `this.onMount(fn)` | Compatibility alias for `this.on("attach", fn)` |
817
+ | `this.onVisible(fn)` | Compatibility alias for `this.on("visible", fn)` |
716
818
 
717
- `on:mount` and `on:visible` are loader pseudo-events with cleanup support. They
718
- do not drive component rerenders.
819
+ Put component lifecycle on the component root element when there is one:
820
+
821
+ ```js
822
+ const attach = this.handler("attach", function ({ element }) {
823
+ element.dataset.attached = "true";
824
+ });
825
+ const visible = this.handler("visible", function ({ element }) {
826
+ element.dataset.visible = "true";
827
+ });
828
+
829
+ return html`<article on:attach="${attach}" on:visible="${visible}">...</article>`;
830
+ ```
831
+
832
+ If a component returns text or multiple root nodes, use the scoped fallback:
833
+
834
+ ```js
835
+ this.on("attach", (target) => {
836
+ target.dataset.attached = "true";
837
+ });
838
+
839
+ this.on("destroy", () => {
840
+ // Clean up fragment-scoped resources.
841
+ });
842
+ ```
843
+
844
+ `on:visible` is defined as a component lifecycle pseudo-event. It runs once when
845
+ the component root first becomes visible. Lifecycle events do not drive
846
+ component rerenders.
719
847
 
720
848
  ## Streaming
721
849
 
@@ -782,3 +910,22 @@ then runs release doctor.
782
910
  The core runtime is intentionally small. Bundling, lazy chunk manifests, JSX
783
911
  lowering, TSRX lowering, server resource compilation, and higher-level
784
912
  resumability metadata are deferred to later layers.
913
+
914
+ ## Async And htmx
915
+
916
+ Async and htmx are both HTML-first and avoid a virtual DOM, but they optimize
917
+ for different boundaries.
918
+
919
+ | Area | htmx | Async |
920
+ | --- | --- | --- |
921
+ | Primary model | HTML attributes issue HTTP requests and swap server responses. | HTML attributes bind signals, command events, server calls, and route boundaries. |
922
+ | State | Server-owned hypermedia state; browser state is intentionally minimal. | Browser signal registry plus server signal patches and cache snapshots. |
923
+ | Server interaction | DOM attributes describe HTTP verbs, targets, and swaps. | `server.*(...)` commands call registered server functions and apply returned effects. |
924
+ | Routing | Usually server navigation or htmx-boosted navigation. | CSR, SPA, SSR, SSR-SPA, and MPA router modes built around partial boundaries. |
925
+ | Components | Server-rendered HTML fragments. | Scoped fragment functions today; higher layers can compile JSX/TSRX later. |
926
+ | Build story | No build by default. | Layer 1 is no-build/CDN; higher layers can add build or compiler steps. |
927
+
928
+ Use htmx when the server should own most interaction through hypermedia and
929
+ HTTP swaps. Use Async when you want an HTML-first runtime that also has local
930
+ signals, async resources, registered browser/server handlers, route partials,
931
+ 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>