prism branch T-1784/bugfix-clipboard-save-web-image-resolution commits 1 files 9 touched (5 source, 1 test, 3 docs) lines +591 / -23

Pre-push review: T-1784/bugfix-clipboard-save-web-image-resolution

Fix T-1784: a clipboard document saved to a file kept resolving its images as an unsaved paste until closed and reopened. Reviewed as git diff origin/main...HEAD (PR #403). Read-only review: no source files were modified.

At a glance

  • Root cause: PrismDocSchemeHandler is a struct copied into WebPage.Configuration; its image context was frozen at page-build time while didSave(to:) re-bases the session in place with no parseRevision bump.
  • Fix: context moved into DocumentImageSourceBox (reference, Mutex) read per request; WebDocumentStateSynchronizer gains an image-source domain that writes the box and reloads the same revision (restoring the reading position).
  • Security: serveImage's scope-root guard and validateLocalFile(within:) are unchanged; the new directory becomes the scope root. Not pinned by a traversal-through-the-box test (minor gap).
  • Tests: 4 new tests, all behavioural through the production assembly, follow the WebKit rule (async tests in a @MainActor suite). Weak spot: a fixed 300 ms sleep as a negative assertion.
  • Docs: CLAUDE.md Serve bullet, CHANGELOG, and bugfix report are accurate. The convenience init's comment says test-only but FootnotePopoverWebPage uses it in production.

Verdict

Ready to push

The fix is correct and narrowly scoped: the scheme handler now reads its image context per request from a shared Mutex-backed box, and the state synchronizer re-bases that box and issues a same-revision reload when session.source changes. All six correctness questions the review put to it (revert-to-clipboard, observation registration of session.source, the superseded-window guard, Save As to a new folder, the seed pass, Sendable/concurrency) resolve in the change's favour. Scope-root canonicalisation in serveImage is untouched and now runs against the new directory by construction. make lint is clean and the four new regression tests plus the existing scheme-handler suite pass on macOS (68 tests). Every finding is minor or a nit; none is blocking.

Review findings

8 raised · 0 fixed · 8 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Prism can show markdown you paste from the clipboard, and later let you save that pasted text into a folder as a real file. If the text contained a picture reference like ![diagram](diagram.png), the picture stayed broken after saving, even though the picture was sitting right next to the saved file. You had to close and reopen the document to see it.

The reason: the part of the app that fetches pictures for the page was handed a note saying "this document has no folder" when the page was first built, and it kept that note forever. Saving changed where the document lived, but nobody told the picture-fetcher. Now the picture-fetcher looks up a shared, always-current note every time it fetches a picture, and the app tells the page to refresh itself (keeping your place) as soon as the save happens.

Why It Matters

Saving a pasted document is meant to be invisible — same document, same position, same notes. Broken images right after saving made it look like the save had gone wrong.

Key Concepts

  • Scheme handler: the app's own tiny web server that hands the rendered page its HTML and images. It is copied into the web page's configuration, like a photocopy: changes to the original don't reach the copy.
  • Box: a small shared object both the original and the copy point at, so an update is seen by everyone. It is protected by a lock (Mutex) because the web engine may ask for images from any thread.
  • Reload: the page fetches everything again. Images that failed once are not retried by the web engine on its own, so a reload is how they get a second chance.

Changes Overview

  • DocumentImageSource.swift (new): DocumentImageSourceContext (scope root, source type, base URL; Equatable, Sendable) and DocumentImageSourceBox (final class: Sendable over a Mutex), plus DocumentSource.imageSourceContext.
  • PrismDocSchemeHandler.swift: the three stored context fields become one let imageSource: DocumentImageSourceBox, read once per reply(for:). A convenience init keeps the old three-parameter signature for fixed-context callers.
  • WebDocumentController.swift: re-exposes the handler's own box (@ObservationIgnored), taken from the handler so it cannot diverge.
  • WebDocumentControllerFactory.swift: seeds the box from session.source.
  • WebDocumentStateSynchronizer.swift: SyncPass gains imageSource; a change writes the box and, unless it is the first (seed) pass, calls reloadDocument for the same revision.

Implementation Approach

The synchronizer is already the single Observation-driven owner pushing native truth to the page, so adding a domain there is the established pattern (lastX diffing). Because DocumentSession is @Observable and source is a stored property, the tracked read in computePass registers it and didSave triggers exactly one pass. The reload reuses WebDocumentControllerFactory.reloadDocument, which hits the cached HTML for an unchanged parseRevision and restores the stored scroll position.

Trade-offs

  • Full page reload versus re-requesting only images: WebKit exposes no per-subresource retry, and with content JS disabled the alternative is a new bridge command; a once-per-document reload is the cheaper engineering choice.
  • Rebuilding the controller was rejected: it would drop readiness, the coalesced snapshot and the reading position.
  • The reload is unconditional, even for a saved document with no local images — a deliberate simplicity choice.

Technical Deep Dive

Ordering in the synchronizer is the load-bearing detail: the box is written before reloadDocument is attempted, so a reload declined by isSuperseded or parseRevision == 0 is still covered — the announced load that caused the decline will fetch against the updated box. The residual is an announced load that is later abandoned, which leaves the domain marked clean with nothing retrying; narrow, and undocumented at the call site (the sibling DocumentScrollContent.reloadWebDocument does document its swallowed Bool).

The seed guard (lastImageSource == nil) is effectively dead defensive code: makeAssembly builds the box and synchronizer in one synchronous stretch, and start() runs before the load task is armed, so the first pass always sees a context equal to the factory seed. Initialising lastImageSource from controller.imageSource.value would remove the sentinel and the branch structurally.

revertToClipboard after a failed note migration cannot produce a reload today: finaliseFailure only reverts on a first attempt where source is still .clipboard; later attempts re-didSave to the same URL (equal context). Save As to a different folder correctly reloads; same-folder rename correctly does not, thanks to Equatable.

Architecture Impact

The handler's context is now the one mutable input to an otherwise frozen struct. The convenience init defaults every parameter, so PrismDocSchemeHandler(documentHTMLProvider:) still compiles into a frozen handler — the same regression class this fix removes, one call site away. FootnotePopoverWebPage uses it legitimately (serves no images), but the doc comment claims it is test-only. Renaming it (fixed…) would make choosing frozen explicit.

Potential Issues

  • documentDirectory and imageBaseURL are always equal in production; the struct carries a state the comment has to declare impossible.
  • No test pins traversal rejection through the box, the Save As file-to-file path, or the revert direction (over-permissive if it ever regressed).
  • The reload visibly re-renders (readiness drops, selection affordance resets); the CHANGELOG's "nothing else about the document moves" slightly overstates.

Important changes — detailed

DocumentImageSource.swift: context value + Mutex-backed box

prism/Services/WebRendering/DocumentImageSource.swift

Why it matters. This is the mechanism that lets a struct copied into WebPage.Configuration follow the session: every copy shares one reference-typed holder. Sendable is compiler-checked (Mutex over a Sendable value), not @unchecked.

What to look at. DocumentImageSource.swift:61-109

Takeaway. When a Sendable value type is copied into a framework configuration you cannot rebuild, hold the one mutable input by reference in a Mutex box rather than storing the value — and expose the box from the owner that took it from the handler so the two cannot diverge.
Rationale. Actor isolation was rejected because WebKit does not specify which executor calls reply(for:); a Mutex read costs nothing and needs no hop. The three values move together (all derived from one DocumentSource) so they are one Equatable value, making a partial update unobservable.

PrismDocSchemeHandler: read the context per request

prism/Services/WebRendering/PrismDocSchemeHandler.swift

Why it matters. Half one of the fix. reply(for:) snapshots the box once, so all values in one request are coherent, and serveImage's scope-root guard now runs against the live directory. The retained convenience init still defaults every parameter, so a frozen handler remains constructible by omission.

What to look at. PrismDocSchemeHandler.swift:110-184, 289-321

Takeaway. Snapshot shared state once at the top of a request rather than inside the async body: concurrent requests each take the lock exactly once and never observe a torn update.
Rationale. The primary init deliberately gives imageSource no default so a production handler must be told which box to follow; the convenience init exists for surfaces whose source cannot change (FootnotePopoverWebPage, test harnesses).

WebDocumentStateSynchronizer: image source as an observation domain

prism/ViewModels/WebDocumentStateSynchronizer.swift

Why it matters. Half two. A change writes the box then reloads the same revision; the box write precedes the reload so a declined reload is still covered by the load that declined it. The seed branch skips the reload on the first pass.

What to look at. WebDocumentStateSynchronizer.swift:80-83, 178-180, 239, 299-321

Takeaway. Adding a push domain to the synchronizer is the established way to get native truth to the page with no view mounted; diff against a lastX and act only on change, exactly like sections and table modes.
Rationale. WebKit will not re-request a failed subresource on its own, and with content JS disabled a targeted retry would need a new bridge command. A same-revision reload hits the cached HTML and restores the reading position, so the save stays invisible. The first pass runs before the initial load, where a reload would fight it.

WebDocumentController + Factory: expose the handler's own box

prism/ViewModels/WebDocumentController.swift

Why it matters. The controller does not read the box; it re-exposes it so the synchronizer can reach the page's handler. Taking it FROM the handler (not passing it alongside) is what guarantees one instance per page.

What to look at. WebDocumentController.swift:61-69, 229-231; WebDocumentControllerFactory.swift:109-118

Takeaway. Derive a re-exposed handle from the collaborator that owns it rather than threading a second parameter — the by-construction identity replaces a comment.
Rationale. @ObservationIgnored because the box is its own synchronisation and nothing in the view world reads it.

WebSavedClipboardImageSourceTests: four behavioural regression tests

prismTests/WebRendering/WebSavedClipboardImageSourceTests.swift

Why it matters. Covers the handler serving against a re-based context, the production assembly re-basing on didSave, the same-revision reload with position restore, and a guard that unrelated mutations do not reload. Follows the WebKit test rule (async tests in a @MainActor suite).

What to look at. WebSavedClipboardImageSourceTests.swift:102-224

Takeaway. Test wiring through makeAssembly plus a real didSave, not by invoking the synchronizer directly — that is the class of regression this subsystem has repeatedly suffered.
Rationale. The two halves are tested separately rather than end to end; the report argues the split is sufficient because the wiring half is the historically fragile one. (inferred — not stated by the author)

Key decisions

Hold the image context by reference (Mutex box) rather than rebuild the controller.

Rebuilding would discard readiness, the coalesced state snapshot and the reading position. A shared reference-typed holder lets the frozen struct copy follow the session. Source: bugfix report, Alternatives considered.

Same-revision reload rather than targeted image re-request.

WebKit does not retry failed subresources and content JS is disabled, so a targeted retry needs a new bridge command plus user script. reloadDocument hits the cached HTML for an unchanged parseRevision and restores scroll position. Source: commit message and inline comments.

The synchronizer owns the update, not the save flow.

It is already the single production owner pushing native truth to the page and is Observation-driven, so it needs no mounted view and no new call from ClipboardSaveFlow. Source: commit message.

Reload unconditionally on any context change, even for documents with no local images.

A once-per-document event; gating on currentCachedDocumentHTML.contains("prism-doc://img/?src=") would be cheap but couples the synchronizer to the emitted HTML shape. Not stated by the author.

(inferred — not stated by the author.)
Keep a fixed-context convenience init with the old three-parameter signature.

Avoids touching ~10 test call sites and FootnotePopoverWebPage. Its doc comment says it is for harnesses, which undersells the production use. Source: inline comment plus report line 95.

No decision-log entry in specs/webview-rendering/decision_log.md.

Precedent (T-1928, and Decision 14's near-identical iOS folder-access same-revision reload) records bugfix-born architecture in the owning spec's decision log; this change stops at the bugfix report. Open question for the author whether Decision 17 should be added.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorPrismDocSchemeHandler.swift:150-170 convenience initEvery parameter is defaulted, so PrismDocSchemeHandler(documentHTMLProvider:) still compiles into a frozen handler — the regression class this fix removes. Its comment says test-harness only, but FootnotePopoverWebPage.swift:102 is a production caller.Not fixed (read-only review). Suggest renaming to make the frozen choice explicit (e.g. init(fixedImageContext:...) or a static fixed(...)) and naming the popover as the sanctioned production user in the comment and in report.md:95.
minorWebDocumentStateSynchronizer.swift:305-320 seed sentinellastImageSource == nil / isSeed duplicates the box: the first pass always sees a context equal to the factory seed, so the branch is dead defensive code and is untested (deleting it leaves the suite green).Not fixed. Seed lastImageSource from controller.imageSource.value in init and drop the sentinel, or add a test that start() issues no navigation.
minorWebDocumentStateSynchronizer.swift:308-318 ordering commentThe box write preceding the reload is load-bearing (a declined reload is covered by the load that declined it), and the discarded reloadDocument Bool is not explained, unlike DocumentScrollContent.reloadWebDocument.Not fixed. One sentence in the comment stating the ordering invariant and why the decline is safe to swallow.
minorWebSavedClipboardImageSourceTests.swift:218 negative assertion_ = await waitUntil(timeout: .milliseconds(300)) { false } is a fixed 300 ms sleep; it cannot prove the pass ran, so a broken start() reads as green.Not fixed. Wait for session.pendingAnchorScroll == nil (the same pass consumes it) before asserting isReady; or use WebNavigationPrecedenceHarness.settle().
minorTest gaps: security and other source transitionsNo test pins traversal rejection THROUGH the box after an update (only the positive sibling read), no Save As file-to-different-folder test, no revertToClipboard test (the over-permissive direction).Not fixed. Two-line additions to handlerResolvesAgainstTheLiveContext (../ path throws; old root not served) plus one test each for the other two transitions.
minorTest duplicationwaitUntil is byte-identical to WebNavigationPrecedenceHarness.waitUntil (whose header exists to stop this); onePixelPNG duplicates WebSecurityRegressionTests:594; makeScope mirrors PrismDocSchemeHandlerTests:113; fetchImage re-implements BlockHTMLEmitter.rewriteImageSrc.Not fixed. Delegate to the harness and the emitter; consider moving the 1x1 PNG to a shared helper (third copy).
nitDocumentImageSource.swift:102-107imageBaseURL is computed twice per synchronizer pass (two Bundle lookups for bundled documents); documentDirectory always equals imageBaseURL in production; .unlocated is only used as an unused default argument.Not fixed. Bind the base URL once; optionally collapse to one directory field.
nitDocsCHANGELOG says "nothing else about the document moves" but the reload visibly re-renders (readiness drops, selection affordance resets). No Decision 17 in specs/webview-rendering/decision_log.md despite precedent; webview-rendering-status.md could note the new reload trigger.Not fixed. Optional wording tweak and decision-log entry.

Per-file diffs

Click to expand.

prism/Services/WebRendering/DocumentImageSource.swift Added +78 / -0
diff --git a/prism/Services/WebRendering/DocumentImageSource.swift b/prism/Services/WebRendering/DocumentImageSource.swiftnew file mode 100644index 00000000..c7de6637--- /dev/null+++ b/prism/Services/WebRendering/DocumentImageSource.swift@@ -0,0 +1,78 @@+//+//  DocumentImageSource.swift+//  prism+//+//  The image-resolution context the rendered document serves images against, and the+//  live holder the scheme handler reads it from (T-1784).+//+//  `PrismDocSchemeHandler` is a struct copied into the `WebPage` configuration when the+//  page is built, so anything stored IN it is frozen at that moment. A clipboard+//  document that is saved to a file changes where its images resolve from without+//  bumping the parse revision (Req 5.3 — no re-parse during the save transition), so+//  nothing rebuilds the page: the frozen copy kept resolving every relative image as a+//  clipboard document, which has no scope root, and the handler rejected them until the+//  document was closed and reopened.+//+//  The handler therefore holds the box below rather than the values: every copy of the+//  struct shares the one instance, and `WebDocumentStateSynchronizer` — which already+//  owns pushing native truth into the page — writes the session's current context into+//  it and reloads the same revision so the images that already failed are re-requested.+//++import Foundation+import Synchronization++/// What resolving an image reference needs to know about where the document lives.+///+/// The three values move together — they are all derived from one `DocumentSource` —+/// so they are carried as one value: a partial update (a new base URL against an old+/// scope root, say) is not a state the handler should be able to observe.+struct DocumentImageSourceContext: Sendable, Equatable {+    /// The scope root local image reads must canonicalise inside. `nil` for a source+    /// with no directory of its own (a clipboard paste), which rejects local files.+    var documentDirectory: URL?+    /// Drives `ImagePathResolver`'s relative/remote rules.+    var sourceType: DocumentSourceType+    /// Base URL relative image references resolve from.+    var imageBaseURL: URL?++    /// A document with no location: no scope root, nothing for a relative reference to+    /// resolve against. What a clipboard paste renders with until it is saved.+    static let unlocated = DocumentImageSourceContext(+        documentDirectory: nil, sourceType: .clipboard, imageBaseURL: nil+    )+}++/// The live holder the scheme handler reads its image context from, per request.+///+/// A reference type so the value survives being copied into the `WebPage`, and+/// `Mutex`-backed rather than actor-isolated so a read costs nothing and needs no+/// assumption about which executor WebKit calls `reply(for:)` on.+final class DocumentImageSourceBox: Sendable {+    private let storage: Mutex<DocumentImageSourceContext>++    init(_ context: DocumentImageSourceContext = .unlocated) {+        storage = Mutex(context)+    }++    /// The context in force right now.+    var value: DocumentImageSourceContext { storage.withLock { $0 } }++    /// Re-bases the page onto `context`. Called from the synchronizer's pass when the+    /// session's source changes (a clipboard save, a Save As to a new folder).+    func update(_ context: DocumentImageSourceContext) {+        storage.withLock { $0 = context }+    }+}++extension DocumentSource {+    /// This source's image-resolution context. The scope root and the base URL are the+    /// same directory: a document's images are read from where the document is.+    var imageSourceContext: DocumentImageSourceContext {+        DocumentImageSourceContext(+            documentDirectory: imageBaseURL,+            sourceType: imageSourceType,+            imageBaseURL: imageBaseURL+        )+    }+}
prism/Services/WebRendering/PrismDocSchemeHandler.swift Modified +44 / -19
diff --git a/prism/Services/WebRendering/PrismDocSchemeHandler.swift b/prism/Services/WebRendering/PrismDocSchemeHandler.swiftindex f1de4230..d2ca1ea2 100644--- a/prism/Services/WebRendering/PrismDocSchemeHandler.swift+++ b/prism/Services/WebRendering/PrismDocSchemeHandler.swift@@ -110,13 +110,15 @@ struct PrismDocSchemeHandler: URLSchemeHandler {     /// Produces the full document HTML for `/document` requests. Supplied by the     /// controller per session; absent in routing-only unit tests.     nonisolated var documentHTMLProvider: (@Sendable () -> String)?-    /// The document's directory (scope root) for local image reads. `nil` for-    /// non-file sources (clipboard, bundled with no sibling access).-    nonisolated var documentDirectory: URL?-    /// The document source type, driving ImagePathResolver's relative/remote rules.-    nonisolated var sourceType: DocumentSourceType-    /// Base URL for resolving relative image references.-    nonisolated var imageBaseURL: URL?+    /// Where the document's images resolve from — scope root, source type, and base URL+    /// — read PER REQUEST rather than captured when the page was built (T-1784).+    ///+    /// This struct is copied into the `WebPage` configuration at page-build time, so+    /// values stored here are frozen at that moment. The document's source is not:+    /// saving a clipboard paste to a file re-bases its images without bumping the parse+    /// revision, so nothing rebuilds the page. Holding the box means the copy the page+    /// owns follows the session rather than the moment it was built.+    nonisolated let imageSource: DocumentImageSourceBox     /// iOS security-scoped directory access for sibling images. Consulted (best-effort)     /// before a local read so a previously-granted folder bookmark is activated; the read     /// is never blocked on it, so images already readable keep working (T-1542, Req 3.x).@@ -126,22 +128,45 @@ struct PrismDocSchemeHandler: URLSchemeHandler {     /// only on iOS, only on a genuine read failure for a scoped local file.     nonisolated var onImageAccessNeeded: (@Sendable (URL) -> Void)? +    /// The production initializer: the image context is a live box shared with whoever+    /// owns the session's source (`WebDocumentControllerFactory` builds it, the state+    /// synchronizer re-bases it). `imageSource` carries no default — a handler serving a+    /// real document must be told which box to follow, and a silently-empty one would+    /// reject every local image.     init(         documentHTMLProvider: (@Sendable () -> String)? = nil,-        documentDirectory: URL? = nil,-        sourceType: DocumentSourceType = .file,-        imageBaseURL: URL? = nil,+        imageSource: DocumentImageSourceBox,         directoryAccessManager: DirectoryAccessManager? = nil,         onImageAccessNeeded: (@Sendable (URL) -> Void)? = nil     ) {         self.documentHTMLProvider = documentHTMLProvider-        self.documentDirectory = documentDirectory-        self.sourceType = sourceType-        self.imageBaseURL = imageBaseURL+        self.imageSource = imageSource         self.directoryAccessManager = directoryAccessManager         self.onImageAccessNeeded = onImageAccessNeeded     } +    /// A handler pinned to one fixed image context, for surfaces whose source cannot+    /// change under them (test harnesses, routing-only construction).+    init(+        documentHTMLProvider: (@Sendable () -> String)? = nil,+        documentDirectory: URL? = nil,+        sourceType: DocumentSourceType = .file,+        imageBaseURL: URL? = nil,+        directoryAccessManager: DirectoryAccessManager? = nil,+        onImageAccessNeeded: (@Sendable (URL) -> Void)? = nil+    ) {+        self.init(+            documentHTMLProvider: documentHTMLProvider,+            imageSource: DocumentImageSourceBox(DocumentImageSourceContext(+                documentDirectory: documentDirectory,+                sourceType: sourceType,+                imageBaseURL: imageBaseURL+            )),+            directoryAccessManager: directoryAccessManager,+            onImageAccessNeeded: onImageAccessNeeded+        )+    }+     // MARK: - Pure routing      /// Decodes a request URL into a route. Pure and synchronous so routing,@@ -264,9 +289,9 @@ struct PrismDocSchemeHandler: URLSchemeHandler {     func reply(for request: URLRequest) -> AsyncThrowingStream<URLSchemeTaskResult, any Error> {         let route = request.url.map(Self.route(for:)) ?? .rejected(.malformedRequest)         let documentHTMLProvider = self.documentHTMLProvider-        let documentDirectory = self.documentDirectory-        let sourceType = self.sourceType-        let imageBaseURL = self.imageBaseURL+        // Read at REQUEST time, not at page-build time: a document saved out of the+        // clipboard re-bases its images while this handler copy lives on (T-1784).+        let imageSource = self.imageSource.value         let directoryAccessManager = self.directoryAccessManager         let onImageAccessNeeded = self.onImageAccessNeeded @@ -288,9 +313,9 @@ struct PrismDocSchemeHandler: URLSchemeHandler {                         try await Self.serveImage(                             src: src, url: url,                             context: ImageServeContext(-                                documentDirectory: documentDirectory,-                                sourceType: sourceType,-                                imageBaseURL: imageBaseURL,+                                documentDirectory: imageSource.documentDirectory,+                                sourceType: imageSource.sourceType,+                                imageBaseURL: imageSource.imageBaseURL,                                 directoryAccessManager: directoryAccessManager,                                 onImageAccessNeeded: onImageAccessNeeded                             ),
prism/ViewModels/WebDocumentController.swift Modified +13 / -0
diff --git a/prism/ViewModels/WebDocumentController.swift b/prism/ViewModels/WebDocumentController.swiftindex 584b9b5a..adc93837 100644--- a/prism/ViewModels/WebDocumentController.swift+++ b/prism/ViewModels/WebDocumentController.swift@@ -58,6 +58,16 @@ final class WebDocumentController {     /// messages from a terminated process are dropped (Req 9.6).     private(set) var processGeneration: UInt64 = 0 +    /// The live image context the page's scheme handler resolves image requests+    /// against (T-1784). The controller does not read it; it re-exposes the handler's+    /// own box so `WebDocumentStateSynchronizer` can re-base the page when the+    /// session's source changes — a clipboard document saved to a file — which happens+    /// with no parse-revision bump and so rebuilds nothing.+    ///+    /// `@ObservationIgnored`: the box is its own synchronization, and nothing in the+    /// view world reads it.+    @ObservationIgnored let imageSource: DocumentImageSourceBox+     /// The generation a message must match to be accepted, and that is stamped     /// onto every outbound command.     var currentGeneration: BridgeGeneration {@@ -216,6 +226,9 @@ final class WebDocumentController {     ) {         self.sessionID = sessionID         self.parseRevision = parseRevision+        // Taken FROM the handler rather than passed alongside it, so the box this+        // exposes is by construction the one the page actually reads (T-1784).+        self.imageSource = schemeHandler.imageSource          var configuration = WebPage.Configuration()         configuration.websiteDataStore = .nonPersistent()                       // Req 8.4
prism/ViewModels/WebDocumentControllerFactory.swift Modified +7 / -3
diff --git a/prism/ViewModels/WebDocumentControllerFactory.swift b/prism/ViewModels/WebDocumentControllerFactory.swiftindex 0cd31f75..3671e039 100644--- a/prism/ViewModels/WebDocumentControllerFactory.swift+++ b/prism/ViewModels/WebDocumentControllerFactory.swift@@ -106,11 +106,15 @@ enum WebDocumentControllerFactory {             }         } +        // The image context is seeded from the session's CURRENT source and then+        // followed, not captured (T-1784): the handler struct is copied into the page,+        // but every copy shares this box, and the controller re-exposes it so the state+        // synchronizer can re-base the page when the session's source changes without a+        // parse-revision bump (a clipboard document saved to a file).+        let imageSource = DocumentImageSourceBox(session.source.imageSourceContext)         let schemeHandler = PrismDocSchemeHandler(             documentHTMLProvider: htmlProvider,-            documentDirectory: session.source.imageBaseURL,-            sourceType: session.source.imageSourceType,-            imageBaseURL: session.source.imageBaseURL,+            imageSource: imageSource,             directoryAccessManager: directoryAccessManager,             onImageAccessNeeded: onImageAccessNeeded         )
prism/ViewModels/WebDocumentStateSynchronizer.swift Modified +30 / -0
diff --git a/prism/ViewModels/WebDocumentStateSynchronizer.swift b/prism/ViewModels/WebDocumentStateSynchronizer.swiftindex e807e484..2d3138c9 100644--- a/prism/ViewModels/WebDocumentStateSynchronizer.swift+++ b/prism/ViewModels/WebDocumentStateSynchronizer.swift@@ -77,6 +77,10 @@ final class WebDocumentStateSynchronizer {     private var lastSectionCollapsedIDs: Set<String>?     private var lastDetailsOpenDOMIDs: Set<String>?     private var lastTableModes: [String: String]?+    /// The image-source context last written into the page's scheme handler (T-1784).+    /// `nil` until the first pass, which is what separates seeding it (the page has not+    /// loaded yet — nothing to re-request) from re-basing it (reload).+    private var lastImageSource: DocumentImageSourceContext?      /// The blocks→DOM id mapping and the `parseRevision` it was built for. Plain     /// stored state on a non-`@Observable` class, so writing it inside the tracked@@ -171,6 +175,9 @@ final class WebDocumentStateSynchronizer {         var sectionCollapsedIDs: Set<String>         var detailsOpenDOMIDs: Set<String>         var tableModes: [String: String]+        /// Where the page must resolve images from (T-1784). Not a bridge push: it is+        /// written into the scheme handler's live box and re-requested by a reload.+        var imageSource: DocumentImageSourceContext         var anchorTarget: String?         var noteNavigationTarget: String?         /// Navigation context, not desired state: the pass's single blocks→DOM id@@ -229,6 +236,7 @@ final class WebDocumentStateSynchronizer {             sectionCollapsedIDs: session.sections.collapsedSectionIds,             detailsOpenDOMIDs: session.expansionCoordinator.openDetailsDOMIDs,             tableModes: translatedTableModes(mapped: mapped),+            imageSource: session.source.imageSourceContext,             anchorTarget: session.pendingAnchorScroll,             noteNavigationTarget: coordinator.noteNavigationTarget,             mapped: mapped@@ -288,6 +296,28 @@ final class WebDocumentStateSynchronizer {             lastTableModes = pass.tableModes             controller.setTableModes(pass.tableModes)         }+        // Where the page resolves images from (T-1784). Saving a clipboard document+        // moves it from "nowhere" to the folder it was saved into, in place: the+        // session keeps its id and its parse output, so no reload is triggered+        // anywhere else and the page would otherwise keep serving images as an+        // unsaved paste — rejecting every relative reference — until it was closed+        // and reopened.+        if pass.imageSource != lastImageSource {+            let isSeed = lastImageSource == nil+            lastImageSource = pass.imageSource+            controller.imageSource.update(pass.imageSource)+            // The images already on screen failed against the old context and WebKit+            // will not re-request them by itself. A same-revision reload re-fetches+            // every subresource and restores the stored reading position, so the save+            // stays invisible to the reader. The first pass runs before the initial+            // load, so it has nothing to re-request — and reloading there would fight+            // the load the document surface is about to issue.+            if !isSeed {+                _ = WebDocumentControllerFactory.reloadDocument(+                    controller: controller, session: session+                )+            }+        }          // Navigation. One-shot targets are consumed and cleared; the clears         // write tracked state, costing exactly one settle pass (see above).
prismTests/WebRendering/WebSavedClipboardImageSourceTests.swift Added +225 / -0
diff --git a/prismTests/WebRendering/WebSavedClipboardImageSourceTests.swift b/prismTests/WebRendering/WebSavedClipboardImageSourceTests.swiftnew file mode 100644index 00000000..17a01529--- /dev/null+++ b/prismTests/WebRendering/WebSavedClipboardImageSourceTests.swift@@ -0,0 +1,225 @@+//+//  WebSavedClipboardImageSourceTests.swift+//  prismTests+//+//  T-1784 regression tests: a clipboard document saved to a file must resolve its+//  images against the file it now IS, without closing and reopening the document.+//+//  The bug: `PrismDocSchemeHandler` is a struct copied into the `WebPage`+//  configuration when the page is built, so the document directory, source type+//  and image base URL it was given are frozen at that moment. `didSave(to:)`+//  mutates the session's source in place and deliberately bumps no parse revision+//  (Req 5.3, no re-parse during the save transition), so nothing rebuilds the+//  page — and the frozen copy kept resolving every relative image as a clipboard+//  document, i.e. with no scope root at all, which the handler rejects.+//+//  Two halves, and both are needed:+//    1. The handler must read the context in force AT REQUEST TIME, so a saved+//       document's images resolve at all.+//    2. Something must re-request them. The images on screen already failed+//       against the old context and WebKit will not retry on its own, so the+//       state synchronizer reloads the SAME revision — the reading position is+//       restored by that path, which is what makes an in-place save invisible.+//++import Foundation+import SwiftUI+import Testing+import WebKit+@testable import prism++@MainActor+struct WebSavedClipboardImageSourceTests {++    // MARK: - Fixture++    /// A temporary directory holding one real PNG plus the path the document is+    /// saved to. The caller removes the directory.+    private struct Scope {+        let root: URL+        let document: URL+        let image: URL+    }++    private func makeScope() throws -> Scope {+        let root = FileManager.default.temporaryDirectory+            .appendingPathComponent("prism-t1784-\(UUID().uuidString)", isDirectory: true)+        try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)+        let image = root.appendingPathComponent("diagram.png")+        try Self.onePixelPNG().write(to: image)+        return Scope(root: root, document: root.appendingPathComponent("pasted.md"), image: image)+    }++    private static func onePixelPNG() -> Data {+        let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"+            + "+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="+        return Data(base64Encoded: base64) ?? Data([0x89, 0x50, 0x4E, 0x47])+    }++    private static let markdown = """+    # Pasted++    ![diagram](diagram.png)++    Some trailing prose so there is a block to hold a reading position on.+    """++    /// Drives one mediated image request through the handler exactly as the page+    /// does, collecting the served bytes.+    private func fetchImage(_ src: String, from handler: PrismDocSchemeHandler) async throws -> Data {+        var components = URLComponents()+        components.scheme = PrismDocSchemeHandler.scheme+        components.host = "img"+        components.path = "/"+        components.queryItems = [URLQueryItem(name: "src", value: src)]+        let url = try #require(components.url)+        var data = Data()+        for try await result in handler.reply(for: URLRequest(url: url)) {+            if case .data(let chunk) = result { data.append(chunk) }+        }+        return data+    }++    /// Polls the main actor until `condition` holds or the timeout elapses — the+    /// synchronizer's pass lands on a later main-actor turn.+    private func waitUntil(+        timeout: Duration = .seconds(2),+        _ condition: () -> Bool+    ) async -> Bool {+        let clock = ContinuousClock()+        let deadline = clock.now.advanced(by: timeout)+        while clock.now < deadline {+            if condition() { return true }+            await Task.yield()+            try? await Task.sleep(for: .milliseconds(10))+        }+        return condition()+    }++    // MARK: - 1. The handler reads the live context++    @Test("the scheme handler resolves an image against the context in force at request time")+    func handlerResolvesAgainstTheLiveContext() async throws {+        let scope = try makeScope()+        defer { try? FileManager.default.removeItem(at: scope.root) }++        // The page was built while the document was a clipboard paste: no scope root,+        // so a relative reference cannot resolve.+        let imageSource = DocumentImageSourceBox(DocumentSource.clipboard.imageSourceContext)+        let handler = PrismDocSchemeHandler(documentHTMLProvider: nil, imageSource: imageSource)++        await #expect(throws: (any Error).self) {+            _ = try await self.fetchImage("diagram.png", from: handler)+        }++        // The document is saved beside the image. The page is NOT rebuilt — the same+        // handler copy must now serve the sibling.+        imageSource.update(DocumentSource.file(url: scope.document).imageSourceContext)++        let served = try await fetchImage("diagram.png", from: handler)+        #expect(+            !served.isEmpty,+            "the handler kept resolving against the frozen clipboard context (T-1784)"+        )+    }++    // MARK: - 2. The production assembly re-bases and re-requests++    @Test("saving a clipboard document re-bases the rendered page's image source")+    func savingReBasesThePageImageSource() async throws {+        let scope = try makeScope()+        defer { try? FileManager.default.removeItem(at: scope.root) }++        let session = DocumentSession(clipboardContent: Self.markdown)+        await session.parseContent()+        let made = WebDocumentStateSynchronizer.makeAssembly(+            session: session,+            settings: AppSettings(),+            coordinator: DocumentLayoutCoordinator(),+            notesManager: NotesManager()+        )+        made.synchronizer.start(dynamicTypeSize: .large)++        #expect(+            made.controller.imageSource.value.sourceType == .clipboard,+            "the page starts out serving a clipboard document"+        )++        session.didSave(to: scope.document)++        #expect(+            await waitUntil { made.controller.imageSource.value.sourceType == .file },+            "the page kept resolving images as a clipboard document after the save (T-1784)"+        )+        #expect(+            made.controller.imageSource.value.imageBaseURL?.standardizedFileURL+                == scope.root.standardizedFileURL+        )+        #expect(+            made.controller.imageSource.value.documentDirectory?.standardizedFileURL+                == scope.root.standardizedFileURL,+            "without a scope root every local image is rejected before it is read"+        )+    }++    @Test("saving reloads the same revision so already-failed images are re-requested")+    func savingReloadsAndKeepsTheReadingPosition() async throws {+        let scope = try makeScope()+        defer { try? FileManager.default.removeItem(at: scope.root) }++        let session = DocumentSession(clipboardContent: Self.markdown)+        await session.parseContent()+        let made = WebDocumentStateSynchronizer.makeAssembly(+            session: session,+            settings: AppSettings(),+            coordinator: DocumentLayoutCoordinator(),+            notesManager: NotesManager()+        )+        made.synchronizer.start(dynamicTypeSize: .large)++        // The reader is part-way down the document when they save it.+        let mapped = BlockDOMID.map(blocks: session.parsedBlocks)+        let readingPosition = try #require(mapped.last?.domID)+        session.scrollPositionID = readingPosition+        let revisionBeforeSave = session.parseRevision++        session.didSave(to: scope.document)++        // The reload is what re-requests the images that already failed; it is a+        // re-fetch of the SAME revision, and it restores the reading position, so+        // the save stays invisible to the reader.+        #expect(+            await waitUntil {+                made.controller.latestSnapshot.scrollTargetBlockID == readingPosition+            },+            "no same-revision reload followed the save, so the failed images never retry (T-1784)"+        )+        #expect(session.parseRevision == revisionBeforeSave, "a save must not re-parse (Req 5.3)")+    }++    @Test("an unchanged source does not reload the document")+    func unchangedSourceDoesNotReload() async throws {+        // The guard against fixing it too hard: the synchronizer's pass runs for every+        // tracked mutation, and a reload on each one would restart the page constantly.+        let session = DocumentSession(clipboardContent: Self.markdown)+        await session.parseContent()+        let made = WebDocumentStateSynchronizer.makeAssembly(+            session: session,+            settings: AppSettings(),+            coordinator: DocumentLayoutCoordinator(),+            notesManager: NotesManager()+        )+        made.synchronizer.start(dynamicTypeSize: .large)+        made.controller.test_markReady()+        made.controller.test_markLayoutSettled()++        // A tracked mutation with nothing to do with the document's source.+        session.pendingAnchorScroll = "no-such-anchor"+        _ = await waitUntil(timeout: .milliseconds(300)) { false }++        #expect(+            made.controller.isReady,+            "an unrelated state change must not reload the page (a reload lowers readiness)"+        )+    }+}
CLAUDE.md Modified +1 / -1
diff --git a/CLAUDE.md b/CLAUDE.mdindex d549b483..8f1094ed 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -50,7 +50,7 @@ The document is rendered by WebKit-for-SwiftUI (`WebView`/`WebPage`). The SwiftU  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. `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`.+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`. The handler is a STRUCT copied into the `WebPage.Configuration`, so anything stored in it is frozen for the life of the page — which is why the image context (scope root, source type, base URL) is not stored but held by reference, in the shared `DocumentImageSourceBox` (`DocumentImageSource.swift`) it reads per request. `didSave(to:)` re-bases a clipboard document onto a file IN PLACE — same session id, same parse output, no `parseRevision` bump — so nothing rebuilds the page, and a captured context left the saved document resolving every relative image as an unsaved paste (no scope root, so all rejected) until it was closed and reopened (T-1784). `WebDocumentStateSynchronizer` owns the update: the context is a domain of its observation pass, and a change writes the box and issues a same-revision `reloadDocument` — the images already on screen failed against the old context and WebKit will not re-request them, and that reload restores the stored reading position. Its first pass only seeds (the initial load has not happened yet). 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. That termination is observed by `startNavigationObservation()`, armed from the controller's `init` — it was missing entirely until T-1943, so the whole recovery path was dead code in production. `WebPage` offers no delegate callback and no Observable property for a crash: it surfaces as `WebPage.NavigationError.webContentProcessTerminated` **thrown** by `page.navigations`, which ENDS the sequence — and which also throws for ordinary navigation failures — so the observer classifies the error (`drainNavigationStream`) and re-subscribes (`applyNavigationOutcome`), or the first failed navigation would silently disarm crash recovery for the rest of the session. A recovery reload that fails as an ORDINARY navigation is the same failure wearing a different error, so `recoveryInFlight` makes a `.navigationFailed` legible: during a recovery it is charged and retried, outside one it is a benign bad link. Recovery gives up after `maxUnproductiveRecoveries` consecutive attempts that never reach the stability milestone that resets the budget — `layoutSettled`, not merely `ready` — rather than reloading in a hot loop; `ready` alone used to reset it, so a crash landing after `ready` but before `layoutSettled` restarted the chain at attempt one every time and could reload forever without ever hitting the cap (T-2107). Giving up is neither silent nor permanent: it clears the observation task handle, raises `recoveryAbandoned` (the banner in `DocumentScrollContent`), and any fresh `load` restores the budget and re-arms observation. Because a direct-invocation test cannot see missing wiring (that is exactly how T-1943 survived the cutover and every review), the production subscription is pinned by a live test over a real `WebPage`: `WebContentTerminationWiringTests.controllerObservesItsOwnPageNavigationStream`. `WebDocumentStateSynchronizer` (T-1719) is the single production owner that pushes native truth (sections, details open-state, table modes, notes, typography, comment visibility) to the controller and routes navigation targets (TOC/fragment via `session.pendingAnchorScroll`, notes via `coordinator.noteNavigationTarget`, search current match) through `controller.scrollTo` with `BlockDOMID.navigationDOMID` id translation — Observation-framework driven, so it works with no view mounted; `DocumentScrollContent` mounts the whole assembly via `WebDocumentStateSynchronizer.makeAssembly`. Two inputs are view-fed, because both are view-world environment values: the palette, pushed via `applyTheme(themeKey:contrast:)` — the colorScheme-resolved theme key plus `colorSchemeContrast`, grouped as a `WebPaletteFeed` so a single `.onChange` pushes them together and they can never be applied out of step (T-1829) — and `dynamicTypeSize`, fed in via `start(dynamicTypeSize:)` / `applyDynamicTypeSize(_:)`, which gets NO push of its own: the synchronizer folds it into the typography domain, because `applyTypography` carries one variables dict that wholly replaces the snapshot's typography, so a second pusher would drop the settings-derived half from the recovery replay (T-1828, font-settings Decision 18). 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. Every interactive piece of that chrome is a NATIVE `<button>` or `<a href>` (T-1725) — never a `div`/`span` with `role="button"` — so the user agent supplies focusability, tab order, and Enter/Space activation, and there is no synthetic key handling to keep in sync. Those elements suppress their UA appearance, so `document.css` must reset it; the indicator dot's `font-size: 1em` is load-bearing rather than cosmetic, since the dot's whole gutter geometry is expressed in em. Accessible names are native-owned because the JS cannot reach the string catalog: the indicator's name rides the `setNoteIndicators` payload (`label`, pluralised via `NoteRenderStrings.noteIndicator`), the bubble's action label is baked in by `NoteHTMLBuilder` as visually-hidden text (an `aria-label` there would *replace* the note's own text in the accessible name), and the add-note "+" reads `<main data-prism-add-note-label>`. Both push handlers rebuild all chrome, so each control carries a `data-prism-focus-key` and `prism-notes.js` captures/restores focus around the rebuild. The restore is gated on `document.hasFocus()` — never pull focus into a document the user is not in — and a restore the gate refuses is DEFERRED as `pendingFocusKey`, not discarded, then consumed by a `window` `focus` listener when the document actually comes back (T-2059); discarding it lost the return address for every note edited through a native sheet. The shared writer, `focusOrDefer`, is called from BOTH `restoreFocusKey` and `suppressAddNote`'s "+"-to-dot handoff — the add-note "+" carries no focus key of its own (`makeAddNoteControl`), so before this the handoff had nothing to defer and lost focus outright on the more common add-a-note sequence (stand on a block's "+", tap it, save from the sheet); `focusOrDefer` falls back to the successor's OWN `data-prism-focus-key` so that path is covered too. `focusControl` verifies `document.activeElement === element` after calling `.focus()` rather than trusting the call, because a suppressed "+" stays in the DOM (`display:none`) and a no-op `.focus()` on it must not read as success. The pending key is invalidated by `bridge.onExplicitNavigation`, matching the sibling deferrals in `prism-search.js` (`revealPending`) and `prism-scroll.js` (`reflowAnchor`) — safe because a note's own bridge messages (indicator/bubble tap, block-context "+") never scroll, so the invalidation cannot cancel the restore it exists to deliver. The consuming `window` `focus` listener also refuses while `bridge.searchIsActive()` (prism-search.js, sourced from `SearchCoordinator.isSearchActive`), mirroring native's `restoreBodyFocusIfIdle` (`RegularDocumentLayout.swift`) — the key stays pending rather than discarded, so it still fires once search is no longer active. 6. **Search**: counts and navigation order stay in `SearchService`/`SearchCoordinator`. `SearchStateFeeder` translates that into a per-block `setSearchState` payload; `prism-search.js` re-finds the query in each block's rendered text and registers ranges on two named **CSS Custom Highlights** (`prism-search`, `prism-search-current`), windowed to the viewport. The web view's built-in find navigator stays disabled so Cmd+F routes to Prism's search.
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex bf353052..97304ada 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Images beside a saved-out pasted document now appear as soon as it is saved (T-1784). Saving pasted markdown into a folder left every relative image in it — `![diagram](diagram.png)` and the like — showing the error placeholder, because the rendered document went on looking for images where an unsaved paste keeps them, which is nowhere: it had no folder to read from, so each one was refused before it was read. Closing and reopening the document was the only way to see them. Saving deliberately changes the document in place, keeping its position, its notes and everything already worked out about it, and that is exactly why nothing told the rendered page where the document now lives. It is told now, and the page re-fetches its images at the same reading position, so the images simply appear where they were missing and nothing else about the document moves. - A remote document opened from a URL is no longer left downloading indefinitely against a server that trickles the body slowly enough to dodge the 30-second timeout (T-2138). The timeout applied only to network inactivity, so a byte sent just before each interval elapsed kept the load open with no end-to-end bound; the whole download is now also bounded by an explicit 30-second deadline covering redirects and streaming together. Implementing that deadline also surfaced, and fixed, a separate, pre-existing problem: accumulating the downloaded body ran on the main thread, where it is roughly 150 times slower. Measured on this project's own build, accumulating a 10 MB body takes 0.88 seconds off the main thread and 133 seconds on it, which could freeze the interface for well over a minute while opening a large document. That accumulation now genuinely runs off the main thread, so a large remote document opens in about a second instead of holding the interface still for minutes; the final UTF-8 decode of the (at most 10 MB) result still runs on the main thread afterwards, at roughly 10 milliseconds, which stays negligible. A file that really is over 10 MB is refused for its size, with the message that says so, rather than as a network timeout — with one trade-off: the new end-to-end deadline applies regardless of why a download is slow, so an honest, otherwise-successful download that used to take longer than 30 seconds to complete now fails with a timeout instead of eventually finishing. - Headings written inside a collapsible section now appear in the table of contents, on iPhone and on iPad/Mac, and choosing one opens the section it lives in before scrolling to it (T-1928). The contents list was built from a model that only ever looked at the top level of the document, so a heading inside a `<details>` block was missing from it entirely and there was no way to navigate to it — even though a separate, unused model in the app had been collecting those headings all along. A nested heading is now listed under whichever heading precedes it, marked with the same chevron the app already uses elsewhere for collapsible content, and is not itself collapsible from the contents list: the collapsible section it sits in is the thing that opens and closes. Following a link to a nested heading's anchor opens its section too, which it previously did not. A document with no collapsible sections is grouped and ordered exactly as before, with two deliberate improvements that also reach it: a heading containing a footnote marker or an HTML comment now lists with those stripped out, matching what the iPhone sheet always showed, and a heading with no text at all now reads "(Empty heading)" instead of appearing as a blank row. Fixing the navigation also uncovered a second problem in the same area, which is fixed here too: from the second collapsible section in a document onwards, the app was identifying those sections by a position that shifts as it counts through the contents of earlier ones, so it could not find them to open. Nothing had noticed because nothing had ever asked it to open one this way. Choosing a heading also no longer competes with the position the app restores when you reopen a document: only a heading you chose yourself is held briefly and re-applied once the section it lives in has opened, and any scroll, wheel flick, key press or click of your own cancels that immediately. - Documents containing HTML comments (`<!--…-->`) no longer stall while opening (T-2147). Several steps that look for comments — in a block of raw HTML, inside a link's label, and in the text Prism searches and exports — cost time in proportion to the *square* of what they were given, so a document that would otherwise open instantly could hold the app for tens of seconds. A run of comment openers with no closing `-->`, which is what a document being written, generated, or truncated mid-comment looks like, was the trigger: every opener read the whole rest of the document looking for a close before giving up. One step was worse than slow. Deciding whether a block of HTML is nothing but comments cost roughly four times as much for every two comments added, so a 145-byte document took 13 milliseconds, a 217-byte one 3.5 seconds, and a 235-byte one 22 seconds, with no upper bound beyond that — and it needed only a handful of ordinary, correctly closed comments followed by a single other character, not a malformed document at all. Removing comments from link labels had two further problems on top of the first. The entire document was rebuilt from scratch once per label carrying a comment, which cost 1.9 seconds for a 480 KB paragraph. And before either of those ran, finding the labels themselves had the same square-law shape on a `[` that is never closed: 32,000 unclosed brackets took 8.4 seconds, and an ordinary 96 KB paragraph that simply opens a few brackets without closing them took 6.2 seconds — so plain prose, not a malformed document, was enough on its own. Every one of these comment-scanning steps, and the label-finding step in front of them, now reads the document once, from left to right, and grows in step with its length rather than with its square. Because a document can be opened from a URL, a file written this way could previously have kept a device busy for a long time on someone else's behalf. Nothing about how comments are displayed changes: each replacement was checked against the exact step it replaced, character for character, over tens of thousands of generated fragments as well as hand-written awkward cases.
specs/bugfixes/clipboard-save-web-image-resolution/report.md Added +192 / -0
diff --git a/specs/bugfixes/clipboard-save-web-image-resolution/report.md b/specs/bugfixes/clipboard-save-web-image-resolution/report.mdnew file mode 100644index 00000000..aeb7d3ec--- /dev/null+++ b/specs/bugfixes/clipboard-save-web-image-resolution/report.md@@ -0,0 +1,192 @@+# Bugfix Report: Clipboard Save Keeps Web Image Resolution in Clipboard Mode++**Date:** 2026-08-29+**Status:** Fixed+**Ticket:** T-1784++## Description of the Issue++After saving a pasted (clipboard) document to a file, the rendered document kept+resolving its images as an unsaved paste. A relative image reference such as+`![diagram](diagram.png)` sitting next to the saved file stayed broken — showing the+image-error placeholder — until the document was closed and reopened.++**Reproduction steps:**++1. Paste markdown containing a relative image, e.g. `![diagram](diagram.png)`.+2. Save the clipboard document into a folder that contains `diagram.png`.+3. Stay in the document view.+4. Observe: the image is still unavailable. Close and reopen the document and it appears.++**Impact:** Medium. Every relative image in a saved-out clipboard document, on both+platforms. The document is otherwise fully functional and closing/reopening recovers, so+it is a correctness and confusion problem rather than a data-loss one.++## Investigation Summary++- **Symptoms examined:** local images rejected after a save, with the same document+  rendering them correctly when reopened from the file.+- **Code inspected:** `DocumentSession.didSave(to:)`, `DocumentSource`'s image-resolution+  extensions, `WebDocumentControllerFactory.make`, `PrismDocSchemeHandler.reply(for:)` and+  `serveImage`, `DocumentScrollContent`'s load/reload tasks,+  `WebDocumentStateSynchronizer`.+- **Hypotheses tested and ruled out:**+  - *The emitted HTML bakes the base URL.* It does not: `BlockHTMLEmitter.rewriteImageSrc`+    rewrites every non-`data:` reference to `prism-doc://img/?src=<original>` with the+    original spelling intact, so the HTML is source-independent and needs no re-emit.+  - *A reload alone would fix it.* It would not: a reload navigates the existing+    `WebPage`, whose scheme-handler copy still holds the stale context.+  - *The controller must be rebuilt.* Rebuilding would discard readiness, the coalesced+    state snapshot and the reading position — and is unnecessary once the handler follows+    the session.++## Discovered Root Cause++`PrismDocSchemeHandler` is a **struct**, copied into the `WebPage.Configuration` when the+page is built. The document directory, source type and image base URL were **stored in that+struct** (`WebDocumentControllerFactory.make`, snapshotting `session.source`), so they were+frozen at page-build time.++`DocumentSession.didSave(to:)` deliberately mutates `source` in place while preserving the+session id and the parse output (Req 4.5 / 5.3 — no re-parse during a save transition).+Nothing therefore rebuilds the page: the session-id key on `DocumentScrollContent`'s+assembly task does not change, and the load task is keyed on `parseRevision`, which does+not move either. The page went on serving images with `sourceType == .clipboard` and no+scope root, and `serveImage` rejects every local file when `documentDirectory` is `nil`.++**Defect type:** stale captured state — a value copied out of an observable model at+construction time, where the model can change afterwards.++**Why it occurred:** the WebKit cutover (T-1542) moved image mediation into a handler that+must be `Sendable` and is copied into WebKit's configuration; the source context was passed+the same way as the other construction-time inputs, which is correct for everything else in+that struct (the CSP, the asset allowlist) but not for the one input the session can change+mid-session.++**Contributing factors:** the save path is the only place a document's source changes+without a parse, so no other code path exercised the staleness.++## Resolution for the Issue++Two halves, both required:++1. **The handler follows the session.** The three context values are replaced by one+   `DocumentImageSourceBox` — a `Mutex`-backed reference type — that every copy of the+   struct shares. `reply(for:)` reads it per request instead of using values captured when+   the page was built.+2. **Something re-requests the images.** The images already on screen failed against the+   old context and WebKit does not retry them. `WebDocumentStateSynchronizer` — the single+   production owner of native→page truth, and Observation-driven so it works with no view+   mounted — now computes the session's image context in its pass, writes it into the box+   when it changes, and issues a **same-revision reload**. That reload path+   (`WebDocumentControllerFactory.reloadDocument`) restores the stored reading position, so+   the save stays invisible to the reader, and it is the same path the iOS folder-access+   grant already uses. The first pass only seeds the box — it runs before the initial load,+   so there is nothing to re-request and a reload there would fight the load the document+   surface is about to issue.++**Changes made:**++- `prism/Services/WebRendering/DocumentImageSource.swift` (new) —+  `DocumentImageSourceContext` (the three values as one, so a partial update is not+  observable), `DocumentImageSourceBox` (the live holder), and+  `DocumentSource.imageSourceContext`.+- `prism/Services/WebRendering/PrismDocSchemeHandler.swift` — holds the box instead of the+  three values; `reply(for:)` reads it per request. The old initializer is kept as a+  fixed-context convenience for harnesses whose source cannot change.+- `prism/ViewModels/WebDocumentControllerFactory.swift` — seeds the box from the session's+  current source and hands it to the handler.+- `prism/ViewModels/WebDocumentController.swift` — re-exposes `schemeHandler.imageSource`,+  taken *from* the handler rather than passed alongside it, so the box the controller+  exposes is by construction the one the page reads.+- `prism/ViewModels/WebDocumentStateSynchronizer.swift` — the context is a domain of the+  observation pass; a change re-bases the box and reloads.++**Approach rationale:** it keeps the page, its readiness, its coalesced state snapshot and+the reading position, and it puts the change where the architecture already says such+changes go (the synchronizer owns native truth reaching the page; the scheme handler stays+the single audited I/O path). The box is `Mutex`-backed rather than actor-isolated so a read+needs no assumption about which executor WebKit calls `reply(for:)` on.++**Alternatives considered:**++- **Rebuild the controller on a source change** (the ticket's suggested direction) —+  rejected: it discards readiness, the coalesced snapshot and the reading position, and+  re-runs the whole page setup for what is a two-URL change.+- **Bump `parseRevision` on save so the existing reload path fires** — rejected: it+  re-parses a document whose content did not change, contradicting Req 5.3, and would still+  not re-base the frozen handler.+- **A `@Sendable` closure over the session instead of a box** — rejected: reading+  `session.source` from the handler needs `MainActor.assumeIsolated`, which traps if WebKit+  ever calls `reply(for:)` off the main thread. The box has no such dependency.+- **A targeted "retry failed images" bridge message instead of a reload** — rejected: more+  JS and a new message for no gain over the reload the project already uses for exactly this+  (the folder-access grant), which additionally restores the reading position.++## Regression Test++**Test file:** `prismTests/WebRendering/WebSavedClipboardImageSourceTests.swift`++| Test | What it verifies |+|------|------------------|+| `handlerResolvesAgainstTheLiveContext()` | The handler serves a sibling image after the context is re-based, with no page rebuild — the mechanism half. |+| `savingReBasesThePageImageSource()` | Through the real production assembly: `didSave` moves the page's context from `.clipboard` to the saved file's folder (scope root and base URL both). |+| `savingReloadsAndKeepsTheReadingPosition()` | A same-revision reload follows the save (so the failed images re-request) and the stored reading position is restored; `parseRevision` does not move. |+| `unchangedSourceDoesNotReload()` | The guard against over-fixing: an unrelated tracked mutation does not reload the page. |++**Red/green check:** with the synchronizer's re-base branch disabled, the two+production-assembly tests fail and the other two pass — the expected split, since the first+is a unit pin of the new mechanism and the last is the over-fix guard.++**Run command:**++```bash+xcodebuild test -project prism.xcodeproj -scheme prism -destination 'platform=macOS' \+  -only-testing:prismTests/WebSavedClipboardImageSourceTests test+```++## Affected Files++| File | Change |+|------|--------|+| `prism/Services/WebRendering/DocumentImageSource.swift` | New: the context value, the live box, and the `DocumentSource` derivation |+| `prism/Services/WebRendering/PrismDocSchemeHandler.swift` | Holds the box; reads it per request |+| `prism/ViewModels/WebDocumentControllerFactory.swift` | Seeds the box and hands it to the handler |+| `prism/ViewModels/WebDocumentController.swift` | Re-exposes the handler's box |+| `prism/ViewModels/WebDocumentStateSynchronizer.swift` | Re-bases the box and reloads on a source change |+| `prismTests/WebRendering/WebSavedClipboardImageSourceTests.swift` | New regression tests |+| `CLAUDE.md`, `CHANGELOG.md` | Documentation |++## Verification++**Automated:**++- [x] Regression tests pass (and are red without the fix)+- [x] `make build-macos` — clean+- [x] `make lint` — 0 violations+- [x] `make verify-test-isolation` — OK+- [x] Related suites pass when run targeted: `WebStateSynchronizerAssemblyTests`,+      `WebReloadNavigationClaimTests`, `WebMediaBehaviourTests`,+      `WebContentTerminationWiringTests`, plus the live note/selection/details/theme suites+      (120 tests, all passing)+- [ ] `make test-quick` in full: the run reported failures confined to live-`WebPage`+      suites, every one a 43–53 second timeout or a host-abort cascade, with the count+      varying between runs (319, then 121). Other fix agents were running in parallel on+      the same machine; each affected suite passes when run targeted.++## Prevention++- A value copied into WebKit's configuration is frozen for the life of the page. Anything+  in it that the session can change must be held by reference, the way the HTML provider and+  the process-generation holder already are.+- The rule of thumb this belongs to: `didSave(to:)` mutates the session *in place* by+  design, so any surface that snapshots something derived from `session.source` at+  construction time has this bug. Prefer deriving at use time, or following a live holder.++## Related++- Ticket: T-1784+- The same class of frozen-at-build-time state: `WebDocumentControllerFactory`'s+  `WeakControllerBox`, which exists so the HTML provider can read the controller's live+  process generation.+- Spec: `specs/webview-rendering/`

Things to double-check

Announced-then-abandoned load during a save.

If the reload is declined by isSuperseded and the announcing load is later abandoned, the image domain stays marked clean and nothing re-fetches. Very narrow; worth confirming it is acceptable rather than restructuring.

Convenience init at future production call sites.

Any new surface that constructs PrismDocSchemeHandler without imageSource: silently gets a frozen context. Watch for this in review until the init is renamed.

Visible reload on save.

The reload happens right as the save sheet dismisses; readiness drops and the coalesced snapshot replays. Worth a manual check on both platforms that the transition reads as intended.