Skip to content

Commit cbcb77f

Browse files
Stvadclaude
andcommitted
test(mobile): pin the keyboard scroll-loop guard; honest cross-platform notes
Follow-up to 90b73b5b from an adversarial review. Defects: - The loop-prevention WIRING (ViewPlugin subscribe → gate → dispatch) was untested — only the pure shouldReassertCaret helper was. Reverting the gate to "always dispatch" (the looping behavior) passed every test. Add a jsdom test that builds a focused EditorView with the real extension and asserts a keyboard-open resize re-asserts once while the scroll it triggers does NOT (the loop is broken). Mutation-checked: forcing always-dispatch turns it red. - Header comment overstated "every mobile browser native-scrolls the editable." Name the layout-anchored Android case (Edge/Samsung) as the unverified one, with the regression risk and the correct (non-sentinel) fix path if it ever matters. Document the accepted settle-frame trade-off (a trailing pure-scroll nudge is ignored by design — don't re-add scroll-reassert) and the innerHeight fragility (shielded by the toolbar arm). Cleanup: - Extract getVisualViewportHeight() for the duplicated Math.round(window.visualViewport?.height ?? 0), co-located with the other viewport readers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f893f0b commit cbcb77f

3 files changed

Lines changed: 110 additions & 9 deletions

File tree

src/utils/keyboardAwareScroll.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { completionStatus } from '@codemirror/autocomplete'
44
import {
55
getEditingToolbarHeight,
66
getKeyboardOverlap,
7+
getVisualViewportHeight,
78
subscribeKeyboardViewport,
89
} from './keyboardViewport.js'
910

@@ -29,7 +30,16 @@ const MIN_KEYBOARD_OVERLAP = 60
2930
* 2. There must actually be something to clear — a real keyboard
3031
* (overlap ≥ MIN) or a mounted toolbar. The toolbar height is only nonzero
3132
* while the toolbar is rendered, so it's a reliable signal even on Chrome
32-
* Android's resizes-content path where the keyboard overlap stays 0. */
33+
* Android's resizes-content path where the keyboard overlap stays 0.
34+
* (`keyboardOverlap` is derived from `window.innerHeight`, which iOS can
35+
* under-report in Stage Manager + scroll — but the toolbar arm carries the
36+
* gate whenever editing on mobile, so a corrupt overlap can't disable it.)
37+
*
38+
* Accepted edge: the keyboard opening always fires a resize (geometry change),
39+
* so the caret is lifted; but if the browser's FINAL settle nudge arrives as a
40+
* trailing pure `scroll` (height already stable), it's ignored — the preceding
41+
* resize already cleared the caret, so this is a deliberate trade, not a miss.
42+
* Do NOT "fix" it by re-asserting on scroll: that resurrects the 60fps loop. */
3343
export const shouldReassertCaret = (
3444
prev: {vvHeight: number; toolbarHeight: number},
3545
cur: {vvHeight: number; toolbarHeight: number; keyboardOverlap: number},
@@ -44,16 +54,24 @@ export const shouldReassertCaret = (
4454
* know about while editing on a touch device.
4555
*
4656
* Division of labor with the browser:
47-
* - The on-screen KEYBOARD is the browser's job. Every mobile browser
48-
* natively scrolls a focused editable above the keyboard (iOS pans the
49-
* visual viewport; Chrome/Android shrinks the layout viewport). We do NOT
50-
* re-do that. Earlier this extension fed the keyboard *overlap* into
57+
* - The on-screen KEYBOARD is the browser's job. Mobile browsers natively
58+
* scroll a focused editable above the keyboard (iOS pans the visual
59+
* viewport; Chrome/Android resizes-content shrinks the layout viewport). We
60+
* do NOT re-do that. Earlier this extension fed the keyboard *overlap* into
5161
* CodeMirror's scrollMargins, but on iOS that overlap is measured against
5262
* a full-height layout viewport while the visible region is the panned
5363
* visual viewport — and scrolling the document to satisfy it itself moves
5464
* the pan, so CM chased a moving target and the block jittered / scrolled
5565
* out of view on every keystroke. Letting the browser own the keyboard
5666
* avoids that coordinate fight entirely.
67+
* CAVEAT: verified on iOS (device) and desktop (inert). On *layout-anchored*
68+
* Android browsers (Edge / Samsung Internet) the layout viewport stays full
69+
* and the keyboard overlays — if such a browser also does NOT native-scroll
70+
* the focused editable, the caret could sit behind the keyboard (the bug
71+
* this code originally fixed for those browsers by reserving the overlap).
72+
* Unverified there, and not our fleet; if it ever regresses, reintroduce the
73+
* overlap margin gated to a RELIABLE "does the visual viewport pan?" probe
74+
* (NOT the MobileKeyboardToolbar sentinel, which misdetects on iOS).
5775
* - The editing TOOLBAR is OUR job. The mobile keyboard toolbar floats
5876
* (`position: fixed`) just above the keyboard, so the browser's native
5977
* scroll — which only clears the keyboard — leaves the caret behind it.
@@ -133,12 +151,12 @@ export const keyboardAwareScroll = (): Extension => [
133151
// Seed the geometry baseline with the *current* values so a stream of
134152
// pure-scroll notifications (no keyboard/toolbar change) can't trigger
135153
// a spurious first re-assert.
136-
this.lastVvHeight = Math.round(window.visualViewport?.height ?? 0)
154+
this.lastVvHeight = getVisualViewportHeight()
137155
this.lastToolbarHeight = getEditingToolbarHeight()
138156
this.unsubscribe = subscribeKeyboardViewport(() => {
139157
if (!view.hasFocus) return
140158
const cur = {
141-
vvHeight: Math.round(window.visualViewport?.height ?? 0),
159+
vvHeight: getVisualViewportHeight(),
142160
toolbarHeight: getEditingToolbarHeight(),
143161
keyboardOverlap: getKeyboardOverlap(),
144162
}

src/utils/keyboardViewport.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ const computeOverlap = (): number => {
3636
* NOT as a scroll amount: the keyboard itself is the browser's job. */
3737
export const getKeyboardOverlap = (): number => computeOverlap()
3838

39+
/** The visual viewport's current height in CSS px (0 when unavailable). The
40+
* geometry signal keyboardAwareScroll compares to tell a keyboard open/close
41+
* (height changed) apart from a pure scroll (offset moved, height same). */
42+
export const getVisualViewportHeight = (): number =>
43+
typeof window === 'undefined' ? 0 : Math.round(window.visualViewport?.height ?? 0)
44+
3945
const listeners = new CallbackSet('keyboard-viewport')
4046
let attached = false
4147

src/utils/test/keyboardAwareScroll.test.ts

Lines changed: 79 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { describe, expect, it } from 'vitest'
2-
import { shouldReassertCaret } from '@/utils/keyboardAwareScroll'
1+
// @vitest-environment jsdom
2+
import { afterEach, describe, expect, it, vi } from 'vitest'
3+
import { EditorState } from '@codemirror/state'
4+
import { EditorView } from '@codemirror/view'
5+
import { keyboardAwareScroll, shouldReassertCaret } from '@/utils/keyboardAwareScroll'
6+
import { setEditingToolbarHeight } from '@/utils/keyboardViewport'
37

48
// The on-device bug this guards: on iOS, re-asserting the caret on a pure
59
// scroll fed a 60fps loop, because scrolling the caret into view itself moves
@@ -47,3 +51,76 @@ describe('shouldReassertCaret', () => {
4751
).toBe(false)
4852
})
4953
})
54+
55+
/** Minimal visualViewport stand-in whose listeners we can fire by hand. */
56+
const installViewport = (height: number) => {
57+
const listeners = new Map<string, Set<() => void>>()
58+
const vv = {
59+
height,
60+
offsetTop: 0,
61+
addEventListener: (type: string, cb: () => void) => {
62+
if (!listeners.has(type)) listeners.set(type, new Set())
63+
listeners.get(type)!.add(cb)
64+
},
65+
removeEventListener: (type: string, cb: () => void) => listeners.get(type)?.delete(cb),
66+
emit: (type: string) => listeners.get(type)?.forEach(cb => cb()),
67+
}
68+
vi.stubGlobal('visualViewport', vv)
69+
return vv
70+
}
71+
72+
// Guards the actual ViewPlugin WIRING (subscribe → gate → dispatch), not just
73+
// the pure helper above. The original bug lived here: re-asserting on every
74+
// viewport notification looped at 60fps. The decisive property is that a pure
75+
// scroll after a re-assert produces NO further dispatch — reverting the gate to
76+
// "always dispatch" must turn this red.
77+
describe('keyboardAwareScroll ViewPlugin', () => {
78+
let view: EditorView | null = null
79+
80+
afterEach(() => {
81+
view?.destroy()
82+
view = null
83+
vi.unstubAllGlobals()
84+
setEditingToolbarHeight(0)
85+
})
86+
87+
const mountFocused = (vvHeight: number) => {
88+
vi.stubGlobal('innerHeight', 800)
89+
const vv = installViewport(vvHeight)
90+
const parent = document.createElement('div')
91+
document.body.appendChild(parent)
92+
view = new EditorView({
93+
state: EditorState.create({doc: 'hello', extensions: [keyboardAwareScroll()]}),
94+
parent,
95+
})
96+
view.focus()
97+
// CM flags focusChanged from the DOM focus event but only delivers it to
98+
// plugins on its next update cycle; flush one so the ViewPlugin actually
99+
// subscribes (it gates the subscription on focus).
100+
view.dispatch({})
101+
return vv
102+
}
103+
104+
it('re-asserts once on keyboard-open, then ignores the scroll it triggers (no loop)', () => {
105+
const vv = mountFocused(800) // focused, keyboard not yet up; plugin seeds vvHeight=800
106+
expect(view!.hasFocus).toBe(true) // precondition: the plugin only subscribes while focused
107+
108+
const dispatch = vi.spyOn(view!, 'dispatch')
109+
110+
// Keyboard opens: visualViewport height shrinks (a geometry change).
111+
vv.height = 500 // 800 - 500 = 300px overlap, well above MIN
112+
vv.emit('resize')
113+
expect(dispatch).toHaveBeenCalledTimes(1) // lifted the caret once
114+
115+
// The scrollIntoView above moves the visual viewport offset on iOS, which
116+
// fires a `scroll`. That MUST NOT re-assert — otherwise it loops forever.
117+
vv.offsetTop = 40
118+
vv.emit('scroll')
119+
expect(dispatch).toHaveBeenCalledTimes(1) // still 1: the loop is broken
120+
121+
// A second self-induced scroll: still nothing.
122+
vv.offsetTop = 80
123+
vv.emit('scroll')
124+
expect(dispatch).toHaveBeenCalledTimes(1)
125+
})
126+
})

0 commit comments

Comments
 (0)