From 4b485da268a04e7aa6995d5a67efae3aefc86161 Mon Sep 17 00:00:00 2001 From: abe238 <36415+abe238@users.noreply.github.com> Date: Fri, 13 Nov 2026 17:30:44 -0800 Subject: [PATCH] fix(edit): selection changes are followed again after a re-attach RichInput.detach cancelled the pending animation frame but kept its handle, so every later selection change returned early. The editor then stopped knowing where the caret was: the page holding the caret was no longer kept mounted while scrolling, and text typed after scrolling away landed at the top of the document instead of at the caret. Co-Authored-By: Claude Fable 5.2 --- src/react/edit/input.ts | 1 + test/input-reattach.test.ts | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 test/input-reattach.test.ts diff --git a/src/react/edit/input.ts b/src/react/edit/input.ts index d3e1745..9e20ff3 100644 --- a/src/react/edit/input.ts +++ b/src/react/edit/input.ts @@ -363,6 +363,7 @@ export class RichInput { r.removeEventListener('keydown', this.onKeyDown) document.removeEventListener('selectionchange', this.onSelChange) cancelAnimationFrame(this.raf) + this.raf = 0 // or the next selection change waits forever for a frame that was cancelled this.root = null this.override = null } diff --git a/test/input-reattach.test.ts b/test/input-reattach.test.ts new file mode 100644 index 0000000..b52df06 --- /dev/null +++ b/test/input-reattach.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, expect, test } from 'bun:test' +import { RichInput } from '../src/react/edit/input' + +// RichInput follows the selection through one animation frame at a time. Detaching cancels a pending frame; if the handle is not +// cleared too, every later selection change is ignored: the editor stops knowing where the caret is, the page holding the caret is +// no longer kept mounted while scrolling, and typing after a scroll lands at the top of the document. + +type Fn = () => void +const g = globalThis as Record +const saved: Record = {} +let frames: Fn[] = [] +let selChange: Fn | null = null + +beforeEach(() => { + for (const k of ['document', 'requestAnimationFrame', 'cancelAnimationFrame']) saved[k] = g[k] + frames = [] + selChange = null + g.document = { + addEventListener: (type: string, fn: Fn) => type === 'selectionchange' && (selChange = fn), + removeEventListener: (type: string, fn: Fn) => type === 'selectionchange' && selChange === fn && (selChange = null), + } + g.requestAnimationFrame = (fn: Fn) => frames.push(fn) + g.cancelAnimationFrame = (id: number) => void (frames[id - 1] = () => {}) +}) +afterEach(() => { + for (const k of Object.keys(saved)) g[k] = saved[k] +}) + +// a root with no selection in it: reading the selection gives null, which the host is still told about +const root = () => ({ addEventListener() {}, removeEventListener() {}, contains: () => false, getRootNode: () => ({}), ownerDocument: { getSelection: () => null } }) as unknown as HTMLElement + +test('a selection change after a re-attach is still followed (a cancelled frame does not block the next one)', () => { + const seen: unknown[] = [] + const input = new RichInput({ onSelection: (s: unknown) => seen.push(s) } as never) + input.attach(root()) + selChange!() // a frame is now pending + expect(frames.length).toBe(1) + input.attach(root()) // another root: detach (the pending frame is cancelled), then attach + selChange!() + expect(frames.length).toBe(2) // the new change asked for its own frame + frames[1]() + expect(seen.length).toBe(1) // and the host heard about it +})