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.
- The click is swallowed. It arrives on inert HTML and does nothing. The visitor presses again.
- The caret is collapsed. Claiming a server-rendered input assigns
.value, and assigning.valuemoves 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:
| Time | Event | State |
|---|---|---|
| 0ms | pointerdown | Hydration starts; a dynamic import() begins |
| ~1ms | mousedown | Still inert |
| ~5ms | mouseup, then click | Handlers may not exist yet |
| ~20–400ms | Import resolves, component mounts | Now 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 does | What they expect | What happens |
|---|---|---|
| Clicks in the middle of a default value, types | Text inserted at the caret | Caret jumps to the end; text appended |
| Selects all, types to replace | Old value replaced | Selection 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:
- Load the page. Do not touch anything else.
- Click the control once.
- Assert the thing it should have done has happened.
- Reload. Click into the middle of a pre-filled input.
- Assert
selectionStartis 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.