fix scroll restoration when navigating back with Lexical hybrid SSR#3046
Conversation
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.
| $initialEditorState: (editor) => { | ||
| if (typeof window === 'undefined' && !html) initiateEditorState(editor, state, text) | ||
| if (typeof window === 'undefined' && html) return | ||
| initiateEditorState(editor, state, text) | ||
| }, |
There was a problem hiding this comment.
$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".
|
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. |
|
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! |
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
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 reviewTL;DR
Correctness1. SSR'd decorators ship a bare
|
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.
…th is always a string on validation
…pan to unify behavior
…o ensure it doesn't conflict with fake DOM
|
Really great findings, some were really serious (fake DOM leaking into Everything got fixed, except for:
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
|
|
Found flickers on autolink Media nodes, I'm investigating. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ 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} | ||
| /> | ||
| ) |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit f073e5a. Configure here.
There was a problem hiding this comment.
The HydratableContentEditable is 1:1 with the ServerHTMLReader div.
| return html || generateHTML(editor) | ||
| } catch (e) { | ||
| return html || '' | ||
| } |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit f073e5a. Configure here.
There was a problem hiding this comment.
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.
It was a false alarm, I got tricked by two-beat.mp4Instead of this: one-half-beat.mp4And I'm honestly conflicted. In both cases the code should be better. long explanation By default, non-proxied autolink stacker.news/components/editor/nodes/media.js Lines 366 to 368 in 87fe98d The effect picks up the change in the stacker.news/components/editor/nodes/media.js Lines 297 to 302 in 87fe98d Autolink // 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 -> stacker.news/components/editor/nodes/media.js Lines 29 to 32 in 87fe98d |
I think this becomes a nit when we have the worker label links as image/video/link. |


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:
contentEditablewhile the real Lexical client-side attaches to thecontentEditable. Not true Lexical SSR, we're not creating a DOM for Lexical here;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
contentEditablethat 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).
SNReaderno longer uses a client-only dynamic import or a separate HTML fallback wrapper—it always rendersReader, which accepts anhtmlprop. On the server, when pre-resolvedhtmlexists (andtextis not overriding it),ServerHTMLReaderpaints that HTML in a div whose attributes match the hydratedContentEditable. On the client,HydratableContentEditableseeds the root with serverhtmlor generated HTML (suppressHydrationWarning) until Lexical attaches; without server HTML, SSR cangenerateHTMLvia a fake DOM (withDOM).Supporting changes:
createLinkeDOMis server-only so linkedom is stripped from client bundles; fake DOM setup exposesDOMParser/MutationObserver; sharedgenerateHTMLfor headless export. Decorator nodes (embeds, media, gallery, TOC, mentions, math, headings) aligncreateDOM/exportDOMwithimportDOMdata attributes for lossless HTML round-trip; headings skip decorative anchor links on import. Reader typography:pre-wrapon[data-sn-reader].Reviewed by Cursor Bugbot for commit f073e5a. Bugbot is set up for automated code reviews on this repo. Configure here.