Ramonda

Diagnostics

Ramonda reports the common mistakes below at runtime, in development only. Every message is wrapped in if (__DEV__), so a production build ships none of them. Nearly every check is stripped with its message; the exception is named where it applies (RML009), because a guard that ran only in development would protect the one build that was never exposed to a request.

They exist because these mistakes are silent. Almost every bug this framework has had produced a wrong result rather than an error: state landing on the wrong row, a click doing nothing, a subtree rendering into nodes nobody can see. None of them threw. A diagnostic is the framework saying the thing a stack trace never would.

Most are deduplicated by cause, so a mistake in a list of a thousand rows is reported once. Whether to dedupe is the reporting package's call, and it follows from what the fault depends on: a core diagnostic fires from the render path for a fixed piece of code, so the second report carries nothing new, while an RML miss depends on the data — the same line can miss for one record and land for the next, and collapsing those would hide the case that matters.

Error or warning says what is at stake, not how bad the code looks. An error means the end result is wrong — something renders the wrong thing, loses state, never becomes interactive, or hands you a value that is not what you asked for; the devtools panel raises its alert for these. A warning means the result is the same and the app just did more work to get there: a wasted render, a refetch, a listener re-attached.

The prefix says which package reported it: RMD is @ramonda/core, RMQ is @ramonda/query, RMF is @ramonda/form, RML is @ramonda/lens. They are listed apart below, because a reader who hits RMQ001 wants the query codes together and not one of them wedged between two core ones.

A code is stable forever and never reused. When a check is removed, its section stays and says retired — a reader who hits an old message in an old build still lands somewhere.


Capturing them

A diagnostic is also a record, so a devtools panel, a test, or a log collector can group and filter reports instead of parsing prose. A collector installs one function, and a reporting package finds it with no dependency on anything:

Every prefix arrives this way — RMD, RML, RMQ and RMF.

interface RamondaDiagnostic {
  /** Stable forever. The prefix says which package raised it. */
  code: string;
  /** Who emitted it — `"ramonda/lens"`. OpenTelemetry calls this `InstrumentationScope.name`. */
  scope: string;
  /** Mapping to OpenTelemetry SeverityNumber 5 · 9 · 13 · 17. */
  severity: "debug" | "info" | "warn" | "error";
  /** One sentence, human first. Interpolated values are fine — grouping is by `code`. */
  message: string;
  /** What to do instead. Always present for an `error`. */
  fix?: string;
  /** The values the message interpolated, structured. What a collector queries. */
  data?: Record<string, unknown>;
  /** Epoch millis. Sortable, comparable, locale-free. */
  time: number;
  /** Identifies the SOURCE of a fault. Absent means "never deduplicate this". */
  dedupKey?: string;
}

declare global {
  var __RAMONDA_DIAGNOSTICS__: ((record: RamondaDiagnostic) => void) | undefined;
}

globalThis.__RAMONDA_DIAGNOSTICS__ = (record) => {
  if (record.severity === "error") myCollector.alert(record);
};

If you have @ramonda/devtools, subscribe instead of assigning. The sink is one function, so an assignment replaces whoever was there — which is normally the panel's own bridge, and it then quietly stops filling. installDiagnostics shares one sink between any number of subscribers and hands back the uninstall:

import { installDiagnostics } from "@ramonda/devtools";

const stop = installDiagnostics((record) => myCollector.alert(record));

A subscriber sees every prefix. The panel's LOGS tab is the one place that differs: RMD rows reach it through core's own log channel, so the bridge does not carry them there a second time.

The assignment above is the protocol-level form, for a package that will not take a dependency to report a warning. Write it when that is the situation, and chain what was already there:

const previous = globalThis.__RAMONDA_DIAGNOSTICS__;
globalThis.__RAMONDA_DIAGNOSTICS__ = (record) => {
  previous?.(record);
  myCollector.alert(record);
};

Four rules make it work across packages that share no code:

  • globalThis, not an event on window. The same line runs in the browser, in Node, in a worker and during a server render. A reporting package needs no dependency and no DOM.
  • A collector is optional. With nothing installed, the call is one property read and the message still goes to the console. Installing a sink adds a consumer; it does not silence the console.
  • Read tolerantly. Ignore fields you do not know, and assume only code, scope, severity, message and time. That is what lets the record grow without a version on every one of them.
  • data holds values, not live objects. A collector keeps a bounded history, and a record holding a component or a DOM node would keep it alive for as long as that history does.

The field names line up with OpenTelemetry's log data model, so bridging to a collector is a rename rather than a redesign. Anything can emit into this channel — the contract is the shape above and the name of the sink, not a package to depend on.

On the server the sink is process-wide, so a collector sees every concurrent request at once; there is no per-request attribution in the record.


Core — RMD

RMD001 — State written during render()

render() must be a function of state, not a place that changes it. A write there schedules another render from inside the render that caused it.

Move it to @created if it is initialisation, or to an event handler if it is a response to something. If it is a value derived from other state, that is @compute.

RMD002 — Duplicate key in a child list

Two siblings claiming the same identity. The diff will match one of them to the wrong node, and the symptom is state appearing on the wrong row.

The real fix is usually to stop writing keys: list() derives identity from the items themselves and cannot collide.

RMD003 — Context consumed without a provider above it

The consumer fell back to the default declared in createContext. Reported when the component mounts, before anything has read a value — so a panel behind a condition nobody has clicked still says so, and the report names the component the provider has to go above.

Either add that provider, or — if the default really is the answer when nobody provides one — declare the context optional. See Context.

RMD004 — Props mutated by the receiving component

Props belong to the parent. The assignment throws, in every build. Swallowing it instead lets the mistake hide: you read the value back, get the old one, and have nothing telling you the write was dropped.

Copy it into @state, or take a callback prop and ask the parent to change it. See Props.

RMD005 — Array in state mutated in place

this.items.push(x) does not re-render: the signal compares values, and the array you pushed into is the same array.

Replace it: this.items = [...this.items, x].

An object changed in place is the same fault and is reported as RMD048.

RMD006 — Timer still running after unmount

A setInterval or setTimeout outlived its component, so it will fire into something that no longer exists.

Use @interval / @timeout, which are cleared on unmount and cannot leak.

RMD007 — Server and client rendered different output

Hydration found the client rendering something other than what the server sent, so nodes were replaced instead of adopted.

Usually a value that differs by nature (Date, Math.random), a browser-only API read during a render, or a branch on the environment inside render(). See hydration mismatches for the two-pass pattern that fixes it.

RMD008 — State changed after the component was unmounted

A fetch that resolved after the user navigated away, most often. The update is dropped — in production too, not only in development — so it cannot render into a detached tree.

Cancel the work in @destroyed, or check before writing.

RMD009 — Update loop

A component kept re-rendering without settling. Two @updated methods writing what the other reads is the usual cause; a write inside render() is the other.

The guard stops it rather than only reporting it, because a synchronous loop freezes the tab. Production has a blunter version of the same stop that throws — a frozen tab is a worse outcome than an error, and leaves nothing to debug.

What a runaway does in production

Two counters, and they are the only errors the framework raises in a production build that can take a page down. Both are deliberate: the alternative is a tab that stops responding.

what it countswhen it throws
MAX_BUILDS_PER_DRAINcomponents rebuilt in one drain100 000
MAX_WORK_PER_FLUSH@mount callbacks in one flush100 000

The message names the last component in the loop, which is where to look first, though the cause may be any component it updates. Neither is reachable by an app that settles: a hundred thousand builds in a single tick is a loop, not a busy page. In development this code is reported by name long before either counter is approached.

Note that a single effect writing what it reads does not loop: the framework detaches a signal an effect mutated itself. See Subscriptions.

RMD010 — The default host is not allowed in this parent

<table>, <tbody>, <tr>, <select> and <svg> reject unknown children — the browser's parser moves or deletes them, so the component is destroyed or split in two.

Become the element the parent expects: @Host("tbody"), @Host("tr"), @Host("td"), @Host("option"), @Host("g"). See the host element.

RMD011 — A function was used as a JSX tag

A tag that is not an element, which breaks the rule the framework is built on. TypeScript rejects it; this fires when the build has no types.

If you wanted vnodes from a function, call it as an expression — {rows()}. If you wanted state and lifecycle without an element, that is a Hook.

RMD012 — retired

Superseded by list(), which prevents the problem structurally rather than reporting it.

RMD013 — A list item produced nothing

The render callback returned nothing for an item. A callback that returned something which is not an element is RMD031 instead.

Give it something to render for that item, or filter the item out of the array before it gets there. See lists.

RMD014 — retired

list() took an options bag with as and render in it, and this fired when both or neither was given. The bag is gone: the second argument is the component or the function, so neither mistake can be written.

RMD015 — A hook's props assigned by the hook that received them

A hook's props belong to whoever called this.use(...). The assignment throws, exactly like a write to a component's props (RMD004) — one rule for read-only inputs, not two.

See writing a hook.

RMD016 — A component updated while its element is not in the document

Something removed the component's DOM without telling the framework, so it is still mounted: its timers still fire, its listeners are still attached, and every render goes into nodes nobody can see.

Ramonda's own removals are safe. This comes from outside — a ref handed to a library that replaces the node, an app embedded in a page whose host removed the mount point, a hand-written innerHTML. Call unmount(container) before the DOM goes away.

If the tree is detached on purpose and will be re-inserted, this is expected.

RMD017 — A deferred hydration never resumed

A component returned a promise from @deferHydration, so the client adopted the server's markup and left the subtree untouched, waiting. The promise never settled.

The page therefore looks finished — the content is on screen, correct and complete — but nothing in that subtree responds. Usually a dynamic import that neither resolves nor rejects: a chunk removed by a deploy, a request that hangs.

Make the promise settle. A rejected promise still releases the subtree; only one that never settles leaves it frozen.

RMD018 — State written during a @compute

A @compute derives a value and returns it. Writing reactive state while it derives is worse than the same write in render() (RMD001): if the compute reads the signal it wrote, it invalidates its own cache and recomputes forever; if it reads another, every read of the compute now fires that signal's listeners too, re-rendering whatever only wanted a derived value.

To produce a value, return it. To cause an effect, use an event handler or a subscription. To count runs or otherwise instrument the compute, use a plain (non-@state) field — render re-runs on the same changes and reads its latest value.

RMD019 — State set to a value that cannot be serialized

@state travels to the client in the hydration blob as JSON, so it can only hold JSON-serializable data. Assigning a function, a symbol, or a bigint to a @state field is flagged the moment it happens — at the field initializer (@state x = …) or a later write — because JSON has no way to carry it: a function and a symbol are dropped silently, a bigint makes serialization throw. Either way the client would come alive with that field missing.

@state onPick = () => {};   // ✗ a function — not serializable
@state total = 42n;         // ✗ a bigint

Keep behaviour off state: a function is a method on the class, or a prop passed in (props are not serialized — the parent re-supplies them on the client). A symbol or bigint should be a string or number in state. And if the field is genuinely client-only and never meant to travel, it should be a plain field, not @state. Deeper cases — a Map, a Date, a circular object — are caught by the server renderer when it serializes, rather than at the write.


RMD020 — render() produced a different value the second time

A development build renders every component twice and compares the two outputs. Two calls in the same tick, with no state change between them, must produce the same values — so anything that differs was built by the render itself, or does not come from state at all.

That is what makes this precise: comparing against the previous render cannot tell a value that was created in place from one that genuinely changed. Two calls in one tick can.

Four things get reported, each with its own fix.

A function built in place. The source is identical between the two calls, only the identity is fresh. That is not just an allocation: an event handler whose identity changed is removed and re-added on the element on every render, and a function passed to a child re-renders that child.

<button onclick={() => this.save()}>   // ✗ a new function every render
<button onclick={this.save}>           // ✓ a bound method

For a handler that must be built per item, @memoized caches it by its arguments, per instance — so the second render hands back the same function and nothing is reported.

An object or array built in place, with the same contents. A child receiving it re-renders every time, a @compute reading it recomputes every time, and if it is a list's items then every row loses its identity and the whole list is rebuilt — per-item state lost, @destroyed and @created run again.

<Chart config={{ smooth: true }} />    // ✗ rebuilt every render
@compute get config() { … }            // ✓ recomputed only when its inputs change

If the two are not the same, the check walks into them — an object by key, an array by index — so a function inside somebody's config is reported as the function it is, at the place it sits:

<Table cfg={{ rows: 10, onRow: () => this.pick() }} />        // reported as `cfg.onRow`
<Table cols={[{ key: "name", render: () => this.cell() }]} /> // reported as `cols[0].render`

A bag whose shape disagrees between the two calls — a different set of keys, a different length — is not a rebuild at all, so that is reported as the last case instead.

An object with a prototype, constructed in place — a Date, a Map, a Set, a class instance. The consequence is the same as a plain object's, and so is the fix: construct it once and keep it in a field, a @compute, or a module constant.

<Row at={new Date()} />     // ✗ a new Date every render
readonly at = new Date();   // ✓ constructed once, with the component
<Row at={this.at} />

The report says the object is fresh, not that its contents matched, because they are not read: the comparison walks own enumerable keys and a Map's entries are not those. And a class written inside a render is a different constructor every time, so its instances are reported as the next case instead.

A value that does not come from stateMath.random(), performance.now(). Decide the value once in @created and keep it in @state. A render that produces two different kinds of value in one tick lands here too — two prototypes that disagree is not a rebuild.

Only the part of that class which varies within a tick, though: the two renders are microseconds apart, so a millisecond clock reads the same both times. Measured over 200,000 tries, two consecutive Date.now() calls differ in 0.006% of them. Date.now() is caught by RMD007 instead — a server render and its hydration are milliseconds to seconds apart. The two checks cover the class between them; neither covers it alone.

A CACHED render is noted, not reported. @compute and @memoized are allowed on render, and a cached render hands back one answer for both calls — so an inline handler, a rebuilt object and a non-deterministic read go unreported in the render itself. Caching a render is a deliberate choice, so this is not a warning and carries no code: it is one info line, once per component, saying what the check can no longer see. A list() row is the exception and keeps its cover, because the list builds each row twice on its own — measured, an inline row handler is still reported under a cached render.

@compute
render() { … }   // an info line: RMD020 cannot see inside this component any more

The note names a second cost too: a cached render refreshes only when a signal it read moves, so anything else it reads keeps its old value — measured, a plain field left the old text on screen where an uncached render showed the new one. To keep the check, cache the expensive data in a @compute and read it from an ordinary render.

It is asked of the decorator, not of the output, and that is deliberate: render() { return this.props.children } and render() { return A_CONSTANT } also hand back one object, and neither hides anything. A @compute body returned from render has the same cost and is not noted, for the same reason — nothing distinguishes it from those two.

Where it reaches, and the one place it cannot. Every row of a .map(), a filter or an array literal is compared — those rows are built by the render, so both renders have them, and each row is checked in full rather than sharing one budget with its neighbours. A list() row is compared too, but not from here: list() is lazy on purpose, so the builder is called by the engine during the diff, and the check runs there. That has a cost worth knowing and a shape worth knowing:

100 rows, a stable callback, mount then three more renders
check on:   200 row builds on mount, 200 after the three
check off:  100                      100

Twice for a row that is built, and nothing at all for one that is reused — a reused row is never rebuilt, so a list whose rows are all steady pays nothing after the first render. A mistake in one row callback is one report, however many rows there are.

What is deliberately not checked. A hook's props callback exists in order to re-run on every render of its owner — that is its contract — so the bag it returns is a fresh object by design, and so are the values in it: a fetcher that closes over a prop cannot be a stable function. That churn is real, and a @compute bag is the cure when a subscription's connect or a @compute reads one, but reporting it would be a warning per hook with nothing to do about it. A vnode passed as a prop — onLoading={<p>…</p>} — is not reported either, for the same reason at a smaller scale: JSX is a fresh object every render. The check walks into it, so an inline handler inside still counts.

One thing to expect: a render() with a side effect performs it twice in development, and so does a list() row callback, which is built twice for the same reason. RMD001 already makes a state write there an error, so "render is pure" is the rule either way — but a console.log in a render, or in a row, really will appear twice. That is the check working.

Turning it off. When that is in the way — you are logging from render() to watch render order, or a render is heavy enough that doubling it makes development uncomfortable — switch it off at your entry point:

import { bootstrap, configureDev } from "@ramonda/core";

configureDev({ strictRender: false });   // keeps devtools and every other check
bootstrap(<App />, document.querySelector("#app")!);

It is a no-op in a production build, where the check is not compiled in at all.

RMD021 — randomness during a render, a @compute, a memoised handler or a hook's props

Math.random(), crypto.randomUUID() and crypto.getRandomValues() are reported when they are called while one of the four pure phases is running. The same call fails differently in each, so the message differs with it:

  • In a render() the output depends on when it ran, so a server render and its hydration disagree and the markup is thrown away (RMD007).
  • In a @compute it is quieter and worse: the answer is cached, so the value is frozen at the moment it was first asked for, and only a dependency the compute actually READ can refresh it — which may be never.
  • In a @memoized builder it is cached with the handler, keyed by the arguments, so every call to that handler uses the one value. The builder runs during a render, so without its own report the fix would look like a render problem.
  • In a hook's props callback it is the strangest of the four. The callback is cached on the signals it reads, and a random or clock value is not one of them — so it is frozen into the bag until something unrelated invalidates the callback, and then it jumps. As a query key: an entry that changes when somebody else's state moves, and never when yours does.

Read it once in @created and keep it in @state (or @persist, so it survives hydration), take it as a prop, or read it in the event handler that needs it.

The clock is deliberately not watched here. The platform reads it behind your back — an Event constructor stamps timeStamp — so a guard on it would report calls your app never made, attributed to whichever component happened to be rendering. new Date() is caught by RMD020 as a fresh identity, and Date.now() only when a hydration disagrees (RMD007). In a client-only app nothing catches it at runtime, which is why ramonda-check reads it out of the source instead, as clock-read-while-rendering.

RMD022 — a hook's props callback built a new value for the same contents

The props callback is called twice in the same tick and the two bags compared — the same check RMD020 runs on render(), on the other place the framework asks the app for a value. It is part of the strict render, so configureDev({ strictRender: false }) turns both off.

Why it matters more here than it looks: every prop is a signal, and a signal compares by reference. A rebuilt array is a changed prop, so a @compute reading it recomputes, a @watchProp on it fires, and a subscription whose connect reads it reconnects — every time the callback runs.

Two conditions, not one. The same-tick pair proves a value was built in place. That alone is not worth saying: key: ["user", self.props.id] is built in place too, and when id moves the array genuinely differs from last time — so the fix below would hand back nothing. The second condition is a count across runs: this prop was rebuilt on four consecutive runs of the callback and its value never moved. Below four, ordinary code gets reported for coincidences; the same threshold, for the same reason, as RMD024.

A corollary worth knowing: a callback that is never invalidated cannot be reported for churn. It runs once, its bag is cached, and a value built once is not churn — the count never leaves zero. The different-contents finding below still applies to it, and matters more there than anywhere: a value that is not a function of state is frozen into that cache at mount and served for the life of the hook.

Three findings, three fixes:

  • an array or object — hold it somewhere that has an identity (a @compute, a field, a module constant) and hand that over, so the callback passes a value along instead of building one. If you own the hook, @StableProps declares the prop a value and settles it for every call site at once.
  • a function — a bound method (fetch: self.load) reads this when it is called, so there is nothing to capture and the identity never changes; @memoized when it has to be built per argument. A declaration cannot help here: two closures with the same body are not equal by any comparison that is safe to make, so a declared function prop is still reported.
  • different contents from two calls in one tick — the callback is not a function of state. Read the value once in @created and keep it in @state, or read it where it is needed. Nothing can hide this one; what is compared is the contents. Reported on the first occurrence, with no count in front of it: this is a fault rather than churn, and it is the one kind the cache makes worse — a Math.random() in the bag is now frozen into the cached bag until something else invalidates it.

A @compute holding the whole bag fixes every value in it at once, and is the shortest answer when several are unstable together.

RMD023 — Children built from an array need a key

You rendered an array straight into children — a .map(), a filter, an array literal — and its rows carry no key. That is supported, and it is what every framework asks for here: give each row a key from your data.

{this.items.map((item) => <Row key={item.id} item={item} />)}

Not the array index. The index is the position, so keying by it changes nothing.

Without a key the rows are matched by position, so inserting or removing anywhere but the end hands every row below it the previous row's state and DOM — a half-typed input, an open menu, a scroll position, all one row off, while the page still looks right.

What is at stake is only the inside of the array. Rows built this way cannot be confused with the siblings around them: every array in JSX becomes its own group with its own key space, so an element toggling in or out beside the array never reaches into it, and the array never reaches out. A missing key costs you which row inside the array is which.

list() is the same thing without the eager build — the descriptor is rebuilt on a render and the rows are not — and a key is good practice there too.

It does not fire for a single child, which has no sibling to be reordered against, nor when any child carries a key, which means you are managing identity yourself.

RMD024 — a @compute recomputes without its answer changing

Four recomputes in a row, each producing a value equal to the last. The cache is doing nothing.

A @compute is invalidated by the signals it read, so if it recomputes on every pass while answering the same thing, something it reads is being replaced every time — most often an array or object literal rebuilt in a hook's props bag, or a value derived from one. Declare that prop with @StableProps if you own the hook, and hold the value somewhere stable if you do not — a @compute of its own, a field, a module constant.

Neither neighbour can see this one. RMD020 renders twice, and inside one strict render the compute is cached between the two calls, so both get the same value and there is nothing to compare. RMD022 compares two props bags, but skips a prop the hook declared — and a compute reading a component's prop is outside its reach entirely.

Three consecutive equal recomputes, not one: a dependency moving while the answer happens not to change is ordinary, and reporting that would put a warning on correct code.

If nothing is being rebuilt, the compute is reading something that is not reactive at all — a counter, Date.now(), a module variable. A @compute is the wrong place for that: read it once in @created and keep it in @state. (The honest limit: a compute reading only something non-reactive is never invalidated, so it is never observed either. Nothing can report a value nobody asked for again.)

RMD025 — per-request data read in the browser

requestContext() reads the real request on the server. In the browser only what the server explicitly exposed is there, so a read of anything else returns nothing — and this says so, rather than throwing and taking the page down.

requestContext().cookies.get("session")   // ✗ in the browser — cookies are never exposed
requestContext().get(sessionKey)          // ✗ unless that key opted in

Two things are never exposed: cookies and headers. They belong to the server, and an httpOnly cookie is invisible to JavaScript anyway. An app-defined value travels only if its key opted in:

export const currentUser = requestKey<User | null>("currentUser", { exposeToClient: true });

Expose only what is safe to publish — a display name, an id, a role. Whatever you expose sits in the page's HTML for anyone to read, so a session token or a database record never belongs there.

Usually you need none of this. Read the request in @created and keep the result in @state: @created is skipped on hydration and the state is restored from the page, so the browser never re-reads the request at all. Reach for exposeToClient when several components read the same value straight from requestContext().

If the server rendered something where this read is, the two sides now disagree and hydration replaces the node — RMD007 reports that separately.

RMD027 — a props callback reads a value that is not reactive

A hook's props callback is cached on the signals it reads, so a render where none of them moved does not call it again. This prop came out different anyway — which means the value reaching it never passes through a signal, so nothing marked the cache stale.

class Panel extends Component {
  items: string[] = [];                    // ✗ not @state
  add(x: string) {
    this.items = [...this.items, x];       // writes no signal
  }
  reader = this.use(List, (self) => ({ items: self.items }));
}

There are really two faults here, and the second is the one you feel: items is not reactive, so assigning it schedules no render either. The hook is left holding a value the app has moved past, and the page shows it.

Make the value reactive and both go away at once — @state for something the component owns, @compute for something derived, a context signal for something shared. If it genuinely cannot be (a Date.now(), a random id), read it once in @created and keep the result in @state rather than reading it in the callback.

The comparison is by value. A callback that returns { filter: { q } } builds a new object every call by construction, and the cache absorbs exactly that — so a bag whose contents match stays silent here. Identity is RMD022's subject, on the renders where the callback does run.

Function props are skipped. load: () => self.tick reads the signal when it is called, so one closure held across renders keeps answering with the current value — a fresh identity there says nothing about staleness.

The comparison goes to the end. The one the framework uses to CHOOSE a reference is bounded at a depth of two and at fifty array entries, because it runs on every render; past either it answers "different", which costs a fresh reference and nothing more. A report cannot be built on that answer, so this one compares thoroughly instead — deep enough for a JSX subtree passed through a bag, wide enough for a table's worth of rows, which is where it used to go quiet for having compared nothing.

What is non-deterministic in JavaScript, and what catches it

The inventory, because "collect how many of these exist" is the right instinct — and the answer is that they fall into groups with different checks:

readRMD020 (render twice)RMD021 (watch the call)
Math.random()every timeyes
crypto.randomUUID()every timeyes
crypto.getRandomValues()every timeyes
new Date() (kept as an object)every time, as instance
performance.now()every time
Date.now()0.006%
new Date().toISOString()0.091%
process.hrtime() (SSR)every time
an app's own let seq = 0; seq++every time

RMD021 patches only the randomness family, and that is a finding rather than a preference: a patched clock catches the PLATFORM's reads too. An Event constructor stamps timeStamp, which under jsdom is a JS-visible Date.now() — so any diagnostic raised during a render tripped it, and under jsdom is where every app runs its own tests. Nothing in the platform generates randomness behind your back, so that half of the check can be trusted.

The residual gap, stated rather than papered over: Date.now() read during a render in a client-only app, with the value rendered. RMD020 misses it (same millisecond), RMD021 does not watch it, and RMD007 never sees it because there is no server render to disagree with. Server-render the app and RMD007 catches it immediately.

Not in scope for either, and a different mistake with a different fix: reading LAYOUT or ambient state during a render — getBoundingClientRect(), window.innerWidth, scrollY, localStorage, document.activeElement. Those are not non-deterministic so much as a forced layout and a dependency on something outside the tree; @updated is where that work belongs.

RMD028 — an element the HTML parser is not allowed to keep here

<p>
  intro
  <div>a block</div>   {/* reported */}
</p>

The client builds the DOM with appendChild, which puts a node exactly where it is told. A parser does not:

your markup:    <p>intro<div>a block</div></p>
what a browser
builds from it: <p>intro</p><div>a block</div>

The <p> is closed early and the <div> becomes its sibling. So this works perfectly until the page is server-rendered, and then the DOM the browser built is not the tree render() described.

Without this, what you would see at that point is RMD007 — a mismatch — whose advice is about new Date() and typeof window. Neither is the problem: the server sent the right markup and the parser moved it.

What is reported, and what the parser does with each:

markupwhat happens
a block element inside <p>the <p> is closed; the block becomes its sibling
<li> outside <ul> / <ol> / <menu>relocated
<tr> outside a table, <td> outside a <tr>relocated
<option> outside <select> / <optgroup> / <datalist>relocated
<form> inside <form>the inner one is dropped; its fields join the outer form
<a> inside <a>the outer link is closed where the inner one starts

"A block element" is every tag that closes a <p> by the parser's own rule — div, ul, ol, table, h1h6, blockquote, form, hr, section, article, pre, figure, and the rest of flow content. Inline content — <strong>, <em>, <a>, <span> — is fine inside a <p>, which is what a <p> is for.

Put the element where the parser allows it. A block beside the paragraph rather than inside it, list items in a list, rows in a table. When the misplaced element is a component's own, @Host is what decides its tag — and if a default host is what is in the wrong place, RMD010 reports that instead, with the host tag to reach for.

RMD029 — a boolean attribute given the string "false"

<input disabled="false" />     {/* reported — and the input IS disabled */}
<input disabled={false} />     {/* what was meant */}

A boolean attribute is true whenever it is present. The parser never reads its value, so the string "false" turns the attribute on and the element does the opposite of what the line says:

writtenresult
disabled="false"the control is disabled and cannot be used
hidden="false"the element is hidden
readonly="false"the field cannot be edited
required="false"the form will not submit without it
checked="false"the box is checked

Pass the boolean itself — disabled={false}, or disabled={isLocked}. A false removes the attribute, and that is what makes it off.

This is not fixed for you, on purpose. <input disabled="false"> is disabled in every browser, by the HTML spec. A framework that quietly read the string and decided otherwise would make its JSX mean something different from the markup it produces — the same page would behave one way rendered by us and another way pasted into an HTML file.

Only the exact string "false", and only on a genuinely boolean attribute. aria-hidden="false" is valid and means what it says: ARIA attributes are enumerated strings rather than boolean attributes. data-open="false" is your own data. "no", "off" and "0" are not reported either — they are probably mistakes, and probably is not enough to warn on.

Nothing in the type system catches this: JSX attributes are typed with an index signature, so any value compiles.

RMD030 — state written during [INSPECT]()

[INSPECT]() {
  this.scans = this.scans + 1;   // reported
  return { scans: this.scans };
}

[INSPECT]() describes an instance. It does not change one.

The panel calls it on every commit while it is open on the components tab, so a write here closes a circle: the write schedules a render, the render commits, the commit pings the panel, and the panel asks again. Two things go wrong, and the second is the worse one:

  • the app does more work to reach the same screen;
  • the values on screen stop being the values the app had — handed to the one reader least able to doubt them, at exactly the moment they are trying to work out what is wrong.

Read fields, derive values, return. If something has to be computed, compute it into a local. If something has to be cached, cache it in a plain field rather than @state — which is what Form and Mutation already do, holding what their version counter stands for:

[INSPECT]() {
  return { values: this.current, errors: [...this.issues], isDirty: this.isDirty };
}

A write that changes nothing is reported too. It schedules no render, so there is no loop — but the method's contract is to read, and the same stance applies as in a @compute. This is the third of that family: RMD001 during render(), RMD018 during a @compute, and this one during a describe.

RMD031 — A list item that is not an element

// reported: a nested list() is a descriptor, not an element
list(pages, (page: Post[]) => list(page, (item) => <PostRow item={item} />));

// the way: a component, whose host element wraps the inner rows
list(pages, (item) => <PostPage item={item} />);

One item becomes exactly one element. The element is what carries the row's key and what the diff matches rows on, so a string, a number, an array or a nested list() has nowhere to carry its identity — the item is skipped, and the page renders one row short.

For plain values, wrap them: list(names, (name) => <li>{name}</li>). For a nested list — a list of pages, each holding rows — put a component between them, as nested lists shows. The component's host element is what wraps the inner rows.

TypeScript rejects all of this at the call site; this fires when the build has no types.

RMD032 — More than one @catchError on a component

@Host("div")
class Panel extends Component {
  @catchError logIt(e: unknown) { report(e); }
  @catchError showFallback() { this.failed = true; }   // reported: the first never runs
  render() { … }
}

A component has one answer to "who handles an error from below?", so one of them gets it; the others never run, and nothing says so — you read a handler that is dead.

The one that runs is the LOWEST, showFallback above. One rule covers this and RMD040: the declaration applied last is the one that stands. @catchError is a member decorator and members initialise top to bottom, so the lowest is applied last. A class decorator applies bottom-up, so there it is the highest — the same rule, the opposite line.

Keep one, and let it decide. It receives the error, and returning false declines it, so the next component above with a handler takes over:

@catchError handle(e: unknown) {
  // Not mine — let the boundary above have it.
  if (!(e instanceof RangeError)) return false;
  this.failed = e.message;
}

A subclass declaring its own is not this. That is an override: the subclass's handler replaces the base's, which is how a specialised boundary is written, and it is not reported. This fires only for two declarations on the same class.

RMD026 — retired

Superseded by the full fix for the ambiguity it reported, which removed the case rather than describing it.

RMD033 — State that cannot cross to the client

@state formatter = new Intl.NumberFormat("sr-RS");   // reported

Only JSON-serializable state travels in the hydration blob, and it fails in three different ways. A function is dropped, so the client starts with whatever the field initialises to. A value JSON.stringify throws on — a bigint, a circular object — never reaches the blob at all, and the whole component starts from its initialisers. And a Date, a Map, a Set or a class instance survives: it arrives as a string or as a plain object, so the field is not missing, it is the wrong type, and the first method call on it throws. Either way the two sides disagree from the first render.

Keep the value out of state and derive it on the client: in @created, which is skipped during hydration, or in a @compute. Where the server's own answer is needed, store a serializable form of it — an id, an ISO string — and rebuild the object where it is used.

RMD034 — State written during create or mount is not carried to the client

@created and @mounted do not run again on the client: hydration adopts the server's DOM and restores state from the blob. A value computed in either is therefore server-only unless it is @state, which is serialized, or marked @persist.

Mark it @persist if the client needs the server's answer. If the work is cheap and deterministic, move it somewhere that runs on both sides instead. See hydration mismatches.

RMD035 — The client's hook tree does not match the server's

State is restored by position, so both sides have to build the same hooks in the same order. A this.use() behind a condition — if (isServer), a feature flag, a branch on props — makes the counts differ, and the state after it lands on the wrong hook or nowhere at all.

Call every this.use() unconditionally, at the top of the class. A hook that should do nothing is still a hook that exists; give it options that make it idle rather than skipping it.

RMD036 — The state blob could not be read

The component starts from its initial values instead of the server's, so the page can differ from what was rendered — RMD007 usually follows.

The blob is written into the markup, so this means it was altered on the way: HTML rewritten by a proxy or a browser extension, a truncated response, or markup edited by hand. Compare what the server sent with what arrived before looking anywhere else in the app.

RMD037 — An object among JSX children that is not markup

<p>{user}</p>          {/* reported, and dropped */}
<p>{user.name}</p>     {/* the way */}

It is dropped, so the page renders without it. Almost always a value meant to be read from rather than rendered: a whole object where one of its fields belongs, a promise nobody awaited, or a list() descriptor passed as a child instead of returned.

Render a string, a number, a vnode, or a list through list().

RMD038 — A @watchProp selector threw

The selector returns undefined so the app keeps running, which means the watcher now sees a change that is not one.

It almost always reads through something absent, so guard the path as you drill into it — p.foo?.[5]?.bar. A selector runs on every props change and has to be total: no assertions, no lookups that can fail.

The console line carries the error the selector threw, stack included, which is what names the failing path. A record carries its message as text instead — see what a record may hold.

RMD039 — class where className was meant

Ramonda reads className, and a class written on an element is renamed to it before the vnode is built. So the element is styled and the page is not broken — this says the source does not match what the element gets, and names the two cases where the rename cannot save it.

className on the same element wins, and the class beside it is dropped without a word:

<span class="muted" className="loud" />   // renders class="loud"; "muted" goes nowhere

A component is renamed too. <Panel class="muted" /> reaches Panel as className, so a class prop that component declared reads undefined on every render:

class Panel extends Component<{ class?: string }> {
  render() { return <span>{this.props.class}</span>; }   // always undefined
}

This is the one place the JSX deliberately differs from HTML, and the reason is the language: class is a reserved word in the object a JSX factory receives.

Reported once per component and tag, so converting a codebase gets one report per place rather than one for the first class and silence for the rest. ramonda-check reports the same attribute before it renders, as class-instead-of-classname.

RMD040 — More than one @ShouldUpdateOnPropsChange on one class

@ShouldUpdateOnPropsChange((_self, previous, next) => next.v !== previous.v)   // this one decides
@ShouldUpdateOnPropsChange(() => false)                                       // never consulted
@Host("b")
class Gated extends Component<{ v: number }> { render() { … } }

There can only be one answer to "take these props?", so one of them decides and the others never run — a gate that looks present and is not.

The one that decides is the HIGHEST, which reads backwards. One rule covers this and RMD032: the declaration applied last is the one that stands. @ShouldUpdateOnPropsChange is a class decorator and class decorators apply bottom-up, so the lower declaration writes the rule and the upper one overwrites it. A member decorator initialises top to bottom, so there it is the lowest — the same rule, the opposite line.

Remove the extras and combine their conditions into one callback.

A subclass declaring its own is not this. That is an override — the ordinary way to specialise the rule — and it is silent. This fires only for two applications on the same class.

RMD041 — A listener with no target

The handler is never attached, so the event it waits for cannot arrive. The selector matched nothing at the moment the listener was set up, which usually means the element is rendered conditionally or arrives later.

Attach to the host and let the event bubble, or move the listener to where the element certainly exists.

RMD042 — The default host cannot be the direct target of this event

<ramonda-host> is display: contents, so it generates no box. Events that bubble from children still reach it; anything tied to a box — pointer position, hover, focus on the host itself — never will.

Give the component a real host tag with @Host("div") if the event needs one. See one tag, one element.

RMD043 — A <meta> with nothing to identify it

// Skipped. Nothing identifies it, so an update could only append a second copy.
// The cast is what it takes to write this at all — see below.
skipped = this.use(Head, () => ({ meta: [{ content: "A framework." } as never] }));

// Written, and matched by its `name` when it changes.
described = this.use(Head, () => ({ meta: [{ name: "description", content: "A framework." }] }));

Head matches the tags it has already written so that an update replaces them rather than appending, and a <meta> is matched by name, property or http-equiv. One with none of the three cannot be found again, so it would be added on every update — it is skipped instead.

MetaTag requires one of the three, so TypeScript refuses the tag that would trip this. It fires for a build with no types, or through a cast.

Reported once per set of fields the tag has, rather than once per tag: a content that carries the page description is a different string on every navigation, and one report for each of them would say nothing the first did not.

RMD044 — An unknown element type in JSX

A tag has to be a string, a component class, or — for the one unsupported case — a function. This was none of them, so an empty host renders in its place and whatever it was meant to be is missing.

Usually a value used where a tag belongs: an object read off a map with the wrong key, or a component whose import failed and arrived as undefined. A function in tag position is a different mistake with its own advice — see RMD011.

The empty host is rendered in every build, so a failed import costs the one element rather than the page. Reported once per component, so two of these in two files are two reports.

RMD045 — More than one @Host on a component

@Host("div")
@Host("span")     // throws: two answers to "which element am I?"
class Panel extends Component { render() { … } }

A component is exactly one element, so there is one answer to which. Keep the @Host you meant and delete the rest.

It throws as well as reporting, in every build. Unlike RMD032 and RMD040, where one declaration quietly wins and the page still renders, there is no way to pick a winner here and carry on. The record is emitted for a collector all the same — a fault that only throws is invisible to anything shipping your diagnostics somewhere.

A subclass declaring its own is not this. That overrides the base's, which is how a specialised component changes its element, and it is silent.

RMD046 — More than one @StableProps on one class

@StableProps("a")
@StableProps("b")     // merged into the union, and reported
class Watcher extends Hook<{ a: readonly unknown[]; b: readonly unknown[] }> { … }

@StableProps names a set, and it already merges along the class chain — a subclass adds names rather than shadowing the base's. So two on one class has an unambiguous reading, the union, and both declarations take effect. Combine them: @StableProps("a", "b").

A warning rather than a refusal, which is the difference from RMD045: there, two element names have no union and carrying on would mean picking one silently. Here the result is exactly what you asked for, written twice — so nothing is wrong except the spelling.

A subclass declaring its own is not this. That adds to the base's list, which is the intended way to extend it.

RMD047 — A memoized handler was given an argument it cannot key on

@memoized
pick(row: Row) {          // reported: a Row cannot be part of a cache key
  return () => this.select(row.id);
}
@memoized
pick(id: string) {        // the way: key on the primitive, read the rest inside
  return () => this.select(id);
}

@memoized caches by the ARGUMENTS, and a cache key can hold a string, a number or a boolean. An object cannot: comparing it by value is not something the cache can do, and keying on its identity would miss every time — a fresh object per render would fill the map and hand back a new handler on every pass, which is the churn the decorator exists to prevent.

Development throws, so the mistake is not shipped. Production builds the handler and moves on without caching that call: the page keeps working and only the memoisation is lost. It used to throw there too, from inside a render, so one handler receiving an object took the whole page down — and which handler that was depended on the data, so it could pass every test and fail for one user.

The code is on the thrown error as well as in the log, the way RMD004 is, so a codebase can be swept for it.

RMD048 — Object in state changed in place

this.user.name = "grace";              // reported: nothing renders
this.user.address.city = "paris";      // reported as `user.address.city`
this.user = { ...this.user, name: "grace" };
this.user = { ...this.user, address: { ...this.user.address, city: "paris" } };

A signal fires when it is assigned a new value, not when the value it holds changes inside. So a write into the object the signal already has changes nothing it can compare, nothing re-renders, and the page goes on showing what it showed before.

The check wraps lazily: a read returns a guarded child only when something asks for that child, so it follows the path a render actually touches. Reading user.name costs two proxies whatever the size of user, and a Date, a Map or a class instance is left alone — their methods need the real receiver.

@ramonda/lens is the shorter way to rebuild a path: this.user = focusOn(this.user).get("address").get("city").set(city).

The array form of the same fault is RMD005.

RMD049 — Two lazy functions with the same source

const make = (path: string) => () => import(path);

<AsyncLoad lazy={make("./Dashboard")} onLoading={<i />} errorFallback={<i />} />
<AsyncLoad lazy={make("./Settings")} onLoading={<i />} errorFallback={<i />} />
<AsyncLoad cacheKey="./Dashboard" lazy={make("./Dashboard")} onLoading={<i />} errorFallback={<i />} />
<AsyncLoad cacheKey="./Settings" lazy={make("./Settings")} onLoading={<i />} errorFallback={<i />} />

AsyncLoad identifies a module by the source of its lazy, which works when that source names one. () => import("./Thing") says what it loads, so the same import written in two components is two different functions with one meaning — and they share a cache entry, which is what you want.

A lazy a factory built names nothing: the path it closed over is not part of the source, so every module the factory produces stringifies the same. Left alone, the first would load and cache and the second would render the first one's module — nothing failing, nothing logged, and which module you got depending on which rendered first.

Which of the two you have written cannot be read from the text of the function — the source a bundler leaves behind is its own business, and a rule looking for a literal specifier would read one bundler's output correctly and another's backwards. So nothing is guessed: when a second lazy meets a key that is already taken, its module is loaded and compared. The module system serves a genuine duplicate from its own registry, so the ordinary case pays one resolved promise and confirms the sharing.

A module that turns out to be a different one is given a key of its own. It then renders what it asked for; what it loses is the shared cache entry — a loading frame the second time. cacheKey gives that back, and a route table that builds its lazies from a list is the usual way to meet this.

See lazy loading for the whole picture.

RMD050 — A decorator whose effect this member already has

@state @state count = 0;        // the same one twice
@state @persist token = "";     // @state already puts a field in the blob

Either the same decorator is on the member twice, or two of them give it the same thing. Delete the one that adds nothing.

A warning, not an error. The member ends up right either way — a doubled @state renders once per write with the right value, because the second application installs the same accessor over the first. What is wrong is the belief that the second line was doing something.

Two decorators that do different work on one member are silent, and that is most pairs. A method that is both @created and @updated, a handler on @onWindow and @onDocument, an @interval beside a @timeout, a @watchProp that is also an @updated — each runs twice on purpose, which is the reason for writing two.

And the pairs that make no sense at all never reach this code: @state with @compute, @compute with @persist, @state with @watchProp, @memoized with @compute all throw, naming the member and what it is, because one of the two is on the wrong kind of member entirely.

Reported once per member, not once per instance — a list of a thousand rows says it once.

Forms — RMF

RMF001 — a field was assigned to

f.email.$.value = "[email protected]";   // throws
f.email.$.set("[email protected]");      // the way

A field node is a proxy over a path, not a place values live. An assignment would land on the proxy and stop there: the form's values would be unchanged, nothing would revalidate, nothing would re-render, and the next read would return the old value — a write that looks like it worked. set records the change where the form can see it.

This one throws rather than warning, and in production too, because there is no correct program in which the assignment does something. RML009 is the only other check that survives into production; every other report on this page is development only.

RMF002 — the list members were used on a field that is not a list

length, rows, append, insert and remove belong to an array field. Reaching for them on a field holding a string or a number is a path that does not say what it meant to — usually a typo, or a schema that changed shape underneath the component.

An absent field is not this error: undefined and null read as an empty list, so a form whose defaults have not filled in an optional array renders zero rows instead of throwing. Only a value that is present and is not an array is reported.

RMF003 — onSubmit threw

The form calls onSubmit from a DOM submit event, where nobody is waiting on the promise it returns. A failure there is the app's to handle — the form does not know whether a network error should become a message, a retry or a redirect — but it must not vanish either, so it is reported and the form leaves isSubmitting behind it.

Handle it inside the handler, which is where the context is:

async save(values: Signup) {
  try {
    await register(values);
  } catch {
    this.form.setError("email", "we could not reach the server");
  }
}

RMF004 — the schema's validation rejected

Standard Schema says validate answers with a result or a promise of one. It does not say the promise resolves, and an async rule doing real work rejects whenever that work does — a uniqueness lookup against a server comes back as a rejected promise the moment the network fails. Every validator propagates it.

The form keeps the messages it already had, because blanking them would claim the values had been re-answered, and reports isValid: false — "we asked and did not hear back" is not "nothing failed". A submit whose validation rejected does not call onSubmit, and releases isSubmitting so the button is usable again.

Catch the failure inside the rule and turn it into an issue, so the reader is told what happened instead of facing a form that will not answer:

import { object, string } from "bguard";
import type { ExceptionContext } from "bguard/core";

const schema = object({
  email: string().customAsync(async (received: string, ctx: ExceptionContext) => {
    try {
      if (await taken(received)) ctx.addIssue("unused", received, "u:taken");
    } catch {
      // The lookup failed, which is not the same as the address being taken. Saying so is what
      // keeps the form answerable.
      ctx.addIssue("a reachable server", received, "u:unreachable");
    }
  }),
});

Query — RMQ

RMQ001 — a query key that cannot be hashed

A key is turned into a string to find its cache entry, so what it holds has to survive that trip. Two kinds of value do not, and each fails in its own direction:

Dropped entirely — a function or a symbol. JSON.stringify omits them, so ["user", fn] and ["user", otherFn] hash identically: two queries share one entry and each renders the other's data. Put the value you were about to close over in the key — ["user", id] — and keep the function in the fetcher.

Serialized unstably — a Date, a Map, a class instance. A Date becomes a timestamp that differs on the next render, so the entry is never found again and every render starts a new fetch; a Map or a class instance becomes whichever of its fields happen to be enumerable, which is often nothing at all. Put a primitive in the key — date.toISOString().slice(0, 10), or the id — and keep the object in the fetcher.

Both are checked when the key is hashed, and the message names the kind it found. Arrays and plain objects are walked, to a depth of ten.

RMQ002 — a query failed and nothing rendered it

The query is in error, and the render that just happened read none of isError, error, status or result. The report names the key and the failure.

It matters because a failed refetch keeps the data it had: the page can look perfectly healthy while showing values that no longer refresh. Nothing throws, nothing is blank, and the only sign is that a number stopped moving.

This is the answer to throwOnError, which @ramonda/query does not have. What that option is really for is noticing, and noticing is a development-time report — where rethrowing into an error boundary would unmount the subtree, run every cleanup, and throw away local state, focus and scroll for something as ordinary as a timeout.

Reading any one of those four silences it, per render: a component that showed the error and then stopped (a collapsed panel, a switched tab) is reported again, because each render is judged on its own reads.

Immutable updates — RML

Every one of these means the write did not happen: the value handed back is the original root, unchanged and uncopied, and the app carried on with the value it already had. What the severity separates is whether the code can be right —

  • error — it cannot be, whatever the data holds. A wrong kind of value for the operation, a refused key, a branch that returns nothing.
  • warn — it may well be, and the data was simply empty or absent. A path through a null, a predicate that matched nothing, a key already gone.

A path steps through a nullable value by design, so reporting that as an error would raise an alarm about a program doing exactly what it was written to do.

None of them is deduplicated. Messages you might see maps every message text to its code, for when you have the console output and not the code.

RML001 — a path that could not be reached

const profile: { profile: { city: string } | null } = { profile: null };

focusOn(profile).get("profile").get("city").set("Niš");
// .profile is undefined, so .profile.city could not be reached.

A hop before the last one holds undefined, null, or a primitive, so there is nothing to descend into. Only the last hop creates what it names — set, update, push and insert all write where nothing is — and a gap before it cannot be walked through.

Set the intermediate value first, or merge the whole object into place. A warning rather than an error because stepping through an optional value is what the types are built for: the path is legal, and this run found the value absent.

RML002 — a path into a Map, Set or Date

Those hold their contents in internal slots that a copy cannot reach, so a clone of one would look right and throw on first use. They are fine as valuesset(new Date()) stores one like any other leaf — but a path cannot descend into one.

Read the value out, rebuild it, and set the result:

const store: { byId: Map<string, { title: string }> } = { byId: new Map() };

focusOn(store).get("byId").set(new Map(store.byId).set(id, { title: "Renamed" }));

RML003 — an array hop on something that is not an array

at and where exist only where the focused value is an array, so TypeScript refuses this at the call site. It fires when the build has no types, or through a cast. Use get(key) for an object.

RML004 — an index outside the array

at(i) accepts -length … length - 1, negative counting from the end, so at(-1) is the last element. insert(i, …) accepts one more — length itself, which appends, and push says that more plainly.

A warning, not an error: the index is the code's, but the length is the data's, and an array that came back shorter than expected is a normal thing for it to do.

RML005 — a predicate that matched nothing

where matches every element that satisfies it, so matching none is a write with no target. Reading the same path with values() shows what is actually there — a stale id and a comparison against the wrong field both look like this.

Often legitimate: "publish every draft" over a list with no drafts left is a program working correctly, which is why this warns rather than erring.

RML006 — an operation that needs a different kind of value

push and insert need an array. A missing or null one counts as empty and is created; a value that is present and is not an array — a number, a string, an object — is a genuine mistake.

merge needs an object and does not create one, and the line between them is what the operation can supply: push hands over a complete array, while merge has only a Partial, so creating from it would mint a half-built object typed as a whole one. Use set where the object itself may be missing.

RML007 — nothing to remove

Either the container above the removal is not one, or the property named is already gone. Check the hop before the one being removed, and the spelling of the key — a typo reads exactly the same way.

A warning: removing a key that is not there is the idempotent case, and a program that runs twice lands here the second time.

RML008 — a fork branch that returned nothing

// ✗ returns undefined, so the branch is skipped
.and((post) => { post.get("title").set("Renamed"); })
// ✓
.and((post) => post.get("title").set("Renamed"))

What a branch returns is the new value of the forked node, so a block body without return hands back undefined. TypeScript rejects it; this fires when the types were loose enough to let it through. For the same reason a branch that ends in a read replaces the node with what it read.

RML009 — a key a write is refused for

get takes a string | number, so a key can come from data — a field name, a key off a parsed request body — and every write ends in an assignment into the copy. __proto__, constructor and prototype are refused there, in remove, and in a merge partial: assigning to __proto__ does not create a property at all, it runs the setter Object.prototype provides and replaces the copy's prototype.

If the key came from data, this is the guard doing its job — filter the key before building the path.

This is the one check that is not compiled out of production; only its message is. A check that ran solely in development would protect the one build that was never exposed to a request.

RML010 — a chain written through twice

const blog: { posts: { title: string }[] } = { posts: [{ title: "a" }, { title: "b" }] };

const posts = focusOn(blog).get("posts");
posts.at(0).get("title").set("one");
posts.at(1).get("title").set("two"); // ✗ throws

focusOn(root) captures root once, so the second write is computed from the original value and silently drops the first edit. The result looks plausible and is missing a change, which is far harder to find than a throw.

Feed the result back in — focusOn(next).… — or make one and of the edits. Sharing a prefix to read is fine and never trips this.

RML011 — remove() at the root

Removal needs the container holding the value, and the root has none. Focus the property or element to drop first: focusOn(state).get("home").remove().

RML010 and RML011 throw in development and are a silent no-op in production, so neither is control flow to rely on — do not wrap either in a try expecting to catch something in a shipped build.

RMD051 — A list row cannot be told apart from its siblings

A list identifies a row by what sets it apart from the others, which is what lets a row replaced by fresh objects — a refetch, a JSON.parse — be recognised as the row it replaces and updated rather than destroyed and rebuilt. This row carries nothing that could do that: every field it has is either nested (compared, but never counted as evidence) or a value its siblings share.

[{ tags: ["a"] }, { tags: ["b"] }];                              // nothing but nested data
[{ done: false, kind: "task" }, { done: false, kind: "task" }];  // only shared flags

So the row is rebuilt whenever the array is replaced, and a half-typed input or an open menu on it goes with it. Give the row a field of its own — an id is the usual answer — or say which row is which where the data arrives, rather than on every list that renders it:

this.rows = merge(this.rows, incoming, (row) => row.id);

It does not fire for a row that is simply new. Page 2 of a table is unpaired too, and warning about that would put a report on correct code. See lists.

RMD052 — A component among JSX children, where an element was meant

render() {
  return <div>{Panel}</div>; // ✗ names the component
}

{Panel} puts the class itself among the children. It is not markup, so it is dropped and the page comes up without it. Write the element:

render() {
  return (
    <div>
      <Panel />
    </div>
  );
}

This is reported separately from RMD037, which looks for an OBJECT among children — a class is a function, so it never reached that check and the mistake was silent until now.

Handing a component to something else is an attribute rather than a child: <Slot view={Panel} /> passes it as a prop, and that is a different thing entirely.

ramonda-check reports the same mistake from the source, before anything renders.

RMD053 — The request was read with no request scope installed

@mounted async load() {
  const posts = await fetchPosts();
  const user = requestContext().get(currentUser); // ✗ below the await
}

requestContext() is live only while the page is being rendered. On the server that means the synchronous section: the scope is installed, the tree is mounted, and it is cleared before the render's first await. A read below one arrives after it is gone.

The scope is cleared that early on purpose. It is one module-level value shared by every request the server is handling at once, and holding it across a yield is what would let one visitor's render read another visitor's user. Reading synchronously is the rule that makes the shared value safe.

Read it where the render is still running, and keep what you need:

@state user = "";

@created init() {
  this.user = requestContext().get(currentUser); // ✓ synchronous
}

@mounted async load() {
  const posts = await fetchPosts();
  console.log(this.user); // the value travelled in @state
}

An async lifecycle method is fine above its first await — that part still runs inside the synchronous section.

Holding the object does not help:

const context = requestContext(); // ✓ called in time
await fetchPosts();
context.get(currentUser); // ✗ still a late read

Every member of what requestContext() returns is a getter over the current request, so the object is a door rather than a copy. @state is what carries a value across a yield.

The other way to arrive here is calling requestContext() at module top level, before any render has started.

This is reported as well as thrown, because the throw does not always arrive anywhere: inside an async @mounted it goes into the server's work drain and is swallowed, so the page is served, complete, and quietly missing the value. The report is what survives that. In a production build there is no report — diagnostics are development-only — which is another reason to read the request where the framework can see you do it.

Reading per-request data during a static build is a different thing and reports separately: see renderStatic, where the read is what marks the route un-bakeable.

RMD054 — A post-commit callback threw, and the failure was swallowed

This one is reported only from a production build, and it is the only code on this page that is. In development the same failure goes to the console with the error object attached, which is more than a record can carry and better to read.

Commit-level work is isolated the way a @mounted is: one piece of it must not stop the rest. It has no component to hand a failure to — that is what makes it commit-level rather than a lifecycle callback — and it is not rethrown, because it runs while a commit may already be unwinding and a throw there would replace the real error with a metadata one.

The consequence is a swallowed exception, and in production nothing said so. Nothing renders differently, nothing logs, and whoever wrote the callback has no way to learn it never ran. So if your app has installed a collector, the fault is reported to it.

The record carries the code and nothing from the error. The message on a thrown error is written by whatever threw — your code, or a library inside it — and a record that may leave the process is the wrong place to discover what is in it for the first time. If you want the detail, catch it in the callback, where you know what you are looking at.

RMD055 — A hook's props passed as a plain object

class Panel extends Component {
  @state count = 1;

  // ✗ the compiler refuses this, and `use()` throws if it arrives anyway
  counter = this.use(Counter, { start: this.count });
}

A field initializer runs once, so an object written in one holds what was true at that moment and goes on holding it for the life of the hook. start there is 1 forever: this.count moving to 7 changes the owner and reaches nothing inside Counter.

Pass a callback, and the props follow:

counter = this.use(Counter, () => ({ start: this.count }));

The callback is cached on the signals it reads, so it is re-run on a render where one of them moved and skipped on a render where none did.

Constants are written the same way, and cost the same. A callback that reads no signal is called once, at mount, and never again, and the inline functions in it keep their identity across the owner's renders — measured in core's PropsBagRuns.test.tsx. So there is no bag cheap enough for the shape to be worth choosing.

A development build calls it more often than that, and keeps none of it: a second time at mount, so RMD022 can compare the two bags and catch a value that is not a function of state, and once per render of the owner, so RMD027 can check the cache has not gone stale. The hook is handed the first bag in every build.

It throws in every build, like a write to props (RMD004, RMD015): the alternative is a shipped bundle serving one stale value for the life of the page, silently. The report beside the throw is development-only, and it names the owner, the hook, and the keys the object carried.

The mistake cannot be found from inside use(), which is handed a finished object with no way to tell { start: this.count } from { start: 1 }. The FORM is the visible half, so the form is what the framework holds you to.

RMD056 — One context provided twice by the same component

const [ThemeProvider] = createContext({ color: "slate" }, { label: "Theme" });

class Panel extends Component {
  // ✗ two Providers of one context, on one component — this throws
  base = this.use(ThemeProvider, () => ({ color: "slate" }));
  accent = this.use(ThemeProvider, () => ({ color: "amber" }));

  render() {
    return <Card />;
  }
}

A component publishes a context on one object, so the second Provider would replace the first under the same key: every descendant reads "amber", and base is unreachable from below.

What hides it is that base still works here. A Provider reads as well as provides, so this.base.color is "slate" inside Panel while every component under it sees "amber"the component that made the mistake is the one place the mistake is invisible. That is why this throws in every build, like a write to props (RMD004) and a plain-object props bag (RMD055): a development-only report would leave a shipped page handing the wrong value to whichever descendant asked.

Write two scopes instead. A component that renders this.props.children scopes its context to what is inside it, which is what a <Provider> element does in a framework that has fragments:

const [ThemeProvider, ThemeConsumer] = createContext({ color: "slate" }, { label: "Theme" });

class Scope extends Component<{ color: string; children?: RamondaNode }> {
  theme = this.use(ThemeProvider, () => ({ color: this.props.color }));
  render() {
    return this.props.children;
  }
}

class Panel extends Component {
  render() {
    return (
      <div>
        <Scope color="slate">
          <Card />
        </Scope>
        <Scope color="amber">
          <Card />
        </Scope>
      </div>
    );
  }
}

Two independent scopes, and a consumer inside each finds its own with nothing passed down. That works because a context object is created from the component that renders a node — so a child handed in as children inherits the wrapper's context, not the context of whoever wrote the JSX.

Nesting is untouched and needs no scope wrapper. A Provider on a descendant component shadows the one above it for its own branch, which is ordinary and is never refused: the check asks whether this component already published the key itself, and a Provider above it never makes that true.

single is a different question. It declares whether nesting is a fault — two on one path, on different components — and a context that welcomes nesting is still broken by two on one component. So this takes no option: there is no version of it an author would choose.

Splitting the keys between two Providers is not a way out, and the types already close it. A Provider takes its options whole, so the second cannot supply half — it would replace the channel and the first half would fall back to the default. If the two values are for different purposes, they are two contexts: call createContext twice.

RMD057 — A context consumed above the provider on the same component

const [ThemeProvider, ThemeConsumer] = createContext({ color: "slate" }, { label: "Theme" });

class Section extends Component {
  // ✗ resolves before the provider on the line below it exists
  outer = this.use(ThemeConsumer);
  own = this.use(ThemeProvider, () => ({ color: "amber" }));

  render() {
    return <Card />;
  }
}

A consumer resolves its channel once, when it is constructed, and hooks are constructed in field-declaration order. So this one looked before its own component had published anything, and reads the nearest provider on an ancestor — or the context's default, if there is none. Swapping the two field declarations changes what the page shows.

If this component's own value was meant, read it through the provider hook. A Provider reads as well as provides, so this.own.color always means this component's value and does not rest on which line came first:

const [ThemeProvider] = createContext({ color: "slate" }, { label: "Theme" });

class Section extends Component {
  own = this.use(ThemeProvider, () => ({ color: "amber" }));

  render() {
    return <p>{this.own.color}</p>;
  }
}

If the value from above was meant — reading the outer theme to derive an inner one — then the example at the top is that arrangement working, and the order it needs is the order it has. Nothing in the source says which of the two it is, which is why this is a warning rather than an error and why the panel does not raise its alert for it.

The other order is not reported. this.use(QueryClientProvider) followed by this.use(Query, …) — mount a client, then query on it — is the arrangement @ramonda/query and @ramonda/router are built around, and reporting it fired fourteen times across query's own tests.

@ramonda/check's context-consumed-above-its-provider reports the same thing before anything runs, including for a component down a branch nobody has opened. The two reach different cases on purpose: the rule sees only a pair written directly — const [P, C] = createContext(…) with both halves handed to this.use in one class — while a provider wrapped in a hook of its own, the way QueryClientProvider wraps one, is invisible to it and is what this catches.

Deduped per context and owning component.

Deduped per context and owning component, so a component that mounts a thousand times says it once.

It reports rather than throwing, unlike a plain-object props bag (RMD055). There, a shipped bundle would go on serving a value nobody set; here the page has one deterministic reading, and refusing it would break an app that has been living with the first Provider being ignored. A later version can refuse.

RMD058 — The request blob could not be read

// The server stamps what the page opted into onto the root element:
//   <main data-ramonda-request='{"review-sid":"s-123"}'>
// and `hydrateRoot` reads it back. If that string does not parse, nothing is restored.
const sid = requestKey<string>("sid", { exposeToClient: true });
requestContext().get(sid); // undefined on the client, for every exposed key

The blob is ignored rather than fatal — a page that renders with a value missing beats a page that does not render, which is the same stance RMD036 takes for the state blob.

What makes this worth its own code is what you see instead. Two other diagnostics fire in its place and both point away from the cause: RMD025 says a key was not exposed — it was — and RMD007 reports the render mismatch that follows, whose advice is about clocks and random numbers. The page looks correct throughout, because the server's markup is still on screen.

The blob is JSON on the root element, so something between the server writing it and the browser parsing it altered it: an HTML transform, a proxy rewriting markup, or a value that did not serialize cleanly.

RMD059 — An async lifecycle rejected

@state posts: unknown[] = [];

@mounted async load() {
  this.posts = await fetchPosts();   // ✗ if this throws, nothing tells anyone
}

An error boundary does not catch this, and that is deliberate. The rejection arrives at an arbitrary later moment — the page is already on screen and interactive, and there is no render left to fail. Replacing what the reader is using with a fallback at that point is the worse outcome.

What follows is why the report exists. The page renders exactly as though the method had succeeded: posts is still [], the empty state shows, and the only trace is an unhandled rejection in a console nobody is watching.

Handle it where it happens, and put the failure somewhere the render can see:

@state error = "";

@mounted async load() {
  try { this.posts = await fetchPosts(); }
  catch (e) { this.error = String(e); }
}

If the failure really should take the page down, re-throw it from render() — that is a render, and a boundary can see it.

ramonda-check reports the same method before it ships, as unguarded-async-lifecycle.

RMD060 — render() is async

// @ts-ignore
async render() {                       // ✗ returns a promise, not markup
  const rows = await api.rows();
  return <ul>{String(rows)}</ul>;
}

An async render() returns a Promise the moment it is called, so the diff is handed an object that is not a node. What you see without this report is a TypeError thrown from inside the framework — a stack of framework frames naming neither your component nor render().

The type system already refuses this, so reaching it means a @ts-ignore, a cast, or a base class loosened somewhere above. That is exactly why the check exists in all three places: a type is a defence only while nobody casts it away.

Load the data outside the render, and let render() show whichever state the component is in:

@state rows: unknown[] = [];

@mounted async load() {
  this.rows = await api.rows();
}

render() {
  return this.rows.length === 0 ? <p>Loading…</p> : <ul>…</ul>;
}

Where the promise itself is the subject, AsyncLoad takes it and renders a fallback while it settles.

ramonda-check reports the same method before it ships, as async-render.