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));