Errors
Every LemonadeJS error and warning carries a stable code, LJS-xxx,
with a one-line cause and a one-line fix in the message itself, designed
to be pattern-matched by tools and agents. explain(code) returns the
long-form documentation offline:
import { explain } from 'lemonadejs';
explain('LJS-302');
// "The bind directive needs the state object itself: bind="${name}"
// (not bind="name", which is a string, and not bind="${name.value}",
// which is a one-time snapshot). Create it with const name = state(""). "
The problem
Debugging is hypothesis-driven from error strings. Three diagnostic patterns recur in the field, in escalating cost:
- Warnings as prose. Prose warnings are human-readable but not stable across versions, not greppable as a stable identity, and carry no machine-checkable code a tool can branch on.
- Errors behind a URL. Error codes pointing to an external decoder put the message somewhere else; the long-form explanation may require a browser and a network connection.
- Errors without messages, the most expensive class. Some mistake
classes produce no runtime diagnostic, just a silent wrong value. The
author reads the code, sees nothing wrong, and iterates blindly. A
thrown
LJS-302with the fix in the message is repaired in one turn; a silent wrong value can consume an entire debugging session.
How LemonadeJS solves it
One code per mistake, fix included. Errors (fail) throw with their
code in dev and production; the code is the identity, stable across
versions. Warnings (warn) are dev-only tripwires for mistakes that
would otherwise be silent: a snapshot that looks like it was meant to be
live (LJS-202), a miscased callback that a component would silently
ignore (LJS-305), a value attribute fighting bind (LJS-304).
The deliberate exception is documented as a code too: in-place mutation
without touch() is silent by design, and LJS-201 names the price of
big-data mutation freedom.
explain() is offline documentation. Development builds carry the
full long-form text for every code; the entire table is declared behind
the dev flag (!DEV ? {} :), so production builds dead-code-
eliminate it completely: zero bytes shipped, nothing to configure.
A production explain() still answers: the short message plus a pointer
to the dev build and llms.txt. Which mode you get is decided by the
artifact you load: dist/lemonade.dev.js and the bundler development
exports condition give checks and full texts; dist/lemonade.min.js and
production bundles are stripped.
Codes gate publication. verify(Component) fails on any engine
warning, so a block that triggers LJS-202 in some prop combination
cannot enter the Studio registry. A warning is not advice here; it is a
failing check with a name. See Tests.
The table
Twenty-one codes, from src/errors.ts. This is the complete set:
| Code | Meaning |
|---|---|
| LJS-001 | Component is not a function |
| LJS-002 | Component must return a template created with html`...` |
| LJS-003 | mount() requires a DOM element as root |
| LJS-101 | Unexpected closing tag; check tag nesting |
| LJS-102 | Unclosed tag at the end of the template |
| LJS-104 | Unknown component; setComponents({ Card }), or embed by value: <${Card} /> |
| LJS-105 | Expression $ is not allowed in this position |
| LJS-201 | In-place mutation is silent; call state.touch() after mutating, or assign |
| LJS-202 | Slot holds a snapshot; wrap dynamic expressions: ${() => ...} |
| LJS-203 | Update loop detected; a state change keeps triggering itself |
| LJS-204 | Duplicate key in a list; keys must be unique for identity matching |
| LJS-205 | A template expression threw; contained, other updates continued |
| LJS-206 | resource fetcher reads its own data/loading/error; async update loop |
| LJS-301 | Event attributes require a function: onclick="${() => ...}" |
| LJS-302 | bind requires a state: bind="${state}" |
| LJS-303 | bind works on <input>, <textarea> and <select>; on components it is a prop |
| LJS-304 | bind owns the element value; remove the explicit value/checked attribute |
| LJS-305 | Event and callback names are lowercase: onclick, onchange, onsave |
| LJS-401 | Prop does not match its contract |
| LJS-402 | Unknown prop, not declared in the contract (with a did-you-mean hint) |
| LJS-501 | Sugar singletons: expose once, never touch; check the api in the contract |
LJS-205 names a behavior, not just a mistake: the failing expression is
contained: its DOM keeps the last good content while every other binding
in the pass still runs. The full containment story, and trace(), are in
Debugging.
Codes drive the engine
A stable code does more than help the session where it fires: it makes a mistake class countable. When the same code keeps appearing while real components are built, the engine absorbs the class instead of documenting a workaround. The Studio catalog left receipts:
-
LJS-203→subscribe()runs untracked. The gantt block bumped aversioncounter from a subscription;version.value++reads the state it writes, which inside a tracked context subscribed the callback to itself, an update loop. The fix was not a note in the docs: the engine now runs everysubscribe()callback untracked, pinned to its one source state. The very line that exposed the bug ships today, protected by the contract it forced (components/gantt):const refresh = () => { computeRange(); preview.value = null; // Safe since subscribe() runs callbacks untracked (the engine // guard born from this very line; it used to LJS-203 loop) version.value++; }; -
The same class via refs and setup. Ref callbacks and component setup bodies used to execute inside the enclosing binding’s tracked context, creating phantom subscriptions that surfaced as
LJS-203loops. The engine now wraps both inuntracked(). Whole class removed. -
Renderer-caused
focusout. Three blocks independently shipped the same bug (an editor committing on blur, a dropdown closing mid-open, a dock losing its click) when the renderer removed a focused element. The answer became an engine primitive,isDisposing(), used by seven Studio blocks today (see Destroy). -
Accidental silent mutation. Dev builds freeze state contents so an unintended mutation throws
LJS-201instead of doing nothing.
In practice
The loop an agent actually runs, with a real code:
// 1. generated code
html`<input bind="${name.value}" />`
// 2. mount throws: "LJS-302: bind requires a state: bind="${state}",
// got string in <input>"
// 3. the message IS the fix:
html`<input bind="${name}" />`
One turn, no source-diving, no hypothesis tree. The same loop works on
warnings because verify() converts them into failing checks: a block
that warns anywhere in its prop matrix fails its proof before it reaches
the registry.
Reference
import { explain } from 'lemonadejs';
explain(code) // long-form documentation, offline (dev build)
// prod: short message + pointer; unknown code → says so
- Errors throw with their code in every build; warnings are dev-only.
- Dev/prod is the artifact, not a flag:
lemonade.dev.js/developmentexports condition vslemonade.min.js/ production bundles. Production pays zero for any of this. - The complete code table also ships in
llms.txt: one request instead of eighteen lookups.