Skip to content

fix: re-render memoized components when their output changes - #584

Merged
farnabaz merged 1 commit into
vercel:mainfrom
dasheidt:fix/memo-render-equivalence
Aug 20, 2026
Merged

fix: re-render memoized components when their output changes#584
farnabaz merged 1 commit into
vercel:mainfrom
dasheidt:fix/memo-render-equivalence

Conversation

@dasheidt

@dasheidt dasheidt commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The contract

A memoized markup component may skip rendering only when every prop capable of changing its rendered output is equivalent.

The default components memoize on className plus the source position of their hast node. Source position is a proxy for render state, and it is not a sound one.

The bug

A replacement of the same length occupies the same lines and the same columns, so the comparator reports "unchanged" for content that changed. The component keeps rendering the previous text. No plugins, no custom components — this is <Streamdown> with default settings, on main at 3327c18:

const { container, rerender } = render(<Streamdown>{"Revenue grew"}</Streamdown>);
rerender(<Streamdown>{"Booking grew"}</Streamdown>);   // same length

container.textContent; // "Revenue grew"

Same for a heading, a list item and a <strong> run — I re-ran all four against main after #581 landed, and all four are still stale. Table cells and blockquotes behave the same way. p is not exempt either: the paragraph above is a MemoParagraph.

Relation to #581

#581 fixed #570 by having the animate plugin stamp data-sd-animated on ancestors and adding that stamp as a third conjunct to sameClassAndNode. That closes the animation-state transition it targets, and this PR keeps it closed — __tests__/animate-issues.test.tsx passes here unmodified, because the stamp is delivered as a prop and a shallow prop comparison therefore includes it for free.

What #581 does not change is the proxy itself. Position is still standing in for render state for every non-animated path, which is why the repro above survives it. #570 was one manifestation of that class; this PR closes the class.

The change

sameClassAndNode becomes React's own shallow prop comparison — what memo does with no comparator at all — minus the node prop, which is a fresh object on every parse and would defeat memoization if compared by identity. The one value read off node that reaches the output, a code fence's meta string, is compared explicitly by MemoCode.

Three components inlined the position expression instead of calling the helper (MemoLi, MemoCode, MemoImg); they now use it too. MemoA no longer needs its extra href conjunct, because href is a prop.

This is not "removing the memo"

The skip that matters is a level up. An unchanged block is memoized by Block and is never re-rendered, so these comparators only run for a block that was re-parsed — exactly the case where the output can differ. __tests__/code-block-memo.test.tsx is unmodified and still green.

The new tests assert both directions, so this cannot quietly become "always re-render":

Case Expectation On main
Same position, different rendered text re-renders ❌ stale
Same position, animate plugin removed (#570) spans come off ✅ since #581
Insertion above shifts positions below attributes describe the current parse ❌ stale
Edit in a different block code block does not re-render ✅ (unchanged)

Benchmarks

Added __benchmarks__/streaming-rerender.bench.tsx, because the existing benchmarks measure parsing and this change does not touch the parser — the comparators only cost anything on the mount-then-re-render path.

Interleaved A/B against main at 3327c18, 5 pairs each, medians of hz (higher is better):

Benchmark main this branch
prose document, 20 streaming appends 98.0 96.5
document with a code block, 20 streaming appends 128.5 128.1

Run-to-run spread within a single arm was larger than the gap between arms, so I would read this as no measurable difference rather than as a win or a loss.

The comparator itself is slower per call, as expected — it allocates a key list where the old one compared four scalars:

Comparator equal props (skip path) changed children
old, position-based 8.7M ops/s 11.4M ops/s
new, shallow props 5.0M ops/s 8.7M ops/s

That is ~0.1µs more per comparison. A document with fifty elements re-rendering twenty times a second spends about 100µs/s more in comparators, which is why it does not show up end to end.

One test changed, and it is worth a look

__tests__/list-animation-retrigger.test.tsx (#410) asserted that the data-sd-animate span objects survive a tight → loose list transition. They survived because the transition was never rendered: the list keeps its source positions, so MemoLi skipped and the DOM kept a tight list for markdown that had become loose.

With this change the transition renders, so the spans are rebuilt. What #410 actually reported — already-visible characters re-running their entry animation — is still prevented, by the second layer the same file documents: prevContentLength sets --sd-duration: 0ms for text already rendered. So the test now asserts durations rather than object identity, and additionally asserts the loose list actually renders.

One caveat I would rather flag than bury: at that transition, one character at the boundary is not suppressed and re-animates. The character accounting in lib/animate.ts counts inter-element whitespace, and tight → loose changes how much of it the block contains, so the boundary lands one character early. Ordinary streaming appends are unaffected. That accounting is untouched here; it was simply unreachable before, because the block never re-rendered — and I re-confirmed it is still one character off on top of #581's rewritten animate pipeline.

Relation to other open work


Rebased onto main at 3327c18 (post-#581). Verified with pnpm build:packages, vitest run (79 files, 1076 tests, all green including #581's animation suites), biome check on the changed files.

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Someone is attempting to deploy a commit to the Vercel Team on Vercel.

A member of the Team first needs to authorize it.

@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
streamdown Canceled Canceled Aug 19, 2026 4:13pm

@dasheidt
dasheidt force-pushed the fix/memo-render-equivalence branch from 8915651 to adef44e Compare August 20, 2026 12:13
@dasheidt

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 3327c18 (post-#581) — conflicts were the three spots #581 touched in lib/components.tsx.

Worth noting for review: __tests__/animate-issues.test.tsx passes here unmodified. #581's data-sd-animated stamp is delivered to the component as a prop, so a shallow prop comparison already includes it — the explicit conjunct is subsumed rather than dropped.

The general case #581 does not cover still reproduces on 3327c18: same-length replacement renders stale for a paragraph, a heading, a list item and a <strong> run. Benchmarks re-measured against the new base (5 interleaved pairs); still no measurable end-to-end difference.

@farnabaz farnabaz left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM 👍

@farnabaz
farnabaz force-pushed the fix/memo-render-equivalence branch 2 times, most recently from 95fd400 to ee6c460 Compare August 20, 2026 14:12
The default markdown components memoize on `className` plus the source
position of their hast node. Source position is a proxy for render state,
and it is not a sound one: a replacement of the same length occupies the
same lines and columns, so the comparator reports "unchanged" for content
that changed and the component keeps rendering the previous text.

Replace the position comparison with React's own shallow prop comparison —
what `memo` does with no comparator at all — minus the `node` prop, which is
a fresh object on every parse. The one value read off `node` that reaches the
output, a code fence's meta string, is compared explicitly.

The skip that matters is preserved: an unchanged block is memoized a level
up and is never re-rendered, so these comparators only run for a block that
was re-parsed.

Co-Authored-By: Ian Heidt <71056976+dasheidt@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@farnabaz
farnabaz force-pushed the fix/memo-render-equivalence branch from ee6c460 to 12b1cd6 Compare August 20, 2026 14:15
@farnabaz
farnabaz merged commit 89c6877 into vercel:main Aug 20, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Animation spans are never removed from settled text — memoized components discard the span-free reparse

2 participants