Skip to content

fix(annotate): pins on images without an id restore by their source - #1747

Merged
backnotprop merged 2 commits into
backnotprop:mainfrom
pro-vi:fix/media-pin-anchors
Oct 7, 2026
Merged

backnotprop merged 2 commits into
backnotprop:mainfrom
pro-vi:fix/media-pin-anchors

Conversation

@pro-vi

@pro-vi pro-vi commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Problem

In raw-HTML annotate, a pin on an <img>, <video> or frame that has no id and no data-* identity attribute does not come back after a reload or an HTML refresh. The pin is saved without an anchor, and its quote ([element: Image (chart.png)]) appears nowhere in the page, so restore finds nothing. Images in generated reports and docs rarely carry an id, so in practice image comments don't persist.

The existing test "an image pin saved with the old bare placeholder still restores through its anchor" passes because its image has id="hero".

We hit this in artifact-server's review frame: every image comment was stored as originalText: "[element: Image]" with htmlAnchor: null.

Why it was this way

buildElementAnchor gives a text-less element an anchor only through #id or a data-* rung. That came from D1 in #1251: a shape signature (tag, classes, child count, size) is the same for identical siblings, so after one is removed a positional selector lands on the other and the signature still verifies.

What changed

  1. A text-less media element is checked by its source. anchorTextSnapshot returns src:<key> from anchorSourceSnapshot when the element has no text. The source comes from descRawSource (src, lazy data-src, srcset, <picture><source>, a video's <source> or poster). The key is the author's reference to the resource: relative to the document's base when the resource lies under it, otherwise origin + path. The query and fragment are dropped, as in ctxScrubUrl: they carry per-visit state and should not end up in a stored annotation.
    • With <base href="/api/html-assets/<token>/">, charts/b.png keys as charts/b.png, not the token route.
    • A host that rewrites attributes to a per-session origin (artifact-server makes eager resource URLs absolute on its content host before srcdoc) gets the same key.
  2. D1 still holds. There is no snapshot, so no anchor, for a data:/blob: source, or when another element of the same kind shows the same source. Siblings with different files can't stand in for each other, and the same file twice is refused.
  3. Restore relocates by source. When the positional selector no longer lands on the element (a sibling was inserted or removed, a refresh reordered the page), relocateBySource finds the one element with that source. Two matches resolve nothing. Text pins already fall back to text search; this is the media equivalent.
  4. AGENTS.md said text-less pins restore through their anchor; it now says when they do.

The stored anchor keeps its shape ({selector, tagName, text}), and text stays within 180 characters, under MAX_HTML_ANCHOR_TEXT_LENGTH. No protocol change. Pins saved before this have no anchor and still won't restore; there is nothing to migrate.

Tests

New in srcdoc.test.ts: "a media pin without stable identity restores by its source; shared and inline sources fail closed". It pins an id-less image whose src has a signed query, restores it, restores it again after a sibling is inserted in front, gets nothing once that source is gone, and gets no anchor for a shared or data: source. A crafted anchor naming the shared source binds nothing.

  • On main the test fails at the first check (the anchor is undefined).
  • It also fails with each of these removed in turn: the shared-source check, the data:/blob: exclusion, the two-matches rule in relocation, and relocation itself.
  • DOM_TESTS=1 bun test packages/ui/components/html-viewer (258 pass), packages/ui/utils and packages/ui/hooks pass; tsc --noEmit -p packages/ui/tsconfig.json is clean.

Not covered

  • I ran this on Bun 1.3.5; CI pins a newer Bun. A single bun test packages/ui packages/core run crashed Bun itself (segfault), so I ran the folders separately.
  • With DOM_TESTS=1, three "live paste service E2E" tests in packages/core/crypto.test.ts fail. They fail the same way on main and pass without the DOM preload.
  • Not exercised: live-app (annotate-app) pages, and HTML Refresh end to end. Relocation applies to both, but the test drives it through find-and-mark only.
  • Matching by source is a judgment call. Two different images served under the same path from different origins, or a path reused for new content, count as the same image. Within one page the shared-source rule catches it; across a reload it doesn't.

🤖 Generated with Claude Code

pro-vi and others added 2 commits October 6, 2026 23:51
A pinpoint on an <img>, <video> or frame with no id and no data-* identity
got no anchor, so its pin never came back after a reload or an HTML
refresh. Screenshots and charts in generated pages rarely carry an id, so
in practice image comments did not persist. The rule comes from D1 in
backnotprop#1251: a structure-derived signature is the same for identical siblings,
so a positional selector could bind the wrong one after a sibling moved.

A media element's source is not structure: two siblings showing different
files never share it. The snapshot for a text-less media element is now
src:<key>, where the key is the author's reference to the resource:
relative to the document's base when it lies under it, otherwise origin and
path, never the query or the fragment. A data: or blob: source, or a source
that another element of the same kind shows, gives no snapshot, so
identical siblings still fail closed. When the positional selector no
longer lands on the element, restore finds it again by its source, and two
matches resolve nothing.

AGENTS.md said text-less pins restore through their anchor, which held only
with an id or a data-* attribute; it now says when they do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dropping the query let a pin bind to a different image that shares a path:
/_next/image?url=a.jpg and ?url=b.jpg, /thumb?id=1 and ?id=2, and
proto.html?step=1 and ?step=2 all read as one source. The query and
fragment now count as an 8-hex FNV-1a digest, so a token never lands in
the annotation in plain text and a changed query fails closed. Same-origin
sources outside the base key by root-relative path (a live app's proxy port
changes per session), and a key past the cap keeps a digest of the whole.

Docs: the embed paragraph described the old fail-closed rule for frames
without an id; it now says when a frame pin restores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@backnotprop
backnotprop merged commit ce2d4de into backnotprop:main Oct 7, 2026
24 checks passed
@backnotprop

Copy link
Copy Markdown
Owner

Thanks @pro-vi, for finding this and for the fix. Comments on images without an id going missing after a reload was a real gap, especially in generated reports.

We added one commit on top of yours: the source key now includes the part of the URL after ? (stored as a short hash, so a token never lands in plain text). Without it, /thumb?id=1 and /thumb?id=2 counted as the same image, and a pin could move to the wrong one. With it, a changed or duplicated source leaves the pin unattached instead.

We checked it in a real browser: a pin on an id-less image comes back after a reload, in a new session, after another image is added before it, and in live-app sessions. A pin on an image that appears twice isn't saved there, so it can't jump to the copy.

It ships in 0.28.7.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants