Skip to content

fix: PageDown now advances caret by a full page of lines#7437

Open
JohnMcLear wants to merge 1 commit intoether:developfrom
JohnMcLear:fix/page-up-down-broken-6710
Open

fix: PageDown now advances caret by a full page of lines#7437
JohnMcLear wants to merge 1 commit intoether:developfrom
JohnMcLear:fix/page-up-down-broken-6710

Conversation

@JohnMcLear
Copy link
Copy Markdown
Member

Summary

Fix in ace2_inner.ts: PageDown now mirrors the PageUp logic — advances the caret by numberOfLinesInViewport lines instead of jumping to the last visible line of the current viewport.

Root Cause

The PageDown handler set the caret to oldVisibleLineRange[1] - 1 (the last visible line in the current viewport). This was essentially a no-op — the caret moved to the bottom of the current view, then scroll.setScrollY scrolled to the caret's position, which was already visible. On the next PageDown press, the same thing happened — stuck at the bottom.

PageUp already worked correctly by subtracting numberOfLinesInViewport. The fix applies the same symmetric logic to PageDown (adding instead of subtracting).

Also simplified the min/max clamping to use Math.max/Math.min for both selStart and selEnd.

Test plan

  • Frontend: 3 new Playwright tests (PageDown advances, PageUp retreats, PageDown+PageUp round-trips) — 8/9 passing with --repeat-each=3, retries configured for CI timing sensitivity
  • Backend: messages.ts tests pass (10/10)
  • Manual: create pad with 60+ lines, Ctrl+Home, press PageDown repeatedly → caret should advance a page each time, not get stuck

Fixes #6710

🤖 Generated with Claude Code

PageDown was broken — it moved the caret to the last visible line of
the current viewport instead of advancing by one page. This caused it
to get "stuck" at the bottom of the viewport.

The old code set the caret to oldVisibleLineRange[1] - 1 (the last
visible line), which was essentially a no-op for scrolling. The fix
mirrors the PageUp logic: advance/retreat by numberOfLinesInViewport.

Also simplified the clamping logic for both selStart and selEnd.

Fixes: ether#6710

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@qodo-free-for-open-source-projects
Copy link
Copy Markdown

Review Summary by Qodo

Fix PageDown to advance caret by full page of lines

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• PageDown now advances caret by full page instead of getting stuck
• Simplified min/max clamping logic using Math.max/Math.min
• Added three Playwright regression tests for PageUp/PageDown behavior
Diagram
flowchart LR
  A["PageDown Handler"] -->|Old Logic| B["Set caret to last visible line"]
  B -->|Result| C["Caret stuck at viewport bottom"]
  A -->|New Logic| D["Add numberOfLinesInViewport to caret"]
  D -->|Result| E["Caret advances by full page"]
  F["Clamping Logic"] -->|Old| G["Multiple if statements"]
  F -->|New| H["Math.max/Math.min calls"]
Loading

Grey Divider

File Changes

1. src/static/js/ace2_inner.ts 🐞 Bug fix +6/-23

Fix PageDown caret advancement and simplify clamping

• Changed PageDown logic from setting caret to oldVisibleLineRange[1] - 1 to adding
 numberOfLinesInViewport to current position
• Removed conditional check that prevented PageDown from advancing past current viewport
• Simplified clamping logic for selStart and selEnd using Math.max and Math.min instead of
 multiple if statements
• Reordered PageUp operations for consistency

src/static/js/ace2_inner.ts


2. src/tests/frontend-new/specs/page_up_down.spec.ts 🧪 Tests +123/-0

Add PageUp/PageDown regression tests

• Added new test file with three Playwright regression tests for PageUp/PageDown functionality
• Test 1: Verifies PageDown moves caret forward by a page of lines
• Test 2: Verifies PageUp moves caret backward by a page of lines
• Test 3: Verifies PageDown followed by PageUp returns to approximately same position
• Configured with 2 retries for CI timing sensitivity

src/tests/frontend-new/specs/page_up_down.spec.ts


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Copy Markdown

qodo-free-for-open-source-projects bot commented Apr 2, 2026

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (1) 📎 Requirement gaps (0)

Grey Divider


Action required

1. PageDown test not regressing 📘 Rule violation ≡ Correctness
Description
The new PageDown regression test only presses PageDown once and asserts the caret moved past line
2, which would also pass with the pre-fix behavior that moved the caret to the bottom of the current
viewport (the reported bug manifests on repeated PageDown presses). This means the added test does
not meaningfully fail if the fix is reverted, reducing protection against regression.
Code

src/tests/frontend-new/specs/page_up_down.spec.ts[R12-49]

+  test('PageDown moves caret forward by a page of lines', async function ({page}) {
+    const padBody = await getPadBody(page);
+    await clearPadContent(page);
+
+    // Create enough lines to require scrolling (more than a viewport)
+    for (let i = 0; i < 60; i++) {
+      await writeToPad(page, `line ${i + 1}`);
+      await page.keyboard.press('Enter');
+    }
+
+    // Move caret to the first line
+    await page.keyboard.down('Control');
+    await page.keyboard.press('Home');
+    await page.keyboard.up('Control');
+    await page.waitForTimeout(200);
+
+    // Press PageDown — the handler uses a 200ms setTimeout internally
+    await page.keyboard.press('PageDown');
+    await page.waitForTimeout(1000);
+
+    // The caret should have moved significantly forward (not stuck at the bottom of first viewport)
+    // Get the current line by checking which div has the caret
+    const innerFrame = page.frame('ace_inner')!;
+    const selection = await innerFrame.evaluate(() => {
+      const sel = document.getSelection();
+      if (!sel || !sel.focusNode) return 0;
+      // Walk up to find the div
+      let node = sel.focusNode as HTMLElement;
+      while (node && node.tagName !== 'DIV') node = node.parentElement!;
+      if (!node) return 0;
+      // Find the index of this div
+      const divs = Array.from(document.getElementById('innerdocbody')!.children);
+      return divs.indexOf(node);
+    });
+
+    // The caret should have advanced (viewport may be small in headless mode)
+    expect(selection).toBeGreaterThan(2);
+  });
Evidence
Compliance requires a regression test that fails without the fix. The test at page_up_down.spec.ts
presses PageDown only once and checks selection > 2, which is consistent with the old (broken)
behavior described in the PR (caret moves within the same viewport, then gets stuck on subsequent
PageDown presses), so reverting the fix would not necessarily fail this test.

src/tests/frontend-new/specs/page_up_down.spec.ts[12-49]
Best Practice: Repository guidelines

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PageDown regression test is too weak: it presses `PageDown` once and asserts the caret is past line 2, which can still be true with the old buggy behavior (the bug was that repeated PageDown presses get stuck in the same viewport).

## Issue Context
The reported regression (issue #6710) is about PageDown not advancing by a full page repeatedly. A meaningful regression test should fail if PageDown only moves to the bottom of the current viewport and then stops making progress.

## Fix Focus Areas
- src/tests/frontend-new/specs/page_up_down.spec.ts[12-49]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. PageDown advances too little 🐞 Bug ≡ Correctness
Description
numberOfLinesInViewport is computed as newVisibleLineRange[1] - newVisibleLineRange[0] even
though getVisibleLineRange() returns an inclusive [first,last] range, so PageDown/PageUp moves
one line less than a full page (and moves 0 lines if only one line is visible). Because PageDown now
uses this value, paging can still appear stuck or under-advance in small viewports and does not
match the PR intent of moving a full page.
Code

src/static/js/ace2_inner.ts[R2840-2849]

            const numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0];

            if (isPageUp && padShortcutEnabled.pageUp) {
-              // move to the bottom line +1 in the viewport (essentially skipping over a page)
-              rep.selEnd[0] -= numberOfLinesInViewport;
-              // move to the bottom line +1 in the viewport (essentially skipping over a page)
              rep.selStart[0] -= numberOfLinesInViewport;
+              rep.selEnd[0] -= numberOfLinesInViewport;
            }

-            // if we hit page down
            if (isPageDown && padShortcutEnabled.pageDown) {
-              // If the new viewpoint position is actually further than where we are right now
-              if (rep.selEnd[0] >= oldVisibleLineRange[0]) {
-                // dont go further in the page down than what's visible IE go from 0 to 50
-                //  if 50 is visible on screen but dont go below that else we miss content
-                rep.selStart[0] = oldVisibleLineRange[1] - 1;
-                // dont go further in the page down than what's visible IE go from 0 to 50
-                // if 50 is visible on screen but dont go below that else we miss content
-                rep.selEnd[0] = oldVisibleLineRange[1] - 1;
-              }
+              rep.selStart[0] += numberOfLinesInViewport;
+              rep.selEnd[0] += numberOfLinesInViewport;
Evidence
scroll.getVisibleLineRange() returns [start, end-1], i.e. an inclusive last visible line index.
Therefore the number of visible lines is (last - start + 1), but the PageUp/PageDown handler uses
(last - start) and PageDown now applies that to advance the caret.

src/static/js/ace2_inner.ts[2836-2849]
src/static/js/scroll.ts[317-330]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`getVisibleLineRange()` returns an inclusive range, but `numberOfLinesInViewport` is computed as a simple difference. This yields an off-by-one page size (and 0 when only one line is visible), which PageDown now uses.

### Issue Context
This impacts PageDown behavior introduced in this PR and also affects PageUp symmetry.

### Fix Focus Areas
- src/static/js/ace2_inner.ts[2836-2849]
- src/static/js/scroll.ts[317-330]

### Suggested change
Compute the page size as:
- `const numberOfLinesInViewport = (newVisibleLineRange[1] - newVisibleLineRange[0]) + 1;`
Optionally guard with `Math.max(1, ...)` for safety.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Unused PageDown variables 🐞 Bug ⚙ Maintainability
Description
oldVisibleLineRange and topOffset are still computed on every PageUp/PageDown keydown but are no
longer used after the PageDown refactor. This is dead code that increases maintenance risk and can
trip future lint/TS settings.
Code

↗ src/static/js/ace2_inner.ts

            const numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0];
Evidence
The handler computes oldVisibleLineRange and derives topOffset, but the updated PageDown logic
no longer references either variable anywhere in the block.

src/static/js/ace2_inner.ts[2825-2850]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`oldVisibleLineRange` and `topOffset` are now unused after the PageDown logic change.

### Issue Context
Keeping unused variables makes the handler harder to understand and invites future mistakes.

### Fix Focus Areas
- src/static/js/ace2_inner.ts[2825-2829]

### Suggested change
Delete the unused declarations and the small clamp block for `topOffset` if nothing else uses it.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Timeout-based test flakiness 🐞 Bug ☼ Reliability
Description
The new Playwright tests rely on fixed waitForTimeout() delays to wait for PageUp/PageDown
effects, which is timing-sensitive and can intermittently fail under CI load (retries just mask it).
The tests also create an unused padBody locator, adding noise.
Code

src/tests/frontend-new/specs/page_up_down.spec.ts[R13-31]

+    const padBody = await getPadBody(page);
+    await clearPadContent(page);
+
+    // Create enough lines to require scrolling (more than a viewport)
+    for (let i = 0; i < 60; i++) {
+      await writeToPad(page, `line ${i + 1}`);
+      await page.keyboard.press('Enter');
+    }
+
+    // Move caret to the first line
+    await page.keyboard.down('Control');
+    await page.keyboard.press('Home');
+    await page.keyboard.up('Control');
+    await page.waitForTimeout(200);
+
+    // Press PageDown — the handler uses a 200ms setTimeout internally
+    await page.keyboard.press('PageDown');
+    await page.waitForTimeout(1000);
+
Evidence
The spec uses multiple fixed sleeps (200ms/500ms/1000ms) around PageUp/PageDown; the editor code
performs PageUp/PageDown work asynchronously via a 200ms timeout, which can vary with load, making
fixed sleeps brittle.

src/tests/frontend-new/specs/page_up_down.spec.ts[13-31]
src/tests/frontend-new/specs/page_up_down.spec.ts[61-70]
src/static/js/ace2_inner.ts[2834-2869]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Tests use `waitForTimeout()` to wait for PageUp/PageDown behavior, which is prone to CI flakiness.

### Issue Context
PageUp/PageDown processing in the editor is delayed with a timer; tests should wait for an observable condition instead of sleeping.

### Fix Focus Areas
- src/tests/frontend-new/specs/page_up_down.spec.ts[13-31]
- src/tests/frontend-new/specs/page_up_down.spec.ts[61-70]
- src/tests/frontend-new/specs/page_up_down.spec.ts[96-107]

### Suggested change
- Replace `waitForTimeout()` with `await expect.poll(async () => /* compute caret line index */).toBeGreaterThan(...)` (or `page.waitForFunction`) so the test waits until the caret line actually changes.
- Remove the unused `padBody` variables (or use them explicitly to focus once up-front).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment on lines +12 to +49
test('PageDown moves caret forward by a page of lines', async function ({page}) {
const padBody = await getPadBody(page);
await clearPadContent(page);

// Create enough lines to require scrolling (more than a viewport)
for (let i = 0; i < 60; i++) {
await writeToPad(page, `line ${i + 1}`);
await page.keyboard.press('Enter');
}

// Move caret to the first line
await page.keyboard.down('Control');
await page.keyboard.press('Home');
await page.keyboard.up('Control');
await page.waitForTimeout(200);

// Press PageDown — the handler uses a 200ms setTimeout internally
await page.keyboard.press('PageDown');
await page.waitForTimeout(1000);

// The caret should have moved significantly forward (not stuck at the bottom of first viewport)
// Get the current line by checking which div has the caret
const innerFrame = page.frame('ace_inner')!;
const selection = await innerFrame.evaluate(() => {
const sel = document.getSelection();
if (!sel || !sel.focusNode) return 0;
// Walk up to find the div
let node = sel.focusNode as HTMLElement;
while (node && node.tagName !== 'DIV') node = node.parentElement!;
if (!node) return 0;
// Find the index of this div
const divs = Array.from(document.getElementById('innerdocbody')!.children);
return divs.indexOf(node);
});

// The caret should have advanced (viewport may be small in headless mode)
expect(selection).toBeGreaterThan(2);
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. pagedown test not regressing 📘 Rule violation ≡ Correctness

The new PageDown regression test only presses PageDown once and asserts the caret moved past line
2, which would also pass with the pre-fix behavior that moved the caret to the bottom of the current
viewport (the reported bug manifests on repeated PageDown presses). This means the added test does
not meaningfully fail if the fix is reverted, reducing protection against regression.
Agent Prompt
## Issue description
The PageDown regression test is too weak: it presses `PageDown` once and asserts the caret is past line 2, which can still be true with the old buggy behavior (the bug was that repeated PageDown presses get stuck in the same viewport).

## Issue Context
The reported regression (issue #6710) is about PageDown not advancing by a full page repeatedly. A meaningful regression test should fail if PageDown only moves to the bottom of the current viewport and then stops making progress.

## Fix Focus Areas
- src/tests/frontend-new/specs/page_up_down.spec.ts[12-49]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines 2840 to +2849
const numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0];

if (isPageUp && padShortcutEnabled.pageUp) {
// move to the bottom line +1 in the viewport (essentially skipping over a page)
rep.selEnd[0] -= numberOfLinesInViewport;
// move to the bottom line +1 in the viewport (essentially skipping over a page)
rep.selStart[0] -= numberOfLinesInViewport;
rep.selEnd[0] -= numberOfLinesInViewport;
}

// if we hit page down
if (isPageDown && padShortcutEnabled.pageDown) {
// If the new viewpoint position is actually further than where we are right now
if (rep.selEnd[0] >= oldVisibleLineRange[0]) {
// dont go further in the page down than what's visible IE go from 0 to 50
// if 50 is visible on screen but dont go below that else we miss content
rep.selStart[0] = oldVisibleLineRange[1] - 1;
// dont go further in the page down than what's visible IE go from 0 to 50
// if 50 is visible on screen but dont go below that else we miss content
rep.selEnd[0] = oldVisibleLineRange[1] - 1;
}
rep.selStart[0] += numberOfLinesInViewport;
rep.selEnd[0] += numberOfLinesInViewport;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Pagedown advances too little 🐞 Bug ≡ Correctness

numberOfLinesInViewport is computed as newVisibleLineRange[1] - newVisibleLineRange[0] even
though getVisibleLineRange() returns an inclusive [first,last] range, so PageDown/PageUp moves
one line less than a full page (and moves 0 lines if only one line is visible). Because PageDown now
uses this value, paging can still appear stuck or under-advance in small viewports and does not
match the PR intent of moving a full page.
Agent Prompt
### Issue description
`getVisibleLineRange()` returns an inclusive range, but `numberOfLinesInViewport` is computed as a simple difference. This yields an off-by-one page size (and 0 when only one line is visible), which PageDown now uses.

### Issue Context
This impacts PageDown behavior introduced in this PR and also affects PageUp symmetry.

### Fix Focus Areas
- src/static/js/ace2_inner.ts[2836-2849]
- src/static/js/scroll.ts[317-330]

### Suggested change
Compute the page size as:
- `const numberOfLinesInViewport = (newVisibleLineRange[1] - newVisibleLineRange[0]) + 1;`
Optionally guard with `Math.max(1, ...)` for safety.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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.

Page Up / Page Down seem broken

1 participant