Skip to content

perf: inferno-inspired element, host config and layout paths - #145

Merged
RuudBurger merged 17 commits into
mainfrom
ruud/perf-inferno
Oct 15, 2026
Merged

RuudBurger merged 17 commits into
mainfrom
ruud/perf-inferno

Conversation

@RuudBurger

@RuudBurger RuudBurger commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #142, from reading how Inferno (and a bit of lightning-tv/solid and the Lightning renderer) do their patching. Most of it can't work under real React, these can in our host config and element class. 12 commits, one change each, each with a patch changeset and tests.

Whole stack, base (main) vs tip, node 24 on a loaded Mac so wall time is noisy (bytes/op is the steadier number)

  • create+destroy tile: +54% (plain stack), +52% (RN stack), 9.8KB to 4.8KB per op
  • subtree, parent + 12 children: +75% / +70%, 126KB to 62KB
  • slow-path style update: +32% / +28%
  • layout readback, 50 nodes: +114% / +101%
  • e2e mount+unmount of a 4x12 tile grid through the real reconciler, css-transform and flexbox: +31% / +27%, 2.0MB to 1.1MB
  • e2e update (focus move + row scroll): inside noise, the reconciler and Yoga dominate it

What changed

  • commitUpdate diffs and maps props in one pass
  • deleted subtrees are destroyed without detaching every descendant, destroy is idempotent
  • w/h/alpha skipped on copy instead of deleted
  • node props built in one object, no rest copy
  • prototype methods instead of per-instance closures
  • flexbox and the RN polyfills use one shared lifecycle observer instead of ~9 listeners per element (biggest win: -40% B/op on create)
  • layout readback reads the plain style before touching the proxy, getDisplay only when it can change
  • internal style reads skip the proxy
  • unchanged alpha/clipping aren't rewritten to the node (the renderer's setters don't check, and each write re-propagates through the subtree: 20us per frame on a 145-node subtree)
  • event emitter created on first subscription

Caveats

  • New public API: observe()/unobserve() and the LightningElementObserver type. flexbox and the RN polyfills fall back to events if the element has no observe(), so newer plugins on an older core still work. Arguably a minor bump.
  • Observers run just before the event listeners for the same event, so a plugin that subscribed with on() to childAdded or stylesChanged in onCreateInstance would now fire after flexbox. Nothing in this repo or the Plex app does.
  • Timings are node, not a TV. The build targets chrome56 where rest/spread compiles to helpers, so removing the copies should matter more on device.
  • Rejected after measuring: one-pass css convert (mixed, e2e inside noise), allocating allEls as a Map (public Record API), skipping unchanged shader writes (getter returns a transformed value).
  • Likely the biggest remaining win, not done: the className plugin has no handledStyleProps, so every style update in an RN app takes the slow path and the fast path is never used. Needs a plugin API change.

Style fast path for reanimated pushes (added after the first version of this PR)

  • New optional plugin hooks: needsTransform(instance, props) (the className plugin only claims updates that carry className or an instance with remembered class styles) and transformStyle(instance, style) (css-transform converts a lone transform/opacity push inline). Style-only updates used to take the fast path 0 times in the Plex app (measured: css-transform claimed 84% of them, the className plugin blocked the rest). With this, 84% of a replayed Home scroll mix goes fast.
  • Microbench: opacity alone +120% (449 to 297 B/op), transform [{scale}] +93%, translateX +30%, opacity+transform +82%. End to end frame is +3-5%, inside the noise (yoga and the reconciler dominate it).
  • 636-case equivalence spec against the slow path, plus a real-plugin-stack test comparing node props, remembered styles, animate() calls, stylesChanged payloads and layout after each step.
  • Behaviour difference worth a device look: the slow path restamps the default Text/Image color (0xffffffff) on every partial push, so a reanimated opacity or transform push on colored text turns it white today. The fast path keeps the color. I think that's a fix, but it isn't identical to before. Also the fast path doesn't re-copy unchanged non-style props to the node each time.
  • Style arrays and strings always go slow now (with the first hook alone their keys "0", "1" would have been written to the node).
  • No Plex app changes needed. Dev and e2e builds still can't show it, testIdInspectorPlugin has transformProps and no hook, so every style-only update takes the slow path there.

plugin-flexbox glue (added after the fast path work)

  • Yoga's solve is only about 26% of mount and 35-40% of a layout-changing update on a Home-like tree (1,855 nodes). The rest was our glue: every setProps queued a layout of every root, readback called hasNewLayout() over the WASM boundary for every node, and mount ran two full layout passes.
  • Three commits: no layout pass when a style changes nothing yoga reads (checks the yoga node dirty flag, percent translate is still handled), clean roots are skipped and readback stops at nodes yoga didn't touch, and the grow-only text width is seeded from the first layout so mount runs one pass.
  • Node numbers (1,855 nodes, 9 alternating rounds): scale-only update 0.53 to 0.03 ms (-94%), artwork resize -40% (single tree) / -60% (per-row roots), title text change -41% / -61%, row insert -34% / -46%, mount 26.6 to 19.9 ms (-25%, 3,470 to 1,855 node writes, 2 passes to 1).
  • Layout is bit-identical to before: an equivalence harness runs the plugin pipeline through 8 scenario sets of about 45 steps each (mount, resizes, ring border, text changes, row insert/remove, percent translate, reanimated partial pushes, display none, absolute children, hero container narrow/wide) and finds 0 diffs in node state and in emitted resized/layout sequences. Mutation check: dropping the percent translate sweep gives 32 diffs, so it catches that class of bug.
  • Behaviour differences: a style change that touches nothing yoga reads no longer runs a pass, so no render or settled follows it (VirtualListCell, the only onFlexLayoutSettled user, only reacts to size changes). Clean roots no longer emit a resized with an unchanged size on every pass (11 events per pass with per-row roots). The bench baseline was rebaselined on purpose (layoutPasses halves, textRemeasures goes to 0).
  • The hero-title grow-only rule is unchanged after the first layout. I couldn't build a case where it changes a final layout, so its real trigger is still unproven either way.

@RuudBurger
RuudBurger marked this pull request as ready for review October 15, 2026 09:13
@RuudBurger RuudBurger changed the title perf: inferno-inspired commit and destroy paths perf: inferno-inspired element, host config and layout paths Oct 15, 2026
@RuudBurger
RuudBurger merged commit d70b215 into main Oct 15, 2026
1 check passed
@RuudBurger
RuudBurger deleted the ruud/perf-inferno branch October 15, 2026 13:32
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