Skip to content

Component events repaint only when the rendered props change - #236

Merged
GenericJam merged 4 commits into
masterfrom
skip-unchanged-repaint
Nov 13, 2026
Merged

GenericJam merged 4 commits into
masterfrom
skip-unchanged-repaint

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Problem

Every Mob.Component handle_event/3 / handle_info/2 sent {:component_changed, id, module}, whatever the callback returned, and Mob.Screen.Server answered with paint(state, :none). That paint never skips: it renders, expands every component, and commits the whole tree through set_root.

FMutate's instrument is a native view that sends an event per key press, key release and knob move, and its handler returns the socket unchanged for key events. On a Moto G Power 2021, 6 presses produced 12 commits (RenderStats). Each cost 5-15 ms on the BEAM side and 10-20 ms of Compose recomposition on the UI thread, and that was the main source of jank left.

Change

  • Mob.ComponentServer notifies the screen only when assigns changed (!==) and render/1 of the new assigns differs (!==) from the props the screen last fetched with render_props/1. A callback that returns the socket untouched, assigns the same value again, or changes only an assign render/1 does not read sends nothing. :rendered is read with Map.get and written with Map.put, so a component started before this change keeps working after mix mob.push.
  • Mob.Screen.Server handles {:component_changed, ...} with repaint_if_changed/1, the fingerprint skip that forwarded messages already use. Several notices queued behind one paint now commit once. Navigation, activation, hot reload and :sync still paint unconditionally.
  • The screen's own handle_info path was already checked. Since 2026-09-03 it skips the commit when the tree is unchanged, but it still runs render/1 and the expansion passes. That part is unchanged and recorded as out of scope in the decision.

Behaviour change

A component's render/1 is now treated as a function of its assigns, as in LiveView. Anything else it reads is picked up on the next screen paint, not by a callback that changed no assign. Identical props are also no longer re-sent to a native view. Screens have behaved this way since the 2026-09-03 decision. The moduledoc and CHANGELOG cover both.

Decision: decisions/2026-11-12-component-events-repaint-only-on-changed-props.md. I added an in-place note to 2026-09-03-skip-the-repaint-when-the-tree-is-unchanged.md, because its "only forward/2 may skip" no longer holds.

Tests

  • test/mob/component_server_test.exs, describe "telling the screen to repaint", covers:
    • an unchanged socket, a re-assigned same value, and an unrendered-assign change: no notice
    • a rendered change: notice
    • the comparison baseline is the last render_props/1 fetch
    • handle_info and dispatch/3 follow the same rule
  • test/mob/component_repaint_test.exs (new) counts set_root at a stub NIF, end to end through a screen with a native view:
    • an ignored event, an unrendered-assign change, and 25 ignored events: 0 commits
    • a rendered change: 1 commit
    • a bare {:component_changed} whose tree is already on screen: 0 commits
    • 5 changes queued behind a suspended screen: 1 commit

Mutation-checked:

  • Reverting component_server.ex fails 6 of the new component-server tests.
  • Reverting screen/server.ex fails 2 of the component-repaint tests.

Full suite: mix test, 2170 passed (28 doctests, 2142 tests), 38 excluded, 0 failures. mix format --check-formatted, mix credo --strict and mix compile --warnings-as-errors are clean, and the pre-push hook passed. A pre-commit adversarial review found nothing blocking.

Verification rung: host BEAM tests only. Not yet measured on a device.

Every Mob.Component handle_event/handle_info sent {:component_changed}
and the screen painted unconditionally, committing the whole native
tree. A native view emitting an event per key press (FMutate's
instrument) committed twice per press: 5-15 ms BEAM plus 10-20 ms of
Compose recomposition each on a Moto G Power 2021.

- Mob.ComponentServer notifies only when assigns changed and render/1
  differs from the props the screen last fetched via render_props/1.
- Mob.Screen.Server repaints on {:component_changed} through
  repaint_if_changed/1, so queued notices commit once.

Decision: decisions/2026-11-12-component-events-repaint-only-on-changed-props.md
- Mob.CrashReport.format_status/1 redacts a component's :rendered (the
  props render/1 last returned) like its assigns; crash reports and
  :sys.get_status/1 no longer print app data held there (MOB-310).
- mix.lock: ecto 3.14.0 -> 3.14.3 (security advisory).
- Regression test: a component state from before :rendered existed
  (kept across mix mob.push) still notifies and renders.
- Correct the stale repaint comment; CHANGELOG documents that native
  views no longer get identical-props re-commits after rejected input.

Reviewer: reviewer subagent (Codex rate-limited until 9:31 PM): SHIP.
Device-verified on Moto G Power (Android 11) and iPhone SE (3rd gen).
@GenericJam

Copy link
Copy Markdown
Owner Author

Follow-up c68db05 (review fixes):

  • Blocking fix: Mob.CrashReport.format_status/1 now redacts a component's :rendered, the props render/1 last returned. Before this, a component crash report or :sys.get_status/1 printed them in plain text, alongside assigns that were already redacted (MOB-310). Regression test in crash_redaction_test.exs; it fails without the fix.
  • ecto 3.14.0 → 3.14.3 (security advisory).
  • Hot-push regression test: a component whose state lacks :rendered still notifies and renders.
  • Corrected the stale repaint comment. The CHANGELOG now says native views no longer get an identical-props re-commit after rejected input, and how to force one.

Device verification (fixture native component; root_renders counts screen renders):

Moto G Power 2021 (Android 11) iPhone SE 3rd gen
no-op taps ×5 no repaint (1) no repaint (1)
hidden-assign tap no repaint (assign changed, render didn't) no repaint
rejected controlled input no repaint, field keeps typed text same
rendered change 1 → 2, lit=1 shown 1 → 2, lit=1 shown

Reviewer: reviewer subagent (Codex rate-limited until 9:31 PM): SHIP.
Host suite 2172 passing. CI: https://github.com/GenericJam/mob/actions/runs/43332023743 ✅

… device verification

The MOB-310 record said format_status/1 "redacts the state"; it rewrites only
the fields it names. Note added in place so the next state field holding app
data gets added to redact_state/1.
@GenericJam
GenericJam merged commit 0a33ec9 into master Nov 13, 2026
4 checks passed
@GenericJam GenericJam mentioned this pull request Nov 13, 2026
@GenericJam
GenericJam deleted the skip-unchanged-repaint branch November 13, 2026 00:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant