Skip to content

[2.x] fix: TextEditor.onbuild race against async Mithril redraw#4657

Merged
imorland merged 1 commit into
2.xfrom
im/text-editor-onbuild-race
May 12, 2026
Merged

[2.x] fix: TextEditor.onbuild race against async Mithril redraw#4657
imorland merged 1 commit into
2.xfrom
im/text-editor-onbuild-race

Conversation

@imorland
Copy link
Copy Markdown
Member

Summary

TextEditor.oncreate schedules onbuild via a hardcoded setTimeout(..., 50) after _load() resolves. _load() ends with m.redraw(), which is async (queued for the next frame). Under slow first-load conditions — cold CDN, heavy DOM, additional _loaders like the one the emoji extension registers — the redraw can land after the 50ms timer fires, leaving the conditional <div class=\"TextEditor-editorContainer\"> out of the DOM when onbuild runs.

When that happens, this.\$('.TextEditor-editorContainer')[0] returns undefined and gets handed to whatever editor driver is being constructed:

this.attrs.composer.editor = this.buildEditor(this.\$('.TextEditor-editorContainer')[0]);

For the stock BasicEditorDriver, this produces a broken textarea. For Tiptap-based drivers (e.g. fof/rich-text), Tiptap v3 only calls its internal mount() if options.element is truthy, so the view is permanently null and the composer renders a non-functional toolbar with no editor underneath. The bug is intermittent because it depends on whether the redraw beats the timer.

Fix

Switch _load's trailing m.redraw() to m.redraw.sync() and drop the 50ms timer in oncreate. After m.redraw.sync() returns, the DOM has been updated to reflect loading = false, so the container element is present when onbuild runs. The .then callback runs on a microtask outside any Mithril event handler, so calling m.redraw.sync() from there is safe.

   oncreate(vnode) {
     super.oncreate(vnode);

-    this._load().then(() => {
-      setTimeout(this.onbuild.bind(this), 50);
-    });
+    this._load().then(this.onbuild.bind(this));
   }

   _load() {
     return Promise.all(this._loaders.map((loader) => loader())).then(() => {
       this.loading = false;
-      m.redraw();
+      m.redraw.sync();
     });
   }

Test plan

Repro using the script from the linked issue:

const orig = m.redraw;
m.redraw = function() { setTimeout(() => orig(), 300); };
m.redraw.sync = orig.sync;
  • Before fix, with the slowdown applied: opening the composer produces a broken textarea (this.\$('.TextEditor-editorContainer')[0] is undefined).
  • After fix, with the slowdown applied: composer opens cleanly, editor mounts, typing works.
  • After fix, no slowdown: normal composer use is unchanged.

Closes #4612

`oncreate` called `_load().then(() => setTimeout(this.onbuild, 50))`.
The 50ms timer is a guess at how long it takes Mithril's async
`m.redraw()` to flush — under slow first-load conditions (cold CDN,
heavy DOM, additional `_loaders`) the redraw can land *after* the
timer fires, leaving `this.$('.TextEditor-editorContainer')[0]` as
`undefined` when `onbuild` runs. That `undefined` then propagates
into the editor driver (notably breaking Tiptap-based drivers like
fof/rich-text, where it produces a non-functional toolbar with no
editor underneath).

Use `m.redraw.sync()` in `_load` so the DOM is updated before the
promise resolves, and drop the 50ms timer entirely. The `.then` runs
on a microtask outside any Mithril event handler, so `redraw.sync()`
is safe here.

Closes #4612
@imorland imorland requested a review from a team as a code owner May 12, 2026 20:46
@imorland imorland added this to the 2.0.0-rc.2 milestone May 12, 2026
@imorland imorland merged commit 3055037 into 2.x May 12, 2026
25 checks passed
@imorland imorland deleted the im/text-editor-onbuild-race branch May 12, 2026 21:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TextEditor.oncreate has a 50ms hardcoded race against Mithril's async redraw

1 participant