From 15d338eeb8f56ddb9524096b5a8b16b6ebbd51ef Mon Sep 17 00:00:00 2001 From: fabi Date: Thu, 16 Jul 2026 21:15:14 +0200 Subject: [PATCH] fix(frontend): render client-side (ssr=false) to close pre-hydration interaction races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventSnap is a client-only SPA: auth is a localStorage JWT and every page fetches its data in onMount, so SSR only ever produced a logged-out skeleton — including interactive controls that exist in the DOM before hydration attaches their handlers. Interacting in that window silently no-ops: a caption typed on /upload never reaches the reactive state (so cancel() navigates away instead of confirming), and a click on /account's custom role=radio / leave button does nothing. This surfaced as a ~1-4% mobile e2e flake and is a real (if brief) bug for users on slow connections too. Disable SSR app-wide (csr stays on). The server now ships a ~2.5 KB shell with no page controls, so the pre-hydration window is structurally impossible. No SEO is lost (private, QR-gated app). app.html paints a themed boot spinner to cover the JS-load gap (script-free; inline diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 3ba6225..1e5a4d0 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -30,6 +30,9 @@ }); onMount(async () => { + // With `ssr = false` the server ships an empty shell; `app.html` paints a boot + // spinner to cover the JS-load gap. The app has now mounted and painted, so drop it. + document.getElementById('app-boot')?.remove(); initAuth(); // Hooks up the appliedTheme → sync. Must run early so the // first paint after hydration matches the saved preference. diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..7806c0c --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1,6 @@ +// EventSnap is a client-only SPA: auth lives in localStorage (JWT) and every page fetches its +// data in onMount, so the server has nothing to render but a logged-out skeleton. Disabling SSR +// app-wide removes that dead skeleton (empty feeds, "Unbekannt" profile) and — crucially — the +// window where server-rendered interactive controls exist BEFORE hydration attaches their handlers. +// csr stays on (default). No SEO is lost (private, QR-gated app). +export const ssr = false; diff --git a/frontend/src/routes/recover/+page.svelte b/frontend/src/routes/recover/+page.svelte index 40b9bc5..351325a 100644 --- a/frontend/src/routes/recover/+page.svelte +++ b/frontend/src/routes/recover/+page.svelte @@ -5,13 +5,14 @@ import { browser } from '$app/environment'; import IconButton from '$lib/components/IconButton.svelte'; - // `from` is non-null only when we arrived here via in-app (client-side) - // navigation; on a full-page load (deep link, new tab) it's null. Using - // history.length is unreliable — a fresh tab keeps `about:blank` as the prior - // entry, so history.back() would land there instead of inside the app. + // We only want history.back() when the user actually reached /recover via in-app + // (client-side) navigation; on a cold load (deep link, new tab) history.back() would + // land on `about:blank`. `type === 'enter'` is SvelteKit's initial page load in BOTH + // SSR and CSR modes — keying on it (rather than `from === null`, which is only null on + // a cold load under SSR) keeps the check correct with `ssr = false`. let cameFromApp = $state(false); - afterNavigate(({ from }) => { - cameFromApp = from !== null; + afterNavigate(({ from, type }) => { + cameFromApp = type !== 'enter' && from !== null; }); function goBack() {