Ramonda

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.

Next