Skip to content

Desktop: Fixes #15057: Stabilize heading checkbox hashes#15059

Closed
Sandesh13fr wants to merge 5 commits intolaurent22:devfrom
Sandesh13fr:desktop-fix-heading-checkbox-hash-stability-15057
Closed

Desktop: Fixes #15057: Stabilize heading checkbox hashes#15059
Sandesh13fr wants to merge 5 commits intolaurent22:devfrom
Sandesh13fr:desktop-fix-heading-checkbox-hash-stability-15057

Conversation

@Sandesh13fr
Copy link
Copy Markdown
Contributor

Summary

This PR fixes heading hash instability for task-marker headings so internal links remain valid when the checkbox state changes.

Problem

Headings such as:

## [ ] title

and:

## [x] title

previously produced different hashes. As a result, internal links could break after toggling the checkbox state in the heading.

Root cause

Heading hash generation and hash matching used raw heading text, so leading task markers affected the computed slug. Existing links using older raw-hash forms were also not consistently resolved after checkbox state changes.

What changed

  • added heading normalization for canonical hash generation by stripping a leading task marker prefix
  • updated canonical hash matching in CodeMirror and ProseMirror to use normalized heading text
  • added legacy hash fallback matching for backward compatibility with previously generated raw hashes such as x-title
  • added renderer legacy alias anchor output only when canonical and legacy hashes differ
  • added focused regression tests for canonical stability and legacy-hash compatibility

Why this is safe

Testing

Added/updated tests for:

  • canonical stability for headings starting with [ ], [x], and [X]
  • hash resolution after checkbox state changes
  • legacy x-... hash compatibility
  • renderer alias-anchor behavior when canonical and legacy hashes differ

Ran:

  • yarn workspace @joplin/renderer test --runInBand packages/renderer/MdToHtml.test.ts
  • yarn workspace @joplin/editor test --runInBand findLineMatchingLink.test.ts jumpToHash.test.ts commands.test.ts
  • yarn workspace @joplin/editor tsc --noEmit
  • yarn workspace @joplin/renderer tsc --noEmit

Fixes #15057

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1b43b516-ea15-477b-88e4-e642b7a63acb

📥 Commits

Reviewing files that changed from the base of the PR and between 024e3bb and 549a1a1.

📒 Files selected for processing (2)
  • .eslintignore
  • .gitignore
✅ Files skipped from review due to trivial changes (2)
  • .eslintignore
  • .gitignore

📝 Walkthrough

Walkthrough

Adds heading normalisation that strips leading checkbox markers before slug generation; emits both canonical and legacy heading hashes/IDs, updates heading iteration and link/jump resolution to accept either form, and preserves legacy anchor IDs in rendered HTML for backward compatibility.

Changes

Cohort / File(s) Summary
Heading Normalisation Utility
packages/editor/utils/normalizeHeadingForHash.ts
New function that removes leading checkbox-like prefixes ([ ], [x], [X]) from heading text before slugging.
Renderer Slug/Heading Output & Tests
packages/renderer/MdToHtml.ts, packages/renderer/MdToHtml.test.ts
Renderer now slugifies normalized headings, computes canonical and legacy IDs, de-duplicates with uniqueHash, and emits a legacy empty anchor (joplin-heading-legacy-anchor) when canonical ≠ legacy; tests added for task-marker headings and deduplication.
ProseMirror Heading Iteration
packages/editor/ProseMirror/utils/forEachHeading.ts
Callback signature changed to receive hashes: string[]; traversal collects heading metadata and produces both canonical and legacy hashes with de-duplication, then invokes the callback.
ProseMirror Jump-to-Hash & Tests
packages/editor/ProseMirror/utils/jumpToHash.ts, packages/editor/ProseMirror/commands/commands.test.ts
Consumer updated to match target against any hash in hashes (hashes.includes(targetHash)); tests added covering task-marker variants and legacy de-duplication.
CodeMirror Link Utilities & Tests
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.ts, packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.ts
Anchor matching computes a canonical hash via normalisation and falls back to legacy slug comparison; tests extended for headings containing checkbox/task markers.
CodeMirror Editor Commands & Tests
packages/editor/CodeMirror/editorCommands/jumpToHash.ts, packages/editor/CodeMirror/editorCommands/jumpToHash.test.ts
jumpToHash updated to compare provided hash against canonical (normalised) and legacy slugs; parameterised tests added for ATX headings with task markers.
Project Files: Ignores
.eslintignore, .gitignore
Added build/test-generated JS paths for the new modules/tests to ignore lists.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Editor as Editor (CodeMirror / ProseMirror)
    participant ForEach as forEachHeading
    participant Normalise as normalizeHeadingForHash
    participant Slug as uslug/slugify

    User->>Editor: Click internal link "#target"
    Editor->>ForEach: Iterate headings (callback receives hashes[])
    ForEach->>Normalise: normalise heading text
    Normalise->>Slug: slugify(normalisedText) -> canonicalHash
    ForEach->>Slug: slugify(originalText) -> legacyHash (if different)
    ForEach-->>Editor: callback(hashes = [canonical, legacy?], pos)
    Editor->>Editor: if hashes.includes(target) -> setSelection/scroll
    Editor-->>User: Navigate to heading
Loading
sequenceDiagram
    participant Renderer as Markdown Renderer
    participant NormaliseR as normalizeHeadingForHash
    participant SlugR as uslug/slugify
    participant Output as HTML output

    Renderer->>NormaliseR: normalise heading inline text
    NormaliseR->>SlugR: slugify(normalisedText) -> canonicalId
    Renderer->>SlugR: slugify(originalText) -> legacyId
    alt canonicalId != legacyId
        Renderer->>Output: emit heading id=canonicalId
        Renderer->>Output: emit empty anchor id=legacyId class="joplin-heading-legacy-anchor"
    else
        Renderer->>Output: emit heading id=canonicalId
    end
Loading

Suggested reviewers

  • personalizedrefrigerator
  • laurent22
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Desktop: Fixes #15057: Stabilize heading checkbox hashes' directly references the linked issue and clearly summarises the main change.
Description check ✅ Passed The description comprehensively explains the problem, root cause, solution approach, and testing performed, all directly related to the changeset.
Linked Issues check ✅ Passed All code changes directly address issue #15057 objectives: heading normalisation for stable hashing, canonical matching, legacy fallback matching, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are within scope of fixing heading hash stability; no unrelated modifications to parser behaviour, plugin code, or other subsystems are present.
Pr Description Must Follow Guidelines ✅ Passed The PR description includes a clear problem statement explaining heading hash instability and its impact on links, identifies the root cause, describes the solution approach with normalisation and legacy fallback matching, and provides specific testing commands for validation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai bot added bug It's a bug desktop All desktop platforms editor renderer About the note renderer labels Apr 9, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/editor/ProseMirror/commands/commands.test.ts (1)

86-107: Refactor repeated hash assertions into a table-driven loop.

This block repeats the same assertion structure five times; a compact table keeps intent identical with less duplication.

♻️ Proposed refactor
 	test('jumpToHash should resolve canonical and legacy hashes for task-marker headings', () => {
 		const editor = createTestEditor({ html: '<h2>[ ] title</h2><h2>[x] checked title</h2><h2>[X] done title</h2>' });

 		const jumpToHash = (hash: string) => {
 			return commands[EditorCommandType.JumpToHash](editor.state, editor.dispatch, editor, [hash]);
 		};

-		expect(jumpToHash('title')).toBe(true);
-		expect(editor.state.selection.$anchor.parent.textContent).toBe('[ ] title');
-
-		expect(jumpToHash('checked-title')).toBe(true);
-		expect(editor.state.selection.$anchor.parent.textContent).toBe('[x] checked title');
-
-		expect(jumpToHash('x-checked-title')).toBe(true);
-		expect(editor.state.selection.$anchor.parent.textContent).toBe('[x] checked title');
-
-		expect(jumpToHash('done-title')).toBe(true);
-		expect(editor.state.selection.$anchor.parent.textContent).toBe('[X] done title');
-
-		expect(jumpToHash('x-done-title')).toBe(true);
-		expect(editor.state.selection.$anchor.parent.textContent).toBe('[X] done title');
+		const cases = [
+			['title', '[ ] title'],
+			['checked-title', '[x] checked title'],
+			['x-checked-title', '[x] checked title'],
+			['done-title', '[X] done title'],
+			['x-done-title', '[X] done title'],
+		] as const;
+
+		for (const [hash, expectedHeading] of cases) {
+			expect(jumpToHash(hash)).toBe(true);
+			expect(editor.state.selection.$anchor.parent.textContent).toBe(expectedHeading);
+		}
 	});

As per coding guidelines: "Avoid duplicating code in tests; use test.each or shared helpers instead of repeating similar test blocks".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/editor/ProseMirror/commands/commands.test.ts` around lines 86 - 107,
The test 'jumpToHash should resolve canonical and legacy hashes for task-marker
headings' repeats the same assertion pattern; refactor it to a table-driven test
using test.each (or a simple loop) to supply pairs of hash and expected heading
text, keep the editor setup via createTestEditor and the jumpToHash helper that
calls commands[EditorCommandType.JumpToHash], and for each row assert the
command returns true and that editor.state.selection.$anchor.parent.textContent
equals the expected string (e.g. '[ ] title', '[x] checked title', '[X] done
title').
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/editor/ProseMirror/utils/forEachHeading.ts`:
- Around line 21-37: The duplicate-hash bug comes from using two separate sets
(seenCanonicalHashes and seenLegacyHashes) so a legacy hash can collide with
another node's canonical hash; replace both with a single shared Set (e.g.,
seenHashes) and use that same set when calling uniqueHash for canonicalHash and
legacyHash inside the doc.descendants callback (in forEachHeading.ts), ensuring
you still push legacyHash only if it differs from canonicalHash but that both
aliases are reserved in the shared set.

In `@packages/renderer/MdToHtml.ts`:
- Around line 652-662: The legacy anchor generation uses
uslug(headingTextToken.content) directly which diverges from the canonical ID
logic; update the legacy alias generation to use
uslug(normalizeHeadingForHash(headingText)) (i.e. call normalizeHeadingForHash
on headingTextToken.content before uslug) and add the same deduplication
mechanism used in forEachHeading.ts to ensure uniqueness (check existing
IDs/counters and append suffixes as needed) so the produced legacyHeadingId both
matches canonical normalization and avoids duplicate anchors; adjust references
around canonicalHeadingId, headingTextToken and the joplin-heading-legacy-anchor
output accordingly.

---

Nitpick comments:
In `@packages/editor/ProseMirror/commands/commands.test.ts`:
- Around line 86-107: The test 'jumpToHash should resolve canonical and legacy
hashes for task-marker headings' repeats the same assertion pattern; refactor it
to a table-driven test using test.each (or a simple loop) to supply pairs of
hash and expected heading text, keep the editor setup via createTestEditor and
the jumpToHash helper that calls commands[EditorCommandType.JumpToHash], and for
each row assert the command returns true and that
editor.state.selection.$anchor.parent.textContent equals the expected string
(e.g. '[ ] title', '[x] checked title', '[X] done title').
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8cb65b1a-264a-4bd0-b82c-3c68c3111626

📥 Commits

Reviewing files that changed from the base of the PR and between 9a180fc and 0c902c3.

📒 Files selected for processing (10)
  • packages/editor/CodeMirror/editorCommands/jumpToHash.test.ts
  • packages/editor/CodeMirror/editorCommands/jumpToHash.ts
  • packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.ts
  • packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.ts
  • packages/editor/ProseMirror/commands/commands.test.ts
  • packages/editor/ProseMirror/utils/forEachHeading.ts
  • packages/editor/ProseMirror/utils/jumpToHash.ts
  • packages/editor/utils/normalizeHeadingForHash.ts
  • packages/renderer/MdToHtml.test.ts
  • packages/renderer/MdToHtml.ts

Comment thread packages/editor/ProseMirror/utils/forEachHeading.ts Outdated
Comment thread packages/renderer/MdToHtml.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
packages/editor/ProseMirror/utils/forEachHeading.ts (1)

15-25: Duplicated uniqueHash function.

This function is duplicated in packages/renderer/MdToHtml.ts (lines 91-101). Consider extracting to a shared utility (e.g., packages/editor/utils/uniqueHash.ts or a package both can depend on).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/editor/ProseMirror/utils/forEachHeading.ts` around lines 15 - 25,
The duplicated uniqueHash function (uniqueHash(baseHash: string, seenHashes:
Set<string>)) should be extracted into a shared utility module and both callers
updated to import it; create a new utility (e.g., uniqueHash in a shared utils
package or packages/editor/utils/uniqueHash.ts), move the implementation there
(preserving behavior with seenHashes parameter and return), then replace the
local definitions in forEachHeading.ts and MdToHtml.ts with imports of the
shared uniqueHash and remove the duplicated code from both files.
packages/renderer/MdToHtml.ts (3)

654-654: Use T[] array type syntax.

ESLint flags the use of Array<T> syntax. Per project conventions, use T[] instead.

🔧 Suggested fix
-	const initializeSeenHeadingHashes = (tokens: Array<{ type: string; attrGet: (attrName: string)=> string|null }>) => {
+	const initializeSeenHeadingHashes = (tokens: { type: string; attrGet: (_attrName: string)=> string|null }[]) => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/renderer/MdToHtml.ts` at line 654, The function signature for
initializeSeenHeadingHashes uses the Array<T> syntax; change the parameter type
from Array<{ type: string; attrGet: (attrName: string) => string | null }> to
the bracket form ({ type: string; attrGet: (attrName: string) => string | null
})[] so the parameter tokens in initializeSeenHeadingHashes uses T[] per project
conventions; update only the type annotation for tokens to the bracket-style
array type.

87-89: Duplicated normalizeHeadingForHash implementation.

This function duplicates the logic in packages/editor/utils/normalizeHeadingForHash.ts. Consider importing from that shared utility to maintain a single source of truth, or if the renderer package cannot depend on the editor package, extract this to a shared package (e.g., @joplin/utils).

As per coding guidelines: "When duplicating a substantial block of code, add a comment above it noting the duplication and referencing the original location."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/renderer/MdToHtml.ts` around lines 87 - 89, The function
normalizeHeadingForHash in packages/renderer/MdToHtml.ts duplicates logic from
packages/editor/utils/normalizeHeadingForHash.ts; replace the duplicate by
importing and using the shared normalizeHeadingForHash utility (or move the
implementation to a shared package like `@joplin/utils` and import it) so there is
a single source of truth; if the renderer cannot depend on the editor package
immediately, add a clear comment above the local normalizeHeadingForHash noting
the duplication and referencing the original file path
(packages/editor/utils/normalizeHeadingForHash.ts) and create a follow-up TODO
to extract it to a shared module.

91-101: Duplicated uniqueHash implementation.

This function is identical to the one in packages/editor/ProseMirror/utils/forEachHeading.ts (lines 15-25). Consider extracting it to a shared utility module to avoid maintaining two copies.

As per coding guidelines: "When duplicating a substantial block of code, add a comment above it noting the duplication and referencing the original location."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/renderer/MdToHtml.ts` around lines 91 - 101, The function uniqueHash
in MdToHtml.ts is duplicated elsewhere; extract this logic into a single
exported utility (e.g., a shared util module exposing uniqueHash), replace the
local uniqueHash implementation in MdToHtml.ts and the other file with an import
from that utility, and update references to call the imported uniqueHash; if you
cannot extract now, add a comment above the duplicate implementation in
MdToHtml.ts noting the duplication and referencing the other implementation and
its function name so future maintainers can consolidate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/editor/ProseMirror/utils/forEachHeading.ts`:
- Around line 15-25: The duplicated uniqueHash function (uniqueHash(baseHash:
string, seenHashes: Set<string>)) should be extracted into a shared utility
module and both callers updated to import it; create a new utility (e.g.,
uniqueHash in a shared utils package or packages/editor/utils/uniqueHash.ts),
move the implementation there (preserving behavior with seenHashes parameter and
return), then replace the local definitions in forEachHeading.ts and MdToHtml.ts
with imports of the shared uniqueHash and remove the duplicated code from both
files.

In `@packages/renderer/MdToHtml.ts`:
- Line 654: The function signature for initializeSeenHeadingHashes uses the
Array<T> syntax; change the parameter type from Array<{ type: string; attrGet:
(attrName: string) => string | null }> to the bracket form ({ type: string;
attrGet: (attrName: string) => string | null })[] so the parameter tokens in
initializeSeenHeadingHashes uses T[] per project conventions; update only the
type annotation for tokens to the bracket-style array type.
- Around line 87-89: The function normalizeHeadingForHash in
packages/renderer/MdToHtml.ts duplicates logic from
packages/editor/utils/normalizeHeadingForHash.ts; replace the duplicate by
importing and using the shared normalizeHeadingForHash utility (or move the
implementation to a shared package like `@joplin/utils` and import it) so there is
a single source of truth; if the renderer cannot depend on the editor package
immediately, add a clear comment above the local normalizeHeadingForHash noting
the duplication and referencing the original file path
(packages/editor/utils/normalizeHeadingForHash.ts) and create a follow-up TODO
to extract it to a shared module.
- Around line 91-101: The function uniqueHash in MdToHtml.ts is duplicated
elsewhere; extract this logic into a single exported utility (e.g., a shared
util module exposing uniqueHash), replace the local uniqueHash implementation in
MdToHtml.ts and the other file with an import from that utility, and update
references to call the imported uniqueHash; if you cannot extract now, add a
comment above the duplicate implementation in MdToHtml.ts noting the duplication
and referencing the other implementation and its function name so future
maintainers can consolidate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e19592c3-34fe-42aa-89b8-9cc9b2dc618d

📥 Commits

Reviewing files that changed from the base of the PR and between 63e37de and 0ed5ed3.

📒 Files selected for processing (4)
  • packages/editor/ProseMirror/commands/commands.test.ts
  • packages/editor/ProseMirror/utils/forEachHeading.ts
  • packages/renderer/MdToHtml.test.ts
  • packages/renderer/MdToHtml.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/editor/ProseMirror/commands/commands.test.ts
  • packages/renderer/MdToHtml.test.ts

@laurent22
Copy link
Copy Markdown
Owner

Thanks for the contribution. We’re closing this PR because it does not follow the project’s contribution policy: it is not based on a triaged, accepted issue and does not address a concrete problem agreed upon by maintainers.

While the change itself is not necessarily incorrect, reviewing and maintaining such PRs takes time away from higher-priority work.

Please see our Contribution Scope Policy for details on what kinds of contributions we accept.

If you intend to participate in GSoC, please make sure you read our GSoC guidelines.

And please pay attention to our AI policy.

@laurent22 laurent22 closed this Apr 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug It's a bug desktop All desktop platforms editor renderer About the note renderer

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Internal links with checkboxes in headings: break when state changes

2 participants