Skip to content

Perf: Update composite keys (cls) in one pass, without intermediate collections - #215

Closed
nguyenyou wants to merge 2 commits into
raquo:masterfrom
nguyenyou:perf-composite-key-updates
Closed

nguyenyou wants to merge 2 commits into
raquo:masterfrom
nguyenyou:perf-composite-key-updates

Conversation

@nguyenyou

@nguyenyou nguyenyou commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Today

Every cls <-- signal emission ends in updateCompositeValue, which rebuilds the attribute string like this:

getAttribute → decode (split → filter → wrap → List) → filterNot → ++ → mkString → setAttribute

That's ~5 short-lived collections per update, usually for a one- or two-word string.

This PR

Default composite keys produce the same result in one pass:

  • DefaultCompositeCodec.encodeUpdated(domValue, removeItems, addItems) walks the split DOM value once, skips removed items, appends added ones, and builds the string directly. It equals encode(decode(domValue).filterNot(removeItems.contains) ++ addItems) for the default codec.
  • CompositeCodec.encodeUpdated calls its overridable decode and encode methods, preserving the behavior of custom codecs.
  • CompositeCodec.normalize: fast path when there's no separator ("active" → List("active"), no split); otherwise builds the List directly instead of jsSplit → ew.filter → asScalaJs.toList.
  • updateCompositeValue reuses keyItemsWithReason instead of looking up _compositeValues twice.

No behaviour change: the bookkeeping, the DOM read (kept for third-party classes), and the exact attribute string written are all the same as before.

Numbers

n elements with cls <-- sharedSignal, toggling the signal. fullLinkJS, Chrome 152, M3 Max, runs interleaved A/B/A/B:

rows before after
1000 461 µs 387 µs 1.19×
2000 1042 µs 802 µs 1.30×
5000 3290 µs 2761 µs 1.19×

These measurements predate the custom-codec compatibility follow-up; default keys still use the same one-pass implementation. Control, the same binding with class set via a plain htmlAttr: unchanged (~200 µs @ 1000 rows). So cls is still ~2× a plain attribute – mostly the DOM read, which I kept on purpose.

Testing

  • Full Scala 3 suite passes (411), including a regression test for overridden codec methods.
  • Scala 2.13 compilation could not be rerun in this checkout because airstream_sjs1_2.13:18.0.0-M5-SNAPSHOT is unavailable.
  • Checked normalize and the default codec's encodeUpdated against the original code on 20k random inputs (extra / leading / trailing separators, duplicates, empty / unset values, " " and "," separators): identical output. I didn't add this as a test because of the "no speculative tests" note in notes/Testing.md – happy to add it if you'd like.

🤖 Generated with Claude Code

…ollections

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nguyenyou
nguyenyou requested a review from raquo as a code owner September 24, 2026 04:47
@raquo

raquo commented Sep 24, 2026

Copy link
Copy Markdown
Owner

Thanks! I will probably leave reviewing this for later, as your other reported correctness & behaviour issues take priority.

Keep the one-pass path for default keys while custom codecs continue to use their overridden decode and encode methods.

Co-Authored-By: Codex GPT-6 Astra <codex@openai.com>
@raquo

raquo commented Sep 24, 2026

Copy link
Copy Markdown
Owner

@nguyenyou Is the second commit a fix for something in the first commit, or for a pre-existing issue in Laminar?

@nguyenyou

Copy link
Copy Markdown
Contributor Author

@raquo The second commit fixes an issue introduced by the first. The optimization skipped custom codec behavior; the fix preserves it while keeping the speedup for default codecs.

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.

2 participants