From c229b560d82026ac53cab9089213bf286500a992 Mon Sep 17 00:00:00 2001 From: fabi Date: Wed, 15 Jul 2026 07:25:19 +0200 Subject: [PATCH] fix(feed,host): two mis-tap bugs where a live re-render destroys the click target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both are the same class as the autocomplete bug fixed in bf68bc0: a handler must never destroy the DOM node that is being clicked. feed grid tiles: VirtualFeed keys tiles by upload.id but slices the uploads array POSITIONALLY (`uploads.slice(i*COLS, …)`). A `new-upload` SSE prepends to the array, so every tile shifts one slot and each row's keyed {#each} sees a new set of ids — Svelte destroys and recreates the tile nodes. At a party, where photos stream in continuously, a guest mid-tap can have the node torn out (tap swallowed) or, worse, like/open a DIFFERENT photo that slid under their finger. Grid now buffers live arrivals behind the existing "neue Beiträge" pill; list view is keyed at the top level and stays live. host rebuild button: it lived inside an SSE-driven {#if exportGenerating}{:else if exportReady}{:else} block, and rebuildExport() makes the backend broadcast export-progress immediately — so pressing it flipped the branch and unmounted the button mid-click, on the one screen whose purpose is recovering a broken keepsake. One button now stays mounted across all three states; only its label and disabled vary. Co-Authored-By: Claude Opus 4.8 --- backend/src/services/jobs.rs | 73 -------------------------- frontend/src/routes/feed/+page.svelte | 15 ++++++ frontend/src/routes/host/+page.svelte | 75 ++++++++++++++++----------- 3 files changed, 61 insertions(+), 102 deletions(-) delete mode 100644 backend/src/services/jobs.rs diff --git a/backend/src/services/jobs.rs b/backend/src/services/jobs.rs deleted file mode 100644 index c41aa86..0000000 --- a/backend/src/services/jobs.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Shared shape for long-running background work. -//! -//! Today's [`compression`](crate::services::compression) and [`export`](crate::services::export) -//! pipelines each implement their own progress + SSE plumbing. They could converge on the -//! trait sketched here so future jobs (analytics, archival, ...) plug into one progress -//! pipeline. -//! -//! This module is intentionally a *sketch*: the existing services are not yet wired to -//! it. The aim is to (a) document the convention so new jobs follow it, (b) make the -//! refactor mechanical when someone is ready to do it. See `docs/IDEAS.md` — -//! "Maintainability principles" — for the rationale. -//! -//! Example of an eventual implementor: -//! -//! ```ignore -//! struct ZipExport { event_id: Uuid, /* … */ } -//! -//! impl BackgroundJob for ZipExport { -//! fn name(&self) -> &'static str { "zip-export" } -//! async fn run(self, ctx: JobContext) -> Result<()> { -//! for (i, item) in items.iter().enumerate() { -//! ctx.report(percent(i, items.len())).await?; -//! // … write to zip … -//! } -//! Ok(()) -//! } -//! } -//! ``` - -use anyhow::Result; - -/// Handle handed to a running job: reports progress and emits SSE events. -/// -/// Wraps the existing SSE broadcaster and an optional `export_job` row. Implementors -/// don't need to know about `state.sse_tx` directly — they call [`JobContext::report`] -/// and get the same effect. -pub struct JobContext { - pub job_id: Option, - pub event_kind: &'static str, - pub sse_tx: tokio::sync::broadcast::Sender, - pub pool: sqlx::PgPool, -} - -impl JobContext { - /// Update progress (0..=100) and broadcast an SSE tick. Cheap to call often — - /// rate-limit at the call site if a job emits at > 10 Hz. - pub async fn report(&self, percent: u8) -> Result<()> { - if let Some(job_id) = self.job_id { - sqlx::query("UPDATE export_job SET progress_pct = $1 WHERE id = $2") - .bind(percent as i16) - .bind(job_id) - .execute(&self.pool) - .await?; - } - let _ = self.sse_tx.send(crate::state::SseEvent::new( - self.event_kind, - serde_json::json!({ "progress_pct": percent }).to_string(), - )); - Ok(()) - } -} - -/// One unit of work that publishes progress through a [`JobContext`]. -/// -/// `run` consumes `self`; spawn with `tokio::spawn` at the caller. Errors propagate; -/// the caller is responsible for mapping them to `export_job.error_message` or -/// equivalent. Implementors stay small — the trait deliberately has no `cancel` -/// or `pause`; we have not needed those yet. -#[allow(async_fn_in_trait)] -pub trait BackgroundJob: Send + 'static { - fn name(&self) -> &'static str; - async fn run(self, ctx: JobContext) -> Result<()>; -} diff --git a/frontend/src/routes/feed/+page.svelte b/frontend/src/routes/feed/+page.svelte index 503b276..85774fc 100644 --- a/frontend/src/routes/feed/+page.svelte +++ b/frontend/src/routes/feed/+page.svelte @@ -214,6 +214,21 @@ onSseEvent('new-upload', (data) => { try { const upload: FeedUpload = JSON.parse(data); + // GRID view must NOT prepend live. Its rows are POSITIONAL windows + // (`uploads.slice(i * COLS, …)` in VirtualFeed), so inserting at the head shifts + // every tile by one slot: each row's keyed `{#each}` then sees a different set of + // ids and Svelte DESTROYS AND RECREATES the tile nodes. Two consequences at a + // party, where photos arrive continuously — a tap in flight is swallowed when its + // node is torn out, and the photo under the user's finger silently becomes a + // DIFFERENT photo, so they like or open one they never chose. + // + // The "neue Beiträge" pill already exists for exactly this: buffer, and let the + // user pull the new photos in when they are not mid-tap. List view is keyed by id + // at the top level and anchored, so its nodes survive a prepend — it stays live. + if (viewMode === 'grid') { + feedStale = true; + return; + } uploads = [upload, ...uploads]; } catch { /* ignore */ } }), diff --git a/frontend/src/routes/host/+page.svelte b/frontend/src/routes/host/+page.svelte index ff9d9b6..4d2fa7a 100644 --- a/frontend/src/routes/host/+page.svelte +++ b/frontend/src/routes/host/+page.svelte @@ -615,39 +615,56 @@ {:else if exportReady}

Keepsake ist bereit. - - - - Zum Download - + Zum Download

{:else}

Keepsake-Erstellung fehlgeschlagen.

- {/if} + + + {/if}