<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Nora Alalou</title>
    <description>The latest articles on Forem by Nora Alalou (@nalalou).</description>
    <link>https://forem.com/nalalou</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3848429%2F62070345-3747-4456-a7d8-4a6b8f297fcf.jpeg</url>
      <title>Forem: Nora Alalou</title>
      <link>https://forem.com/nalalou</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/nalalou"/>
    <language>en</language>
    <item>
      <title>most AI-generated tests are worse than no tests</title>
      <dc:creator>Nora Alalou</dc:creator>
      <pubDate>Sun, 29 Mar 2026 17:17:33 +0000</pubDate>
      <link>https://forem.com/nalalou/most-ai-generated-tests-are-worse-than-no-tests-10no</link>
      <guid>https://forem.com/nalalou/most-ai-generated-tests-are-worse-than-no-tests-10no</guid>
      <description>&lt;p&gt;i started having claude write tests for my project and quickly realized something: most of them were useless. they passed, but they didn't test anything and i had a major sense of false security seeing  12/12 tests pass, etc. over and over.&lt;/p&gt;

&lt;p&gt;a test that asserts &lt;code&gt;expect(result).toBeDefined()&lt;/code&gt; after calling a function is technically a passing test. it will never fail unless the function throws.&lt;/p&gt;

&lt;p&gt;this was like 80% of what i was getting. tests that exercised code paths without actually checking that the code did the right thing so I had great code coverage but stuff was still breaking constantly.&lt;/p&gt;

&lt;p&gt;so i started thinking about what makes a test actually worth having, and i ended up with a set of gates that changed how i think about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;the mutation test is the most important one.&lt;/strong&gt; take a passing test, go flip a condition or change a return value in the source code, and run the test again. if it still passes, then the test is garbage because it's not actually sensitive to the behavior it claims to test. this is the thing that catches &lt;code&gt;toBeDefined&lt;/code&gt; and &lt;code&gt;toBeTruthy&lt;/code&gt; and all the other assertions that look like tests but aren't.&lt;/p&gt;

&lt;p&gt;the other gates i landed on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;no weak assertions. &lt;code&gt;toBeDefined&lt;/code&gt;, &lt;code&gt;toBeTruthy&lt;/code&gt;, &lt;code&gt;toBeInstanceOf&lt;/code&gt; — these get rejected automatically. if your assertion would pass on literally any non-null value, it's not an assertion.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;isolation. the test has to pass in isolation, not just when the full suite runs. tests that accidentally depend on state from other tests are a time bomb.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;coverage delta. the test has to actually cover new lines. a test that just re-covers stuff that's already tested is noise.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;full suite compatibility. doesn't break anything else.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;i eventually built a &lt;a href="https://github.com/nalalou/daedalus/tree/main/test-agent" rel="noopener noreferrer"&gt;skill for claude code&lt;/a&gt; that automates this whole loop. it finds untested files, generates tests, runs them through all five gates, retries up to 3 times with feedback if they fail, and commits the ones that pass. it runs in a background tmux session so i can work on other stuff while it churns through the backlog.&lt;/p&gt;

&lt;p&gt;the point is that "AI can write tests" is true in the same way that "AI can write code" is true. the output looks correct and compiles and runs. the question is whether it actually works. and for tests specifically, "works" means "fails when the code is wrong." most AI-generated tests don't clear that bar.&lt;/p&gt;

&lt;p&gt;if you're using AI to write tests, add a mutation step. flip something in the source, run the test. if it still passes, delete it. that one check filters out more garbage than everything else combined.&lt;/p&gt;

&lt;p&gt;markdown edited in &lt;a href="https://www.ginsberg.ai/" rel="noopener noreferrer"&gt;ginsberg.ai&lt;/a&gt;&lt;/p&gt;

</description>
      <category>testing</category>
      <category>ai</category>
      <category>markdown</category>
    </item>
    <item>
      <title>i was burning a ton of tokens on silly stuff</title>
      <dc:creator>Nora Alalou</dc:creator>
      <pubDate>Sun, 29 Mar 2026 15:23:03 +0000</pubDate>
      <link>https://forem.com/nalalou/i-was-burning-a-ton-of-tokens-on-silly-stuff-od</link>
      <guid>https://forem.com/nalalou/i-was-burning-a-ton-of-tokens-on-silly-stuff-od</guid>
      <description>&lt;p&gt;has anyone else noticed they're chewing through claude tokens way faster than they should be? anthropic just announced they're &lt;a href="https://x.com/trq212/status/2037254607001559305" rel="noopener noreferrer"&gt;tightening the 5-hour session limits during peak hours&lt;/a&gt; and it made me actually look at where my tokens were going.&lt;/p&gt;

&lt;p&gt;turns out most of it was waste. claude was reading files it had no reason to read like lock files, build artifacts, node_modules, coverage reports, media files. every time it explored the codebase it was burning tokens on stuff that would never help it write better code.&lt;/p&gt;

&lt;p&gt;i added a &lt;code&gt;.claudeignore&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Dependencies
node_modules/
.pnp.*

# Build artifacts
.next/
out/
build/
dist/

# Lock files (huge, no value to read)
package-lock.json
pnpm-lock.yaml
yarn.lock

# Minified bundles
*.min.js
*.min.css

# Generated code
next-env.d.ts
*.tsbuildinfo

# Caches
.cache/
__pycache__/
coverage/

# Environment / secrets
.env*
.vercel/

# Large non-code files
*.gif
*.mov
*.mp4
*.png
*.jpg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;it works like &lt;code&gt;.gitignore&lt;/code&gt; but for claude's file exploration. claude won't read or search anything that matches.&lt;/p&gt;

&lt;p&gt;the other thing that helps is keeping &lt;code&gt;CLAUDE.md&lt;/code&gt; lean. mine is about 145 lines with architecture essentials, key conventions, common gotchas. not a novel. every line of CLAUDE.md gets loaded into context at the start of every conversation, so bloat there costs you tokens on every single interaction.&lt;/p&gt;

&lt;p&gt;i'm still seeing how much this helps. they're obviously just clamping down.&lt;/p&gt;

&lt;p&gt;if you're on the Max plan and it still feels like you're burning through it, check what claude is actually reading. you might be feeding it your pnpm-lock.yaml on every exploration.&lt;/p&gt;

&lt;p&gt;markdown edited in &lt;a href="//ginsberg.ai"&gt;ginsberg.ai&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>markdown</category>
    </item>
    <item>
      <title>fixing two bugs stacked on top of each other in ProseMirror</title>
      <dc:creator>Nora Alalou</dc:creator>
      <pubDate>Sun, 29 Mar 2026 00:38:49 +0000</pubDate>
      <link>https://forem.com/nalalou/why-bold-bleeds-when-you-join-blocks-in-prosemirror-1ob7</link>
      <guid>https://forem.com/nalalou/why-bold-bleeds-when-you-join-blocks-in-prosemirror-1ob7</guid>
      <description>&lt;p&gt;i'm building a markdown editor built on Milkdown (which wraps ProseMirror). i hit a bug in how marks behave when you join blocks together.&lt;/p&gt;

&lt;h2&gt;
  
  
  the bug
&lt;/h2&gt;

&lt;p&gt;type a bold heading. move your cursor to the start of it. press Backspace. the heading joins into the paragraph above, but header just becomes unstyled. it doesn't remove the space before it as you'd expect.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr8y0aa0l5d8fur9yoy7r.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr8y0aa0l5d8fur9yoy7r.gif" alt="heading joins with par" width="800" height="609"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;i assumed this was a bug in &lt;code&gt;joinTextblockBackward&lt;/code&gt;. it wasn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  what's actually going on
&lt;/h2&gt;

&lt;p&gt;when you press Backspace at position 0 of a textblock, &lt;code&gt;joinTextblockBackward&lt;/code&gt; fires. under the hood, &lt;code&gt;joinTextblocksAround&lt;/code&gt; calls:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;replaceStep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;beforePos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;afterPos&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Slice&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;this deletes the boundary between the two blocks. the Fitter algorithm stitches the content of the second block into the first. it doesn't touch inline marks. the text nodes transfer as-is, bold and all.&lt;/p&gt;

&lt;p&gt;there's a &lt;code&gt;clearIncompatible&lt;/code&gt; function that strips marks disallowed by the target node type, but paragraphs allow bold, so nothing gets stripped. there's also &lt;code&gt;splitBlockKeepMarks&lt;/code&gt; which handles the reverse operation (Enter), but that's about &lt;code&gt;storedMarks&lt;/code&gt; — cursor behavior for the next typed character. different problem.&lt;/p&gt;

&lt;p&gt;so the marks survive the join, and they're supposed to. consider two paragraphs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;She said the results were
**statistically significant** and could not be ignored.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;press Backspace at the start of the second paragraph. they merge into one. the bold &lt;em&gt;must&lt;/em&gt; survive because you put it there intentionally. if ProseMirror stripped marks on join, it would destroy user content.&lt;/p&gt;

&lt;p&gt;the heading case feels wrong for a different reason. in a markdown editor, &lt;code&gt;# **Bold Heading**&lt;/code&gt; has explicit bold marks on the text nodes because that's what the markdown source says. you see the boldness as part of the heading, like a visual property of the block. but ProseMirror sees it as an inline mark that happens to sit inside a heading node. the markdown editor stores the heading's visual weight in two places: the node type &lt;em&gt;and&lt;/em&gt; the inline marks. when the heading unwraps into a paragraph, only the node type changes. the marks stay because ProseMirror doesn't know they were "heading-ness" and not "the user explicitly wanted bold."&lt;/p&gt;

&lt;p&gt;not a ProseMirror bug. a modeling problem specific to markdown editors.&lt;/p&gt;

&lt;h2&gt;
  
  
  the fix
&lt;/h2&gt;

&lt;p&gt;i intercept &lt;code&gt;joinTextblockBackward&lt;/code&gt;'s dispatch. before the transaction reaches the editor state, i snapshot the heading content range, then append &lt;code&gt;removeMark&lt;/code&gt; calls for the region that used to be the heading. i also clear &lt;code&gt;storedMarks&lt;/code&gt; so the cursor doesn't inherit marks either. everything lands in one transaction, so undo reverses it atomically.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhmypx582tt2j4s11zxvm.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhmypx582tt2j4s11zxvm.gif" alt="heading joins correctly with no mark bleed" width="800" height="609"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;here's the standalone plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Plugin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PluginKey&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prosemirror-state&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;joinTextblockBackward&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;prosemirror-commands&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headingBackspacePlugin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Plugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PluginKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;heading-backspace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;handleKeyDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Backspace&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dispatch&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headingType&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;heading&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;headingType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;empty&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;empty&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="nx"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentOffset&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
        &lt;span class="nx"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;node&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;headingType&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

      &lt;span class="c1"&gt;// Snapshot before the join mutates anything.&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headingContentSize&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;node&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prevEnd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$from&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;before&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wrappedDispatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Map prevEnd through the join's ReplaceStep to find where&lt;/span&gt;
        &lt;span class="c1"&gt;// the heading content now sits inside the merged paragraph.&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prevEnd&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;headingContentSize&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="c1"&gt;// Strip every mark type from the former-heading range.&lt;/span&gt;
        &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;markType&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;marks&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeMark&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;markType&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;// Clear storedMarks so the cursor doesn't inherit marks.&lt;/span&gt;
        &lt;span class="nx"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setStoredMarks&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;

        &lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tr&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;

      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;joinTextblockBackward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wrappedDispatch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;view&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$from.before()&lt;/code&gt; gives the position just before the heading's opening token — after the join's &lt;code&gt;ReplaceStep&lt;/code&gt; deletes the block boundary, &lt;code&gt;tr.mapping.map(prevEnd)&lt;/code&gt; gives the new position where the heading content starts inside the merged paragraph. &lt;code&gt;headingContentSize&lt;/code&gt; is stable because the join doesn't change the heading's content, just moves it. &lt;code&gt;tr.removeMark&lt;/code&gt; is safe to call multiple times on the same transaction and &lt;code&gt;tr.setStoredMarks([])&lt;/code&gt; clears cursor marks so typing after the join doesn't inherit bold/italic. the whole thing is one transaction — Ctrl+Z undoes the join and the mark removal together.&lt;/p&gt;

&lt;p&gt;if you're using Milkdown or Tiptap, register this through the framework's plugin API. the ProseMirror logic is identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  related discussion
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://discuss.prosemirror.net/t/marks-not-cleared-on-join/4269" rel="noopener noreferrer"&gt;marks surviving block joins&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://discuss.prosemirror.net/t/clearincompatible-and-allowed-marks/3223" rel="noopener noreferrer"&gt;clearIncompatible behavior&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>prosemirror</category>
      <category>typescript</category>
      <category>editors</category>
      <category>markdown</category>
    </item>
  </channel>
</rss>
