Repository navigation
Fill the link Edit forms from the document, not from innerText #518
Description
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 throughgetHyperlinkDisplayText. 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.
getHyperlinkDisplayTextthrows on a position of-1or past the document end. It can also return the text of a neighbouring link whennodePosis 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 fromnodePos. It guards the position, and it matches the mark byhref. 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:
- Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513: the extension clean-room spec
cypress/e2e/link-icons.cy.tsasserts "the Edit form text equals the link text". - Show a link icon before every pad link in the webapp #516: the webapp Cypress prefill spec
apps/webapp/cypress/e2e/document/hyperlink-icon-prefill.cy.tsasserts the Edit form Text on desktop with an icon present. - So this issue adds no test. It reruns the existing specs, plus those two specs when they exist.
- Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513: the extension clean-room spec
Depends on / Blocks
- Depends on nothing. It can land before or after Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513.
- Open PR Bump TipTap packages to 3.32.0 for the remote-cursor fix #473 does not change the order. It moves
@tiptap/coreto 3.32.0,prosemirror-modelto 1.25.12 andprosemirror-viewto 1.42.6. The source lines that The shared helper today cites are the same in those versions. So this issue can land before or after Bump TipTap packages to 3.32.0 for the remote-cursor fix #473. - If it lands before the 2.3.0 family release, the extension change ships in that release. Otherwise it waits for the next one. The release is the maintainer's step in [Tracking] Inline link icons through a hyperlink mark view #512.
- Related: Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 and Show a link icon before every pad link in the webapp #516. Their ruled specs pin the prefill value with icons. If they have not landed, skip the iconed-link walk and say so at the Review checkpoint.
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
previewHyperlinkbuilds the floating preview. Its Edit button callsopenEditHyperlink(previewHyperlink.ts#L146-L148). That runs the configurededitHyperlink, which iseditHyperlinkDesktopfor the pad and for the desktop composer (getHyperlinkPopoverConfig.ts#L25-L29; composer wiring at useTiptapEditor.ts#L185-L188). - Mobile pad.
previewHyperlinkopens thelinkPreviewsheet and returnsnull(previewHyperlink.ts#L56-L66). The sheet's Edit action is row 2. It never callsopenEditHyperlink. - Why row 3 has no user path. On mobile, the pad wires
editHyperlinkMobilethroughgetHyperlinkPopoverConfig(isMobile)(TipTap.tsx#L211), butpreviewHyperlinkopens the sheet and never callsopenEditHyperlink. 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?)resolvesnodePos, takes the mark range, and returnsdoc.textBetween. It falls back toinnerTextonly 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 pastdoc.content.size(installed prosemirror-model 1.25.11src/resolvedpos.ts:219).view.posAtDOMreturns-1for a detached<a>; it does not throw (installed prosemirror-view 1.42.3src/index.ts:421-424,src/viewdesc.ts:288-293). A probe confirmed both (laptop, jsdom view, no layout). - Defect 2, a neighbour's text.
getMarkRangetakes the node after the position first, and looks back only when that node has no mark of the type (installed@tiptap/core3.31.3src/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 anodePosmeant for link A (laptop, jsdom view, no layout). nodePoscan be stale. It is captured when the preview opens.EditHyperlinkOptions.nodePosis optional (hyperlink.ts#L72-L84). The extension already trusts the live<a>first:findLiveEquivalentAnchorreturns a connected link as is (liveAnchor.ts#L9).buildPreviewOptionsFromAnchorthen asksposAtDOM(liveLink, 0)before it usesnodePos(buildPreviewOptionsFromAnchor.ts#L33-L42).- The prebuilt popover runs before the live anchor is found.
openEditHyperlinkcalls the factory withopts.linkat line 20, and runsfindLiveEquivalentAnchoronly at line 22 (openEditHyperlink.ts#L19-L22). The new read handles a detached<a>by falling back tonodePos, so it needs no change there. - The prebuilt popover already honours
markNameon 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:
- Live
<a>first. It matchesbuildPreviewOptionsFromAnchorandfindLiveEquivalentAnchor. A connected<a>is always current, whilenodePosmay be stale after a collab edit. - Position guard. It removes defect 1. A detached
<a>gives-1, and the read then usesnodePosor falls back. hrefmatch. It removes defect 2. With a filter,getMarkRangereturns nothing when the node after the position belongs to another link. The read then falls back instead of showing the wrong text.- The
<a>hrefattribute equals the mark'shref. The one exception is an unsafe href, whichrenderHTMLblanks to""(hyperlink.ts#L263-L272). An empty href means no filter.
- The
posAtDOM(a, 0)after Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513. It still gives the link start on prosemirror-view 1.42.3 and 1.42.6 with the mark view (Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 Step 5 and its test 9 pin this).
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
- Read every file in the Current behaviour table, plus the files it cites in prose:
previewHyperlink.ts,getHyperlinkPopoverConfig.ts,openEditHyperlink.ts,liveAnchor.ts,buildPreviewOptionsFromAnchor.tsanddocs/popovers.md. - Read
extensions/extension-hyperlink/AGENTS.md§Public Surface (AGENTS.md#L78-L84) and §Floating Popover. - Confirm that
getHyperlinkDisplayTexthas only the two callers in rows 4 and 5:git grep -n getHyperlinkDisplayText -- apps/webapp/src.
Step 1. Webapp helper
- In
linkMarkUtils.ts, replacegetHyperlinkDisplayText(lines 72-83 at the pinned commit) with the body in Target behaviour. Keep the name. Thelinktype becomesHTMLAnchorElement. - Keep the JSDoc to 4 lines or fewer. Say why, not what.
Step 2. Webapp callers
desktopPopoverEntries.ts: line 62 becomes the row 1 call. Delete theinnerTextcomment on line 61. Import the helper from./linkMarkUtils.mobilePopoverEntries.ts: line 47 becomes the row 3 call.linkPreviewActions.tsx: replace lines 81-86 with the row 2 call. Keep the comment on line 80. Remove thegetMarkRangeimport if nothing else uses it.composerMobilePopoverEntries.ts:63: the row 4 call.composerLinkDialogStore.ts:117: the row 5 call.
Step 3. Extension helper
- Add
extensions/extension-hyperlink/src/popovers/getHyperlinkDisplayText.tswith the same body. UsemarkName = HYPERLINK_MARK_NAMEfrom../constants. - Keep it internal. Do not add it to any barrel:
src/popovers/index.ts;src/openers/index.ts;src/utils/index.ts, becausesrc/index.tsre-exports that file withexport *(index.ts#L33);src/index.ts.- This follows the internal-helper rule in AGENTS.md#L81-L82.
- In
editHyperlinkPopover.ts:23, setvalueto the row 6 call. Do not resolve the live anchor first; the helper falls back tonodePos.
Step 4. Public doc
- In
extensions/extension-hyperlink/docs/popovers.mdlines 228-233, change the BYO example to read Text from the document. Use only public APIs:getMarkRangefrom@tiptap/core,editor.view.posAtDOM(link, 0)anddoc.textBetween. - Guard a negative position, and fall back to
link.textContent. - Reword the comment so "the prebuilt form does the same" covers both the raw
hrefand the Text read. - 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 frominnerText. Before, CSS such astext-transformcould 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 throughgetHyperlinkDisplayTextinlinkMarkUtils.ts, never frominnerTextdirectly."extensions/extension-hyperlink/AGENTS.md§Floating Popover: one line. "The prebuilt edit popover fills Text through the internalgetHyperlinkDisplayText. It keeps the same body as the webapp copy inlinkMarkUtils.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:
- Live
<a>ornodePosfirst? Live<a>first, as the extension's own openers do. - Stale
nodePosat a neighbour? Thehrefmatch stops it. The read falls back instead. markNamein the extension? Yes,options.markName ?? HYPERLINK_MARK_NAME, as ateditHyperlinkPopover.ts:119.- One helper or one per file? One per package, with the same name and shape. The webapp cannot import an internal extension helper.
- New tests? No. The ruled list has none for this issue. Add a linkIcon option and a hyperlink mark view that draws an icon before each link #513 and Show a link icon before every pad link in the webapp #516 pin the prefill value.
Acceptance criteria
-
git grep -n "innerText" -- apps/webapp/src/components/TipTap/hyperlinkPopovers extensions/extension-hyperlink/src/popoversshows no Edit-form prefill outside the twogetHyperlinkDisplayTextfallbacks. Lines that setinnerTexton new elements are fine. - All six rows in Current behaviour call
getHyperlinkDisplayTextwith the arguments in Target behaviour. - The webapp and extension helpers have the same name, signature and body, except for the
markNamedefault. -
getHyperlinkDisplayTextis not reachable from any extension barrel.bun run --filter @docs.plus/extension-hyperlink build, thengrep -c getHyperlinkDisplayText extensions/extension-hyperlink/dist/index.d.tsprints0. - Every command in Verification passes.
- In each browser walk, the Edit form Text equals the link text, including the bold-first link.
-
docs/popovers.mdno longer readslink.innerTextin 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 checkWebapp Cypress:
- See [Tracking] Inline link icons through a hyperlink mark view #512 §Local stack.
bun --cwd="$PWD/apps/webapp" run cypress:run --spec cypress/e2e/editor/hyperlink/hyperlink-preview-edit.cy.js --config baseUrl=http://localhost:3000- If Show a link icon before every pad link in the webapp #516 has landed, also run its prefill spec. It lives in
e2e/document/, not ine2e/editor/hyperlink/, because it needs the full local stack.
bun --cwd="$PWD/apps/webapp" run cypress:run --spec cypress/e2e/document/hyperlink-icon-prefill.cy.ts --config baseUrl=http://localhost:3000- The two existing specs pin only a plain link on desktop (hyperlink-preview-edit.cy.js#L125-L132, preview-edit.cy.ts#L84-L87). The walks below cover the rest.
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.
- Desktop pad, row 1. Desktop Chrome.
- Desktop chat composer, row 1. Desktop Chrome.
- 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.mdline 119). - Mobile chat composer, rows 4 and 5. Chrome with an iPhone user agent. Open Edit directly, and also from the composer preview.
- Extension playground, row 6. Desktop Chrome. Run
bun run --filter @docs.plus/extension-hyperlink playground, then openhttp://127.0.0.1:5173. The playground loads builtdist/, so build first (AGENTS.md#L99). - 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
markNameis checked by reading the code only.
Docs to update
extensions/extension-hyperlink/CHANGELOG.md(Step 5).extensions/extension-hyperlink/docs/popovers.mdlines 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 bytextContent. 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.mdandapps/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
HMarzban commentedon Oct 25, 2026
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.