Skip to content

fix(plugin-tiling): dedupe tile ids when the scale changes - #721

Open
jkkronk wants to merge 1 commit into
embedpdf:v2from
jkkronk:fix/tiling-duplicate-tile-ids
Open

jkkronk wants to merge 1 commit into
embedpdf:v2from
jkkronk:fix/tiling-duplicate-tile-ids

Conversation

@jkkronk

@jkkronk jkkronk commented Aug 7, 2026

Copy link
Copy Markdown

Bug

Rotating a document repeatedly while a fit zoom mode is active (FitWidth / FitPage / Automatic) can leave duplicate tile.ids in visibleTiles[page]. Every keyed consumer of that array then breaks: the Svelte TilingLayer ({#each tiles as tile (tile.id)}) throws each_key_duplicate mid-flush — in our production app (Svelte, @embedpdf/*@2.14.4) this wedges the whole tab after ~3 rotate presses. Safari reproduces most readily because slower rasterization keeps fallback tiles alive across presses; React/Vue keyed lists would silently misbehave on the same state.

Console stack from a production bundle at the moment of the freeze:

Error: https://svelte.dev/e/each_key_duplicate
  flush ...
  emit
  pushScrollerLayout
  onStoreUpdated
  refreshDocumentLayout
  onRotationChanged
  dispatchToCore
  setRotationForDocument
  rotateForward

Mechanism

Three ingredients, all in plugin-tiling:

  1. Tile ids encode page/scale/rect but not rotation: `p${page.index}-${scale}-x${x}-y${y}-w${w}-h${h}`. Under a fit zoom mode, rotating alternates between two fit scales, so the third 90° press revisits the first press's scale and rects and regenerates identical ids for a different tile generation.
  2. The UPDATE_VISIBLE_TILES reducer's scale-change branch concatenates [...fallbackToCarry, ...promoted, ...newTiles] with no id dedup — the same-scale branch right below it carefully dedupes.
  3. Fallback tiles are only purged once all fresh tiles of the page reach ready (MARK_TILE_STATUS), so a slow rasterizer (Safari, or several viewers sharing one engine worker) keeps an old generation alive across presses.

The failing sequence: press 1 (90°, fit scale s1, tiles render) → a debounced fit recalc after a viewport resize (e.g. a scrollbar-gutter flip) lands on s1′ and promotes the ready s1 tiles to fallbacks → press 2 (180°, s0; the fresh tiles never all reach ready) → press 3 (270°): fit width returns exactly s1, the fresh tiles carry the same ids as the still-alive fallbacks, and the concat emits duplicates.

Reproduction (published package, no DOM needed)

Node script replaying the reducer action sequence against @embedpdf/plugin-tiling@2.14.4 — ends with p0-0.455-x0-y0-w766-h383 twice in one page's array
import { TilingPluginPackage } from '@embedpdf/plugin-tiling';

const DOC = 'doc';
const reducer = TilingPluginPackage.reducer;

const tile = (scale, x, y) => ({
  id: `p0-${scale}-x${x}-y${y}-w766-h383`,
  col: Math.round(x / 765.5), row: Math.round(y / 765.5),
  pageRect: { origin: { x, y }, size: { width: 766, height: 383 } },
  screenRect: { origin: { x, y }, size: { width: 766, height: 383 } },
  status: 'queued', srcScale: scale, isFallback: false,
});

const S_PORTRAIT = 0.652, S_LANDSCAPE = 0.455, S_LANDSCAPE_NUDGED = 0.451;
const landscape = (s) => [tile(s, 0, 0), tile(s, 765.5, 0)];
const portrait = (s, y) => [tile(s, 0, y), tile(s, 0, 765.5 + y)];

const upd = (tiles) => ({ type: 'TILING/UPDATE_VISIBLE_TILES', payload: { documentId: DOC, tiles: { 0: tiles } } });
const mark = (id) => ({ type: 'TILING/MARK_TILE_STATUS', payload: { documentId: DOC, pageIndex: 0, tileId: id, status: 'ready' } });

let state = { documents: {} };
const apply = (a) => { state = reducer(state, a); };

apply({ type: 'TILING/INIT_STATE', payload: { documentId: DOC, state: { visibleTiles: {} } } });
apply(upd(portrait(S_PORTRAIT, 0)));                    // steady state
portrait(S_PORTRAIT, 0).forEach((t) => apply(mark(t.id)));
apply(upd(landscape(S_PORTRAIT)));                      // press 1: rotation step
apply(upd(landscape(S_LANDSCAPE)));                     // press 1: fit scale step
landscape(S_LANDSCAPE).forEach((t) => apply(mark(t.id)));
apply(upd(landscape(S_LANDSCAPE_NUDGED)));              // debounced fit recalc -> fallbacks born
apply(upd(portrait(S_LANDSCAPE_NUDGED, 1)));            // press 2: rotation step
apply(upd(portrait(S_PORTRAIT, 1)));                    // press 2: fit scale step (never renders)
apply(upd(landscape(S_PORTRAIT)));                      // press 3: rotation step
apply(upd(landscape(S_LANDSCAPE)));                     // press 3: fit scale step -> ids recur

const ids = state.documents[DOC].visibleTiles[0].map((t) => t.id);
console.log(ids.filter((id, i) => ids.indexOf(id) !== i)); // 2.14.4: two duplicate ids

Fix

Dedupe carried tiles against fresh ids in the scale-change branch — the fresh tile wins: an identical id means identical page/scale/rect (same visual content), and the fresh tile keeps the normal render lifecycle so the all-ready fallback purge still fires.

Verification: I built @embedpdf/plugin-tiling from this branch and replayed the sequence above against the built output — no duplicates; the same replay against the published 2.14.4 package shows the two duplicate ids. eslint src reports no new findings for this change.

I first included a colocated jest test mirroring the packages/models pattern, but backed it out to keep the diff minimal: this package deliberately excludes **/*.test.ts from its tsconfig (including it sweeps a reducer.test.d.ts into dist), and I found no test-runner wiring in the repo. Happy to contribute the test plus the wiring in a follow-up if you want it — the repro above is the test body.

A deeper alternative would be to include rotation (or a generation counter) in tile ids so distinct generations can never collide. I kept this PR to the minimal state-level fix and left that call to you.

We currently carry this exact change as a pnpm patch in our application; this targets the v2 branch since that's where 2.14.x/2.15.x release from (I have not audited whether the v3 architecture on main has an equivalent code path).

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 7, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the CloudPDF Team on Vercel.

A member of the Team first needs to authorize it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant