Skip to content

Commit c424a34

Browse files
Soxasorahuumn
andauthored
fix scroll restoration when navigating back with Lexical hybrid SSR (#3046)
* chore: bump Lexical to 0.44.0, handle breaking changes * update to Lexical v0.45.0 * fix: adapt empty inline elements to NormalizeInlineElementsExtension 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 * fix: prevent accidental image uploads when pasting from rich text editors 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. * fix: prevent alignments from surfacing in the rich text editor * cleanup, update docs * fix: give sn-code-block the bs-body-color to mitigate <pre> -> <code> flash * enhancement: don't inject default code block language * enhancement: add support for space-indented outdenting in code blocks * feat: render Lexical Reader with SSR, improve decorator nodes HTML portability 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. * fix: decorator nodes HTML fallback for Lexical SSR * cleanup: remove debug log * fix: build Reader editor state on the client, drop takeover ref * fix: don't use server-resolved HTML when markdown is supplied to the Reader * fix: use exportDOM for table of contents generation in SSR * extend Lexical SSR support to items with empty HTML * deduplicate lexical HTML generation, use exportDOM pipeline in SSR 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 * skip Lexical editor construction in SSR when resolved HTML is present 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. * fix: respect autolink status when converting MediaNode, fix typo srcSet<->srcset * fix: protect from unexpected throws due to null MathNode, ensure __math is always a string on validation * cleanup: follow data-attribute convention for SNHeadingNode * cleanup: exportDOM and createDOM helpers for decorators, createMediaSpan to unify behavior * use SSR constant across reader.js, use SSR constant in mute-lexical to ensure it doesn't conflict with fake DOM --------- Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
1 parent 87fe98d commit c424a34

17 files changed

Lines changed: 276 additions & 130 deletions

File tree

components/editor/index.js

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import { createContext, useContext } from 'react'
2-
import dynamic from 'next/dynamic'
3-
import { useRouter } from 'next/router'
41
import Editor from './editor'
52
import { ToolbarContextProvider } from './contexts/toolbar'
63
import { EditorModeProvider } from './contexts/mode'
4+
import Reader from './reader'
75

86
export function SNEditor ({ ...props }) {
97
return (
@@ -15,27 +13,6 @@ export function SNEditor ({ ...props }) {
1513
)
1614
}
1715

18-
const HTMLContext = createContext('')
19-
20-
function HTMLFallback () {
21-
const html = useContext(HTMLContext)
22-
return <div data-sn-reader dangerouslySetInnerHTML={{ __html: html }} />
23-
}
24-
25-
const Reader = dynamic(() => import('./reader'), {
26-
ssr: false,
27-
loading: HTMLFallback
28-
})
29-
30-
export function SNReader ({ html, ...props }) {
31-
const router = useRouter()
32-
const debug = router.isReady && router.query.html
33-
34-
if (debug) return <div data-sn-reader dangerouslySetInnerHTML={{ __html: html }} />
35-
36-
return (
37-
<HTMLContext.Provider value={html}>
38-
<Reader {...props} />
39-
</HTMLContext.Provider>
40-
)
16+
export function SNReader (props) {
17+
return <Reader {...props} />
4118
}

components/editor/reader.js

Lines changed: 88 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { useMemo } from 'react'
2-
import { defineExtension, configExtension } from 'lexical'
2+
import { defineExtension } from 'lexical'
33
import { RichTextExtension } from '@lexical/rich-text'
44
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
55
import { LexicalExtensionComposer } from '@lexical/react/LexicalExtensionComposer'
6-
import { ReactExtension } from '@lexical/react/ReactExtension'
76
import { TableExtension } from '@lexical/table'
87
import { CodeShikiSNExtension } from '@/lib/lexical/exts/shiki'
98
import { CodeThemePlugin } from './plugins/core/code-theme'
@@ -15,8 +14,63 @@ import { AutoLinkExtension } from '@/lib/lexical/exts/autolink'
1514
import NextLinkPlugin from './plugins/patch/next-link'
1615
import { MuteLexicalExtension } from '@/lib/lexical/exts/mute-lexical'
1716
import theme from '@/lib/lexical/theme'
17+
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
18+
import { withDOM } from '@/lib/lexical/server/dom'
19+
import { generateHTML } from '@/lib/lexical/server/html'
20+
import { SSR } from '@/lib/constants'
1821

19-
const initiateLexical = (editor, state, text) => {
22+
// on the server, generate HTML from the editor state in a fake DOM
23+
// server-resolved HTML never reaches this branch on the server,
24+
// the Reader dispatcher short-circuits it.
25+
// on the client, prioritize resolved HTML so hydration adopts the server-painted div.
26+
// fallback: if editor/genHTML errors, return html prop or ''
27+
const initialContentEditable = (editor, html) => {
28+
try {
29+
if (SSR) {
30+
return withDOM(() => generateHTML(editor))
31+
}
32+
return html || generateHTML(editor)
33+
} catch (e) {
34+
return html || ''
35+
}
36+
}
37+
38+
// server-resolved HTML needs no editor: paint it directly.
39+
// attributes must mirror HydratableContentEditable's output, hydration won't patch mismatches.
40+
// data-lexical-editor is an exception, Lexical sets it at attach but CSS needs it at first paint
41+
function ServerHTMLReader ({ html, innerClassName }) {
42+
return (
43+
<div
44+
aria-autocomplete='none'
45+
aria-readonly='true'
46+
className={innerClassName}
47+
contentEditable={false}
48+
role='textbox'
49+
spellCheck
50+
data-sn-reader='true'
51+
data-lexical-editor='true'
52+
dangerouslySetInnerHTML={{ __html: html }}
53+
/>
54+
)
55+
}
56+
57+
function HydratableContentEditable ({ html, ...props }) {
58+
const [editor] = useLexicalComposerContext()
59+
60+
const initialHTML = useMemo(() => initialContentEditable(editor, html), [editor, html])
61+
62+
return (
63+
<ContentEditable
64+
{...props}
65+
suppressHydrationWarning
66+
// server HTML is preserved until ContentEditable attaches the root element,
67+
// which repaints it from the editor state before the browser paints
68+
dangerouslySetInnerHTML={{ __html: initialHTML }}
69+
/>
70+
)
71+
}
72+
73+
const initiateEditorState = (editor, state, text) => {
2074
if (text) {
2175
markdownToLexical(editor, text)
2276
return
@@ -35,7 +89,7 @@ const initiateLexical = (editor, state, text) => {
3589
}
3690
}
3791

38-
export default function Reader ({ topLevel, state, text, readerRef, innerClassName }) {
92+
function ComposedReader ({ topLevel, state, text, html, readerRef, innerClassName }) {
3993
const reader = useMemo(() =>
4094
defineExtension({
4195
name: 'reader',
@@ -48,26 +102,48 @@ export default function Reader ({ topLevel, state, text, readerRef, innerClassNa
48102
CodeShikiSNExtension,
49103
AutoLinkExtension,
50104
GalleryExtension,
51-
MuteLexicalExtension,
52-
configExtension(ReactExtension, { contentEditable: null })
105+
MuteLexicalExtension
53106
],
54107
theme: {
55108
...theme,
56109
topLevel: topLevel && 'topLevel'
57110
},
58-
$initialEditorState: (editor) => initiateLexical(editor, state, text),
111+
$initialEditorState: (editor) => initiateEditorState(editor, state, text),
59112
onError: (error) => console.error('reader has encountered an error:', error)
60113
}), [topLevel, state, text])
61114

115+
// paints resolved HTML or generates it, see initialContentEditable
116+
const contentEditable = useMemo(() => (
117+
<HydratableContentEditable html={html} data-sn-reader='true' className={innerClassName} />
118+
), [html, innerClassName])
119+
62120
return (
63-
<LexicalExtensionComposer extension={reader} contentEditable={null}>
121+
<LexicalExtensionComposer extension={reader} contentEditable={contentEditable}>
64122
<EditorRefPlugin editorRef={readerRef} />
65-
<ContentEditable
66-
data-sn-reader='true'
67-
className={innerClassName}
68-
/>
69123
<CodeThemePlugin />
70124
<NextLinkPlugin />
71125
</LexicalExtensionComposer>
72126
)
73127
}
128+
129+
export default function Reader ({ topLevel, state, text, html, readerRef, innerClassName }) {
130+
// text is the supplied or truncated markdown,
131+
// it overrides html, so the server paints the same content the client builds from text
132+
const effectiveHTML = text ? undefined : html
133+
134+
// instantly paint the server-resolved HTML
135+
if (SSR && effectiveHTML) {
136+
return <ServerHTMLReader html={effectiveHTML} innerClassName={innerClassName} />
137+
}
138+
139+
return (
140+
<ComposedReader
141+
topLevel={topLevel}
142+
state={state}
143+
text={text}
144+
html={effectiveHTML}
145+
readerRef={readerRef}
146+
innerClassName={innerClassName}
147+
/>
148+
)
149+
}

lib/dompurify.js

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@
44
* @returns {Object} parsed HTML object with window and document
55
*/
66
export function createLinkeDOM (html) {
7-
const { parseHTML } = require('linkedom')
8-
return parseHTML(html || '<!DOCTYPE html>')
7+
// the typeof window guard is compile-time constant in Next bundles,
8+
// so webpack drops linkedom from client builds entirely
9+
if (typeof window === 'undefined') {
10+
const { parseHTML } = require('linkedom')
11+
return parseHTML(html || '<!DOCTYPE html>')
12+
}
13+
throw new Error('createLinkeDOM is server-only')
914
}
1015

1116
/**

lib/lexical/exts/mute-lexical.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { defineExtension } from 'lexical'
2+
import { SSR } from '@/lib/constants'
23
/**
34
* DOM Translators modify the DOM via 'characterData' mutations (text nodes)
45
* Lexical's MutationObserver detects these and restores the DOM to the EditorState
@@ -14,7 +15,7 @@ export const MuteLexicalExtension = defineExtension({
1415
if (disabled) return
1516

1617
return editor.registerRootListener((rootElement) => {
17-
if (typeof window === 'undefined') return
18+
if (SSR) return
1819
if (!rootElement) return
1920

2021
const originalObserver = editor._observer

lib/lexical/nodes/content/embed.jsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@ export function $convertEmbedElement (domNode) {
2323
return { node }
2424
}
2525

26+
/** writes embed fields as data attributes so the node can be losslessly
27+
* reconstructed from HTML into Lexical (see $convertEmbedElement) */
28+
function setEmbedHydrationAttributes (node, el) {
29+
const id = node.getId()
30+
const src = node.getSrc()
31+
const meta = node.getMeta()
32+
el.setAttribute('data-lexical-embed-provider', node.getProvider() || '')
33+
if (id) el.setAttribute('data-lexical-embed-id', id)
34+
if (src) el.setAttribute('data-lexical-embed-src', src)
35+
if (meta) el.setAttribute('data-lexical-embed-meta', JSON.stringify(meta))
36+
}
37+
2638
export class EmbedNode extends DecoratorBlockNode {
2739
__provider
2840
__id
@@ -112,17 +124,20 @@ export class EmbedNode extends DecoratorBlockNode {
112124
}
113125
container.classList.add(...classes.filter(Boolean))
114126

115-
container.setAttribute('data-lexical-embed-provider', this.__provider || '')
116-
this.__id && container.setAttribute('data-lexical-embed-id', this.__id)
117-
this.__src && container.setAttribute('data-lexical-embed-src', this.__src)
118-
this.__meta && container.setAttribute('data-lexical-embed-meta', JSON.stringify(this.__meta))
127+
setEmbedHydrationAttributes(this, container)
119128

120129
wrapper.append(container)
121130
decorator.append(wrapper)
122131

123132
return { element: decorator }
124133
}
125134

135+
createDOM (config) {
136+
const div = super.createDOM(config)
137+
setEmbedHydrationAttributes(this, div)
138+
return div
139+
}
140+
126141
updateDOM () {
127142
return false
128143
}

lib/lexical/nodes/content/gallery.jsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ function $convertGalleryElement (domNode) {
66
return { node }
77
}
88

9+
/** base element shared by createDOM and exportDOM; the data attribute lets
10+
* the node be reconstructed from HTML into Lexical (see $convertGalleryElement) */
11+
function createGalleryElement () {
12+
const div = document.createElement('div')
13+
div.setAttribute('class', 'sn-gallery')
14+
div.setAttribute('data-lexical-gallery', 'true')
15+
return div
16+
}
17+
918
export class GalleryNode extends ElementNode {
1019
$config () {
1120
return this.config('gallery', {
@@ -18,17 +27,13 @@ export class GalleryNode extends ElementNode {
1827
}
1928

2029
createDOM (config) {
21-
const div = document.createElement('div')
22-
div.setAttribute('class', 'sn-gallery')
30+
const div = createGalleryElement()
2331
div.contentEditable = 'false'
2432
return div
2533
}
2634

2735
exportDOM () {
28-
const div = document.createElement('div')
29-
div.setAttribute('class', 'sn-gallery')
30-
div.setAttribute('data-lexical-gallery', 'true')
31-
return { element: div }
36+
return { element: createGalleryElement() }
3237
}
3338

3439
static importDOM () {

0 commit comments

Comments
 (0)