Skip to content

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

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

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

Conversation

@nguyenyou

Copy link
Copy Markdown
Contributor

Recreates the original PR #215, which was automatically closed when my fork was accidentally deleted.

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

nguyenyou and others added 2 commits September 24, 2026 11:46
…ollections

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
@nguyenyou
nguyenyou requested a review from raquo as a code owner October 7, 2026 03:01

This branch has not been deployed

No deployments
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