Skip to content

fix(session-ui): dispose live markdown renderer when blocks settle - #56995

Draft
kitlangton wants to merge 1 commit into
devfrom
markdown-live-dispose
Draft

kitlangton wants to merge 1 commit into
devfrom
markdown-live-dispose

Conversation

@kitlangton

@kitlangton kitlangton commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

What it does

Streamed Markdown blocks kept their live Solid renderer and one <span> per word after they finished, which retained about 1 GB in a long session. Now a block that finishes streaming drops its renderer and word spans while its paragraphs, links, images and the user's selection stay where they are.

How it works

  1. createMarkdownRenderer mounts with createRoot + insert instead of render(), so disposing it doesn't clear the DOM.
  2. When a live block settles, updateBlock keeps its element even though block keys include the mode (…:0:live becomes …:0:full, the normal path in the app).
    • Same content: dispose the renderer right away.
    • New content in the same update: let the renderer apply one last patch, then dispose it in a microtask (Solid applies the patch after the current effect).
  3. settleMarkdownWords waits for running [data-markdown-enter] fade-ins, then unwraps each word span in place, restores the selection with setBaseAndExtent (so backward and in-progress drags keep their anchor), and calls normalize(). Live ranges follow merged text nodes, so the selection survives and each run of words is one text node again, which in-app Find needs to match across words.
flowchart LR
  Live["Live block: Solid root + word spans"] --> Settle["Block leaves live mode (same element, new key)"]
  Settle --> Patch["Changed content: last live patch"]
  Settle --> Dispose["Dispose Solid root, DOM stays"]
  Patch --> Dispose
  Dispose --> Wait["Wait for running fade-ins"]
  Wait --> Unwrap["Unwrap spans, restore selection, normalize"]
Loading

Risk

UI only (packages/session-ui Markdown). Things to know:

  • A settled block has no renderer, so a later edit to its text (not a stream append) replaces its inner elements with fresh HTML, like any completed block. Selection inside that block is lost on such an edit.
  • A live block that settles into code at the same index is still replaced, as before.
  • If a block changes while its fade-ins are finishing, the delayed unwrap skips it (it checks isConnected, mode and hash first).
  • I didn't re-measure memory after the review fixes. The 1,090 MB → 82 MB number comes from the earlier 35-message happy-dom session simulation, which settles to the same structure: no live roots, no word spans.

Proof

  • bun test src/components/markdown-solid.test.tsx in packages/session-ui: 6 pass (unwrap in place, cross-block and backward selection, waiting for fade-ins)
  • bun test src/components/markdown in packages/session-ui: 118 pass
  • hivemind run --kind browser -- bunx playwright test --config playwright.components.config.ts component-tests/markdown.spec.ts: 30 passed in Chromium, including
    • settle disposes the renderer and spans, keeps the block and paragraph, keeps a backward selection ("llo wor"), and leaves the paragraph as ["Hello ", <strong>, " again"] text nodes
    • settle with new content in the same update keeps the paragraph and patches the text
    • cache-keyed blocks (as in the app) settle in place under their :full key
  • bun run typecheck in packages/session-ui: 0 errors
  • bun run lint:changed: 0 problems

@kitlangton
kitlangton force-pushed the markdown-live-dispose branch from c28309d to d762e65 Compare October 23, 2026 17:30

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant