From ad92bf6645b77a06e0574dfe3e466250ca798096 Mon Sep 17 00:00:00 2001 From: Jonatan Kronander Date: Fri, 7 Aug 2026 15:47:57 +0200 Subject: [PATCH] fix(plugin-tiling): dedupe tile ids when the scale changes The UPDATE_VISIBLE_TILES scale-change branch concatenated carried fallback tiles with fresh tiles without deduplicating ids. Tile ids encode page/scale/rect but not rotation, so rotating 90 -> 270 under a fit zoom mode revisits an earlier scale and re-generates identical ids while that generation's fallbacks can still be alive (they are only purged once every fresh tile of the page is ready, which a slow rasterizer may never reach between presses). Keyed renderers crash on the duplicates - in Svelte this throws each_key_duplicate mid-flush and can wedge the tab. The fresh tile wins on collision: an identical id means identical page/scale/rect, and the fresh tile keeps the normal render lifecycle so the all-ready fallback purge still fires. Co-Authored-By: Claude Fable 5 --- .changeset/tiling-duplicate-tile-ids.md | 5 +++++ packages/plugin-tiling/src/lib/reducer.ts | 13 +++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 .changeset/tiling-duplicate-tile-ids.md diff --git a/.changeset/tiling-duplicate-tile-ids.md b/.changeset/tiling-duplicate-tile-ids.md new file mode 100644 index 000000000..9f9a3ad88 --- /dev/null +++ b/.changeset/tiling-duplicate-tile-ids.md @@ -0,0 +1,5 @@ +--- +'@embedpdf/plugin-tiling': patch +--- + +Fix duplicate tile ids emitted by the UPDATE_VISIBLE_TILES reducer when the scale changes while fallback tiles from an earlier generation are still present. Tile ids encode page/scale/rect but not rotation, so rotating 90° → 270° under a fit zoom mode revisits an earlier scale and re-generates identical ids; the scale-change branch concatenated carried and fresh tiles without deduplication. Keyed renderers crash on the duplicates — in Svelte this throws `each_key_duplicate` mid-flush and can wedge the tab. diff --git a/packages/plugin-tiling/src/lib/reducer.ts b/packages/plugin-tiling/src/lib/reducer.ts index c9e8306d2..482f4bc5e 100644 --- a/packages/plugin-tiling/src/lib/reducer.ts +++ b/packages/plugin-tiling/src/lib/reducer.ts @@ -64,8 +64,17 @@ export const tilingReducer: Reducer = (state, action) /* 2️⃣ decide which fallback tiles to keep */ const fallbackToCarry = promoted.length > 0 ? [] : prevTiles.filter((t) => t.isFallback); - /* 3️⃣ final list = (maybe-kept fallback) + promoted + newTiles */ - nextPages[pageIndex] = [...fallbackToCarry, ...promoted, ...newTiles]; + /* 3️⃣ final list = (maybe-kept fallback) + promoted + newTiles. + Tile ids encode page/scale/rect but not rotation, so a carried + tile can share an id with a new tile (e.g. rotating 90° → 270° + under a fit zoom mode revisits the same scale and rects while + old fallbacks are still alive). Keyed renderers crash on + duplicate ids, so the fresh tile wins. */ + const newIds = new Set(newTiles.map((t) => t.id)); + nextPages[pageIndex] = [ + ...[...fallbackToCarry, ...promoted].filter((t) => !newIds.has(t.id)), + ...newTiles, + ]; } else { /* same zoom → keep current fallback, replace visible */ const newIds = new Set(newTiles.map((t) => t.id));