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:
@@ -39,5 +39,59 @@
|
|||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMount(async () => {
|
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();
|
initAuth();
|
||||||
// Hooks up the appliedTheme → <html class="dark"> sync. Must run early so the
|
// Hooks up the appliedTheme → <html class="dark"> sync. Must run early so the
|
||||||
// first paint after hydration matches the saved preference.
|
// first paint after hydration matches the saved preference.
|
||||||
|
|||||||
6
frontend/src/routes/+layout.ts
Normal file
6
frontend/src/routes/+layout.ts
Normal file
@@ -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;
|
||||||
@@ -5,13 +5,14 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import IconButton from '$lib/components/IconButton.svelte';
|
import IconButton from '$lib/components/IconButton.svelte';
|
||||||
|
|
||||||
// `from` is non-null only when we arrived here via in-app (client-side)
|
// We only want history.back() when the user actually reached /recover via in-app
|
||||||
// navigation; on a full-page load (deep link, new tab) it's null. Using
|
// (client-side) navigation; on a cold load (deep link, new tab) history.back() would
|
||||||
// history.length is unreliable — a fresh tab keeps `about:blank` as the prior
|
// land on `about:blank`. `type === 'enter'` is SvelteKit's initial page load in BOTH
|
||||||
// entry, so history.back() would land there instead of inside the app.
|
// 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);
|
let cameFromApp = $state(false);
|
||||||
afterNavigate(({ from }) => {
|
afterNavigate(({ from, type }) => {
|
||||||
cameFromApp = from !== null;
|
cameFromApp = type !== 'enter' && from !== null;
|
||||||
});
|
});
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
|
|||||||
Reference in New Issue
Block a user