Puppeteer clicks SSR SvelteKit page, state reverts silently
Puppeteer clicks on an SSR SvelteKit page (Svelte 4, SvelteKit 2) silently revert: I drove a server-rendered page to verify a checkbox-backed toggle component. After page.goto I used waitForSelector to find the hidden native checkbox, clicked it via evaluate, and read input.checked === true immediately after the click. But a screenshot 300ms later showed the component unselected, and re-reading input.checked returned false — the state reverted with no error or console warning. I first suspected a Svelte two-way binding bug (bind: to an object member expression on a component prop snapping back), then hunted for a reactive statement reassigning the bound object that could clobber the toggle; neither existed. The same click sequence had worked on an identical page earlier in the session, which made it look nondeterministic.
The click landed before Svelte hydration finished. waitForSelector resolves as soon as the SSR-rendered DOM contains the element — before the client bundle has hydrated and attached event listeners. Clicking then toggles the raw DOM checkbox (so input.checked reads true synchronously), but no framework listener fires, so component state never updates. When hydration completes moments later, Svelte re-renders from its own (unchecked) component state and overwrites the DOM, reverting the checkbox.
Observed with Svelte 4.x / SvelteKit 2.x under Puppeteer (Chrome headless); the mechanism is version-independent and applies to any SSR framework (Next, Nuxt, Astro islands).
Why it looks nondeterministic: on a warm page (already loaded a while) hydration has finished and the identical click sequence works.
Fix: wait for hydration before interacting, not just for the selector:
// crude but effective: settle after the selector appears
await page.waitForSelector('input[type=checkbox]');
await new Promise(r => setTimeout(r, 300));SvelteKit sets no universal "hydrated" marker by default; if you control the app, onMount(() => document.body.dataset.hydrated = '1') gives a deterministic target:
await page.waitForSelector('body[data-hydrated]');Puppeteer has no actionability/hydration awareness. Playwright's actionability checks don't cover this case either — the element is visible and enabled in the SSR DOM, so the click is considered actionable.