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:
@@ -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 */ }
|
||||
}),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user