Skip to content

fix scroll restoration when navigating back with Lexical hybrid SSR#3046

Merged
huumn merged 29 commits into
stackernews:masterfrom
Soxasora:feat/ssr-lexical-reader
Jun 12, 2026
Merged

fix scroll restoration when navigating back with Lexical hybrid SSR#3046
huumn merged 29 commits into
stackernews:masterfrom
Soxasora:feat/ssr-lexical-reader

Conversation

@Soxasora

@Soxasora Soxasora commented Jun 9, 2026

Copy link
Copy Markdown
Member

Description

Based on #2975, merging this PR will also update Lexical to 0.45.0

This PR adds SSR support to our Lexical Reader, and fixes the scroll restoration bug by using the server HTML as the placeholder in SPA nav too.

I'm calling it hybrid SSR because:

  • items: show a server-resolved HTML as fallback contentEditable while the real Lexical client-side attaches to the contentEditable. Not true Lexical SSR, we're not creating a DOM for Lexical here;
  • non-items: same as above, but instead of using a server-resolved HTML we're creating a fake DOM for Lexical to render its markdown/state on the first paint.

Media autolinks will still cause a layout shift/wrong scroll restoration until imgproxy proxies them.
Decorators gained better HTML export/import in preparation for a future full Lexical SSR feature.

Screenshots

prod_vs_ssrdev.mp4

Additional Context

Technically, the way we were loading the server HTML wasn't actually the problem. The problem was that re-rendering Lexical instances in SPA navigation takes more than a frame, so next.js would try to restore a scroll position before all the Lexical readers were loaded.
Given this core concept, we now render the server HTML as the initial contentEditable that Lexical will then replace with its client-side DOM. This means that whether it's SPA nav or SSR, Lexical will always have something to show on the first frame.

Content that lacks a server-resolved HTML, like the notification bulletin or the invite info modal, will render directly in SSR, generating the editor state and the HTML, eliminating layout shifts in such cases.

Checklist

Are your changes backward compatible? Please answer below:

Yes, items still render as usual avoiding reflows, only content lacking a server HTML will be rendered in SSR.

On a scale of 1-10 how well and how have you QA'd this change and any features it might affect? Please answer below:

7, items render correctly, non-items (notification bulletin and invites info) render through SSR and don't show any signs of regressions.

Did you use AI for this? If so, how much did it assist you?

Yes, to understand the constraints of Lexical SSR and review.


Note

Medium Risk
Touches the main content rendering and hydration path; mismatched server/client HTML or Lexical attach timing could cause layout or content glitches, though item flows keep pre-resolved HTML.

Overview
Adds hybrid SSR for the Lexical reader so the first paint shows real content (fixing scroll restoration on back navigation and SPA routes when Lexical mounts slowly).

SNReader no longer uses a client-only dynamic import or a separate HTML fallback wrapper—it always renders Reader, which accepts an html prop. On the server, when pre-resolved html exists (and text is not overriding it), ServerHTMLReader paints that HTML in a div whose attributes match the hydrated ContentEditable. On the client, HydratableContentEditable seeds the root with server html or generated HTML (suppressHydrationWarning) until Lexical attaches; without server HTML, SSR can generateHTML via a fake DOM (withDOM).

Supporting changes: createLinkeDOM is server-only so linkedom is stripped from client bundles; fake DOM setup exposes DOMParser / MutationObserver; shared generateHTML for headless export. Decorator nodes (embeds, media, gallery, TOC, mentions, math, headings) align createDOM/exportDOM with importDOM data attributes for lossless HTML round-trip; headings skip decorative anchor links on import. Reader typography: pre-wrap on [data-sn-reader].

Reviewed by Cursor Bugbot for commit f073e5a. Bugbot is set up for automated code reviews on this repo. Configure here.

Soxasora and others added 13 commits May 14, 2026 13:48
behavior.

`NormalizeInlineElementsExtension` is active by
default in Lexical 0.45.0
This extension automatically removes empty inline
elements from the editor state.

- fix: append href text to empty autolink nodes on paste
- refactor: promote backref node to DecoratorNode
…tors

if `clipboardData` contains text/plain AND text/html,
don't let the upload's PASTE_COMMAND handler only paste
the image. instead bail out and prefer text.
…rtability

The Lexical Reader now renders the server HTML
directly into the Lexical contentEditable in SSR.
When we're on the client, the Lexical Reader will
replace the server HTML with the client-side
Lexical state.

Fixes messed-up scroll restoration due to the
previous reflow caused by the HTML->Lexical
dynamic loading.

When we don't have server HTML, we use a fake DOM
to load the full Lexical state in SSR.
This path lacks any kind of optimization to media,
embeds, dimensions in general, so it's only used
for simple non-item content lacking html.

- improved decorator nodes HTML portability by
  serializing their internal state into HTML
  attributes, so they can be reconstructed from
  HTML into Lexical.
@Soxasora Soxasora added enhancement improvements to existing features editor labels Jun 9, 2026
@Soxasora Soxasora changed the title feat: SSR lexical reader fix scroll restoration when navigating back with Lexical SSR Jun 9, 2026
@Soxasora Soxasora marked this pull request as ready for review June 9, 2026 13:04
Comment thread lib/lexical/nodes/decorative/mentions/user.jsx Outdated
Comment thread components/editor/reader.js Outdated
@Soxasora Soxasora changed the title fix scroll restoration when navigating back with Lexical SSR fix scroll restoration when navigating back with Lexical hybrid SSR Jun 9, 2026
@Soxasora Soxasora marked this pull request as draft June 9, 2026 18:21
Comment thread components/editor/reader.js Outdated
Comment on lines 105 to 108
$initialEditorState: (editor) => {
if (typeof window === 'undefined' && !html) initiateEditorState(editor, state, text)
if (typeof window === 'undefined' && html) return
initiateEditorState(editor, state, text)
},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

$initialEditorState now runs on the client too (like prod). only skips on the server when a fallback HTML exists, so SSR still avoids building state it doesn't need.

This gets rid of the takeoverRef callback that waited for Lexical to attach before initializing the editor state. We don't actually need it anyway, because we're not "hydrating".

@Soxasora Soxasora marked this pull request as ready for review June 9, 2026 19:01
@huumn

huumn commented Jun 10, 2026

Copy link
Copy Markdown
Member

Bots flagged that we have hydration mismatch for truncated text. It might be wise to truncate/clip with CSS.

Demo video looks great btw! Can't wait to ship this.

I'll continue QA/reviewing.

@Soxasora

Copy link
Copy Markdown
Member Author

oh man! That's a bug in prod too!

This is probably another use case for the new full SSR lexical reader. I'll push a fix!

Comment thread components/editor/reader.js Outdated
export default function Reader ({ topLevel, state, text, html, readerRef, innerClassName }) {
// text (e.g. truncated markdown) overrides html, so the server paints
// the same content the client builds from text
const effectiveHTML = text ? undefined : html

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Markdown cases will always render in SSR, even if a server-resolved HTML is present. This also fixes a bug in prod where satistics would show the full item from the HTML and then the truncated version via Lexical.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

reminder: I didn't extend full Lexical SSR to items because there are too many variables at play and I don't want to delay this any further. satistics and places that feed markdown directly to <Text> are a good testing ground for SSR node inconsistencies, instead.

Comment thread lib/lexical/nodes/content/toc.jsx
Comment thread components/editor/reader.js Outdated
@huumn

huumn commented Jun 11, 2026

Copy link
Copy Markdown
Member

I got these back from Fable. I haven't had time to investigate. At a glance, most seem like nits and those that aren't are at least rarity gated.

QA has been fine for me. Scroll is restored properly now.

fable review

TL;DR

  1. SSR fallback HTML serves every decorator/gallery node with a bare contenteditable attribute — i.e. editable — reproduced end-to-end. (#1)
  2. linkedom (~106KB gzip of server-only DOM code) now ships in the client bundle of every content page. (#2)
  3. renderServerHTML re-implements the existing sanitized $generateHtmlFromNodes pipeline; switching to it would also fix [Feature request] - More detailed documentation/info #1 and delete the eight per-node SSR branches. (#8)

Correctness

1. SSR'd decorators ship a bare contenteditable attribute (editable)

components/editor/reader.js:21 (renderServerHTML), plus lib/lexical/nodes/content/gallery.jsx:24

Lexical's reconciler sets dom.contentEditable = 'false' for every DecoratorNode (GalleryNode does the same itself), and linkedom's boolean-attribute setter treats the truthy string 'false' as true, serializing a bare attribute:

<span contenteditable data-lexical-decorator="true" data-lexical-user-mention="true">@somebody</span>

Per the HTML spec, bare contenteditable is the true state, and a descendant attribute overrides the ancestor's contenteditable="false" reader root — each mention, media placeholder, embed, math node, footnote ref, TOC, and gallery becomes an editable island.

  • Reproduced with lexical 0.45.0 + linkedom 0.18.12 (the exact pinned versions).
  • Affected pages: anything on the renderServerHTML path — truncated comments on notifications/invoice/payIn pages, territory descriptions, items with null/empty html.
  • Window: first paint until hydration (Lexical repaints on attach), or indefinitely with JS disabled. Client-local only — no persistence, no XSS.
  • The resolver-html path is unaffected only by construction (exportDOM never sets the property). DOMPurify does not strip contenteditable — it's in the default allowlist — so don't count on the sanitizer.

2. linkedom bundled into the client

lib/lexical/server/dom.js:1

The top-level import { createLinkeDOM } from '@/lib/dompurify' (whose createLinkeDOM does an unguarded require('linkedom')) is now reachable from the client: 8 lexical node files import isServerRendering and reader.js imports withDOM from this module.

  • Measured (esbuild): linkedom + htmlparser2/css-select/cssom ≈ 324KB minified / ~106KB gzip (+~9KB dompurify), shipped to every page that renders Text or the editor — front page, items, notifications, territories.
  • On master this chain was reachable only from api/resolvers. The pre-existing canvas: false alias in next.config.js lets linkedom bundle silently instead of failing.
  • Fix: move isServerRendering (it only reads a global flag) to a dependency-free module, and guard the linkedom require with typeof window === 'undefined' so webpack dead-branches it in client builds.

3. renderServerHTML has no error containment → whole-page 500 class

components/editor/reader.js:22

Any deterministic node paint error under linkedom escapes Lexical's one-shot recovery (which re-throws on the second failure), escapes setRootElement, and 500s the entire page request during renderToString.

  • This exact class was live until the PR's own dependency bump: lexical ≤0.44 called getComputedStyle for indented elements, and withDOM's new global.getComputedStyle = newWindow.getComputedStyle shim assigns undefined (linkedom has no getComputedStyle). Fixed upstream exactly at 0.45.0 — but the shim is still a dead no-op and the next linkedom DOM-API gap rejoins this class.
  • The sibling pipeline lexicalHTMLGenerator (lib/lexical/server/html.js:30-48) already wraps parse + generation in try/catch returning placeholders.
  • Fix: try/catch in renderServerHTML returning '' (or the html prop) — the client repaints from state on attach anyway.

4. Media hydration round-trip drops the autolink flag

lib/lexical/nodes/content/media.jsx:61 (setMediaHydrationAttributes)

The new round-trip (documented as "losslessly reconstructed") serializes alt/title/srcset/bestres/maxwidth but never writes or reads __autolink; $createMediaNode defaults it to false on import.

  • Scenario: a bare URL becomes a MediaNode with __autolink=true → its rendered/SSR'd HTML is pasted back into the editor without the application/x-lexical-editor clipboard flavor (cross-browser or external-app paste) → node reconstructs with autolink=falselib/lexical/mdast/visitors/media.js:22 gates bare-URL markdown output on isAutolink(), so the user's plain URL is silently rewritten to ![](url) on save.

5. __SN_LEXICAL_SSR__ leaks onto the real globalThis permanently

lib/lexical/server/dom.js:36

newWindow.__SN_LEXICAL_SSR__ = true writes through linkedom's defaultView Proxy onto Node's real globalThis (the set trap's default arm is target[name] = value with target === globalThis). After the first withDOM call the flag is process-wide and permanent — the comment "it's discarded with the window, so no cleanup is needed" is factually wrong.

  • Reproduced. Every linkedom window in the process reports the flag via proxy get-fallthrough.
  • Nothing misbehaves today (no code sets global.window outside withDOM), but any future polyfill/test that does makes isServerRendering() permanently true, flipping all decorator createDOMs to their exportDOM branch outside SSR painting.
  • Fix: scope the flag to the paint (set/clear around setRootElement, or an editor-level flag) instead of ambient globals.

6. MathNode: duplicated serialization blocks + exportDOM null violates the 0.45 contract

lib/lexical/nodes/formatting/math.jsx:74 (createDOM block), :90 (exportDOM return null)

createDOM and exportDOM duplicate the $encodeMath + data-lexical-math/data-lexical-inline + console.error sequence with divergent failure policies — and exportDOM's bare return null on encode failure violates @lexical/html 0.45, which destructures the result (const {element, after, append, $getChildNodes} = exportProps) and throws on null, failing the whole document export.

  • Trigger: serialized state with a non-string math value (e.g. "math": null$validateMathContent passes falsy through, importJSON only defaults undefined) makes Buffer.from throw → item.html generation degrades to the error placeholder for the entire item.
  • Fix: one setMathAttributes(el) helper shared by both methods; return { element: null } to skip just the node.

Architecture / cleanup

7. The html/text/state matrix is re-derived in three places

components/editor/reader.js:109 (also :86, :41-46)

effectiveHTML, initialContentEditable's server branch, and the $initialEditorState guard each independently re-derive "the server paints resolver html iff it skips building editor state" — coupled only by convention.

  • Proven fragile by this PR's own history: commit bb37e3bf1 fixed the server painting full html while the client built truncated text; commit 7126268e5 fixed the html === '' case where a ??-vs-truthiness mismatch between two sites produced a wasted server state build and an empty SSR paint simultaneously.
  • Fix: compute one { paintHTML, buildState } decision in Reader and feed all three consumers. (The useMemo around the contentEditable element is required by LexicalExtensionComposer — that part of the complexity is forced.)

8. renderServerHTML duplicates the existing exportDOM pipeline

components/editor/reader.js:17, plus the 8 copied branches in media.jsx:206, embed.jsx:128, toc.jsx:38, math.jsx:67, footnote/reference.jsx:72, mentions/item.jsx:68, mentions/territory.jsx:50, mentions/user.jsx:57

A second server-side state→HTML pipeline (withDOM + setRootElement + reconciler innerHTML) parallel to the sanitized $generateHtmlFromNodes pipeline in lib/lexical/server/html.js. The reconciler's empty decorator shells are exactly what forced the same 3-line if (isServerRendering()) return this.exportDOM(editor).element branch into 8 node classes.

  • Costs: two SSR HTML generators to keep consistent; the fallback paint skips sanitizeHTML (hardening delta — no concrete XSS vector found); every future decorator node must remember the boilerplate or silently SSR as an empty shell; a future node adding the branch while inheriting the default exportDOM (which calls createDOM) infinite-recurses under withDOM.
  • Verified feasible: calling $generateHtmlFromNodes inside withDOM here works — the client wipes and repaints the root on attach regardless, and production item.html already proves exportDOM markup is adequate. It would delete all 8 branches and fix finding [Feature request] - More detailed documentation/info #1, since exportDOM never sets contentEditable.

9. SSR builds a full unused editor per Reader on the html path

components/editor/reader.js:121

With effectiveHTML present (the dominant items/comments path), LexicalExtensionComposer still builds and registers a complete editor per Reader even though $initialEditorState returns early and the painted div comes from dangerouslySetInnerHTML.

  • Measured: ~212µs wasted per Reader (~52× a bare div) → ≈21ms extra SSR CPU on a 100-comment page, plus GC churn. Master did zero server-side Lexical work.
  • Short-circuiting to a bare server div is feasible but must replicate ContentEditable's attribute output (role/aria-*/contenteditable/spellcheck) — React doesn't patch attribute mismatches during suppressed hydration.

10. SPA navigations parse the full resolver html only to throw it away

components/editor/reader.js:45

initialContentEditable returns the full html on every fresh client mount, not just hydration; Lexical's setRootElement (ref callback, same commit) wipes and repaints pre-paint.

  • Cost: invisible throwaway innerHTML parse per reader per post-hydration mount (SPA nav, "load more") — low single-digit ms on a 100-comment thread. No image fetches (SN's resolver html contains no <img>; media exports placeholder spans).
  • Caution if fixing: the per-instance value must stay stable (e.g. useState initializer) — flipping __html from html to '' after attach would make React wipe Lexical's live DOM.

11. Three server-detection mechanisms; one guard already inverted

components/editor/reader.js:42 (and :110), plus lib/lexical/exts/mute-lexical.js:17

reader.js inlines typeof window === 'undefined' twice instead of the established SSR constant (lib/constants.js:96, imported by 16 modules) — and the call-time form is the fragile variant in this PR's own withDOM world: mute-lexical's identical guard is defeated inside withDOM (global.window is set) and runs its browser path (MutationObserver swap) during every server paint. Benign today (resetEditor cleans up), but any future browser-only logic there silently runs server-side.

  • Fix: use the SSR constant in reader.js; use isServerRendering() (or SSR) in mute-lexical.

12. Embed hydration attributes spelled out twice

lib/lexical/nodes/content/embed.jsx:132

The new createDOM repeats verbatim exportDOM's four data-lexical-embed-* writes (provider/id/src/meta JSON). A setEmbedAttributes(el) helper mirrors the setMediaHydrationAttributes pattern this same PR introduced for the identical problem in media.jsx.

13. Media attribute writes split across three places

lib/lexical/nodes/content/media.jsx:61

setMediaHydrationAttributes centralizes only the five new fields while the two attributes $convertMediaElement actually requires (data-sn-media-src, data-sn-media-kind) plus className and --width/--height remain hand-duplicated in both createDOM and exportDOM around the helper call. A kind/src/style change touches 3 places; forgetting one silently breaks round-tripping. (Note: exportDOM intentionally skips width/height for kind === 'unknown' — a merge needs that care.)

14. Dead domNode.textContent = '' in the backref converter

lib/lexical/nodes/decorative/footnote/backref.jsx:19

Now that FootnoteBackrefNode is a DecoratorNode, both @lexical/html and @lexical/clipboard 0.45 append converted children only if ($isElementNode(currentLexicalNode)) — the ' ↩' glyph can never import as a child regardless. The line (and its comment) documents a hazard the framework already guarantees away, while mutating the caller's DOM as a side effect. Delete it.

15. Heading anchor import filter gates on a CSS class, not the data-attribute convention

lib/lexical/nodes/misc/heading.jsx:160

The importDOM skip matches 'sn-heading__link' by class while the rest of the codebase — including this PR's own footnote backref skip — gates on data-lexical-* attributes; the class literal appears 3× in the file with no shared constant.

  • Renaming the class at creation (:31) without updating the filter 130 lines below silently regresses to the master-era duplicate-heading-text paste bug. (External HTML coincidentally using the class would be dropped — theoretical; the anchor text is redundant by construction.)
  • Fix: emit data-lexical-heading-link on the anchor and gate on hasAttribute; keep the class for CSS.

Soxasora added 7 commits June 11, 2026 17:48
generateHTML is branched off of lexicalHTMLGenerator,
and is used by Lexical Readers when server-resolved
HTML is not available.
Ensures that first-paint content is always available.

- removed HTML debug paths
- removed `isServerRendering` flag and usage from nodes
- stricter DOMPurify guard
building and registering an editor per Reader wasted server CPU per item
when the painted div came from resolved HTML anyway.

when server-resolved HTML is present, we short-circuit to a bare div
mirroring ContentEditable's read-only attributes (ServerHTMLReader)

on the client, HydratableContentEditable renders the same resolved HTML
so hydration adopts the server-painted div; Lexical repaints it from
the editor state once it attaches.

when no resolved HTML is present, we still build the editor state in
SSR and render the resulting HTML via HydratableContentEditable.
@Soxasora

Copy link
Copy Markdown
Member Author

Really great findings, some were really serious (fake DOM leaking into mute-lexical, SSR with pre-existing HTML building an editor it doesn't need, etc.) and others were just refactors and cleanups.

Everything got fixed, except for:

SPA navigations parse the full resolver html only to throw it away

We need to have that HTML ready for SPA navigation because Lexical doesn't render its content instantly. This PR objective was to fix scroll restoration issues, and the culprit was the absence of the placeholder HTML in SPA navigation.

This PR now not only fixes the bug, it also introduces SSR support for the Lexical Reader. It's especially important for Reader consumers (non-items/non-subs) that feed Markdown directly, without a placeholder HTML.

The full list of fixes is available here:

list of fixes
  1. SSR'd decorators ship a bare contenteditable attribute (editable)
  • reproduced, cannot be exploited
  • fixed by using exportDOM to generate the HTML
  1. linkedom bundled into the client
  • fixed by guarding the linkedom require with window check
  1. renderServerHTML has no error containment
  • now inside initialContentEditable
  • fixed by returning an empty string or the server-resolved HTML on error
  1. Media hydration round-trip drops the autolink flag
  • fixed inside createMediaSpan
  • also fixed srcset retrieval from the DOM node, srcSet didn't exist
  1. __SN_LEXICAL_SSR__ leaks
  • removed
  1. MathNode: duplicated serialization blocks + exportDOM null violates the 0.45 contract
  • fixed by using a helper to set the attributes
  • also fixed the exportDOM return value to be { element: null }
  1. The html/text/state matrix is re-derived in three places
  • fixed
  • effectiveHTML in Reader determines whether to build the editor state in SSR.
  • when SSR && effectiveHTML, we return ServerHTMLReader instead of loading Lexical,
    we don't need to guard $initialEditorState anymore.
  • initialContentEditable in SSR always generates the HTML from the editor state,
    because ServerHTMLReader already does its job when we already have the HTML.
    Instead, client-side will always prioritize the resolved HTML over the editor state,
    and will generate HTML if needed (SPA navigations for non-items).
  1. renderServerHTML duplicates the existing exportDOM pipeline
  • fixed by extracting and using generateHTML from lexicalHTMLGenerator
  1. SSR builds a full unused editor per Reader on the html path
  • SSR with HTML is now short-circuited to ServerHTMLReader, a simple div matching ContentEditable's read-only attributes
  1. SPA navigations parse the full resolver html only to throw it away
  • not fixed
  • in SPA nav we need to parse the HTML because Lexical defers rendering.
  • not worth optimizing imo, we'd have to play around module-scoped flags to differentiate between SPA nav and hydration.
  1. Three server-detection mechanisms; one guard already inverted
  • fixed by using the SSR constant, a module-level flag, to avoid withDOM interference
  1. Embed hydration attributes spelled out twice
  2. Media attribute writes split across three places
  • fixed with helpers across all decorators
  • MediaNode has a createMediaSpan helper to build the media wrapper span shared by createDOM/exportDOM
  1. Dead domNode.textContent = '' in the backref converter
  • fixed
  1. Heading anchor import filter gates on a CSS class, not the data-attribute convention
  • fixed

@Soxasora

Copy link
Copy Markdown
Member Author

Found flickers on autolink Media nodes, I'm investigating.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f073e5a. Configure here.

readerRef={readerRef}
innerClassName={innerClassName}
/>
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SSR client render tree mismatch

Medium Severity

For content with server-resolved html, SSR renders ServerHTMLReader (a plain div), while the client always mounts ComposedReader with LexicalExtensionComposer. React therefore hydrates different component trees for the same slot, unlike the prior dynamic loading fallback that matched on both sides before Lexical loaded.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f073e5a. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The HydratableContentEditable is 1:1 with the ServerHTMLReader div.

return html || generateHTML(editor)
} catch (e) {
return html || ''
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Global DOM mutation during render

Medium Severity

Markdown-only SSR readers call withDOM inside useMemo during render to build placeholder HTML. That swaps global.window and global.document while React is rendering, which is unsafe under concurrent or overlapping SSR work in one Node process.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f073e5a. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

if (SSR)

it's gated and SSR refers to the typeof window === undefined collected at module-level. It's one of the bugs that Fable found, it got fixed this way.

@Soxasora

Soxasora commented Jun 12, 2026

Copy link
Copy Markdown
Member Author

Found flickers on autolink Media nodes, I'm investigating.

It was a false alarm, I got tricked by MediaLoading actually never working as intended with autolinks.
And if it were working, it would be something like this:

two-beat.mp4

Instead of this:

one-half-beat.mp4

And I'm honestly conflicted. In both cases the code should be better.


long explanation

By default, non-proxied autolink MediaNodes have kind: 'unknown' and are rendered as pulsing links, while in the background checkMedia starts fetching the media types.
When the check resolves, setIsImage(true) makes media.image true and renders MediaLoading, the grey placeholder, while loading the real image. At this moment the kind prop is still 'unknown', the check result hasn't been written to the Lexical node yet (setKind()).

} else if (data.isImage) {
setIsImage(true)
setKind?.('image')

The effect picks up the change in the kind state and calls $confirmMedia, writing kind: 'image' to the node. This re-renders the media node with the new kind, MediaLoading now renders with autolink={false} and doesn't reserve the fallback size.

if (media.image || media.video) {
// when we don't know the dimensions of the media (e.g. autolink),
// preserveScroll helps us avoid scrolling shift when media finally loads
const content = (
<>
{isLoading && <MediaLoading autolink={props.kind === 'unknown'} />}

Autolink MediaNodes are still autolinks, we don't know their width and height, so we render them without them:

  // dimensions are only meaningful once the media check has resolved the kind;
  // unknown exports render as links
  if (kind !== 'unknown') {
    const { width, height } = node.getWidthAndHeight() || {}
    width && span.style.setProperty('--width', width)
    height && span.style.setProperty('--height', height)
  }

This causes the autolink to go from link -> 200x150 grey box -> 0x0 grey box -> real image.
The fix would be to just check for missing dimensions to render MediaLoading with autolink={true}.

function MediaLoading ({ autolink = false }) {
const style = autolink ? { width: '200px', height: '150px' } : undefined
return <div className='sn-media__loading' style={style} />
}

@huumn huumn merged commit c424a34 into stackernews:master Jun 12, 2026
7 checks passed
@huumn

huumn commented Jun 12, 2026

Copy link
Copy Markdown
Member

In both cases the code should be better.

I think this becomes a nit when we have the worker label links as image/video/link.

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

Labels

editor enhancement improvements to existing features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants