Skip to content

Land a heading link on its heading, even when the heading is folded or filtered #338

Description

@HMarzban

Parent

#328. Related: #309, #150

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-id that 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: scrollDown in apps/webapp/src/components/TipTap/TipTap.tsx:73-85 runs from onCreate (:268) plus a 200 ms timer. It does not wait for sync. It uses block: 'nearest', and its selector [data-toc-id="${id}"] is not escaped.
  • Click: runInternalDocumentLink calls scrollToHeading(link.headingId) with the default block: 'nearest' (TipTap/hyperlinkPopovers/internalDocumentLinkActions.ts:30-32).
  • scrollToHeading returns silently when no heading matches (apps/webapp/src/utils/scrollToHeading.ts:22-25). A folded heading still matches, but it is display: none, so the scroll does nothing. It already escapes the id (escapeAttr, :12-17).
  • A folded body gets the heading-fold-hidden class, which is display: none !important (apps/webapp/src/styles/editor/_heading-fold.scss:12-14).
  • Filter hides sections through the same fold state. HeadingFilter gets a foldAdapter whose setTemporaryFolds sends a fold set meta with persist: false (TipTap.tsx:154-165).
  • Find already reveals a hidden hit for the session only: revealHit and foldedSectionsAt in TipTap/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, not loading, not providerSyncing. It runs once per ?id= value, like the handledRef guard in useCheckUrlAndOpenHeadingChat (hooks/useCheckUrlAndOpenHeadingChat.ts:48-50). Remove scrollDown and the onCreate: scrollDown line.
  • The click path has no editor argument today. Read the editor from the store (useStore.getState().settings.editor.instance).
  • Trap (fold storage): a fold set meta with persist: false turns on skipPersist in the fold plugin state (TipTap/extensions/heading-fold/heading-fold-plugin.ts:347-357). That flag stays on until the next set without persist: false. Find restores the old value when Find closes (closeFindTr, caret-find-plugin.ts:227). An arrival has no close step. So a plain persist: false set makes every later manual fold stop saving for the session. The two reload criteria above catch this. Follow the pattern of foldedIdsIncludingFind (caret-find-plugin.ts:186-190): the save must still see the arrival-opened ids as folded.

Where to start:

  • Move foldedSectionsAt next to the fold plugin (for example into extensions/heading-fold/helpers/), and import it from Find. Do not copy it.
  • scrollToHeading.ts, internalDocumentLinkActions.ts, TipTap.tsx (scrollDown), hooks/useEditorAndProvider.ts (mounts useCheckUrlAndOpenHeadingChat and useApplyFilters).
  • Cold Filter URLs: the arrival must run after the Filter applies. useApplyFilters runs editor.commands.applyFilter synchronously in an effect. useEditorAndProvider calls useCheckUrlAndOpenHeadingChat() (:25) before useApplyFilters() (:29). React runs effects of one component in call order. So call the new hook after useApplyFilters(), 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 !loading and !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 useStore selectors; 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.js fits 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, then bun run lint at the root.
  • Start the local stack with make dev-local. Note the webapp port; it is not always 3000. From apps/webapp, run bunx 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%5C and /<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

Activity

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

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions