Skip to content

Fill the link Edit forms from the document, not from innerText #518

Open

Description

@HMarzban

Part of #512. Read the tracker first. Work on main.

Summary

  • Today. Six code paths fill the Text field of a link Edit form. Three read the DOM with innerText. Two read the document through getHyperlinkDisplayText. One reads the document inline, with no fallback. The table in Current behaviour lists all six.
  • Why it matters. A DOM read is right only while the <a> holds nothing but link text. After Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513, an iconed <a> holds an icon and a <span>. The icon has no text, so the value stays right. But the forms then depend on a DOM rule that nobody pins.
  • Two defects in the shared helper. getHyperlinkDisplayText throws on a position of -1 or past the document end. It can also return the text of a neighbouring link when nodePos is stale.
  • After. One read, with one name and one argument shape, in the webapp and in the extension. It takes the position from the live <a> first, then from nodePos. It guards the position, and it matches the mark by href. The DOM read stays only as a fallback.

Maintainer rulings that bind this issue

  • Ruling 17, file the side issues (2026-10-17). This issue is the side issue "Edit-form prefill from doc text", adjusted to the mark-view plan.
  • Ruling 15, tests (2026-10-17). The ruled test list has no prefill test in this issue. The prefill value is pinned by two ruled specs that other issues own:

Depends on / Blocks

Current behaviour

All links are pinned to 69ca1ef1649540f7c8989dfd74ad901c741a30b1.

Every Edit-form prefill today

# Surface Code Read
1 Desktop pad Edit, and desktop chat composer Edit editHyperlinkDesktop (desktopPopoverEntries.ts#L57-L62) DOM: opts.link.innerText ?? opts.link.textContent
2 Mobile pad Edit, the real user path buildLinkPreviewActions (linkPreviewActions.tsx#L80-L95) Document, inline: getMarkRange plus textBetween. No guard, no fallback; undefined when no range
3 editHyperlinkMobile mobilePopoverEntries.ts#L42-L53 DOM, line 47. No user path reaches it today (see below)
4 Mobile chat composer Edit editHyperlinkComposerMobile (composerMobilePopoverEntries.ts#L46-L66) Document, through getHyperlinkDisplayText(editor, nodePos, link)
5 Chat composer dialog, Edit from its preview openEditFromPreview (composerLinkDialogStore.ts#L105-L121) Document, through getHyperlinkDisplayText(editor, nodePos)
6 The extension's prebuilt edit popover editHyperlinkPopover.ts#L21-L25 DOM: link.innerText || ''. The webapp never runs it, because the webapp always sets popovers.editHyperlink

How each surface is reached

  • Desktop pad and desktop chat composer. On desktop, the webapp's previewHyperlink builds the floating preview. Its Edit button calls openEditHyperlink (previewHyperlink.ts#L146-L148). That runs the configured editHyperlink, which is editHyperlinkDesktop for the pad and for the desktop composer (getHyperlinkPopoverConfig.ts#L25-L29; composer wiring at useTiptapEditor.ts#L185-L188).
  • Mobile pad. previewHyperlink opens the linkPreview sheet and returns null (previewHyperlink.ts#L56-L66). The sheet's Edit action is row 2. It never calls openEditHyperlink.
  • Why row 3 has no user path. On mobile, the pad wires editHyperlinkMobile through getHyperlinkPopoverConfig(isMobile) (TipTap.tsx#L211), but previewHyperlink opens the sheet and never calls openEditHyperlink. This is read from code, not measured; the issue still changes row 3, so that every entry shares one read.

The shared helper today

  • getHyperlinkDisplayText(editor, nodePos, link?) resolves nodePos, takes the mark range, and returns doc.textBetween. It falls back to innerText only when no range is found (linkMarkUtils.ts#L72-L83).
  • Defect 1, a throw. doc.resolve(pos) throws "Position -1 out of range" for any position below 0 or past doc.content.size (installed prosemirror-model 1.25.11 src/resolvedpos.ts:219). view.posAtDOM returns -1 for a detached <a>; it does not throw (installed prosemirror-view 1.42.3 src/index.ts:421-424, src/viewdesc.ts:288-293). A probe confirmed both (laptop, jsdom view, no layout).
  • Defect 2, a neighbour's text. getMarkRange takes the node after the position first, and looks back only when that node has no mark of the type (installed @tiptap/core 3.31.3 src/helpers/getMarkRange.ts:52-62). At a position where link A ends and link B starts, it returns link B. The probe returned "Other" (link B) for a nodePos meant for link A (laptop, jsdom view, no layout).
  • nodePos can be stale. It is captured when the preview opens. EditHyperlinkOptions.nodePos is optional (hyperlink.ts#L72-L84). The extension already trusts the live <a> first: findLiveEquivalentAnchor returns a connected link as is (liveAnchor.ts#L9). buildPreviewOptionsFromAnchor then asks posAtDOM(liveLink, 0) before it uses nodePos (buildPreviewOptionsFromAnchor.ts#L33-L42).
  • The prebuilt popover runs before the live anchor is found. openEditHyperlink calls the factory with opts.link at line 20, and runs findLiveEquivalentAnchor only at line 22 (openEditHyperlink.ts#L19-L22). The new read handles a detached <a> by falling back to nodePos, so it needs no change there.
  • The prebuilt popover already honours markName on submit: options.markName ?? HYPERLINK_MARK_NAME (editHyperlinkPopover.ts#L119).

Where innerText and the document differ

In a host with CSS text-transform, the prebuilt popover writes the transformed text back on Apply. So the CHANGELOG uses ### Fixed.

Public docs

The BYO edit example in docs/popovers.md reads link.innerText. Its comment says "The prebuilt form does the same" (popovers.md#L228-L233). That comment is about the raw href, but it reads as if it covers the Text field too. After this issue, it is wrong for the Text field.

Target behaviour

One read, one name, one shape

Both packages hold a function named getHyperlinkDisplayText with this exact signature and body. The only difference is the markName default: 'hyperlink' in the webapp, HYPERLINK_MARK_NAME in the extension.

import type { Editor } from '@tiptap/core'
import { getMarkRange } from '@tiptap/core'

/** Link text from the document. The live <a> wins over a stale nodePos; the href match stops a neighbour's text. DOM text is the last resort. */
export const getHyperlinkDisplayText = (
  editor: Editor,
  at: { nodePos?: number; link?: HTMLAnchorElement; href?: string | null; markName?: string }
): string => {
  const { link, markName = 'hyperlink' } = at
  const fallback = () => link?.innerText ?? link?.textContent ?? ''
  const type = editor.schema.marks[markName]
  const { doc } = editor.state
  let live = -1
  try {
    if (link?.isConnected) live = editor.view.posAtDOM(link, 0)
  } catch {
    // The <a> is in another editor; use nodePos.
  }
  const pos = live >= 0 ? live : at.nodePos
  if (!type || typeof pos !== 'number' || pos < 0 || pos > doc.content.size) return fallback()
  const href = at.href ?? link?.getAttribute('href')
  const range = getMarkRange(doc.resolve(pos), type, href ? { href } : undefined)
  return range ? doc.textBetween(range.from, range.to, '') : fallback()
}

Why each rule exists:

Probe results for this body

Label: laptop, jsdom view, no layout. The probe is local, not in the repo. The table is evidence, not a step to repeat. The document is ab [Bold rest](a.test)[Other](b.test) z, with "Bold" in bold.

Input Result
Live link A, no nodePos "Bold rest"
Live link A, stale nodePos 13 (link B start) "Bold rest"
No link, nodePos 4, no href "Bold rest"
No link, nodePos 13, no href "Other" (today's behaviour)
No link, nodePos 13, href a.test "" (no wrong text)
No link, nodePos 12, href a.test "Bold rest"
No link, nodePos 99 or -1 "" (no throw)
Detached link A, nodePos 4, the mark's href changed DOM fallback

The detached rows show a jsdom side effect: ProseMirror reused child nodes, so the detached <a> had lost part of its text. The DOM fallback can therefore be stale. That is today's behaviour on every DOM read, so this issue does not make it worse.

Each surface after the change

# Call
1 getHyperlinkDisplayText(opts.editor, { nodePos: opts.nodePos, link: opts.link })
2 getHyperlinkDisplayText(editor, { nodePos, href }), where href is the sheet payload's href
3 Same call as row 1
4 getHyperlinkDisplayText(opts.editor, { nodePos, link: opts.link })
5 getHyperlinkDisplayText(preview.editor, { nodePos: preview.nodePos }). No href, the same as today
6 getHyperlinkDisplayText(editor, { nodePos: options.nodePos, link, markName: options.markName })
  • For docs.plus links, the prefill value is the same as today, except where defect 1 or 2 used to apply.
  • In an extension host with CSS case changes, the prebuilt popover now shows the stored text. This is a fix.

Implementation steps

Step 0. Read first

  1. Read every file in the Current behaviour table, plus the files it cites in prose: previewHyperlink.ts, getHyperlinkPopoverConfig.ts, openEditHyperlink.ts, liveAnchor.ts, buildPreviewOptionsFromAnchor.ts and docs/popovers.md.
  2. Read extensions/extension-hyperlink/AGENTS.md §Public Surface (AGENTS.md#L78-L84) and §Floating Popover.
  3. Confirm that getHyperlinkDisplayText has only the two callers in rows 4 and 5: git grep -n getHyperlinkDisplayText -- apps/webapp/src.

Step 1. Webapp helper

  1. In linkMarkUtils.ts, replace getHyperlinkDisplayText (lines 72-83 at the pinned commit) with the body in Target behaviour. Keep the name. The link type becomes HTMLAnchorElement.
  2. Keep the JSDoc to 4 lines or fewer. Say why, not what.

Step 2. Webapp callers

  1. desktopPopoverEntries.ts: line 62 becomes the row 1 call. Delete the innerText comment on line 61. Import the helper from ./linkMarkUtils.
  2. mobilePopoverEntries.ts: line 47 becomes the row 3 call.
  3. linkPreviewActions.tsx: replace lines 81-86 with the row 2 call. Keep the comment on line 80. Remove the getMarkRange import if nothing else uses it.
  4. composerMobilePopoverEntries.ts:63: the row 4 call.
  5. composerLinkDialogStore.ts:117: the row 5 call.

Step 3. Extension helper

  1. Add extensions/extension-hyperlink/src/popovers/getHyperlinkDisplayText.ts with the same body. Use markName = HYPERLINK_MARK_NAME from ../constants.
  2. Keep it internal. Do not add it to any barrel:
    • src/popovers/index.ts;
    • src/openers/index.ts;
    • src/utils/index.ts, because src/index.ts re-exports that file with export * (index.ts#L33);
    • src/index.ts.
    • This follows the internal-helper rule in AGENTS.md#L81-L82.
  3. In editHyperlinkPopover.ts:23, set value to the row 6 call. Do not resolve the live anchor first; the helper falls back to nodePos.

Step 4. Public doc

  1. In extensions/extension-hyperlink/docs/popovers.md lines 228-233, change the BYO example to read Text from the document. Use only public APIs: getMarkRange from @tiptap/core, editor.view.posAtDOM(link, 0) and doc.textBetween.
  2. Guard a negative position, and fall back to link.textContent.
  3. Reword the comment so "the prebuilt form does the same" covers both the raw href and the Text read.
  4. Do not export the helper to make the example shorter. A new public export would be a new API, and nobody asked for one.

Step 5. CHANGELOG

  • extensions/extension-hyperlink/CHANGELOG.md, ## [Unreleased], ### Fixed: "The prebuilt edit popover fills Text from the document, not from innerText. Before, CSS such as text-transform could change the text that Apply wrote back."
  • Use ### Fixed, not ### Internal, because extension hosts can see the change. The webapp gets no CHANGELOG entry, because docs.plus users see no change.

Step 6. Rule lines

  • extensions/CLAUDE.md §Webapp-Owned Hyperlink Popovers (CLAUDE.md#L29): one line. "Webapp Edit forms fill Text through getHyperlinkDisplayText in linkMarkUtils.ts, never from innerText directly."
  • extensions/extension-hyperlink/AGENTS.md §Floating Popover: one line. "The prebuilt edit popover fills Text through the internal getHyperlinkDisplayText. It keeps the same body as the webapp copy in linkMarkUtils.ts; change both together."

Step 7. Run the checks

Run every command in Verification. Then do the browser walks there.

Review checkpoint

Show the touched files, the command output and the walk notes, then stop. Say whether the iconed-link walk ran or was skipped. Do not run git add, git commit, git push or git stash. Do not create a branch.

Decisions

None open. The answers below come from the code and the rulings:

Acceptance criteria

  • git grep -n "innerText" -- apps/webapp/src/components/TipTap/hyperlinkPopovers extensions/extension-hyperlink/src/popovers shows no Edit-form prefill outside the two getHyperlinkDisplayText fallbacks. Lines that set innerText on new elements are fine.
  • All six rows in Current behaviour call getHyperlinkDisplayText with the arguments in Target behaviour.
  • The webapp and extension helpers have the same name, signature and body, except for the markName default.
  • getHyperlinkDisplayText is not reachable from any extension barrel. bun run --filter @docs.plus/extension-hyperlink build, then grep -c getHyperlinkDisplayText extensions/extension-hyperlink/dist/index.d.ts prints 0.
  • Every command in Verification passes.
  • In each browser walk, the Edit form Text equals the link text, including the bold-first link.
  • docs/popovers.md no longer reads link.innerText in the BYO example.

Verification

Run from the repo root.

bun run --filter @docs.plus/extension-hyperlink typecheck
# pretest builds dist/, then runs unit tests and every clean-room Cypress spec,
# including preview-edit.cy.ts and transaction-edit.cy.ts, and link-icons.cy.ts once #513 lands.
bun run --filter @docs.plus/extension-hyperlink test
bun run --filter @docs.plus/webapp typecheck
bun run --filter @docs.plus/webapp test
bun run lint
bun run check

Webapp Cypress:

bun --cwd="$PWD/apps/webapp" run cypress:run --spec cypress/e2e/editor/hyperlink/hyperlink-preview-edit.cy.js --config baseUrl=http://localhost:3000
bun --cwd="$PWD/apps/webapp" run cypress:run --spec cypress/e2e/document/hyperlink-icon-prefill.cy.ts --config baseUrl=http://localhost:3000

Browser walks. In each walk, open Edit on a plain link, on a bold-first link, and on two touching links. Check that Text equals the link text. Use one browser per surface.

  1. Desktop pad, row 1. Desktop Chrome.
  2. Desktop chat composer, row 1. Desktop Chrome.
  3. Mobile pad, row 2. Chrome with an iPhone user agent. Tap a link, then Edit link in the sheet. A narrow window does not give the mobile shell (AGENTS.md line 119).
  4. Mobile chat composer, rows 4 and 5. Chrome with an iPhone user agent. Open Edit directly, and also from the composer preview.
  5. Extension playground, row 6. Desktop Chrome. Run bun run --filter @docs.plus/extension-hyperlink playground, then open http://127.0.0.1:5173. The playground loads built dist/, so build first (AGENTS.md#L99).
  6. Iconed link. Only if Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 has landed. Repeat walks 1, 3 and 5 on an iconed link. Otherwise skip, and say so at the Review checkpoint.

Known gaps, not walked:

  • Row 3 has no user path.
  • No harness fixture renames the mark, so a custom markName is checked by reading the code only.

Docs to update

  • extensions/extension-hyperlink/CHANGELOG.md (Step 5).
  • extensions/extension-hyperlink/docs/popovers.md lines 228-233 (Step 4). After the release, the maintainer refreshes Context7 as part of [Tracking] Inline link icons through a hyperlink mark view #512.
  • extensions/CLAUDE.md §Webapp-Owned Hyperlink Popovers (Step 6).
  • extensions/extension-hyperlink/AGENTS.md §Floating Popover (Step 6).

Out of scope

  • liveAnchor.ts, which matches anchors by textContent. An icon has no text, so it stays right.
  • Any change to the icon or the mark view.
  • A public export of getHyperlinkDisplayText.
  • The .trim() on the prebuilt popover's submitted text.
  • The stale DOM fallback on a detached <a>. It is today's behaviour, and the read now reaches it less often.

Read first

  • AGENTS.md, mainly §Test Policy and §Simplified English Mandate.
  • apps/webapp/CLAUDE.md and apps/webapp/src/components/TipTap/CLAUDE.md.
  • apps/webapp/src/components/chatroom/CLAUDE.md, for the composer link dialog.
  • extensions/CLAUDE.md §Webapp-Owned Hyperlink Popovers.
  • extensions/extension-hyperlink/AGENTS.md, mainly §Public Surface, §Floating Popover and §Clean-Room Harness.
  • .cursor/skills/release-extensions/SKILL.md §CHANGELOG style guide.

Activity

  1. added this to the v2.3.0 milestone on Oct 18, 2026
  2. HMarzban commented on Oct 25, 2026

    @HMarzban
    CollaboratorAuthor

    Built and committed on local main. Awaits the maintainer's final push.

    Remaining gate: the webapp deploy, and the extension release for the package part.

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

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions