fix(feed): survive a bad network, and let the lightbox actually browse
Four things a guest on congested venue wifi would have hit, and one they would have
hit immediately.
A FAILED FEED LOAD CLAIMED THE GALLERY WAS EMPTY. `loadFeed` caught, toasted for five
seconds and left `uploads` empty, so the page fell through to "Noch keine Fotos. Tippe
auf den Kamera-Button unten!" — the most likely first impression at the party, and a
lie. There is now a distinct error state with "Erneut laden". Refreshes suppressed the
toast entirely, so pull-to-refresh and the "Neue Beiträge" pill failed in total
silence; they now report, and the pill survives its own failure instead of clearing
before the request.
THE FILTER-EMPTY STATE WAS DEAD CODE. With filtering server-side `displayUploads` is a
plain alias of `uploads`, so the grid's "Keine Treffer für die gewählten Filter." plus
its reset button sat behind an identical earlier branch and could never render — a
guest tapping a chip with no matches was told to go take a photo.
SSE COULD FREEZE THE FEED FOR THE WHOLE EVENING. Nothing in the feed ever refetched on
a timer; every update path was triggered exclusively by a stream event. Behind a proxy
that buffers `text/event-stream` `onopen` never fires, so the guest saw only the photos
that were on screen when they arrived; and a socket left half-open by an AP roam is
worse, because `connectSse` early-returns on a non-null EventSource and nothing ever
reconnects. A pure silence timer is not implementable — the backend sends keep-alives
as SSE comments, which the EventSource parser discards without dispatching — so
liveness is established on evidence instead: a jittered 60-120s `/feed/delta` backstop
that reconnects when a poll returns content the stream never delivered. The ticket
round-trip also seeds the delta cursor before the EventSource is created, so the
backstop has a `since` even if `onopen` never fires.
THE PILL COLLAPSED A DEEPLY-SCROLLED FEED to 20 items and dumped the guest at an
arbitrary scroll position — the exact yank the pill exists to avoid. It merges now.
The refresh debounce was 800ms + jitter, which during a burst is roughly one feed query
per client every two seconds; at 100 guests that approaches the 60/min per-user limit,
and the resulting 429s were swallowed by a bare `catch {}`, so the feed would simply
stop updating with no signal. Now 8s + jitter, coalescing, and skipped entirely while
the page is hidden.
Not one `<img>` in the app had an `onerror`. `pickMediaUrl` falls back to the original
whenever preview and thumbnail are null — i.e. for everything still compressing, which
during a burst is the top of the feed — so a 404 there rendered an empty grey box with
`alt=""`, not even a message. Each now retries once, then shows the placeholder.
The lightbox had no swipe, no prev/next and no arrow keys, so browsing 300 photos meant
closing and reopening the modal for every one — while FEATURES.md and USER_JOURNEYS
both claimed swipe shipped. It now has chevrons (44px, German aria-labels, hidden at
the ends), arrow keys, and horizontal swipe, with focus handed to the surviving control
so a disappearing chevron can't drop focus to `<body>`. Comment deletion was a ~14px
`✕` four pixels from the text that deleted permanently on one tap, while deleting a
POST two components away goes through a ConfirmSheet; it now matches.
`feed-filter.ts` and its test are deleted — with the server filtering, they were dead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,27 @@ const handlers: Map<string, EventHandler[]> = new Map();
|
||||
let reconnectAttempt = 0;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* True once a stream has opened at least once this session. Distinguishes "first
|
||||
* connect" from "reconnect" now that `lastEventTime` is seeded at ticket-mint rather
|
||||
* than in `onopen` (see `connectSse`) — without it every boot would fire a pointless
|
||||
* zero-width delta.
|
||||
*/
|
||||
let streamEverOpened = false;
|
||||
|
||||
/**
|
||||
* `Date.now()` of the last thing we actually received on the live stream.
|
||||
*
|
||||
* Keep-alives are deliberately NOT observable here: the backend sends them as SSE
|
||||
* COMMENTS (`KeepAlive::new().text("ping")` emits `:ping`), and the EventSource parser
|
||||
* discards comments without dispatching anything at all. There is no browser API that
|
||||
* exposes them. So liveness cannot be decided by a plain silence timer — it could not
|
||||
* tell a dead socket from a genuinely quiet half hour, and would churn reconnects for
|
||||
* every guest through every lull. The backstop poll below decides it on evidence
|
||||
* instead: the server had news that this stream never delivered.
|
||||
*/
|
||||
let lastStreamActivity = 0;
|
||||
|
||||
/**
|
||||
* SSE event names emitted by the backend. Add new ones here as `state.sse_tx.send`
|
||||
* call sites grow — every entry becomes a relay registration below.
|
||||
@@ -91,29 +112,40 @@ export function connectSse(): void {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
// Auth flow may have torn things down while we were awaiting the ticket.
|
||||
if (!getToken() || eventSource) return;
|
||||
// Seed the reconnect cursor the moment we have a server clock — NOT in `onopen`,
|
||||
// which is where it used to live. `onopen` never fires behind a captive portal or
|
||||
// any proxy that buffers `text/event-stream`, so on exactly the networks where the
|
||||
// stream fails the cursor stayed `null` forever and the backstop poll below had no
|
||||
// `since` to fetch from. Still the server clock and never `new Date()`: a skewed
|
||||
// browser clock would shift the window and silently drop uploads.
|
||||
if (!lastEventTime) lastEventTime = serverTime;
|
||||
|
||||
// Auth flow may have torn things down while we were awaiting the ticket — and the
|
||||
// phone may have gone to sleep during that round-trip. Opening a stream while
|
||||
// hidden is worse than not opening one: iOS reaps a backgrounded socket without
|
||||
// ever firing `onerror`, so `eventSource` stays non-null and the guard at the top
|
||||
// of this function then treats the corpse as a live connection for the rest of the
|
||||
// evening. The visibility handler reconnects us when the screen comes back.
|
||||
if (!getToken() || eventSource || (typeof document !== 'undefined' && document.hidden)) return;
|
||||
|
||||
eventSource = new EventSource(`/api/v1/stream?ticket=${encodeURIComponent(ticket)}`);
|
||||
|
||||
eventSource.onopen = () => {
|
||||
// Successful connection — reset the backoff counter.
|
||||
reconnectAttempt = 0;
|
||||
// If we have a previous timestamp this is a reconnect — fetch the gap. The
|
||||
// delta advances `lastEventTime` from the SERVER clock it returns.
|
||||
const since = lastEventTime;
|
||||
if (since) {
|
||||
void deltaFetchAndFan(since);
|
||||
} else {
|
||||
// First connect: seed the cursor from the server clock at ticket-mint time,
|
||||
// never `new Date()` — a skewed browser clock would otherwise shift the very
|
||||
// first reconnect window and could drop uploads.
|
||||
lastEventTime = serverTime;
|
||||
}
|
||||
noteStreamActivity();
|
||||
// A reconnect has a gap to close; the very first open of a session does not
|
||||
// (the cursor was just seeded from this ticket's server clock). The delta
|
||||
// advances `lastEventTime` from the SERVER clock it returns.
|
||||
if (streamEverOpened && lastEventTime) void deltaFetchAndFan(lastEventTime);
|
||||
streamEverOpened = true;
|
||||
};
|
||||
|
||||
for (const eventName of KNOWN_EVENTS) {
|
||||
eventSource.addEventListener(eventName, (e) => dispatch(eventName, (e as MessageEvent).data));
|
||||
eventSource.addEventListener(eventName, (e) => {
|
||||
noteStreamActivity();
|
||||
dispatch(eventName, (e as MessageEvent).data);
|
||||
});
|
||||
}
|
||||
|
||||
// `resync` is emitted by the server when our broadcast subscription fell
|
||||
@@ -123,6 +155,7 @@ export function connectSse(): void {
|
||||
// own listener — not via `dispatch` — so reading `lastEventTime` as the gap
|
||||
// start isn't clobbered by dispatch bumping it to "now".
|
||||
eventSource.addEventListener('resync', () => {
|
||||
noteStreamActivity();
|
||||
const since = lastEventTime;
|
||||
if (since) void deltaFetchAndFan(since);
|
||||
});
|
||||
@@ -141,7 +174,14 @@ export function connectSse(): void {
|
||||
function scheduleReconnect(): void {
|
||||
reconnectAttempt++;
|
||||
const delay = Math.min(60_000, 1_000 * 2 ** (reconnectAttempt - 1));
|
||||
const jitter = Math.random() * 500;
|
||||
// Jitter must SCALE WITH the backoff, not be a flat 500ms. Every client that dropped
|
||||
// together shares the same `reconnectAttempt`, so they compute an identical `delay` —
|
||||
// a fixed 500ms window spreads 100 phones over half a second no matter how long the
|
||||
// backoff grew, which is the thundering herd the backoff exists to prevent. Each
|
||||
// reconnect costs a ticket POST + stream GET + feed-delta fetch, so the herd lands on
|
||||
// the DB pool three times over. Scaling the jitter to the delay (floored at 1s so the
|
||||
// first, most synchronised retry is spread too) turns that into a smooth ramp.
|
||||
const jitter = Math.random() * Math.max(delay, 1_000);
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(connectSse, delay + jitter);
|
||||
}
|
||||
@@ -157,6 +197,91 @@ export function disconnectSse(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function noteStreamActivity(): void {
|
||||
lastStreamActivity = Date.now();
|
||||
}
|
||||
|
||||
// ── Stream backstop ────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// EVERY feed update is triggered by an SSE event — there is no periodic refetch — so a
|
||||
// stream that stops delivering freezes a guest's gallery for the rest of the evening.
|
||||
// And a venue produces exactly the two failures that the reconnect path cannot see:
|
||||
//
|
||||
// • a captive portal or any proxy that buffers `text/event-stream` never forwards a
|
||||
// byte, so `onopen` may never fire and neither does `onerror`;
|
||||
// • a phone that roams between APs leaves a HALF-OPEN socket — the connection is gone
|
||||
// but `readyState` still reads OPEN, no error is raised, and `connectSse` early-
|
||||
// returns on its non-null `eventSource`, so nothing ever reconnects.
|
||||
//
|
||||
// Neither surfaces anything to react to, which is why the only honest backstop is to
|
||||
// ask the server. `/feed/delta` is idempotent and answers with an empty payload when
|
||||
// nothing changed, and one request per 60–120s is ~1/60th of the per-user feed limit —
|
||||
// cheap insurance against a guest staring at a frozen feed all night. The diashow uses
|
||||
// the same reconcile-on-a-timer for the same reason (`RECONCILE_INTERVAL_MS` there).
|
||||
const BACKSTOP_MIN_MS = 60_000;
|
||||
const BACKSTOP_MAX_MS = 120_000;
|
||||
let backstopTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let backstopEnabled = false;
|
||||
|
||||
/**
|
||||
* Start the poll-based liveness/completeness backstop. Opt-in per page (the feed is the
|
||||
* consumer that needs it; the export page's SSE use is a status ping and the diashow
|
||||
* runs its own full reconcile), and idempotent.
|
||||
*/
|
||||
export function startStreamBackstop(): void {
|
||||
if (typeof document === 'undefined' || backstopEnabled) return;
|
||||
backstopEnabled = true;
|
||||
scheduleBackstop();
|
||||
}
|
||||
|
||||
export function stopStreamBackstop(): void {
|
||||
backstopEnabled = false;
|
||||
if (backstopTimer) {
|
||||
clearTimeout(backstopTimer);
|
||||
backstopTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBackstop(): void {
|
||||
if (backstopTimer) clearTimeout(backstopTimer);
|
||||
// Jittered for the same reason the reconnect backoff is: ~100 phones that joined
|
||||
// within the same few minutes would otherwise poll in permanent lockstep.
|
||||
const delay = BACKSTOP_MIN_MS + Math.random() * (BACKSTOP_MAX_MS - BACKSTOP_MIN_MS);
|
||||
backstopTimer = setTimeout(() => void runBackstop(), delay);
|
||||
}
|
||||
|
||||
async function runBackstop(): Promise<void> {
|
||||
backstopTimer = null;
|
||||
try {
|
||||
// A hidden tab has no stream (the visibility handler closed it) and cannot show a
|
||||
// result anyway; the reopen path already fetches the gap.
|
||||
if (document.hidden || !getToken()) return;
|
||||
// No stream, or one the browser has admitted is closed, while we are visible: there
|
||||
// is no other way back, because `connectSse`'s `eventSource` guard cannot tell a
|
||||
// corpse from a live connection.
|
||||
if (!eventSource || eventSource.readyState === EventSource.CLOSED) {
|
||||
disconnectSse();
|
||||
reconnectAttempt = 0;
|
||||
connectSse();
|
||||
return;
|
||||
}
|
||||
const since = lastEventTime;
|
||||
if (!since) return;
|
||||
const activityBefore = lastStreamActivity;
|
||||
const carried = await deltaFetchAndFan(since);
|
||||
// The server had news that this stream never delivered. That is the evidence a
|
||||
// half-open socket cannot otherwise give us — reconnect, or every remaining update
|
||||
// tonight arrives at poll latency instead of instantly.
|
||||
if (carried && lastStreamActivity === activityBefore) {
|
||||
disconnectSse();
|
||||
reconnectAttempt = 0;
|
||||
connectSse();
|
||||
}
|
||||
} finally {
|
||||
if (backstopEnabled) scheduleBackstop();
|
||||
}
|
||||
}
|
||||
|
||||
export function getLastEventTime(): string | null {
|
||||
return lastEventTime;
|
||||
}
|
||||
@@ -196,14 +321,23 @@ function extractCreatedAt(data: string): string | undefined {
|
||||
* event. Subscribers (typically the feed page) merge the result into their
|
||||
* in-memory list. Swallows errors — a failed delta is non-fatal; the next live
|
||||
* SSE event will keep the feed moving.
|
||||
*
|
||||
* Resolves to whether the delta actually CARRIED something. `runBackstop` uses that as
|
||||
* its liveness signal: content the poll found but the stream never pushed means the
|
||||
* stream is dead in the way the browser will not report.
|
||||
*/
|
||||
async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
|
||||
async function deltaFetchAndFan(since: string, attempt = 0): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.get<DeltaResponse>(`/feed/delta?since=${encodeURIComponent(since)}`);
|
||||
// Advance the cursor to the server clock this delta was computed at, so the next
|
||||
// reconnect resumes exactly where the server left off (no browser-clock skew).
|
||||
lastEventTime = response.server_time;
|
||||
dispatch('feed-delta', JSON.stringify(response));
|
||||
return (
|
||||
response.uploads.length > 0 ||
|
||||
response.deleted_ids.length > 0 ||
|
||||
response.hidden_user_ids.length > 0
|
||||
);
|
||||
} catch (e) {
|
||||
// A throttled delta (429) must NOT be silently dropped: live events keep advancing
|
||||
// `lastEventTime`, so the next reconnect would resume PAST this un-fetched gap and
|
||||
@@ -211,10 +345,15 @@ async function deltaFetchAndFan(since: string, attempt = 0): Promise<void> {
|
||||
// covered regardless of how the live cursor moves in the meantime. Bounded, and only
|
||||
// reachable by a rapidly flapping EventSource hitting the per-user delta limit.
|
||||
if (e instanceof ApiError && e.status === 429 && attempt < MAX_DELTA_RETRIES) {
|
||||
const delayMs = DELTA_RETRY_BASE_MS * 2 ** attempt;
|
||||
setTimeout(() => void deltaFetchAndFan(since, attempt + 1), delayMs);
|
||||
// Jittered for the same reason `scheduleReconnect` is: the clients that hit this
|
||||
// 429 are the ones that just reconnected together after a venue-wide wifi blip,
|
||||
// so they share `attempt` and would otherwise retry in lockstep at exactly 2s,
|
||||
// 4s, 8s — re-tripping the same per-user limit in a synchronised wave.
|
||||
const base = DELTA_RETRY_BASE_MS * 2 ** attempt;
|
||||
setTimeout(() => void deltaFetchAndFan(since, attempt + 1), base + Math.random() * base);
|
||||
}
|
||||
// Other errors are non-fatal — the next live SSE event keeps the feed moving.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +367,12 @@ function handleVisibilityChange() {
|
||||
if (document.hidden) {
|
||||
disconnectSse();
|
||||
} else {
|
||||
// Tear down UNCONDITIONALLY before reconnecting rather than leaning on
|
||||
// `connectSse`'s `eventSource` guard. iOS can reap a backgrounded socket without
|
||||
// ever firing `onerror`, which leaves a non-null but permanently dead EventSource —
|
||||
// and the guard would then read that as "already connected" and never reconnect,
|
||||
// for the rest of the evening. Closing an already-closed EventSource is a no-op.
|
||||
disconnectSse();
|
||||
// User-initiated reconnect — clear backoff so we don't wait out a long
|
||||
// retry delay that was scheduled from a prior background error.
|
||||
reconnectAttempt = 0;
|
||||
|
||||
Reference in New Issue
Block a user