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
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:
@@ -34,6 +34,47 @@ export async function longPress(page: Page, locator: Locator, durationMs = 600)
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
import { test, expect } from '../../fixtures/test';
|
||||
import { uploadRaw } from '../../helpers/upload-client';
|
||||
import { longPress } from '../../helpers/touch';
|
||||
import { longPress, quickTap } from '../../helpers/touch';
|
||||
import { readFileSync } from 'node:fs';
|
||||
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();
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Simulate a short press (200 ms — well under the 500 ms threshold).
|
||||
await longPress(page, card, 200);
|
||||
// A short press (200 ms — well under the 500 ms threshold), dispatched in-browser so
|
||||
// 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
|
||||
// open). A quick tap opens the lightbox instead, which is a different element.
|
||||
|
||||
@@ -11,6 +11,13 @@ test.describe('Mobile a11y — sheets dismiss on Escape', () => {
|
||||
await signIn(page, g);
|
||||
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.
|
||||
// The radio's accessible name is its title + description ("Original Lädt die
|
||||
// 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 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();
|
||||
const sheet = page.getByTestId('confirm-sheet');
|
||||
await expect(sheet).toBeVisible();
|
||||
|
||||
@@ -19,7 +19,13 @@ test.describe('Mobile — upload composer cancel confirmation', () => {
|
||||
await page.goto('/upload');
|
||||
|
||||
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');
|
||||
|
||||
// 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 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.waitForURL('**/feed', { timeout: 3_000 });
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 → <html class="dark"> sync. Must run early so the
|
||||
// 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 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() {
|
||||
|
||||
Reference in New Issue
Block a user