Ramonda

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.

ramonda-check reports the same thing from the source, as function-built-in-the-markup, so a handler on a page nobody has opened is found before anything runs. It makes the same two exceptions this does: a @memoized call, and a prop the child declared with @StableProps.

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.

configureDev takes a DevFlags — one field, strictRender, on by default because the mistakes this check names are silent otherwise.

Next