Frozen empty base + non-mutating prepends#2
Merged
Conversation
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>
2 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cuts ~1.5–2 MB of allocations per heavy page render (measured on rijksakademie.nl via MemoryProfiler) by:
dom_classes,dom_aria,dom_data,dom_stylereturn shared frozen constants instead of fresh empty collections per call.super().tap { mutate }to non-mutatingsuper() + [v]/super().merge(name => v)so the frozen bases are safe.dom_attrsto avoid the 4-allocation.reject.deep_stringify_keys.deep_transform_keyschain — now oneresult = {}with conditional per-slot assignments.DomStyle#mergeadded so thathas_dom_stylechained prepends still returnDomStyle(not a plainHash).Behavioral parity
dom_attrsis functionally identical: top-level keys as strings ("aria"/"class"/"data"/"style"), nested keys dasherized, empty slots omitted.has_dom_classstill 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
MemoryProfiler.reporton rijksakademie.nl after bump — expect gem allocation drop of ~1.5–2 MB🤖 Generated with Claude Code