Repository navigation
Land a heading link on its heading, even when the heading is folded or filtered #338
Copy link
Copy link
Open
Labels
DesktopEditorTiptap & ProsemirrorTiptap & ProsemirrorMobileUIbugSomething isn't workingSomething isn't working
Description
Parent
What to build
A heading link fails in two ways today. A click on a link to a folded or filtered heading does nothing. A cold-opened ?id= link, for example from a digest email, scrolls before the document syncs, so it can land at the top. After this change, both paths land on the heading. A hidden target unfolds for this session only, and folds saved on the device do not change.
Acceptance criteria
- A click on a heading link to a folded target unfolds only the sections that hide it. The heading then scrolls to the top of the pad.
- The same click works when a Filter hides the target.
- A cold tab on
/<pad>?id=<toc-id>of a long pad shows that heading at the top after sync. This holds on desktop and on a phone. - A cold tab on
/<pad>/<term>?id=<toc-id>lands on the heading after the Filter applies, and the Filter does not fold it again. - After a click arrival on a pad address with no
?id=, a reload shows the target's ancestor section folded again. - After that click arrival, fold a different section by hand and reload: that manual fold is kept.
- A
toc-idthat holds a quote or a backslash does not throw. - A link to a heading that no longer exists behaves as today: nothing moves, and nothing throws.
- The change adds no new
setTimeout.
Blocked by
None — can start now.
Agent brief
Type: AFK — an agent can finish this alone. The browser check below is required before the issue closes.
Category: bug
Current behavior:
- Cold open:
scrollDowninapps/webapp/src/components/TipTap/TipTap.tsx:73-85runs fromonCreate(:268) plus a 200 ms timer. It does not wait for sync. It usesblock: 'nearest', and its selector[data-toc-id="${id}"]is not escaped. - Click:
runInternalDocumentLinkcallsscrollToHeading(link.headingId)with the defaultblock: 'nearest'(TipTap/hyperlinkPopovers/internalDocumentLinkActions.ts:30-32). scrollToHeadingreturns silently when no heading matches (apps/webapp/src/utils/scrollToHeading.ts:22-25). A folded heading still matches, but it isdisplay: none, so the scroll does nothing. It already escapes the id (escapeAttr,:12-17).- A folded body gets the
heading-fold-hiddenclass, which isdisplay: none !important(apps/webapp/src/styles/editor/_heading-fold.scss:12-14). - Filter hides sections through the same fold state.
HeadingFiltergets afoldAdapterwhosesetTemporaryFoldssends a foldsetmeta withpersist: false(TipTap.tsx:154-165). - Find already reveals a hidden hit for the session only:
revealHitandfoldedSectionsAtinTipTap/extensions/caret-find/caret-find-plugin.ts:101-183.
Desired behavior:
- One arrival function: unfold the sections that hide the target for this session, then call
scrollToHeading(id, { block: 'start' }). - The click path and the cold-open path both call it.
- The cold-open path runs from a new hook. Gate it like
useApplyFilters(apps/webapp/src/hooks/useApplyFilters.ts:19):router.isReady, the editor instance set, notloading, notproviderSyncing. It runs once per?id=value, like thehandledRefguard inuseCheckUrlAndOpenHeadingChat(hooks/useCheckUrlAndOpenHeadingChat.ts:48-50). RemovescrollDownand theonCreate: scrollDownline. - The click path has no editor argument today. Read the editor from the store (
useStore.getState().settings.editor.instance). - Trap (fold storage): a fold
setmeta withpersist: falseturns onskipPersistin the fold plugin state (TipTap/extensions/heading-fold/heading-fold-plugin.ts:347-357). That flag stays on until the nextsetwithoutpersist: false. Find restores the old value when Find closes (closeFindTr,caret-find-plugin.ts:227). An arrival has no close step. So a plainpersist: falseset makes every later manual fold stop saving for the session. The two reload criteria above catch this. Follow the pattern offoldedIdsIncludingFind(caret-find-plugin.ts:186-190): the save must still see the arrival-opened ids as folded.
Where to start:
- Move
foldedSectionsAtnext to the fold plugin (for example intoextensions/heading-fold/helpers/), and import it from Find. Do not copy it. scrollToHeading.ts,internalDocumentLinkActions.ts,TipTap.tsx(scrollDown),hooks/useEditorAndProvider.ts(mountsuseCheckUrlAndOpenHeadingChatanduseApplyFilters).- Cold Filter URLs: the arrival must run after the Filter applies.
useApplyFiltersrunseditor.commands.applyFiltersynchronously in an effect.useEditorAndProvidercallsuseCheckUrlAndOpenHeadingChat()(:25) beforeuseApplyFilters()(:29). React runs effects of one component in call order. So call the new hook afteruseApplyFilters(), with the same gate. - Line numbers are hints as of 2026-09-28; the agent searches by symbol.
Rules that apply:
- CONTEXT.md §Pad tools (Find unfolds a hit for the session only and never writes folds to storage).
- apps/webapp/CLAUDE.md §Document Filters: never poll the DOM for readiness; gate on
!loadingand!providerSyncing. - apps/webapp/CLAUDE.md §Heading Fold Crinkle and §TOC And Heading Actions.
- apps/webapp/src/components/TipTap/CLAUDE.md §Editor State And References: leaf
useStoreselectors; the canonical editor handle. - AGENTS.md §Test Policy: add a test only if the arrival order gets dense branching. A Cypress spec beside
cypress/e2e/editor/heading/heading-fold-unfold.cy.jsfits best. If you add one, prove it by sabotage. The same section says the mobile shell is user-agent gated.
Verify:
cd apps/webapp && bun run typecheck, thenbun run lintat the root.- Start the local stack with
make dev-local. Note the webapp port; it is not always 3000. Fromapps/webapp, runbunx cypress run --spec "cypress/e2e/editor/heading/*,cypress/e2e/editor/find/*,cypress/e2e/editor/filter/*" --env EDITOR_BASE_URL=http://localhost:<port>. It stays as green as before. The Filter apply Cypress test has never passed #311 notes one Filter spec that has never passed. - Browser, desktop, light and dark, on a pad address with no
?id=: fold a section that holds an H2. Elsewhere, press Mod-k and pick that H2 to insert a heading link. Click it: the section opens and the H2 sits at the top. Reload: the section is folded again. - Browser: after that click arrival, fold another section by hand, then reload. That fold is kept.
- Browser: apply a Filter that hides the H2, then click the link. The H2 shows at the top.
- Browser: open
/<pad>?id=a%22b%5Cand/<pad>?id=<deleted-toc-id>. Nothing moves, and the console shows no error. - Cold tab: open a fresh profile or private window on
/<pad>?id=<toc-id>for a heading far down a long pad. Throttle the network to "Slow 4G". The heading lands at the top after the skeleton leaves. - Phone: repeat the cold tab with a mobile user agent (CDP
Network.setUserAgentOverride). A narrow window renders the desktop shell and proves nothing. A maintainer walks it on iOS Safari and Android Chrome after the agent finishes.
Out of scope
- The first Tab landing on a TOC row after a deep link — A heading deep link puts the first Tab on a TOC row #309. This issue leaves one scroll call, so A heading deep link puts the first Tab on a TOC row #309 fixes one place.
- The 800 ms timer in
useCheckUrlAndOpenHeadingChat— Replace the 800ms setTimeout in useCheckUrlAndOpenHeadingChat with a real readiness signal #150. - A toast for a link to a deleted heading.
- Building clean heading links — Build stored heading and message links from the pad path, not the address bar #331.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
DesktopEditorTiptap & ProsemirrorTiptap & ProsemirrorMobileUIbugSomething isn't workingSomething isn't working
Activity