Merge branch 'fix/mobile-flake-ssr-2026-07-16'
Some checks failed
Audit / cargo audit (backend) (push) Failing after 8m37s
Audit / npm audit (frontend) (push) Successful in 37s
Checks / Backend — cargo test + clippy + fmt (push) Failing after 53s
Checks / Frontend — vitest + svelte-check (push) Successful in 10m16s
Checks / E2E — typecheck + lint (push) Successful in 31s
E2E / Playwright E2E (chromium-desktop) (push) Failing after 6m45s
E2E / Cross-UA smoke matrix (push) Failing after 3m29s

This commit is contained in:
fabi
2026-07-16 22:02:43 +02:00
8 changed files with 139 additions and 10 deletions

View File

@@ -34,6 +34,47 @@ export async function longPress(page: Page, locator: Locator, durationMs = 600)
await page.mouse.up(); await page.mouse.up();
} }
/**
* A sub-threshold press dispatched ENTIRELY in-browser: `pointerdown`, an in-browser
* `setTimeout(holdMs)`, then `pointerup` — all on the element carrying `use:longpress`.
*
* Why not `longPress` (page.mouse + waitForTimeout) for the "quick tap must NOT long-press"
* case: those are three independent CDP round-trips with a Node-side wait between the down
* and the up. Under cross-test load the browser can process the `pointerup` far later than
* `holdMs` of *browser* time, so the app's real 500 ms long-press timer fires and the
* ContextSheet opens on what the test intended as a quick tap. (Observed: a 200 ms-intended
* tap opening the sheet ~1 % of full-suite runs.) Dispatching both events from one
* `evaluate` puts the down→up gap and the app's 500 ms timer on the SAME clock: even if the
* event loop stalls, the earlier-scheduled `pointerup` (holdMs) still fires before the
* later 500 ms timer, so the relationship holds. Mirrors `doubleTap`.
*
* No trusted `click` follows a synthetic `pointerup`, so use this ONLY where the assertion is
* that the long-press did NOT fire — not where a real click must land (see `longPress`).
*/
export async function quickTap(locator: Locator, holdMs = 200) {
await locator.evaluate((el: HTMLElement, ms: number) => {
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const mk = (type: string) =>
new PointerEvent(type, {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
pointerType: 'touch',
isPrimary: true,
});
el.dispatchEvent(mk('pointerdown'));
return new Promise<void>((resolve) =>
setTimeout(() => {
el.dispatchEvent(mk('pointerup'));
resolve();
}, ms)
);
}, holdMs);
}
/** /**
* Two rapid pointer-event pairs within the doubletap action's 300 ms window. * Two rapid pointer-event pairs within the doubletap action's 300 ms window.
* *

View File

@@ -12,7 +12,7 @@
*/ */
import { test, expect } from '../../fixtures/test'; import { test, expect } from '../../fixtures/test';
import { uploadRaw } from '../../helpers/upload-client'; import { uploadRaw } from '../../helpers/upload-client';
import { longPress } from '../../helpers/touch'; import { longPress, quickTap } from '../../helpers/touch';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
@@ -67,8 +67,12 @@ test.describe('Mobile — long-press gesture', () => {
const card = page.locator('article').filter({ hasText: g.displayName }).first(); const card = page.locator('article').filter({ hasText: g.displayName }).first();
await expect(card).toBeVisible({ timeout: 10_000 }); await expect(card).toBeVisible({ timeout: 10_000 });
// Simulate a short press (200 ms — well under the 500 ms threshold). // A short press (200 ms — well under the 500 ms threshold), dispatched in-browser so
await longPress(page, card, 200); // its duration is measured on the browser's own clock. `longPress` (page.mouse +
// Node-side waitForTimeout) can't guarantee that under load: the pointerup can reach
// the browser >500 ms after the pointerdown even when Node waited only 200 ms, firing
// the long-press and opening the sheet on a "quick tap". See `quickTap`.
await quickTap(card, 200);
// Within 1 s, the ContextSheet must not be open (aria-modal is set only when // Within 1 s, the ContextSheet must not be open (aria-modal is set only when
// open). A quick tap opens the lightbox instead, which is a different element. // open). A quick tap opens the lightbox instead, which is a different element.

View File

@@ -11,6 +11,13 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
await signIn(page, g); await signIn(page, g);
await page.goto('/account'); await page.goto('/account');
// /account is server-rendered, so its controls exist and look clickable BEFORE Svelte
// hydrates their handlers. The data-mode options are custom `div[role=radio]` (no native
// input fallback), so a pre-hydration click does nothing and the sheet never opens (a
// reproduced ~1% flake). The profile name/expiry are filled in onMount (post-hydration);
// waiting for the real display name is a reliable "the page is interactive now" barrier.
await expect(page.getByText(g.displayName)).toBeVisible();
// Click the "Original" radio in the Datennutzung section to open the warning sheet. // Click the "Original" radio in the Datennutzung section to open the warning sheet.
// The radio's accessible name is its title + description ("Original Lädt die // The radio's accessible name is its title + description ("Original Lädt die
// Originaldateien…"), so anchor on the start, not the end. // Originaldateien…"), so anchor on the start, not the end.
@@ -33,6 +40,10 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
await signIn(page, g); await signIn(page, g);
await page.goto('/account'); await page.goto('/account');
// Wait for /account to hydrate before tapping the (server-rendered) leave button, so the
// click reaches a live handler. See the test above.
await expect(page.getByText(g.displayName)).toBeVisible();
await page.getByRole('button', { name: /Event verlassen/i }).click(); await page.getByRole('button', { name: /Event verlassen/i }).click();
const sheet = page.getByTestId('confirm-sheet'); const sheet = page.getByTestId('confirm-sheet');
await expect(sheet).toBeVisible(); await expect(sheet).toBeVisible();

View File

@@ -19,7 +19,13 @@ test.describe('Mobile — upload composer cancel confirmation', () => {
await page.goto('/upload'); await page.goto('/upload');
const caption = page.getByTestId('upload-caption'); const caption = page.getByTestId('upload-caption');
await expect(caption).toBeVisible(); // The composer is server-rendered, so the textarea is visible (and fillable) BEFORE Svelte
// hydrates its `bind:value`. A fill that lands pre-hydration sets the DOM value but never
// reaches the reactive `caption` state — so `cancel()` sees an empty caption and navigates
// straight to /feed instead of opening the discard sheet (a reproduced ~1% flake). The
// composer auto-focuses the textarea in onMount (which only runs post-hydration), so waiting
// for focus is a reliable "the bindings are live now" barrier before we type.
await expect(caption).toBeFocused();
await caption.fill('a meaningful caption that I do not want to lose'); await caption.fill('a meaningful caption that I do not want to lose');
// Tap the close (X) button in the composer header. // Tap the close (X) button in the composer header.
@@ -44,6 +50,9 @@ test.describe('Mobile — upload composer cancel confirmation', () => {
await signIn(page, g); await signIn(page, g);
await page.goto('/upload'); await page.goto('/upload');
// Wait for the composer to hydrate (onMount auto-focuses the textarea) before tapping
// Abbrechen, so the click reaches a hydrated button. See the test above.
await expect(page.getByTestId('upload-caption')).toBeFocused();
await page.getByRole('button', { name: 'Abbrechen' }).click(); await page.getByRole('button', { name: 'Abbrechen' }).click();
await page.waitForURL('**/feed', { timeout: 3_000 }); await page.waitForURL('**/feed', { timeout: 3_000 });
}); });

View File

@@ -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>

View File

@@ -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.

View 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;

View File

@@ -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() {