Meta Mado 0.15.3
Mado · LLM guide
Pitfalls that AI assistants (Copilot, Claude, ChatGPT, Cursor) hit when generating Mado code, and a practical zero-history test you can hand to a fresh model.
Mado is pre-1.0. Read the current version from the project's package.json;
do not infer contracts from an older model snapshot.
This document is for two audiences:
- AI agents in the IDE that read
AGENTS.md/.cursorrules/.github/copilot-instructions.md. The agent entrypoints are intentionally short; this file is the long version. - Humans who received code from an AI with these errors and don't understand what's wrong.
If anything below doesn't fit a real Mado codebase, open mado/src/ and read
the relevant source path together with its focused tests. Do not rely on a
remembered line count or an older model snapshot.
Part 1 — Pitfalls
Pitfall #1: ${signal()} instead of ${() => signal()}
Symptom: the value renders once and never updates.
const count = signal(0);
// ❌ AI often generates this
html`<div>Count: ${count() * 2}</div>`;
// → Renders "Count: 0" once. count() is read when the TemplateResult is built.
// ✅ Correct — getter function
html`<div>Count: ${() => count() * 2}</div>`;
// → Mado wraps this slot in an effect() and updates only the slot.
// ✅ Also correct — the signal itself is a function
html`<div>Count: ${count}</div>`;
Rule:
- If
${...}contains an expression (anything done with the signal) — wrap it in() => .... - If
${...}contains the signal itself — pass it as-is.
This applies to child bindings (text inside tags) and to attribute
values (@click, .prop, ?attr, regular attributes).
Pitfall #2: <button disabled=${loading}> instead of ?disabled
Symptom: the button is not disabled, or is always disabled.
const loading = signal(false);
// ❌ setAttribute("disabled", "false") — the DOM treats this as disabled
html`<button disabled=${loading()}>Save</button>`;
// ✅ boolean binding (toggles the attribute)
html`<button ?disabled=${loading}>Save</button>`;
| Prefix | What it does | When to use |
|---|---|---|
attr= |
setAttribute("attr", value) |
strings, numbers, URLs |
.attr= |
el.attr = value (DOM property) |
objects, arrays, input.value |
?attr= |
toggle attribute (by truthiness) | disabled, hidden, checked, etc. |
@evt= |
addEventListener("evt", fn) |
event handlers |
Pitfall #3: useState / useEffect style
Symptom: AI generates React-shaped code that doesn't work in Mado.
// ❌ AI often writes this
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => { console.log(count); }, [count]);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// ✅ Correct in Mado
import { component, signal, effect, html } from "@madojs/mado";
component("x-counter", () => {
const count = signal(0);
effect(() => console.log(count())); // auto-subscribes, auto-disposed
return html`
<button @click=${() => count.update(c => c + 1)}>${count}</button>
`;
});
Key differences:
- No hooks, no hook rules.
signal()can be created anywhere — setup, effect, handler.effect()discovers its dependencies on its own; no dependency array.- A component is
component("x-name", setup), not a JSX function.
Pitfall #4: effect() around a one-shot non-reactive resource
Symptom: AI creates an effect() only to imitate React mounting, even
though the resource does not depend on reactive state.
// ⚠️ Safe, but an unnecessary reactive scope when nothing is reactive
component("x-timer", () => {
effect(() => {
const id = setInterval(..., 1000);
return () => clearInterval(id);
});
return html`...`;
});
// ✅ Prefer onDispose for a one-shot raw browser resource
component("x-timer", (ctx) => {
const id = setInterval(..., 1000);
ctx.onDispose(() => clearInterval(id));
return html`...`;
});
An effect() return value is real lifecycle cleanup: Mado runs it before the
effect reruns and during final page/component lifecycle disposal. Use that
form when resource acquisition depends on reactive state:
// Inside component setup or a page view:
effect(() => {
const socket = new WebSocket(`/stream/${channel()}`);
return () => socket.close();
});
Use onDispose for a one-shot, non-reactive timer, listener, observer, socket
or other raw resource. The direct form communicates intent without creating an
unnecessary reactive scope.
Pitfall #5: Component as a class or with a decorator
Symptom: AI emits Lit-style or vanilla HTMLElement subclasses.
// ❌ "let's do it like Lit"
@customElement('x-counter')
class XCounter extends LitElement { ... }
// ❌ "let's do it vanilla"
class XCounter extends HTMLElement {
connectedCallback() { ... }
}
customElements.define("x-counter", XCounter);
// ✅ Functional component()
import { component, html, signal } from "@madojs/mado";
component("x-counter", () => {
const count = signal(0);
return html`<button @click=${() => count.update(n => n + 1)}>${count}</button>`;
});
Pitfall #6: applying no-bundler import rules to a Vite app
Symptom: AI rejects valid extensionless local imports or deep-imports framework internals while trying to manufacture a browser-resolvable path.
// ✅ Generated Mado apps use Vite; extensionless local imports are valid
import { foo } from "./bar";
import HomePage from "./pages/home.page";
// ✅ Use .js only in browser-native examples that run without Vite
import { foo } from "./bar.js";
// ❌ A deep package path ending in dist/src/signal.js is internal
// ✅ Import the public package surface
import { signal } from "@madojs/mado";
Vite is Mado's canonical development and build transport. Browser-native,
no-bundler ESM still requires resolvable file extensions, but that rule does
not make .js mandatory in generated source. Never import dist/src/* or
other Mado internals; use @madojs/mado and documented public subpaths.
Pitfall #7: lists via .map() without keys
Symptom: reordering items loses input focus / breaks CSS transitions / gets slow.
// ❌ Unkeyed — recreates DOM on every change
html`<ul>${() => items().map((t) => html`<li>${t.name}</li>`)}</ul>`;
// ✅ each() with a key function
import { each } from "@madojs/mado";
html`<ul>
${() =>
each(
items(),
(t) => t.id,
(t) => html`<li>${t.name}</li>`,
)}
</ul>`;
Use each() for any dynamic list with stable IDs. Reserve .map() for
static lists.
Pitfall #8: signal.value or count.get()
const count = signal(0);
// ❌ No such API
count.value;
count.value = 5;
count.get();
// ✅ Correct
count(); // read
count.set(5); // write
count.update((n) => n + 1);
count.peek(); // read without subscribing
Pitfall #9: provide(Ctx, value) without host
// ❌ AI forgets host
provide(ApiCtx, myApi);
inject(ApiCtx);
// ✅ First argument is the host (the current component)
component("x-app", ({ host }) => {
provide(host, ApiCtx, myApi);
return html`...`;
});
component("x-child", ({ host }) => {
const api = inject(host, ApiCtx); // Signal<value>
return html`...`;
});
Pitfall #10: expecting SSR
Symptom: AI writes code assuming pages are pre-rendered on a server.
// ❌ Don't do this — assumes universal/server rendering
if (typeof window !== "undefined") { ... }
Mado does not do SSR with hydration. Page views render in a real browser:
mado static opens the built application in Chromium at release time and
freezes the resulting HTML (including Shadow DOM via Declarative Shadow DOM).
On first paint the live app performs an atomic takeover of the snapshot —
not hydration, not reconciliation, not per-attribute diffing.
This means:
- ✅ Browser APIs belong directly in page views and component setup; do not add SSR-only branches around them.
- ⚠️ Vite imports route/page modules in Node as a discovery control plane. Keep module initialization environment-neutral and do not perform browser work at module top level.
- ❌ Don't wrap page-view logic in
typeof windowchecks copied from an SSR framework. - ❌ Don't use Next.js patterns (
getServerSideProps,headers()). - ⚠️
static.paths()andstatic.initialData()execute during Node discovery, while their module and callback code remain in the client bundle. Use environment-neutral code and keep it secret-free.
Pitfall #11: useForm() with a zod/yup resolver
// ❌ No such API
const f = useForm({ resolver: zodResolver(schema) });
// ✅ Native constraints in markup, reactive state in Mado
const f = useForm({
initial: { email: "", age: "" as number | "" },
});
html`<input name="email" type="email" required @input=${f.onInput} />`;
// ✅ Or a custom function when HTML5 isn't enough
const f = useForm({
initial: { name: "" },
validate: (values) => {
const errors: Record<string, string> = {};
if (values.name && /\d/.test(values.name as string)) {
errors.name = "Name must not contain digits";
}
return Object.keys(errors).length ? errors : null;
},
});
Pitfall #12: Tailwind / styled-components / CSS Modules
Symptom: AI suggests typical React CSS solutions.
Screens and layouts are page() definitions in the light DOM, so a global
stylesheet imported by main.ts reaches them. Autonomous Web Components use
Shadow DOM + css`` + CSS variables by default:
// Light-DOM screen — document-level utility classes apply
export default page({
view: () => html`
<section class="bg-white shadow-lg rounded-lg p-4">...</section>
`,
});
// Shadow-DOM component (default) — Tailwind does NOT reach inside.
// Style with css`` and customize from outside via ::part() / CSS variables.
component("x-button", () => html`<button><slot></slot></button>`, {
styles: css`
button {
background: var(--button-bg, #2563eb);
color: white;
padding: 0.5rem 1rem;
border-radius: 6px;
}
`,
});
The full mental model lives in 10-pages-and-components.md.
Pitfall #13: import * as Mado from "@madojs/mado"
Works, but duplicates names and tree-shakes poorly. Prefer named imports:
// ✅ Canonical
import { signal, html, component, css, page } from "@madojs/mado";
// ⚠️ Works, but heavy
import * as Mado from "@madojs/mado";
Mado.signal(0);
Pitfall #14: adding a runtime dependency
Mado is zero runtime deps by design. If AI proposes:
- lodash → use native JS (
Object.entries,structuredClone, etc.); - date-fns →
Intl.DateTimeFormat,Intl.RelativeTimeFormat; - uuid →
crypto.randomUUID(); - axios → native
fetch+jsonFetcher()from Mado; - classnames → template literal or an object map.
Runtime deps in user projects are fine. Runtime deps in @madojs/mado
core are not.
Pitfall #15: inline <style> inside page templates
// ❌ Works, but scales poorly and complicates cleanup
page({
view: () => html`
<style>.panel { padding: 1rem; }</style>
<section class="panel">...</section>
`,
});
// ✅ Keep screen markup in the page; put .panel in a global stylesheet
// imported once by src/main.ts.
page({
view: () => html`<section class="panel">...</section>`,
});
If the section is an autonomous reusable widget instead of screen content,
make it a component() and colocate its styles in css``. Layouts remain
page() wrappers using { child }; do not replace them with slotted custom
elements.
Pitfall #16: Shadow DOM links without data-link
Symptom: clicking a link inside a Web Component causes a full reload, or hover-prefetch never fires.
// ❌ Browser will perform a full reload
html`<a href="/tickets/42">Open</a>`;
// ✅ Router intercepts the click, even across Shadow DOM
html`<a href=${routeUrl("/tickets/42")} data-link>Open</a>`;
Mado finds the link via event.composedPath(), so data-link works
inside Shadow DOM. Hover-prefetch uses the same path. Use
data-no-prefetch to opt a single link out of prefetch.
routeUrl() resolves against import.meta.env.BASE_URL, so the same
code works at / or under a base path (/docs/).
Pitfall #17: standalone resource() without an owner
Symptom: AI creates a resource in module scope to "reuse" data between pages, but never disposes its key effect and invalidation listener.
// ❌ No lifecycle owner and no explicit cleanup
const tickets = resource(
() => "tickets",
() => api.listTickets(),
);
component("x-tickets", () => {
return html`${() => tickets.data()?.length ?? 0}`;
});
// An integration-owned standalone resource is valid only when its owner
// exposes and eventually invokes teardown:
export const disposeTicketsIntegration = () => tickets.dispose();
// ✅ Inside the component setup
component("x-tickets", () => {
const tickets = resource(
() => "tickets",
() => api.listTickets(),
);
return html`${() => tickets.data()?.length ?? 0}`;
});
This way invalidation subscriptions, abort controllers and effects are
disposed when the component disconnects. A resource intentionally owned by an
application integration may stay standalone, but that integration must call
its idempotent dispose().
Pitfall #18: assuming nested templates leak
Symptom: AI manually clears #app "to be safe" before mounting a
new view.
const view = signal(html`<x-home></x-home>`);
// ✅ Nested TemplateResult can be returned from a child binding
html`${view}`;
Replaced child bindings recursively dispose nested template instances and effects. This is a regression-tested guarantee since v0.3. If pages seem to accumulate, that's a core bug, not something you patch in user code.
Pitfall #19: global CSS utilities inside Shadow DOM
Symptom: the page looks unstyled: .page-head, .btn,
.form-grid and friends are not applied.
// ❌ .page-head is global, but x-dashboard defaults to Shadow DOM
component(
"x-dashboard",
() => html`
<header class="page-head">...</header>
<div class="metric-grid">...</div>
`,
);
// ✅ A route screen is a page and therefore already lives in Light DOM
export default page({
view: () => html`
<header class="page-head">...</header>
<div class="metric-grid">...</div>
`,
});
Rule of thumb: Light DOM for pages and route layouts that consume shared
layout/form/table CSS; Shadow DOM for autonomous widgets with their own
styles. Native markup plus an open-code CSS/bindings recipe is also valid when
no custom-element boundary is useful. Remember that <slot> only projects
children in Shadow DOM. See
10-pages-and-components.md.
Pitfall #20: host.getAttribute() in render = not reactive
// ❌ Read once during setup; external attribute changes do not update it
component("x-badge", ({ host }) => {
const variant = host.getAttribute("variant") ?? "default";
return html`<span class=${variant}>...</span>`;
});
// ✅ ctx.attr() returns a Signal<string> that updates via
// attributeChangedCallback
component("x-badge", ({ attr }) => {
const variant = attr("variant", "default");
return html`<span class=${() => `badge-${variant()}`}>...</span>`;
});
Never read host.getAttribute() / host.hasAttribute() inside render
for values that may change from outside.
Pitfall #21: Shadow DOM <button> not submitting forms
A <button> inside Shadow DOM is not part of the form owner
algorithm for a <form> in Light DOM — this is a spec limitation, not
a Mado bug.
// ❌ Inner <button type="submit"> cannot submit the parent Light-DOM form
component("x-button", () =>
html`<button type="submit"><slot></slot></button>`);
// ✅ Keep form semantics native; style the real control
html`<button class="button" type="submit">Save</button>`;
Do not recreate native form ownership in a visual button component. Use a Light-DOM component only when the component genuinely needs to render native controls into the parent form.
Pitfall #22: useForm() with Shadow DOM custom inputs
When a Shadow DOM input dispatches input, the browser retargets
e.target from the inner <input> to the host <x-input>. The host
is an HTMLElement and has no .name / .value — so useForm
silently sees undefined.
// ❌ Missing proxy properties
component("x-input", ({ attr }) => {
const name = attr("name", "");
return html`<input name=${name} />`;
});
// ✅ Proxy name + value back to the host
component("x-input", ({ host, attr }) => {
const name = attr("name", "");
Object.defineProperty(host, "name", {
get: () => host.getAttribute("name") ?? "",
configurable: true,
});
Object.defineProperty(host, "value", {
get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
configurable: true,
});
return html`<input name=${name} />`;
});
Again, full pattern in 14-forms.md.
Pitfall #23: expecting a direct signal read to stay reactive
Symptom: a value renders once and then never changes.
Page views and component setup are deliberately outside reactive tracking. They build one template for the active lifecycle. Reactivity belongs to the template slots, not to the function that created the template.
// ❌ Snapshot: count() is evaluated once while the view is created
export default page({
view: () => {
const count = signal(0);
return html`<output>${count()}</output>`;
},
});
// ✅ Pass the signal itself, or use a getter for an expression
export default page({
view: () => {
const count = signal(0);
return html`
<button @click=${() => count.update((value) => value + 1)}>
Count: ${count}
</button>
<output>Double: ${() => count() * 2}</output>
`;
},
});
Initialization code may read signals normally without subscribing the router
or causing a whole-view re-render. untracked() remains an advanced tool for
reading a signal inside your own effect() without making it a dependency;
it is not required merely because code runs during view().
Pitfall #24: setInterval / subscriptions in page() view without cleanup
Symptom: after navigating away, timers/subscriptions keep running (zombie polling, server logs show requests from pages the user already left).
// ❌ Interval survives navigation
export default page({
view: () => {
const tick = signal(0);
setInterval(() => tick.update((n) => n + 1), 3000); // never cleaned up
return html`<div>${tick}</div>`;
},
});
// ✅ onDispose for cleanup
export default page({
view: ({ onDispose }) => {
const tick = signal(0);
const id = setInterval(() => tick.update((n) => n + 1), 3000);
onDispose(() => clearInterval(id));
return html`<div>${tick}</div>`;
},
});
resource() and effect() created inside view() are cleaned up
automatically on navigation. Only raw browser APIs need explicit
onDispose:
setInterval/setTimeoutaddEventListeneronwindow/documentWebSocket/EventSourceIntersectionObserver/ResizeObserver
Pitfall #25: treating Mado UI as runtime or guessing lock ownership
Mado UI is copied source, not a browser package:
// ❌ There is no Mado UI runtime to ship
import { Badge } from "@madojs/ui";
// ✅ Import the application-owned file copied by the CLI
import "../components/mado-ui-badge.component";
Before generating a generic component or changing installed UI, inspect
mado-ui.json, .mado-ui.lock.json and the
live catalog. Lock format 2 records
explicitItems plus the direct dependencies captured for each installed item.
Those fields are ownership history. Never replace them with guesses from the
current registry graph.
A format-1 lock cannot say which items were requested directly. Ask for or recover the original roots, then use the explicit migration:
npx @madojs/ui@latest migrate badge dialog --dry-run
npx @madojs/ui@latest migrate badge dialog
Do not delete copied files manually. Remove an explicit root by reviewing the CLI's deterministic plan first:
npx @madojs/ui@latest remove dialog --dry-run
npx @madojs/ui@latest remove dialog
The command protects locally modified files and dependencies still reachable from another explicit root.
Part 2 — Cheat-sheet
| If you want to do… | Correct in Mado |
|---|---|
useState(0) |
signal(0) |
useEffect(() => {...}, [a, b]) |
effect(() => {...}) (auto-deps) |
one-shot raw resource in useEffect |
acquire directly + ctx.onDispose(cleanup) |
useMemo(() => x, [a]) |
computed(() => x) |
useCallback(fn, []) |
ordinary function |
useContext(Ctx) |
inject(host, Ctx) |
useQuery(['key'], fn) |
resource(() => 'key', fn) |
useMutation(fn) |
mutation(fn, { invalidates: [...] }) |
useRouter().push('/') |
navigate('/') |
useRouter().query.q |
queryParam('q') |
<input value={v} onChange={...}> |
<input .value=${v} @input=${...}> |
{items.map(x => ...)} |
${() => each(items, x => x.id, x => ...)} |
useForm({ resolver: zodResolver }) |
useForm({ initial, validate }) + native constraints |
class extends HTMLElement |
component('x-name', setup) |
@customElement('x') |
component('x-name', setup) |
host.getAttribute('x') in render |
ctx.attr('x', default) (reactive) |
jsonFetcher() with auth |
apiFetcher() (attaches Bearer token) |
setInterval in page view |
onDispose(() => clearInterval(id)) |
| reactive expression in a template | ${() => expression} |
Internal <a href> in components |
<a data-link href=${routeUrl("/x")}> |
| SSR / hydration | mado static (snapshot + atomic takeover) |
Part 3 — Zero-history evaluation test
A practical validation script: can a fresh LLM (no prior Mado context) write idiomatic Mado without falling back to React-shaped code?
Allowed context
For the first pass, give the agent only:
AGENTS.mdREADME.mddocs/en/40-llm-guide.md(this file)- specific files from
starters/default/src/when the agent asks for a complete small-app pattern - specific files from
starters/modular/src/only when the task already demonstrates a need for guarded zones or module boundaries
The agent may search targeted APIs in mado/src/ when blocked, but it
should not load the whole framework into context.
Task
Build a small ticket-admin SPA aimed at a solo / backend developer.
Required behaviour:
- routes:
/,/tickets,/tickets/new,/tickets/:id,*; - in-memory mock API with realistic async delays;
- list page using
resource(),queryParam()search + status filters,computed(), and keyedeach()rows; - create + edit flows with
useForm()+mutation()+invalidates; - local UI state with
signal(); - shared route chrome implemented as a
page()layout with{ child }, wired throughlayout()in the route manifest and rendered in Light DOM rather than through a slotted shell component; - a domain-specific
ticket-summaryrecipe plus an autonomousticket-status-badgecomponent that exercises scoped Shadow-DOM styles, slots and reactivectx.attr()state; - a smoke test importing the built example.
This test intentionally evaluates the core application API without assuming
that Mado UI has been initialized. In a real application, inspect
mado-ui.json, .mado-ui.lock.json and the catalog before recreating a
generic UI item. Never import @madojs/ui in browser code or infer explicit
roots for a legacy lock.
Failure checklist
After implementation, look for any of these and reject:
- JSX,
useState,useEffect, React/JSXref={...},Vue.ref(),$state, or class-style components (Mado'sref()directive is valid); ${signal()}or${signal() + 1}where a reactive child thunk is required;disabled=${...}instead of?disabled=${...};- dynamic lists rendered with unkeyed array mapping instead of
each(); - imports from internal Mado package paths instead of its public surfaces;
resource()created outside a page view/component setup without an explicit integration owner that callsdispose();- internal links without
data-linkandrouteUrl(); - new runtime dependencies or new public framework APIs;
- assumptions of SSR / hydration /
getServerSideProps-style hooks.
What the test is really measuring
The hardest documentation pressure point is lifecycle: it should
never look acceptable to create long-lived resource() instances at
module scope or in route code that never cleans up. If the agent does
that, the docs need a sharper warning, not a less strict test.