Skip to content

fix: restore scroll position reliably after a hot-swap page reload - #25224

Draft
Artur- wants to merge 3 commits into
mainfrom
fix-scroll-restore
Draft

fix: restore scroll position reliably after a hot-swap page reload#25224
Artur- wants to merge 3 commits into
mainfrom
fix-scroll-restore

Conversation

@Artur-

@Artur- Artur- commented Aug 14, 2026

Copy link
Copy Markdown
Member

The positions captured before a full page reload were applied once, in a single animation frame after the Flow clients reported being idle. Idle only means that the initial UIDL has been processed, so on a slow load the positions were applied to a view that had not rendered yet: scrolling is clamped to the currently scrollable area, and the scroll containers were not in the DOM at all. The page was left at the top with no further attempt made. Keep re-applying each position until it takes effect instead, and give up on the ones that never become reachable after a timeout or once the user scrolls.

The browser restores the scroll position of a reloaded page on its own, which is what made the restore look like it worked: it only ran in the cases where the browser had already given up. Take restoration over from the browser for the reload so that the two do not compete.

The stored snapshot is now kept until restoring has settled, so that a page load interrupted by another reload does not lose it, and is ignored when it is older than the load it was written for.

The test waited for an element that the page the reload was triggered on already had, so it could assert against the document being replaced, and it reported a timeout on a lambda instead of the scroll positions that were expected.

The positions captured before a full page reload were applied once, in a
single animation frame after the Flow clients reported being idle. Idle
only means that the initial UIDL has been processed, so on a slow load
the positions were applied to a view that had not rendered yet: scrolling
is clamped to the currently scrollable area, and the scroll containers
were not in the DOM at all. The page was left at the top with no further
attempt made. Keep re-applying each position until it takes effect
instead, and give up on the ones that never become reachable after a
timeout or once the user scrolls.

The browser restores the scroll position of a reloaded page on its own,
which is what made the restore look like it worked: it only ran in the
cases where the browser had already given up. Take restoration over from
the browser for the reload so that the two do not compete.

The stored snapshot is now kept until restoring has settled, so that a
page load interrupted by another reload does not lose it, and is ignored
when it is older than the load it was written for.

The test waited for an element that the page the reload was triggered on
already had, so it could assert against the document being replaced, and
it reported a timeout on a lambda instead of the scroll positions that
were expected.
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Test Results

 1 450 files  ± 0   1 451 suites  ±0   1h 32m 56s ⏱️ + 3m 25s
10 412 tests  - 32  10 345 ✅  - 32  67 💤 ±0  0 ❌ ±0 
10 848 runs   - 32  10 780 ✅  - 32  68 💤 ±0  0 ❌ ±0 

Results for commit c6a60f4. ± Comparison against base commit 58e607b.

♻️ This comment has been updated with latest results.

@web-padawan

Copy link
Copy Markdown
Member

Disclaimer: the output below is AI generated - output of the guided-review skill. I did read it before posting 🙂


Findings

The core mechanism — verify-by-read-back with retry, plus taking scrollRestoration off the
browser — is the right fix. Findings are ranked most serious first. There were no existing PR
comments to duplicate. vaadin-dev-server has no frontend test infrastructure, so IT-only
coverage is consistent with the rest of the module.

1. saveScrollPositionsForReload deletes the snapshot on an interrupted reload — defeating the PR's own stated goal

vaadin-dev-server/src/main/frontend/hotswap-scroll.ts:209-218

const positions = captureScrollPositions();
if (Object.keys(positions).length === 0) {
  window.sessionStorage.removeItem(SNAPSHOT_KEY);
  return;
}

The description says the snapshot is now kept "so that a page load interrupted by another
reload does not lose it". But the interrupted case is exactly when the page is still at the top
— the restore has not landed yet — so captureScrollPositions() returns {} and this branch
deletes the snapshot it was meant to preserve.

Failure scenario: hot-swap reload #1 → page loads, restore loop still waiting for idle
clients (page at y=0) → a second hot-swap fires at t=800 ms → captureScrollPositions() sees
{}removeItem → the original position is gone for good. Keeping the snapshot until settle
only helps if a second save does not wipe it.

Fix: do not remove on empty; leave a still-fresh existing snapshot in place. The
SNAPSHOT_MAX_AGE_MS guard already handles genuinely stale ones.

2. history.scrollRestoration can be left stuck at 'manual'

hotswap-scroll.ts:217 sets 'manual'; the only reset is forgetStoredScrollPositions() at
hotswap-scroll.ts:249. Two reachable paths never get there:

  • The empty-positions early return above (return at line 213) leaves a previously-set
    'manual' in place with no snapshot. On the next load restoreScrollPositionsAfterReload
    hits if (stored === null) return; (line 227) and also never resets it. This compounds with
    finding 1: after that sequence, neither Flow nor the browser restores scroll.
  • If the reloaded document never runs dev-tools connectedCallback (dev tools disabled,
    bootstrap error, reload landing on an error page), 'manual' persists on that history entry
    — so browser-native restore stays silently off for subsequent manual reloads and for
    back/forward on that entry.

Dev-mode only, but it is a persistent side effect on a global the code does not own.
Minimum fix: reset to 'auto' in the stored === null branch of
restoreScrollPositionsAfterReload.

3. Scrollbar dragging is not treated as user scrolling

hotswap-scroll.ts:20const USER_SCROLL_EVENTS = ['wheel', 'touchstart', 'keydown'];

Dragging the scrollbar thumb with a mouse fires mousedown / mousemove, no wheel and no
keydown. So for a position that never becomes reachable (the new version of the view has less
content), the rAF loop keeps yanking the page back for the full 10 seconds while the user is
dragging — which the comment at lines 160-162 explicitly says it wants to avoid. The same
applies to a click on the scrollbar track and to drag-select autoscroll.

Fix: add mousedown (or pointerdown) to the list.

4. Cancellation is checked after applying, so one apply overrides the user

hotswap-scroll.ts:172-183 — the loop applies every pending position, then checks
cancelled. The wheel / keydown listener fires before the next rAF, so the user gets one
visible jump back to the restored position before the loop stops.

Fix: move an if (cancelled) { settle(); return; } check to the top of the rAF callback.

5. The idle wait and the retry loop share one 10 s deadline

hotswap-scroll.ts:158 and hotswap-scroll.ts:187-198waitForIdleClients polls until
performance.now() > deadline, then calls applyUntilRestored(), which after its first pass
immediately hits performance.now() > deadline and settles.

So whenever the clients never report idle, the restore degrades to exactly one apply
attempt
— the single-shot behavior this PR exists to fix. This is reachable when the page has
no Flow client registered under Vaadin.Flow.clients (the clients.length > 0 guard at line
189 means "no clients" polls forever), and when a UIDL round trip keeps isActive() true past
10 s.

Fix: give the idle wait its own smaller budget (e.g. 3 s) and leave the rest for retries, so
the fallback path actually retries.

Nits

  • parseSnapshot (hotswap-scroll.ts:238) does not validate timestamp. A snapshot with
    positions but no timestamp yields Date.now() - undefined = NaN, and NaN > 30000 is
    false, so it is treated as fresh. Not reachable from the current writer, only from
    hand-edited storage.
  • Overlapping restoreScrollPositions calls (a vaadin-refresh-ui arriving while a reload
    restore is in flight) each keep their own pending / cancelled and will fight each frame.
    Edge case, no state corruption.
  • ScrollPositionLiveReloadIT.javagetScrollPositions() returns -1 for a missing element,
    which then flows into the isRestored comparison. It works because the asserts in
    scrollAllContainers reject -1 up front, but the sentinel makes the post-reload diff
    message (expected [3400, 400, 300] but was [0, -1, -1]) read as a scroll value rather than
    "element absent". Cosmetic.

Verdict

One blocker: finding 1 — the empty-positions removeItem contradicts the change's stated
purpose and, combined with finding 2, leaves the page with neither restoration mechanism
active.

Findings 3–5 are real behavioral gaps that are cheap to close and worth raising in the same
round.

The overall approach and the test rewrite (marker-based document detection, actual-vs-expected
failure message, removal of sleep(500)) are clear improvements. I would ask for these five
points before approving rather than reworking anything structural.

@web-padawan

Copy link
Copy Markdown
Member

🤖 AI-generated

Adversarial Review: fix: restore scroll position reliably after a hot-swap page reload

The verify-by-read-back approach is right, and I'm not repeating the five findings already on the PR. What's left are mostly consequences of two design choices this PR makes: deferring snapshot removal until settle, and driving the retry loop from requestAnimationFrame. One of them is a behavior regression against main.


🔴 High

Stale snapshot re-applied to an unrelated page load

restoreScrollPositionsAfterReload (hotswap-scroll.ts:225-236) no longer removes the snapshot on read — it removes it in the onSettled callback. If the document is replaced before settle, forgetStoredScrollPositions never runs and the snapshot survives with its original timestamp, valid for SNAPSHOT_MAX_AGE_MS (30 s).

Failure scenario: hot-swap reload at t=0 → new document loads at t=0.5 s, restore loop running → developer clicks a link or edits the URL at t=1 s → new document, dev tools connectedCallback runs → snapshot age 1 s < 30 s → the previous view's __window__ offset (and any selector that also matches on the new page, #outer-scroll being exactly the kind of generic ID that does) is applied to a page it never belonged to. Because the retry loop then re-applies it every frame for up to 10 s and only wheel/touchstart/keydown cancel, the developer's page yanks itself back to an unrelated offset for up to ten seconds. On main this cannot happen: removeItem ran before JSON.parse.

Fix: TRIGGERED_KEY_IN_SESSION_STORAGE already means "this load came from a dev-tools reload" and is read three lines above the call in vaadin-dev-tools.ts:801-806. Gate restoration on it, and the snapshot can be kept for the interrupted-reload case without being applicable to any other load.


🟠 Medium

The retry loop does not run in a background tab, but its deadline does

applyUntilRestored (hotswap-scroll.ts:171-185) recurses through requestAnimationFrame, which browsers do not fire for a hidden document. deadline is wall-clock (performance.now()), and waitForIdleClients uses setTimeout, which keeps running while hidden. So for a tab that is in the background across the reload: the idle poll completes, applyUntilRestored queues a frame that never fires, the 10 s deadline expires unobserved, and on refocus exactly one apply pass runs before performance.now() > deadline settles it — the single-shot behavior this PR exists to replace, now with scrollRestoration = 'manual' so the browser is no longer there to catch it either.

That is the dominant hot-swap scenario: the developer saves in their IDE with the browser behind it. A single apply after refocus often does land, since the DOM has had time to render — but any view whose content is itself rAF-driven (a vaadin-grid scroller, virtual lists) has rendered nothing while hidden, so the one attempt hits a non-scrollable element and the position is lost with no fallback. Fix: don't let the deadline advance while document.hidden, or restart the budget on visibilitychange.

A restored position can be undone after it is spliced out of pending

hotswap-scroll.ts:173-178 removes an entry permanently on the first frame its read-back matches. Nothing re-checks it. Two ways that first match is premature: content can grow and then shrink during progressive render, and the browser silently clamps scrollY back down afterwards; and the keys are positional selectors (#outer-scroll > div:nth-of-type(1), hotswap-scroll.ts:49), so mid-render nth-of-type(1) can resolve to a different element than the one captured — if that element happens to accept the offset, the entry is retired against the wrong node and the real container never gets restored. Fix: keep verifying until the deadline or until positions have held for N consecutive frames, rather than retiring on first success.

Both apply paths honor scroll-behavior, so an animated or snapped scroll never reads back equal

applyScrollPosition uses window.scrollTo(pos.scrollLeft, pos.scrollTop) (hotswap-scroll.ts:122) and plain scrollTop/scrollLeft assignment (hotswap-scroll.ts:129-130). Both resolve their scroll behavior from the computed scroll-behavior, so under scroll-behavior: smooth — common in application stylesheets, and this code runs on arbitrary user apps — the read-back on the same frame returns the pre-animation value, the entry stays in pending, and the loop re-issues the scroll every frame for the full 10 s. CSS scroll snapping produces the same non-convergence for a different reason: the browser adjusts the offset to the nearest snap point, which will not be within SCROLL_TOLERANCE_PX of an arbitrary captured value. Fix: window.scrollTo({ left, top, behavior: 'instant' }) and el.scrollTo({ ..., behavior: 'instant' }); for snapping, compare against the post-adjustment value rather than the requested one.


🟡 Low / Nitpicks

A third path to the already-reported stuck 'manual'

Nothing inside the rAF callback is guarded, and document.querySelector throws SyntaxError on a malformed selector. A snapshot whose keys did not come from getElementPath — hand-edited storage, or a future/foreign writer — kills the recursion mid-loop, so settle() never runs: the snapshot is never removed and scrollRestoration stays 'manual' with no code path left to reset it. Not reachable from the current writer (CSS.escape plus tagName), but the failure is silent and sticky, which the two paths already reported are not.

Shadow-DOM scroll containers are never captured

captureScrollPositions walks document.querySelectorAll('*') (hotswap-scroll.ts:71) and applyScrollPosition uses document.querySelector — neither pierces shadow roots, so the scroller inside vaadin-grid, vaadin-scroller, and friends is invisible to this mechanism. Pre-existing, not introduced here, and out of scope for the PR — noting it because "restore scroll position reliably" reads as a stronger guarantee than the code can make for a real component-based view.

Two of the three new waits still report a lambda on timeout

waitForScrollRestoration gained an actual-vs-expected message, which is the right call. waitForStableScrollPositions (ScrollPositionLiveReloadIT.java:127-141) and waitForNewDocument (ScrollPositionLiveReloadIT.java:170-183) still surface the bare waitUntil timeout — the same opaque failure the PR description calls out as a problem.

No negative control in the UI-refresh test

scrollPositionPreservedAfterUIRefresh asserts the positions equal what they were before the refresh, which is also their value if nothing ever disturbed them. It passes whether the restore code ran or the DOM patch simply left the scroll containers alone. The reload test avoids this via markDocument; the refresh test has no equivalent. Neither test exercises the retry, the timeout, or the user-cancel logic that is the substance of this change — understandable given vaadin-dev-server has no frontend test setup at all, but worth knowing the ITs would still pass against the old single-shot code on a fast machine.


✅ What is done well

  • Setting history.scrollRestoration = 'manual' on the outgoing document (hotswap-scroll.ts:217) so it lands on the history entry the reload reuses. Easy to get backwards by setting it after load, when the browser has already restored.
  • Reverse iteration with splice in the apply loop (hotswap-scroll.ts:173-178) — correct index handling for in-place removal.
  • Cancel listeners registered { once: true, passive: true } and explicitly removed in settle (hotswap-scroll.ts:165-169); no listener outlives the restore.
  • parseSnapshot discarding anything without a positions field (hotswap-scroll.ts:241) — this is what makes the old bare-ScrollSnapshot format left in an existing session storage a no-op instead of a crash.
  • Constants carry the reasoning rather than the value (SNAPSHOT_MAX_AGE_MS, SCROLL_TOLERANCE_PX, USER_SCROLL_EVENTS), and the sleep(500) in the test is gone.

Summary: Not mergeable as-is — the deferred snapshot removal is a regression that lets a stale snapshot hijack an unrelated page load, and it needs gating on the existing dev-tools-reload flag; the rAF/visibility and behavior: 'instant' issues are cheap to close in the same round.

Artur- added 2 commits August 14, 2026 15:00
Keeping the stored snapshot until restoring has settled only helps if the
save before the next reload does not remove it. That is exactly what it
did: a reload interrupting a restore that had not landed yet captures
nothing, since the page is still at the top, and the empty capture
removed the snapshot. Leave a stored snapshot in place instead, and only
take restoration over from the browser when there is one.

Restoration was also left taken over from the browser when a reload had
nothing to store, so neither mechanism restored the next load. Hand it
back when there is no snapshot to apply.

Waiting for idle clients and retrying shared one deadline, so clients
that never report being idle used up the whole budget waiting and left a
single apply attempt, which is the behaviour being replaced. Give the
wait its own budget and start the retry budget when applying starts.

Dragging the scrollbar produces neither a wheel nor a keydown event, so
it did not count as the user scrolling, and cancelling was checked only
after applying, which yanked the page back once more before stopping.
Keeping the snapshot until the restore has settled made it outlive the
page load it belongs to: a document replaced while restoring, by a link
or an edited URL, left a snapshot that the next load applied to a page
the positions never came from. Restore only on the load the dev tools
reload produced, which is what the existing triggered flag in session
storage already marks, and discard the snapshot on any other load.

The retry budget was wall-clock while the retries themselves run on
animation frames, which a hidden document does not get. A tab in the
background across the reload, which is what hot swapping from an IDE
looks like, came back with the budget already spent and one attempt
left. Charge the budget per frame instead, capped, so that time when
nothing is being retried does not count against it.

Applying a position resolved its behaviour from the computed
scroll-behavior, so an application that sets it to smooth animated
towards the position and read back the value it started from. Scroll
explicitly instant. A position the element can reach but the browser
places elsewhere, as scroll snapping does, is now taken as final rather
than retried until the timeout.

A position is confirmed over several frames before it is considered
done, since content rendered afterwards can shrink the scrollable area
and have the position clamped back down. A key that is not a usable
selector no longer breaks out of the loop, which used to leave both the
snapshot and the browser's scroll restoration disabled with nothing left
to reset them.

The two remaining waits in the test report what they saw on timeout.
@totally-not-ai

Copy link
Copy Markdown
Contributor

Thanks for both rounds — all ten points were valid and are addressed in 3b10807 (first review) and c6a60f4 (adversarial review). Two claims I checked and read differently are at the end.

First review

  1. Empty capture deletes a pending snapshot. Correct, and it defeated the point of deferring removal. saveScrollPositionsForReload now only writes when there is something to capture and leaves an existing snapshot alone, and takes restoration over from the browser only when a snapshot exists.
  2. scrollRestoration stuck at 'manual'. Reset to 'auto' in the stored === null branch, as suggested.
  3. Scrollbar dragging. pointerdown added to the cancel events.
  4. Cancellation checked after applying. The check is now the first thing in the animation frame callback.
  5. Shared deadline collapsing to one attempt. Waiting for idle clients has its own budget (3 s) and the retry budget starts when applying starts.

Reproduced 1+2 together and 5 in a browser against the reviewed commit before fixing: the interrupted reload ended at [0, 0, 0] with scrollRestoration left at 'manual', and never-idle clients with a late render ended at [0, 0, 0]. Both restore correctly now. For 3+4, with an unreachable target the old code yanked a manual scroll from 200 back to 1435; it now stays at 200.

Adversarial review

Stale snapshot on an unrelated load — agreed, this was a regression against main and the suggested gate was the right one. restoreScrollPositionsAfterReload now takes whether this load came from a dev-tools reload, which connectedCallback already knows from TRIGGERED_KEY_IN_SESSION_STORAGE three lines above the call, and discards the snapshot on any other load. Verified: with a fresh snapshot in session storage, a load that did not come from a reload restores nothing, drops the snapshot, and hands scrollRestoration back.

Animation frames versus a wall-clock budget — agreed. The budget is now charged per frame and capped at 100 ms per frame, so time when no frames are delivered does not consume it. I could not reproduce an actually hidden document here (this headless Chromium keeps delivering frames to background tabs at 60 fps), so I verified the arithmetic instead by delivering frames 2 s apart: with content rendering at 12 s, the previous commit gave up at 0 and the current one restores.

scroll-behavior: smooth — fixed, both paths now scroll with behavior: 'instant'. One note on the impact: in the smooth case the old code did converge, because re-issuing the same target every frame keeps the animation heading there, so the symptom was churn and a late settle rather than a lost position. Snapping is handled as you suggested — a position the element can scroll to but the browser places elsewhere is taken as final, instead of being retried until the timeout.

Retiring on first match — a position is now confirmed over three consecutive frames before it is dropped, which covers the grow-then-shrink clamp. The positional-selector variant is real but needs the wrong element to also accept the exact offset; I left that as is rather than changing the key format in this PR.

querySelector throwing — guarded per position rather than around the loop, so one unusable key is dropped with a warning and the rest of the snapshot still restores and still settles.

The two waits reporting a lambda — both now fail with what they saw.

Two I read differently

Negative control in the UI-refresh test. The refresh test waits for the attach id to change, so the view is re-instantiated and both scroll containers are new elements at scrollTop 0 — those two assertions do fail without the restore. It is only the __window__ assertion that a DOM patch can satisfy on its own. The related point stands though: on a fast machine these ITs pass against the old single-shot code, which is why the behaviour above was checked in a browser harness rather than through them.

Shadow-DOM containers — agreed it is a real gap, and agreed it is pre-existing and out of scope here.

@sonarqubecloud

Copy link
Copy Markdown

@Artur-

Artur- commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

@claude please review

@claude

claude Bot commented Aug 17, 2026

Copy link
Copy Markdown

Claude finished @Artur-'s task in 2m 26s —— View job


Review: restore scroll position reliably after a hot-swap page reload

I read the current state of all three files and traced the flows that the two earlier review rounds flagged. This is a re-review — the ten prior findings are addressed in 3b10807/c6a60f4, so I focused on confirming those fixes are correct and on anything still open.

Prior findings — verified fixed

  • Interrupted reload no longer deletes the snapshotsaveScrollPositionsForReload now only writes when there is something to capture and leaves an existing snapshot in place (hotswap-scroll.ts:299-312). scrollRestoration is taken over only when a snapshot actually exists. ✅
  • scrollRestoration handed backrestoreScrollPositionsAfterReload resets to 'auto' in the stored === null branch (:331), and forgetStoredScrollPositions always resets it on settle (:355-358). ✅
  • Stale snapshot on an unrelated load — gated on triggeredByReload, wired from TRIGGERED_KEY_IN_SESSION_STORAGE in connectedCallback (vaadin-dev-tools.ts:804-809); a non-reload load discards the snapshot. ✅
  • rAF vs wall-clock budget — the retry budget is charged per frame and clamped to MAX_FRAME_COST_MS (:255), so a hidden tab that delivers no frames does not burn the deadline. ✅
  • scroll-behavior: smooth / snapping — both apply paths use behavior: 'instant' and 'settled' treats a browser-placed offset as final (:160-173). ✅
  • Retire-on-first-matchCONFIRMATION_FRAMES requires the read-back to hold for 3 frames, with a reset to 0 on unreachable (:200-207). ✅
  • querySelector throwing — guarded per position, drops the one bad key and keeps the rest restoring (:191-199). ✅
  • Idle wait vs retry deadline — separate budgets (IDLE_TIMEOUT_MS 3 s, RESTORE_TIMEOUT_MS 10 s), so a never-idle client still gets full retries (:276-288). ✅
  • Cancel checked firststopped() is the first thing in the frame callback (:257). ✅
  • parseSnapshot NaN nit / test messages — timestamp type-checked (:349); waits report actual-vs-expected. ✅

I also re-traced the interrupted-reload → manual-navigation sequence end to end: reload #1 consumes TRIGGERED, restore runs; if the developer navigates before it settles, the next load sees triggeredByReload=false, calls forgetStoredScrollPositions(), and hands scrollRestoration back. No stale snapshot survives. The mechanism is sound.

Residual observations (minor — not blockers)

  1. pointerdown cancels on any click, not just scrollbar drags (hotswap-scroll.ts:33). Adding pointerdown correctly catches scrollbar dragging, but it also fires for a click anywhere in the view — a button, a menu — which will cancel an in-flight restore even though the user did not scroll. In practice the restore usually settles within a few hundred ms of render, so the window for a false cancel is small, and a developer interacting with the view arguably no longer wants the old position. Worth a one-line comment noting the intent is broader than "scrolled by hand," or narrowing to the document scrolling element if false cancels show up.

  2. 'manual' can still persist if the reloaded document never runs connectedCallback (dev tools disabled after the reload, bootstrap error, or an error page). This was raised in round 1 and acknowledged as dev-mode-only; the reset only runs when the dev-tools element mounts. Fine to leave, but it is the one path where the global stays flipped with no code left to reset it. Not introduced differently by the latest changes.

  3. No frontend coverage for the retry / timeout / cancel logic — acknowledged already, since vaadin-dev-server has no frontend test setup. The ITs exercise the happy path and, as the author noted, would pass against the old single-shot code on a fast machine. The behavioral cases were verified manually in a browser harness. Not something to fix in this PR, but the substance of the change (retry-until-reachable, cancel, timeout) rests on manual verification rather than automated tests — worth keeping in mind if this area is touched again.

Assessment

The verify-by-read-back-with-retry approach plus taking scrollRestoration off the browser is the right design, and the two prior review rounds were thorough and correctly closed. I confirmed each fix landed and re-traced the reload/interruption/navigation state machine without finding a new correctness issue. The residual items above are minor and do not block merge. Nice work on the constants-carry-the-reasoning comments and the marker-based test rewrite.
· branch fix-scroll-restore

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants