BriskFile

Guides ·

Why a hydrating page eats the visitor's first click

Lazy hydration drops the gesture that triggered it, and assigning input.value while claiming a server-rendered node collapses the caret. Both fail silently, and neither shows up in a Lighthouse audit.

Because hydration starts on that click and finishes asynchronously, so the click lands on markup with no handlers attached and is simply lost. The fix is to record the click during hydration and replay it once the component is live.

Two bugs with one cause

Deferring hydration until the visitor interacts is the right default — it is how a content page ships no framework JavaScript until somebody reaches for the tool. But the gesture that triggers hydration happens before the component can respond to it, and two separate things get destroyed in that window.

  1. The click is swallowed. It arrives on inert HTML and does nothing. The visitor presses again.
  2. The caret is collapsed. Claiming a server-rendered input assigns .value, and assigning .value moves the selection to the end — even when the value is unchanged.

Both fail silently. Neither appears in a Lighthouse audit, because an audit never interacts with the page.

The swallowed click

The sequence, with a pointerdown trigger:

TimeEventState
0mspointerdownHydration starts; a dynamic import() begins
~1msmousedownStill inert
~5msmouseup, then clickHandlers may not exist yet
~20–400msImport resolves, component mountsNow live, and the click is long gone

Whether the click survives is a race between the browser’s event timing and the size of the chunk. A small component wins it on a fast machine, which is exactly why this ships: it works on the developer’s laptop and on the light pages, and fails on the heavy ones and on slow connections.

The fix is to remember the click and replay it:

let pendingClick = null;
let hydrated = false;

// Capture phase, so it is seen before anything can stop propagation.
element.addEventListener('click', (e) => {
  if (!hydrated) pendingClick = e.target;
}, { capture: true, passive: true });

async function start() {
  const hydrate = await load();
  await hydrate();
  hydrated = true;

  const target = pendingClick;
  pendingClick = null;
  if (!target || !element.contains(target)) return;
  // Skip anything the browser already acted on: a <label> has opened its file
  // picker and an <a> has navigated. Replaying would do it twice.
  if (target.closest('label, a, input, select, textarea, [role="link"]')) return;
  target.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
}

The exclusion list is the part that is easy to miss. Replaying a click on a <label> bound to a file input opens the file picker a second time.

The collapsed caret

This one is subtler and does more damage, because it corrupts input rather than discarding it.

When a framework claims a server-rendered <input> for a two-way binding, it assigns input.value. Assigning .value collapses the selection to the end of the field — and it does so even when the value being assigned is identical to the one already there.

On an empty input that costs nothing. On a pre-filled one it destroys the first gesture:

What the visitor doesWhat they expectWhat happens
Clicks in the middle of a default value, typesText inserted at the caretCaret jumps to the end; text appended
Selects all, types to replaceOld value replacedSelection dropped; new text appended to old

Measured on a real page with a 16-character default: clicking at roughly character 5 left selectionStart at 16 of 16. A triple-click select-all came back as 16–16 rather than 0–16.

The second row is the worse one. The visitor selects the default, types their own value, and gets both concatenated — usually followed by a validation error about the thing they thought they had replaced.

The fix is a snapshot either side of the hydrate call:

const selectionOf = (node) => {
  if (!node || node.selectionStart === undefined) return null;
  try {
    // Throws on input types with no text selection: number, email, colour.
    return { node, value: node.value, start: node.selectionStart,
             end: node.selectionEnd, direction: node.selectionDirection };
  } catch { return null; }
};

const caret = selectionOf(document.activeElement);
await hydrate();
if (caret && document.activeElement === caret.node && caret.node.value === caret.value) {
  caret.node.setSelectionRange(caret.start, caret.end, caret.direction ?? 'none');
}

The DOM work inside hydrate() is synchronous, so no user event can be processed between the snapshot and the restore. The value and focus checks cover the case where it is not.

Why tests do not catch either

Neither bug is visible to the things that normally protect a page.

  • Unit tests mount the component directly. There is no server-rendered markup and no hydration, so the window in which both bugs live does not exist.
  • Lighthouse and every synthetic audit load the page and measure it. They never click anything, so a component that ignores its first click scores perfectly.
  • Manual testing by the developer happens on a warm cache and a fast machine, where the import resolves before the click arrives.
  • A second test run is worse than useless for the click bug: the island is already hydrated from the first interaction, so a check that re-uses one page load passes while testing nothing at all.

That last point deserves emphasis. Each gesture has to be tested on its own fresh page load, because the first click is the thing under test and it only exists once per load.

What a real test looks like

Drive a real browser, on a fresh navigation, and assert on the outcome rather than on the mechanism:

  1. Load the page. Do not touch anything else.
  2. Click the control once.
  3. Assert the thing it should have done has happened.
  4. Reload. Click into the middle of a pre-filled input.
  5. Assert selectionStart is not the end of the value.

Throttle the CPU while you do it — 4× or 6× — so the race resolves the way it does on a mid-range phone rather than the way it does on your laptop.

Then break it on purpose and confirm the test fails. A gate that has never been seen to fail is not known to be checking anything, and both of these bugs are the kind that a test can be written to look like it covers while covering nothing.

The general lesson

Lazy hydration moves work off the critical path by deferring it past the moment the visitor arrives. That is a real gain, and the cost is that the boundary now sits in the middle of the first interaction rather than before it.

So the first gesture is not an ordinary gesture. It is the one that has to survive a component being replaced underneath it — and it is the one gesture every visitor makes.