@madojs/mado 0.8.0 → 0.9.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/AGENTS.md CHANGED
@@ -9,8 +9,9 @@
9
9
 
10
10
  ## Project at a glance
11
11
 
12
- - **Mado** — SPA framework built on Web Components + signals + tagged-template `html`.
13
- - No build system (only `tsc`), no runtime dependencies.
12
+ - **Mado** — a calm browser-native SPA framework for internal tools, admin panels and business apps.
13
+ - Built on Web Components + signals + tagged-template `html`.
14
+ - No build system beyond `tsc`, no runtime dependencies.
14
15
  - Small TypeScript core in `src/`; bundled/minified full API is roughly 11 KB gzip.
15
16
 
16
17
  ## HARD RULES — violation = bug
@@ -119,7 +120,11 @@ component("x-badge", ({ attr }) => {
119
120
  ```
120
121
 
121
122
  `ctx.attr(name, defaultValue?)` returns a `Signal<string>` that auto-updates.
122
- The attribute is automatically added to `observedAttributes`.
123
+ Internally Mado uses `observedAttributes` when available and a per-instance
124
+ `MutationObserver` fallback for attributes registered during `setup()`. This is
125
+ necessary because the browser reads `observedAttributes` once at
126
+ `customElements.define()` time — before any instance calls `setup()`. The
127
+ observer auto-disconnects on component removal via lifecycle cleanup.
123
128
 
124
129
  ### 5. Reactive value in template child position = function
125
130
 
@@ -202,6 +207,27 @@ export default page<{ id: string }>({
202
207
  - Each page is a **separate file** in `pages/` with `export default page({...})`.
203
208
  - Import via `() => import("./pages/foo.js")` — this enables code-splitting via ESM.
204
209
  - Programmatic navigation: `import { navigate } from "@madojs/mado"; navigate("/users/42")`.
210
+ - **`onDispose`** — cleanup hook for page views. Use for `setInterval`, `WebSocket`, `EventSource`. `resource()` and `effect()` are auto-cleaned.
211
+ - **`untracked()`** — required when reading signals inside async functions called synchronously from `view()`. Without it, the signal subscribes the router's render effect → infinite loop.
212
+
213
+ ```ts
214
+ // page with polling and cleanup
215
+ import { page, html, signal, untracked } from "@madojs/mado";
216
+ export default page({
217
+ view: ({ onDispose }) => {
218
+ const data = signal(null);
219
+ const poll = async () => {
220
+ // untracked: don't subscribe the router's render effect
221
+ const res = await fetch("/api/status");
222
+ data.set(await res.json());
223
+ };
224
+ const id = setInterval(poll, 5000);
225
+ onDispose(() => clearInterval(id)); // ← cleaned up on navigation
226
+ poll(); // initial call
227
+ return html`<div>${() => JSON.stringify(data())}</div>`;
228
+ },
229
+ });
230
+ ```
205
231
 
206
232
  ### 9. Data fetching — `resource()` / `mutation()`
207
233
 
package/CHANGELOG.md CHANGED
@@ -2,7 +2,118 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- Nothing yet.
5
+ _No unreleased changes._
6
+
7
+ ## 0.9.0 - 2026-06-12
8
+
9
+ Correctness release from the v1 tracker Phase A: C1-C8 are closed with focused
10
+ regression tests.
11
+
12
+ ### Changed
13
+
14
+ - **`mutation().run()` is concurrent by default (C6).** Previously a `run()`
15
+ aborted any previous in-flight run, so two quick submits of different entities
16
+ through one mutation cancelled the first POST client-side — its `invalidates`
17
+ never fired even though the server had likely applied it. Mutations are now
18
+ concurrent: each `run()` has its own `AbortController`, `loading` is an
19
+ in-flight counter (true until the last run settles), and aborting the previous
20
+ run is opt-in via `mutation(fetcher, { abortPrevious: true })` for
21
+ search-as-you-type. `reset()` aborts all in-flight runs. **Behavioural change**
22
+ (done before the v1 API freeze). Regression test:
23
+ `test/mutation-concurrent.test.mjs`.
24
+
25
+ ### Fixed
26
+
27
+ - **Lifecycle/router defect pack is closed (C8).** `onDispose()` registered
28
+ after a lifecycle was already disposed now runs immediately instead of being
29
+ dropped. SPA link interception now respects `target="_blank"` and `download`.
30
+ Same-path `#hash` navigation scrolls to its anchor instead of being swallowed
31
+ by signal deduplication. Guard redirects now have a per-tick loop detector
32
+ that reports and halts mutually-redirecting routes. Regression test:
33
+ `test/lifecycle-router-pack.test.mjs`.
34
+
35
+ - **Parser fails loudly instead of silently dropping bindings (C7).** A `${}`
36
+ slot inside a RAW_TEXT element (`<textarea>`/`<title>`/`<style>`/`<script>`)
37
+ was silently ignored — an LLM writing `<textarea>${draft}</textarea>` got
38
+ neither an error nor a render. And a nested `html\`<path …>\`` for `<svg>` was
39
+ parsed in the HTML namespace, producing an invisible element. The parser now
40
+ throws a clear, fixable error in both cases (the RAW_TEXT message points at
41
+ `.value=`; the SVG message says to keep SVG content in one `<svg>…</svg>`
42
+ template). A self-contained `<svg>` still works. Regression test:
43
+ `test/html-rawtext-svg.test.mjs`.
44
+
45
+ - **Forms: stale async validation no longer lands on a shifted field-array row
46
+
47
+
48
+ (C5).** `useForm().array()` mutations (`remove`/`move`/`replace`/…) shift
49
+ indices, but an in-flight `validateAsync` for e.g. `items.2.title` still
50
+ matched its per-path generation guard and wrote its result onto a row that had
51
+ moved — a red error "jumping" onto a neighbouring valid row after a delete.
52
+ Array writes now bump the validation generation for every in-flight path under
53
+ the array prefix, so stale results are discarded. Row identity remains
54
+ positional. Regression test: `test/forms-array-stale-async.test.mjs`.
55
+
56
+ - **`computed({ equals })` no longer breaks `batch()` atomicity (C4).** An
57
+
58
+ observed `equals`-computed recomputed eagerly inside `set()`, so during
59
+ `batch(() => { x.set(2); y.set(2) })` a computed reading both `x` and `y` ran
60
+ on half-applied state `(new x, old y)` — observing an inconsistent snapshot,
61
+ potentially notifying with it, and running once per `set()`. An observed
62
+ `equals`-computed invalidated inside a batch now defers its recompute+compare
63
+ to a queue drained at the end of the outermost `batch()`, so it runs once on
64
+ fully-applied state. Behaviour outside a batch is unchanged. Regression test:
65
+ `test/signal-batch-equals.test.mjs`.
66
+
67
+ - **`update()` reuses nested templates instead of recreating them (C3).** A
68
+
69
+ renderer returning a nested `html\`\`` (e.g. a conditional form block) rebuilt
70
+ its entire subtree on every change of any signal it read, because `renderChild`
71
+ always did clear + re-instantiate for a single `TemplateResult` — unlike
72
+ `each()` and `render()`, which compare `_strings` and patch in place. Focus and
73
+ `<input>` values inside such blocks were lost and listeners re-attached.
74
+ `renderChild` now reuses the existing instance via `update()` when the new
75
+ value is a single `TemplateResult` with matching `_strings`, preserving DOM
76
+ identity; a structurally different template still rebuilds. Regression test:
77
+ `test/update-nested-reuse.test.mjs`.
78
+
79
+ - **`persisted()` cross-tab sync no longer ping-pongs, and `destroy()` is
80
+
81
+ complete (C2).** For object/array values, every cross-tab message produced a
82
+ new structured-clone identity, so the publisher effect re-broadcast each
83
+ received value forever (a single change generated 80+ messages in the
84
+ regression test). Cross-tab values are now echo-suppressed by their serialized
85
+ form, so an arriving value is never re-published. `destroy()` previously only
86
+ closed the channel and cleared storage while leaving the write/publish effects
87
+ alive — so the next `set()` re-created the key; it now disposes both effects,
88
+ clears the debounce timer, and marks the signal inert. `persisted()` also
89
+ registers `destroy()` with the active component/page lifecycle, so a persisted
90
+ signal created inside `setup()` no longer leaks. Regression test:
91
+ `test/persisted-crosstab.test.mjs`.
92
+
93
+ - **`each()` reorder no longer destroys custom-element state (C1).** Moving a
94
+ connected node (which keyed `each()` does via `insertBefore`) fires
95
+ `disconnectedCallback` → `connectedCallback` synchronously, and the old
96
+
97
+ `disconnectedCallback` tore the component down immediately — re-running
98
+ `setup()` and wiping every signal/resource/timer plus focus and `<input>`
99
+ values on a shuffle. Teardown is now deferred to a microtask and cancelled if
100
+ the element is re-inserted in the same tick, so a keyed move preserves state;
101
+ a genuine removal still disposes. `each()` also uses `Node.prototype.moveBefore`
102
+ (Chrome 133+) when available, which relocates connected nodes without firing
103
+ lifecycle callbacks at all. Regression test: `test/each-component-state.test.mjs`.
104
+
105
+ ### Docs / planning
106
+
107
+
108
+ - **Road to v1 re-sequenced around correctness.** Added
109
+ [`MADO_V1_TRACKER.md`](./MADO_V1_TRACKER.md), the active task-by-task tracker
110
+ derived from the `FABLE_REPORT.md` audit: phase A `v0.9` (correctness fixes
111
+ C1–C8, TDD: reproducing test → fix), phase B `v0.10` (surface cleanup, explicit
112
+ exports map, API freeze map), phase C `v1.0-rc` (live demo + external
113
+ dogfooding), phase D `v1.0` (freeze). `ROADMAP.md` now gates its
114
+ product-surface milestones behind a new "Milestone 0"; `TODO.md` points at the
115
+ tracker and drops items it now owns (exports policy, size reporting).
116
+
6
117
 
7
118
  ## 0.8.0
8
119