fix(frontend): render client-side (ssr=false) to close pre-hydration interaction races

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 <style> is CSP-safe via style-src 'unsafe-inline'); the root layout's onMount removes
it once the app paints.

Also fixes the one regression ssr=false exposed: /recover's back chevron used
`cameFromApp = from !== null`, which assumes SSR semantics (cold load => from is null). In
CSR mode the initial navigation reports a non-null `from`, sending history.back() to
about:blank. Key on `type !== 'enter'` instead — SvelteKit's initial-load marker in both
SSR and CSR modes.

Verified: desktop 210 passed, mobile 22 passed, back-chevron 10/10, frontend gates green
(svelte-check 0 errors, eslint, prettier, vitest 46).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-16 21:15:14 +02:00
parent 461b1eaf65
commit 15d338eeb8
4 changed files with 70 additions and 6 deletions

View File

@@ -39,5 +39,59 @@
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
<!--
Cold-start placeholder. With `ssr = false` the app renders entirely client-side,
so `%sveltekit.body%` is empty until the JS bundle mounts. This themed spinner
(the FOUC guard above has already applied `.dark`) fills that gap instead of a
blank flash, and mirrors the /-route splash. The root layout's onMount removes
it once the app has painted. Inline <style> is permitted by our CSP
(style-src 'unsafe-inline'); intentionally no <script> here so script-src stays 'self'.
-->
<div id="app-boot" role="status" aria-label="Lädt">
<span class="app-boot__spinner"></span>
<span class="app-boot__label">EventSnap</span>
</div>
<style>
#app-boot {
position: fixed;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
background: #f9fafb;
}
html.dark #app-boot {
background: #030712;
}
.app-boot__spinner {
width: 2rem;
height: 2rem;
border-radius: 9999px;
border: 2px solid #d1d5db;
border-top-color: #2563eb;
animation: app-boot-spin 0.6s linear infinite;
}
html.dark .app-boot__spinner {
border-color: #374151;
border-top-color: #60a5fa;
}
.app-boot__label {
font-family:
system-ui,
-apple-system,
sans-serif;
font-size: 0.875rem;
font-weight: 500;
color: #9ca3af;
}
@keyframes app-boot-spin {
to {
transform: rotate(360deg);
}
}
</style>
</body>
</html>