fix(tabs): clear the whole reading position when a tab changes document - #447
Merged
Merged
Conversation
Following a link, and back/forward, keep the tab and swap the document under it. `navigate()` ended that with `tab.scrollTop = 0` and nothing else; `goBack`/`goForward` cleared nothing. Both restore paths are fallback cascades that stop at the first entry that resolves — the preview tries `anchorLine`, then `scrollPercentage`, then `scrollTop`, and the editor tries `editorViewState`, then `anchorLine`, then `scrollPercentage`. `scrollTop` is the entry each one consults LAST, so for any tab that had been scrolled the reset was unreachable and the tab restored the previous document's position. The three routes that repoint a tab now clear all four fields together through one private `clearReadingPosition`. Save As and rename change the path while the text on screen stays put, so they keep the position. The preview restore effect also gains a dependency on which document the active tab holds. It is the only thing that moves the preview to a tab's recorded position, and it ran on tab activation only — so before this the reset reached nothing at the moment of the navigation either, and the container kept the pixel offset the reader had in the previous document. Measured in the running frontend, scrolled 7000px into a 60-section document and following a link to a 40-section one: the new document opened at 7000px on "beta section 16 paragraph 9", and switching away and back restored 6623px from the old document's `anchorLine` of 361. Both are 0 after this change. Back and forward were the same: Back left the preview at 11000px in a document it had been at the top of. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao
force-pushed
the
fix/navigate-leaves-stale-scroll-anchors
branch
from
August 3, 2026 12:34
1707b39 to
0ab6c4f
Compare
PathGao
pushed a commit
that referenced
this pull request
Aug 3, 2026
…er one #425 gave each document its own heading-fold state by moving the set from the window onto the tab. A tab is not a document, though: `navigate` (following a Markdown link), `goBack` and `goForward` keep the tab and swap the file under it, and `collapsedHeaders` travelled with it. A fold key is a heading slug, unique only within a document, so the incoming file was rendered with any section whose slug happened to match already shut. `loadMarkdown` reads the set on the line after the `navigate` call, so nothing is deferred — the HTML that first appears carries `is-collapsed`, and the outline hides the same section's children on the same render, with nothing on screen to explain either. Measured over 202 real Markdown documents (this repo and its dependencies' READMEs and changelogs): for 27.9% of ordered document pairs, at least one heading slug of the first names a heading in the second. `installation` occurs in 29.7% of them, `usage` in 27.7%. #447 established that the same three routes must clear the reading position, and left folds for a separate change. Rather than have each route remember two resets — the shape #436 and #439 were both about — those routes now call one `forgetPreviousDocument(tab)`, which calls #447's `clearReadingPosition` and a new `clearCollapsedHeaders`. The two helpers stay separate: a stale position moves the viewport, a stale fold hides text, and each needs its own explanation. What they shared was the trigger, which had no name until now. Save As (`updateTabPath`) and rename (`renameTab`) change the path while the text on screen stays put, so they do not get there. Tests guard both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PathGao
added a commit
that referenced
this pull request
Aug 3, 2026
…er one (#448) #425 gave each document its own heading-fold state by moving the set from the window onto the tab. A tab is not a document, though: `navigate` (following a Markdown link), `goBack` and `goForward` keep the tab and swap the file under it, and `collapsedHeaders` travelled with it. A fold key is a heading slug, unique only within a document, so the incoming file was rendered with any section whose slug happened to match already shut. `loadMarkdown` reads the set on the line after the `navigate` call, so nothing is deferred — the HTML that first appears carries `is-collapsed`, and the outline hides the same section's children on the same render, with nothing on screen to explain either. Measured over 202 real Markdown documents (this repo and its dependencies' READMEs and changelogs): for 27.9% of ordered document pairs, at least one heading slug of the first names a heading in the second. `installation` occurs in 29.7% of them, `usage` in 27.7%. #447 established that the same three routes must clear the reading position, and left folds for a separate change. Rather than have each route remember two resets — the shape #436 and #439 were both about — those routes now call one `forgetPreviousDocument(tab)`, which calls #447's `clearReadingPosition` and a new `clearCollapsedHeaders`. The two helpers stay separate: a stale position moves the viewport, a stale fold hides text, and each needs its own explanation. What they shared was the trigger, which had no name until now. Save As (`updateTabPath`) and rename (`renameTab`) change the path while the text on screen stays put, so they do not get there. Tests guard both directions. Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#443 documented that
scrollTop/scrollPercentage/anchorLineare a fallback cascade and reported, without fixing it, thatnavigate()resets only the last entry. This is that fix, plus the two things the investigation turned up:goBack/goForwardhave the same shape and reset nothing, and the reset does not reach the preview at the moment of the navigation either.npm test573/573,npm run check638 files / 0 errors,npm run buildclean, all on1707b39. No Rust touched.Mechanism
Three routes point an existing tab at a different document:
navigate()(following a Markdown link),goBack()andgoForward(). Both restore paths are cascades that stop at the first entry that resolves:MarkdownViewer.svelte:1133-1162)anchorLinescrollPercentagescrollTopcomponents/Editor.svelte:290-311)editorViewStateanchorLinescrollPercentagescrollTopis the entry each one consults last, and it was the only thingnavigate()reset (tabs.svelte.ts:813). For any tab that had actually been scrolled,anchorLine > 0, so stage 1 answers and the reset is unreachable.goBack/goForwardreset nothing at all.anchorLineis a source line. The line the reader left in one file names an unrelated block in the next one, and it resolves: overprocessMarkdownHtmloutput of two different 400-line documents, 420/420 (100%) of the anchor lines reachable in document A resolve to some element in document B (scripts/tabReadingPosition.test.ts, run against the realfindAnchorElement). Nothing reports a failure — the cascade stops at stage 1 and the later entries are never consulted.The reset was written when it was the only field
navigate()isb00eb24(2026-01-28). At that commitscrollTopwas the only position field, and the restore effect was reactive to it:So
tab.scrollTop = 0did scroll the preview to the top, immediately.scrollPercentageandanchorLinearrived three days later inedfb555("roughly match viewer-editor scroll location") without touchingnavigate(), and the effect was later narrowed toactiveTabId+markdownBodywith its bodyuntracked. The decision was right when it was made; two fields and one effect rewrite went past it.Measured in the running frontend
Driven through the real Svelte frontend against a stubbed Tauri bridge (two synthetic documents,
render_markdownemitting comrak'sdata-sourceposshape).a.mdis 60 sections × 6 paragraphs,b.mdis 40 × 11. Numbers are the preview container'sscrollTopand the block sitting at the anchor offset.a.mdalpha section 26 paragraph 4b.mdbeta section 16 paragraph 9b.mdbeta section 15 paragraph 11— the oldanchorLineof 361 resolved in the new documentb.mdto 11000pxa.mdalpha section 41 paragraph 1b.mdThroughout the "before" column the tab's own
scrollTopreads0— the reset landed on the field, and nothing ever asked it.The fix
One place that owns "this tab now shows a different document." A private
clearReadingPosition(tab)clearseditorViewState,anchorLine,scrollPercentage,scrollTop, called fromnavigate,goBackandgoForward. All four, because the cascades stop at their first resolved entry — clearing only some of them reproduces the same defect one stage up, which is what clearing onlyscrollTopwas.editorViewStateis in the set because it is the editor's stage 1, the direct analogue ofanchorLine.collapsedHeadersis deliberately not: it is fold state, not a position. See "Not covered".The preview restore effect gains a dependency on which document the active tab holds (
void currentFile). It is the only thing that moves the preview to a tab's recorded position, so without it the store can record "top of the new document" and nothing acts on it — the container simply keeps the pixel offset from the previous document, which is the 7000px row above. This restores whatb00eb24's effect did reactively, at the one event that needs it.Save As and rename keep the position. They change the tab's path while the text on screen stays put, so they do not call
clearReadingPosition. The effect does re-run for them, and re-applies the position the tab already has: measured at 8000px, Save As and rename both land at 7998px withanchorLineunchanged at 435 — the anchor round trip's own 2px, not a jump. Closing an unrelated tab is the same.Tests
scripts/tabReadingPosition.test.ts, 8 tests, driving the realTabManager, the realprocessMarkdownHtmland the realfindAnchorElement. No file is read as text.scrollTopsurvives the navigationgoBackresets nothinggoForwardresets nothingtab.path === pathearly returnscrollTopalone is unreachableRed on
6b58cd5: 4 fail / 4 pass. The four that pass on master are the guards — they must stay green on both sides or they are not guarding anything.Mutation check
Each mutation applied alone to
6b58cd5+ this test file:editorViewState = nullanchorLine = 0scrollPercentage = 0scrollTop = 0navigategoBackgoForwardupdateTabPath(over-reach)renameTab(over-reach)Both directions, including over-reach.
Not covered
The effect dependency has no executable test. It lives inside an
$effectin a 4341-line.sveltefile, whichnode --testcannot import — the coverage gap #442 is about. Its evidence is the measured table above, not a test. A source-text assertion would only detect that a line was edited, which is what #433 deleted a test for.collapsedHeadershas the same shape and is not fixed here.loadMarkdownrenders the incoming document withfoldsForTab(activeId)(documentSession.svelte.ts:396), which still holds the previous document's fold keys, and a fold key is a heading slug — every file with an## Introductionshares the keyintroduction. So following a link can open the new document with a section already shut. That is the exact failure #425's own comment describes, reached through navigation rather than through a window-wide set. It is fold state rather than a reading position, it hides text rather than moving the viewport, and it needs its own evidence, so it belongs in its own PR.A hash link to another file still does not land on its anchor.
openRelativeMarkdownTargetcallsscrollToAnchorWhenReadyafter the load and I could not get it to resolve in the harness — but that is true on master too, where the deep link left the preview at the previous document's 6000px. This change moves the failure from "the old document's offset" to "the top of the right document"; the anchor jump itself is untouched and unexplained. The ordering is not a race:loadMarkdownawaitstick()before returning, so the effect has flushed before the anchor scroll starts.No
cargo test. No Rust in the diff.Severity. Losing a scroll position is an annoyance, not data loss. What makes it worth a PR is that it is silent in both directions — nothing reports that the anchor failed to mean anything, and the deferred half has no visible connection to the link click that caused it.
🤖 Generated with Claude Code