Skip to content

fix: restore wrap anchor with paint retries#134

Open
benvinegar wants to merge 1 commit intomainfrom
pi/todo-f0837365-wrap-anchor
Open

fix: restore wrap anchor with paint retries#134
benvinegar wants to merge 1 commit intomainfrom
pi/todo-f0837365-wrap-anchor

Conversation

@benvinegar
Copy link
Copy Markdown
Member

Summary

  • replace wrap-toggle scroll restoration timeouts with paint-cycle retries that prefer requestAnimationFrame and fall back to timed frames in tests
  • keep the viewport-row anchor restore and selection auto-scroll suppression behavior intact
  • preserve existing wrap-toggle interaction coverage while avoiding the hard-coded 0/16/48ms timeout chain

Testing

  • bun run typecheck
  • bun test
  • bun run test:tty-smoke
  • real TTY smoke: timeout 5 script -q -f -e -c "bun run src/main.tsx -- diff <before> <after>" <transcript>

This PR description was generated by Pi using OpenAI GPT-5

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 30, 2026

Greptile Summary

This PR replaces the hard-coded [0, 16, 48]ms timeout chain used for wrap-toggle viewport-anchor restoration with a new schedulePaintRetries helper that uses requestAnimationFrame in browser/TTY environments and falls back to 16ms setTimeout intervals in test environments where requestAnimationFrame is unavailable.

Key changes:

  • New schedulePaintRetries(callback, frames) utility schedules callback across frames consecutive paint cycles, returning a cancel function wired to the useLayoutEffect cleanup path.
  • The wrap-toggle useLayoutEffect now calls schedulePaintRetries(restoreViewportAnchor, 2) instead of creating three setTimeout handles at 0, 16, and 48ms.
  • Total restoreViewportAnchor invocations reduced from 4 (1 sync + 3 timeouts) to 3 (1 sync + 2 rAF frames), intentional per the PR description.
  • The selection auto-scroll useLayoutEffect and the suppressNextSelectionAutoScrollRef suppression behavior are preserved unchanged.
  • The cancellation logic is correct: cancels.splice(0) drains all accumulated handles on cleanup; calling cancelAnimationFrame on a frame that has already fired is a well-defined no-op.

Confidence Score: 5/5

Safe to merge — the change is a clean drop-in replacement with no correctness issues.

No P0 or P1 issues found. The new schedulePaintRetries helper is correct: cancellation is handled properly (stale no-op calls are harmless), the rAF/setTimeout branch logic is sound, and the existing wrap-toggle and selection-suppression behavior is fully preserved. The two P2 comments are minor quality/consistency notes that do not block merging.

No files require special attention.

Important Files Changed

Filename Overview
src/ui/components/panes/DiffPane.tsx Adds schedulePaintRetries helper that replaces the 0/16/48ms timeout chain for wrap-toggle anchor restoration with semantically correct rAF frames (with a 16ms setTimeout fallback in test environments); cancellation logic is correct; no functional regressions.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["useLayoutEffect fires\n(wrapLines changed)"] --> B{anchor found?}
    B -- no --> C[update prev refs]
    B -- yes --> D["restoreViewportAnchor()\n(immediate, synchronous)"]
    D --> E["suppressNextSelectionAutoScroll = true"]
    E --> F["schedulePaintRetries(restoreViewportAnchor, 2)"]
    F --> G["scheduleNext(2)\n→ push cancel1"]
    G --> H["Frame 1 (rAF / setTimeout 16ms)\ncallback() + scheduleNext(1)\n→ push cancel2"]
    H --> I["Frame 2 (rAF / setTimeout 16ms)\ncallback()"]
    F --> J["return cancelFn\n(cancels.splice(0).forEach)"]
    J --> K["Effect cleanup\ncancels all pending frames"]
Loading

Comments Outside Diff (1)

  1. src/ui/components/panes/DiffPane.tsx, line 638-644 (link)

    P2 Selection auto-scroll still uses the old hard-coded timeout chain

    The second useLayoutEffect (which handles scrolling the selection into view) still uses the pre-existing [0, 16, 48]ms pattern that this PR replaces for the wrap-anchor path. This is clearly out of scope for this PR given its stated goal, but noting it here for future consistency — schedulePaintRetries would be a natural fit here too when the time comes.

Reviews (1): Last reviewed commit: "fix: restore wrap anchor with paint retr..." | Re-trigger Greptile

Comment on lines +123 to +153
function schedulePaintRetries(callback: () => void, frames = 2) {
const cancels: Array<() => void> = [];
const requestFrame =
typeof globalThis.requestAnimationFrame === "function"
? (next: () => void) => {
const frameId = globalThis.requestAnimationFrame(() => next());
return () => {
if (typeof globalThis.cancelAnimationFrame === "function") {
globalThis.cancelAnimationFrame(frameId);
}
};
}
: (next: () => void) => {
const timeout = setTimeout(next, 16);
return () => clearTimeout(timeout);
};

const scheduleNext = (remaining: number) => {
const cancel = requestFrame(() => {
callback();
if (remaining > 1) {
scheduleNext(remaining - 1);
}
});
cancels.push(cancel);
};

scheduleNext(frames);
return () => {
cancels.splice(0).forEach((cancel) => cancel());
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Stale cancel handles accumulate in the cancels array

After a frame fires, its cancel handle stays in the cancels array even though calling cancelAnimationFrame on a frame that has already executed is a harmless no-op. With frames=2:

  1. scheduleNext(2)cancels = [cancel1]
  2. Frame 1 fires → scheduleNext(1)cancels = [cancel1, cancel2] (cancel1 is now stale)
  3. Frame 2 fires → cancels = [cancel1, cancel2] (both stale if cleanup runs here)

The cleanup calls both, but cancel1 and cancel2 become no-ops once their respective frames have fired. This is correct in all paths but results in unnecessary no-op calls. A minor defensive improvement would be to splice the cancel out of the array once its frame has run:

const scheduleNext = (remaining: number) => {
  const cancel = requestFrame(() => {
    const idx = cancels.indexOf(cancel);
    if (idx !== -1) cancels.splice(idx, 1);
    callback();
    if (remaining > 1) {
      scheduleNext(remaining - 1);
    }
  });
  cancels.push(cancel);
};

This is low priority since cancelAnimationFrame/clearTimeout on already-fired IDs is well-defined as a no-op in all major environments.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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.

1 participant