Repository navigation
Stop link preview metadata writes from reaching every peer #519
Description
Part of #512. Read the tracker first. Work on main.
Summary
- Today. A reader opens a link preview. The webapp then writes the fetched page title and image URL into the link's mark attributes,
titleandimage. That is a document change.- y-sync sends it to Yjs, so every peer receives it.
addToHistory: falseonly stops undo capture (installed@tiptap/y-tiptap3.0.9,dist/y-tiptap.js:245-273). - It stays in every local y-indexeddb copy (useYdocAndProvider.ts#L91).
- It runs for anyone who opens a preview, read-only viewers included. ProseMirror
dispatchnever checkseditable(installedprosemirror-view@1.42.3,src/index.ts:511-515).
- y-sync sends it to Yjs, so every peer receives it.
- The change digest likely counts it.
canonicalizeBlockkeeps every non-null attr, mark attrs included, and drops onlytoc-id(canonicalizeBlock.ts#L6-L15, types.ts#L201). So opening a preview can mark a section as changed. The digest email uses the same compute (pgmqConsumer.ts#L68). This is read from code, not measured. Step 0 measures it. - After Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513. The mark view's
updatekeeps the<a>and its icon on such a write. So the icon no longer rebuilds on every peer. The write itself still happens. - Goal. Opening a preview stops changing the shared document (Decision 1, recommended), or only real editors write (Alternative A).
Maintainer rulings that bind this issue
- Ruling 17, file the side issues (2026-10-17). This issue is the side issue "
writeLinkMetadataAttrswrites to every peer", adjusted to the mark-view plan. - No other ruling binds this issue.
Depends on / Blocks
- Depends on nothing. Work can start at once.
- Related: Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513. Its
updatemethod removes the icon rebuild. Its unit test 4 pins that, if its Decision 6 (jsdom) is ruled yes. - Related: Take y-tiptap 3.0.10 so a remote change stops rebuilding the paragraphs below it #520. Both reduce work on remote peers. They touch different code.
Current behaviour
Links are pinned to 69ca1ef1649540f7c8989dfd74ad901c741a30b1.
- The write.
writeLinkMetadataAttrsfinds the mark range. It dispatchesremoveMarkplusaddMarkwith the newtitleandimage, and setsaddToHistory: false(linkMarkUtils.ts#L31-L70). It checks no editable flag. Its argument typeMarkMetadata(linkMarkUtils.ts#L16-L23) has no other user. - Desktop.
renderMetadataIntoreturns aflushthat writes the fetched data (previewShared.ts#L151-L198).buildDesktopPopoverpassesflushup (previewHyperlink.ts#L97-L171). See L100, L164 and L168-L170.observeDetachmentaborts the fetch and callsbuilt.flush()when the popover detaches (previewHyperlink.ts#L80-L91).- The same
renderMetadataIntoreads storedtitleas an L1 cache. On a hit it skips the fetch (previewShared.ts#L163-L182).
- Mobile. The sheet passes stored
titleandimageas the L1 cache. It writes as soon as its fetch loads (LinkPreviewSheet.tsx#L32-L43). The hook returns the L1 value at once wheninitialTitleis set (useLinkMetadata.ts#L40-L74). - Surfaces. The pad, the desktop chat composer and the history viewer all reach
previewHyperlink(getHyperlinkPopoverConfig.ts#L13-L30, useHistoryEditor.ts#L16-L17). The mobile composer usespreviewComposerHyperlinkand is not part of this write. titlerenders to the DOM;imagedoes not. The mark'stitlehas norendered: false, so the<a>gains a native tooltip.imageisrendered: false(hyperlink.ts#L236-L243).- Paste fills both attrs too. Neither attr has its own
parseHTML. Tiptap then readselement.getAttribute(name)(installed@tiptap/core3.31.3,src/helpers/injectExtensionAttributesToParseRule.ts:30-32). So a pasted<a title="…" image="…">stores both. The L1 read then shows them as fetched metadata (read from code, not measured). - The server stub keeps both attrs so they survive re-encode (migration-extensions.ts#L47-L62). The comment at L49 names
writeLinkMetadataAttrs. - Read-only connections. The server sets
connectionConfig.readOnlyonly for the admin Read-only lock on a non-owner (hocuspocus.server.ts#L577, #L605-L610).- Hocuspocus then refuses their updates with
writeSyncStatus(false). See installed@hocuspocus/server@3.4.4,src/MessageReceiver.ts:165-187(SyncStep2) and:204-212(Update). - The provider counts a change as synced only when
appliedis true (installed@hocuspocus/provider@3.4.4,src/MessageReceiver.ts:97-101). - So the refused write stays in the viewer's Y.Doc and y-indexeddb. It likely reaches every peer once the lock lifts and the viewer reconnects (read from code, not measured).
- Hocuspocus then refuses their updates with
- The client editing gate.
isDocumentEditingLocked()covers a content-fork freeze, the WSauthorizedScope === 'readonly'and the SSR Read-only lock for a non-owner (isDocumentEditingLocked.ts#L8-L17).editor.isEditableis a UI flag. It also goes false for real editors for a short time (openHeadingChatroom.ts#L12, useResizeContainer.tsx#L152, TipTap.tsx#L280). The desktop popover hides Edit and Remove onview.editable(previewHyperlink.ts#L155-L157). - Caches that remain without the write.
- The webapp session cache is a module-level
Mapwith a 5-minute TTL (fetchMetadata.ts#L42-L59). A reload empties it. - The server caches results in Redis (redisCache.ts#L29-L31). A hit lives 24 h and a miss 10 min (cache.ts#L5-L6).
- The webapp session cache is a module-level
- Unknown today, measured in Step 0: whether the write creates a new version row and whether the digest counts it. What a locked viewer's copy holds is measured only under Alternative A (Step 1).
Target behaviour
Per Decision 1:
- Recommended: opening a preview never changes the document. The preview reads the session cache, then REST, which the Redis cache answers. Stored
titleandimagevalues are no longer read as metadata. The<a title>on old links still renders as before. - Alternative A: only an editor who may edit the pad writes the metadata. The gate is
editor.isEditable && !isDocumentEditingLocked(). Locked viewers never write. - Alternative B: opening a preview never changes the document. A stored
titleis still read as metadata. A storedimageis not. - Alternative C: opening a preview never changes the document. Stored
titleandimageare still read as metadata, as today.
Implementation steps
Step 0. Measure
Setup:
- Run
make dev-local, and confirm the webapp port per [Tracking] Inline link icons through a hyperlink mark view #512 §Local stack (AGENTS.md §Test Policy). REST listens onAPP_PORT4000 (.env.example#L91). - Use two signed-in users on one pad. The owner is client B. A non-owner is client A.
- Add a link to
https://example.com/pagewith no storedtitle. Make one plain edit. Wait 15 s, so that a version row exists before the test. - From the repo root, before the curl in measure 2, run
export SUPABASE_SERVICE_ROLE_KEY=$(grep '^SUPABASE_SERVICE_ROLE_KEY=' .env.local | cut -d= -f2-).
Measures:
- Write and version row.
- Record the head version. From the repo root, run the query with
docker exec docsy-postgres-local psql. Take the user and database fromDATABASE_URLin.env.local:Compare the head, not a row count, becausedocker exec -i docsy-postgres-local psql -U <user> -d <database> <<'SQL' SELECT version, "createdAt", trigger FROM "Documents" WHERE "documentId" = '<documentId>' ORDER BY version DESC LIMIT 1; SQL
pruneAutosaveVersionsthins rows (retention.ts#L62). - On B, run
JSON.stringify(document.querySelector('.ProseMirror').editor.getJSON())in the console and keep the string. - Note the time
T0in ISO form. On A, open the preview, wait for the title, then close it. Make no other edit. - On B, inspect the
<a>in the DevTools Elements panel. Record whether it gains atitleattribute. On B, run the sameJSON.stringify(…)line again. Record whether the string is the same as before. - Wait at least 15 s; the store debounce is 10 s (hocuspocus.config.ts#L403). Note
T1. Run the query again and record whether the head version moved.
- Record the head version. From the repo root, run the query with
- Change digest. Run the route (router.ts#L25-L31, schema.ts#L10-L15, auth per API.md#L39). Use the key that Setup exported from
.env.local(the local demo key, .env.example#L74):
curl -s -H "Authorization: Bearer $SUPABASE_SERVICE_ROLE_KEY" "http://localhost:4000/api/documents/<documentId>/changes?since=<T0>&until=<T1>&scope=headings"
Record whether the section that holds the link counts as changed. The digest email uses the same compute, so this answers both paths.
Post all results as a comment here.
Step 1. Apply the ruled route (stop until a Maintainer ruling comment exists for Decision 1)
Line numbers are at the pinned commit; see #512 §Rules every child keeps.
Recommended route (Decision 1):
linkMarkUtils.ts: deletewriteLinkMetadataAttrswith its comment (L25-L70) andMarkMetadata(L16-L23).previewShared.ts:- Remove the
writeLinkMetadataAttrsimport (L17),RenderHandle(L151-L153),pendingand theflushreturn (L186-L197). renderMetadataIntoreturnsvoid. Rewrite its JSDoc (L155-L159) so it no longer says "MUST invokeflush()".
- Remove the
previewHyperlink.ts:buildDesktopPopoverreturns the element only (L100, L164, L168-L170).- Keep
observeDetachment(L85-L88), because it still aborts the fetch. Its callback calls onlycontroller.abort(). Replace the comment at L82-L84 with one line: the observer aborts the fetch on detach.
LinkPreviewSheet.tsx: delete the effect (L39-L43) and its import (L13). DropuseEffectfrom the L9 import, andeditorandnodePosfrom the L33 destructure, when nothing else uses them.bun run lintcatches leftovers.- Stop the L1 read (Decision 1):
previewShared.ts: delete the stored-attr branch (L163-L182).useLinkMetadata.ts: deletefromMarkAttrs,UseLinkMetadataOptionsand theinitialTitlebranch (L40-L48, L59-L65). Drop theoptionsparameter.LinkPreviewSheet.tsx: calluseLinkMetadata(href)and dropattrsfrom the L33 destructure.- Keep
ctx.attrswhere other code still reads it. - Under Alternative B, skip the four sub-bullets above. Instead, keep the
titleread and drop theimageread.previewShared.ts: deleteexistingImage(L165) and theimagefield (L174). Changereturn { flush: () => {} }(L181) toreturn. Set the L163 comment to "L1 cache: stored marktitle."useLinkMetadata.ts: dropinitialImage(L47, L60), theimageparameter offromMarkAttrs(L40-L43) and its argument in the L64 call.LinkPreviewSheet.tsx: drop theinitialImageline (L36).
migration-extensions.ts: keep both stub attrs, because stored documents still hold them. Reword L49 to: "title/imageare preview metadata older clients stored; keep them so re-encode does not strip them."
Alternative B (Decision 1): apply items 1 to 6 above, with the Alternative B bullet in item 5.
Alternative C (Decision 1): apply items 1 to 4 and 6; skip item 5. Item 2 makes renderMetadataInto return void, so still change return { flush: () => {} } (L181) in previewShared.ts to return. Set the L163 comment to "L1 cache: stored mark title and image."
Alternative A (Decision 1):
- Locked viewer, measured first. Run this before the edit, and post the results as a comment here.
- Remove the stored
titleagain from B (edit the link, or use a fresh link). The owner B turns on Read-only for the pad in Settings › Documents › row menu › Read-only (DocumentRowMenu.tsx#L267-L277). A reloads, so that the server reads the lock on connect. - On B, run
JSON.stringify(document.querySelector('.ProseMirror').editor.getJSON())and keep the string. On A, open and close a preview on a fresh link. Record whether B's<a>gainstitle, and whether B's string is the same as before. - On A, record whether its own
<a>gainstitle. That shows the local Y.Doc and y-indexeddb diverge from the server. - B turns Read-only off with the same toggle. A reloads. Record whether B now gains the
titleattribute.
- Remove the stored
- Add one helper in
linkMarkUtils.ts:canWriteLinkMetadata(editor) = editor.isEditable && !isDocumentEditingLocked().- A short
isEditablefalse skips only a cache write, which is harmless. - The lock check covers the durable cases.
- A short
- Call it first in
writeLinkMetadataAttrs. Return early when it is false. - Keep
flush,RenderHandleand the L1 read. Update the L49 comment only if the wording changes.
Step 2. Run the existing spec
Run hyperlink-preview-edit.cy.js as a regression run. It runs on the /editor playground with persist: false (hyperlink-preview-edit.cy.js#L72). So it sees no Yjs and asserts nothing about metadata. The manual checks in Verification prove the behaviour. Add no new spec; see Out of scope.
Step 3. CHANGELOG
Create or extend ### Fixed under ## [Unreleased] in apps/webapp/CHANGELOG.md. The house order is in RELEASE_POLICY.md#L202-L226. Bullet for the recommended route, Alternative B and Alternative C: "Opening a link preview no longer changes the document. Peers get no update, and the change digest no longer counts it." For Alternative A, say that only editors store preview metadata.
Review checkpoint
Show the touched files, the Step 0 comment link and the command output. Then stop. Do not run git add, git commit, git push or git stash. Do not create a branch.
Decisions
Owner: the maintainer. The recommended answer comes first.
- Make previews read-only: stop the write, and stop reading stored
titleandimage? Yes. This accepts that new links lose the fetched native tooltip.- Reason, the write: a preview is a read. It should not send an update to every peer, mint version rows, count as a digest change or set a native tooltip for everyone.
- Reason, the read: once the write stops, a stored value no longer comes from a fetch. It comes from paste (
injectExtensionAttributesToParseRule.ts:30-32), an old client or any editor.- A pasted
titlewould show as the site's own title, which is a spoof. - A stored
imageis a URL an editor chose. Every reader's browser would load it, so it can track readers and never expires.
- A pasted
- Cost: a reader fetches once per link per tab every 5 minutes (
fetchMetadata.ts:42). Repeat requests then hit Redis for 24 h. The code gets one data source. - Alternative A: write only when
editor.isEditable && !isDocumentEditingLocked(). That keeps a cross-session cache, but every peer still receives the update, and the digest still counts it. - Alternative B: stop the write but keep the read of the stored
titleonly. - Alternative C: stop the write and keep both reads, as today.
- Parity note (AGENTS.md §Workflow And Review Expectations). New links lose the native
titletooltip that the fetch used to fill. Google Docs shows link details in its own chip and hover card, not a browser tooltip (developers.google.com). For Word and Notion no primary source was found. So the majority rule is UNVERIFIED. Please confirm that dropping the fetched tooltip is acceptable, or rule an exception. - Needed by Step 1.
Acceptance criteria
- The Step 0 comment exists with measures 1 and 2.
- Decision 1 has a Maintainer ruling comment.
- Recommended route, Alternative B and Alternative C: after a preview opens and closes on client A, client B's
<a>gains notitle. On B,JSON.stringify(document.querySelector('.ProseMirror').editor.getJSON())is the same before and after A opens and closes the preview. - Recommended route, Alternative B and Alternative C: the head
versioninDocumentsdoes not move after 15 s with no other edits. - Recommended route, Alternative B and Alternative C: the changes route for that window does not count the section.
- Alternative A: the locked-viewer comment exists. A locked viewer's preview leaves its own
<a>withouttitle. After the lock lifts and A reloads, B still gains nothing. - Recommended route: a link with a stored
titlemakes one/metadatarequest on desktop and on the mobile sheet. The preview shows the fetched title, not the stored one. - Alternative A, B or C: a link with a stored
titlemakes no/metadatarequest on desktop or on the mobile sheet. Under Alternative B, the preview shows no storedimage. - Old links with stored
titleandimagestill open a working preview on desktop and mobile. - No stale comment names
writeLinkMetadataAttrs,flushor "persisted mark attrs" (git grep -n "writeLinkMetadataAttrs\|RenderHandle"returns nothing on the recommended route, Alternative B and Alternative C). -
hyperlink-preview-edit.cy.jspasses.
Verification
bun run --filter @docs.plus/webapp typecheck
bun run lint
bun run check
bun --cwd="$PWD/apps/webapp" run cypress:run --spec cypress/e2e/editor/hyperlink/hyperlink-preview-edit.cy.js --config baseUrl=http://localhost:3000bun run checkincludescheck:agent-docs, which covers theextensions/CLAUDE.mdedit.- Cypress needs
make dev-localup. Use the webapp port that Step 0 confirmed. visitEditordefaults tohttp://localhost:3001unlessEDITOR_BASE_URLorbaseUrlis set (commands.ts#L445-L454). So the run passes--config baseUrl=http://localhost:3000, unless the port check shows another port.
Manual walks, on make dev-local with two clients, desktop Chrome and iOS Safari:
- No write. Repeat Step 0 measures 1 and 2. Expect no
titleon B, the sameJSON.stringify(…getJSON())string on B before and after, no new head version and no changed section. - Fetch count. In the Network panel, filter on
metadata. Open a preview for a link with a storedtitle, then for one without. Check the counts in the acceptance criteria. Do it in the desktop popover and in the mobile sheet. - Other surfaces. Open a preview in the desktop chat composer and in the history viewer. Expect the same preview and no document change.
- Alternative A only. Repeat the locked-viewer measure in Step 1, Alternative A, item 1.
Docs to update
extensions/CLAUDE.md§Webapp-Owned Hyperlink Popovers: add one line. Recommended route: "The webapp never writes preview metadata into mark attrs. Previews read the session cache and REST." Alternative A: "Onlyeditor.isEditable && !isDocumentEditingLocked()writes preview metadata." Alternative B: the recommended line, plus "A storedtitleis still read first." Alternative C: the recommended line, plus "Storedtitleandimageare still read first."extensions/CLAUDE.md:27keeps the extensionAGENTS.mdfor extension-internal rules, so this webapp fact goes here.extensions/extension-hyperlink/AGENTS.md: no change. Its rules at L29 and L49 are about the extension and hosts.apps/webapp/CHANGELOG.md(Step 3).
Out of scope
- The extension's
setHyperlink({ image })API. Hosts may still store metadata if they choose. - Removing stored
titleandimagevalues from existing documents, or the<a title>they render. - The link icon. Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 keeps it through
update. - The privacy page. Third-party preview images load directly in the reader's browser today, and this issue does not change that. The privacy paragraph belongs to Show a link icon before every pad link in the webapp #516. The Google s2 leaks belong to Load profile and preview favicons from our route, not from Google #517.
- The extension clean-room suite. This issue touches no extension source.
- New tests. The maintainer ruled the test list for this plan, and it has no spec for this issue. A new Cypress assertion needs its own ruling.
Read first
AGENTS.md, mainly §Test Policy, §Workflow And Review Expectations and §Simplified English Mandate.extensions/CLAUDE.md§Webapp-Owned Hyperlink Popovers.extensions/extension-hyperlink/AGENTS.md§Metadata And Preview.apps/webapp/CLAUDE.md.apps/webapp/src/components/TipTap/CLAUDE.md.apps/hocuspocus.server/CLAUDE.md, for the store debounce and the changes route.
Activity
HMarzban commentedon Oct 18, 2026
Maintainer ruling, 2026-10-18
The maintainer answered these Decisions through a multiple-choice prompt in the agent session. Each answer below is the option the maintainer picked; the quoted label is the option text.
titleorimage. New links lose the fetched native tooltip; that is accepted. Alternatives A, B and C are not taken.Recorded on the tracker #512.