Skip to content

Stop link preview metadata writes from reaching every peer #519

Open

Description

@HMarzban

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, title and image. That is a document change.
    • y-sync sends it to Yjs, so every peer receives it. addToHistory: false only stops undo capture (installed @tiptap/y-tiptap 3.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 dispatch never checks editable (installed prosemirror-view@1.42.3, src/index.ts:511-515).
  • The change digest likely counts it. canonicalizeBlock keeps every non-null attr, mark attrs included, and drops only toc-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 update keeps 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 "writeLinkMetadataAttrs writes to every peer", adjusted to the mark-view plan.
  • No other ruling binds this issue.

Depends on / Blocks

Current behaviour

Links are pinned to 69ca1ef1649540f7c8989dfd74ad901c741a30b1.

  1. The write. writeLinkMetadataAttrs finds the mark range. It dispatches removeMark plus addMark with the new title and image, and sets addToHistory: false (linkMarkUtils.ts#L31-L70). It checks no editable flag. Its argument type MarkMetadata (linkMarkUtils.ts#L16-L23) has no other user.
  2. Desktop. renderMetadataInto returns a flush that writes the fetched data (previewShared.ts#L151-L198).
  3. Mobile. The sheet passes stored title and image as the L1 cache. It writes as soon as its fetch loads (LinkPreviewSheet.tsx#L32-L43). The hook returns the L1 value at once when initialTitle is set (useLinkMetadata.ts#L40-L74).
  4. 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 uses previewComposerHyperlink and is not part of this write.
  5. title renders to the DOM; image does not. The mark's title has no rendered: false, so the <a> gains a native tooltip. image is rendered: false (hyperlink.ts#L236-L243).
  6. Paste fills both attrs too. Neither attr has its own parseHTML. Tiptap then reads element.getAttribute(name) (installed @tiptap/core 3.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).
  7. The server stub keeps both attrs so they survive re-encode (migration-extensions.ts#L47-L62). The comment at L49 names writeLinkMetadataAttrs.
  8. Read-only connections. The server sets connectionConfig.readOnly only 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 applied is 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).
  9. The client editing gate. isDocumentEditingLocked() covers a content-fork freeze, the WS authorizedScope === 'readonly' and the SSR Read-only lock for a non-owner (isDocumentEditingLocked.ts#L8-L17). editor.isEditable is 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 on view.editable (previewHyperlink.ts#L155-L157).
  10. Caches that remain without the write.
  11. 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 title and image values 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 title is still read as metadata. A stored image is not.
  • Alternative C: opening a preview never changes the document. Stored title and image are 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 on APP_PORT 4000 (.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/page with no stored title. 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:

  1. 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 from DATABASE_URL in .env.local:
      docker 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
      Compare the head, not a row count, because pruneAutosaveVersions thins rows (retention.ts#L62).
    • On B, run JSON.stringify(document.querySelector('.ProseMirror').editor.getJSON()) in the console and keep the string.
    • Note the time T0 in 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 a title attribute. On B, run the same JSON.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.
  2. 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):

  1. linkMarkUtils.ts: delete writeLinkMetadataAttrs with its comment (L25-L70) and MarkMetadata (L16-L23).
  2. previewShared.ts:
    • Remove the writeLinkMetadataAttrs import (L17), RenderHandle (L151-L153), pending and the flush return (L186-L197).
    • renderMetadataInto returns void. Rewrite its JSDoc (L155-L159) so it no longer says "MUST invoke flush()".
  3. previewHyperlink.ts:
    • buildDesktopPopover returns the element only (L100, L164, L168-L170).
    • Keep observeDetachment (L85-L88), because it still aborts the fetch. Its callback calls only controller.abort(). Replace the comment at L82-L84 with one line: the observer aborts the fetch on detach.
  4. LinkPreviewSheet.tsx: delete the effect (L39-L43) and its import (L13). Drop useEffect from the L9 import, and editor and nodePos from the L33 destructure, when nothing else uses them. bun run lint catches leftovers.
  5. Stop the L1 read (Decision 1):
    • previewShared.ts: delete the stored-attr branch (L163-L182).
    • useLinkMetadata.ts: delete fromMarkAttrs, UseLinkMetadataOptions and the initialTitle branch (L40-L48, L59-L65). Drop the options parameter.
    • LinkPreviewSheet.tsx: call useLinkMetadata(href) and drop attrs from the L33 destructure.
    • Keep ctx.attrs where other code still reads it.
    • Under Alternative B, skip the four sub-bullets above. Instead, keep the title read and drop the image read.
      • previewShared.ts: delete existingImage (L165) and the image field (L174). Change return { flush: () => {} } (L181) to return. Set the L163 comment to "L1 cache: stored mark title."
      • useLinkMetadata.ts: drop initialImage (L47, L60), the image parameter of fromMarkAttrs (L40-L43) and its argument in the L64 call.
      • LinkPreviewSheet.tsx: drop the initialImage line (L36).
  6. migration-extensions.ts: keep both stub attrs, because stored documents still hold them. Reword L49 to: "title/image are 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):

  1. Locked viewer, measured first. Run this before the edit, and post the results as a comment here.
    • Remove the stored title again 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> gains title, and whether B's string is the same as before.
    • On A, record whether its own <a> gains title. 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 title attribute.
  2. Add one helper in linkMarkUtils.ts: canWriteLinkMetadata(editor) = editor.isEditable && !isDocumentEditingLocked().
    • A short isEditable false skips only a cache write, which is harmless.
    • The lock check covers the durable cases.
  3. Call it first in writeLinkMetadataAttrs. Return early when it is false.
  4. Keep flush, RenderHandle and 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.

  1. Make previews read-only: stop the write, and stop reading stored title and image? 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 title would show as the site's own title, which is a spoof.
      • A stored image is a URL an editor chose. Every reader's browser would load it, so it can track readers and never expires.
    • 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 title only.
    • Alternative C: stop the write and keep both reads, as today.
    • Parity note (AGENTS.md §Workflow And Review Expectations). New links lose the native title tooltip 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 no title. 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 version in Documents does 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> without title. After the lock lifts and A reloads, B still gains nothing.
  • Recommended route: a link with a stored title makes one /metadata request 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 title makes no /metadata request on desktop or on the mobile sheet. Under Alternative B, the preview shows no stored image.
  • Old links with stored title and image still open a working preview on desktop and mobile.
  • No stale comment names writeLinkMetadataAttrs, flush or "persisted mark attrs" (git grep -n "writeLinkMetadataAttrs\|RenderHandle" returns nothing on the recommended route, Alternative B and Alternative C).
  • hyperlink-preview-edit.cy.js passes.

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:3000
  • bun run check includes check:agent-docs, which covers the extensions/CLAUDE.md edit.
  • Cypress needs make dev-local up. Use the webapp port that Step 0 confirmed.
  • visitEditor defaults to http://localhost:3001 unless EDITOR_BASE_URL or baseUrl is 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 title on B, the same JSON.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 stored title, 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: "Only editor.isEditable && !isDocumentEditingLocked() writes preview metadata." Alternative B: the recommended line, plus "A stored title is still read first." Alternative C: the recommended line, plus "Stored title and image are still read first." extensions/CLAUDE.md:27 keeps the extension AGENTS.md for 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

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

  1. added
    bugSomething isn't working
    EditorTiptap & Prosemirror
    PerformanceQuery cost, load, and capacity
    on Oct 18, 2026
  2. HMarzban commented on Oct 18, 2026

    @HMarzban
    CollaboratorAuthor

    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.

    • Decision 1: the recommended route. "Read-only previews." Opening a link preview writes nothing to the document, and the preview does not read a stored title or image. New links lose the fetched native tooltip; that is accepted. Alternatives A, B and C are not taken.

    Recorded on the tracker #512.

  3. added this to the v2.3.0 milestone on Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    EditorTiptap & ProsemirrorPerformanceQuery cost, load, and capacitybugSomething isn't working

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions