PineUiPhoenix.Internal.Id (Pine UI v0.2.0)

Copy Markdown View Source

Stable, collision-free DOM ids for components.

Every interactive component needs an id for two reasons:

  • Accessibility. aria-controls, aria-labelledby and aria-describedby reference other elements by id, so those elements need ids that are unique on the page.
  • LiveView DOM patching. morphdom matches old and new elements by id. Without a stable id, an Alpine-initialised element can be replaced rather than updated, discarding its state even with pineDom() wired up.

Generate once, not per render

unique/1 returns a different value on every call, so it must be resolved through assign_new/3, which memoises it in the component's assigns:

# Correct — resolved once, stable across re-renders.
assigns = assign_new(assigns, :id, fn -> Id.unique("pine-modal") end)

# Wrong — a new id on every render; morphdom sees a brand new element each time.
~H|<div id={Id.unique("pine-modal")}>|

Note the memoisation only holds within a live view's assigns lifecycle. A component rendered from a controller (a "dead" view) generates a fresh id per render, which is harmless in the browser but makes exact-value assertions in tests non-deterministic — pass an explicit id in tests, or assert on shape rather than value.

Summary

Functions

Ensures assigns.id is set, generating one from prefix when the caller did not pass one.

Derives a child id from a parent id.

Returns a process-unique id with the given prefix.

Functions

ensure_id(assigns, prefix)

@spec ensure_id(map(), String.t()) :: map()

Ensures assigns.id is set, generating one from prefix when the caller did not pass one.

assigns = ensure_id(assigns, "pine-modal")

Why not assign_new/3

assign_new/3 only fires when the key is absent. A component that declares

attr :id, :string, default: nil

always has :id present — Phoenix injects the default before the function body runs — so assign_new(assigns, :id, fn -> unique(...) end) never fires and @id stays nil. Every scoped_id(@id, "panel") downstream then raises, and every aria-controls silently becomes empty.

This helper checks the value, not the key, which is what the situation actually calls for.

scoped_id(parent, part)

@spec scoped_id(String.t(), String.t()) :: String.t()

Derives a child id from a parent id.

iex> scoped_id("pine-accordion-1f9a", "panel")
"pine-accordion-1f9a-panel"

Use this for every sub-element that needs to be referenced — it keeps aria-controls and friends pointing at real, unique elements without a second assign_new.

unique(prefix)

@spec unique(String.t()) :: String.t()

Returns a process-unique id with the given prefix.

iex> Id.unique("pine-accordion")
"pine-accordion-1f9a"

Uses System.unique_integer/1, which is monotonic per VM — no randomness, so nothing here depends on the seeding of :rand.