prism branch T-1759/bugfix-raw-source-recolor-race commits 1 (+ 1 editorial fix in this review) files 4 touched lines +153 / -6 mergeable MERGEABLE / CLEAN lint make lint clean

Pre-push review: T-1759/bugfix-raw-source-recolor-race

Stops a background recolour of raw markdown source from resurrecting pre-reload document text. Adds a linesGeneration identity counter to RawSourceViewModel and splits recolor(with:) into a snapshot half and an apply half that drops results describing content no longer on screen. PR #325, reviewed against origin/main at c3ac551.

At a glance

  • Root cause is correctly identified and the obvious fix is correctly rejected. loadingContentHash is assigned synchronously before the detached parse (RawSourceViewModel.swift:163), so a recolour snapshotting afterwards captures the new hash and a hash-based guard would pass. The guard has to describe the content assigned to lines, which is what linesGeneration does.
  • All three writers of lines audited. Lines 193 (parse), 255 (applyRecolor), 262 (reset). Bumps at 194 and 264 only. The omission at 255 is correct — a recolour restyles the same text.
  • The guard is conservative in the safe direction. A spurious bump can only drop a valid recolour; it can never admit a stale one.
  • Test-suite cascade genuinely fixed. Reproduced on origin/main: 101 failures / 97 at 0.000s, versus 2 distinct honest failures on this branch, with the four new tests green 8/8.
  • No blockers, no majors. Findings are one behavioural residual (pre-existing, narrower than the bug fixed), one signature-hardening suggestion, and polish (counter type consistency, &+=, nested-type placement, prose spelling drift).

Verdict

Ready to push

The core mechanism is correct and independently verified. lines is assigned at exactly three sites (parse write-back, applyRecolor, reset()); the generation counter is bumped at exactly the two that change content identity, and never by a recolour. Every interleaving of loadContent / recolor / reset was enumerated and none leaves a wrong lines value, a wrong isLoading, or a stale maxLineLength. RecolorSnapshot: Sendable is sound (both member types infer Sendable), and generation overflow or re-aliasing is unreachable.

Measured, not assumed: the four new regression tests pass across all eight locale-configuration runs; the two pre-existing raw-source race failures are reproduced identically on origin/main, where their unguarded lines[0] subscript turned into 101 reported failures, 97 of them at 0.000s — a fake host-crash cascade. With the subscripts guarded the same run reports only the two honest failures. That test change is a strict improvement and touches no test logic.

Two items are worth a follow-up ticket rather than a change on this branch: a residual in which newly parsed content can keep the pre-change syntax palette (pre-existing outcome, now the only one), and an applyRecolor signature that does not enforce that its two arguments correspond. Neither is a regression and neither blocks the push.

Review findings

12 raised · 1 fixed · 11 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism can show a markdown file two ways: rendered, or as the raw text you typed. In the raw view the text is colour-coded — headings one colour, code markers another. Two separate background jobs can rewrite that coloured text:

  • Parse — runs when the file's content changes (someone edited it on disk, or a remote document was refreshed). It reads the new text and colours it from scratch.
  • Recolour — runs when you switch theme. It reuses the positions the parse already worked out and just swaps the colours, so it is much cheaper and finishes much faster.

Recolour worked by taking a copy of the lines currently on screen, colouring that copy on a background thread, then writing the copy back. The bug: if the file reloaded and then you changed theme, recolour copied the old text, and because it is the faster job it usually finished last — so it put the old document back on screen. It stayed there until the next reload or until you toggled out of raw view and back.

Why It Matters

The reader is silently shown stale content. Nothing looks broken, so there is no reason to distrust what is on screen.

Key Concepts

The fix gives the on-screen content a version number, linesGeneration. When recolour takes its copy, it also notes the version number that copy came from. When it is finally ready to write back, it compares: if the number has moved on, a newer parse has replaced the content and the recolour's result describes a document nobody is looking at any more, so it is thrown away.

The obvious alternative — asking “is this still the document being loaded?” — does not work, and the commit explains why: by the time the racing recolour takes its copy, that answer already refers to the new document, so the check passes and the stale write goes through anyway. The version number has to describe what is currently on screen, not what is on its way.

Architecture

RawSourceViewModel is an @Observable @MainActor class with two off-main pipelines that both write the same private(set) var lines:

  • loadContent(_:colors:highlightingEnabled:)Task.detached full regex parse, write-back guarded by loadingContentHash (T-700).
  • recolor(with:)Task.detached token restyle, write-back previously guarded only by Task.isCancelled.

Cancellation is a one-directional guarantee: it can only cancel work that already exists. loadContent cancels the live recolorTask (line 160), which covers recolour-then-reload. It cannot cover reload-then-recolour, because that recolour is a task nobody has a handle on yet. Being an order of magnitude cheaper than a parse, it reliably wins the write race.

Patterns

The fix is the repo's established generation-tag pattern, already used in DocumentSession.parseGeneration / parseRevision and NotesManager.loadGeneration: a monotonic counter compared after the await, dropping the result on mismatch. Two details make this instance correct where a naive port would not be:

  1. Bump at write-back, not at operation start. The counter must name the content assigned to lines. Bumping when loadContent starts is exactly the failure mode of the loadingContentHash approach.
  2. Recolour deliberately does not bump. It restyles the text it snapshotted, so content identity is unchanged; bumping would invalidate a concurrent sibling for no reason.

recolor(with:) is split into makeRecolorSnapshot() -> RecolorSnapshot? and applyRecolor(_:from:). This mirrors the rationale documented on BridgeMessageRouter: extract the validation decision into a directly-callable unit so the ordering rules can be unit-tested without depending on scheduler timing.

Trade-offs

Both halves are internal rather than private so @testable import can reach them (@testable exposes internal, not private). That widens the view model's API surface for a test seam — justified here because the timing-based alternative is precisely the flakiness class T-1541 warns against, and because both halves are load-bearing in the production path, not test-only.

The chosen design drops a stale recolour without re-issuing it. In the exact interleaving being fixed the parse may have captured the pre-change palette, so the correct-content / stale-colours outcome becomes deterministic instead of a coin flip. That is strictly better than the alternative outcome it replaces (wrong content entirely), but it is a real residual — see Double-check.

Deep dive

All writes to lines and linesGeneration happen on the MainActor (the class is @MainActor; the detached tasks re-enter via MainActor.run), so each write-back is atomic with respect to its own guard and no cross-actor tearing is possible. lines is assigned at exactly three sites — 193, 255, 262 — and the counter is bumped at 194 and 264. Enumerating the interleavings:

  • parse(A)snapshot(g1)parse(B) write-back (g2) → apply: dropped, lines = B. Correct — this is the bug.
  • snapshot(g1)reset()(g2) → apply: dropped, lines stays []. Correct, and doubly guarded since reset also cancels recolorTask.
  • parse(A)(g1)snapshot(g1)apply(g1): applies. Correct — the fix must not block the ordinary theme-change path, and recolorAppliesWhenGenerationUnchanged pins that.
  • reset() during an in-flight parse: loadingContentHash = nil (271) fails the pre-existing write-back guard, so the generation is not bumped and lines stays empty. Correct.
  • Older parse write-back arriving last: dropped by the hash guard at 188-191; generation not bumped. Correct.

applyRecolor deliberately touches neither isLoading (a recolour is not a load) nor maxLineLength. The latter is safe by construction: RawSourceHighlightParser.recolor is a map that copies index and text verbatim and only rebuilds attributedText, so max(text.count) is invariant under recolouring.

Guard directionality. The check is conservative in the safe direction: a spurious bump can only discard a still-valid recolour, never admit a stale one. One spurious-bump path is reachable today (two loadContent calls with identical content hashes both pass the write-back guard and bump twice) and is harmless.

Sendable. RecolorSnapshot: Sendable is required, not decorative — snapshot is captured implicitly by the @Sendable Task.detached closure, outside the explicit [colors] capture list. The conformance rests on inferred Sendable for HighlightedSourceLine (String, Int, [HighlightToken], AttributedString?) and HighlightToken (Range<String.Index> + payload-free enum), both internal non-frozen structs. All-let snapshot, value-copied array taken on the MainActor, read-only in the detached task.

Edge cases

Overflow / reuse: unreachable. &+= on a 64-bit Int needs 2^63 increments. Even granting wraparound, a stale snapshot's generation could only re-alias after exactly 2^64 further bumps, so wraparound can never resurrect a stale match. The wrapping operator is therefore inconsequential either way; += 1 would read better, and the repo's two sibling counters use UInt64.

Recolour-vs-recolour ordering remains unguarded (pre-existing, out of scope). Overlapping recolours share a generation, so if a cancelled task slips past its !Task.isCancelled check and lands after a newer one, the older palette wins. RawSourceView wraps each theme change in its own detached Task {}, so overlap is possible. Worth remembering if a rapid-theme-flip bug ever surfaces.

Test determinism

The regression tests call makeRecolorSnapshot() and applyRecolor(_:from:) directly, so the interleaving is fixed by statement order — no sleeps, no thresholds, no dependence on which detached task wins. Mutation-tested upstream: reverting the generation guard fails recolorDoesNotOverwriteNewerParse and resetInvalidatesRecolorSnapshot while the other two still pass, which is the correct red/green signature for a guard.

Completeness Assessment

Fully implemented: the parse-vs-recolour ordering guarantee; reset() invalidation; the empty-lines early return; deterministic regression coverage of all four states (drop-on-newer-parse, apply-on-unchanged, drop-on-reset, nil-when-empty); agent-note documentation of both the invariant and the test-host cascade hazard; a user-facing CHANGELOG entry.

Partially implemented: palette freshness after the race. The stale write is stopped, but the dropped recolour is not re-issued, so the new content can render with the pre-change token colours until the next reparse or raw→rendered→raw toggle.

Not implemented (correctly out of scope): the pre-existing async let start-order flakiness in loadContentCancelsPreviousParse / staleParseResultsNotApplied, which still fail on main; and recolour-vs-recolour ordering.

Important changes — detailed

RawSourceViewModel: linesGeneration identifies the content assigned to lines

prism/ViewModels/RawSourceViewModel.swift

Why it matters. This is the entire fix. If the counter's semantics were 'content being loaded' rather than 'content on screen', the guard would silently pass and the bug would remain — which is exactly the trap the ticket's own suggested fix fell into.

What to look at. prism/ViewModels/RawSourceViewModel.swift:75-87 (declaration + doc), :194 (parse bump), :264 (reset bump)

Takeaway. A staleness guard must be keyed on the state that is currently committed, not on the operation in flight. loadingContentHash is assigned synchronously at line 163, before the detached parse, so any check against it is already answering for the new document by the time a racing task reads it. Ask 'is the thing I based my work on still the thing being shown?' — not 'is the newest work still the newest?'.
Rationale. Stated in the commit body and in the property's own doc comment: cancellation can only cancel work that already exists, so reload-then-recolour is structurally uncoverable by recolorTask?.cancel(). A recolour never bumps because it restyles the text it snapshotted, leaving content identity unchanged.

recolor(with:) split into makeRecolorSnapshot() and applyRecolor(_:from:)

prism/ViewModels/RawSourceViewModel.swift

Why it matters. The split is what makes the ordering rule testable without depending on which detached task happens to finish first. It also localises the invariant: there is now exactly one place a restyle can be written back, and it is guarded.

What to look at. prism/ViewModels/RawSourceViewModel.swift:209-256

Takeaway. When a concurrency guard is the thing under test, extract the decision into a synchronous unit and drive the halves in the order you want to verify. The alternative — sleeps or timing thresholds — is what produced this repo's earlier flaky-test debt (T-1541). Same rationale is already written down on BridgeMessageRouter for the JS bridge's generation tags.
Rationale. Explicit in the doc comment: 'so the ordering guarantee is expressed in the model rather than left to task scheduling — and so it can be tested deterministically.'

Two pre-existing racy tests changed from lines[0] to lines.first?

prismTests/RawSourceViewModelHighlightingTests.swift

Why it matters. Verified by running both refs: on origin/main these two tests produce 101 reported failures, 97 of them at 0.000s, because the trapping subscript kills the test host and every queued test is reported as failed. On this branch the same run reports only the two honest failures. This converts an unreadable suite into a diagnosable one, and touches no test logic.

What to look at. prismTests/RawSourceViewModelHighlightingTests.swift:147, :432

Takeaway. In a test that deliberately races, never assert through a trapping accessor. A trap in a Swift Testing process takes the host down and every queued test is reported as a 0.000s failure — the real signal is buried under a hundred fabricated ones. lines.first? / try #require(lines.first) turns the same mismatch into one readable failure.
Rationale. Stated in the commit body as an incidental hardening, with the underlying async let start-order flakiness explicitly left pre-existing and untouched.

Four deterministic regression tests covering all guard outcomes

prismTests/RawSourceViewModelHighlightingTests.swift

Why it matters. A guard needs both directions pinned or a later refactor can delete it and stay green. These cover drop-on-newer-parse, apply-on-unchanged, drop-on-reset, and nil-when-empty; the two negative cases fail when the guard is reverted while the positive case keeps passing.

What to look at. prismTests/RawSourceViewModelHighlightingTests.swift:222-299

Takeaway. recolorAppliesWhenGenerationUnchanged is the test that earns its keep. Without it, 'return early always' would satisfy every other test in the file — a guard test suite that only proves work gets dropped proves nothing.
Rationale. Commit body reports the mutation check: removing the generation check fails the two race tests and leaves the normal theme-change test passing.

Agent note records both the invariant and the cascade hazard

docs/agent-notes/raw-source-view.md

Why it matters. The second-order knowledge is the valuable part: it tells a future session not to re-attempt the loading-hash fix, and not to mistake a host-crash cascade for a hundred real failures. Both are things that would otherwise be re-investigated from scratch.

What to look at. docs/agent-notes/raw-source-view.md:60-75

Takeaway. Documenting the rejected approach is worth as much as documenting the accepted one. 'Do NOT try to fix this class of bug with a loading hash, and here is precisely why the check passes' saves the next session an entire debugging cycle.
Rationale. Matches the repo's own agent-notes policy: write a note only where a future session would otherwise re-investigate — a non-obvious gotcha or a rejected approach worth remembering.

Key decisions

Bump the counter at parse write-back, not at loadContent entry.

The counter has to describe the content currently assigned to lines. Bumping on entry reproduces the loadingContentHash failure mode: the racing recolour snapshots after the bump, captures the new value, matches at write-back, and the stale lines land anyway.

A recolour never bumps the generation.

Recolouring restyles the very lines it snapshotted, so content identity is unchanged. Bumping would invalidate a concurrent sibling recolour for no reason, and would break recolorAppliesWhenGenerationUnchanged.

reset() bumps rather than relying on its own recolorTask?.cancel().

Belt and braces, and consistent: a recolour that starts after the cancel but before the view unmounts is not covered by cancellation either. The bump makes the rule uniform — anything that changes what lines holds invalidates outstanding snapshots.

Expose the two halves as internal for a test seam rather than keeping them private.

@testable import reaches internal but not private, and the timing-based alternative is exactly the flakiness class T-1541 warns against. Both halves are load-bearing in recolor(with:), so this is not a test-only API. The repo does use #if DEBUG elsewhere for genuinely test-only surface.

(inferred — not stated by the author.)
Keep the bespoke counter instead of threading DocumentSession.parseRevision through.

RawSourceViewModel has no session dependency and RawSourceView receives only content: String, so a session revision would be a new dependency existing purely to serve a guard. It would also be insufficient: raw-source content reparses on a highlighting setting change and is invalidated by reset(), neither of which bumps parseRevision — so a session revision would let a stale recolour through on exactly those paths.

(inferred — not stated by the author.)
Leave the pre-existing async let start-order flakiness alone.

Fixing it would mean rewriting two tests' concurrency setup, unrelated to this ticket and independently reproducible on main. The branch only removes the trapping subscript that made their failure catastrophic.

Review findings

SeverityAreaFindingResolution
minordocs/agent-notes/raw-source-view.md:75The new cascade-hazard section grouped RawSourceViewModelTests.staleTaskDoesNotClearLoading into 'When they fail, lines[0] traps'. It does not — that test already asserts through lines.first? on origin/main, and the diff does not touch that file. The note credited the branch with a hardening it never made, which is the kind of wrong detail that sends a future session to 'fix' a test that is already correct. Same sentence said the tests 'race two loadContent calls' (staleParseResultsNotApplied races three) and 'fail intermittently on main', where a measured run failed them every time.Rewritten editorially. Names only the two tests the branch actually hardened, notes staleTaskDoesNotClearLoading races the same way and already used .first?, corrects the call count, and records the measured cascade numbers (main: 101 failures / 97 at 0.000s, versus 2 honest failures guarded). No code or test change.
minorprism/ViewModels/RawSourceViewModel.swift:253-256 and prism/Views/RawSourceView.swift:84-91Residual: a dropped recolour is never re-issued. In the exact race being fixed, the reload's parse may have captured the pre-change palette (the .task id is ContentIdentifier(content:highlighting:) — theme identity is not part of it, so a theme change does not re-run it), and .onChange(of: colors.rawHeaderText.description) is edge-triggered and has already fired. recolor has exactly one caller in the app, and RawSourceView carries no theme-derived .id(), so nothing re-mounts it. The newly parsed document can therefore render with the previous theme's token colours until the next reparse or a raw/rendered toggle. Scope is the highlighting-on case only: the background and unhighlighted text read live colours. NOT a regression — on main this same interleaving already produced this outcome whenever the parse won the race; the fix removes the worse outcome (wrong content) and leaves this one deterministic. Window is narrow: the theme change must land between the parse starting and its MainActor write-back, concurrently with an on-disk change or URL refresh, with raw source visible.Reported, not fixed — a re-issue would be a production change, and the shape of it is a judgement call for the author (applyRecolor returning Bool and retrying once with a fresh snapshot; storing the palette identity lines were last coloured with and re-recolouring after a parse write-back when it differs; or folding theme identity into the .task id). Recommend a follow-up ticket. Independently confirmed twice by code reading.
minorprism/ViewModels/RawSourceViewModel.swift:253applyRecolor(_ recolored:from:) validates the generation but not that recolored derives from snapshot.lines. A caller can legally pass an unrelated array with a fresh snapshot and replace the document body with content that neither bumps linesGeneration nor updates maxLineLength, leaving the horizontal scroll width wrong. Nothing does this today — the only callers are recolor(with:) and the tests — but the two-argument pairing contract is unenforced.Reported, not fixed (production change). Cheap hardening: guard recolored.count == snapshot.lines.count, since the parser's recolor is a 1:1 map so any mismatch is misuse. Stronger: make the snapshot the only producer of the applicable value (snapshot.recolored(with:) returning a type that carries the generation, applied by a single-argument apply), which also removes the pairing contract entirely.
nitprism/ViewModels/RawSourceViewModel.swift:87linesGeneration is declared Int; the repo's two sibling counters both use UInt64 (DocumentSession.parseGeneration / parseRevision, NotesManager.loadGeneration). Not a bug, but the codebase has a settled type for this field.Reported, not fixed (production change). Cosmetic consistency only.
nitprism/ViewModels/RawSourceViewModel.swift:194, :264&+= (wrapping add) on a 64-bit Int buys nothing: overflow needs 2^63 increments, so the trap it avoids is as unreachable as the overflow. It costs a reader a moment wondering what wraparound would mean here. (Answering the question directly: even under wraparound a stale snapshot could only re-alias after exactly 2^64 further bumps, so it can never resurrect a stale match.)Reported, not fixed (production change). += 1 would read better; the sibling counters do use &+= on UInt64, so this is arguably house style.
nitprism/ViewModels/RawSourceViewModel.swift:234struct RecolorSnapshot sits mid-'MARK: - Methods', between recolor(with:) and makeRecolorSnapshot(). The file's other nested types (Thresholds:97, Constants:137) are grouped under their own MARKs.Reported, not fixed. Placement is arguably defensible next to its only users.
nitprism/ViewModels/RawSourceViewModel.swift:75-87, :247-252, :263The T-1759 rationale is written out at similar length four times (property doc, applyRecolor doc, the inline comment inside reset(), plus the agent note) on top of the CHANGELOG and commit body. The comment at :263 restates what :77 already says.Reported, not fixed. One canonical explanation on the property plus short pointers would age better, but nothing here is wrong.
nitprism/ViewModels/RawSourceViewModel.swift (prose)Spelling drifts within the file: identifiers and pre-existing docs use American 'recolor' (recolor(with:), 'Recolors existing lines'), the new comments use British 'recolour'. The project is en-AU/en-GB leaning, so the new prose is arguably the house spelling and the identifiers cannot change.Left as-is deliberately — matches the CHANGELOG's en-GB voice, and renaming the API for spelling consistency would be churn.
nitCHANGELOG.md:21Factually accurate and stylistically consistent with neighbours (voice, length, en-GB spelling, ticket-ref placement all match). Two small gaps: three of the four sibling entries explicitly disclose their residual, and this one does not (see the palette finding above); and the trigger list ('file changed on disk — or a URL document was refreshed') omits the other reparse triggers that share the race, namely toggling the raw-source highlighting setting and switching documents.Not changed. Disclosing a residual in user-facing release notes is a product-communication decision for the author, not an editorial fix, and the entry as written is not wrong.
nitspecs/bugfixes/No specs/bugfixes/<name>/report.md for this fix, which the repo has 98 folders' worth of precedent for. But the four most recent bug-fix merges on origin/main (T-1655, T-1829, T-1876, T-1851, all 2026-07-26) also add none, documenting via docs/agent-notes/ and CHANGELOG instead — only 3 of the last 14 commits touch specs/bugfixes/ at all.Not created. This branch does exactly what the last four merges did. Flagging as a convention drift worth an explicit decision (revive the report convention, or record that agent-notes superseded it) rather than a gap unique to this PR.
minorprismTests (pre-existing, verified against origin/main)loadContentCancelsPreviousParse and staleParseResultsNotApplied still fail on this branch — and failed on origin/main in an identical targeted run, so they are not caused by this change. On this branch they are the only failures (16 across 4 locale configurations x repetitions, 0 at 0.000s). On origin/main the same run reported 101 failures with 97 at 0.000s. The only change to loadContent is a linesGeneration increment, which cannot affect lines, isLoading, or maxLineLength.Not fixed — explicitly out of scope, and the fix would mean rewriting two tests' concurrency setup. Documented in the agent note. Worth knowing that make test-quick will now report 2 red raw-source tests instead of a cascade; that is honest reporting, not a new break.
nitprism/ViewModels/RawSourceViewModel.swift (pre-existing)Recolour-vs-recolour ordering remains unguarded: overlapping recolours share a generation, so if a cancelled task slips past its !Task.isCancelled check and lands after a newer one, the older palette wins. RawSourceView wraps each theme change in its own detached Task {}, so overlap is possible.Out of scope for this ticket and pre-existing. Noted here so it is on record if a rapid-theme-flip bug ever surfaces.

Per-file diffs

Click to expand.

prism/ViewModels/RawSourceViewModel.swift Modified +49 / -4
diff --git a/prism/ViewModels/RawSourceViewModel.swift b/prism/ViewModels/RawSourceViewModel.swiftindex d3372b4..56db394 100644--- a/prism/ViewModels/RawSourceViewModel.swift+++ b/prism/ViewModels/RawSourceViewModel.swift@@ -72,6 +72,20 @@ final class RawSourceViewModel {     /// Content hash being loaded, used to prevent stale results from overwriting.     private var loadingContentHash: Int? +    /// Identifies the document currently assigned to `lines`.+    ///+    /// Bumped every time a parse writes a document into `lines`, and by+    /// `reset()`. A recolour never bumps it — recolouring restyles the very+    /// lines it snapshotted, so the content identity is unchanged.+    ///+    /// This is what makes parse-vs-recolour ordering safe (T-1759). Cancellation+    /// alone is not enough: `loadContent` can only cancel recolour tasks that+    /// already exist, so a theme change that *starts* after a reload's parse has+    /// begun snapshots the pre-reload lines and, if it finishes last, would+    /// otherwise overwrite the freshly parsed document. Comparing this+    /// generation at write-back time drops that stale result.+    private var linesGeneration: Int = 0+     /// Current parsing task (for cancellation on new content).     private var parseTask: Task<Void, Never>? @@ -177,6 +191,7 @@ final class RawSourceViewModel {                 }                  self.lines = computedLines+                self.linesGeneration &+= 1                 self.maxLineLength = computedLines.reduce(0) { max($0, $1.text.count) }                 self.isLoading = false             }@@ -192,31 +207,61 @@ final class RawSourceViewModel {     ///     /// - Parameter colors: New theme colors to apply.     func recolor(with colors: any ThemeColors) async {-        guard !lines.isEmpty else { return }+        guard let snapshot = makeRecolorSnapshot() else { return }          // Cancel previous recolor if in progress         recolorTask?.cancel() -        let currentLines = lines         let parser = RawSourceHighlightParser()          recolorTask = Task.detached(priority: .userInitiated) { [colors] in-            let recolored = parser.recolor(currentLines, colors: colors)+            let recolored = parser.recolor(snapshot.lines, colors: colors)              await MainActor.run { [weak self] in                 guard let self = self, !Task.isCancelled else { return }-                self.lines = recolored+                self.applyRecolor(recolored, from: snapshot)             }         }          await recolorTask?.value     } +    /// The lines a recolour operates on, tagged with the generation they belong to.+    ///+    /// Recolouring is split into snapshot and apply halves so the ordering+    /// guarantee is expressed in the model rather than left to task scheduling+    /// (T-1759) — and so it can be tested deterministically.+    struct RecolorSnapshot: Sendable {+        let lines: [HighlightedSourceLine]+        let generation: Int+    }++    /// Captures the lines to recolour together with the generation they belong to.+    ///+    /// Returns `nil` when there is nothing to recolour.+    func makeRecolorSnapshot() -> RecolorSnapshot? {+        guard !lines.isEmpty else { return nil }+        return RecolorSnapshot(lines: lines, generation: linesGeneration)+    }++    /// Applies recoloured lines, but only while the snapshot they came from is+    /// still the content on screen.+    ///+    /// A generation mismatch means a parse (or a `reset()`) landed after the+    /// snapshot was taken, so these lines describe a document that is no longer+    /// displayed. Writing them back would resurrect pre-reload content (T-1759).+    func applyRecolor(_ recolored: [HighlightedSourceLine], from snapshot: RecolorSnapshot) {+        guard linesGeneration == snapshot.generation else { return }+        lines = recolored+    }+     /// Resets state when toggling away from raw source view.     func reset() {         parseTask?.cancel()         recolorTask?.cancel()         lines = []+        // Invalidate any recolour snapshot taken before the reset (T-1759).+        linesGeneration &+= 1         isLoading = true         isDocumentTooLarge = false         maxLineLength = 0
prismTests/RawSourceViewModelHighlightingTests.swift Modified +86 / -2
diff --git a/prismTests/RawSourceViewModelHighlightingTests.swift b/prismTests/RawSourceViewModelHighlightingTests.swiftindex c9106c5..964c388 100644--- a/prismTests/RawSourceViewModelHighlightingTests.swift+++ b/prismTests/RawSourceViewModelHighlightingTests.swift@@ -141,7 +141,10 @@ struct RawSourceViewModelHighlightingTests {          // The second content should be loaded (first was cancelled or overwritten)         #expect(viewModel.lines.count == 1)-        #expect(viewModel.lines[0].text == "Short second content")+        // `.first` rather than `[0]`: this test races two loads on purpose, and an+        // out-of-range subscript traps the whole test host, turning one failure+        // into a suite-wide cascade.+        #expect(viewModel.lines.first?.text == "Short second content")         #expect(!viewModel.isLoading)     } @@ -216,6 +219,85 @@ struct RawSourceViewModelHighlightingTests {         #expect(viewModel.lines.isEmpty)     } +    // MARK: - Parse vs Recolor Ordering (T-1759)++    // These drive the two halves of `recolor(with:)` — `makeRecolorSnapshot()`+    // and `applyRecolor(_:from:)` — directly, so the interleaving is fixed by+    // the call order rather than by how fast the detached tasks happen to run.+    // A timing-based version of this test would be flaky (T-1541).++    @Test("recolor snapshotted before a reload does not overwrite the new content")+    func recolorDoesNotOverwriteNewerParse() async throws {+        // T-1759: while raw source is visible, a theme change can start *after*+        // a reload's parse has begun. It snapshots the pre-reload lines and, if+        // it finishes last, must not replace the freshly parsed document.+        let viewModel = RawSourceViewModel()+        let lightColors = LightThemeColors()+        let darkColors = DarkThemeColors()++        await viewModel.loadContent("# Old document", colors: lightColors, highlightingEnabled: true)++        // The theme change snapshots whatever is on screen right now.+        let snapshot = try #require(viewModel.makeRecolorSnapshot())+        #expect(snapshot.lines.map(\.text) == ["# Old document"])++        // The reload's parse lands while that recolor is still running off-main.+        await viewModel.loadContent("# New document", colors: lightColors, highlightingEnabled: true)+        #expect(viewModel.lines.map(\.text) == ["# New document"])++        // The stale recolor finishes last. Expected: dropped.+        // Before the fix: it overwrote `lines` with the pre-reload content.+        let recolored = RawSourceHighlightParser().recolor(snapshot.lines, colors: darkColors)+        viewModel.applyRecolor(recolored, from: snapshot)++        #expect(viewModel.lines.map(\.text) == ["# New document"])+    }++    @Test("recolor applies when no reload happened after the snapshot")+    func recolorAppliesWhenGenerationUnchanged() async throws {+        // The T-1759 guard must not block the ordinary theme-change path.+        let viewModel = RawSourceViewModel()+        let lightColors = LightThemeColors()+        let darkColors = DarkThemeColors()++        await viewModel.loadContent("# Header", colors: lightColors, highlightingEnabled: true)++        let snapshot = try #require(viewModel.makeRecolorSnapshot())+        let recolored = RawSourceHighlightParser().recolor(snapshot.lines, colors: darkColors)+        viewModel.applyRecolor(recolored, from: snapshot)++        let applied = try #require(viewModel.lines.first)+        #expect(applied.text == "# Header")+        #expect(applied.attributedText != nil)+        #expect(!applied.tokens.isEmpty)+    }++    @Test("reset invalidates a recolor snapshot taken before it")+    func resetInvalidatesRecolorSnapshot() async throws {+        // A recolor in flight when the view is toggled away must not repopulate+        // `lines` after reset cleared them.+        let viewModel = RawSourceViewModel()+        let lightColors = LightThemeColors()+        let darkColors = DarkThemeColors()++        await viewModel.loadContent("# Header", colors: lightColors, highlightingEnabled: true)++        let snapshot = try #require(viewModel.makeRecolorSnapshot())+        viewModel.reset()++        let recolored = RawSourceHighlightParser().recolor(snapshot.lines, colors: darkColors)+        viewModel.applyRecolor(recolored, from: snapshot)++        #expect(viewModel.lines.isEmpty)+    }++    @Test("makeRecolorSnapshot returns nil when there are no lines")+    func recolorSnapshotNilWhenEmpty() {+        let viewModel = RawSourceViewModel()++        #expect(viewModel.makeRecolorSnapshot() == nil)+    }+     // MARK: - Document Size Threshold Tests (Req 9.4)      @Test("document exceeding 15000 lines disables highlighting")@@ -345,7 +427,9 @@ struct RawSourceViewModelHighlightingTests {          // Final content should be the last one loaded         #expect(viewModel.lines.count == 1)-        #expect(viewModel.lines[0].text == "Third content")+        // See `loadContentCancelsPreviousParse` — `.first` keeps a racy mismatch+        // from trapping the test host.+        #expect(viewModel.lines.first?.text == "Third content")     }      // MARK: - Reset Tests
docs/agent-notes/raw-source-view.md Modified +17 / -0 (incl. this review's editorial fix)
diff --git a/docs/agent-notes/raw-source-view.md b/docs/agent-notes/raw-source-view.mdindex dc1e0a1..4b00f7d 100644--- a/docs/agent-notes/raw-source-view.md+++ b/docs/agent-notes/raw-source-view.md@@ -57,6 +57,23 @@ SwiftUI's `ScrollView([.vertical, .horizontal])` on macOS centers its content in  `RawSourceViewModel.loadContent` spawns detached tasks. Multiple tasks can overlap when `loadContent` is called rapidly. The `isLoading` state must only be cleared by the *active* task (the one whose content hash matches `loadingContentHash`). Using `defer { self.isLoading = false }` before the freshness guard allows stale tasks to clear loading prematurely. See T-700. +### Recolour Must Validate `linesGeneration`, Not Just Cancellation (T-1759)++`RawSourceViewModel` runs two off-main pipelines that both write `lines`: `loadContent` (parse) and `recolor` (restyle a snapshot). Task cancellation only covers work that *already exists* — `loadContent` cancels the current `recolorTask`, but it cannot cancel a recolour that starts afterwards. A theme change firing after a reload's parse has begun therefore snapshots the pre-reload lines and, if it finishes last, overwrites the freshly parsed document.++The guard is `linesGeneration`: a counter bumped by every parse write-back and by `reset()`, never by a recolour (a recolour restyles the same content, so the identity is unchanged). `recolor(with:)` is split into `makeRecolorSnapshot()` and `applyRecolor(_:from:)`; the apply half drops the result when the generation has moved on.++Two consequences for future edits:++- Do NOT add a code path that assigns `lines` without either bumping `linesGeneration` (new content) or going through `applyRecolor` (restyle). A bare assignment reopens the race.+- Do NOT try to fix this class of bug with a *loading* hash. By the time the racing recolour takes its snapshot, `loadingContentHash` already refers to the new document, so it matches and lets the stale write through. The generation must describe the content currently *assigned* to `lines`, not the one being loaded.++`RawSourceViewModelHighlightingTests` drives the two halves directly so the interleaving is fixed by call order rather than by timing (this repo has a history of flaky timing-threshold tests — T-1541).++### Concurrency Tests in This Area Must Not Subscript `lines` Unguarded++`loadContentCancelsPreviousParse` and `staleParseResultsNotApplied` race two and three overlapping `loadContent` calls via `async let`, whose start order is not guaranteed, and they fail on `main` — reliably in a macOS run, not just occasionally. Their assertions used to read `lines[0]`, which traps when the losing load wins, killing the test *host* so every queued test in the run reports as failed at 0.000s. Measured on 2026-07-26: `main` reported 101 failures, 97 of them at 0.000s; with the subscripts guarded the same run reports only the two honest failures. Use `lines.first?` / `try #require(lines.first)` for any assertion in a test that races loads. (`RawSourceViewModelTests.staleTaskDoesNotClearLoading` races the same way and already does.)+ ### Code Fence Pattern Must Use `\S*` Not `\w*` for Language IDs  `RawSourceHighlightParser.codeFencePattern` uses `\S*` (non-whitespace) for the language identifier capture group. Do NOT change this to `\w*` — language IDs like `c++`, `c#`, `objective-c`, `f#`, and `node.js` contain non-word characters that `\w` would reject. See T-485.
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex c740e3a..578bfe6 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Changing the theme while viewing raw source no longer brings back the previous version of the document (T-1759). If the file changed on disk — or a URL document was refreshed — at the same moment the colours changed, the recolour worked from a snapshot taken before the reload and, finishing last, put the old text back on screen, where it stayed until the next reload or raw/rendered toggle. Recolouring now only applies while the lines it started from are still the ones being shown. - The system **Increase Contrast** accessibility setting applies to the document again (T-1829). Since the WebKit rendering cutover, turning it on changed the app's own interface but left the document body untouched: search highlights and footnote badges stayed translucent and tertiary text stayed low-contrast. The rendered document now uses the same higher-contrast search, footnote, and tertiary colours as the rest of the app, on every theme, and the deliberately-faded "add note" button beside each block is shown at full strength. It follows the setting live — toggling it while a document is open updates immediately, without a reload and without losing your reading position, and the styling survives a WebKit process recovery. Footnote popovers keep the standard palette. - Adding a note to text selected after a footnote badge now quotes the text you actually selected (T-1876). In a paragraph like `before[^1] after`, selecting `after` quoted words from near the start of the paragraph instead, and saving stored a wrong source range, which the note then carried into relocation and inline-note export. The rendered document's text-to-source map now keeps its offsets in the block's own coordinates across any number of footnote badges. Footnotes inside list items and table cells are tracked separately. - The saved reading position is no longer corrupted by content the document does not show (T-1851). Hidden content measures as sitting exactly at the top of the window, which beat every genuinely visible block, so the document reported a block the reader could not see as the block being read. That happened with any heading collapsed, and — because the carrier holding a document's YAML frontmatter is hidden the same way — on every document that starts with frontmatter, collapsed or not. Reopening the document, returning from raw source, or recovering from a rendering-process restart then landed on the wrong block or did nothing at all. Content the document does not lay out is now skipped when working out the reading position.

Things to double-check

Palette freshness after the race (recommended follow-up ticket)

The one behavioural gap left by the design. Reproduce it by having raw source open with highlighting on, triggering a reload (edit the file on disk or refresh a URL document), and switching theme in the same instant. Expected after this fix: the new document text, correct background, but token colours from the previous theme, persisting until the next reparse or a raw→rendered→raw toggle.

Not a regression — main already produced this whenever the parse won the race; the fix makes it the only outcome instead of a coin flip against showing the wrong document entirely. Fix options, cheapest first: have applyRecolor return Bool and let recolor(with:) retry once with a fresh snapshot on false; store the palette identity lines were last coloured with and re-recolour after a parse write-back when it differs; or fold theme identity into the .task(id:) so the reparse itself uses the current palette.

The two red raw-source tests will now be visible

Anyone running make test-quick after this lands will see loadContentCancelsPreviousParse and staleParseResultsNotApplied fail, where previously the run collapsed into a ~100-failure cascade. Verified identical on origin/main, so do not attribute them to this branch. Their async let start-order flakiness is the real underlying issue and is deliberately untouched — worth its own ticket.

Any future assignment to lines

The invariant is now load-bearing and easy to break silently: a new code path that assigns lines without either bumping linesGeneration (new content) or going through applyRecolor (restyle) reopens the race, and nothing in the type system prevents it. The agent note records this; a reviewer of any future lines = should check it.

Base moved during this review

origin/main advanced from 5ebd43d to c3ac551 (T-1960, a SearchCoordinator test-helper fix) while the review was running. The merge base is still 5ebd43d, so the reviewed diff is unchanged; the new commit touches disjoint files and git merge-tree plus the GitHub API both report the branch MERGEABLE / CLEAN. Re-confirm before merging if further PRs in this batch land.