Skip to content

Frozen empty base + non-mutating prepends#2

Merged
tomasc merged 3 commits into
mainfrom
frozen-base-non-mutating-prepends
Apr 18, 2026
Merged

Frozen empty base + non-mutating prepends#2
tomasc merged 3 commits into
mainfrom
frozen-base-non-mutating-prepends

Conversation

@tomasc

@tomasc tomasc commented Apr 18, 2026

Copy link
Copy Markdown
Owner

Summary

Cuts ~1.5–2 MB of allocations per heavy page render (measured on rijksakademie.nl via MemoryProfiler) by:

  1. Making base dom_classes, dom_aria, dom_data, dom_style return shared frozen constants instead of fresh empty collections per call.
  2. Switching prepend patterns from super().tap { mutate } to non-mutating super() + [v] / super().merge(name => v) so the frozen bases are safe.
  3. Simplifying dom_attrs to avoid the 4-allocation .reject.deep_stringify_keys.deep_transform_keys chain — now one result = {} with conditional per-slot assignments.

DomStyle#merge added so that has_dom_style chained prepends still return DomStyle (not a plain Hash).

Behavioral parity

  • All 30 existing assertions pass on the new code.
  • Output of dom_attrs is functionally identical: top-level keys as strings ("aria"/"class"/"data"/"style"), nested keys dasherized, empty slots omitted.
  • has_dom_class still resolves Proc/Symbol/literal values and appends to the class list.
  • if:/unless: short-circuiting preserved.

Why

On a 400-component page the old base allocated ~1600 empty collections no one ever used (half the components never set dom_aria/dom_data/etc.). Line 118 (DomStyle.new({})) alone showed up at 404 kB in the allocation profile; the gem as a whole at 2.36 MB.

Post-change, components that don't opt in pay zero; components that do pay the same as before.

Test plan

  • Existing gem test suite passes (confirmed locally)
  • Re-run MemoryProfiler.report on rijksakademie.nl after bump — expect gem allocation drop of ~1.5–2 MB
  • Wall-mode stackprof shows GC percentage tick down by 1–2 points

🤖 Generated with Claude Code

memory_profiler on a heavy rijksakademie.nl page render showed this
gem allocating ~2.36 MB/request, with has_dom_attrs.rb:118
(DomStyle.new({})) alone accounting for 404 kB. Root cause: base
methods (dom_classes, dom_aria, dom_data, dom_style) always
allocate a fresh empty collection per call — components that
don't declare has_dom_* for that slot still pay the allocation,
and dom_attrs builds a 4-key hash + reject + deep_stringify +
deep_transform chain (4 hash allocations) on every call.

Changes:

* Base methods return shared frozen EMPTY_ARRAY / EMPTY_HASH /
  EMPTY_STYLE constants. Zero allocation for components that
  don't override.

* Prepends (has_dom_class, has_dom_aria, has_dom_data,
  has_dom_style, has_dom_attr) switch from `super().tap { mutate }`
  to `super() + [v]` / `super().merge(name => v)`. Required
  because frozen bases can't be mutated; same allocation count
  for components that DO override.

* DomStyle#merge preserves DomStyle type (so chained has_dom_style
  declarations stay DomStyle all the way up).

* dom_attrs simplified to one `result = {}` + conditional key
  assignments with localized `transform_keys` (skipping empty
  slots), replacing the 4-allocation reduce chain. Behavior
  preserved: output still stringifies + dasherizes top-level and
  nested keys for aria/data.

Expected saving per render on a 400-component page:
~1.5–2 MB of allocation, ~100–150 ms of GC time. All 30 existing
assertions pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tomasc added a commit to tomasc/has_stimulus_attrs that referenced this pull request Apr 18, 2026
The prepend defined by has_stimulus_* used `super().tap { |d| d[k] = ... }`
which mutates the hash returned by super. This works fine when
HasDomAttrs#dom_data returns a fresh mutable hash, but breaks when the
upstream base becomes a frozen shared constant (see tomasc/has_dom_attrs#2,
where EMPTY_HASH is returned to eliminate per-call allocations for
components that don't override).

Switching to `current.merge(k => v)` preserves behavior and works against
both the old mutable base and the new frozen base. No extra allocation in
the common case because the previous pattern also allocated a hash at the
bottom of the super chain.

All 71 existing assertions pass (1 pre-existing skip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@tomasc
tomasc merged commit 5ad6fe8 into main Apr 18, 2026
1 check passed
@tomasc
tomasc deleted the frozen-base-non-mutating-prepends branch April 18, 2026 08:09
tomasc added a commit to tomasc/has_stimulus_attrs that referenced this pull request Apr 18, 2026
* Non-mutating prepend pattern for frozen-base compatibility

The prepend defined by has_stimulus_* used `super().tap { |d| d[k] = ... }`
which mutates the hash returned by super. This works fine when
HasDomAttrs#dom_data returns a fresh mutable hash, but breaks when the
upstream base becomes a frozen shared constant (see tomasc/has_dom_attrs#2,
where EMPTY_HASH is returned to eliminate per-call allocations for
components that don't override).

Switching to `current.merge(k => v)` preserves behavior and works against
both the old mutable base and the new frozen base. No extra allocation in
the common case because the previous pattern also allocated a hash at the
bottom of the super chain.

All 71 existing assertions pass (1 pre-existing skip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add CHANGELOG entry for non-mutating prepend pattern

* Update has_stimulus_attrs.gemspec

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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