prism branch worktree-t-1681-offmain-html-emit commits 5 files 25 (code: ~22; sample+specs are data/docs) lines +8225 / -78 (sample dominates)

Pre-push review: T-1681 off-main HTML emission

Moves document HTML emission off the MainActor with a per-parseRevision cache and a SwiftSoup Mutex, plus a mechanical nonisolated sweep of the pure emit call tree.

At a glance

  • SwiftSoup serialized behind a Mutex: SwiftSoup's static object pools (Parser tree-builders, StringBuilder.pool, QueryParser.cacheInstance) are unsynchronized; one per-call lock around sanitize/plainText/sanitizeSVG restores serialization regardless of calling thread.
  • Emit moved off-main via Task.detached: under NonisolatedNonsendingByDefault a plain nonisolated async helper would inherit the MainActor caller's executor and run on the main thread — detaching forces the emit onto the cooperative pool.
  • Per-parseRevision HTML cache on DocumentSession with revision-guarded writes and a synchronous cache-miss fallback that also stores — correctness never depends on precompute timing.
  • ~15 pure model/service types marked nonisolated: converged with zero new warnings against an origin/main baseline; a down payment on the Swift 6 migration (T-1741).
  • Benchmark fixture + 10 tests: a 247 KB HTML-heavy sample (~1,560 SwiftSoup parses/emit) and off-main byte-parity, cache-semantics, serve-path, concurrency-stress, and timed-regression tests.
  • Loading indicator and incremental rendering deferred to T-1744 and T-1743 respectively — not in this scope.

Verdict

Ready to push

Concurrency and locking design verified sound by independent quality + efficiency reviews; all 10 smolspec requirements met with divergences documented; all review findings fixed; iOS+macOS builds clean with zero new warnings vs an origin/main baseline; lint clean; 10 OffMainEmitTests plus HTMLSanitizer/BlockHTMLEmitter/WebSecurityRegression/SamplesCompliance suites green.

Review findings

7 raised · 5 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What Changed / What This Does

When you open a markdown document in Prism, the app turns the parsed document into HTML before the web view can display it. For a big or HTML-heavy document that conversion used to run on the main thread — the same thread that keeps the UI responsive — so the app froze until it finished, and it re-did the work on every reload.

This change fixes the freeze two ways: (1) it moves the work off the main thread onto a background worker, and (2) it remembers the result (a cache) so reloads reuse the saved HTML instead of rebuilding it. Underneath, a lock makes the HTML-cleaning library (SwiftSoup) safe to use now that it can run on a background thread.

Why It Matters

Opening or reloading a large or HTML-heavy document no longer freezes the app. The user can keep interacting while it renders, and reloads are near-instant. This was the exact user complaint the ticket set out to fix.

Key Concepts

  • Main thread: draws the UI and handles input; slow work on it makes the app feel frozen.
  • Off-main / background thread: a worker that does slow work without blocking the UI.
  • Cache: a saved copy of an expensive result — here, the document's HTML.
  • Parse revision: a counter that ticks up each time the document is re-parsed; it's the “version number” the cache is keyed on.
  • SwiftSoup: the library that sanitises (cleans and makes safe) raw HTML in markdown.
  • Lock (Mutex): a gate that lets only one thread through at a time, preventing shared-data corruption.

Changes Overview

The WebKit document render path (BlockHTMLEmitter.emit → served by PrismDocSchemeHandler) previously rebuilt the full-document HTML synchronously on the MainActor on every serve, wrapped in MainActor.assumeIsolated. This branch reworks that path across five areas:

  • HTMLSanitizer.swift — a shared Mutex(()) (swiftSoupLock, line 38) wraps the SwiftSoup body of sanitize, plainText, and sanitizeSVG.
  • WebDocumentControllerFactory.swift — new precomputeDocumentHTML(for:settings:) async (line 213) emits off-main and stores on the session; emitHTML (line 153) serves from cache with a synchronous on-main fallback; renderDocumentHTML (line 188) and documentRenderSettings (line 203) are extracted shared helpers.
  • DocumentSession.swift — an @ObservationIgnored single (revision, html) tuple cache (line 159), a revision-guarded storeCachedDocumentHTML(_:for:) (line 164), and a currentCachedDocumentHTML accessor (line 177) returning nil unless the key matches the live parseRevision.
  • DocumentScrollContent.swift — the web-load .task(id:) now awaits precomputeDocumentHTML before webController.load(...) (line 219).
  • The nonisolated sweep — ~15 one-line annotations across pure model/service value types so emit's closure is callable off the MainActor.

Plus the benchmark fixture (samples/large-html-heavy.md), 10 tests in OffMainEmitTests.swift, and CHANGELOG / CLAUDE.md doc updates.

Implementation Approach

Precompute → cache → serve, with a fallback. The view's load task snapshots the emit inputs (parsedBlocks, footnoteData, parseRevision, RenderSettings) in one synchronous MainActor step, then runs emit inside Task.detached(priority: .userInitiated). The store is dropped if the captured revision drifted or the task was cancelled. At serve time, emitHTML reads the cache; on a hit it serves the cached body, on a miss it emits synchronously on the MainActor (pre-T-1681 behaviour) and stores. Either way it stamps the per-serve generation island via WebDocumentLoader.injectGeneration — so the generation is never baked into the cache.

Cache key is parseRevision alone, not RenderSettings: catalogStrings() is launch-static and showHTMLComments only sets the inert prism-comment-hidden marker (live visibility is driven by the setCommentVisibility bridge command, which does not reload or bump the revision).

One lock serialises SwiftSoup. It keeps unsynchronized mutable static pools; the previous guarantee was “everything runs on the MainActor.” A per-call Mutex around each entry point restores serialisation regardless of caller thread, with short critical sections that let an on-main image sanitise interleave between blocks.

Trade-offs

  • Task.detached over a nonisolated async helper: under NonisolatedNonsendingByDefault a plain nonisolated async function inherits the caller's executor and would run on the MainActor, defeating the fix (Decision 8).
  • Shared Mutex over a @globalActor: an actor would force await into the synchronous emit walk and turn the synchronous search path async — far more invasive (Decision 6).
  • Memory: retaining the emitted HTML roughly doubles resident document memory near the 10MB cap; accepted because re-emitting on every re-serve is exactly the cost being removed (Decision 7).
  • No per-block sanitiser size cap: deferred (Decision 4) — behaviour-changing and out of proportion to a performance fix.

Technical Deep Dive

Isolation model. With SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, emit and its whole helper tree are MainActor-isolated by default. Making it callable off-main required marking the transitive closure it reads nonisolated — not just the emitter tree but the pure model value types it walks (MarkdownBlock + ListItem, ColumnAlignment, TableDisplayMode; BlockIDStore/BlockIDCache; DocumentSourceMap; CommentBlockDetector, TableRowContextQuote, PrismLinkRoute, MermaidTypeParser; Logger.prism; the NSRegularExpression(staticPattern:) init). Because SWIFT_VERSION = 5.0 these are warnings, not errors — but the zero-new-warning bar still requires resolving them. The sweep converged with zero new warnings against an origin/main baseline; all targets are pure/stateless, so nonisolated is behaviour-preserving and compiler-verified (Decision 2/8), and a down payment on the Swift 6 language-mode migration (T-1741).

The NonisolatedNonsendingByDefault trap is the most load-bearing decision. A nonisolated async emit helper would inherit the MainActor caller's executor and run on the main thread — silently defeating the fix while compiling cleanly. Task.detached forces the emit onto the cooperative pool. The off-main-parity test plus the manual before/after on the fixture are what actually prove the emit left the main thread.

Cache invariants. One optional (revision, html) tuple (not two parallel optionals) so revision and html can never drift. Writes are revision-guarded; reads re-check the key against the live revision, returning nil on mismatch. The precompute early-outs (guard currentCachedDocumentHTML == nil) so a same-revision re-fire (view remount, iOS folder-access re-navigation) skips a redundant emit — safe because comment toggles don't bump parseRevision. The synchronous fallback both preserves exact pre-change output on a miss and repopulates the cache so a subsequent same-revision serve (WebContent-recovery replay) hits.

SwiftSoup serialization. The pools (Parser tree-builder pools, StringBuilder.pool, QueryParser.cacheInstance, all nonisolated(unsafe) static var) are the shared state; every access flows through the three entry points, so one Mutex around each fully serialises SwiftSoup regardless of caller thread, with zero changes to the image / search call sites. The 90-call tri-directional stress test guards it.

Architecture Impact

  • Clean ownership: the model owns storage but knows nothing about RenderSettings/AppSettings; the factory owns the settings-aware emit; the view triggers the precompute at the one place that owns the load lifecycle (Decision 5) — respecting the model-import boundary.
  • Cache on the session, not the controller, so it survives WebDocumentController/WebPage rebuilds — exactly the recovery and re-navigation paths where a re-emit would be most wasteful.
  • Generation island stays per-serve: caching only the emit body and injecting processGeneration at serve time preserves the bridge's generation-tagging contract unchanged.

Potential Issues

  • Comment-visibility race (pre-existing): a cached string carries whatever prism-comment-hidden state it was emitted with; the live setCommentVisibility reasserts correct state on every load before paint (Decision 1).
  • In-session locale change (accepted, low-likelihood): catalog strings are baked at emit time and keyed only on parseRevision; a language change relaunches the app, so baked strings never go stale within a session (Decision 1).
  • Superseded precompute still completes: Task.detached isn't cancellable mid-emit, so a superseding reparse lets the emit finish before its store is discarded by the guards. Bounded and correct; reparse is rare.
  • First-paint latency: the emit no longer blocks, but WebKit still lays out the emitted DOM, so a very HTML-dense document takes a visible moment to first-paint. Incremental rendering is deferred to T-1743.

Important changes — detailed

HTMLSanitizer: serialize every SwiftSoup entry point behind a Mutex

prism/Services/WebRendering/HTMLSanitizer.swift

Why it matters. The enabling safety change. Moving emit off-main moves its per-block/per-inline sanitises off-main for the first time, where they can race the still-on-main image sanitizeSVG / search plainText on SwiftSoup's unsynchronized static pools.

What to look at. HTMLSanitizer.swift:38 (swiftSoupLock) wrapping sanitize/plainText/sanitizeSVG

Takeaway. When a not-thread-safe library funnels all shared state through a few entry points, one per-call Mutex around each restores serialization with zero changes to any caller, and short critical sections let callers interleave rather than stall.
Rationale. A full-actor confinement would force await into the synchronous emit walk (per-block hops) and ripple into the synchronous search path. The pools are the shared state and every access flows through these three functions, so a single per-call lock is the minimal correct guarantee (Decision 6).

WebDocumentControllerFactory: off-main precompute + cache-serve with synchronous fallback

prism/ViewModels/WebDocumentControllerFactory.swift

Why it matters. This is where the freeze is actually fixed. precomputeDocumentHTML snapshots inputs synchronously then emits on Task.detached; emitHTML serves the cache on a hit and, on a miss, emits synchronously on-main AND stores. The generation island is injected per serve, never baked in.

What to look at. WebDocumentControllerFactory.swift:153 (emitHTML), :188 (renderDocumentHTML), :203 (documentRenderSettings), :213 (precomputeDocumentHTML)

Takeaway. Precompute-then-cache with a synchronous fallback decouples correctness from background-task timing: the common path is free, the rare race still produces exact output and repopulates the cache for the next serve.
Rationale. Task.detached (not a nonisolated async helper) is required under NonisolatedNonsendingByDefault to actually leave the MainActor (Decision 8). Retaining the synchronous on-main emit as a cache-miss fallback keeps the rollout safe and incremental (Decision 3).

DocumentSession: per-revision HTML cache as one revision-guarded tuple

prism/Models/DocumentSession.swift

Why it matters. The cache lives on the session (not the controller) so it survives WebPage rebuilds on WebContent recovery and iOS folder-access re-navigation. A single (revision, html) tuple with a revision-guarded writer and a key-checked reader makes stale HTML unrepresentable.

What to look at. DocumentSession.swift:159 (cachedDocument tuple), :164 (storeCachedDocumentHTML), :177 (currentCachedDocumentHTML)

Takeaway. Collapsing two parallel optionals (value + its version) into one optional tuple removes an entire class of invalid mixed states; guard writes on the current key and re-check the key on read for belt-and-suspenders staleness safety.
Rationale. The session survives controller/WebPage rebuilds, so it is the right owner (Decision 5). Keyed on parseRevision alone, deliberately independent of RenderSettings, because catalogStrings() is launch-static and comment visibility is driven live by the bridge (Decision 1). Accepting up to ~10MB extra resident memory is bounded by the document cap (Decision 7).

The nonisolated sweep across the pure emit call tree

prism/Models/MarkdownBlock.swift

Why it matters. SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor makes emit and everything it reads MainActor-isolated by default. ~15 one-line nonisolated annotations on pure model/service value types make emit's closure callable off-main; compiler-checked, so any accidental MainActor dependency is caught at build time.

What to look at. MarkdownBlock.swift (BlockIDStore, BlockIDCache, ListItem, ColumnAlignment, TableDisplayMode, MarkdownBlock, NSRegularExpression(staticPattern:)) + ~11 sibling one-liners

Takeaway. Getting a synchronous function off the MainActor under default-MainActor isolation is a mechanical, compiler-guided nonisolated sweep of its transitive read closure — not a rewrite — when the targets are genuinely pure.
Rationale. These types are pure/stateless with only immutable static state; nonisolated is behaviour-preserving and compiler-enforced. The sweep converged with zero new warnings against an origin/main baseline and is a down payment on the T-1741 Swift 6 migration (Decision 2/8).

OffMainEmitTests: lock, off-main parity, cache, serve, and timed regression

prismTests/WebRendering/OffMainEmitTests.swift

Why it matters. 10 tests pin the behaviours that could silently regress: the SwiftSoup lock under 90 interleaved calls, byte-parity of a detached emit vs on-main, the cache store/stale/reparse semantics, the serve path (cache-hit, miss-emits-and-stores, per-serve generation island), and a timed hang-guard over the fixture.

What to look at. OffMainEmitTests.swift:55 (concurrent lock), :106 (off-main parity), :127-167 (cache), :193-229 (serve path), :233 (timed)

Takeaway. Prove an off-main move with a byte-parity assertion between the detached and on-main outputs, and treat a timed test as a hang guard (generous ceiling) rather than a tight budget, since wall-clock depends on parallel test load and a shared lock.
Rationale. The serve-path tests (emitHTML) close the gap review flagged: cache-hit-serves-verbatim, miss-emits-and-stores, and the per-serve generation island were previously unverified. The concurrency stress test guards the Decision 6 lock.

Key decisions

Decision 1: Cache keyed by parseRevision alone (not RenderSettings)

The emitted HTML depends on parseRevision and, in principle, on RenderSettings (showHTMLComments tags comment nodes; catalogStrings() bakes chrome labels). The cache is keyed on parseRevision only: comment visibility is owned by the live setCommentVisibility bridge command (no reload, reasserted on every load via pushInitialState + the coalesced recovery snapshot), and a system-language change relaunches the app (resetting the revision), so baked strings are session-stable. Adding a settings-hash to the key would rebuild on every comment toggle and buy nothing.

Decision 2: Move emit off-main by marking the pure emit tree nonisolated

Under SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor the emit tree is MainActor-isolated by default, which is why the serve provider used MainActor.assumeIsolated. Marking the pure call tree nonisolated and running emit off-main is the minimal way to fix the freeze; all inputs/outputs are already Sendable, and nonisolated is compiler-checked so any accidental MainActor dependency is a build error. Caching alone was rejected: it wouldn't fix the first-open freeze.

Decision 3: Retain a synchronous on-main emit as a cache-miss fallback

The scheme handler's provider is invoked asynchronously by WebKit and could run before the background precompute populated the cache. emitHTML reads the cache and, on a miss, falls back to a synchronous on-main emit (today's behaviour) then stamps the generation island — and stores the result. Correctness must not depend on precompute timing; the common path serves the cache and never blocks main. Blocking the serve until precompute completes, or serving a placeholder, were both rejected as regressions.

Decision 4: Defer a per-block HTMLSanitizer size cap / timeout

Moving sanitisation off-main removes the user-visible freeze, which was the actual harm; the 10MB document cap already bounds aggregate work. A per-block byte cap would change rendering behaviour (escaping large HTML blocks that render today), a behavioural decision that belongs in its own change rather than bundled into a performance move. Tracked separately if a pathological single block is observed.

Decision 6: Serialize all SwiftSoup access behind one lock rather than an actor

SwiftSoup is not thread-safe: it keeps unsynchronized mutable static pools (Parser.htmlTreeBuilderPool/xmlTreeBuilderPool, StringBuilder.pool, QueryParser.cacheInstance, all nonisolated(unsafe) static var), safe today only because every call ran serialized on the MainActor. One shared Mutex wrapping the SwiftSoup body of each entry point restores serialization regardless of thread, with zero changes to the image/search call sites. A dedicated @globalActor was rejected: it forces await into the synchronous emit walk (per-block hops) and turns the synchronous search path async — much larger ripple for the same guarantee.

Decision 7: Cache the emitted HTML string on the session despite the memory cost

Caching retains the full emitted HTML on DocumentSession for the session's lifetime — roughly doubling resident document memory near the 10MB ceiling. Accepted: the document is already capped at 10MB, and the alternative (re-emitting on every re-serve) is exactly the cost this change removes. No eviction beyond revision replacement; evicting aggressively would defeat the recovery / re-navigation re-serve benefit for a marginal saving.

Decision 8: Off-main via Task.detached, and the actual breadth of the nonisolated sweep

Two facts surfaced at implementation time. (1) The project enables NonisolatedNonsendingByDefault, under which a plain nonisolated async function inherits the caller's executor — so a nonisolated async emit helper called from the MainActor would run on the MainActor, defeating the fix. Hence Task.detached(priority: .userInitiated), mirroring the parse offload in DocumentSession.parseAndApplyBlocks. (2) SWIFT_VERSION = 5.0 makes the isolation violations warnings, not errors, but the zero-new-warning bar still requires resolving them, and the nonisolated cascade reaches ~17 files of pure model/service types. It converged with zero new warnings; the one remaining CopyNotesButton.swift:99 warning is pre-existing on main and belongs to T-1741.

Review findings

SeverityAreaFindingResolution
majorWebDocumentControllerFactory.swift precomputeprecomputeDocumentHTML re-emitted the full document even when the cache was already valid for the current revision, so a same-revision re-fire (view remount, iOS folder-access re-nav) burned a full off-main emit.Added <code>guard session.currentCachedDocumentHTML == nil else { return }</code>; safe because comment toggles don't bump parseRevision.
mediumOffMainEmitTests test gapWebDocumentControllerFactory.emitHTML (the serve path) was untested &mdash; cache-hit, cache-miss-emits-and-stores, and per-serve generation island unverified.Added two tests covering all three.
minorWebDocumentControllerFactory RenderSettings duprenderDocumentHTML and precomputeDocumentHTML built RenderSettings identically.Extracted documentRenderSettings(from:).
minorDocumentSession cache stateTwo parallel optionals (html + revision) allowed invalid mixed states.Collapsed to one (revision, html) tuple; clarified doc comment; documented settings-independent key.
minorDocsNo CHANGELOG entry; CLAUDE.md didn't mention off-main emit + cache.Added CHANGELOG [Unreleased] Fixed entry and a CLAUDE.md clause.
minorWebDocumentLoader.injectGenerationForward O(n) scan for an end-of-body marker; .backwards would be cheaper.Pre-existing code outside this change's scope; left as-is.
minorprecompute detached emitTask.detached isn't cancellable, so a superseded reparse still runs a full emit before discard.Bounded and correct (revision + isCancelled guards); reparse is rare; noted, not changed.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 57ca0c1..faa8fdb 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 +- Opening or reloading a large or HTML-heavy document no longer freezes the UI (T-1681). Since the WebKit rendering cutover, the full document HTML — including a SwiftSoup sanitisation pass per raw-HTML block and per inline HTML run — was built synchronously on the main thread on every serve, so a big or markup-dense document blocked the app while it rendered, and re-built the HTML on every reload. The build now runs off the main thread and its result is cached per parse: the UI stays responsive, and reloads (external file change, URL refresh, WebContent-process recovery, the iOS folder-access retry) reuse the cached HTML instead of re-emitting. Very HTML-dense documents can still take a noticeable moment to appear; making that incremental is tracked separately. - Search matches are highlighted in the rendered document again (T-1680). Since the WebKit rendering cutover, searching counted matches and navigated natively but the page never showed a highlight — the native→web search-state feed was never connected. Matches now light up as you type, the current match gets its distinct emphasis and scrolls into view when navigating (including when a result is picked from the iPhone search overlay), footnote badges whose content matches are marked, and dismissing search clears the highlights. - Task list items written `- [ ] 1. Task name` render their number again (T-1640). Since the WebKit rendering cutover, inline text beginning with a list marker (`1.`, `3)`, `-`) was re-parsed as a list and the marker silently dropped — this also affected headings like `## 1. Introduction` and table cells starting with a number. The literal marker the author wrote is now rendered, and text selection and search work on it. - Text beginning with an `@`-prefixed word (for example a list item `- @Observable macro is used`) renders again instead of showing as empty (T-1641). Since the WebKit rendering cutover, the inline re-parse enabled block directives, so `@Observable`, `@MainActor`, `@State` and similar leading words were consumed as a directive and the whole run was dropped. This affected paragraphs, headings, and table cells as well as list items; all now render the text verbatim with working selection and search.
CLAUDE.md Modified +1 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex 1344c6c..47d0463 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -48,7 +48,7 @@ xcodebuild test -project prism.xcodeproj -scheme prism \ The document is rendered by WebKit-for-SwiftUI (`WebView`/`WebPage`). The SwiftUI/Textual in-flow renderer was retired in the T-1542 cutover — there is no legacy path or render-policy switch any more (see the CHANGELOG `[Unreleased]` "Changed" entry). Native stays the source of truth: parsing, search counting, notes logic, and persistence all run natively; the web view only renders HTML and reports interactions back over the bridge.  1. **Parse**: `swift-markdown` → AST → `MarkdownBlock` enum variants (`MarkdownBlockParser`), unchanged from before. T-1558 made the model lossless for nested blockquotes, ordered-list `start`, and rich blocks inside list items (see `specs/web-markdown-fidelity/`).-2. **Emit**: `BlockHTMLEmitter` (`prism/Services/WebRendering/`) is a pure, deterministic function of `[MarkdownBlock]` + `FootnoteData` + `RenderSettings`. It emits one `<section>` per block carrying the content-hash block identity and an occurrence-qualified DOM id (`b-{hash}-{sourceIndex}`, allocated via the shared `BlockDOMID`), escapes by default, and is total (a block that fails to emit falls back to escaped-source `<pre>`, never dropped). `InlineHTMLRenderer` wraps mappable text runs in `<span data-prism-run>` and records a `DocumentSourceMap` (UTF-16 offsets, shipped as an inert `<div hidden>` data island) for selection-anchored notes.+2. **Emit**: `BlockHTMLEmitter` (`prism/Services/WebRendering/`) is a pure, deterministic function of `[MarkdownBlock]` + `FootnoteData` + `RenderSettings`. It emits one `<section>` per block carrying the content-hash block identity and an occurrence-qualified DOM id (`b-{hash}-{sourceIndex}`, allocated via the shared `BlockDOMID`), escapes by default, and is total (a block that fails to emit falls back to escaped-source `<pre>`, never dropped). `InlineHTMLRenderer` wraps mappable text runs in `<span data-prism-run>` and records a `DocumentSourceMap` (UTF-16 offsets, shipped as an inert `<div hidden>` data island) for selection-anchored notes. `emit` (and the model/service value types it reads) is `nonisolated`, so it runs off the MainActor: `WebDocumentControllerFactory.precomputeDocumentHTML` emits once per `parseRevision` on a `Task.detached` and caches the HTML on `DocumentSession`; the scheme handler serves that cache (synchronous on-main emit only on a miss). Its per-block/inline `HTMLSanitizer` (SwiftSoup) passes are serialized behind a shared `Mutex` because SwiftSoup keeps unsynchronized static pools (T-1681, `specs/offmain-html-emit/`). 3. **Serve**: `PrismDocSchemeHandler` (`prism-doc://` `URLSchemeHandler`) is the single audited I/O path — it serves the document HTML, `document.css` (the only asset fetched through the scheme), and mediates every image subresource through `/img/?src=` (rewritten absolute/relative URLs routed via `ImagePathResolver`/`ImageLoader`/`SVGSourceLoader`). It serves the verbatim CSP (`script-src 'none'`, `connect-src 'none'`, …) as a response header. The document is loaded via the scheme, never `loadHTMLString`. 4. **Host + bridge**: `WebDocumentController`/`WebDocumentView` (`prism/ViewModels/`, `prism/Views/`) own one `WebPage` per session (non-persistent store, `allowsContentJavaScript = false`, all JS injected as user scripts via `WebDocumentControllerFactory.userScripts()`). The native↔JS bridge runs in a dedicated isolated `WKContentWorld`; every message in both directions carries a generation tag (sessionID, parseRevision, processGeneration). `BridgeMessageRouter` validates inbound messages against an enumerated allowlist + exact-generation match and drops forged/stale/malformed ones; `WebDocumentMessageRouter` routes accepted messages onto the existing native session/coordinator state. Outbound commands queue until `ready`; scroll restore waits for `layoutSettled`; on WebContent termination the controller bumps the process generation, reloads, and replays one coalesced state snapshot. 5. **Notes**: `NoteStateFeeder` (`prism/Services/WebRendering/`) maps `NotesManager` state onto `setNoteIndicators`/`setInlineNotes` payloads; `NoteHTMLBuilder` renders the (escaped) bubble/banner HTML natively; `prism-notes.js` (isolated world) injects it as `data-prism-chrome` and posts interaction messages back.
Tools/gen-large-html-heavy-sample.py Added +154 / -0
diff --git a/Tools/gen-large-html-heavy-sample.py b/Tools/gen-large-html-heavy-sample.pynew file mode 100644index 0000000..6d83633--- /dev/null+++ b/Tools/gen-large-html-heavy-sample.py@@ -0,0 +1,154 @@+#!/usr/bin/env python3+"""Generate samples/large-html-heavy.md — the T-1681 emit benchmark fixture.++The full-document emit freeze (T-1681) is driven by the NUMBER of SwiftSoup+parses, not raw byte count: HTMLSanitizer.sanitize runs once per raw-HTML block+and once per inline HTML run. So each generated section packs several distinct+raw-HTML blocks (a stat-card div+table, a figure, a details) and a paragraph+dense in inline HTML runs (kbd/sup/sub/mark/span/abbr), plus some plain markdown+for realism. The result is ~2,800+ independent SwiftSoup parses in a single emit.++Run from the repository root:++    python3 Tools/gen-large-html-heavy-sample.py++The output is a committed fixture. Auto-covered by+prismTests SamplesComplianceTests.everySampleEmits (parse + emit non-empty).+Do not hand-edit samples/large-html-heavy.md — regenerate it here instead.+"""++N = 120  # sections; ~250KB, ~1,560 SwiftSoup parses (heavy enough to freeze on-device,+         # light enough that the SamplesComplianceTests sweep stays reasonable in test-quick)++HEADER = """<!--+Perf benchmark fixture for T-1681 (off-main HTML emission + per-revision cache).++Purpose: exercise the BlockHTMLEmitter + HTMLSanitizer (SwiftSoup) path hard, so+the main-thread emit cost is measurable before the change and gone after it.+Every raw-HTML block below is one SwiftSoup parse; every inline <span>/<sup>/<kbd>/+<mark>/<abbr> run is another. Open this document and compare first-paint+responsiveness (and any UI freeze) before vs after the off-main-emit change.++Auto-covered by prismTests SamplesComplianceTests.everySampleEmits (parse + emit).+Generated by Tools/gen-large-html-heavy-sample.py. Do not hand-edit.+-->++# Large HTML-Heavy Document (T-1681 emit benchmark)++This document is intentionally dense in raw HTML blocks and inline HTML so the+full-document emit + sanitisation pass is expensive. It is used to measure the+UI freeze on open before the off-main-emit change, and to confirm it is gone after.++There are **{n} sections**, each carrying several raw-HTML blocks and a paragraph+rich in inline HTML — together on the order of two thousand independent SwiftSoup+parses in a single emit.++"""++# One "card" raw-HTML block: a div wrapping a small table + figure. One sanitize call.+CARD = """<div class="stat-card" data-index="{i}">+  <table>+    <thead>+      <tr><th scope="col">Metric</th><th scope="col">Value</th><th scope="col">Delta</th></tr>+    </thead>+    <tbody>+      <tr><td>Throughput</td><td>{a} ops/s</td><td>+{b}%</td></tr>+      <tr><td>Latency p99</td><td>{c} ms</td><td>&minus;{d}%</td></tr>+      <tr><td>Cache hit</td><td>{e}%</td><td>+{f}%</td></tr>+    </tbody>+  </table>+  <p>Row {i} summary with <strong>bold</strong>, <em>italic</em>, and a+     <a href="https://example.com/row/{i}" title="Row {i}">link</a>.</p>+</div>"""++# A figure raw-HTML block. One sanitize call.+FIGURE = """<figure>+  <img src="https://placehold.co/320x180.png" alt="Chart for section {i}" width="320" height="180">+  <figcaption>Figure {i}: rendered throughput for section {i} &mdash; sample data.</figcaption>+</figure>"""++# A details raw-HTML block. One sanitize call. Includes content the sanitizer must+# STRIP (onclick handler, script) so cleaning does real work + guards the security path.+DETAILS = """<details{open}>+  <summary>Section {i} details</summary>+  <div onclick="alert('{i}')">+    <p>Expanded notes for section {i}. The <code>onclick</code> handler and the+       following script are stripped by the sanitizer.</p>+    <script>console.log("should be removed {i}")</script>+    <ul>+      <li>Point one for {i}</li>+      <li>Point <mark>two</mark> for {i}</li>+    </ul>+  </div>+</details>"""++# A paragraph dense in inline HTML runs — each run is a separate SwiftSoup parse.+INLINE = ("Press <kbd>Cmd</kbd>+<kbd>{k}</kbd> to jump to section {i}. "+          "The formula x<sup>{p}</sup> + y<sup>{p}</sup> holds for H<sub>{sub}</sub>O, "+          "and <mark>row {i}</mark> is <span data-role=\"note\">highlighted</span>. "+          "See the <abbr title=\"Application Programming Interface\">API</abbr> notes, "+          "and this <span onmouseover=\"steal()\">span's handler</span> is stripped.")++# Some plain markdown for realism (exercises non-HTML emit paths too).+MARKDOWN = """Regular markdown paragraph for section {i} with `inline code`, a+[reference link][ref{i}], and an emphasis *run* plus **strong** text.++| Column A | Column B | Column C |+|:---------|:--------:|---------:|+| a{i} | b{i} | c{i} |+| d{i} | e{i} | f{i} |++1. First ordered item in section {i}+2. Second ordered item with `code`+3. Third item++> Blockquote for section {i} with a nested note.+>+> Second paragraph of the quote.++```swift+func section{i}() -> Int {{ return {i} * 2 }}+```++[ref{i}]: https://example.com/ref/{i}+"""+++def section(i: int) -> str:+    a = 1000 + i * 7+    b = (i * 3) % 40 + 1+    c = 5 + (i % 17)+    d = (i * 2) % 30 + 1+    e = 70 + (i % 30)+    f = (i % 25) + 1+    k = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"[i % 26]+    p = (i % 5) + 2+    sub = (i % 3) + 1+    open_attr = " open" if i % 4 == 0 else ""+    parts = [+        f"## Section {i}: metrics, figure, and notes\n",+        CARD.format(i=i, a=a, b=b, c=c, d=d, e=e, f=f),+        "",+        INLINE.format(i=i, k=k, p=p, sub=sub),+        "",+        FIGURE.format(i=i),+        "",+        DETAILS.format(i=i, open=open_attr),+        "",+        MARKDOWN.format(i=i),+    ]+    return "\n".join(parts)+++def main():+    out = [HEADER.format(n=N)]+    for i in range(1, N + 1):+        out.append(section(i))+    text = "\n".join(out).rstrip() + "\n"+    with open("samples/large-html-heavy.md", "w", encoding="utf-8") as fh:+        fh.write(text)+    print(f"wrote samples/large-html-heavy.md: {len(text)} bytes, {N} sections")+++if __name__ == "__main__":+    main()
prism/Models/DocumentSession.swift Modified +33 / -0
diff --git a/prism/Models/DocumentSession.swift b/prism/Models/DocumentSession.swiftindex 0148c49..c236eee 100644--- a/prism/Models/DocumentSession.swift+++ b/prism/Models/DocumentSession.swift@@ -145,6 +145,39 @@ final class DocumentSession: Identifiable {     /// "value changed" signal for `.task(id:)`.     private(set) var parseRevision: UInt64 = 0 +    // MARK: - Emitted HTML Cache (T-1681)++    /// The document HTML (excluding the per-serve generation island) and the `parseRevision`+    /// it was emitted for. The emit runs off the MainActor+    /// (`WebDocumentControllerFactory.precomputeDocumentHTML`); this property is *written* on+    /// the MainActor, so the scheme handler serves cached HTML instead of running the full+    /// emit + sanitise on the MainActor on every serve — the initial load, a WebContent-+    /// recovery reload, and the iOS folder-access re-navigation all reuse it. One optional+    /// tuple (not two parallel optionals) so revision and html can never drift apart.+    /// `@ObservationIgnored`: written during a serve and read by the scheme handler, never in+    /// a view body, so it must not drive SwiftUI updates.+    @ObservationIgnored private var cachedDocument: (revision: UInt64, html: String)?++    /// Stores emitted HTML for `revision`, dropping a stale write whose revision no longer+    /// matches the live `parseRevision` (a precompute that finished after a newer parse+    /// started). Keeps the cache from ever holding HTML for a superseded revision.+    func storeCachedDocumentHTML(_ html: String, for revision: UInt64) {+        guard revision == parseRevision else { return }+        cachedDocument = (revision: revision, html: html)+    }++    /// The cached HTML iff it was emitted for the current `parseRevision`; nil once a reparse+    /// has advanced the revision (so the cached HTML is stale and must be re-emitted).+    ///+    /// The cache is keyed on `parseRevision` alone, deliberately independent of+    /// `RenderSettings`: `catalogStrings()` is launch-static and `showHTMLComments` only sets+    /// the inert `prism-comment-hidden` marker (live visibility is driven by+    /// `setCommentVisibility`). A future setting that changes emitted *structure* would need+    /// folding into the key (decision_log Decision 1).+    var currentCachedDocumentHTML: String? {+        cachedDocument.flatMap { $0.revision == parseRevision ? $0.html : nil }+    }+     // MARK: - Table Display Mode State      /// Tracks table display modes by composite block ID.
prism/Models/DocumentSourceMap.swift Modified +1 / -1
diff --git a/prism/Models/DocumentSourceMap.swift b/prism/Models/DocumentSourceMap.swiftindex 4e81a1d..bd7df96 100644--- a/prism/Models/DocumentSourceMap.swift+++ b/prism/Models/DocumentSourceMap.swift@@ -26,7 +26,7 @@ import Foundation  /// A block-ordered, anchor-based map from rendered text runs to source offsets.-struct DocumentSourceMap: Codable, Equatable, Sendable {+nonisolated struct DocumentSourceMap: Codable, Equatable, Sendable {      /// One mappable text run: the source span covered by a single     /// `data-prism-run` wrapper span in the emitted HTML.
prism/Models/FootnoteData.swift Modified +1 / -1
diff --git a/prism/Models/FootnoteData.swift b/prism/Models/FootnoteData.swiftindex 5b7e752..e084203 100644--- a/prism/Models/FootnoteData.swift+++ b/prism/Models/FootnoteData.swift@@ -53,7 +53,7 @@ extension EnvironmentValues { // MARK: - ParseResult  /// The result of parsing markdown content, bundling blocks with footnote data.-struct ParseResult: Sendable {+nonisolated struct ParseResult: Sendable {     let blocks: [MarkdownBlock]     let footnoteData: FootnoteData }
prism/Models/MarkdownBlock.swift Modified +7 / -7
diff --git a/prism/Models/MarkdownBlock.swift b/prism/Models/MarkdownBlock.swiftindex 060718d..636d190 100644--- a/prism/Models/MarkdownBlock.swift+++ b/prism/Models/MarkdownBlock.swift@@ -32,7 +32,7 @@ import Synchronization /// through ``BlockIDCache``'s `Mutex`, so a future non-`Sendable` stored /// property must be a compile-time error at this declaration, not a silent /// thread-safety hole at the `Mutex` use site.-struct BlockIDStore: Sendable {+nonisolated struct BlockIDStore: Sendable {     /// Maximum entries in the primary generation before a rotation.     let maxEntries: Int @@ -108,7 +108,7 @@ struct BlockIDStore: Sendable { /// Thread-safe, process-global cache for memoizing MarkdownBlock ID /// computations. Delegates storage and eviction to a shared ``BlockIDStore`` /// guarded by a `Mutex`.-private enum BlockIDCache {+nonisolated private enum BlockIDCache {     /// Maximum entries per generation. Memory is bounded at ~2× this value.     /// Set high enough to handle large documents without eviction during     /// normal use.@@ -178,7 +178,7 @@ extension ListItemChild: Hashable { extension ListItemChild: Sendable {}  /// Represents a single item in a markdown list, supporting nested lists and blocks.-struct ListItem: Equatable, Sendable {+nonisolated struct ListItem: Equatable, Sendable {     /// The text content of the list item (without checkbox prefix).     let content: String @@ -298,7 +298,7 @@ enum ImageDimension: Equatable, Sendable, Hashable { /// /// Maps to `swift-markdown`'s `Table.ColumnAlignment` values. When no alignment /// is specified in the markdown source, `.leading` is used as the GFM default.-enum ColumnAlignment: Equatable, Sendable, Hashable {+nonisolated enum ColumnAlignment: Equatable, Sendable, Hashable {     /// Left-aligned (`:---` or no specifier).     case leading     /// Center-aligned (`:---:`).@@ -323,7 +323,7 @@ enum ColumnAlignment: Equatable, Sendable, Hashable { /// /// Controls how column widths are calculated in `AccessibleTableView`. /// Cycles in order: fitted → readable → wide → fitted.-enum TableDisplayMode: Sendable, Hashable, CaseIterable {+nonisolated enum TableDisplayMode: Sendable, Hashable, CaseIterable {     /// Columns share available width equally. No horizontal scrolling.     case fitted     /// Columns sized to natural content width, capped at `readableMaxWidth` points.@@ -429,7 +429,7 @@ enum TableDisplayMode: Sendable, Hashable, CaseIterable { /// Requirements covered: /// - 4.1: Detects mermaid code blocks via the `.mermaid` case /// - 9.4: Enables lazy rendering by breaking documents into discrete blocks-enum MarkdownBlock: Identifiable, Equatable, Sendable {+nonisolated enum MarkdownBlock: Identifiable, Equatable, Sendable {     /// A heading with level (1-6) and plain text content.     case heading(level: Int, text: String) @@ -1113,7 +1113,7 @@ extension MarkdownBlock { private extension NSRegularExpression {     /// Creates a regex from a compile-time constant pattern.     /// The pattern must be a valid regex literal; failure is a programming error.-    convenience init(staticPattern pattern: String) {+    nonisolated convenience init(staticPattern pattern: String) {         // swiftlint:disable:next force_try         try! self.init(pattern: pattern)     }
prism/Services/CommentBlockDetector.swift Modified +1 / -1
diff --git a/prism/Services/CommentBlockDetector.swift b/prism/Services/CommentBlockDetector.swiftindex 4566d13..1412239 100644--- a/prism/Services/CommentBlockDetector.swift+++ b/prism/Services/CommentBlockDetector.swift@@ -8,7 +8,7 @@  import Foundation -enum CommentBlockDetector {+nonisolated enum CommentBlockDetector {     struct DetectionResult: Sendable {         let author: String         let content: String
prism/Services/MermaidTypeParser.swift Modified +1 / -1
diff --git a/prism/Services/MermaidTypeParser.swift b/prism/Services/MermaidTypeParser.swiftindex 3052ca8..3970338 100644--- a/prism/Services/MermaidTypeParser.swift+++ b/prism/Services/MermaidTypeParser.swift@@ -15,7 +15,7 @@ import Foundation /// Requirements covered: /// - 4.3: Parse diagram type from first line (flowchart, sequenceDiagram, classDiagram, ///        stateDiagram, erDiagram, gantt, pie, journey, or "Diagram" for unrecognized types)-enum MermaidTypeParser {+nonisolated enum MermaidTypeParser {     /// Known diagram type keywords and their human-readable names.     private static let diagramTypes: [(keyword: String, name: String)] = [         // Common diagram types
prism/Services/TableRowContextQuote.swift Modified +1 / -1
diff --git a/prism/Services/TableRowContextQuote.swift b/prism/Services/TableRowContextQuote.swiftindex 6f8f575..13d08fe 100644--- a/prism/Services/TableRowContextQuote.swift+++ b/prism/Services/TableRowContextQuote.swift@@ -19,7 +19,7 @@ import Foundation /// - Header rows: `"{h1} | {h2} | ..."` /// /// Requirements: 1.5–1.10-enum TableRowContextQuote {+nonisolated enum TableRowContextQuote {      // MARK: - Soft / hard limits 
prism/Services/WebRendering/BlockDOMID.swift Modified +1 / -1
diff --git a/prism/Services/WebRendering/BlockDOMID.swift b/prism/Services/WebRendering/BlockDOMID.swiftindex cff446c..77f545e 100644--- a/prism/Services/WebRendering/BlockDOMID.swift+++ b/prism/Services/WebRendering/BlockDOMID.swift@@ -16,7 +16,7 @@  import Foundation -enum BlockDOMID {+nonisolated enum BlockDOMID {      /// The DOM id for the block at `occurrence`-th position among blocks sharing     /// `contentHash`. Matches `WebDocumentMessageRouter`'s parse (`b-{hash}-{index}`,
prism/Services/WebRendering/BlockHTMLEmitter.swift Modified +2 / -2
diff --git a/prism/Services/WebRendering/BlockHTMLEmitter.swift b/prism/Services/WebRendering/BlockHTMLEmitter.swiftindex 61fa01b..6688c75 100644--- a/prism/Services/WebRendering/BlockHTMLEmitter.swift+++ b/prism/Services/WebRendering/BlockHTMLEmitter.swift@@ -27,7 +27,7 @@ import Foundation  /// The emitted document and its source map.-struct EmittedDocument: Equatable, Sendable {+nonisolated struct EmittedDocument: Equatable, Sendable {     /// The full document HTML, including the CSP meta fallback and the source-map     /// data island.     let html: String@@ -35,7 +35,7 @@ struct EmittedDocument: Equatable, Sendable {     let sourceMap: DocumentSourceMap } -enum BlockHTMLEmitter {+nonisolated enum BlockHTMLEmitter {      /// Mutable per-emission state, bundled into one reference type so the per-block     /// helpers take few parameters (and so run IDs/runs accumulate across the walk
prism/Services/WebRendering/HTMLEscaping.swift Modified +1 / -1
diff --git a/prism/Services/WebRendering/HTMLEscaping.swift b/prism/Services/WebRendering/HTMLEscaping.swiftindex 78d26e0..ca5ee06 100644--- a/prism/Services/WebRendering/HTMLEscaping.swift+++ b/prism/Services/WebRendering/HTMLEscaping.swift@@ -10,7 +10,7 @@  import Foundation -enum HTMLEscaping {+nonisolated enum HTMLEscaping {      /// Escapes text for an HTML text-node context (`&`, `<`, `>`).     static func escapeText(_ text: String) -> String {
prism/Services/WebRendering/HTMLImageSourceRewriter.swift Modified +1 / -1
diff --git a/prism/Services/WebRendering/HTMLImageSourceRewriter.swift b/prism/Services/WebRendering/HTMLImageSourceRewriter.swiftindex e811af7..4f56939 100644--- a/prism/Services/WebRendering/HTMLImageSourceRewriter.swift+++ b/prism/Services/WebRendering/HTMLImageSourceRewriter.swift@@ -23,7 +23,7 @@  import Foundation -enum HTMLImageSourceRewriter {+nonisolated enum HTMLImageSourceRewriter {      /// Matches a whole `<img>` or `<source>` start tag.     private static let tagRegex: NSRegularExpression? = {
prism/Services/WebRendering/HTMLSanitizer.swift Modified +65 / -45
diff --git a/prism/Services/WebRendering/HTMLSanitizer.swift b/prism/Services/WebRendering/HTMLSanitizer.swiftindex e77bde5..4747b21 100644--- a/prism/Services/WebRendering/HTMLSanitizer.swift+++ b/prism/Services/WebRendering/HTMLSanitizer.swift@@ -18,11 +18,25 @@ import Foundation import OSLog import SwiftSoup+import Synchronization -enum HTMLSanitizer {+nonisolated enum HTMLSanitizer {      private static let logger = Logger.prism(category: "HTMLSanitizer") +    /// Serializes every SwiftSoup call so no two parses run concurrently (T-1681).+    ///+    /// SwiftSoup is not thread-safe: it reuses unsynchronized mutable static pools+    /// (`Parser.htmlTreeBuilderPool`/`xmlTreeBuilderPool`, `StringBuilder.pool`,+    /// `QueryParser.cacheInstance`, all `nonisolated(unsafe) static var`), borrowed and+    /// returned per parse. This was safe only while every caller ran serialized on the+    /// MainActor. Now that `BlockHTMLEmitter.emit` sanitises off the MainActor, a document+    /// sanitise can run concurrently with the still-on-main image `sanitizeSVG` / search+    /// `plainText`, or with another emit — a data race on those pools. A single lock around+    /// each entry point below restores serialization regardless of the calling thread.+    /// Locking is per-call, so critical sections stay short and callers interleave.+    private static let swiftSoupLock = Mutex(())+     /// Schemes permitted on link/image URL attributes. Everything else     /// (`javascript:`, `vbscript:`, `data:` for non-images, `file:`) is stripped     /// because it is not on this list. `prism-doc` is allowed because the emitter@@ -38,16 +52,18 @@ enum HTMLSanitizer {     /// fallback from the design's error-handling table) so no raw markup leaks.     static func sanitize(_ rawHTML: String) -> String {         guard !rawHTML.isEmpty else { return "" }-        do {-            // prettyPrint is disabled so the output is a stable fixed point:-            // pretty-printing inserts whitespace between block elements, which the-            // next parse turns into text nodes that re-format differently, breaking-            // `sanitize(sanitize(x)) == sanitize(x)` (Decision 9 / design idempotence).-            let cleaned = try SwiftSoup.clean(rawHTML, "", allowlist(), compactOutput)-            return cleaned ?? ""-        } catch {-            logger.error("Sanitize failed; escaping fragment (category: parse)")-            return escape(rawHTML)+        return swiftSoupLock.withLock { _ in+            do {+                // prettyPrint is disabled so the output is a stable fixed point:+                // pretty-printing inserts whitespace between block elements, which the+                // next parse turns into text nodes that re-format differently, breaking+                // `sanitize(sanitize(x)) == sanitize(x)` (Decision 9 / design idempotence).+                let cleaned = try SwiftSoup.clean(rawHTML, "", allowlist(), compactOutput)+                return cleaned ?? ""+            } catch {+                logger.error("Sanitize failed; escaping fragment (category: parse)")+                return escape(rawHTML)+            }         }     } @@ -63,15 +79,17 @@ enum HTMLSanitizer {     /// display form.     static func plainText(_ rawHTML: String) -> String {         guard !rawHTML.isEmpty else { return "" }-        do {-            // Clean first so script/style payloads never contribute to searchable-            // text, then take the visible text of the cleaned fragment.-            let cleaned = try SwiftSoup.clean(rawHTML, "", allowlist(), compactOutput) ?? ""-            let document = try SwiftSoup.parseBodyFragment(cleaned)-            return try document.body()?.text() ?? ""-        } catch {-            logger.error("plainText failed; returning empty (category: parse)")-            return ""+        return swiftSoupLock.withLock { _ in+            do {+                // Clean first so script/style payloads never contribute to searchable+                // text, then take the visible text of the cleaned fragment.+                let cleaned = try SwiftSoup.clean(rawHTML, "", allowlist(), compactOutput) ?? ""+                let document = try SwiftSoup.parseBodyFragment(cleaned)+                return try document.body()?.text() ?? ""+            } catch {+                logger.error("plainText failed; returning empty (category: parse)")+                return ""+            }         }     } @@ -85,34 +103,36 @@ enum HTMLSanitizer {     /// risk passing through unsanitized markup.     static func sanitizeSVG(_ svg: String) -> String {         guard !svg.isEmpty else { return "" }-        do {-            let document = try SwiftSoup.parse(svg, "", Parser.xmlParser())-            document.outputSettings().prettyPrint(pretty: false)-            // Remove dangerous elements wholesale (case-insensitive tag match).-            for element in try document.getAllElements().array() {-                let tag = element.tagName().lowercased()-                let localName = tag.split(separator: ":").last.map(String.init) ?? tag-                if localName == "script" || localName == "foreignobject" {-                    try element.remove()-                    continue-                }-                // Strip event-handler attributes and javascript/data-html URLs.-                for attribute in element.getAttributes()?.asList() ?? [] {-                    let key = attribute.getKey().lowercased()-                    let value = attribute.getValue()-                    let isHandler = key.hasPrefix("on")-                    let isURLAttr = key == "href" || key.hasSuffix(":href")-                    if isHandler {-                        try element.removeAttr(attribute.getKey())-                    } else if isURLAttr, isDangerousURL(value) {-                        try element.removeAttr(attribute.getKey())+        return swiftSoupLock.withLock { _ in+            do {+                let document = try SwiftSoup.parse(svg, "", Parser.xmlParser())+                document.outputSettings().prettyPrint(pretty: false)+                // Remove dangerous elements wholesale (case-insensitive tag match).+                for element in try document.getAllElements().array() {+                    let tag = element.tagName().lowercased()+                    let localName = tag.split(separator: ":").last.map(String.init) ?? tag+                    if localName == "script" || localName == "foreignobject" {+                        try element.remove()+                        continue+                    }+                    // Strip event-handler attributes and javascript/data-html URLs.+                    for attribute in element.getAttributes()?.asList() ?? [] {+                        let key = attribute.getKey().lowercased()+                        let value = attribute.getValue()+                        let isHandler = key.hasPrefix("on")+                        let isURLAttr = key == "href" || key.hasSuffix(":href")+                        if isHandler {+                            try element.removeAttr(attribute.getKey())+                        } else if isURLAttr, isDangerousURL(value) {+                            try element.removeAttr(attribute.getKey())+                        }                     }                 }+                return try document.html()+            } catch {+                logger.error("SVG sanitize failed; dropping content (category: svg)")+                return "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"             }-            return try document.html()-        } catch {-            logger.error("SVG sanitize failed; dropping content (category: svg)")-            return "<svg xmlns=\"http://www.w3.org/2000/svg\"></svg>"         }     } 
prism/Services/WebRendering/InlineHTMLRenderer.swift Modified +1 / -1
diff --git a/prism/Services/WebRendering/InlineHTMLRenderer.swift b/prism/Services/WebRendering/InlineHTMLRenderer.swiftindex 6619a65..8c16327 100644--- a/prism/Services/WebRendering/InlineHTMLRenderer.swift+++ b/prism/Services/WebRendering/InlineHTMLRenderer.swift@@ -31,7 +31,7 @@ import Foundation import Markdown  /// Renders inline markdown to HTML + run records.-struct InlineHTMLRenderer {+nonisolated struct InlineHTMLRenderer {      /// The HTML output plus the runs collected during rendering.     struct Result {
prism/Services/WebRendering/PrismDocSchemeHandler.swift Modified +1 / -1
diff --git a/prism/Services/WebRendering/PrismDocSchemeHandler.swift b/prism/Services/WebRendering/PrismDocSchemeHandler.swiftindex 0efdaa2..0b1d89b 100644--- a/prism/Services/WebRendering/PrismDocSchemeHandler.swift+++ b/prism/Services/WebRendering/PrismDocSchemeHandler.swift@@ -62,7 +62,7 @@ struct PrismDocSchemeHandler: URLSchemeHandler {     /// The verbatim Content-Security-Policy served as a response header (design.md).     /// `style-src 'unsafe-inline'` is required by mermaid's SVG styling and is     /// acceptable alongside `script-src 'none'`.-    static let contentSecurityPolicy =+    nonisolated static let contentSecurityPolicy =         "default-src 'none'; img-src prism-doc: data:; style-src 'unsafe-inline' prism-doc:; "         + "font-src prism-doc:; script-src 'none'; connect-src 'none'; base-uri 'none'; "         + "form-action 'none'; frame-src 'none'"
prism/Services/WebRendering/PrismLinkRoute.swift Modified +1 / -1
diff --git a/prism/Services/WebRendering/PrismLinkRoute.swift b/prism/Services/WebRendering/PrismLinkRoute.swiftindex 0fca0ee..7c6a4ab 100644--- a/prism/Services/WebRendering/PrismLinkRoute.swift+++ b/prism/Services/WebRendering/PrismLinkRoute.swift@@ -16,7 +16,7 @@ import Foundation  /// App-owned `prism://` link routes shared by the HTML emitters and the message router.-enum PrismLinkRoute {+nonisolated enum PrismLinkRoute {     /// The document-notes banner's "add a document note" affordance.     static let documentNoteAdd = "prism://document-note/add"     /// The failed-image placeholder's "grant folder access" affordance (iOS).
prism/Services/WebRendering/RenderSettings.swift Modified +1 / -1
diff --git a/prism/Services/WebRendering/RenderSettings.swift b/prism/Services/WebRendering/RenderSettings.swiftindex 2b75d04..9e2a6a0 100644--- a/prism/Services/WebRendering/RenderSettings.swift+++ b/prism/Services/WebRendering/RenderSettings.swift@@ -19,7 +19,7 @@ import Foundation /// /// A plain value type so the emitter stays deterministic and total: identical /// `settings` + blocks produce identical HTML (golden-fixture testable).-struct RenderSettings: Equatable, Sendable {+nonisolated struct RenderSettings: Equatable, Sendable {      /// Whether HTML comment blocks render visibly. When false the emitter still     /// emits the comment content (so toggling needs no reload, Req 1.7) but marks
prism/Utilities/Logger+Prism.swift Modified +1 / -1
diff --git a/prism/Utilities/Logger+Prism.swift b/prism/Utilities/Logger+Prism.swiftindex eb64a6a..4cf0a87 100644--- a/prism/Utilities/Logger+Prism.swift+++ b/prism/Utilities/Logger+Prism.swift@@ -3,7 +3,7 @@ import OSLog extension Logger {     /// Creates a Logger with the app's bundle identifier as the subsystem.     /// - Parameter category: The category for filtering in Console.app (e.g., "NotesStore", "App").-    static func prism(category: String) -> Logger {+    nonisolated static func prism(category: String) -> Logger {         Logger(subsystem: "me.nore.ig.prism", category: category)     } }
prism/ViewModels/WebDocumentControllerFactory.swift Modified +70 / -14
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex 01d55ac..7ffb232 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -155,25 +155,81 @@ enum WebDocumentControllerFactory {         settings: AppSettings,         processGeneration: UInt64 = 0     ) -> String {-        // The selection "Add note" affordance is a native SwiftUI overlay on both-        // platforms (T-1542): the emitter no longer ships an in-page pill — the WebKit-        // native selection callout would cover it. Selection state is reported via the-        // selectionCandidate bridge message and native draws the affordance.-        let renderSettings = RenderSettings(-            showHTMLComments: settings.showHTMLComments,-            strings: catalogStrings()-        )-        let emitted = BlockHTMLEmitter.emit(-            blocks: session.parsedBlocks,-            footnotes: session.footnoteData,-            settings: renderSettings-        )+        let html: String+        if let cached = session.currentCachedDocumentHTML {+            // Fast path: serve the HTML the off-main precompute already built for this+            // parseRevision, without re-running the full emit + sanitise on the MainActor.+            html = cached+        } else {+            // Cache miss (a serve raced ahead of the off-main precompute). Emit synchronously+            // on the MainActor — the pre-T-1681 behaviour — and store the result so the next+            // same-revision serve (e.g. a WebContent-recovery replay) hits the cache.+            html = renderDocumentHTML(for: session, settings: settings)+            session.storeCachedDocumentHTML(html, for: session.parseRevision)+        }+        // The generation island is stamped per serve: `processGeneration` is the controller's+        // live value (bumped on a WebContent-crash reload), so it is NOT baked into the cached+        // HTML and must be injected on every request.         let generation = BridgeGeneration(             sessionID: session.id.uuidString,             parseRevision: session.parseRevision,             processGeneration: processGeneration         )-        return WebDocumentLoader.injectGeneration(generation, into: emitted.html)+        return WebDocumentLoader.injectGeneration(generation, into: html)+    }++    /// Builds the document HTML (without the generation island) for `session`. Shared by the+    /// off-main precompute and the synchronous cache-miss fallback so both produce identical+    /// output; a pure function of the session's blocks + footnotes + `RenderSettings`.+    ///+    /// The selection "Add note" affordance is a native SwiftUI overlay on both platforms+    /// (T-1542): the emitter ships no in-page pill — the WebKit native selection callout would+    /// cover it. Selection state is reported via the selectionCandidate bridge message.+    private static func renderDocumentHTML(+        for session: DocumentSession,+        settings: AppSettings+    ) -> String {+        BlockHTMLEmitter.emit(+            blocks: session.parsedBlocks,+            footnotes: session.footnoteData,+            settings: documentRenderSettings(from: settings)+        ).html+    }++    /// The `RenderSettings` for the document surface: the comment-visibility flag plus+    /// catalog-resolved chrome strings. Shared by the off-main precompute and the synchronous+    /// cache-miss fallback so both emit byte-identical HTML. (The footnote popover builds its+    /// own settings with a `theme:`, not this.)+    private static func documentRenderSettings(from settings: AppSettings) -> RenderSettings {+        RenderSettings(showHTMLComments: settings.showHTMLComments, strings: catalogStrings())+    }++    /// Builds the document HTML off the MainActor and caches it on `session`, keyed by the+    /// session's current `parseRevision` (T-1681). Called by the document surface before it+    /// navigates so the scheme handler serves cached HTML instead of running the full emit ++    /// sanitise on the MainActor. Blocks/footnotes/settings are snapshotted synchronously (no+    /// `await` between the reads) so the emit sees a coherent revision; the store is dropped if+    /// the task was cancelled or a newer parse advanced the revision.+    static func precomputeDocumentHTML(for session: DocumentSession, settings: AppSettings) async {+        // Skip the full off-main emit when the cache is already valid for the current revision+        // — a same-revision re-fire (view remount, the iOS folder-access re-navigation).+        // Comment-visibility toggles push live and do not bump parseRevision, so a+        // current-revision cache is always correct output.+        guard session.currentCachedDocumentHTML == nil else { return }+        let revision = session.parseRevision+        let blocks = session.parsedBlocks+        let footnotes = session.footnoteData+        let renderSettings = documentRenderSettings(from: settings)+        // Task.detached (not a plain `nonisolated async` call): under the project's+        // NonisolatedNonsendingByDefault mode a nonisolated async function inherits the+        // MainActor caller's executor and would emit ON the main thread. Detaching forces the+        // full emit + per-block SwiftSoup sanitise onto the cooperative pool — mirroring the+        // parse offload in DocumentSession.parseAndApplyBlocks. Inputs are all Sendable.+        let html = await Task.detached(priority: .userInitiated) {+            BlockHTMLEmitter.emit(blocks: blocks, footnotes: footnotes, settings: renderSettings).html+        }.value+        guard !Task.isCancelled else { return }+        session.storeCachedDocumentHTML(html, for: revision)     }      /// Pushes the current theme, typography, and comment visibility onto the
prism/Views/DocumentScrollContent.swift Modified +8 / -0
diff --git a/prism/Views/DocumentScrollContent.swift b/prism/Views/DocumentScrollContent.swiftindex 1dc358a..4c1c7db 100644--- a/prism/Views/DocumentScrollContent.swift+++ b/prism/Views/DocumentScrollContent.swift@@ -212,6 +212,14 @@ struct DocumentScrollContent<ScrollModifiers: View>: View {         )) {             guard let webController, context.session.parseRevision > 0 else { return }             let revision = context.session.parseRevision+            // Build the document HTML off the MainActor and cache it before navigating, so a+            // large/HTML-heavy document is emitted without freezing the main thread (T-1681).+            // The scheme handler then serves the cached HTML; a cache miss falls back to a+            // synchronous emit in WebDocumentControllerFactory.emitHTML.+            await WebDocumentControllerFactory.precomputeDocumentHTML(+                for: context.session, settings: context.settings+            )+            guard !Task.isCancelled else { return }             let url = WebDocumentControllerFactory.documentURL(                 session: context.session,                 parseRevision: revision
prismTests/WebRendering/OffMainEmitTests.swift Added +252 / -0
diff --git a/prismTests/WebRendering/OffMainEmitTests.swift b/prismTests/WebRendering/OffMainEmitTests.swiftnew file mode 100644index 0000000..c0e2fb7--- /dev/null+++ b/prismTests/WebRendering/OffMainEmitTests.swift@@ -0,0 +1,252 @@+//+//  OffMainEmitTests.swift+//  prismTests+//+//  T-1681: off-main document HTML emission + per-revision cache.+//+//  Covers:+//  - The SwiftSoup lock (Decision 6): concurrent HTMLSanitizer / emit calls stay correct+//    and do not race SwiftSoup's unsynchronized static pools.+//  - Off-main callability + byte-parity: BlockHTMLEmitter.emit runs from a detached+//    (nonisolated) context and produces output identical to the on-main path.+//  - The DocumentSession per-revision cache: store/read/stale semantics.+//  - WebDocumentControllerFactory.precomputeDocumentHTML populating the cache off-main.+//  - A timed regression over the committed samples/large-html-heavy.md benchmark fixture.+//++import Foundation+import Testing+@testable import prism++@Suite("T-1681 — Off-Main Emit + Cache")+nonisolated struct OffMainEmitTests {++    private static let settings = RenderSettings(showHTMLComments: false, strings: .fallback)++    // A small HTML-heavy document exercising both block-level and inline sanitisation.+    private static let sampleMarkdown = """+    # Title++    <div class="card" onclick="steal()">+      <table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>+      <script>bad()</script>+    </div>++    A paragraph with <kbd>Cmd</kbd>+<kbd>K</kbd>, x<sup>2</sup>, H<sub>2</sub>O, and+    <span onmouseover="x">a span</span>.++    | Col A | Col B |+    |:------|------:|+    | a | b |++    - item one+    - item **two**+    """++    private func emit(_ markdown: String) -> String {+        let parsed = MarkdownBlockParser.parseWithFootnotes(markdown)+        return BlockHTMLEmitter.emit(+            blocks: parsed.blocks, footnotes: parsed.footnoteData, settings: Self.settings+        ).html+    }++    // MARK: - 1. SwiftSoup lock: concurrent sanitisation is safe + correct (Decision 6)++    @Test("Concurrent sanitize/plainText/sanitizeSVG stay correct under the SwiftSoup lock")+    func concurrentSanitizeIsSafe() async {+        let raw = "<div onclick=\"x\">hello <script>bad()</script><b>world</b></div>"+        let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"><script>bad()</script><rect/></svg>"+        let refSanitize = HTMLSanitizer.sanitize(raw)+        let refPlain = HTMLSanitizer.plainText(raw)+        let refSVG = HTMLSanitizer.sanitizeSVG(svg)++        await withTaskGroup(of: Bool.self) { group in+            for i in 0..<90 {+                group.addTask {+                    switch i % 3 {+                    case 0: return HTMLSanitizer.sanitize(raw) == refSanitize+                    case 1: return HTMLSanitizer.plainText(raw) == refPlain+                    default: return HTMLSanitizer.sanitizeSVG(svg) == refSVG+                    }+                }+            }+            for await ok in group {+                #expect(ok, "a concurrent sanitiser call diverged from its serial reference")+            }+        }+        // The sanitiser still strips the active-content vectors.+        #expect(!refSanitize.contains("onclick"))+        #expect(!refSanitize.contains("<script"))+        #expect(!refSVG.contains("<script"))+    }++    @Test("Concurrent full-document emits (which sanitise per block) produce identical output")+    func concurrentEmitsAreSafe() async {+        let parsed = MarkdownBlockParser.parseWithFootnotes(Self.sampleMarkdown)+        let reference = BlockHTMLEmitter.emit(+            blocks: parsed.blocks, footnotes: parsed.footnoteData, settings: Self.settings+        ).html++        await withTaskGroup(of: Bool.self) { group in+            for _ in 0..<16 {+                group.addTask {+                    BlockHTMLEmitter.emit(+                        blocks: parsed.blocks, footnotes: parsed.footnoteData, settings: Self.settings+                    ).html == reference+                }+            }+            for await ok in group {+                #expect(ok, "a concurrent emit diverged from the serial reference")+            }+        }+    }++    // MARK: - 2. Off-main callability + byte-parity++    @Test("emit runs off the MainActor (detached) and is byte-identical to the on-main path")+    func emitOffMainByteParity() async {+        let parsed = MarkdownBlockParser.parseWithFootnotes(Self.sampleMarkdown)+        let settings = Self.settings+        let onMain = BlockHTMLEmitter.emit(+            blocks: parsed.blocks, footnotes: parsed.footnoteData, settings: settings+        ).html+        // Compiling + running this proves emit is callable from a nonisolated/off-main context.+        let offMain = await Task.detached {+            BlockHTMLEmitter.emit(+                blocks: parsed.blocks, footnotes: parsed.footnoteData, settings: settings+            ).html+        }.value+        #expect(!offMain.isEmpty)+        #expect(offMain.contains("data-prism-document"))+        #expect(onMain == offMain, "off-main emit output differs from on-main")+    }++    // MARK: - 3. DocumentSession per-revision cache++    @MainActor+    @Test("Cache stores and returns HTML for the current revision")+    func cacheStoreAndRead() async {+        let session = DocumentSession(clipboardContent: "# Hello\n\nWorld")+        await session.parseContent()+        // Bind MainActor reads to locals so the #expect autoclosure compares plain values+        // (accessing the session property inside the macro autoclosure would be cross-actor).+        let revision = session.parseRevision+        #expect(revision > 0)+        let before = session.currentCachedDocumentHTML+        #expect(before == nil, "nothing cached before a store")++        session.storeCachedDocumentHTML("<html>cached</html>", for: revision)+        let after = session.currentCachedDocumentHTML+        #expect(after == "<html>cached</html>")+    }++    @MainActor+    @Test("Cache rejects a stale-revision write and never serves it")+    func cacheRejectsStaleWrite() async {+        let session = DocumentSession(clipboardContent: "# Hi")+        await session.parseContent()+        let current = session.parseRevision++        session.storeCachedDocumentHTML("<html>stale</html>", for: current &- 1)+        let cached = session.currentCachedDocumentHTML+        #expect(cached == nil, "a stale-revision write must be dropped")+    }++    @MainActor+    @Test("A reparse advances the revision and invalidates the cached HTML")+    func cacheInvalidatedOnReparse() async {+        let session = DocumentSession(clipboardContent: "# One")+        await session.parseContent()+        let revision = session.parseRevision+        session.storeCachedDocumentHTML("<html>rev1</html>", for: revision)+        let cached1 = session.currentCachedDocumentHTML+        #expect(cached1 == "<html>rev1</html>")++        await session.reloadContent(markdownString: "# Two\n\nchanged")+        let cached2 = session.currentCachedDocumentHTML+        #expect(cached2 == nil, "cache must be stale after a reparse")+    }++    // MARK: - 4. Off-main precompute populates the cache++    @MainActor+    @Test("precomputeDocumentHTML builds and caches HTML off the MainActor for the current revision")+    func precomputePopulatesCache() async {+        let session = DocumentSession(clipboardContent: "# Doc\n\n<div>raw <b>x</b></div>")+        await session.parseContent()+        let before = session.currentCachedDocumentHTML+        #expect(before == nil)++        await WebDocumentControllerFactory.precomputeDocumentHTML(for: session, settings: AppSettings())++        let cached = session.currentCachedDocumentHTML+        #expect(cached != nil, "precompute should populate the cache")+        #expect(cached?.contains("data-prism-document") == true)+        // The emitted block content is present (the precompute uses catalog strings, so this+        // asserts structure/content rather than byte-equality with a .fallback-strings emit).+        #expect(cached?.contains("raw") == true)+    }++    // MARK: - 4b. The serve path (emitHTML): cache hit, miss-emits-and-stores, generation++    @MainActor+    @Test("emitHTML serves the cached HTML on a hit and injects the generation island")+    func emitHTMLServesCacheHit() async {+        let session = DocumentSession(clipboardContent: "# Doc\n\ntext")+        await session.parseContent()+        session.storeCachedDocumentHTML("<div data-cache-marker>cached body</div>", for: session.parseRevision)++        let served = WebDocumentControllerFactory.emitHTML(+            for: session, settings: AppSettings(), processGeneration: 0+        )+        #expect(served.contains("data-cache-marker"), "a cache hit must serve the cached HTML verbatim")+        #expect(served.contains("id=\"prism-generation\""), "the generation island is injected per serve")+    }++    @MainActor+    @Test("emitHTML on a cache miss emits + stores, and stamps the live processGeneration per serve")+    func emitHTMLCacheMissEmitsAndStores() async {+        let session = DocumentSession(clipboardContent: "# Doc\n\n<div>raw <b>x</b></div>")+        await session.parseContent()+        let before = session.currentCachedDocumentHTML+        #expect(before == nil)++        let served5 = WebDocumentControllerFactory.emitHTML(+            for: session, settings: AppSettings(), processGeneration: 5+        )+        #expect(served5.contains("data-prism-document"), "a miss emits the real document")+        #expect(served5.contains("id=\"prism-generation\""))++        let after = session.currentCachedDocumentHTML+        #expect(after != nil, "the cache-miss fallback must store its result (Decision 3)")++        // A second serve at the same revision is a cache hit; only the per-serve generation+        // island differs, proving processGeneration is stamped per request, not baked in.+        let served9 = WebDocumentControllerFactory.emitHTML(+            for: session, settings: AppSettings(), processGeneration: 9+        )+        #expect(served5 != served9, "the per-serve generation island reflects the live processGeneration")+    }++    // MARK: - 5. Timed regression over the benchmark fixture++    @Test("Emitting samples/large-html-heavy.md completes within a generous budget")+    func largeSampleEmitPerformance() throws {+        let url = ParityFixtureSupport.samplesDirectory()+            .appendingPathComponent("large-html-heavy.md")+        let markdown = try String(contentsOf: url, encoding: .utf8)+        let parsed = MarkdownBlockParser.parseWithFootnotes(markdown)++        let start = ContinuousClock.now+        let html = BlockHTMLEmitter.emit(+            blocks: parsed.blocks, footnotes: parsed.footnoteData, settings: Self.settings+        ).html+        let elapsed = start.duration(to: .now)++        #expect(!html.isEmpty)+        #expect(html.contains("data-prism-document"))+        // Hang guard, not a tight budget: wall-clock here depends on parallel test load and the+        // shared SwiftSoup lock, so the ceiling only catches a catastrophic regression / hang.+        #expect(elapsed < .seconds(120), "large-sample emit took \(elapsed), expected < 120s")+    }+}
samples/large-html-heavy.md Added +7103 / -0
samples/large-html-heavy.md — 247 KB generated benchmark fixture (7,103 lines added).Generated by Tools/gen-large-html-heavy-sample.py; do not hand-edit. Dense in raw-HTML blocks (~360 block-level sanitises) and inline HTML runs (~1,200 inline sanitises), ~1,560 SwiftSoup parses per emit, plus content the sanitiser must strip (onclick, <script>, onmouseover).Full diff omitted intentionally — it is generated data, not reviewable source.
specs/offmain-html-emit/decision_log.md Added +370 / -0
diff --git a/specs/offmain-html-emit/decision_log.md b/specs/offmain-html-emit/decision_log.mdnew file mode 100644index 0000000..a390d49--- /dev/null+++ b/specs/offmain-html-emit/decision_log.md@@ -0,0 +1,370 @@+# Decision Log: Off-Main Document HTML Emission + Per-Revision Cache++## Decision 1: Cache emitted HTML keyed by parseRevision alone (not RenderSettings)++**Date**: 2026-07-11+**Status**: accepted++### Context++The emitted document HTML depends on `parseRevision` (blocks/footnotes) and, in principle, on+`RenderSettings`: `showHTMLComments` tags comment nodes with a `prism-comment-hidden` class+(`BlockHTMLEmitter.swift:658`), and `catalogStrings()` bakes localized chrome labels ("Copy",+"View source", …) into the HTML. If the cache key included these, they would drive+invalidation/rebuild.++### Decision++Key the cache on `parseRevision` only. Comment visibility remains owned by the live+`setCommentVisibility` bridge command (toggling `showHTMLComments` neither invalidates the cache+nor reloads). Catalog strings are treated as stable for a session's lifetime.++### Rationale++**Comments:** toggling comments today calls `webController.setCommentVisibility(visible)`+(`DocumentScrollContent.swift:186`) with no reload — the emitter always emits the comment nodes+and the stylesheet + bridge command toggle visibility live. The current visibility is reasserted+on every load via `pushInitialState` (`WebDocumentControllerFactory.swift:195`) and replayed in+the coalesced recovery snapshot (`WebDocumentController.swift:553`), so a cached HTML string with+a stale initial `prism-comment-hidden` class is corrected before paint.++**Catalog strings:** a system-language change relaunches the app on iOS/macOS, which recreates+the session and resets `parseRevision` — so within one session the baked strings do not change.+Theme and text-scale (the other appearance inputs) are pushed live via the bridge, not baked+into the emit. So `parseRevision` fully captures the emit-affecting inputs in practice.++### Alternatives Considered++- **Key on `parseRevision` + `RenderSettings` identity**: correct but rebuilds on every comment+  toggle and adds a settings-hash to the key — Rejected: the live bridge command already owns+  visibility and strings are session-stable; the extra key buys nothing and adds churn.+- **Re-emit on every comment toggle**: Rejected: reintroduces a full emit for a setting the+  bridge toggles live without a reload.++### Consequences++**Positive:**+- Comment toggles stay a cheap, reload-free bridge command; simpler cache key.++**Negative:**+- A cached document could momentarily carry a stale initial comment-hidden class until the+  queued `setCommentVisibility` applies (already the pre-existing recovery-replay behaviour).+- A hypothetical in-session locale change that did not bump `parseRevision` would serve+  stale-language chrome until the next reparse — accepted as low-likelihood given the relaunch+  behaviour.++---++## Decision 2: Move emit off the MainActor by marking the pure emit tree `nonisolated`++**Date**: 2026-07-11+**Status**: accepted++### Context++`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor` makes `BlockHTMLEmitter.emit` and its helpers+MainActor-isolated by default, which is why the serve provider uses `MainActor.assumeIsolated`.+The primary user complaint — the UI freezing while a large document is emitted — is only fixed+by running the emit off the main thread. All emit inputs/outputs are already `Sendable`.++### Decision++Mark the pure emit call tree `nonisolated` (`BlockHTMLEmitter`, `InlineHTMLRenderer`,+`HTMLSanitizer`, `BlockDOMID`, `HTMLEscaping`, `HTMLImageSourceRewriter`, plus any referenced+constant the compiler flags, e.g. `PrismDocSchemeHandler.contentSecurityPolicy`) and run+`emit` inside a `Task.detached`.++### Rationale++These types are pure, stateless functions with only immutable static state (regexes, loggers,+constant strings). `nonisolated` is compiler-checked, so any accidental MainActor dependency is+caught at build time. This is the minimal way to get emit off-main given the module's default+isolation; the inputs' existing `Sendable` conformance means no data-race surface is introduced.++### Alternatives Considered++- **Rewrite `emit` as an `async` chunked walk that yields to the main run loop**: Rejected:+  far larger change to a pure synchronous function; `nonisolated` + `Task.detached` achieves+  off-main execution with no restructuring.+- **Cache only, keep emit on-main**: Rejected: caching alone does not fix the first-open freeze+  (the first emit still blocks main); it only helps no-reparse re-serves.++### Consequences++**Positive:**+- Emit (and its SwiftSoup sanitisation) no longer blocks the main thread.+- Compiler enforces the purity assumption.++**Negative:**+- The `nonisolated` annotation spans ~6 files (mostly one-word changes), above the smolspec+  file-count heuristic — accepted after scope review.++---++## Decision 3: Retain a synchronous on-main emit as a cache-miss fallback++**Date**: 2026-07-11+**Status**: accepted++### Context++The scheme handler's provider is invoked asynchronously by WebKit and could, in principle, run+before the background precompute has populated the cache for the current revision.++### Decision++`emitHTML` reads `session.currentCachedDocumentHTML` and, on a miss, falls back to a synchronous+`BlockHTMLEmitter.emit` on the MainActor (today's behaviour), then stamps the generation island.++### Rationale++Correctness must not depend on precompute timing. The fallback preserves the exact current+output; the common path (precompute completed before load) serves the cache and never blocks+main. This makes the change incremental and safe.++### Alternatives Considered++- **Block the serve until precompute completes**: Rejected: reintroduces a stall and couples the+  serve to background-task timing.+- **Serve empty/placeholder on miss**: Rejected: visible correctness regression.++### Consequences++**Positive:**+- No correctness dependency on precompute timing; safe incremental rollout.++**Negative:**+- A rare cache-miss serve still emits on main once (same as today) — acceptable as a fallback.++---++## Decision 4: Defer a per-block HTMLSanitizer size cap / timeout++**Date**: 2026-07-11+**Status**: accepted++### Context++The ticket notes `HTMLSanitizer.sanitize` has no size limit or timeout (unlike mermaid's 10s+timeout / SVG's 2MB cap). A pathological single raw-HTML block could still be expensive.++### Decision++Do not add a per-block sanitiser size cap or timeout in this change. Track separately if needed.++### Rationale++Moving sanitisation off the MainActor removes the user-visible freeze, which was the actual+harm. The document is already capped at 10MB total, bounding aggregate sanitiser work. A+per-block cap would change rendering behaviour (escaping large HTML blocks that render today),+which is a behavioural decision that belongs in its own change rather than bundled into a+performance move.++### Alternatives Considered++- **Add a per-block byte cap now**: Rejected: behaviour-changing and out of proportion to the+  perf fix; risks regressing legitimate large HTML blocks.++### Consequences++**Positive:**+- Keeps this change focused on the freeze; no rendering-behaviour change.++**Negative:**+- A single very large raw-HTML block still pays a full off-main sanitise — no longer blocking+  the UI, but still CPU work. Revisit if observed in practice.++---++## Decision 5: Session owns the cache; factory emits; view triggers the precompute++**Date**: 2026-07-11+**Status**: accepted++### Context++The emit needs `parsedBlocks`/`footnoteData` (from `DocumentSession`) plus `showHTMLComments`+and catalog strings (from `AppSettings` + `WebDocumentControllerFactory.catalogStrings()`). The+cache must survive controller/`WebPage` rebuilds and be readable by the scheme handler provider.++### Decision++`DocumentSession` stores the cached HTML string keyed by `parseRevision` (it knows nothing about+`RenderSettings`/`AppSettings`). `WebDocumentControllerFactory` owns `precomputeDocumentHTML`+(builds `RenderSettings`, emits off-main, stores on the session) and the serve-time `emitHTML`.+`DocumentScrollContent`'s web-load task triggers the precompute (it already has `AppSettings`+and gates the load).++### Rationale++Keeps the model free of view/settings coupling (consistent with the model-import boundary), puts+the settings-aware emit in the factory that already builds `RenderSettings`, and triggers it at+the one place that owns the load lifecycle so the cache is ready before the provider runs.++### Alternatives Considered++- **Emit inside `DocumentSession.parseAndApplyBlocks`**: Rejected: the session would have to+  build `RenderSettings` and hold an `AppSettings` reference, coupling the model to settings and+  catalog strings.+- **Cache on `WebDocumentController`**: Rejected: the controller is rebuilt on recovery, losing+  the cache exactly when it is most useful.++### Consequences++**Positive:**+- Clear ownership; cache survives controller/page rebuilds; no model/settings coupling.++**Negative:**+- The precompute trigger lives in the view layer rather than the parse pipeline — mitigated by+  the `emitHTML` synchronous fallback covering any missed trigger.++---++## Decision 6: Serialize all SwiftSoup access behind one lock rather than confining it to an actor++**Date**: 2026-07-11+**Status**: accepted++### Context++Moving `BlockHTMLEmitter.emit` off the MainActor moves its per-block/per-inline `HTMLSanitizer.sanitize`+calls off-main for the first time. SwiftSoup is **not** thread-safe: it keeps unsynchronized+mutable static object pools — `Parser.htmlTreeBuilderPool`/`xmlTreeBuilderPool` (`Parser.swift:26-27`),+`StringBuilder.pool` (`StringBuilder.swift:14`), `QueryParser.cacheInstance` (`QueryParser.swift:23`),+all declared `nonisolated(unsafe) static var` and borrowed/returned per parse. Today this is safe+only because every SwiftSoup call (document `sanitize`, image `sanitizeSVG` at+`PrismDocSchemeHandler.swift:428`, search `plainText` at `MarkdownBlock.swift:858`) runs serialized+on the MainActor. Off-main emit would let a document sanitise race the still-on-main image/search+SwiftSoup calls, and race other concurrent emits — a data race on those pools.++### Decision++Add one shared lock (`Mutex`/`NSLock`) in `HTMLSanitizer` and wrap the SwiftSoup body of every+entry point (`sanitize`, `plainText`, `sanitizeSVG`) in it. Locking is per-call.++### Rationale++The pools are the shared state, and every access to them flows through these three functions, so a+single lock around each fully serializes SwiftSoup regardless of the calling thread — with no+changes to the image or search call sites. Per-call granularity keeps critical sections short:+an off-main emit takes/releases the lock per block, letting an on-main image sanitise interleave+between blocks, so the main thread never waits longer than a single sanitise call. A full-actor+confinement would force `await` into the synchronous emit walk (per-block actor hops) and ripple+into the synchronous search path — far more invasive for the same guarantee.++### Alternatives Considered++- **Dedicated `@globalActor` owning all SwiftSoup**: correct but forces the synchronous emit to+  run on the actor (per-block hops) and turns the synchronous search `plainText` path async —+  Rejected: much larger ripple for the same serialization.+- **Serialize document emits only (one background executor)**: Rejected: still races the on-main+  image `sanitizeSVG` / search `plainText`.+- **Assume SwiftSoup is thread-safe**: Rejected: verified false against the source.++### Consequences++**Positive:**+- Off-main emit is safe against every existing SwiftSoup caller with zero changes to those callers.+- Short, per-call critical sections; no coarse stall.++**Negative:**+- SwiftSoup calls no longer run truly in parallel (they never safely could); a background emit and+  an on-main image sanitise briefly contend on the lock — bounded by one sanitise call.++---++## Decision 7: Cache the emitted HTML string on the session despite the memory cost++**Date**: 2026-07-11+**Status**: accepted++### Context++Caching the emitted document requires retaining the full HTML string on `DocumentSession` for the+session's lifetime. For a document near the 10MB ceiling the emitted HTML is of comparable size,+roughly doubling resident document memory for that session.++### Decision++Retain the emitted HTML string on the session, keyed by `parseRevision`, superseded on the next+parse. No eviction beyond revision replacement.++### Rationale++The document is already capped at 10MB, bounding the worst case. The alternative — re-emitting on+every re-serve — is exactly the cost this change removes, and the emit is the expensive operation.+A single retained string per open document is an acceptable trade for eliminating repeated+full-document emits (recovery, folder-access re-navigation) and the main-thread freeze.++### Alternatives Considered++- **No cache, off-main emit only**: Rejected: WebContent recovery and folder-access re-navigation+  would re-emit; the eager precompute would have nowhere to store its result for the serve.+- **Evict the cache aggressively (e.g. after first serve)**: Rejected: defeats the recovery /+  re-navigation re-serve benefit for a marginal memory saving.++### Consequences++**Positive:**+- Re-serves are near-free; the freeze is eliminated on every reload path.++**Negative:**+- Up to ~10MB additional resident memory per open document until the next parse or session close.++---++## Decision 8: Off-main via `Task.detached`, and the actual breadth of the `nonisolated` sweep++**Date**: 2026-07-11+**Status**: accepted++### Context++Implementation surfaced two facts not fully known at spec time. (1) The project enables the+`NonisolatedNonsendingByDefault` upcoming feature, under which a plain `nonisolated async`+function inherits the *caller's* executor — so a `nonisolated async` emit helper called from the+MainActor would run *on* the MainActor, defeating the fix. (2) `SWIFT_VERSION = 5.0` means the+isolation violations from running `emit` off-main are *warnings*, not errors, but the project's+zero-new-warning bar still requires resolving them, and the `nonisolated` cascade reaches deeper+into the model/service layer than "a few constants".++### Decision++Run the off-main emit via `Task.detached(priority: .userInitiated)` (not a `nonisolated async`+helper). Mark the full pure closure `emit` transitively reads `nonisolated`: the emit tree+(Decision 2) plus `MarkdownBlock`, `ListItem`, `TableDisplayMode`, `ColumnAlignment`,+`BlockIDStore`, `BlockIDCache`, `DocumentSourceMap`, `CommentBlockDetector`,+`TableRowContextQuote`, `PrismLinkRoute`, `MermaidTypeParser`, `Logger.prism`, and the+`NSRegularExpression(staticPattern:)` convenience init.++### Rationale++`Task.detached` unambiguously runs the emit on the cooperative pool regardless of the+`NonisolatedNonsendingByDefault` default, and mirrors the existing parse offload in+`DocumentSession.parseAndApplyBlocks`. The wider `nonisolated` sweep is all pure data/model and+stateless service helpers that *should* be nonisolated anyway; it converged with **zero new+warnings** against an `origin/main` clean-build baseline. The one remaining warning+(`CopyNotesButton.swift:99`, a `@MainActor static var = ClipboardHelper.copy` whose static-var+initializer is nonisolated) is **pre-existing on main** (verified by baseline build) and is not+in `emit`'s closure — it belongs to the Swift 6 language-mode migration (T-1741), not here.++### Alternatives Considered++- **`@concurrent nonisolated async` helper**: the idiomatic approachable-concurrency tool, but+  its exact runtime placement under this toolchain was less certain than `Task.detached`, and the+  whole fix hinges on emit actually leaving the main thread — Rejected for certainty.+- **Mark only individual members (`id`, `textContent`) instead of whole types**: more surgical+  but far fiddlier (per-member conformance isolation), and the whole-type marking converged+  cleanly — Rejected as unnecessary.++### Consequences++**Positive:**+- Emit provably runs off-main; the sweep leaves the model layer correctly nonisolated (a down+  payment on T-1741) with no new warnings.++**Negative:**+- The change touches ~17 files (mostly one-word `nonisolated` annotations) — broader than the+  initial "few constants" estimate, though mechanical and compiler-verified.++---
specs/offmain-html-emit/smolspec.md Added +161 / -0
diff --git a/specs/offmain-html-emit/smolspec.md b/specs/offmain-html-emit/smolspec.mdnew file mode 100644index 0000000..0093a55--- /dev/null+++ b/specs/offmain-html-emit/smolspec.md@@ -0,0 +1,161 @@+# Off-Main Document HTML Emission + Per-Revision Cache++Transit: T-1681 (chore, high). Feature folder: `specs/offmain-html-emit/`.++## Overview++The WebKit document render path rebuilds the full document HTML on the MainActor on+every serve. `BlockHTMLEmitter.emit` (wrapped in `MainActor.assumeIsolated` at+`WebDocumentControllerFactory.swift:97-106`) walks the entire block array synchronously,+and `HTMLSanitizer.sanitize` runs a full SwiftSoup parse per raw-HTML block and per inline+HTML run with no size limit. Opening or reloading a large / HTML-heavy document freezes the+UI for the duration of the build, and it re-emits on every reload because no cache exists.+This change moves the emit off the MainActor and caches the emitted HTML per `parseRevision`,+so the emit runs once per parse on a background task and re-serves reuse the cached result.+Because SwiftSoup is not thread-safe (see Decision 6), it also serializes SwiftSoup access so+the newly off-main sanitisation cannot race the still-on-MainActor image / search callers.++## Requirements++- The system MUST build the full-document HTML (`BlockHTMLEmitter.emit`, including its+  per-block and per-inline-run `HTMLSanitizer` passes) off the MainActor, so emitting a+  large or HTML-heavy document does not block the main thread.+- The system MUST serialize every SwiftSoup entry point (`HTMLSanitizer.sanitize`,+  `plainText`, `sanitizeSVG`) so that no two SwiftSoup parses run concurrently, regardless of+  the thread each is called from. (SwiftSoup keeps unsynchronized mutable static pools —+  Decision 6.)+- The system MUST cache the emitted document HTML keyed by the session's `parseRevision`,+  so a re-serve that does not change the parse reuses the cached HTML instead of re-emitting.+- The system MUST serve freshly-emitted HTML after a reparse: when `parseRevision` advances+  (external file change, URL refresh), the previously cached HTML MUST NOT be served.+- For a given set of blocks, footnotes, and catalog strings, the served document body MUST be+  identical to today's output; the per-serve generation island is still stamped at serve time.+  (The initial `prism-comment-hidden` state MAY differ from the live `showHTMLComments` setting+  and is reconciled by the queued `setCommentVisibility` command — Decision 1.)+- The system MUST retain a synchronous on-MainActor emit as a cache-miss fallback (and MUST+  store that fallback's result into the cache for the current revision) so correctness never+  depends on the background precompute having completed for a given serve.+- The background precompute MUST capture its inputs (`parsedBlocks`, `footnoteData`,+  `parseRevision`, `RenderSettings`) in one synchronous MainActor step before any suspension,+  and MUST NOT store a result whose captured revision no longer matches the live+  `parseRevision` (stale-write guard).+- Comment visibility MUST remain owned by the live `setCommentVisibility` bridge command;+  toggling `showHTMLComments` MUST NOT invalidate or rebuild the cache.+- The system MUST add a test proving `BlockHTMLEmitter.emit` runs off the MainActor and+  produces output identical to the on-main path, plus a timed regression check on a large+  HTML-heavy document (generous margin — the timing is a floor, not the primary assertion).+- The system MUST ship a large HTML-heavy sample (`samples/large-html-heavy.md`) so the emit+  freeze can be observed manually in the app before the change and confirmed gone after, and so+  the timed test and the existing `SamplesComplianceTests` sweep exercise the same document.++## Implementation Approach++**Serialize SwiftSoup (`prism/Services/WebRendering/HTMLSanitizer.swift`).** Add one shared+lock (`Mutex`/`NSLock`) and wrap the SwiftSoup body of `sanitize`, `plainText`, and+`sanitizeSVG` in it. Locking is per-call (fine-grained), so an off-main emit's per-block+sanitises interleave with an on-main image `sanitizeSVG` or search `plainText` without a data+race and without a coarse stall. This is the enabling safety change for off-main emit.++**Move emit off the MainActor (`prism/Services/WebRendering/`).** `SWIFT_DEFAULT_ACTOR_ISOLATION+= MainActor` makes `emit` and its helpers MainActor-isolated by default. Mark the pure emit+call tree `nonisolated`: `BlockHTMLEmitter`, `InlineHTMLRenderer`, `HTMLSanitizer`, `BlockDOMID`,+`HTMLEscaping`, `HTMLImageSourceRewriter`. The sweep is compiler-guided and may extend to+referenced constants (e.g. `PrismDocSchemeHandler.contentSecurityPolicy`, mirrored by+`BlockHTMLEmitter.cspMetaContent`) and may need `nonisolated(unsafe)` on immutable regex/logger+statics. All targets are pure/stateless, so the change is mechanical and behaviour-preserving.+Emit inputs/output are already `Sendable` (`MarkdownBlock`, `FootnoteData`, `RenderSettings`,+`EmittedDocument`). Caching `EmittedDocument.html` alone is lossless: the source map is already+embedded as the hidden data-island inside `html`, and `emitHTML` discards the `sourceMap` object+today.++**Cache storage on the session (`prism/Models/DocumentSession.swift`).** Add a cached HTML+string keyed by the `parseRevision` it was emitted for, a `storeCachedDocumentHTML(_:for:)`+method that accepts only a write whose revision equals the current `parseRevision`, and a+`currentCachedDocumentHTML` accessor returning the cached HTML only when its key matches the+live `parseRevision` (nil otherwise). The session is the right owner: it survives controller /+`WebPage` rebuilds (WebContent recovery, folder-access re-navigation). All three are+MainActor-isolated members (same isolation the provider already reads under).++**Emit + serve wiring (`prism/ViewModels/WebDocumentControllerFactory.swift`).** Add+`precomputeDocumentHTML(for:settings:) async`: synchronously snapshot `parsedBlocks`,+`footnoteData`, `parseRevision`, and build `RenderSettings` on the MainActor (single step, no+intervening `await`), then run `BlockHTMLEmitter.emit` off the MainActor via a `nonisolated`+`async` helper (structured — the parent `.task` cancellation propagates); before storing, bail+if `Task.isCancelled` or the revision drifted. Change `emitHTML(for:settings:processGeneration:)`+to read `session.currentCachedDocumentHTML`, and on a miss synchronously `emit` (today's+behaviour) **and store** the result for the current revision; either way it still applies+`WebDocumentLoader.injectGeneration` per serve so the generation island carries the live+`processGeneration`.++**Trigger the precompute before the reload (`prism/Views/DocumentScrollContent.swift`).** The+web-load `.task(id: WebLoadKey(...))` (lines 209-221) already runs on the MainActor and gates+`webController.load(...)`. `await WebDocumentControllerFactory.precomputeDocumentHTML(...)`+there before `load(...)`, so the cache is populated (off-main) before the scheme handler's+provider runs. The iOS folder-access re-navigation (`reloadWebDocumentForAccess`, 289-296) is+same-revision and reuses the cache; the `emitHTML` fallback covers any residual miss.++**Benchmark fixture (`samples/large-html-heavy.md`).** A committed ~247KB sample dense in raw+HTML blocks (~360 block-level sanitises) and inline HTML runs (~1,200 inline sanitises) —+~1,560 SwiftSoup parses in one emit. It exists to make the before/after difference observable+when opening the document in the app (a multi-second main-thread freeze before the change,+gone after), and it is exercised automatically by `SamplesComplianceTests.everySampleEmits`+(parse + emit non-empty). It also includes content the sanitiser must strip (`onclick`,+`<script>`, `onmouseover`) so cleaning does real work. Generated by+`Tools/gen-large-html-heavy-sample.py` (committed generator; do not hand-edit the sample). Sized+to freeze noticeably on-device while keeping the compliance sweep reasonable in `test-quick`.++**Test (`prism/prismTests/`).** New `@Test`s: (1) call `BlockHTMLEmitter.emit` from a detached /+`nonisolated` context and assert the output equals the reference (proves off-main callability+and byte-parity); (2) a timed `ContinuousClock` regression that loads `samples/large-html-heavy.md`+(via `ParityFixtureSupport.samplesDirectory()`, as `SamplesComplianceTests` does) and asserts+`emit` completes within a generous budget; (3) a concurrency stress test running a background+emit while an image `sanitizeSVG` / search `plainText` runs, asserting no crash / correct output+(guards the SwiftSoup lock, Decision 6). Mirrors+`MarkdownBlockParserTests.testLargeDocumentParsingPerformance` (`:523-548`).++**Existing patterns leveraged:** the `Task.detached` + main-actor-apply + generation-guard in+`DocumentSession.parseAndApplyBlocks` (`:487-521`); the parse-only perf test+(`MarkdownBlockParserTests.swift:523-548`); existing `nonisolated` usage+(`BlockHTMLEmitter.rewriteImageSrc:766`, `PrismDocSchemeHandler.assetAllowlist:78`).++**Dependencies:** `BlockHTMLEmitter.emit`, `WebDocumentLoader.injectGeneration`, `RenderSettings`++ `catalogStrings()`, `Mutex`/`NSLock`. No new SPM dependencies.++### Out of Scope++- Wiring WebContent-process-termination recovery to a live `WebPage` signal (today's+  `handleProcessTermination` is test-only). The cache benefits it if wired later.+- Chunking / streaming the document `Data` in `PrismDocSchemeHandler.serveDocument`.+- Moving the image `sanitizeSVG` / search `plainText` calls off the MainActor (they stay where+  they are; the lock makes them coexist safely with the off-main emit).+- A per-block `HTMLSanitizer` hard size cap or timeout (Decision 4): behaviour-changing,+  deferred; the 10MB document cap already bounds aggregate work.+- Optimising the per-serve generation-island string replacement (`injectGeneration`).+- `FootnotePopoverWebPage` emit (small fragments, not the document surface) and the removed+  SwiftUI/Textual legacy path.++## Risks and Assumptions++- Risk: the `nonisolated` sweep cascades to referenced constants and needs `nonisolated(unsafe)`+  on immutable regex/logger statics. Mitigation: compiler pinpoints each; all targets are pure+  and immutable. Acceptance = the app builds with `emit` off-main and all WebRendering tests green.+- Risk: SwiftSoup is not thread-safe — it keeps unsynchronized mutable static pools+  (`Parser.htmlTreeBuilderPool`/`xmlTreeBuilderPool`, `StringBuilder.pool`,+  `QueryParser.cacheInstance`, all `nonisolated(unsafe) static var`). Today safe only because+  all SwiftSoup runs serialized on the MainActor. Mitigation: the shared lock around every+  `HTMLSanitizer` SwiftSoup entry point restores that serialization while allowing off-main+  execution (Decision 6). A concurrency stress test (background emit + concurrent image SVG+  serve / search) is part of acceptance.+- Risk: the cache serves stale HTML. Mitigation: keyed by `parseRevision`, revision-guarded+  writes, `currentCachedDocumentHTML` returns nil unless the key matches the live revision, and+  a synchronous emit fallback on any miss.+- Risk: retaining a full emitted-HTML string per session roughly doubles resident document+  memory for a large document over the session's lifetime (Decision 7). Accepted; the document+  is capped at 10MB.+- Assumption: comment visibility is reasserted on every load via the queued+  `setCommentVisibility` command (`pushInitialState` + coalesced recovery snapshot), so a cached+  HTML string with a stale `prism-comment-hidden` class is corrected before paint (Decision 1).+- Assumption: catalog strings baked into the HTML (via `catalogStrings()`) are stable for a+  session's lifetime — a system-language change relaunches the app, resetting `parseRevision`+  (Decision 1). Theme and text-scale are pushed live via the bridge, not baked into the emit.+- Assumption: emit inputs are `Sendable` (verified) so they cross into the off-main emit cleanly.
specs/offmain-html-emit/tasks.md Added +41 / -0
diff --git a/specs/offmain-html-emit/tasks.md b/specs/offmain-html-emit/tasks.mdnew file mode 100644index 0000000..c6e07ce--- /dev/null+++ b/specs/offmain-html-emit/tasks.md@@ -0,0 +1,41 @@+---+references:+    - specs/offmain-html-emit/smolspec.md+    - specs/offmain-html-emit/decision_log.md+---+# T-1681: Off-Main HTML Emission + Per-Revision Cache++## Enable off-main emit safely++- [x] 1. Serialize all SwiftSoup access behind one lock <!-- id:1y49c8q -->+  - Outcome: every SwiftSoup entry point in HTMLSanitizer (sanitize, plainText, sanitizeSVG) runs under one shared lock so no two SwiftSoup parses execute concurrently, regardless of the calling thread (Decision 6).+  - This is the safety prerequisite for running emit off the MainActor: SwiftSoup keeps unsynchronized mutable static pools (Parser htmlTreeBuilderPool/xmlTreeBuilderPool, StringBuilder.pool, QueryParser.cacheInstance).+  - Verify: a new test runs many concurrent sanitize/plainText/sanitizeSVG calls without a crash or corrupted output, and the existing HTMLSanitizer tests stay green.++- [x] 2. Make the emit call tree callable off the MainActor <!-- id:1y49c8r -->+  - Outcome: BlockHTMLEmitter.emit and its pure helper tree (InlineHTMLRenderer, HTMLSanitizer, BlockDOMID, HTMLEscaping, HTMLImageSourceRewriter, plus any referenced constant the compiler flags such as PrismDocSchemeHandler.contentSecurityPolicy) are nonisolated so emit can run off the MainActor; emitted output is unchanged (Decision 2).+  - Verify: the app builds on iOS and macOS; a new test calls emit from a detached/nonisolated context and asserts byte-identical output to the on-main reference.+  - Blocked-by: 1y49c8q (Serialize all SwiftSoup access behind one lock)++## Cache and serve off-main++- [x] 3. Cache emitted HTML per parse revision on the session <!-- id:1y49c8s -->+  - Outcome: DocumentSession stores the emitted document HTML keyed by the parseRevision it was built for; a write is accepted only when its revision equals the current parseRevision, and the accessor returns the cached HTML only when its key matches the live revision (nil otherwise) (Decision 5/7).+  - Verify: unit tests — a current-revision write is retrievable; a stale-revision write is rejected; a read after parseRevision advances returns nil.++- [x] 4. Emit off-main, serve from cache with a synchronous fallback, wired into the document load <!-- id:1y49c8t -->+  - Outcome: the factory precomputes the document HTML off the MainActor (snapshot blocks/footnotes/revision/RenderSettings in one synchronous step, emit off-main, store guarded by revision and Task.isCancelled); the serve path returns the cached HTML and on a miss emits synchronously AND stores the result, always stamping the per-serve generation island; the document surface awaits the precompute before navigating so the reload does not emit on the main thread (Decision 3/5).+  - Verify: tests that the serve path returns cached HTML when present and emits+stores on a miss, and that the generation island carries the live processGeneration; opening samples/large-html-heavy.md builds the HTML off-main with no main-thread emit on the initial load or a reparse reload.+  - Blocked-by: 1y49c8r (Make the emit call tree callable off the MainActor), 1y49c8s (Cache emitted HTML per parse revision on the session)++## Benchmark and verify++- [x] 5. Emit performance and concurrency tests over the benchmark fixture <!-- id:1y49c8u -->+  - Outcome: emit performance and concurrency coverage over the committed benchmark fixture samples/large-html-heavy.md (the fixture and Tools/gen-large-html-heavy-sample.py are already added).+  - Verify: a timed ContinuousClock test loads the fixture and asserts emit completes within a generous budget; a concurrency stress test runs a background emit alongside an image sanitizeSVG / search plainText and asserts no crash and correct output (guards the Decision 6 lock); SamplesComplianceTests stays green.+  - Blocked-by: 1y49c8q (Serialize all SwiftSoup access behind one lock), 1y49c8r (Make the emit call tree callable off the MainActor)++- [x] 6. Confirm the change builds and the unit suite passes on both platforms <!-- id:1y49c8v -->+  - Outcome: the change builds and the unit suite passes (the nonisolated sweep and off-main concurrency compile cleanly under strict concurrency).+  - Verify: make build-ios and make build-macos pass with zero warnings/errors, and make test-quick is green.+  - Blocked-by: 1y49c8t (Emit off-main, serve from cache with a synchronous fallback, wired into the document load), 1y49c8u (Emit performance and concurrency tests over the benchmark fixture)

Things to double-check

Pre-existing CopyNotesButton.swift:99 isolation warning is unrelated

The one remaining build warning (CopyNotesButton.swift:99, a @MainActor static var = ClipboardHelper.copy whose static-var initializer is nonisolated) is pre-existing on the origin/main baseline (verified by baseline build) and is not in emit's closure. It belongs to the Swift 6 language-mode migration tracked under T-1741, not this change.

Heavy documents still take a moment to first-paint

The emit no longer blocks the main thread, but WebKit still has to lay out the emitted DOM, so a very HTML-dense document takes a visible moment to first-paint. Making that render incremental is tracked under T-1743.