prism branch T-1852/bugfix-selection-affordance-reload commits 3 (2 fix + 1 editorial) files 10 touched (6 prod/docs, 4 test) lines +493 / -13 targeted tests 233 passed / 0 failed builds iOS + macOS succeeded

Pre-push review: T-1852/bugfix-selection-affordance-reload

The native Add Note affordance is page-scoped state held natively, so a document reload used to leave it floating over content it no longer describes. This branch clears it on the navigation path, gates pre-ready selectionCandidate messages so the outgoing page cannot re-arm it mid-load, and has each fresh page publish an initial cleared as defence in depth. PR #344.

At a glance

  • Three layers, each doing distinct work. The native clear removes the stale overlay; the readiness gate stops the outgoing page from putting it back during the load; the incoming page's initial cleared is belt-and-braces from the one poster whose generation native will actually accept.
  • The gate's soundness now has a written premise. prism-bridge.js posts ready exactly once per script evaluation — that is the reason an already-ready, still-generation-matched outgoing page cannot re-raise isReady after the reset. Previously relied on, documented nowhere; now stated at the source and referenced from the gate.
  • Wiring is pinned in both halves. makeAssembly hands the affordance to the controller as well as the router, and a source-structural test pins DocumentScrollContent actually passing selectionAffordance: — the parameter defaults to nil, so dropping it would compile and leave every behavioural test green (the T-1719 regression class).
  • The snapshot exclusion is defended by shape, not by behaviour. snapshotCarriesNoSelectionState mirrors WebDocumentStateSnapshot's fields and fails the moment one is added, with a message explaining why a selection must never be replayable. A behavioural "flush, then assert cleared" test would have passed by construction.
  • Sibling PRs are disjoint. #345 and #346 both touch prism-notes.js, but only within lines 21–230; this branch edits the selection IIFE tail (~483–507). Neither sibling changes the user-script order in WebDocumentControllerFactory.userScripts() that the gate's ordering argument depends on.
  • One residual behaviour worth knowing (not a defect): if ready never arrives, selection candidates are now dropped forever rather than working on an otherwise-broken page. Such a page is already non-functional — every outbound command queues indefinitely, so theme, notes, and scroll restore never apply.

Verdict

Ready to push

The fix is correct at the level it claims. The clear sits at resetForNavigation() — the single choke point load and handleProcessTermination share, and the only two page.load(…) call sites in the controller — so it covers re-parse reloads, the same-revision iOS folder-access retry, and WebContent recovery with no fourth path to miss. The readiness gate closes the re-arm window rather than narrowing it, and its load-bearing premise (bridge.js posts ready exactly once per page evaluation) is now written down at its source. The affordance is deliberately kept out of WebDocumentStateSnapshot, and that decision is pinned by a shape test that fails when a field is added.

Verification: SwiftLint 0 violations across 513 files; 233 targeted tests green (5 selection/router/security suites plus all 15 suites that depend on the modified live harness); iOS and macOS builds succeed. The only build warning (ImageDimension main-actor Equatable conformance) is pre-existing on main and untouched by this branch. Two comment-only editorial fixes were applied and committed; no other production changes were made.

Review findings

6 raised · 2 fixed · 4 skipped

Jump to findings →

Commits

Three-level explanation

What changed

When you drag across text in Prism, a small Add Note button appears next to your selection. That button is drawn by the app itself, sitting on top of the rendered document rather than inside it.

If the document reloaded while text was selected — because the file changed on disk, because you refreshed a URL document, or because you granted access to a folder of images — the page underneath was replaced and the selection disappeared with it, but the button stayed exactly where it was. Tapping it opened the note editor quoting text you were no longer looking at, or, if the reload had shuffled the content, text from a completely different part of the document.

Why it matters

A note that quotes the wrong text is worse than no note: the wrong quote is saved, and it follows the note into relocation and into exported files.

How it was fixed

  • The moment a reload starts, the app takes the button away itself.
  • While the reload is in progress, the app ignores anything the outgoing page says about selections — so dragging over text on a page that is about to vanish cannot bring the old button back.
  • Each freshly loaded page then confirms for itself that it has no selection, as a second line of defence.

The shape of the bug

The selection affordance is native-only state: WebSelectionAffordanceState is written exclusively from inbound selectionCandidate bridge messages and read by a SwiftUI overlay above the WebView. It therefore outlives the page it describes. Nothing on the navigation path cleared it.

Why the obvious fix cannot work

The natural instinct is to have the outgoing page post cleared on pagehide/unload. That message is guaranteed to be dropped: load(documentURL:parseRevision:) assigns the new parseRevision before navigating, and BridgeMessageRouter requires an exact generation match. The outgoing page's post is stale by construction.

The three layers

  1. Native clear in resetForNavigation() — the single choke point load and handleProcessTermination share. Synchronous, so there is no window between the decision to navigate and the overlay going away.
  2. Readiness gate in WebDocumentController.handle: selectionCandidate is dropped while !isReady. Clearing alone is insufficient — on a same-revision reload (the iOS folder-access retry) the outgoing page stays interactive and generation-matched for the whole load, so a selection made during the load was accepted and the stale overlay came straight back.
  3. Incoming page's initial cleared in prism-notes.js, deferred one timer turn (the handler channel is not reliably live at injection) and guarded on lastCandidateKey === null so it seeds the dedup key rather than asserting a state — an unconditional post would wipe a candidate that resolved first.

Trade-offs recorded in the code

The affordance is deliberately excluded from WebDocumentStateSnapshot. That snapshot is replayable native truth — every field is re-pushed verbatim onto the page that replaced the one the value was computed for. A selection is the user pointing at one rendered page; replaying it after a crash recovery is the same bug in different clothing.

Ordering argument

The gate is only as good as the claim that the incoming page's ready precedes anything else it posts, and that the outgoing page cannot re-raise isReady. Both halves now hold explicitly:

  • Within a page: WebDocumentControllerFactory.userScripts() injects prism-bridge before prism-notes, both at .atDocumentEnd; equal-delay timers fire in registration order, so bridge's setTimeout(ready, 0) precedes notes' setTimeout(cleared, 0). WKScriptMessage delivery preserves post order, so isReady is true by the time the initial cleared is routed.
  • Across pages: bridge.js posts ready exactly once per script evaluation — one unconditional timer at injection, no re-post path. The outgoing page has already spent its ready, so on a same-revision reload (where it stays generation-matched) it cannot re-open the gate. This premise was load-bearing and undocumented; this branch's editorial commit pins it in prism-bridge.js and references it from the gate.

Layering

The gate lives in the controller, not in BridgeMessageRouter. That is the right seam: the router is a value type whose job is generation + allowlist validation and which has no notion of readiness, and the decode verdict stays .accepted — the tests assert exactly that, so the router's honesty is not sacrificed to a lifecycle concern. The cost is that the gate returns before the DEBUG receivedMessages append, which made that property's doc comment ("every accepted message") false; corrected in the editorial commit.

Coverage design

Two invariants here cannot be reached behaviourally, and the branch pins both structurally rather than writing tests that pass by construction:

  • snapshotCarriesNoSelectionState mirrors the snapshot's stored properties against an expected list. A "replay then assert still cleared" test would pass trivially, because a unit test with no live page can never observe the flush re-arming an affordance that is only written from inbound messages.
  • scrollContentPassesAffordanceToAssembly parses DocumentScrollContent.swift with paren balancing and asserts the selectionAffordance: argument is present. The parameter defaults to nil, so omitting it compiles and disconnects the overlay from both router and controller with no behavioural failure — matching the existing FootnotePresentationHostTests / #filePath precedent in this repo.

Live-test consequence

"The first selectionCandidate" is no longer "the candidate under test", so the harness grows waitForMessage(type:where:) and waitForSelectionCandidate(state:), and existing call sites were migrated to #require so a regression surfaces as a state mismatch instead of a 2-second poll returning nil. The new injectedScriptSource seam injects between bridge.js and the feature scripts — the only deterministic way to occupy the window between ready and notes.js's deferred cleared, which is what makes the order-independence guard testable rather than asserted.

Residual

A page whose ready never arrives now loses the selection affordance permanently. Acceptable: such a page has already lost theme, note indicators, search state, and scroll restore, since every outbound command queues on the same flag.

Important changes — detailed

WebDocumentController: clear the affordance on the navigation path

WebDocumentController.swift

Why it matters. This is the fix proper. Placing it in resetForNavigation() — rather than in load() — is what makes it total: load and handleProcessTermination are the only two page.load(…) call sites in the controller, and both funnel through it, so re-parse reloads, the same-revision folder-access retry, and WebContent recovery are covered by one line with no fourth path to forget.

What to look at. WebDocumentController.swift:resetForNavigation() — selectionAffordance?.clear()

Takeaway. When native state describes a resource with a shorter lifetime than itself, clear it at the lifetime boundary, not by asking the dying resource to announce its own death. The announcement is exactly what the staleness check will discard.
Rationale. A late `cleared` from the outgoing page cannot be relied on: load() assigns the new parseRevision before navigating, so BridgeMessageRouter's exact-generation match drops it. The clear has to be native, synchronous, and on the navigation path.

WebDocumentController.handle: gate selectionCandidate on readiness

WebDocumentController.swift

Why it matters. The clear alone leaves a re-arm window. On a same-revision reload the outgoing page keeps a matching generation for the entire load, so a selection made during the load was accepted and put the stale overlay straight back. The gate closes the window instead of narrowing it, because resetForNavigation lowers isReady synchronously and only the incoming page's ready raises it.

What to look at. WebDocumentController.swift:handle — `if case .selectionCandidate = message, !isReady { return }`

Takeaway. Keep the lifecycle gate out of the value-type validator. The decode verdict stays `.accepted` — the router's generation + allowlist judgement remains honest — and readiness, which the router has no notion of, is enforced by the object that knows about navigations.
Rationale. Stated in the code comment and now backed by the once-per-page-evaluation `ready` invariant pinned in prism-bridge.js: the outgoing page has already spent its ready and cannot re-raise isReady after the reset.

prism-notes.js: initial `cleared` from each fresh page, guarded on the dedup key

prism-notes.js

Why it matters. Defence in depth from the one poster whose generation native will accept — the incoming page, stamped with the generation island the scheme handler embedded. The `lastCandidateKey === null` guard is what makes it safe: unconditional, it would wipe a candidate that resolved first, i.e. yank the Add Note button out from under a live selection.

What to look at. prism-notes.js:483-507 (selection IIFE tail)

Takeaway. A deferred "announce the initial state" post should seed the dedup key, not assert the state. Written as a guarded seed it is order-independent; written as an assertion it is a race that usually wins.
Rationale. Deferred one timer turn for the same reason bridge.js defers `ready` — the message-handler channel is not reliably live at the exact injection instant, so a post issued during injection is silently dropped.

WebDocumentStateSynchronizer.makeAssembly: wire the affordance to the controller too

WebDocumentStateSynchronizer.swift

Why it matters. Without this line the clear exists and never fires in production, with every unit test still green — the T-1719 wiring regression class. Both halves (router writes it, controller clears it) must be wired at the same seam.

What to look at. WebDocumentStateSynchronizer.swift:399

Takeaway. Wiring that is optional at the call site (a defaulted `nil` parameter) needs a structural pin, not a behavioural one: omitting the argument compiles and every behavioural assertion keeps passing.
Rationale. Explicit in the added comment; matched by the two pinning tests (assemblyWiresAffordanceToController and scrollContentPassesAffordanceToAssembly).

snapshotCarriesNoSelectionState: pin the snapshot's shape, not a behaviour

WebSelectionNoteTests.swift

Why it matters. The reason the recovery replay is safe is that WebDocumentStateSnapshot has no selection field — not anything observable. A "replay, then assert still cleared" test passes by construction and would keep passing after someone added one. This mirrors the snapshot's fields and fails when the set changes, with a message explaining the decision to re-read.

What to look at. WebSelectionNoteTests.swift — snapshotCarriesNoSelectionState()

Takeaway. When an invariant is upheld by a type's shape rather than by its behaviour, assert the shape. A behavioural test aimed at a shape invariant is a test that cannot fail.
Rationale. Spelled out in the test's own doc comment: every snapshot field is re-pushed verbatim onto the page that replaced the one it was computed for, so a selection in there resurrects exactly this bug.

Editorial: pin the once-per-page `ready` invariant and correct receivedMessages

prism-bridge.js

Why it matters. The gate's correctness rests on the outgoing page being unable to re-raise isReady. That holds only because bridge.js posts `ready` exactly once per script evaluation — relied on, documented nowhere. Separately, the gate returns before the DEBUG append, so `receivedMessages`' "every accepted message" was no longer true.

What to look at. prism-bridge.js:252-268; WebDocumentController.swift:146-152 and the gate comment

Takeaway. Document a load-bearing invariant at the place that could break it, not only at the place that consumes it. The consumer's comment is where you learn the invariant matters; the producer's is where someone about to violate it is actually reading.
Rationale. Sanctioned by the round-2 review as the two remaining comment-grade items; T-1878 is expected to lean on the same `ready` premise.

Key decisions

Clear on the navigation path, not via an outgoing-page message.

load assigns the new parseRevision before navigating, so BridgeMessageRouter's exact-generation match drops anything the old page posts afterwards. A page-side cleared is unreachable by construction, not merely unreliable.

The gate lives in the controller, and acceptance stays <code>.accepted</code>.

Readiness is a lifecycle property of the controller; the router is a value type validating generation + allowlist. Folding readiness into the router would make the decode verdict lie about a message that genuinely passed validation. The controller drops it from routing instead, which the tests assert explicitly.

The affordance is excluded from <code>WebDocumentStateSnapshot</code>.

The snapshot is replayable native truth, re-pushed verbatim after a reload or WebContent crash. A selection describes one rendered page, so replaying it is the same stale-overlay bug. Pinned by a Mirror-based shape test rather than a behavioural one.

The incoming page's initial <code>cleared</code> is a guarded seed, not an assertion.

lastCandidateKey === null makes the post order-independent. A programmatic selection resolving inside the first timer turn (which the live tests do) has already set the key, and an unconditional post would wipe a live candidate off the native overlay.

Two invariants are pinned structurally (Mirror over the snapshot, source parse of <code>DocumentScrollContent</code>).

Unusual, and justified in both test doc comments: each invariant is upheld by shape or by a call site, so a behavioural test would pass by construction. The repo already has this precedent in FootnotePresentationHostTests and ParityFixtureSupport.

No <code>specs/bugfixes/</code> report folder was created.

The record lives in the CHANGELOG entry and a dense docs/agent-notes/webview-rendering-status.md bullet instead. Consistent with recent practice — only one of the last fifteen commits added a bugfix report — and the agent-note carries the rejected approach and the ordering argument, which is the part a future session would otherwise re-derive.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorprism-bridge.js / WebDocumentController.handleThe readiness gate is sound only because `ready` is posted exactly once per page evaluation — otherwise an already-ready, still-generation-matched outgoing page could re-raise `isReady` mid-load and re-open the re-arm window. The premise was relied on but written down nowhere, and T-1878 is expected to lean on it.Stated as an explicit INVARIANT block at the `ready` post in prism-bridge.js (the place someone about to violate it is reading) and referenced from the gate's comment in WebDocumentController.handle. Comment-only.
minorWebDocumentController.receivedMessagesThe doc comment claimed the array records "every accepted message". Since the gate returns before the DEBUG append, a pre-ready `selectionCandidate` is accepted by the router yet absent from the log — a reader debugging with this array would draw the wrong conclusion about what the router did.Reworded to "every ROUTED message", naming the gate as the exception, pointing at `receive`'s return value for router verdicts, and noting the array is cleared per navigation. Comment-only.
minorWebDocumentController.handle (readiness gate)A page whose `ready` never arrives now drops selection candidates permanently, where previously selection still worked. Behavioural narrowing on a failure path.Not addressed — such a page is already non-functional: every outbound command queues on the same flag, so theme, note indicators, search state, and scroll restore never apply. Recorded here rather than guarded against.
minorspecs/bugfixes/No bugfix report folder for T-1852, where the fix-bug workflow would normally produce one.Not addressed — recent practice is inconsistent (1 of the last 15 commits), and the CHANGELOG entry plus the agent-note bullet capture the rejected approach and the ordering argument, which is the reusable part.
infoBuildsBoth builds emit one warning: `main actor-isolated conformance of 'ImageDimension' to 'Equatable' cannot be used in nonisolated context`.Pre-existing on main and in MarkdownBlock.swift, which this branch does not touch. Not introduced here.
infoSibling PRs #345 / #346Both sibling PRs also modify prism-notes.js and one modifies WebDocumentControllerFactory.Verified disjoint: sibling hunks fall within prism-notes.js lines 21-230, this branch edits ~483-507; #346's factory change adds accessibility strings and does not touch the userScripts() ordering the gate's argument depends on. Flagged for awareness at merge time, not resolved here.

Per-file diffs

Click to expand.

prism/ViewModels/WebDocumentController.swift Modified +63 / -1
diff --git a/prism/ViewModels/WebDocumentController.swift b/prism/ViewModels/WebDocumentController.swiftindex e186f39..e50b1c0 100644--- a/prism/ViewModels/WebDocumentController.swift+++ b/prism/ViewModels/WebDocumentController.swift@@ -125,7 +125,31 @@ final class WebDocumentController {     /// integrating view; nil in unit tests that inspect state directly.     var onMessage: ((InboundBridgeMessage) -> Void)? -    /// Records every accepted message for tests/diagnostics in DEBUG.+    /// The native selection "Add note" affordance for this surface (Req 12.2), held+    /// so it can be cleared the instant a navigation starts (T-1852).+    ///+    /// The affordance is native-only state written from `selectionCandidate`+    /// messages, and it outlives the page it describes: a reload replaces the DOM+    /// (so the in-page selection is gone) while the native overlay keeps the old+    /// block id, range, and screen rect. A late `cleared` from the outgoing page+    /// cannot fix that — `load` sets the new parse revision BEFORE navigating, so+    /// that message fails the router's exact-generation match and is dropped. The+    /// clear therefore has to be native, synchronous, and on the navigation path+    /// itself (`resetForNavigation`), which is also why it belongs here rather than+    /// in the message router: the controller is the single thing that knows a+    /// navigation is starting, for BOTH `load` and WebContent-crash recovery.+    ///+    /// Set by `WebDocumentStateSynchronizer.makeAssembly` alongside the router's+    /// reference; nil in tests that do not exercise the native overlay.+    @ObservationIgnored var selectionAffordance: WebSelectionAffordanceState?++    /// Records every ROUTED message for tests/diagnostics in DEBUG — i.e. every+    /// message `handle` forwards to `onMessage`. That is not quite "every accepted+    /// message": a `selectionCandidate` arriving before the page reports ready is+    /// accepted by the router but dropped by the readiness gate in `handle`+    /// (T-1852), so it appears in neither this log nor `onMessage`. Tests asserting+    /// on a router verdict should read `receive`'s return value, not this array.+    /// Cleared by `resetForNavigation`, so it spans one navigation, not the session.     #if DEBUG     private(set) var receivedMessages: [InboundBridgeMessage] = []     #endif@@ -208,6 +232,37 @@ final class WebDocumentController {      /// Applies an accepted message's readiness effect and forwards it.     private func handle(_ message: InboundBridgeMessage) {+        // A selection belongs to a page that has reported ready, so anything the+        // outgoing page says during a navigation is not native truth (T-1852).+        // `resetForNavigation` lowers `isReady` synchronously at the start of the+        // load and only the INCOMING page's `ready` raises it again, so this closes+        // the whole reload window — including the same-revision (folder-access+        // retry) path, where the outgoing page stays interactive and generation-+        // matched for the entire load and could otherwise re-arm the overlay at its+        // own rect after the clear.+        //+        // "Only the INCOMING page's `ready`" is the load-bearing premise, and it is+        // pinned at its source: prism-bridge.js posts `ready` EXACTLY ONCE per+        // evaluation of the script (a single unconditional `setTimeout` registered+        // at injection, no re-post path), so the outgoing page — already ready,+        // already generation-matched — cannot raise `isReady` again after the reset.+        // If that ever stops holding, this gate degrades from closing the window to+        // merely narrowing it.+        //+        // Safe in both directions. Nothing legitimate is lost: a genuine candidate+        // needs user interaction on a rendered page, and the incoming page's own+        // `ready` is posted from a timer prism-bridge.js registers before+        // prism-notes.js runs (script order in `WebDocumentControllerFactory+        // .userScripts()`; both inject at `.atDocumentEnd`, and equal-delay timers+        // fire in registration order), so `ready` is delivered first and the fresh+        // page's initial `cleared` still lands. Even if that `cleared` were dropped,+        // `resetForNavigation` has already cleared the affordance.+        //+        // The decode result stays `.accepted`: acceptance is the router's+        // generation + allowlist verdict, which this message genuinely passes. This+        // is a lifecycle gate on routing, deliberately kept out of the value-type+        // router, which has no notion of readiness.+        if case .selectionCandidate = message, !isReady { return }         switch message {         case .ready:             markReady()@@ -541,6 +596,13 @@ final class WebDocumentController {         isReady = false         isLayoutSettled = false         pendingCommands.removeAll()+        // The selection the native "Add note" overlay describes belongs to the page+        // being replaced, so it dies with it (T-1852). Deliberately NOT part of the+        // coalesced snapshot: `WebDocumentStateSnapshot` holds replayable native+        // truth, and a selection is the user's transient pointing at one rendered+        // page — replaying it after a reload or a WebContent crash is exactly the+        // stale-overlay bug. Keep it out of the snapshot.+        selectionAffordance?.clear()         #if DEBUG         receivedMessages.removeAll()         #endif
prism/ViewModels/WebDocumentStateSynchronizer.swift Modified +6 / -0
diff --git a/prism/ViewModels/WebDocumentStateSynchronizer.swift b/prism/ViewModels/WebDocumentStateSynchronizer.swiftindex b4c12db..e807e48 100644--- a/prism/ViewModels/WebDocumentStateSynchronizer.swift+++ b/prism/ViewModels/WebDocumentStateSynchronizer.swift@@ -391,6 +391,12 @@ final class WebDocumentStateSynchronizer {         )         router.selectionAffordance = selectionAffordance         router.presentSelectionDeclined = presentSelectionDeclined+        // The controller gets the same reference so it can clear the overlay the+        // instant a navigation starts (T-1852) — the router only ever writes it+        // from inbound messages, and the outgoing page's `cleared` is dropped as+        // stale. Both halves must be wired here or the clear never fires in+        // production (the T-1719 wiring regression class).+        controller.selectionAffordance = selectionAffordance         let synchronizer = WebDocumentStateSynchronizer(             controller: controller,             session: session,
prism/Resources/WebRenderer/prism-notes.js Modified +26 / -0
diff --git a/prism/Resources/WebRenderer/prism-notes.js b/prism/Resources/WebRenderer/prism-notes.jsindex d912029..f377316 100644--- a/prism/Resources/WebRenderer/prism-notes.js+++ b/prism/Resources/WebRenderer/prism-notes.js@@ -480,6 +480,32 @@      document.addEventListener("selectionchange", handleSelectionChange, false); +    // A fresh page has no selection, so announce that once at load (T-1852). This is+    // defence in depth behind the native clear in WebDocumentController: the overlay+    // is native state that outlives the page it describes, and the OUTGOING page's+    // "cleared" is dropped by native's exact-generation match (load sets the new parse+    // revision before navigating). This post is the incoming page's, so it carries the+    // generation native embedded in the served document — the one native now expects —+    // and passes the allowlist.+    //+    // Deferred one timer turn for the same reason prism-bridge.js defers its `ready`:+    // the message-handler channel is not reliably live at the exact injection instant,+    // so a post issued during injection is silently dropped. This also puts it after+    // `ready`, and it seeds `lastCandidateKey`, so the first real selectionchange that+    // resolves to nothing does not re-post the same cleared state.+    //+    // Guarded on `lastCandidateKey === null` so the post is order-INDEPENDENT: this is+    // "seed the dedup key if nothing has reported yet", not "assert cleared and hope we+    // got here first". A selection resolved before this timer turn (a programmatic one —+    // no human selects inside one timer turn of documentEnd, but the live tests do) has+    // already set the key, and an unconditional post would wipe that live candidate off+    // the native overlay.+    setTimeout(function () {+        if (lastCandidateKey === null) {+            postSelectionCandidate({ state: "cleared" });+        }+    }, 0);+     // Expose resolution for tests (bridge world only; not reachable from page world).     bridge.resolveSelectionRange = resolveSelectionRange; })();
prism/Resources/WebRenderer/prism-bridge.js Modified (editorial) +9 / -0
diff --git a/prism/Resources/WebRenderer/prism-bridge.js b/prism/Resources/WebRenderer/prism-bridge.jsindex 823ce88..dd26d10 100644--- a/prism/Resources/WebRenderer/prism-bridge.js+++ b/prism/Resources/WebRenderer/prism-bridge.js@@ -255,6 +255,15 @@     // timer turn: timers fire regardless of whether the page repaints, so this is     // reliable even for an inert, offscreen document (requestAnimationFrame may not     // fire for a page that never paints).+    //+    // INVARIANT, relied on by native: `ready` is posted EXACTLY ONCE per evaluation+    // of this script, i.e. once per page load. There is no re-post path — the timer+    // is registered unconditionally at injection and never re-armed. Native's+    // readiness gate on `selectionCandidate` (T-1852) rests on this: a navigation+    // lowers `isReady`, and the OUTGOING page — which stays interactive and, on a+    // same-revision reload, generation-matched for the whole load — must not be able+    // to raise it again. Only the incoming page's evaluation posts a new `ready`.+    // Anything added here that could post `ready` a second time re-opens that window.     setTimeout(function () { post("ready"); }, 0);      // Fallback: if no feature script reports layoutSettled (e.g. a document with
prismTests/WebRendering/WebSelectionNoteTests.swift Modified +266 / -4
diff --git a/prismTests/WebRendering/WebSelectionNoteTests.swift b/prismTests/WebRendering/WebSelectionNoteTests.swiftindex 58ce036..e7774da 100644--- a/prismTests/WebRendering/WebSelectionNoteTests.swift+++ b/prismTests/WebRendering/WebSelectionNoteTests.swift@@ -459,6 +459,262 @@ struct WebSelectionNoteTests {         #expect(resolved?["start"] == nil)     } +    // MARK: - Affordance lifecycle across a navigation (T-1852)+    //+    // The native "Add note" overlay is native-only state written from+    // `selectionCandidate` messages. A reload replaces the page (so the in-page+    // selection is gone) but nothing used to clear the native state, leaving the+    // button floating at the old rect with the old block id + range. The clear+    // must be SYNCHRONOUS at the point a navigation starts — a late `cleared`+    // from the outgoing page cannot be relied on: `load` bumps the expected parse+    // revision before navigating, so that message is dropped as stale.++    /// A live, actionable affordance for a block that the reload will invalidate.+    private func armedAffordance() -> WebSelectionAffordanceState {+        let affordance = WebSelectionAffordanceState()+        affordance.apply(+            state: .available,+            blockID: "b-stale-0",+            range: .init(start: 4, length: 5),+            rect: .init(x: 10, y: 20, width: 60, height: 18)+        )+        return affordance+    }++    private func makeController(+        sessionID: String = "t1852",+        parseRevision: UInt64 = 1+    ) -> WebDocumentController {+        WebDocumentController(+            sessionID: sessionID,+            parseRevision: parseRevision,+            schemeHandler: PrismDocSchemeHandler()+        )+    }++    private func documentURL(revision: UInt64) -> URL {+        URL(string: "prism-doc://document/t1852?rev=\(revision)")!+    }++    @Test("A reload for a new parse revision clears the selection affordance")+    func reloadClearsSelectionAffordance() {+        let affordance = armedAffordance()+        #expect(affordance.canAddNote == true)++        let controller = makeController()+        controller.selectionAffordance = affordance+        controller.load(documentURL: documentURL(revision: 2), parseRevision: 2)++        // The new page has no selection, so the native overlay must be gone with it.+        #expect(affordance.canAddNote == false)+        #expect(affordance.rect == nil)+        #expect(affordance.pendingNote == nil)+    }++    @Test("A same-revision reload (image-access retry) clears the selection affordance")+    func sameRevisionReloadClearsSelectionAffordance() {+        // The iOS grant-folder-access path reloads at the SAME revision. The page is+        // still replaced, so the affordance is just as stale as after a re-parse.+        let affordance = armedAffordance()+        let controller = makeController(parseRevision: 3)+        controller.selectionAffordance = affordance+        controller.load(documentURL: documentURL(revision: 3), parseRevision: 3)+        #expect(affordance.canAddNote == false)+    }++    @Test("WebContent process recovery clears the selection affordance")+    func processRecoveryClearsSelectionAffordance() {+        let affordance = armedAffordance()+        let controller = makeController()+        controller.selectionAffordance = affordance+        // Push replayable native truth so the recovery replay has a full snapshot.+        controller.applyTheme(theme: "prism-dark", contrast: .standard, variables: [:])+        controller.setNoteIndicators(json: "[]")+        controller.scrollTo(blockID: "b-stale-0")++        controller.handleProcessTermination(documentURL: documentURL(revision: 1))+        #expect(affordance.canAddNote == false)+        #expect(affordance.rect == nil)+    }++    /// Pins the reason the recovery replay is safe, which no behavioural assertion+    /// can: the affordance is only ever written from an inbound `selectionCandidate`,+    /// so a unit test with no live page can never observe the flush re-arming it —+    /// "flush, then assert still cleared" passes by construction and would keep+    /// passing after someone put a selection field in the snapshot.+    ///+    /// What actually holds the invariant up is the snapshot's SHAPE. It is replayable+    /// native truth: every field in it is re-pushed verbatim onto a page that just+    /// replaced the one the values were computed for. A selection is the user pointing+    /// at ONE rendered page, so replaying it after a reload or a WebContent crash is+    /// exactly the stale-overlay bug (T-1852) wearing a different hat. This fails the+    /// moment a field is added, which is when the decision needs re-reading.+    @Test("WebDocumentStateSnapshot carries no selection state (it must stay unreplayable)")+    func snapshotCarriesNoSelectionState() {+        let fields = Mirror(reflecting: WebDocumentStateSnapshot())+            .children.compactMap(\.label).sorted()+        #expect(+            fields == [+                "commentVisibility", "detailsExpandedIDs", "inlineNotesJSON",+                "noteIndicatorsJSON", "scrollTargetBlockID", "searchStateJSON",+                "sectionCollapsedIDs", "tableModes", "theme", "typography",+            ],+            """+            WebDocumentStateSnapshot's fields changed: \(fields).++            Every field here is replayed verbatim onto the page that REPLACED the one \+            the value was computed for (reload + WebContent recovery). If the new field \+            describes a selection — a range, a block-local rect, an affordance mode — it \+            must not live here: replaying it resurrects the stale "Add Note" overlay the \+            navigation clear exists to remove (T-1852). If it is genuinely replayable \+            native truth, add it to this list.+            """+        )+    }++    @Test("The production assembly wires the affordance to the controller, not only the router")+    func assemblyWiresAffordanceToController() async {+        // DocumentScrollContent creates its assembly through `makeAssembly`; if the+        // controller is not given the affordance there, the synchronous reload clear+        // exists but never fires in production (the T-1719 wiring regression class).+        let session = DocumentSession(clipboardContent: "hello world")+        await session.parseContent()+        let affordance = armedAffordance()+        let made = WebDocumentStateSynchronizer.makeAssembly(+            session: session,+            settings: AppSettings(),+            coordinator: DocumentLayoutCoordinator(),+            notesManager: NotesManager.makeForTesting(store: MockNotesStore()),+            selectionAffordance: affordance+        )+        #expect(made.controller.selectionAffordance === affordance)++        made.controller.load(+            documentURL: WebDocumentControllerFactory.documentURL(+                session: session, parseRevision: session.parseRevision+            ),+            parseRevision: session.parseRevision+        )+        #expect(affordance.canAddNote == false)+    }++    /// The other half of the wiring, which no behavioural test can reach: the+    /// production call site has to actually PASS the affordance. `selectionAffordance:`+    /// is an optional parameter defaulting to `nil`, so dropping the argument from+    /// `DocumentScrollContent` compiles, wires the overlay to neither the router nor+    /// the controller, and leaves every assembly test above green. Source-structural,+    /// matching the repo's other wiring pins (FootnotePresentationHostTests).+    @Test("DocumentScrollContent passes the affordance into makeAssembly")+    func scrollContentPassesAffordanceToAssembly() throws {+        let source = try String(+            contentsOf: URL(fileURLWithPath: #filePath)+                .deletingLastPathComponent()   // prismTests/WebRendering+                .deletingLastPathComponent()   // prismTests+                .deletingLastPathComponent()   // repo root+                .appendingPathComponent("prism/Views/DocumentScrollContent.swift"),+            encoding: .utf8+        )+        let callRange = try #require(+            source.range(of: "WebDocumentStateSynchronizer.makeAssembly("),+            "DocumentScrollContent must build its assembly through makeAssembly."+        )+        // Paren-balanced so a closure argument (`routeLinkString: { … (…) }`) does not+        // truncate the argument list before the one being pinned.+        var depth = 1+        var arguments = ""+        for character in source[callRange.upperBound...] {+            if character == "(" { depth += 1 }+            if character == ")" {+                depth -= 1+                if depth == 0 { break }+            }+            arguments.append(character)+        }+        #expect(+            arguments.contains("selectionAffordance:"),+            """+            DocumentScrollContent's makeAssembly call must pass `selectionAffordance:`. \+            The parameter defaults to nil, so omitting it silently disconnects the \+            native "Add note" overlay from BOTH the message router (nothing arms it) \+            and the controller (the navigation clear never fires, T-1852) with no \+            behavioural test failing. Arguments seen: \(arguments)+            """+        )+    }++    @Test("A freshly loaded page publishes an initial cleared selection state")+    func freshPagePublishesClearedSelectionState() async throws {+        // Defence in depth for the reload case: the new page announces "no selection"+        // itself, stamped with the generation native embedded in the served document —+        // so it passes the router's exact-generation allowlist rather than being+        // dropped like a late post from the outgoing page.+        let para = MarkdownBlock.paragraph(markdown: "The quick brown fox")+        let harness = try await harness([para])+        let message = try #require(+            await harness.waitForMessage(type: "selectionCandidate"),+            "A fresh page must publish an initial selectionCandidate."+        )+        #expect(message["state"] as? String == "cleared")+        let generation = message["generation"] as? [String: Any]+        #expect(generation?["sessionID"] as? String == harness.generation.sessionID)+        #expect((generation?["parseRevision"] as? NSNumber)?.uint64Value == harness.generation.parseRevision)+    }++    @Test("A selection resolved before the deferred cleared fires is not wiped by it")+    func selectionBeforeInitialClearedSurvives() async throws {+        // Ordering guard for the initial `cleared` post. It is deferred one timer turn,+        // so it is only safe if it cannot overwrite a candidate that got in first —+        // hence `if (lastCandidateKey === null)`. Unconditional, it wipes a live+        // affordance and the user's Add Note button vanishes under their selection.+        //+        // The window is reached deterministically by injecting a script between+        // prism-bridge.js and prism-notes.js: its `setTimeout(…, 0)` is registered+        // after bridge's `ready` timer (channel live) and before notes' `cleared`+        // timer, so it runs in between. The synthetic `selectionchange` dispatch+        // invokes the listener SYNCHRONOUSLY, so the candidate is resolved and posted+        // before the cleared timer callback — no cross-task-source ordering luck.+        let para = MarkdownBlock.paragraph(markdown: "The quick brown fox")+        let selectEarly = """+        (function () {+            setTimeout(function () {+                var run = document.querySelector('#\(domID(para)) [data-prism-run]');+                if (!run) { return; }+                var range = document.createRange();+                range.setStart(run.firstChild, 4);+                range.setEnd(run.firstChild, 9);+                var sel = window.getSelection();+                sel.removeAllRanges();+                sel.addRange(range);+                document.dispatchEvent(new Event('selectionchange'));+            }, 0);+        })();+        """+        let harness = try await WebDocumentLiveHarness.make(+            blocks: [para],+            featureScripts: Self.notesScripts,+            injectedScriptSource: selectEarly+        )++        let available = try #require(+            await harness.waitForSelectionCandidate(state: "available"),+            "The early selection must resolve to an 'available' candidate."+        )+        #expect(available["blockID"] as? String == domID(para))+        // Let the deferred cleared timer turn (and the real selectionchange task the+        // programmatic selection queued) run to completion before judging.+        _ = try await harness.settledMessageCount(type: "selectionCandidate")+        let states = harness.messages(type: "selectionCandidate").compactMap { $0["state"] as? String }+        #expect(+            states == ["available"],+            """+            The live candidate must be the only reported state: the deferred initial \+            `cleared` seeds the dedup key only when nothing has reported yet. Saw \+            \(states) — an unconditional cleared wipes the affordance the user is \+            looking at (T-1852).+            """+        )+    }+     @Test("A live single-block selection posts selectionCandidate 'available' with the range")     func liveSelectionPostsCandidate() async throws {         let para = MarkdownBlock.paragraph(markdown: "The quick brown fox")@@ -480,10 +736,16 @@ struct WebSelectionNoteTests {             """,             contentWorld: harness.bridgeWorld         )-        let message = try await harness.waitForMessage(type: "selectionCandidate")-        #expect(message?["state"] as? String == "available")-        #expect(message?["blockID"] as? String == domID(para))-        let range = message?["range"] as? [String: Any]+        // The page publishes an initial `cleared` on load (T-1852), so wait for the+        // `available` report specifically rather than the first candidate of any state.+        // `#require` keeps a regression legible: without it a `declined`-first failure+        // degrades from a state mismatch into a 2s poll returning nil.+        let message = try #require(+            await harness.waitForSelectionCandidate(state: "available"),+            "A single-block selection must be reported as 'available'."+        )+        #expect(message["blockID"] as? String == domID(para))+        let range = message["range"] as? [String: Any]         #expect((range?["start"] as? NSNumber)?.intValue == 4)         #expect((range?["length"] as? NSNumber)?.intValue == 5)     }
prismTests/WebRendering/WebDocumentControllerTests.swift Modified +72 / -0
diff --git a/prismTests/WebRendering/WebDocumentControllerTests.swift b/prismTests/WebRendering/WebDocumentControllerTests.swiftindex 78d7cf7..0597ae0 100644--- a/prismTests/WebRendering/WebDocumentControllerTests.swift+++ b/prismTests/WebRendering/WebDocumentControllerTests.swift@@ -276,6 +276,78 @@ struct WebDocumentControllerTests {         #expect(result == .dropped(.malformedPayload(.selectionCandidate)))     } +    // MARK: - selectionCandidate is gated on readiness (T-1852)+    //+    // The native "Add note" overlay is page-scoped state held natively, so only a+    // page that has reported `ready` may arm it. `resetForNavigation` lowers+    // `isReady` synchronously when a load starts, so this gate covers the whole+    // reload window — including the same-revision folder-access retry, where the+    // outgoing page stays interactive AND generation-matched for the entire load+    // and would otherwise re-arm the overlay after the clear.++    @Test("A selectionCandidate arriving before ready is not routed")+    func selectionCandidateBeforeReadyDropped() {+        let controller = makeController()+        var routed: [InboundBridgeMessage] = []+        controller.onMessage = { routed.append($0) }++        let result = controller.receive(messageBody: body(+            type: "selectionCandidate", generation: controller.currentGeneration,+            extra: ["state": "available", "blockID": "b-1-0",+                    "range": ["start": 2, "length": 3], "rect": rect()]+        ))+        // It passes the router's generation + allowlist check — the gate is on+        // routing, not on decoding, so the decode verdict stays honest.+        #expect(result == .accepted(.selectionCandidate(+            state: .available, blockID: "b-1-0",+            range: .init(start: 2, length: 3),+            rect: .init(x: 1, y: 2, width: 3, height: 4)+        )))+        #expect(routed.isEmpty)+    }++    @Test("A selectionCandidate arriving after ready is routed")+    func selectionCandidateAfterReadyRouted() {+        let controller = makeController()+        var routed: [InboundBridgeMessage] = []+        controller.onMessage = { routed.append($0) }+        controller.test_markReady()++        controller.receive(messageBody: body(+            type: "selectionCandidate", generation: controller.currentGeneration,+            extra: ["state": "available", "blockID": "b-1-0",+                    "range": ["start": 2, "length": 3], "rect": rect()]+        ))+        #expect(routed == [.selectionCandidate(+            state: .available, blockID: "b-1-0",+            range: .init(start: 2, length: 3),+            rect: .init(x: 1, y: 2, width: 3, height: 4)+        )])+    }++    @Test("A reload re-closes the gate: the outgoing page cannot re-arm mid-load")+    func selectionCandidateDroppedAgainAfterReload() {+        let controller = makeController(sessionID: "t1852-gate", parseRevision: 4)+        var routed: [InboundBridgeMessage] = []+        controller.test_markReady()+        controller.onMessage = { routed.append($0) }++        // Same-revision reload (the iOS folder-access retry): the outgoing page keeps+        // a matching generation for the whole load, so only the readiness gate stops+        // a selection MADE DURING the load from re-arming the overlay.+        controller.load(+            documentURL: URL(string: "prism-doc://document/t1852-gate?rev=4")!,+            parseRevision: 4+        )+        #expect(controller.isReady == false)+        controller.receive(messageBody: body(+            type: "selectionCandidate", generation: controller.currentGeneration,+            extra: ["state": "available", "blockID": "b-1-0",+                    "range": ["start": 2, "length": 3], "rect": rect()]+        ))+        #expect(routed.isEmpty)+    }+     // MARK: - Queue-until-ready      @Test("Commands sent before ready are queued, not dispatched")
prismTests/WebRendering/WebDocumentLiveHarness.swift Modified +39 / -4
diff --git a/prismTests/WebRendering/WebDocumentLiveHarness.swift b/prismTests/WebRendering/WebDocumentLiveHarness.swiftindex a799e73..ecc49b9 100644--- a/prismTests/WebRendering/WebDocumentLiveHarness.swift+++ b/prismTests/WebRendering/WebDocumentLiveHarness.swift@@ -27,13 +27,22 @@ struct WebDocumentLiveHarness {      /// Builds and loads a harness for `blocks`. Includes the bridge core plus the     /// named feature scripts (default: scroll, theme, media — all bridge world).+    ///+    /// `injectedScriptSource`, when given, is injected as a bridge-world script+    /// immediately after `prism-bridge` and BEFORE the feature scripts. That+    /// position is the point of the parameter: a `setTimeout(…, 0)` it registers+    /// runs after prism-bridge's `ready` timer (so the message-handler channel is+    /// live) but before any timer a later feature script registers — the only way+    /// to reach the window between injection and prism-notes.js's deferred initial+    /// `cleared` deterministically (T-1852).     static func make(         blocks: [MarkdownBlock],         footnotes: FootnoteData = .empty,         settings: RenderSettings = RenderSettings(),         featureScripts: [String] = ["prism-scroll", "prism-theme", "prism-media"],         sessionID: String = "live",-        parseRevision: UInt64 = 1+        parseRevision: UInt64 = 1,+        injectedScriptSource: String? = nil     ) async throws -> WebDocumentLiveHarness {         let bridgeWorld = WKContentWorld.world(name: WebDocumentController.bridgeWorldName)         let recorder = LiveBridgeRecorder()@@ -45,7 +54,11 @@ struct WebDocumentLiveHarness {         let html = WebDocumentLoader.injectGeneration(generation, into: emitted.html)          var scripts: [SpikeWebPageHarness.UserScriptSpec] = []-        for name in (["prism-bridge"] + featureScripts) {+        scripts.append(.init(source: try loadScript("prism-bridge"), world: bridgeWorld))+        if let injectedScriptSource {+            scripts.append(.init(source: injectedScriptSource, world: bridgeWorld))+        }+        for name in featureScripts {             let source = try loadScript(name)             scripts.append(.init(source: source, world: bridgeWorld))         }@@ -98,13 +111,35 @@ struct WebDocumentLiveHarness {      /// Polls the recorder for the first message of `type`, up to ~2s.     func waitForMessage(type: String) async throws -> [String: Any]? {+        try await waitForMessage(type: type) { _ in true }+    }++    /// Polls the recorder for the first message of `type` satisfying `predicate`,+    /// up to ~2s. Needed wherever a page posts several messages of one type and the+    /// test cares about a specific one — e.g. `selectionCandidate`, where every+    /// fresh page publishes an initial `cleared` before any user selection (T-1852),+    /// so "the first candidate" is no longer "the candidate under test".+    func waitForMessage(+        type: String, where predicate: ([String: Any]) -> Bool+    ) async throws -> [String: Any]? {         for _ in 0..<40 {-            if let message = recorder.messages.first(where: { ($0["type"] as? String) == type }) {+            if let message = recorder.messages.first(where: {+                ($0["type"] as? String) == type && predicate($0)+            }) {                 return message             }             try await Task.sleep(for: .milliseconds(50))         }-        return recorder.messages.first { ($0["type"] as? String) == type }+        return recorder.messages.first {+            ($0["type"] as? String) == type && predicate($0)+        }+    }++    /// Polls for the first `selectionCandidate` reporting `state` (see above).+    func waitForSelectionCandidate(state: String) async throws -> [String: Any]? {+        try await waitForMessage(type: "selectionCandidate") {+            ($0["state"] as? String) == state+        }     }      /// All messages of a given type seen so far.
prismTests/WebRendering/WebStructuredSelectionTests.swift Modified +10 / -4
diff --git a/prismTests/WebRendering/WebStructuredSelectionTests.swift b/prismTests/WebRendering/WebStructuredSelectionTests.swiftindex 5a44047..4e2aa17 100644--- a/prismTests/WebRendering/WebStructuredSelectionTests.swift+++ b/prismTests/WebRendering/WebStructuredSelectionTests.swift@@ -290,10 +290,16 @@ struct WebStructuredSelectionTests {             """,             contentWorld: harness.bridgeWorld         )-        let message = try await harness.waitForMessage(type: "selectionCandidate")-        #expect(message?["state"] as? String == "available")-        #expect(message?["blockID"] as? String == domID(quote))-        let range = message?["range"] as? [String: Any]+        // A fresh page publishes an initial `cleared` candidate (T-1852), so select the+        // `available` report rather than taking the first candidate of any state.+        // `#require` keeps a regression legible: without it a `declined`-first failure+        // degrades from a state mismatch into a 2s poll returning nil.+        let message = try #require(+            await harness.waitForSelectionCandidate(state: "available"),+            "A selection in the quote's first paragraph must be reported as 'available'."+        )+        #expect(message["blockID"] as? String == domID(quote))+        let range = message["range"] as? [String: Any]         #expect((range?["start"] as? NSNumber)?.intValue == 0)         #expect((range?["length"] as? NSNumber)?.intValue == 5)     }
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex c95e42b..3070fb9 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 +- The **Add Note** button that appears when you select text now goes away when the document reloads (T-1852). If a file changed on disk — or a URL document was refreshed — while you had text selected, the selection vanished with the old page but the button stayed floating where it was. Tapping it then opened the note editor quoting text you were no longer looking at, or, if the reload had moved the content around, text from somewhere else entirely. The button is now dismissed the moment a reload starts, including the reload after granting folder access to images and the one that follows a rendering-process restart, and stays away for the rest of the reload: text you drag over while the document is still loading no longer brings the old button back. Each freshly loaded page then confirms for itself that it has no selection. - Opening a document that writes characters as HTML entities or backslash escapes inside emphasis, bold, or link text is no longer slow enough to matter (T-1966). Text written `*&#65;*` shows an `A`, but the file spells it `&#65;` — so while working out which part of the file each word on screen came from, which is what lets you select text and attach a note to it, the app searched the rest of the paragraph for an `A`, found none, and then searched the same stretch again for the next word, and again for the one after. A paragraph of 3,200 such words took 12.4 seconds to render; it now takes 57 milliseconds, and the cost grows in step with the length of the document rather than with its square. Nothing about the result changes — the same text, the same footnote badges, in the same order, with notes anchoring exactly where they did before, which was checked by rendering four thousand generated samples before and after and comparing every character and every anchor position. 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. The three examples originally reported for this fault had already stopped being slow through earlier fixes, and are now pinned by growth guards so they cannot come back. One deliberately-constructed shape is not covered: where every repetition writes a *different* word the file spells some other way, the cost still grows with the square of the document's length, as this fault did. That is tracked separately (T-2034). - Adding a note to text in a paragraph that also mentions a footnote reference inside an image, a link address, an image tooltip, or raw HTML now works, as long as that reference is written out plainly (T-1992). In a paragraph like `![[^1]](cat.png) choose [^1] after` — or the same with the reference written inside a link address such as `[link](http://example.com/[^1])`, inside an image's tooltip text such as `![cat](cat.png "see [^1]")`, or inside raw HTML — the badge still appeared in the right place, but the app matched it to the reference-shaped text inside the image, address or tooltip rather than the real one. Selecting the words in between and choosing **Add Note** was then declined, or saved a note quoting the wrong text and pointing at the image or link syntax, which the note carried into relocation and inline-note export. Stepping search onto such a footnote could also mark the wrong badge. Text that merely looks like a reference in those positions is now accounted for, so the words either side of a badge map to what you actually selected. Two spellings are not covered yet and still behave as they did before: an image whose alt text mixes the reference with formatting, as in `![*a*[^1]](cat.png) choose [^1] after`, and a link address that writes a character as an HTML entity, as in `[link](x&amp;/[^1]) choose [^1] after`. In both the app cannot line the text up with your document and deliberately leaves it alone, so a selection over the words before the badge is still declined — tracked under T-2033. This is separate from the earlier fix for selecting after a badge (T-1876); footnotes inside list items and table cells are still tracked separately. - Adding a note to text selected inside a table cell or a list item now quotes the text you actually selected (T-1941). Selecting a word in the second cell of a row, in any row after the first, or in any list item after the first quoted text from the start of the table or list instead — and saving stored a wrong source range, which the note then carried into relocation and inline-note export. Only the very first cell and the very first list item behaved correctly. The rendered document's text-to-source map now records every cell and every item at its real position within the block's text, so a note anchors where you put it. A few places where the map used to record an anchor that could only ever be wrong now record none at all: a nested list's items, a list nested inside a quote or another list item, a list inside a collapsible `<details>` section, the summary of a `<details>` nested inside another, and the rare quote the parser cannot break into parts. Selecting text in one of those and reaching for **Add note** now declines quietly instead of quoting text from elsewhere in the block — the block's own **+** button still adds a note, as does the **+** beside each item of a nested list. Anchoring a selection in those places is tracked separately (T-2032). This was the same fault as the footnote-selection fix below (T-1876) on a different path; every place in the renderer that draws part of a block must now state where that part sits, so the next one cannot repeat it.
docs/agent-notes/webview-rendering-status.md Modified +1 / -0
diff --git a/docs/agent-notes/webview-rendering-status.md b/docs/agent-notes/webview-rendering-status.mdindex c2727a5..ae5d42d 100644--- a/docs/agent-notes/webview-rendering-status.md+++ b/docs/agent-notes/webview-rendering-status.md@@ -165,6 +165,7 @@ source rather than shapes seen today. - **`xcodebuild test` hangs** in post-test xcresult finalization (it builds prismUITests). Use `build-for-testing` then `test-without-building` with `NSUnbufferedIO=YES`; the process **exit code is authoritative**. Run targeted classes with `-only-testing:prismTests/<Class>` to avoid the hang. - **iOS WKWebView selection**: long-press triggers native text selection (not `contextmenu`), and the native selection callout renders above all web content → use native SwiftUI overlays / visible tap targets, not in-page pills or long-press gestures. - **Note flow is native-as-truth**: JS posts `selectionCandidate` / `noteIndicatorTapped` / `inlineNoteTapped` / `blockContextRequested` / `linkActivated` → `WebDocumentMessageRouter` → `DocumentLayoutCoordinator` state → SwiftUI sheets/popovers. `NotesManager` owns the notes; the web view only renders + reports. In-page affordances that open native UI route via `linkActivated` + a `prism://…` URL (footnote, `document-note/add`, `image-access/grant`); the Swift route literals live in `PrismLinkRoute`.+- **The native selection affordance is PAGE-scoped state living natively, so it must be cleared on the navigation path, not by a message** (T-1852). `WebSelectionAffordanceState` is written only from inbound `selectionCandidate`, so before the fix a reload left the "Add Note" button floating at the outgoing page's rect with its block id + range. The obvious fix — have the outgoing page post `cleared` — CANNOT work and looks like it should: `load` assigns the new `parseRevision` *before* navigating, so `BridgeMessageRouter`'s exact-generation match drops anything the old page says afterwards. The clear lives in `WebDocumentController.resetForNavigation()`, the one choke point `load` and `handleProcessTermination` share (so it covers re-parse reloads, the same-revision iOS folder-access retry, and WebContent recovery). It is deliberately NOT in `WebDocumentStateSnapshot`: that snapshot is *replayable* native truth, and a selection is the user pointing at one rendered page — replaying it after a crash recovery is the same bug wearing a different hat. A clear alone is not enough, because clearing does not stop the OUTGOING page from re-arming: on a same-revision reload (the iOS folder-access retry) the old page stays interactive and generation-matched for the whole load, so a selection made *during* the load was accepted and the stale overlay came straight back until the new page's scripts ran. `WebDocumentController.handle` therefore also drops `selectionCandidate` while `!isReady` — `resetForNavigation` lowers `isReady` synchronously and only the INCOMING page's `ready` raises it, so clear + gate close the window rather than narrowing it. Nothing legitimate is lost: `ready` is posted from a timer prism-bridge.js registers before prism-notes.js runs (script order in `WebDocumentControllerFactory.userScripts()`, all `.atDocumentEnd`, equal-delay timers fire in registration order), so it always precedes the fresh page's own `cleared`. Defence in depth: each fresh page posts that initial `cleared` from `prism-notes.js`, deferred one timer turn (same reason as bridge.js's `ready` — the handler channel is not live at injection) and guarded on `lastCandidateKey === null`, i.e. "seed the dedup key if nothing has reported yet" — unconditional, it wipes a candidate that resolved first. It passes the allowlist because it is the INCOMING page's post, stamped with the per-serve generation island `WebDocumentControllerFactory.emitHTML` injects from the controller's live values. Consequence for live tests: "the first `selectionCandidate`" is no longer "the candidate under test" — use `WebDocumentLiveHarness.waitForSelectionCandidate(state:)`; and `make(injectedScriptSource:)` injects a script between bridge.js and the feature scripts, the only deterministic way to reach the window before notes.js's deferred timer. - **`<button>` UA font-size trap (cost me 4 device rounds)**: a `<button>`'s default font-size is ~13.3px, NOT the content's 17px. Any `em` offset or `::before` chip size on a button-based affordance (`.prism-add-note`, `.prism-notes-toggle`) computes against 13.3px, so it silently mismatches sibling `<span>`s (e.g. the note dot at 17px). Fix: put `font-size: 1em` on the button so its em math matches the surrounding content. Symptom was the `+` never aligning with the note dot no matter the offset. - **Headless Chrome is a reliable CSS-geometry probe** when you can't see the device: `"/Applications/Google Chrome.app/.../Google Chrome" --headless=new --disable-gpu --dump-dom "file://probe.html"` runs the page's JS; have the JS write `getBoundingClientRect()` results into a `<pre id=out>` and read it from the dumped DOM. Gotchas: `print()`/console are swallowed (write to the DOM or `document.title`); `top` is `window.top` (read-only global) so don't `var top = …`; inline the real `document.css` into the probe. - **`make test-locales` runs the FULL unit suite ×4 locales** (en/en-AU/en-GB/en-US) — it is NOT a quick catalog check, and it wedges the test daemon on a contended machine (saw a 12-min hang). The catalog validation (`Tools/validate-localisation.py`) actually runs as a **build phase** during ANY build, so a clean build at zero warnings already validates the catalog — don't run test-locales just to check it.

Things to double-check

Merge order against #345 / #346.

No textual conflict is expected — the sibling hunks in prism-notes.js sit hundreds of lines above this branch's edit — but all three touch the same file and the same CHANGELOG section, so whichever merges last should re-run the notes live suites rather than trusting a clean auto-merge.

The gate on a device, not just in the harness.

The ordering argument (bridge's ready timer before notes' cleared timer, both at documentEnd) is verified by the live harness on macOS. iOS WKWebView has the same registration-order semantics, but the failure mode if it did not — the fresh page's initial cleared being dropped — is invisible, because resetForNavigation has already cleared the affordance. Worth one manual reload-with-selection on device to confirm the visible behaviour.

The two structural tests are intentionally brittle.

snapshotCarriesNoSelectionState fails on any snapshot field addition and scrollContentPassesAffordanceToAssembly fails if the assembly call is restructured. That is the point in both cases, and both carry failure messages saying so — but a future contributor hitting them should read the message rather than updating the expectation reflexively.