DEV Community

Franklin
Franklin

Posted on

Notes from the Pass: The Reference That Went Stale

Also available in Español

The Claim Being Tested

Thursday's essay argued that the browser doesn't only render native elements — for a defined set of them, it also implements and maintains the interaction contract that goes with them, and application code that reimplements the same contract takes on an obligation the platform was already fulfilling. The specific claim: a hand-rolled focus trap and a native <dialog> might not differ much in a diff, but they differ in who's on the hook when the modal's contents change after it opens.

This Note tests that directly. The same failure — new content loading into an open modal — run against both implementations, to see whether the difference is real or just rhetorical.

The Old Trap

The hand-rolled version enumerates the modal's focusable elements once, at open, and checks every Tab press against two of them:

function openModal(modal) {
  previouslyFocused = document.activeElement;
  modal.classList.add('is-open');

  const focusable = modal.querySelectorAll(FOCUSABLE_SELECTOR);
  first = focusable[0];
  last = focusable[focusable.length - 1];
  first?.focus();

  modal.addEventListener('keydown', trapFocus);
}

function trapFocus(event) {
  if (event.key !== 'Tab') return;
  if (event.shiftKey && document.activeElement === first) {
    event.preventDefault();
    last.focus();
  } else if (!event.shiftKey && document.activeElement === last) {
    event.preventDefault();
    first.focus();
  }
}
Enter fullscreen mode Exit fullscreen mode

first and last aren't positions. They're references to two specific DOM nodes, captured the moment the modal opened.

Now a request resolves, and the options list re-renders to show the new choice:

optionsList.innerHTML = renderOptions(updatedOptions);
Enter fullscreen mode Exit fullscreen mode

innerHTML doesn't update the existing nodes. It discards them and builds new ones — including whatever last was pointing at. The variable still holds a reference. The node it refers to no longer exists anywhere in the page.

document.activeElement can never equal that reference again. The next time a user tabs forward off the new final option, trapFocus's second condition silently fails. No preventDefault() fires. Nothing errors. The browser's native Tab order simply takes over, carrying focus straight past the modal into whatever comes next in the document — a page still dimmed by an overlay that no longer has any actual say in where focus goes.

The New Contract

Rebuild the same modal on <dialog>, and run it through the identical re-render:

<dialog id="preferences">
  <form method="dialog">
    <div class="options">
      <!-- options render here -->
    </div>
    <button autofocus>Save</button>
  </form>
</dialog>
Enter fullscreen mode Exit fullscreen mode
const optionsContainer = dialog.querySelector('.options');

dialog.showModal();
// later, a request resolves
optionsContainer.innerHTML = renderOptions(updatedOptions);
Enter fullscreen mode Exit fullscreen mode

optionsContainer is scoped to the options list on purpose — the re-render never reaches the Save button, which is the one thing in this dialog that isn't re-evaluated on its own. Sequential focus order (what Tab responds to) is computed fresh at every keypress, which is why the new option becomes reachable without any code noticing it arrived. autofocus isn't that kind of mechanism — the browser looks for it exactly once, when showModal() runs. An element added after that, even with autofocus set, needs an explicit .focus() call; the platform won't pick it up on its own the way it does for Tab order.

Nothing here caches a first or last element, so nothing can go stale. showModal() doesn't compute a snapshot of focusable descendants at open time. It makes everything outside the dialog inert for as long as it stays open, which means the browser's Tab order search space is limited to what's actually inside the dialog, evaluated fresh at every keypress. Replace the options list's contents mid-open, and the next Tab press still only considers whatever is currently there, new option included. There's no reference to invalidate, because none was ever taken.

What Moved, What Didn't

Responsibility Hand-rolled <dialog>
Focus containment Cached references, checked on every keydown Structural — background is inert, nothing to cache
Restoring focus on close Manually stored and restored Automatic on close, however it closes
Escape to dismiss Explicit keydown check Automatic
Inert background / stacking Manual aria-hidden, manual z-index Automatic — native top layer
Initial focus target Imperative .focus() call Declarative — autofocus, still an authoring decision
Dismiss on backdrop click Explicit click-outside check Still explicit — <dialog> has no default for this

Four responsibilities move to the platform outright. One moves from a runtime decision to a declarative one — still the application's call, just a lighter one to carry. One doesn't move at all: clicking outside a <dialog> does nothing on its own, and closing it that way still needs the same kind of listener the hand-rolled version needed.

Whether the Claim Held

The essay HTML Doesn't Need More Reinvention — It Needs Platform Trust claim wasn't that <dialog> has fewer bugs. It was that reimplementing a contract the platform already owns means owning its correctness indefinitely, including cases the original code never anticipated. This is exactly that case. The hand-rolled trap didn't fail because of bad code. It failed because "check against a cached reference" is a strategy with a specific, permanent blind spot, and a re-render was always going to find it eventually.

<dialog> doesn't close that blind spot with better code. It closes it by not having a cache in the first place — the entire category of failure the hand-rolled version was exposed to doesn't apply to a mechanism that re-evaluates focusability live. That's the four rows that moved outright. The two that didn't — choosing an initial focus target, handling a backdrop click — are decisions specific to what a given dialog contains, not something the platform could own on the application's behalf even if it tried to.

Top comments (0)