Skip to content

fix(tabs): clear the whole reading position when a tab changes document - #447

Merged
PathGao merged 1 commit into
masterfrom
fix/navigate-leaves-stale-scroll-anchors
Aug 3, 2026
Merged

fix(tabs): clear the whole reading position when a tab changes document#447
PathGao merged 1 commit into
masterfrom
fix/navigate-leaves-stale-scroll-anchors

Conversation

@PathGao

@PathGao PathGao commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

#443 documented that scrollTop / scrollPercentage / anchorLine are a fallback cascade and reported, without fixing it, that navigate() resets only the last entry. This is that fix, plus the two things the investigation turned up: goBack/goForward have the same shape and reset nothing, and the reset does not reach the preview at the moment of the navigation either.

npm test 573/573, npm run check 638 files / 0 errors, npm run build clean, all on 1707b39. No Rust touched.

Mechanism

Three routes point an existing tab at a different document: navigate() (following a Markdown link), goBack() and goForward(). Both restore paths are cascades that stop at the first entry that resolves:

1st 2nd 3rd
preview (MarkdownViewer.svelte:1133-1162) anchorLine scrollPercentage scrollTop
editor (components/Editor.svelte:290-311) editorViewState anchorLine scrollPercentage

scrollTop is the entry each one consults last, and it was the only thing navigate() 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/goForward reset nothing at all.

anchorLine is a source line. The line the reader left in one file names an unrelated block in the next one, and it resolves: over processMarkdownHtml output 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 real findAnchorElement). 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() is b00eb24 (2026-01-28). At that commit scrollTop was the only position field, and the restore effect was reactive to it:

$effect(() => {
	const tab = tabManager.activeTab;
	if (tab && markdownBody) markdownBody.scrollTop = tab.scrollTop;
});

So tab.scrollTop = 0 did scroll the preview to the top, immediately. scrollPercentage and anchorLine arrived three days later in edfb555 ("roughly match viewer-editor scroll location") without touching navigate(), and the effect was later narrowed to activeTabId + markdownBody with its body untracked. 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_markdown emitting comrak's data-sourcepos shape). a.md is 60 sections × 6 paragraphs, b.md is 40 × 11. Numbers are the preview container's scrollTop and the block sitting at the anchor offset.

step before after
reader scrolls a.md 7000px, line 361 alpha section 26 paragraph 4 same
follows the link to b.md 7000px, beta section 16 paragraph 9 0px, top of b.md
switches to another tab and back 6623px, beta section 15 paragraph 11 — the old anchorLine of 361 resolved in the new document 0px
reader scrolls b.md to 11000px line 597 same
Back to a.md 11000px, alpha section 41 paragraph 1 0px
Forward to b.md 11000px 0px

Throughout the "before" column the tab's own scrollTop reads 0 — 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) clears editorViewState, anchorLine, scrollPercentage, scrollTop, called from navigate, goBack and goForward. 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 only scrollTop was.

editorViewState is in the set because it is the editor's stage 1, the direct analogue of anchorLine. collapsedHeaders is 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 what b00eb24'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 with anchorLine unchanged 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 real TabManager, the real processMarkdownHtml and the real findAnchorElement. No file is read as text.

test what can go wrong
following a link to another file leaves the tab at the top of it the cascade entry above scrollTop survives the navigation
going back to the previous file leaves the tab at the top of it goBack resets nothing
going forward again leaves the tab at the top of that file goForward resets nothing
Save As renames the tab without moving the reader a blanket "clear on every path write"
renaming the file on disk does not move the reader same
a link that resolves to the file already open is not a navigation the tab.path === path early return
a line number carried over from the previous document resolves into this one the measurement above — states why clearing scrollTop alone is unreachable
a cleared reading position resolves to nothing, so the cascade falls through the guards that make stage 3 reachable again

Red 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:

mutation result
baseline 8 pass
drop editorViewState = null 3 fail
drop anchorLine = 0 4 fail
drop scrollPercentage = 0 4 fail
drop scrollTop = 0 4 fail
call removed from navigate 2 fail
call removed from goBack 1 fail
call removed from goForward 1 fail
also called from updateTabPath (over-reach) 1 fail
also called from renameTab (over-reach) 1 fail

Both directions, including over-reach.

Not covered

The effect dependency has no executable test. It lives inside an $effect in a 4341-line .svelte file, which node --test cannot 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.

collapsedHeaders has the same shape and is not fixed here. loadMarkdown renders the incoming document with foldsForTab(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 ## Introduction shares the key introduction. 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. openRelativeMarkdownTarget calls scrollToAnchorWhenReady after 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: loadMarkdown awaits tick() 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

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
PathGao force-pushed the fix/navigate-leaves-stale-scroll-anchors branch from 1707b39 to 0ab6c4f Compare August 3, 2026 12:34
@PathGao
PathGao merged commit df80968 into master Aug 3, 2026
4 checks passed
@PathGao
PathGao deleted the fix/navigate-leaves-stale-scroll-anchors branch August 3, 2026 12:54
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>
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