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-302 with 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:

CodeMeaning
LJS-001Component is not a function
LJS-002Component must return a template created with html`...`
LJS-003mount() requires a DOM element as root
LJS-101Unexpected closing tag; check tag nesting
LJS-102Unclosed tag at the end of the template
LJS-104Unknown component; setComponents({ Card }), or embed by value: <${Card} />
LJS-105Expression $ is not allowed in this position
LJS-201In-place mutation is silent; call state.touch() after mutating, or assign
LJS-202Slot holds a snapshot; wrap dynamic expressions: ${() => ...}
LJS-203Update loop detected; a state change keeps triggering itself
LJS-204Duplicate key in a list; keys must be unique for identity matching
LJS-205A template expression threw; contained, other updates continued
LJS-206resource fetcher reads its own data/loading/error; async update loop
LJS-301Event attributes require a function: onclick="${() => ...}"
LJS-302bind requires a state: bind="${state}"
LJS-303bind works on <input>, <textarea> and <select>; on components it is a prop
LJS-304bind owns the element value; remove the explicit value/checked attribute
LJS-305Event and callback names are lowercase: onclick, onchange, onsave
LJS-401Prop does not match its contract
LJS-402Unknown prop, not declared in the contract (with a did-you-mean hint)
LJS-501Sugar 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-203subscribe() runs untracked. The gantt block bumped a version counter 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 every subscribe() 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-203 loops. The engine now wraps both in untracked(). 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-201 instead 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 / development exports condition vs lemonade.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.