@madojs/mado 0.7.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.
Files changed (41) hide show
  1. package/AGENTS.md +29 -3
  2. package/CHANGELOG.md +164 -1
  3. package/README.md +168 -242
  4. package/ROADMAP.md +174 -79
  5. package/TODO.md +8 -5
  6. package/dist/src/component.js +48 -3
  7. package/dist/src/component.js.map +1 -1
  8. package/dist/src/forms.js +21 -1
  9. package/dist/src/forms.js.map +1 -1
  10. package/dist/src/html/bindings.js +32 -3
  11. package/dist/src/html/bindings.js.map +1 -1
  12. package/dist/src/html/parser.js +60 -3
  13. package/dist/src/html/parser.js.map +1 -1
  14. package/dist/src/lifecycle.js +18 -0
  15. package/dist/src/lifecycle.js.map +1 -1
  16. package/dist/src/page.d.ts +12 -0
  17. package/dist/src/page.js.map +1 -1
  18. package/dist/src/persisted.js +43 -9
  19. package/dist/src/persisted.js.map +1 -1
  20. package/dist/src/resource.d.ts +10 -0
  21. package/dist/src/resource.js +24 -6
  22. package/dist/src/resource.js.map +1 -1
  23. package/dist/src/router/manifest.js +29 -3
  24. package/dist/src/router/manifest.js.map +1 -1
  25. package/dist/src/router/navigation.js +56 -2
  26. package/dist/src/router/navigation.js.map +1 -1
  27. package/dist/src/signal.js +55 -7
  28. package/dist/src/signal.js.map +1 -1
  29. package/docs/en/00-the-mado-way.md +23 -12
  30. package/docs/en/05-why-mado.md +78 -68
  31. package/docs/en/06-for-backenders.md +75 -55
  32. package/docs/en/07-llm-pitfalls.md +101 -0
  33. package/docs/fr/00-the-mado-way.md +25 -13
  34. package/docs/fr/07-llm-pitfalls.md +2 -0
  35. package/docs/ru/00-the-mado-way.md +24 -11
  36. package/docs/ru/07-llm-pitfalls.md +2 -0
  37. package/docs/uk/00-the-mado-way.md +3 -1
  38. package/docs/uk/07-llm-pitfalls.md +2 -0
  39. package/llms.txt +7 -5
  40. package/package.json +3 -3
  41. package/scripts/bundle.mjs +6 -6
@@ -1,22 +1,27 @@
1
1
  # Why Mado (and why not Lit / Solid / Alpine / htmx)
2
2
 
3
- > If you are choosing a frontend stack for a new project, this page is for you.
4
- > If you already have something working — **don't migrate for the sake of migration**, it always costs more than it seems.
3
+ > If you are choosing a frontend stack for an admin panel, internal tool or
4
+ > business SPA, this page is for you.
5
+ > If you already have something working — **don't migrate for the sake of
6
+ > migration**, it always costs more than it seems.
5
7
 
6
- Mado is not a "killer" of React/Vue/Svelte. It is a narrowly specialized tool. Here I honestly explain **in which cases Mado is genuinely better than the alternatives**, and in which it is not.
8
+ Mado is not a "killer" of React/Vue/Svelte. It is a focused tool for teams that
9
+ want a complete app stack (routing, forms, data, state, prerender) without
10
+ frontend infrastructure overhead. Here is an honest comparison of **when Mado is
11
+ genuinely better than the alternatives**, and when it is not.
7
12
 
8
13
  ---
9
14
 
10
15
  ## TL;DR — one table
11
16
 
12
- | If you care about… | Choose |
13
- |---|---|
14
- | Best learning infrastructure / huge ecosystem | **React** or **Vue** |
15
- | Component design system for embedding into any framework | **Lit** |
16
- | Top performance on large lists, "close to vanilla" with JSX | **Solid** or **Svelte 5** |
17
- | Progressive enhancement of classic server-rendered apps | **htmx** + your backend |
18
- | "Sprinkling" reactivity onto a static site | **Alpine.js** |
19
- | Minimal tooling, maximum platform, everything in one box (router + data + forms + SEO), readable in an evening | **Mado** ✓ |
17
+ | If you care about… | Choose |
18
+ | -------------------------------------------------------------------------------------------------------------- | ------------------------- |
19
+ | Best learning infrastructure / huge ecosystem | **React** or **Vue** |
20
+ | Component design system for embedding into any framework | **Lit** |
21
+ | Top performance on large lists, "close to vanilla" with JSX | **Solid** or **Svelte 5** |
22
+ | Progressive enhancement of classic server-rendered apps | **htmx** + your backend |
23
+ | "Sprinkling" reactivity onto a static site | **Alpine.js** |
24
+ | Minimal tooling, maximum platform, everything in one box (router + data + forms + SEO), readable in an evening | **Mado** ✓ |
20
25
 
21
26
  If your case does not fall into the last point — Mado is most likely not the best choice. That's fine.
22
27
 
@@ -26,21 +31,21 @@ If your case does not fall into the last point — Mado is most likely not the b
26
31
 
27
32
  **Lit** is the closest alternative in spirit. Same approach: Web Components + tagged templates + minimal magic.
28
33
 
29
- | | Lit | Mado |
30
- |---|---|---|
31
- | Size | ~6 KB | ~16 KB |
32
- | Age / support | ~10 years, Google | 6 months, single author |
33
- | Reactivity | `@property` decorators + manual `requestUpdate` | signals (`signal`/`computed`/`effect`) out of the box |
34
- | Router | none, you need to find one (`@lit-labs/router`, etc) | included: `routes()` + nested + prefetch + sync-cache |
35
- | Data fetching | none, you need to assemble it | `resource()` + `mutation()` + glob invalidation |
36
- | Forms | none | `useForm()` with HTML-like constraints |
37
- | SEO / static | complex (`@lit-labs/ssr`) | `bake` (linkedom) + edge-prerender |
38
- | Build | needs esbuild/rollup/webpack | `tsc` is enough |
39
- | Code style | classes + decorators | functions + tagged templates |
40
- | Ecosystem | real (Shoelace, Material Web, etc.) | none |
34
+ | | Lit | Mado |
35
+ | -------------- | -------------------------------------------------------------- | ------------------------------------------------------ |
36
+ | Size | ~6 KB | ~16 KB |
37
+ | Age / support | ~10 years, Google | 6 months, single author |
38
+ | Reactivity | `@property` decorators + manual `requestUpdate` | signals (`signal`/`computed`/`effect`) out of the box |
39
+ | Router | none, you need to find one (`@lit-labs/router`, etc) | included: `routes()` + nested + prefetch + sync-cache |
40
+ | Data fetching | none, you need to assemble it | `resource()` + `mutation()` + glob invalidation |
41
+ | Forms | none | `useForm()` with HTML-like constraints |
42
+ | SEO / static | complex (`@lit-labs/ssr`) | `bake` (linkedom) + edge-prerender |
43
+ | Build | needs esbuild/rollup/webpack | `tsc` is enough |
44
+ | Code style | classes + decorators | functions + tagged templates |
45
+ | Ecosystem | real (Shoelace, Material Web, etc.) | none |
41
46
  | When to choose | writing a design system / Web Components library for embedding | writing a full application, want everything in one box |
42
47
 
43
- **Honest pitch:** *"Lit is better if you're writing a component design system. Mado is better if you're writing an application and want batteries included without assembling 8 packages."*
48
+ **Honest pitch:** _"Lit is better if you're writing a component design system. Mado is better if you're writing an application and want batteries included without assembling 8 packages."_
44
49
 
45
50
  ---
46
51
 
@@ -48,21 +53,21 @@ If your case does not fall into the last point — Mado is most likely not the b
48
53
 
49
54
  **Solid** is a top-tier reactive library built on signals. Technically very impressive.
50
55
 
51
- | | Solid | Mado |
52
- |---|---|---|
53
- | Size | ~7 KB | ~16 KB |
54
- | Performance | top-3 on js-framework-benchmark | good, but not top |
55
- | Reactivity | signals (same class of ideas) | signals |
56
- | Templates | JSX (compiled to reactive expressions) | tagged template `html\`\`` |
57
- | Component model | functions, Solid virtual nodes | Web Components |
58
- | Build | Vite + babel-plugin-solid required | `tsc` only |
59
- | Router | `@solidjs/router` | included |
60
- | Data | `createResource` | `resource()` |
61
- | SSR | seriously supported (SolidStart) | intentionally none |
62
- | Ecosystem | growing, ~50 packages | none |
63
- | When to choose | need top performance + JSX + willing to configure the build | want to run without a build / minimal infrastructure |
64
-
65
- **Honest pitch:** *"Solid is technically faster and more mature. But Solid requires Vite + a babel plugin. Mado requires nothing but `tsc` — it's 'open VS Code, F5, and work'. If that difference isn't critical — go with Solid."*
56
+ | | Solid | Mado |
57
+ | --------------- | ----------------------------------------------------------- | ---------------------------------------------------- |
58
+ | Size | ~7 KB | ~16 KB |
59
+ | Performance | top-3 on js-framework-benchmark | good, but not top |
60
+ | Reactivity | signals (same class of ideas) | signals |
61
+ | Templates | JSX (compiled to reactive expressions) | tagged template `html\`\`` |
62
+ | Component model | functions, Solid virtual nodes | Web Components |
63
+ | Build | Vite + babel-plugin-solid required | `tsc` only |
64
+ | Router | `@solidjs/router` | included |
65
+ | Data | `createResource` | `resource()` |
66
+ | SSR | seriously supported (SolidStart) | intentionally none |
67
+ | Ecosystem | growing, ~50 packages | none |
68
+ | When to choose | need top performance + JSX + willing to configure the build | want to run without a build / minimal infrastructure |
69
+
70
+ **Honest pitch:** _"Solid is technically faster and more mature. But Solid requires Vite + a babel plugin. Mado requires nothing but `tsc` — it's 'open VS Code, F5, and work'. If that difference isn't critical — go with Solid."_
66
71
 
67
72
  ---
68
73
 
@@ -70,17 +75,17 @@ If your case does not fall into the last point — Mado is most likely not the b
70
75
 
71
76
  **Svelte 5** with runes — also a signal model, also minimalist.
72
77
 
73
- | | Svelte 5 | Mado |
74
- |---|---|---|
75
- | Runtime size | ~3 KB | ~16 KB |
76
- | Compiler | required (.svelte → JS) | none |
77
- | Syntax | custom .svelte format | TS + tagged templates |
78
- | Reactivity | `$state`/`$derived` (runes) | `signal`/`computed` |
79
- | SSR / SvelteKit | full-featured, mature | intentionally none |
80
- | Ecosystem | large, excellent dev-tools | none |
81
- | When to choose | new production project with a team | private/internal tool, need simplicity |
78
+ | | Svelte 5 | Mado |
79
+ | --------------- | ---------------------------------- | -------------------------------------- |
80
+ | Runtime size | ~3 KB | ~16 KB |
81
+ | Compiler | required (.svelte → JS) | none |
82
+ | Syntax | custom .svelte format | TS + tagged templates |
83
+ | Reactivity | `$state`/`$derived` (runes) | `signal`/`computed` |
84
+ | SSR / SvelteKit | full-featured, mature | intentionally none |
85
+ | Ecosystem | large, excellent dev-tools | none |
86
+ | When to choose | new production project with a team | private/internal tool, need simplicity |
82
87
 
83
- **Honest pitch:** *"Svelte is a product choice. Mado is an engineering one. If you have a team and a production app — Svelte. If you're alone and want control — Mado."*
88
+ **Honest pitch:** _"Svelte is a product choice. Mado is an engineering one. If you have a team and a production app — Svelte. If you're alone and want control — Mado."_
84
89
 
85
90
  ---
86
91
 
@@ -88,17 +93,17 @@ If your case does not fall into the last point — Mado is most likely not the b
88
93
 
89
94
  **htmx** is a different school: HTML-fragments over the wire.
90
95
 
91
- | | htmx | Mado |
92
- |---|---|---|
93
- | Architecture | HTML from server, updated via fragments | SPA: JS loads data, renders itself |
94
- | Backend dependency | strong (backend must be able to serve HTML) | weak (backend is a JSON API) |
95
- | Client state | minimal (cookies, localStorage) | full (signal, persisted) |
96
- | Optimistic updates | difficult | easy (mutation + invalidates) |
97
- | Offline / PWA | poor | decent |
98
- | Size | ~14 KB | ~16 KB |
99
- | When to choose | classic server-rendered app (Rails, Django, Phoenix), need to "liven up" | SPA experience is required, backend is REST/GraphQL |
96
+ | | htmx | Mado |
97
+ | ------------------ | ------------------------------------------------------------------------ | --------------------------------------------------- |
98
+ | Architecture | HTML from server, updated via fragments | SPA: JS loads data, renders itself |
99
+ | Backend dependency | strong (backend must be able to serve HTML) | weak (backend is a JSON API) |
100
+ | Client state | minimal (cookies, localStorage) | full (signal, persisted) |
101
+ | Optimistic updates | difficult | easy (mutation + invalidates) |
102
+ | Offline / PWA | poor | decent |
103
+ | Size | ~14 KB | ~16 KB |
104
+ | When to choose | classic server-rendered app (Rails, Django, Phoenix), need to "liven up" | SPA experience is required, backend is REST/GraphQL |
100
105
 
101
- **Honest pitch:** *"htmx — if the backend is solid and can serve HTML. Mado — if the backend serves JSON and you need a full SPA experience."*
106
+ **Honest pitch:** _"htmx — if the backend is solid and can serve HTML. Mado — if the backend serves JSON and you need a full SPA experience."_
102
107
 
103
108
  ---
104
109
 
@@ -106,16 +111,16 @@ If your case does not fall into the last point — Mado is most likely not the b
106
111
 
107
112
  **Alpine** — reactive attributes directly in HTML.
108
113
 
109
- | | Alpine | Mado |
110
- |---|---|---|
111
- | Purpose | enhancing static HTML | full SPA |
112
- | Size | ~7 KB | ~16 KB |
113
- | State management | `x-data` locally | signals + context + persisted |
114
- | Routing | none | included |
115
- | TypeScript | poor | first-class |
116
- | When to choose | static sites, landing pages, need 5 interactive buttons | full app: pages, navigation, forms, data |
114
+ | | Alpine | Mado |
115
+ | ---------------- | ------------------------------------------------------- | ---------------------------------------- |
116
+ | Purpose | enhancing static HTML | full SPA |
117
+ | Size | ~7 KB | ~16 KB |
118
+ | State management | `x-data` locally | signals + context + persisted |
119
+ | Routing | none | included |
120
+ | TypeScript | poor | first-class |
121
+ | When to choose | static sites, landing pages, need 5 interactive buttons | full app: pages, navigation, forms, data |
117
122
 
118
- **Honest pitch:** *"Alpine — for interactivity on static sites. Mado — for a full application."*
123
+ **Honest pitch:** _"Alpine — for interactivity on static sites. Mado — for a full application."_
119
124
 
120
125
  ---
121
126
 
@@ -124,12 +129,14 @@ If your case does not fall into the last point — Mado is most likely not the b
124
129
  I won't dwell on this for long, because React is in a **different weight class** in terms of ecosystem and maturity. But if you're seriously comparing:
125
130
 
126
131
  **React wins:**
132
+
127
133
  - massive ecosystem: thousands of UI kits, thousands of articles, endless tutorials;
128
134
  - AI assistants (ChatGPT, Copilot) know React better than anything;
129
135
  - better job market;
130
136
  - better SSR support (Next.js).
131
137
 
132
138
  **Mado wins:**
139
+
133
140
  - bundle size dozens of times smaller;
134
141
  - zero infrastructure (no Vite, no Babel, no 200 packages);
135
142
  - readable in an evening — if something breaks, open `src/`;
@@ -137,11 +144,13 @@ I won't dwell on this for long, because React is in a **different weight class**
137
144
  - no need to migrate between major versions.
138
145
 
139
146
  **When to choose Mado over React:**
147
+
140
148
  - 1–3 person project, for years to come;
141
149
  - bundle size is critical;
142
150
  - you're tired of React fatigue and are ready to sacrifice the ecosystem for simplicity.
143
151
 
144
152
  **When to choose React:**
153
+
145
154
  - team of 5 or more people;
146
155
  - you need UI kits, you need the ecosystem;
147
156
  - a project that will be hiring new people from the market;
@@ -166,6 +175,7 @@ For backend developers who are used to small, understandable libraries (chi in G
166
175
  Honestly: **Mado is not the fastest**. The top-3 on js-framework-benchmark are Solid, Inferno, and Svelte. Mado is closer to Lit / Preact in characteristics.
167
176
 
168
177
  What Mado does for performance out of the box:
178
+
169
179
  - **lazy `computed`** (dirty-flag, not eager);
170
180
  - **batch microtask scheduler** for `signal.set`;
171
181
  - **keyed reconciliation** in `each()` with real DOM reuse;
@@ -1,6 +1,7 @@
1
1
  # Mado for Backend Developers
2
2
 
3
- > You write in Go / Rust / .NET / Java / Python and you need to build a web UI.
3
+ > You write in Go / Rust / .NET / Java / Python and you need to build a web UI
4
+ > for an admin panel, internal tool or dashboard.
4
5
  > This page is the mental model of Mado in 10 minutes, in your language.
5
6
 
6
7
  ---
@@ -9,17 +10,17 @@
9
10
 
10
11
  Mado is structured **like an HTTP server**. Seriously:
11
12
 
12
- | Server world | Mado |
13
- |---|---|
14
- | HTTP router (chi, axum, mux) | `routes()` — path manifest |
15
- | Handler `func(req, resp)` | `page({ view: (ctx) => html\`...\` })` |
16
- | Middleware | `layout` in `nested()` (wraps the handler) |
17
- | Template engine (Jinja, Handlebars) | `html\`\`` tagged template |
18
- | HTTP client with cache | `resource()` — fetch + cache + invalidation |
19
- | Reactive variable / atom | `signal()` — reactive getter |
20
- | Background goroutine / task | `effect()` — auto-reruns when a signal changes |
21
- | `defer cleanup()` | `ctx.onDispose(fn)` in component setup |
22
- | ENV variables | `createContext()` + `provide()`/`inject()` |
13
+ | Server world | Mado |
14
+ | ----------------------------------- | ---------------------------------------------- |
15
+ | HTTP router (chi, axum, mux) | `routes()` — path manifest |
16
+ | Handler `func(req, resp)` | `page({ view: (ctx) => html\`...\` })` |
17
+ | Middleware | `layout` in `nested()` (wraps the handler) |
18
+ | Template engine (Jinja, Handlebars) | `html\`\`` tagged template |
19
+ | HTTP client with cache | `resource()` — fetch + cache + invalidation |
20
+ | Reactive variable / atom | `signal()` — reactive getter |
21
+ | Background goroutine / task | `effect()` — auto-reruns when a signal changes |
22
+ | `defer cleanup()` | `ctx.onDispose(fn)` in component setup |
23
+ | ENV variables | `createContext()` + `provide()`/`inject()` |
23
24
 
24
25
  If you understand an HTTP server, you understand Mado.
25
26
 
@@ -130,20 +131,23 @@ import { resource, mutation, jsonFetcher, invalidate } from "@madojs/mado";
130
131
  const userId = signal(1);
131
132
 
132
133
  const user = resource(
133
- () => `/api/users/${userId()}`, // cache key (reactive!)
134
- jsonFetcher<User>(), // how to load
135
- { staleTime: 60_000 }, // 60-second cache
134
+ () => `/api/users/${userId()}`, // cache key (reactive!)
135
+ jsonFetcher<User>(), // how to load
136
+ { staleTime: 60_000 }, // 60-second cache
136
137
  );
137
138
 
138
139
  // in the component:
139
- user.data(); // User | undefined
140
- user.error(); // Error | null
141
- user.loading(); // boolean
140
+ user.data(); // User | undefined
141
+ user.error(); // Error | null
142
+ user.loading(); // boolean
142
143
 
143
144
  // mutation (like POST/PUT)
144
145
  const save = mutation<User, User>(
145
- (u) => fetch("/api/users", { method: "POST", body: JSON.stringify(u) }).then(r => r.json()),
146
- { invalidates: ["/api/users*"] }, // glob invalidation like `cache.Drop("users:*")`
146
+ (u) =>
147
+ fetch("/api/users", { method: "POST", body: JSON.stringify(u) }).then((r) =>
148
+ r.json(),
149
+ ),
150
+ { invalidates: ["/api/users*"] }, // glob invalidation — like `cache.Drop("users:*")`
147
151
  );
148
152
 
149
153
  await save.run(newUser);
@@ -169,9 +173,7 @@ component("x-counter", () => {
169
173
  const count = signal(0);
170
174
 
171
175
  return () => html`
172
- <button @click=${() => count.update(n => n + 1)}>
173
- Clicks: ${count}
174
- </button>
176
+ <button @click=${() => count.update((n) => n + 1)}>Clicks: ${count}</button>
175
177
  `;
176
178
  });
177
179
  ```
@@ -179,7 +181,7 @@ component("x-counter", () => {
179
181
  Usage:
180
182
 
181
183
  ```ts
182
- html`<x-counter></x-counter>`
184
+ html`<x-counter></x-counter>`;
183
185
  ```
184
186
 
185
187
  We register the `<x-counter>` tag in the browser — it becomes a "function" that can be inserted into HTML. This is a **native** browser mechanism (Web Components), Mado only glues it together with signals.
@@ -195,22 +197,29 @@ import { useForm } from "@madojs/mado";
195
197
 
196
198
  const f = useForm({
197
199
  email: { required: true, type: "email" },
198
- age: { required: true, type: "number", min: 18 },
200
+ age: { required: true, type: "number", min: 18 },
199
201
  });
200
202
 
201
203
  // in the template:
202
204
  html`
203
- <form @submit=${f.onSubmit(async (v) => {
204
- await api.save(v);
205
- f.reset();
206
- })}>
207
- <input name="email" .value=${() => f.values().email ?? ""}
208
- @input=${f.onInput} @blur=${f.onBlur} />
209
-
210
- ${() => f.errors().email && f.touched().email
211
- ? html`<small>${f.errors().email}</small>`
212
- : null}
213
-
205
+ <form
206
+ @submit=${f.onSubmit(async (v) => {
207
+ await api.save(v);
208
+ f.reset();
209
+ })}
210
+ >
211
+ <input
212
+ name="email"
213
+ .value=${() => f.values().email ?? ""}
214
+ @input=${f.onInput}
215
+ @blur=${f.onBlur}
216
+ />
217
+
218
+ ${() =>
219
+ f.errors().email && f.touched().email
220
+ ? html`<small>${f.errors().email}</small>`
221
+ : null}
222
+
214
223
  <button ?disabled=${() => !f.isValid() || f.submitting()}>Save</button>
215
224
  </form>
216
225
  `;
@@ -233,12 +242,12 @@ const ApiCtx = createContext<ApiClient>(defaultApiClient);
233
242
  // in the root component — provide
234
243
  component("x-app", ({ host }) => {
235
244
  provide(host, ApiCtx, new ApiClient("https://api.example.com"));
236
- return () => html`<x-page/>`;
245
+ return () => html`<x-page />`;
237
246
  });
238
247
 
239
248
  // in any child — consume
240
249
  component("x-page", ({ host }) => {
241
- const api = inject(host, ApiCtx); // signal<ApiClient>
250
+ const api = inject(host, ApiCtx); // signal<ApiClient>
242
251
  return () => html`<div>API version: ${() => api().version}</div>`;
243
252
  });
244
253
  ```
@@ -255,7 +264,7 @@ If you're used to server-side rendering for SEO, in Mado this is solved differen
255
264
  // src/pages/product.ts
256
265
  export default page({
257
266
  bake: {
258
- paths: () => api.allProductSlugs(), // build-time fetch
267
+ paths: () => api.allProductSlugs(), // build-time fetch
259
268
  data: ({ slug }) => api.getProduct(slug),
260
269
  revalidate: 3600,
261
270
  },
@@ -264,7 +273,7 @@ export default page({
264
273
  canonical: `/product/${slug}`,
265
274
  og: { title: data.name, image: data.image },
266
275
  }),
267
- view: ({ params }) => html`<x-product data-slug=${params.slug}/>`,
276
+ view: ({ params }) => html`<x-product data-slug=${params.slug} />`,
268
277
  });
269
278
  ```
270
279
 
@@ -288,14 +297,20 @@ import { page, html, resource, each, signal } from "@madojs/mado";
288
297
  export default page({
289
298
  view: () => {
290
299
  const users = resource(() => "/api/users", jsonFetcher<User[]>());
291
-
300
+
292
301
  return html`
293
- ${() => users.loading() ? html`<p>Loading…</p>` : null}
294
- ${() => users.error() ? html`<p>Error: ${users.error()!.message}</p>` : null}
302
+ ${() => (users.loading() ? html`<p>Loading…</p>` : null)}
303
+ ${() =>
304
+ users.error() ? html`<p>Error: ${users.error()!.message}</p>` : null}
295
305
  <ul>
296
- ${() => each(users.data() ?? [], u => u.id, u => html`
297
- <li><a href="/users/${u.id}" data-link>${u.name}</a></li>
298
- `)}
306
+ ${() =>
307
+ each(
308
+ users.data() ?? [],
309
+ (u) => u.id,
310
+ (u) => html`
311
+ <li><a href="/users/${u.id}" data-link>${u.name}</a></li>
312
+ `,
313
+ )}
299
314
  </ul>
300
315
  `;
301
316
  },
@@ -308,7 +323,10 @@ export default page({
308
323
  import { useForm, mutation } from "@madojs/mado";
309
324
 
310
325
  const createUser = mutation<NewUser, User>(
311
- (u) => fetch("/api/users", { method: "POST", body: JSON.stringify(u) }).then(r => r.json()),
326
+ (u) =>
327
+ fetch("/api/users", { method: "POST", body: JSON.stringify(u) }).then((r) =>
328
+ r.json(),
329
+ ),
312
330
  { invalidates: ["/api/users*"] },
313
331
  );
314
332
 
@@ -316,11 +334,13 @@ const createUser = mutation<NewUser, User>(
316
334
  const f = useForm({ name: { required: true } });
317
335
 
318
336
  html`
319
- <form @submit=${f.onSubmit(async (v) => {
320
- await createUser.run(v);
321
- navigate("/users");
322
- })}>
323
- <input name="name" @input=${f.onInput}>
337
+ <form
338
+ @submit=${f.onSubmit(async (v) => {
339
+ await createUser.run(v);
340
+ navigate("/users");
341
+ })}
342
+ >
343
+ <input name="name" @input=${f.onInput} />
324
344
  <button>Create</button>
325
345
  </form>
326
346
  `;
@@ -353,8 +373,8 @@ export default routes({
353
373
  "/app/*": nested({
354
374
  layout: () => import("./layouts/auth-layout.js"),
355
375
  routes: {
356
- "dashboard": () => import("./pages/dashboard.js"),
357
- "users": () => import("./pages/users.js"),
376
+ dashboard: () => import("./pages/dashboard.js"),
377
+ users: () => import("./pages/users.js"),
358
378
  },
359
379
  }),
360
380
  });
@@ -367,7 +387,7 @@ export default routes({
367
387
  export class ApiClient {
368
388
  constructor(private base: string) {}
369
389
  get<T>(path: string): Promise<T> {
370
- return fetch(this.base + path).then(r => r.json());
390
+ return fetch(this.base + path).then((r) => r.json());
371
391
  }
372
392
  }
373
393
 
@@ -598,6 +598,105 @@ See [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md) for the full pattern.
598
598
 
599
599
  ---
600
600
 
601
+ ## Pitfall #23: signal reads in async functions called from `view()` create effect cycles
602
+
603
+ **Symptom:** `[mado] effect cycle detected: subscriber re-ran more than 100 times in one flush.`
604
+
605
+ The router calls `page.view()` inside a reactive effect. Any signal read
606
+ **synchronously** during `view()` subscribes the router's render effect. If that
607
+ signal is then written (e.g. `loading.set(true)`) — the router re-runs `view()`,
608
+ which reads the signal again → infinite loop.
609
+
610
+ ```ts
611
+ // ❌ INFINITE LOOP — loadMore reads signals inside the router's effect
612
+ export default page({
613
+ view: () => {
614
+ const cursor = signal<string | null>("start");
615
+ const loading = signal(false);
616
+
617
+ const loadMore = async () => {
618
+ if (cursor() === null || loading()) return; // ← subscribes render effect!
619
+ loading.set(true); // ← re-triggers render → loadMore() → ∞
620
+ const res = await fetch(`/api/items?cursor=${cursor()}`);
621
+ // ...
622
+ };
623
+
624
+ loadMore(); // called synchronously during view()
625
+ return html`...`;
626
+ },
627
+ });
628
+
629
+ // ✅ CORRECT — wrap signal reads in untracked()
630
+ import { untracked } from "@madojs/mado";
631
+
632
+ export default page({
633
+ view: () => {
634
+ const cursor = signal<string | null>("start");
635
+ const loading = signal(false);
636
+
637
+ const loadMore = async () => {
638
+ const c = untracked(() => cursor());
639
+ if (c === null || untracked(() => loading())) return;
640
+ loading.set(true);
641
+ const res = await fetch(`/api/items?cursor=${c}`);
642
+ // ...
643
+ };
644
+
645
+ loadMore();
646
+ return html`...`;
647
+ },
648
+ });
649
+ ```
650
+
651
+ **Rule:** Any function that reads signals AND is called synchronously during
652
+ `view()` initialization must use `untracked()` for those reads. This includes:
653
+
654
+ - Data fetching / loadMore functions
655
+ - IntersectionObserver callbacks set up during init
656
+ - Timer/polling setup functions that check state
657
+
658
+ Signals read inside the **returned template** (`html\`...\``) are fine — they are
659
+ wrapped in a child-binding function `${() => ...}` which creates its own effect.
660
+
661
+ ---
662
+
663
+ ## Pitfall #24: `setInterval` / manual subscriptions in `page()` view without cleanup
664
+
665
+ **Symptom:** After navigating away, timers/subscriptions keep running (zombie intervals,
666
+ server logs show polling requests from pages the user already left).
667
+
668
+ ```ts
669
+ // ❌ ZOMBIE — interval survives navigation
670
+ export default page({
671
+ view: () => {
672
+ const tick = signal(0);
673
+ setInterval(() => tick.update((n) => n + 1), 3000); // never cleaned up!
674
+ return html`<div>${tick}</div>`;
675
+ },
676
+ });
677
+
678
+ // ✅ CORRECT — use onDispose for cleanup
679
+ export default page({
680
+ view: ({ onDispose }) => {
681
+ const tick = signal(0);
682
+ const id = setInterval(() => tick.update((n) => n + 1), 3000);
683
+ onDispose(() => clearInterval(id));
684
+ return html`<div>${tick}</div>`;
685
+ },
686
+ });
687
+ ```
688
+
689
+ **Note:** `resource()` and `effect()` created inside `view()` are automatically
690
+ cleaned up on navigation (they register with the page lifecycle). Only raw
691
+ browser APIs need explicit `onDispose()`:
692
+
693
+ - `setInterval` / `setTimeout`
694
+ - `addEventListener` (on window/document)
695
+ - `WebSocket` / `EventSource`
696
+ - `IntersectionObserver` / `ResizeObserver`
697
+
698
+ ---
699
+
601
700
  ## Cheat-sheet for AI
602
701
 
603
702
  | If you want to do… | Correct in Mado |
@@ -619,5 +718,7 @@ See [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md) for the full pattern.
619
718
  | `@customElement('x')` | `component('x-name', setup)` |
620
719
  | `host.getAttribute('x')` in render | `ctx.attr('x', default)` (reactive) |
621
720
  | `jsonFetcher()` with auth | `apiFetcher()` (attaches Bearer token) |
721
+ | `setInterval` in page view | `onDispose(() => clearInterval(id))` |
722
+ | signal read in view() async init | `untracked(() => cursor())` |
622
723
 
623
724
  If something doesn't fit this list — open `src/` and **read 500 lines**. Seriously. Mado is intentionally small to be readable.