Ramonda

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.

Next