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

@@ -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 */ }
}),

View File

@@ -615,39 +615,56 @@
{:else if exportReady}
<p class="flex items-center justify-between gap-2">
<span class="font-medium text-green-700 dark:text-green-300">Keepsake ist bereit.</span>
<span class="flex items-center gap-3">
<!-- Rebuilding a READY keepsake is disruptive: guests who tap Download during the
rebuild get nothing until it finishes. Worth a confirm, unlike the failed case. -->
<button
onclick={() => (confirmAction = {
title: 'Keepsake neu erstellen?',
message:
'Das Keepsake wird aus dem aktuellen Stand der Galerie neu erzeugt. ' +
'Während der Erstellung können Gäste es nicht herunterladen.',
confirmLabel: 'Neu erstellen',
tone: 'danger',
run: rebuildExport
})}
disabled={rebuilding}
data-testid="export-rebuild"
class="font-medium text-gray-500 underline disabled:opacity-50 dark:text-gray-400"
>
Neu erstellen
</button>
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400">Zum Download</a>
</span>
<a href="/export" class="font-medium text-blue-600 underline dark:text-blue-400">Zum Download</a>
</p>
{:else}
<p class="text-red-700 dark:text-red-300">Keepsake-Erstellung fehlgeschlagen.</p>
<button
onclick={rebuildExport}
disabled={rebuilding}
data-testid="export-rebuild"
class="mt-2 rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400"
>
{rebuilding ? 'Wird gestartet…' : 'Erneut versuchen'}
</button>
{/if}
<!-- ONE button, mounted in every state — deliberately OUTSIDE the branches above.
Those branches are driven by SSE (`export-progress` / `export-available`), and
`rebuildExport` itself makes the backend broadcast `export-progress` at 0%
immediately. If the button lived inside a branch, activating it would flip
`exportGenerating` and UNMOUNT THE BUTTON BEING PRESSED — and a worker tick
landing between mousedown and mouseup would do the same unprompted. Chromium
fires no `click` when the element dies mid-sequence, so the host would tap
"Erneut versuchen" and nothing at all would happen, on the one screen whose
entire purpose is recovering a broken keepsake. (Same class of bug as the feed
autocomplete: never let a handler destroy the node that is being clicked.)
So the button's EXISTENCE is invariant; only its label and `disabled` change. -->
<button
onclick={() => {
// Rebuilding a READY keepsake is disruptive — guests who tap Download during
// the rebuild get nothing until it finishes — so it gets a confirm. A FAILED
// keepsake has nothing to lose, so it retries immediately.
if (exportReady) {
confirmAction = {
title: 'Keepsake neu erstellen?',
message:
'Das Keepsake wird aus dem aktuellen Stand der Galerie neu erzeugt. ' +
'Während der Erstellung können Gäste es nicht herunterladen.',
confirmLabel: 'Neu erstellen',
tone: 'danger',
run: rebuildExport
};
} else {
void rebuildExport();
}
}}
disabled={rebuilding || exportGenerating}
data-testid="export-rebuild"
class="mt-2 rounded-lg px-3 py-1.5 text-xs font-medium transition disabled:opacity-50
{exportReady
? 'text-gray-500 underline dark:text-gray-400'
: 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400'}"
>
{rebuilding
? 'Wird gestartet…'
: exportReady
? 'Neu erstellen'
: 'Erneut versuchen'}
</button>
</div>
{/if}
</div>