---
name: paragraph-mode-toggle-resume-4717
description: "Paragraph mode (#4717) Shift+P double-toggle, dialog key handling, and chapter-start rewind — root causes + reusable gotchas (eventDispatcher re-entrancy, foliate lastLocation vs store progress)"
metadata: 
  node_type: memory
  type: project
  originSessionId: a19a021a-0e84-4bf6-bde4-02b115b6306f
---

PR #4725 fixed three Shift+P paragraph-mode bugs (#4717). Follow-on to #4723 (Alt+P proofread + initial overlay attempt). Three reusable, non-obvious findings:

**1. `eventDispatcher.dispatch` live-Set re-entrancy (the widest-reach gotcha).** `dispatch()` iterated the live `asyncListeners` Set while `await`ing each listener. A listener that triggers a React state change which re-runs an effect that re-subscribes a handler for the SAME event gets that new handler invoked in the same dispatch loop → the event double-fires. Symptom here: one Shift+P toggled paragraph mode twice (exit→re-enter "flash"); `useParagraphMode`'s `toggle-paragraph-mode` subscription effect has `paragraphConfig.enabled` in deps, so the exit's awaited `dispatch('paragraph-mode-disabled')` re-subscribed mid-loop. Fix = snapshot before iterating: `for (const l of [...listeners])` in `dispatch` (`dispatchSync` already did). **Applies to ANY event whose handler re-subscribes.**

**2. Overlay key handling = dialog/alert pattern, NOT a global window listener.** The maintainer (chrox) explicitly rejected wiring `onEscape` into useShortcuts. Correct pattern: the overlay's container is `role=dialog tabIndex=-1`, gets `containerRef.current.focus({preventScroll:true})` on open, and handles Escape / toggle shortcut / nav in its OWN `onKeyDown` (stopPropagation so the global handler can't double-fire). Add `outline-none` to the programmatically-focused non-tab-stop container or it draws a focus ring around the whole viewport. The old capture-phase `window.addEventListener('keydown',...,true)` + `stopImmediatePropagation()` swallowed global shortcuts and only fired when focus was on the parent doc (not the foliate iframe).

**3. Paragraph-mode resume rewound to chapter start.** Two causes: (a) entering/exiting scrolled the underlying view to the focused paragraph's START via `renderer.goTo`/`scrollToAnchor`; when the page-top paragraph began on the previous page this rewinds a page, and repeated enter/exit accumulates. Fix = `focusCurrentParagraph(align=false)` on resume/first-mount, drop the exit `scrollToAnchor`; navigation keeps `align=true`. (b) resume preferred the rAF-debounced `readerStore` progress and a stored last-paragraph CFI (`view.getCFI(docIndex, paragraphBlockRange)`) that comes out MALFORMED (e.g. `epubcfi(/6/18!,/4/110,/4)`) and `resolveCFI`s to a non-null EMPTY range, shadowing the correct candidate in the `??` chain and sending `findByRangeAsync` to `first()` (the title). Fix = resume from `view.lastLocation.cfi` FIRST.

**foliate `view.lastLocation` vs readerStore progress:** `view.lastLocation = {cfi, range, ...}` is set SYNCHRONOUSLY by foliate on every relocate; the readerStore progress is rAF-debounced (FoliateViewer `commitRelocate`) and lags/desyncs. For any resume/current-position logic prefer `view.lastLocation.cfi` (CFI is document-instance-independent, survives the section iframe being recreated on toggle — a stored `Range` does not). `view.lastLocation` is NOT in the FoliateView TS type by default (had to add it).

**Verification/harness gotchas (claude-in-chrome on the reader):** synthetic keystrokes (`computer key shift+p`) BUFFER/DROP chaotically — single presses vanish then fire in delayed bursts, creating inconsistent multi-toggle states; `ArrowRight` repeat worked, single Shift+P often didn't. Menu clicks (View menu → Paragraph Mode) are reliable for toggling. The content iframe lives in the foliate-view shadow DOM (`iframeCount:0` at top level); focus on `FOLIATE-VIEW` → keydown arrives as an `iframe-keydown` postMessage, focus on parent → real window keydown. Probe live state via `document.querySelector('foliate-view').lastLocation`. Tool returns containing "overlay=" strings sometimes hit a `[BLOCKED: Cookie/query string data]` filter — return JSON objects instead. Related: [[tts-sync-chrome-verification]], [[tts-browser-e2e-harness]].
