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 state — Math.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
- All diagnostics — every code the framework can report.
- Checking your app — the faults proved from the source, before anything runs.