fix(web): isDestroyed editor check - #777
Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a safety wrapper around Tiptap editor operations to avoid running commands on a null/destroyed editor instance.
Changes:
- Introduced
runSafelyInEditorhelper to guard editor command execution. - Wrapped multiple editor command invocations (
focus,blur,setContent, normalization) with the new helper. - Updated imperative handle methods to use the safety wrapper.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/web/EnrichedTextInput.tsx:189
- The guard is evaluated against
editorInstanceRef.current, but the callback re-readseditorInstanceRef.currentagain. If the ref changes between the guard check and the callback execution, you may end up blurring a different editor instance (or skipping blur due to optional chaining) than the one you validated. Capture the current editor in a local variable and use that consistently inside the callback, or changerunSafelyInEditorto pass the validated editor into the callback (e.g.,(safeEditor) => safeEditor.commands.blur()).
runSafelyInEditor(editorInstanceRef.current, () =>
editorInstanceRef.current?.commands.blur()
);
src/web/EnrichedTextInput.tsx:93
- Using
!!editor && ... && toRun()relies on short-circuit side effects, which is harder to read and makes it difficult to extend (e.g., logging, debugging, returning a status). Consider rewriting this as an explicitifblock. Also, consider passing the validatededitorinto the callback so call sites don’t need to close over potentially-stale variables or use optional chaining inside the guarded callback.
function runSafelyInEditor(editor: Editor | null, toRun: () => void) {
!!editor && !editor.isDestroyed && toRun();
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/web/EnrichedTextInput.tsx:364
- These imperative methods now return
T | nullbecauserunSafelyInEditorreturnsnullwhen the editor is unavailable/destroyed. IfEnrichedTextInputInstancepreviously exposedfocus/blur/setValueas returningvoidor a non-nullable boolean, this is a behavioral and typing change for callers. Consider preserving the prior return contract by (a) making these methods explicitlyvoidand no-op when unsafe, or (b) returning a non-nullable sentinel (e.g.,false) when the editor can’t run, or (c) updatingEnrichedTextInputInstanceto explicitly allownulland documenting the new behavior.
focus: () => runSafelyInEditor(editor, (e) => e.commands.focus()),
blur: () => runSafelyInEditor(editor, (e) => e.commands.blur()),
setValue: (value: string) =>
runSafelyInEditor(editor, (e) =>
e.commands.setContent(
prepareHtmlForTiptap(
value,
useHtmlNormalizerRef.current,
sanitizationConfigRef.current
)
)
),
src/web/EnrichedTextInput.tsx:99
runSafelyInEditorforces callers into aT | nullreturn type, which can easily leak into public APIs (as in the imperative handle) or encourage silently ignoring failures. A more maintainable pattern is to either (1) provide a dedicatedrunSafelyInEditorVoid(editor, fn): voidfor side-effecting commands, or (2) accept a requiredfallbackvalue so the function returnsT(no union), making call sites intentional about what happens when the editor is unavailable.
function runSafelyInEditor<T>(
editor: Editor | null,
toRun: (editor: Editor) => T
): T | null {
if (editor && !editor.isDestroyed) {
return toRun(editor);
}
return null;
}
szydlovsky
left a comment
There was a problem hiding this comment.
The safety net looks really good (code-wise).
Summary
There is a well-known issue with TipTap editor where it doesn't necessarily check for its
isDestroyedstate, when trying to use e.g.commands.ueberdosis/tiptap#1451
Here this flow resulted in an uncaught error
Cannot read properties of null (reading 'commands'):There was a found edge-case, when you re-rendered
EnrichedTextInputwith newhtmlStyleanddefaultValue:resolvedHtmlchanges because of the newhtmlStyletiptapContentchanges because of the newdefaultValueuseEditorruns and recreates the input astiptapContentchanged - this is a hook so actual recreation will happen after the renderuseEffectwithcommands.normalizeBoldInStyledHeadingsgets scheduled to run after the render. The closure here captures the staleeditor, beforeuseEditoractually ran.useEditorsuccessfully runs and updateseditoruseEffectruns with astaleeditor, which crashes the appAfter the added check the
6.will never run, buteditorchanged, so the sameuseEffectwill correctly run on the next render.Provided safety-checks, when using
commands, outside of the TipTap internal state, inEnrichedTextInput.all web e2e tests pass
Compatibility
Checklist