Skip to content

Defer initialization to the next rAF#1044

Merged
samuelpecher merged 7 commits into
mainfrom
defer-init
May 25, 2026
Merged

Defer initialization to the next rAF#1044
samuelpecher merged 7 commits into
mainfrom
defer-init

Conversation

@monorkin
Copy link
Copy Markdown
Contributor

@monorkin monorkin commented May 7, 2026

Immediate initialization can sometimes cause lag spikes if the parent element is being morphed at the same time. This PR moves initialization to the next animation frame to give the DOM time to flush the pending layout before Lexical attempts to compute geometry.

The root cause of the lag is a forced layout flush caused by Lexical. It has a selection handler that calls Selection.anchorNode during setRootElement. This forces the browser to immediately flush the layout, persiste pending mutations, and perform style invalidation, all of which pushes the paint back causing causing a visual hang.

This often triggers on navigation with Turbo morph enabled. As morph updates the DOM, Lexxy initializes, and forces the browser to flush all the changes and perform style invalidation, causing the navigation to lag by 50-100ms.

By deferring initialization, navigation is able to complete as normal, the page is laid out and painted, and then Lexxy initializes, and does so much quicker as it doesn't have to perform additional layouts.

Copilot AI review requested due to automatic review settings May 7, 2026 13:38
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a defer attribute to <lexxy-editor> to postpone Lexical root attachment and initial value loading until the next animation frame, reducing layout-flush lag during DOM morphing (e.g., Turbo morph navigation).

Changes:

  • Introduces defer handling in LexicalEditorElement to delay setRootElement, initial value load, autofocus, and initialization dispatch sequencing.
  • Updates editor internals to bind certain event listeners to editorContentElement instead of editor.getRootElement() (supports deferred root attachment).
  • Adjusts drag-and-drop handler registration to use editorContentElement as the event target.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
src/elements/editor.js Adds defer attribute behavior and gates root/value initialization accordingly.
src/editor/selection.js Registers internal move-selection listener on editorContentElement instead of root element.
src/editor/command_dispatcher.js Registers drag/drop listeners on editorContentElement to work when root is deferred.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/elements/editor.js Outdated
Comment thread src/elements/editor.js Outdated
Comment thread src/elements/editor.js Outdated
Comment thread src/elements/editor.js Outdated
this.#handleAutofocus()
if (this.hasAttribute("defer")) {
requestAnimationFrame(() => {
if (!this.isConnected) return
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I don't love these early returns, I would just use the positive condition branch explicitly: if (this.connected)...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Also do we need this isConnected check in this branch at all?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Oh, I thought because it was the first in the callback it would be fine.
That guards against stale fires of the callback - the editor connecting and disconnecting before the callback fires.

After the last commit that addresses the rAF leak that Copilot raised we no longer need this. Removing.

Copilot AI review requested due to automatic review settings May 7, 2026 14:23
Comment thread src/elements/editor.js Outdated
}

disconnectedCallback() {
this.#cancelDeferredInit()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this could be too defensive (cancel the animation frame on disconnect)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed it. I though it would be semantically cleaner - everything that's in-flight is stopped on disconnect.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

src/editor/command_dispatcher.js:333

  • #registerDragAndDropHandlers() now binds drag/drop listeners to editorContentElement (to support deferred setRootElement()), but the drag handlers still call this.editor.getRootElement().classList... without a null check. With defer, getRootElement() will be null until the next rAF, so a drag event during that window would throw. Use editorElement.editorContentElement (or the root variable) consistently, or guard getRootElement() against null.
  #registerDragAndDropHandlers() {
    if (this.editorElement.supportsAttachments) {
      this.dragCounter = 0
      const root = this.editorElement.editorContentElement
      this.#listeners.track(
        registerEventListener(root, "dragover", this.#handleDragOver.bind(this)),
        registerEventListener(root, "drop", this.#handleDrop.bind(this)),
        registerEventListener(root, "dragenter", this.#handleDragEnter.bind(this)),

Comment thread src/elements/editor.js Outdated
Comment thread src/elements/editor.js Outdated
Comment thread src/elements/editor.js Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

Comments suppressed due to low confidence (1)

src/editor/command_dispatcher.js:335

  • Drag/drop listeners are now attached to editorContentElement before Lexical’s root element is set, but the handlers still call this.editor.getRootElement().classList... (e.g. in #handleDragEnter/#handleDrop). Because getRootElement() is null until the first rAF, a drag event before that frame will throw. Use editorElement.editorContentElement (or the captured root) for class toggles, or guard against a null root element.
  #registerDragAndDropHandlers() {
    if (this.editorElement.supportsAttachments) {
      this.dragCounter = 0
      const root = this.editorElement.editorContentElement
      this.#listeners.track(
        registerEventListener(root, "dragover", this.#handleDragOver.bind(this)),
        registerEventListener(root, "drop", this.#handleDrop.bind(this)),
        registerEventListener(root, "dragenter", this.#handleDragEnter.bind(this)),
        registerEventListener(root, "dragleave", this.#handleDragLeave.bind(this))
      )

Comment thread src/elements/editor.js
Comment thread src/elements/editor.js
Comment thread src/elements/editor.js
Comment thread src/editor/selection.js
@monorkin monorkin changed the title Add option to defer initialization to the next rAF Defer initialization to the next rAF May 8, 2026
@monorkin monorkin requested a review from Copilot May 8, 2026 08:11
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread src/elements/editor.js Outdated
Copy link
Copy Markdown
Collaborator

@samuelpecher samuelpecher left a comment

Choose a reason for hiding this comment

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

Great find! I've just got one suggestion about hooking into Lexical's root element registration rather than holding the reference on the element.

Comment thread src/editor/command_dispatcher.js Outdated
if (this.editorElement.supportsAttachments) {
this.dragCounter = 0
const root = this.editor.getRootElement()
const root = this.editorElement.editorContentElement
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.

This gives me pause. Holding the reference to the content element separately from Lexical breaks the concepts of observing Lexical's RootElement.

I think the right move is to defer the registration of listeners by registering a root element listener, where Lexical will provide the listener upon registration. That will ensure the listeners are attached to the linked root element and the timing of listener registration is all controlled through setRootElement.

So roughly:

#registerSomeDomHandlers() {
  this.#listeners.track(this.editor.registerRootElementListener(({ rootElement }) => {
    // attach handlers to root element
  }))
}

Comment thread src/elements/editor.js
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@monorkin
Copy link
Copy Markdown
Contributor Author

Putting this on hold for a bit.

I did an extensive test yesterday and this seems to only help in the worst-case scenario. Something else is causing the calculations to hang.

This change might not be wroth the performance gain it brings.

Copy link
Copy Markdown
Collaborator

@samuelpecher samuelpecher left a comment

Choose a reason for hiding this comment

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

@monorkin I think initializing event listeners via a root listener is worth it on it's own, and performance gains at the worst end of the tail are the most appreciated. Unless this causes us other issues I'd be for a merge.

@jorgemanrubia would you concur?

Comment thread src/elements/toolbar.js Outdated
this.#setItemPositionValues()
this.#monitorSelectionChanges()
this.#monitorHistoryChanges()
this.#refreshToolbarOverflow()
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.

This is coming in #1040

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Oh great! I'll pop my last commit.

Copilot AI review requested due to automatic review settings May 15, 2026 08:01
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Comment thread src/editor/command_dispatcher.js
Comment thread src/editor/selection.js
Comment thread src/editor/selection.js
monorkin added 5 commits May 22, 2026 19:18
Fixes the possibility of an external piece of code reading and setting the value only for it to get overridden one init occurs. And removes the defensive rAF cleanup - if it fires nothing happens, and on reconnect a new rAF is scheduled.
Move #loadInitialValue, form-value sync, validity, and #initialValue
capture back to synchronous #initialize. Only setRootElement (the
layout-flush culprit) and the post-attach work (autofocus, lexxy:initialize
dispatch) stay in the rAF.

This fixes three issues Codex's review surfaced:

- Background tabs paused rAF could leave forms with empty values and
  stale validity, since form-value sync ran from the rAF's update
  listener.
- formResetCallback used #initialValue, which was set inside the rAF's
  #loadInitialValue. Reset before the first frame blanked the editor.
- A stale rAF firing between disconnect and reconnect re-ran
  #loadInitialValue on the disposed editor and cleared valueBeforeDisconnect,
  poisoning #valueLoaded and the saved value across the cycle.

Wrap setRootElement with editor.setEditable(false/true) in #mountRoot.
Lexical clears _updateTags between commits, so the SKIP_DOM_SELECTION_TAG
that `set value` adds is gone by the time setRootElement commits;
toggling editable short-circuits the DOM-selection sync block in
$commitPendingUpdates instead, preventing focus theft when the editor
state has a selection.

Switch selection.js and command_dispatcher.js listener registrations to
editor.registerRootListener so handlers are wired by Lexical when it
announces the root, instead of pre-binding to a content element we only
know is the future root.

Split #initializeEventDispatched and #editorInitializedDispatched so
lexxy:initialize fires when registerAdapter races with the rAF (the
single-flag version silently suppressed it).

Add regression tests for sync value load, formResetCallback before first
frame, stale-rAF poisoning, registerAdapter race, and external-value
focus theft.
Copilot AI review requested due to automatic review settings May 22, 2026 19:50
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@samuelpecher samuelpecher merged commit 856f57e into main May 25, 2026
13 checks passed
@samuelpecher samuelpecher deleted the defer-init branch May 25, 2026 08:40
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.

4 participants