Replaying Buffered Events After Island Hydration

There is a gap between the moment a user can see and click an island and the moment that island’s JavaScript is actually attached. Under partial hydration that gap can be hundreds of milliseconds — long enough that an eager user clicks a button, toggles a control, or types into a field before any handler exists to respond. Those interactions hit inert server-rendered DOM and vanish. Buffering-and-replay closes the gap: a capture-phase listener that is active from the first byte records interactions aimed at not-yet-hydrated islands, and once an island mounts, the buffer replays its events in order so nothing the user did is lost. This builds directly on event delegation in partially hydrated apps, which establishes the shared-ancestor listener this technique depends on.

Prerequisites


Capture Early, Replay on Mount

One capture-phase listener, active from the first byte, queues events for inert islands. When an island announces it has mounted, its queued events flush into it in order.

Buffering pre-hydration events and replaying on mount A horizontal timeline. Early on, three user clicks land on an inert island and are recorded into an ordered buffer by a capture-phase listener. Later the island hydrates and emits a mounted signal, at which point the buffer replays event 1, 2, and 3 in order into the island's handlers. first byte time → island INERT — capture listener active click 1 click 2 click 3 buffer = [e1, e2, e3] (ordered) island:mounted replay in order handler(e1) → handler(e2) → handler(e3)

Implementation Steps

Step 1 — Attach a capture listener before any island loads

Goal: Have a listener active from the first byte, ahead of every island bundle.

<!-- In <head>, inline and synchronous so it runs before island scripts.
     Capture phase (true) means it sees events before they reach targets,
     even on inert islands whose own handlers do not yet exist. -->
<script>
  window.__islandBuffer = new Map(); // islandId → ordered event descriptors
  document.addEventListener('pointerdown', (e) => {
    const island = e.target.closest('[data-island-id]');
    // Only buffer for islands that have NOT signalled hydration yet.
    if (!island || island.dataset.hydrated === 'true') return;
    const id = island.dataset.islandId;
    const queue = window.__islandBuffer.get(id) ?? [];
    // Store a serialisable descriptor, not the live event object.
    queue.push({ type: 'pointerdown', selector: cssPath(e.target), t: e.timeStamp });
    window.__islandBuffer.set(id, queue);
    e.preventDefault(); // suppress the inert default so nothing half-fires
  }, true); // ← capture
</script>

Expected output: Clicking an un-hydrated island appends a descriptor to window.__islandBuffer for that island’s id; nothing else happens yet.

Two properties of this listener are deliberate. It is registered in the capture phase (the true third argument) so it runs on the way down the DOM tree, before the event reaches the inert island — capture guarantees the buffer sees the event even though the island’s own bubbling handlers do not yet exist. And it stores a serialisable descriptor rather than the live Event object. The original event is a one-shot artefact of a dispatch that has already completed; you cannot meaningfully re-dispatch it later, and holding it pins DOM references. A plain descriptor — type, a CSS path to the target, any value, and a timestamp — is durable, replayable, and cheap to keep around for the length of the hydration gap.

preventDefault on the buffered click is a judgement call. For islands whose default action is inert anyway (a <button type="button">) it does nothing and is harmless. For a form control or a link inside the island it stops the browser’s native default from firing against half-ready state, which is usually what you want during the pre-hydration window — the replay will apply the intended effect once handlers exist.


Step 2 — Buffer inputs and keystrokes, not just clicks

Goal: Capture the full range of pre-hydration interactions that carry state.

// Extend the same capture listener to inputs so typed values are not lost.
document.addEventListener('input', (e) => {
  const island = e.target.closest('[data-island-id]');
  if (!island || island.dataset.hydrated === 'true') return;
  const id = island.dataset.islandId;
  const queue = window.__islandBuffer.get(id) ?? [];
  // For inputs, record the value so the island can restore it on mount.
  queue.push({ type: 'input', selector: cssPath(e.target), value: e.target.value, t: e.timeStamp });
  window.__islandBuffer.set(id, queue);
}, true);

Expected output: Typing into an un-hydrated field records each input descriptor with its current value, preserving the order of keystrokes.


Step 3 — Signal hydration and flush the buffer

Goal: Let each island announce it is ready and receive its buffered events in order.

// islands/registerReplay.ts — called at the END of an island's mount fn.
export function flushBuffer(node: HTMLElement, handle: (evt: BufferedEvent) => void) {
  const id = node.dataset.islandId!;
  const queue = window.__islandBuffer.get(id) ?? [];
  // Replay strictly in recorded order so causally dependent events stay correct.
  for (const descriptor of queue) handle(descriptor);
  window.__islandBuffer.delete(id);       // clear so nothing replays twice
  node.dataset.hydrated = 'true';         // stop the capture listener buffering
}
// islands/Counter.tsx — Astro-mounted React island. On mount it drains its buffer.
'use client';
import { useEffect, useRef, useState } from 'react';

export default function Counter({ node }: { node: HTMLElement }) {
  const [count, setCount] = useState(0);
  const drained = useRef(false);
  useEffect(() => {
    if (drained.current) return;           // guard against double-drain in StrictMode
    drained.current = true;
    flushBuffer(node, (evt) => {
      // Apply each buffered interaction as if it had hit the live handler.
      if (evt.type === 'pointerdown') setCount((c) => c + 1);
    });
  }, [node]);
  return <button>{count}</button>;
}

Expected output: An island clicked three times while inert mounts showing a count of 3, then behaves normally for subsequent clicks.


Step 4 — Mark hydration and stop buffering

Goal: Ensure the capture listener stops queueing once the island is live.

Setting node.dataset.hydrated = 'true' in flushBuffer (Step 3) makes the Step 1/2 guards short-circuit, so post-hydration events flow straight to the island’s real handlers. If your island also broadcasts to other islands, publish onto the bus described in implementing global event buses for island communication only after the buffer has drained, so replayed events and live events arrive in the correct order.

Expected output: After mount, the same button click updates the island directly with no buffering, confirmed by an empty window.__islandBuffer entry for that id.


Verification

  1. No lost input. Script rapid pre-hydration clicks and assert the final state reflects all of them:

    // Playwright — click three times before hydration, expect count 3.
    test('replays pre-hydration clicks', async ({ page }) => {
      await page.route('**/islands/counter.js', (r) => setTimeout(() => r.continue(), 800)); // delay hydration
      await page.goto('/demo');
      const btn = page.locator('[data-island-id="counter"] button');
      await btn.click(); await btn.click(); await btn.click();   // while inert
      await expect(btn).toHaveText('3');                          // after replay
    });
  2. No duplication. Confirm the buffer entry is deleted after flush and the drain guard prevents a second replay (React StrictMode double-invokes effects in dev). The count must be exactly the number of pre-hydration clicks, never double.

  3. Order preservation. Buffer a sequence where order matters (type “abc” then clear) and assert the island ends in the correct final state, proving events replay in recorded order.

  4. Post-hydration passthrough. After mount, verify window.__islandBuffer has no entry for the island and live clicks update it directly with no buffering overhead.


Troubleshooting

Buffered clicks replay twice, doubling the count

Root cause: Either the buffer was not cleared after flushing, or the mount effect ran twice (React StrictMode in development double-invokes effects), draining the same queue twice.

Fix: Delete the island’s buffer entry inside flushBuffer immediately after replay, and guard the drain with a ref so it runs at most once per island instance. Setting dataset.hydrated = 'true' also stops the capture listener from re-queueing during the second invocation.

if (drained.current) return; drained.current = true;   // run drain once
window.__islandBuffer.delete(id);                      // never replay twice
The capture listener never fires for early clicks

Root cause: The listener is registered inside a deferred or module script that itself loads after the island bundle, so the “early” window it is meant to cover has already passed by the time it attaches.

Fix: Inline the capture-listener script synchronously in the <head>, before any <script type="module"> or island bundle. It must be the first script to run so it is active from the first byte, which is the whole point of a shared-ancestor delegation listener.

A replayed input event does not restore the typed value

Root cause: The replay re-dispatches a synthetic event but the island reads from component state initialised to empty, so the DOM value and the island’s model diverge — the descriptor’s value was never applied to the island’s state.

Fix: On drain, apply the buffered value directly to the island’s state (e.g. setValue(descriptor.value)), not just re-dispatch an event. Buffered inputs carry the value precisely so the island can seed its model from it rather than relying on event side effects.


← Back to Event Delegation in Partially Hydrated Apps