fix(feed,host): two mis-tap bugs where a live re-render destroys the click target

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 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-15 07:25:19 +02:00
parent af997a84dd
commit c229b560d8
3 changed files with 61 additions and 102 deletions

View File

@@ -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<uuid::Uuid>,
pub event_kind: &'static str,
pub sse_tx: tokio::sync::broadcast::Sender<crate::state::SseEvent>,
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<()>;
}