fix: read caret geometry from the editor document (#8038) - #8080
fix: read caret geometry from the editor document (#8038)#8080JohnMcLear wants to merge 3 commits into
Conversation
`caretPosition.getPosition()` read `window.getSelection()` from the pad's top-level window. Since 3.0 the editor modules are bundled into that window instead of being loaded inside the ace_inner iframe, so that selection is always empty: getPosition() returned null, and `_isCaretAtTheBottomOfViewport()` passed it straight into getBottomOfNextBrowserLine(), which threw `can't access property "bottom"` and popped up the "An error occurred" box for anyone running with `scrollWhenCaretIsInTheLastLineOfViewport` enabled. Caret geometry now comes from the ace_inner document, which is the same coordinate space scroll.ts's viewport arithmetic already uses (the inner frame is as tall as its content and never scrolls). Callers handle a null position — no caret in the pad — instead of asserting it away. Scrolling in the same module had the mirror-image problem: `outerWin` is the ace_outer iframe *element*, and Element.scrollTo()/scrollBy() on an iframe silently does nothing, so the scroll this feature computed never actually happened. Those calls now go through the frame's contentWindow. Fixes #8038 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR Summary by QodoFix caret geometry and scrolling across editor iframes
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)
Great, no issues found!Qodo reviewed your code and found no material issues that require reviewTo customize comments, go to the Qodo configuration screen, or learn more in the docs. |
There was a problem hiding this comment.
Pull request overview
This PR fixes a 3.x regression where caret geometry was being measured from the top-level document (which has an empty selection), causing getPosition() to return null and triggering a browser-side crash when caret-follow scrolling is enabled. It updates the caret/scroll logic to explicitly operate on the ace_inner document and to scroll via the ace_outer iframe’s contentWindow, and adds a Playwright regression spec to validate both “no crash” and restored scrolling behavior.
Changes:
- Measure caret position from the ace_inner document (instead of the top-level
window/document) and makenulla supported “no caret in editor” outcome. - Route scrolling through
ace_outer.contentWindowand resolve the inner editor document on demand to avoid stale iframe document references. - Add a Playwright regression spec for the crash and for caret-follow scrolling behavior under the relevant setting.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/static/js/caretPosition.ts | Updates caret geometry helpers to take a Document and read selection/ranges from the editor iframe’s document. |
| src/static/js/scroll.ts | Uses the editor iframe document for caret measurements; scrolls via the ace_outer iframe’s contentWindow; adds null-safe bails. |
| src/tests/frontend-new/specs/scroll_caret_viewport.spec.ts | Adds E2E regression coverage for the bottom-of-viewport caret crash and restored scrolling behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| for (let i = 0; i < LINE_COUNT; i++) { | ||
| await page.keyboard.type(`line ${i}`); | ||
| await page.keyboard.press('Enter'); | ||
| } |
There was a problem hiding this comment.
Good catch — adopted in 427cda4. The 40-line setup now uses keyboard.insertText() per line (same reasoning as writeToPad()), which also cuts the spec's runtime by about a third. Re-verified after the change: 4 consecutive clean runs in Chromium + Firefox, and the spec still fails as expected when the source fix is reverted.
Per-key events race Etherpad's input pipeline under Firefox + WITH_PLUGINS load and drop characters — the shared writeToPad() helper moved off keyboard.type for the same reason. Also cuts the spec's runtime by a third. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The click point was picked from line boxes ("last line that starts
inside the viewport"), but scroll.ts decides from text rects of the
*next* browser line, so on CI's rendering the caret landed a line short
of the trigger and the scroll never happened (biggestJump 0). Mirror the
real predicate — first visible line whose successor's first-character
rect reaches past the viewport bottom — and report the measured geometry
when the assertion fails.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes #8038
The bug
With
scrollWhenFocusLineIsOutOfViewport.scrollWhenCaretIsInTheLastLineOfViewportenabled (FOCUS_LINE_CARET_SCROLL=truein the Docker image), moving the caret onto the line at the bottom edge of the viewport throws in the browser and raises the pad's "An error occurred / press Ctrl+F5" box:Reproduces in Chromium as well as Firefox — it is not browser specific.
Root cause
caretPosition.getPosition()measured the caret fromwindow.getSelection()of the pad's top-level window. Up to 2.x that was the right window:ace2_innerwas loaded inside the ace_inner iframe, so the module's ambientwindow/documentwere the editor's. Since the 3.x bundling changeace2_inner.tsruns in the top-level window and reaches into the frames explicitly (document.getElementsByName("ace_outer")[0]…), butcaretPosition.tswas never updated.The caret lives in the ace_inner iframe, so the top window's selection is permanently empty (
type: "None",rangeCount: 0).getPosition()therefore always returnednull, and_isCaretAtTheBottomOfViewport()passed thatnull— through a!assertion that was never true — straight intogetBottomOfNextBrowserLine(), which dereferences.bottom.Two consequences, both fixed here:
scroll.tshas been inert since 3.0, becausegetPosition()never returned anything. On top of that,outerWinis the ace_outer iframe element, not a window, soouterWin.scrollTo()/scrollBy()hitElement.scrollTo()/scrollBy(), which silently do nothing on an iframe — the pixel amounts this feature computed were never applied.The fix
caretPosition.tstakes the editor document explicitly and reads the selection, the shadow-caret text node and the measuring ranges from it. That is also the coordinate spacescroll.tsalready compares against: the inner frame is as tall as its content and never scrolls, so its client rects line up with the outer document's scroll offsets (verified in-browser:rect.top === offsetTopfor every line).getPosition()returningnullis now a real case (focus outside the editor) and callers bail out instead of asserting it away.contentWindow, and thewindow.parent/outer[0].innerHeighthack inscrollNodeVerticallyIntoView— which from the top window scrolled the pad page by the editor's viewport height — scrolls the ace_outer window to the end of the pad, as originally intended (editor: fix enter key keep line in view #4639).$(range.endContainer).closest('body')[0].idalso threw on a detached node; it is now null-safe.Not in this PR, but found on the way and worth a follow-up:
ace2_inner.tshas the same element-instead-of-window mistake for horizontal scrolling (outerWin.scrollBy(...)/outerWin.scrollXat lines 2824 and 3648-3653), so scrolling a long line into view sideways in no-wrap mode is equally inert. Left out to keep this diff to the reported crash.Note for reviewers: restoring the caret-follow scrolling means
scrollNodeVerticallyIntoViewdoes something again on 3.x, so a caret that ends up outside the viewport is scrolled back into view instead of being left to the browser. That is the pre-3.0 behaviour.Tests
New spec
src/tests/frontend-new/specs/scroll_caret_viewport.spec.ts(the setting is off by default, so the spec turns it on by patchingclientVarsbeforepad.tsreads it):percentage.editionBelowViewport: 0.6, the same click scrolls the caret line clear of the bottom edge (measured jump ~506px, versus 0 before). Chromium only: Firefox applies its own scroll-into-view before the check runs, which leaves the trigger comparison decided by a pixel or two — too marginal to assert on. The behaviour itself was verified there by hand.Verified failing without the source change (all three runnable tests fail, including the exact
Cannot read properties of null (reading 'bottom')) and passing with it, 6 consecutive runs clean.Backend suite:
1611 passing, 1 failing— the failure (Pad.ts › padDefaultContent hook › issue #7885) reproduces on unmodifieddevelopand is unrelated.