Stable IDs
Every Poetry component mints DOM ids to wire its internal
relationships: aria-controls,
label targets, floating anchors, SVG defs. How those ids are chosen
decides whether Turbo morph can pair your components across renders,
and whether cached fragments stay composable.
The identity ladder
In precedence order:
-
id:, explicit caller identity. The root id; every internal id derives from it (settings-trigger,settings-content, item ids). Order-independent, cache-safe, morph-stable. It is your namespace: passid: "settings"and Poetry derives the rest, so don't hand-author those. An explicit id is reduced to letters, digits, underscores, and hyphens before use; if nothing safe remains, Poetry falls back to a random token. -
key:, semantic identity. A record or a literal:poetry_dropdown_menu(key: message)derivespoetry-dropdown-menu-message_42via Rails' owndom_id, so identity follows the record, and a sorted or filtered collection morphs with state staying on the right row. Literals parameterize (key: "faq"becomespoetry-accordion-faq). - Form components ride the form builder's attribute-derived ids.
- Random fallback. Without identity, ids are random per render: under a Turbo morph the component is replaced (open state, focus, and client state reset). That is deliberate. An unkeyed component must over-replace rather than ever falsely retain state on the wrong record.
<%# explicit: root id "settings", derived ids settings-trigger, settings-content %>
<%= poetry_dropdown_menu(id: "settings") do |menu| %> ... <% end %>
<%# semantic: dom_id derivation -> poetry-dropdown-menu-message_42 %>
<%= poetry_dropdown_menu(key: message) do |menu| %> ... <% end %>
<%# a literal key parameterizes -> poetry-accordion-faq %>
<%= poetry_accordion(key: "faq") do |accordion| %> ... <% end %>
When you must pass identity
- Collections. Without keys, every morphing reorder replaces every row's component:
<% @messages.each do |message| %>
<%= poetry_dropdown_menu(key: message) do |menu| %>
...
<% end %>
<% end %>
- Fragment-cache blocks. An unkeyed component freezes a random id into the cached HTML; replays can collide with fresh ids and morphs cannot pair it. The invariant: cached HTML is safe only when every id frozen into it remains unique under every composition and replay of that HTML. The Caching guide has the full fragment recipe.
-
Broadcast and stream partials destined for
turbo_stream.morph. The morph pairs against the live DOM by id. Plainappendandreplacestreams are safe unkeyed: ids churn per broadcast but stay unique and internally consistent. -
Repeated new-record forms.
key:on an unsaved record derivesnew_messageand the like; two of those on one page collide. Pass literal keys.
poetry:check warns on the first two
(stable-identity/cache,
stable-identity/collection); they are heuristics over
conventional ERB, not proofs. Components whose registry entry
declares identity: false are exempt - their families
never reach the id mint, so key: would have nothing
to stabilize - and the warnings fire only where a random id truly
exists. The runtime complement is
poetry_id_integrity_script: it scans the composed page
for duplicate ids after every load, frame load, morph, and stream
insertion, the only check that sees real cross-template
composition.
<%# in the development layout's <head> %>
<%= poetry_id_integrity_script %>
Keeping primary keys out of the DOM
key: derives through dom_id, which reads
your model's to_key. To keep primary keys out of the
DOM app-wide, with Poetry ids, your own dom_id calls,
turbo_frame_tag, and Turbo Stream broadcast targets all
staying consistent, override it at the model:
class Message < ApplicationRecord
def to_key = [slug]
def to_param = slug
end
Or pass a literal: key: message.public_uid. Poetry
deliberately never reads slugs itself: slugs are mutable and
recyclable (a freed slug claimed by another record would transfer
identity), and Poetry preferring them while your own
dom_id stayed pk-based would split the app into two id
vocabularies.
What morph identity does and does not preserve
With a paired (keyed) component, Turbo morph keeps the DOM node itself: Stimulus controller instances, element properties, scroll state, everything riding the element object. Two things never survive regardless of ids, by design:
- Focus across a reorder. A moved node detaches and reinserts, which blurs it. Not an id failure.
-
User-typed input values. The morph deliberately
syncs values back to server truth. User-held input state is
data-turbo-permanentterritory.
The sequence mode (opt-in, experimental)
The sequence mode seeds a per-request deterministic id sequence (seed: the request path, matching Turbo's definition of a page refresh). Same page, identical ids, byte-stable responses: Turbo morph pairs even unkeyed components, and body-hash ETags can match on responses with no other request-varying output.
# config/initializers/poetry.rb
Poetry::Core::Config.current.stable_id_mode = :sequence
# the seed defaults to the request path; override the lambda if needed
Poetry::Core::Config.current.stable_id_seed = ->(request) { request.path }
Read the hazards before enabling; they are why this is not the default:
-
Same-path turbo-frames collide deterministically.
Two frames loading
/widgets?aand/widgets?bboth seed from/widgetsand draw identical id sequences into one composed page. Poetry's own deferred regions are turbo-frames. -
Positional false identity. A reordered same-type
collection pairs by position, so an open menu's state can
land on the wrong record after a morphing re-sort. Use
key:for collections, always; the sequence is for byte-stable static and content pages.
The allocator is pinned by a golden-vector test: a gem upgrade cannot silently re-identify sequence-mode pages.
ETags and CSRF: what ids do and don't buy
-
Body-hash ETags (
Rack::ETag) match only byte-identical responses. Stable ids remove one source of churn, butcsrf_meta_tagsmasks a fresh token per render, so a page with zero forms still varies if the standard layout emits it. -
fresh_whenandstale?are unrelated to ids. Record-derived validators never hash the body, so stable ids do not change their hit rate. They remain the right 304 mechanism for form pages. -
Fragment caching. Keep the token-bearing
formtag outside the cached fragment; the Caching guide has the full recipe and the replay invariant.