From a66793831ecc9f161e7e2e4a82094a70fec60871 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 31 Aug 2026 15:34:53 +0200 Subject: [PATCH 01/14] feat(core,react): public focus API with editor-UI-aware tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `editor.isFocused()` and `onFocusChange` previously only saw the content area, so focus moving into the editor's own UI — a toolbar popover's input — read as a blur. That is fine for a desktop toolbar that unmounts anyway, but the mobile toolbar has to stay up while the user types a URL into the popover it opened. Adds an `includeEditorUI` option that treats the editor's UI as part of the editor, and defers the decision until focus has settled (at focusout the outgoing element has already lost focus and `document.activeElement` reads as ``, so the destination isn't knowable yet). `useEditorFocus` exposes it as state for components that render off focus; `useEditorFocusChange` is the side-effect counterpart, the same split as `useEditorState` vs `useEditorChange`. The mobile toolbar controller switches to the hook, dropping its private reach into `editor._tiptapEditor`. The new hooks hold their callback in a ref so the subscription survives re-renders; `useEditorChange` and `useEditorSelectionChange` are converted to the same pattern for consistency. (Behaviour note: they no longer resubscribe when the callback identity changes — the latest callback is simply invoked.) The DOM contract this rests on is asserted rather than assumed — EventManager.browser.test.ts pins the documented focus event order, and that `document.activeElement` is `` during focusout, across all three engines. --- packages/core/src/editor/BlockNoteEditor.ts | 57 +++- .../managers/EventManager.browser.test.ts | 301 ++++++++++++++++++ .../core/src/editor/managers/EventManager.ts | 151 +++++++++ .../MobileFormattingToolbarController.tsx | 43 +-- packages/react/src/hooks/useEditorChange.ts | 15 +- packages/react/src/hooks/useEditorFocus.ts | 76 +++++ .../react/src/hooks/useEditorFocusChange.ts | 50 +++ .../src/hooks/useEditorSelectionChange.ts | 16 +- packages/react/src/index.ts | 2 + .../end-to-end/focus/useEditorFocus.test.tsx | 196 ++++++++++++ 10 files changed, 863 insertions(+), 44 deletions(-) create mode 100644 packages/core/src/editor/managers/EventManager.browser.test.ts create mode 100644 packages/react/src/hooks/useEditorFocus.ts create mode 100644 packages/react/src/hooks/useEditorFocusChange.ts create mode 100644 tests/src/end-to-end/focus/useEditorFocus.test.tsx diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 25b93d03f4..83624785fd 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -797,6 +797,15 @@ export class BlockNoteEditor< * Checks whether a DOM element belongs to this editor — either inside the * editor's DOM tree or inside its portal container (used for floating UI * elements like menus and toolbars). + * + * The first check starts at the content area's *parent*, so that UI the + * host app renders as `BlockNoteView` children counts too — React places + * those beside the content (see the "Static Formatting Toolbar" example). + * + * So the boundary is whatever the element passed to `editor.mount()` has as + * its parent. `BlockNoteView` always provides a wrapper; mounting bare into + * ``, as the vanilla-JS docs do, makes the whole page count as within + * the editor. */ public isWithinEditor = (element: Element): boolean => { return !!( @@ -805,11 +814,25 @@ export class BlockNoteEditor< ); }; - public isFocused() { + public isFocused(options?: { + /** + * When true, the editor's own UI (toolbars, menus, popovers — + * everything portalled into `editor.portalElement`) also counts as + * focused, answering "is the user still interacting with this editor?". + * The default reports content-area focus only. + */ + includeEditorUI?: boolean; + }) { if (this.headless) { return false; } - return this.prosemirrorView?.hasFocus() || false; + const contentFocused = this.prosemirrorView?.hasFocus() || false; + if (!options?.includeEditorUI) { + return contentFocused; + } + const active = + typeof document !== "undefined" ? document.activeElement : null; + return contentFocused || (!!active && this.isWithinEditor(active)); } public headless = true; @@ -1365,6 +1388,36 @@ export class BlockNoteEditor< ); } + /** + * A callback function that runs whenever the editor's content area gains or + * loses DOM focus. + * + * Note that `focused: false` only means the content area itself blurred — + * focus may have moved into the editor's own UI (e.g. a toolbar + * popover's input). + * + * @param callback The callback to execute. + * @returns A function to remove the callback. + */ + public onFocusChange( + callback: ( + editor: BlockNoteEditor, + context: { focused: boolean; event: FocusEvent }, + ) => void, + options?: { + /** + * When true, the editor's own UI (toolbars, menus, popovers — + * everything portalled into `editor.portalElement`) counts as focused, + * and the callback fires only when that combined focus state actually + * changes, after focus movement has settled. The default reports raw + * content-area focus/blur events. + */ + includeEditorUI?: boolean; + }, + ) { + return this._eventManager.onFocusChange(callback, options); + } + /** * A callback function that runs when the editor has been mounted. * diff --git a/packages/core/src/editor/managers/EventManager.browser.test.ts b/packages/core/src/editor/managers/EventManager.browser.test.ts new file mode 100644 index 0000000000..684c19745c --- /dev/null +++ b/packages/core/src/editor/managers/EventManager.browser.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteEditor } from "../BlockNoteEditor.js"; + +// Focus tracking is almost entirely DOM semantics — event ordering, what +// `document.activeElement` reads as at each step, and how focus behaves when +// it moves into UI that is portalled outside the editor. None of that is +// reproducible in jsdom, so these run in the browser suite across all three +// engines. + +/** Resolves once the deferred focus settle has run (see attachUIFocusTracker). */ +function settle() { + return new Promise((resolve) => setTimeout(resolve, 20)); +} + +describe("Focus events", () => { + let editor: BlockNoteEditor; + let container: HTMLElement; + let outside: HTMLInputElement; + + /** + * Mounts an editor inside its own container, mirroring how `BlockNoteView` + * renders it. The nesting matters: `isWithinEditor` — which `isFocused` + * and the focus events are built on — treats the mount element's *parent* + * as the editor's boundary, so that it also covers UI rendered as a + * sibling of the content area. Mounting straight into `` would make + * that boundary the whole page. + */ + function mountEditor() { + const editorContainer = document.createElement("div"); + const mountPoint = document.createElement("div"); + editorContainer.append(mountPoint); + document.body.append(editorContainer); + const instance = BlockNoteEditor.create(); + instance.mount(mountPoint); + return { editor: instance, container: editorContainer }; + } + + beforeEach(() => { + outside = document.createElement("input"); + outside.id = "outside"; + document.body.append(outside); + + ({ editor, container } = mountEditor()); + }); + + afterEach(() => { + editor.unmount(); + container.remove(); + outside.remove(); + }); + + /** + * The DOM contract the tracker is built on + * (https://developer.mozilla.org/en-US/docs/Web/API/FocusEvent#order_of_events). + * If a future engine changes this ordering, the tracker's "settle + * immediately on focusin, defer on focusout" split stops being valid — so + * it's asserted rather than assumed. + */ + it("follows the documented focus event order", async () => { + const a = document.createElement("input"); + const b = document.createElement("input"); + document.body.append(a, b); + const order: string[] = []; + for (const [name, element] of [ + ["a", a], + ["b", b], + ] as const) { + for (const type of ["blur", "focusout", "focus", "focusin"]) { + element.addEventListener(type, () => order.push(`${type}:${name}`)); + } + } + + a.focus(); + order.length = 0; + b.focus(); + + expect(order).toEqual(["blur:a", "focusout:a", "focus:b", "focusin:b"]); + + a.remove(); + b.remove(); + }); + + /** + * Why the focusout side has to be deferred: at focusout time the outgoing + * element has *already* lost focus and `document.activeElement` reads as + * ``, so the destination isn't knowable yet. (`relatedTarget` can't + * substitute — MDN documents it as null in cases like tabbing out of the + * page, and it is unreliable on mobile.) + */ + it("reports as the active element during focusout", async () => { + const a = document.createElement("input"); + document.body.append(a); + let activeDuringFocusOut: Element | null = null; + a.addEventListener("focusout", () => { + activeDuringFocusOut = document.activeElement; + }); + + a.focus(); + a.blur(); + + expect(activeDuringFocusOut).toBe(document.body); + a.remove(); + }); + + it("isFocused() tracks the content area", async () => { + expect(editor.isFocused()).toBe(false); + + editor.focus(); + expect(editor.isFocused()).toBe(true); + + outside.focus(); + expect(editor.isFocused()).toBe(false); + }); + + it("isFocused({ includeEditorUI }) counts the editor's own UI", async () => { + // The portal element is where menus, toolbars and popovers render — it + // lives outside the content area, so plain content focus can't see it. + const popoverInput = document.createElement("input"); + editor.portalElement.append(popoverInput); + + popoverInput.focus(); + + expect(editor.isFocused()).toBe(false); + expect(editor.isFocused({ includeEditorUI: true })).toBe(true); + + outside.focus(); + expect(editor.isFocused({ includeEditorUI: true })).toBe(false); + + popoverInput.remove(); + }); + + it("onFocusChange reports content focus and blur", async () => { + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange((_editor, ctx) => + events.push(ctx.focused), + ); + + editor.focus(); + await settle(); + outside.focus(); + await settle(); + + expect(events).toEqual([true, false]); + unsubscribe(); + }); + + it("onFocusChange({ includeEditorUI }) stays focused across a handoff into the editor's UI", async () => { + const popoverInput = document.createElement("input"); + editor.portalElement.append(popoverInput); + + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange( + (_editor, ctx) => events.push(ctx.focused), + { includeEditorUI: true }, + ); + + editor.focus(); + await settle(); + // Content -> a popover input. This is the handoff that matters: the raw + // channel would report a blur here, which is what used to tear the mobile + // toolbar (and the popover with it) down mid-interaction. + popoverInput.focus(); + await settle(); + + expect(events.at(-1)).toBe(true); + expect(events).not.toContain(false); + + // Leaving the editor entirely does report a blur. + outside.focus(); + await settle(); + expect(events.at(-1)).toBe(false); + + unsubscribe(); + popoverInput.remove(); + }); + + it("does not report a spurious blur while focus moves between UI elements", async () => { + const first = document.createElement("input"); + const second = document.createElement("input"); + editor.portalElement.append(first, second); + + editor.focus(); + await settle(); + + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange( + (_editor, ctx) => events.push(ctx.focused), + { includeEditorUI: true }, + ); + + first.focus(); + second.focus(); + editor.focus(); + await settle(); + + expect(events).not.toContain(false); + + unsubscribe(); + first.remove(); + second.remove(); + }); + + it("ignores focus changes that never involve the editor", async () => { + // The tracker listens at the document level, so it sees every focus + // change on the page — including ones with nothing to do with this + // editor. Those must not reach subscribers. + const otherA = document.createElement("input"); + const otherB = document.createElement("input"); + document.body.append(otherA, otherB); + + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange( + (_editor, ctx) => events.push(ctx.focused), + { includeEditorUI: true }, + ); + + otherA.focus(); + await settle(); + otherB.focus(); + await settle(); + + expect(events).toEqual([]); + + unsubscribe(); + otherA.remove(); + otherB.remove(); + }); + + it("keeps two editors on one page independent", async () => { + const { editor: other, container: otherContainer } = mountEditor(); + + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange( + (_editor, ctx) => events.push(ctx.focused), + { includeEditorUI: true }, + ); + + other.focus(); + await settle(); + + expect(other.isFocused()).toBe(true); + expect(editor.isFocused({ includeEditorUI: true })).toBe(false); + expect(events).toEqual([]); + + unsubscribe(); + other.unmount(); + otherContainer.remove(); + }); + + it("stops delivering events after unsubscribing", async () => { + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange( + (_editor, ctx) => events.push(ctx.focused), + { includeEditorUI: true }, + ); + + editor.focus(); + await settle(); + const countWhileSubscribed = events.length; + expect(countWhileSubscribed).toBeGreaterThan(0); + + unsubscribe(); + outside.focus(); + await settle(); + editor.focus(); + await settle(); + + expect(events.length).toBe(countWhileSubscribed); + }); + + it("supports several subscribers independently", async () => { + const first: boolean[] = []; + const second: boolean[] = []; + const unsubscribeFirst = editor.onFocusChange( + (_editor, ctx) => first.push(ctx.focused), + { includeEditorUI: true }, + ); + const unsubscribeSecond = editor.onFocusChange( + (_editor, ctx) => second.push(ctx.focused), + { includeEditorUI: true }, + ); + + editor.focus(); + await settle(); + expect(first.length).toBeGreaterThan(0); + expect(second.length).toBe(first.length); + + // The document listeners are shared and reference-counted, so dropping + // one subscriber must not stop the other's events. + unsubscribeFirst(); + const firstCount = first.length; + outside.focus(); + await settle(); + + expect(first.length).toBe(firstCount); + expect(second.at(-1)).toBe(false); + + unsubscribeSecond(); + }); +}); diff --git a/packages/core/src/editor/managers/EventManager.ts b/packages/core/src/editor/managers/EventManager.ts index 4d2f9f581a..beed2a5dd2 100644 --- a/packages/core/src/editor/managers/EventManager.ts +++ b/packages/core/src/editor/managers/EventManager.ts @@ -34,6 +34,20 @@ export class EventManager< onSelectionChange: [ ctx: { editor: BlockNoteEditor; transaction: Transaction }, ]; + onFocusChange: [ + ctx: { + editor: BlockNoteEditor; + focused: boolean; + event: FocusEvent; + }, + ]; + onFocusChangeWithinUI: [ + ctx: { + editor: BlockNoteEditor; + focused: boolean; + event: FocusEvent; + }, + ]; onMount: [ctx: { editor: BlockNoteEditor }]; onUnmount: [ctx: { editor: BlockNoteEditor }]; }> { @@ -51,15 +65,94 @@ export class EventManager< editor._tiptapEditor.on("selectionUpdate", ({ transaction }) => { this.emit("onSelectionChange", { editor, transaction }); }); + editor._tiptapEditor.on("focus", ({ event }) => { + this.emit("onFocusChange", { editor, focused: true, event }); + }); + editor._tiptapEditor.on("blur", ({ event }) => { + this.emit("onFocusChange", { editor, focused: false, event }); + }); editor._tiptapEditor.on("mount", () => { this.emit("onMount", { editor }); }); editor._tiptapEditor.on("unmount", () => { this.emit("onUnmount", { editor }); }); + editor._tiptapEditor.on("destroy", () => { + // Subscribers normally detach the tracker when the last one + // unsubscribes; this covers subscribers that outlive the editor. + this.detachUIFocusTracker?.(); + }); }); } + /** + * Settled focus-within-UI tracking. Document-level listeners (attached only + * while someone subscribes with `includeEditorUI`) cover the case tiptap + * events can't: focus moving from the editor's own UI (which lives in + * `editor.portalElement`, outside the content area) to somewhere else + * entirely. Blur-side changes are re-checked a frame later because + * `document.activeElement` transiently becomes `` during focus + * handoffs (and `relatedTarget` is unreliable on mobile). + */ + private uiFocused = false; + + private uiFocusSubscriberCount = 0; + + private uiFocusSettleHandle: ReturnType | undefined; + + private detachUIFocusTracker: (() => void) | undefined; + + private computeUIFocused(): boolean { + const active = + typeof document !== "undefined" ? document.activeElement : null; + return ( + this.editor.isFocused() || + (!!active && this.editor.isWithinEditor(active)) + ); + } + + private settleUIFocus(event: FocusEvent) { + const focused = this.computeUIFocused(); + if (focused !== this.uiFocused) { + this.uiFocused = focused; + this.emit("onFocusChangeWithinUI", { + editor: this.editor, + focused, + event, + }); + } + } + + private attachUIFocusTracker() { + if (typeof document === "undefined") { + return; + } + this.uiFocused = this.computeUIFocused(); + // On focusin the new element already holds focus, so the state can be + // read immediately. + const onFocusIn = (event: FocusEvent) => this.settleUIFocus(event); + + // On focusout it can't: `document.activeElement` is still the outgoing + // element (and passes through `` mid-handoff), and some UI + // libraries restore focus asynchronously — the ariakit and shadcn link + // popovers both do. The check therefore has to wait for the current task + // to finish. A microtask is too early (verified: those popover tests go + // red), and a frame would work but doesn't run in a background tab. + const onFocusOut = (event: FocusEvent) => { + clearTimeout(this.uiFocusSettleHandle); + this.uiFocusSettleHandle = setTimeout(() => this.settleUIFocus(event)); + }; + + document.addEventListener("focusin", onFocusIn, true); + document.addEventListener("focusout", onFocusOut, true); + this.detachUIFocusTracker = () => { + clearTimeout(this.uiFocusSettleHandle); + document.removeEventListener("focusin", onFocusIn, true); + document.removeEventListener("focusout", onFocusOut, true); + this.detachUIFocusTracker = undefined; + }; + } + /** * Register a callback that will be called when the editor changes. */ @@ -131,6 +224,64 @@ export class EventManager< }; } + /** + * Register a callback that will be called when the editor's content area + * gains or loses DOM focus. + * + * Note that `focused: false` only means the content area itself blurred — + * focus may have moved into the editor's own UI (e.g. a toolbar + * popover's input). Consumers that need to distinguish should check where + * `document.activeElement` ended up. + */ + public onFocusChange( + callback: ( + editor: BlockNoteEditor, + ctx: { focused: boolean; event: FocusEvent }, + ) => void, + options?: { + /** + * When true, the editor's own UI (toolbars, menus, popovers — + * everything portalled into `editor.portalElement`) counts as focused, + * and events fire only when that combined focus state actually changes, + * after focus movement has settled. Use this to know whether the user + * is still interacting with the editor; the default reports raw + * content-area focus/blur. + */ + includeEditorUI?: boolean; + }, + ): Unsubscribe { + const cb = ({ + focused, + event, + }: { + focused: boolean; + event: FocusEvent; + }) => { + callback(this.editor, { focused, event }); + }; + + if (options?.includeEditorUI) { + this.uiFocusSubscriberCount++; + if (this.uiFocusSubscriberCount === 1) { + this.attachUIFocusTracker(); + } + this.on("onFocusChangeWithinUI", cb); + return () => { + this.off("onFocusChangeWithinUI", cb); + this.uiFocusSubscriberCount--; + if (this.uiFocusSubscriberCount === 0) { + this.detachUIFocusTracker?.(); + } + }; + } + + this.on("onFocusChange", cb); + + return () => { + this.off("onFocusChange", cb); + }; + } + /** * Register a callback that will be called when the editor is mounted. */ diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx index b1ea2f757a..797b201753 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -1,7 +1,7 @@ -import { FC, useEffect, useState } from "react"; +import { FC } from "react"; import { UIModeContext } from "../../editor/UIModeContext.js"; -import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; +import { useEditorFocus } from "../../hooks/useEditorFocus.js"; import { FormattingToolbarProps } from "./FormattingToolbarProps.js"; import { FormattingToolbar } from "./FormattingToolbar.js"; import { useVirtualKeyboard } from "./useVirtualKeyboard.js"; @@ -41,42 +41,13 @@ import { useVirtualKeyboard } from "./useVirtualKeyboard.js"; export const MobileFormattingToolbarController = (props: { formattingToolbar?: FC; }) => { - const editor = useBlockNoteEditor(); const keyboardOpen = useVirtualKeyboard(); - // Whether focus is within this editor's UI, kept in sync via its - // `focus`/`blur` events so the toolbar shows/hides as focus enters or leaves - // the editor. - const [focused, setFocused] = useState(() => editor.isFocused()); - useEffect(() => { - // Re-sync on mount in case focus changed before the listeners attached. - setFocused(editor.isFocused()); - - const onFocus = () => setFocused(true); - // When the editor's content blurs, focus may still be within the editor's - // own floating UI — e.g. a toolbar popover's input autofocusing, which - // portals into `editor.portalElement`. Treating that as "focus left the - // editor" would unmount this toolbar (and the popover with it), so it would - // appear to never open. `relatedTarget` is unreliable on mobile, so we - // re-check `document.activeElement` on the next frame and only hide once - // focus has truly left the editor and its portal. - const onBlur = () => { - requestAnimationFrame(() => { - const active = document.activeElement; - setFocused( - editor.isFocused() || (!!active && editor.isWithinEditor(active)), - ); - }); - }; - - editor._tiptapEditor.on("focus", onFocus); - editor._tiptapEditor.on("blur", onBlur); - - return () => { - editor._tiptapEditor.off("focus", onFocus); - editor._tiptapEditor.off("blur", onBlur); - }; - }, [editor]); + // Whether the user is still interacting with this editor: content focus or + // focus within its UI (a toolbar popover's input, portalled into + // `editor.portalElement`, must not hide the toolbar — unmounting it would + // take the popover down with it). + const focused = useEditorFocus({ includeEditorUI: true }); if (!keyboardOpen || !focused) { return null; diff --git a/packages/react/src/hooks/useEditorChange.ts b/packages/react/src/hooks/useEditorChange.ts index ade26292eb..0e15c1c7df 100644 --- a/packages/react/src/hooks/useEditorChange.ts +++ b/packages/react/src/hooks/useEditorChange.ts @@ -1,5 +1,5 @@ import type { BlockNoteEditor } from "@blocknote/core"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; /** @@ -20,6 +20,13 @@ export function useEditorChange( editor = editorContext?.editor; } + // Latest-ref pattern: the subscription lives as long as the editor does, + // while the callback stays current without resubscribing on re-renders. + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }); + useEffect(() => { if (!editor) { throw new Error( @@ -27,6 +34,8 @@ export function useEditorChange( ); } - return editor.onChange(callback); - }, [callback, editor]); + return editor.onChange((...args: Parameters) => + callbackRef.current(...args), + ); + }, [editor]); } diff --git a/packages/react/src/hooks/useEditorFocus.ts b/packages/react/src/hooks/useEditorFocus.ts new file mode 100644 index 0000000000..98cb68014c --- /dev/null +++ b/packages/react/src/hooks/useEditorFocus.ts @@ -0,0 +1,76 @@ +import type { BlockNoteEditor } from "@blocknote/core"; +import { useCallback, useRef, useSyncExternalStore } from "react"; +import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; + +/** + * Whether the editor is focused, as state — re-rendering the component when + * that changes. + * + * Use this when focus decides what to *render*. + * {@link useEditorFocusChange} is the counterpart for running a side effect on + * focus changes (the same split as `useEditorState` vs `useEditorChange`). + * + * By default this reports raw content-area focus, so `false` may just mean + * focus moved into the editor's own UI — a toolbar popover's input, say. Pass + * `includeEditorUI: true` to instead get "is the user still interacting with + * this editor", which counts toolbars, menus and popovers as focused and only + * changes once focus movement has settled. + * + * @param options - See `editor.onFocusChange`. + * @param editor - The BlockNote editor instance. If omitted, uses the editor + * from the nearest `BlockNoteContext`. + */ +export function useEditorFocus( + options?: { includeEditorUI?: boolean }, + editor?: BlockNoteEditor, +): boolean { + const editorContext = useBlockNoteContext(); + const resolvedEditor = editor ?? editorContext?.editor; + + if (!resolvedEditor) { + // Thrown during render rather than from an effect: the return value is + // used to render, so a deferred throw would first paint a frame with a + // meaningless value. + throw new Error( + "'editor' is required, either from BlockNoteContext or as a function argument", + ); + } + + const includeEditorUI = options?.includeEditorUI ?? false; + + // The snapshot is the last *settled* value, never a live read. With + // `includeEditorUI` the editor's own events are already settled, whereas + // reading focus state during an arbitrary render can catch a mid-handoff + // frame, where `document.activeElement` is transiently `` and the + // editor looks unfocused for one frame. + const focused = useRef(undefined); + if (focused.current === undefined) { + focused.current = resolvedEditor.isFocused({ includeEditorUI }); + } + + const subscribe = useCallback( + (onStoreChange: () => void) => { + // Re-sync: focus can have changed between the render that produced the + // current snapshot and this subscription attaching. React does compare + // the snapshot again after subscribing (its subscribe effect is + // registered before the consistency-check one), so refreshing the + // cached value here is enough — but notifying explicitly keeps that + // independent of React's internal effect ordering. + focused.current = resolvedEditor.isFocused({ includeEditorUI }); + onStoreChange(); + + return resolvedEditor.onFocusChange( + (_editor, ctx) => { + focused.current = ctx.focused; + onStoreChange(); + }, + { includeEditorUI }, + ); + }, + [resolvedEditor, includeEditorUI], + ); + + const getSnapshot = useCallback(() => focused.current!, []); + + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/packages/react/src/hooks/useEditorFocusChange.ts b/packages/react/src/hooks/useEditorFocusChange.ts new file mode 100644 index 0000000000..b14b8f514d --- /dev/null +++ b/packages/react/src/hooks/useEditorFocusChange.ts @@ -0,0 +1,50 @@ +import type { BlockNoteEditor } from "@blocknote/core"; +import { useEffect, useRef } from "react"; +import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; + +/** + * Subscribes to the editor gaining or losing focus. The subscription is + * automatically cleaned up when the component unmounts, and the latest + * `callback` is always invoked without resubscribing on re-renders. + * + * By default this reports raw content-area focus/blur; `focused: false` may + * mean focus moved into the editor's own UI (e.g. a toolbar + * popover's input). Pass `includeEditorUI: true` to instead observe "is the + * user still interacting with this editor" — floating UI counts as focused, + * and the callback fires only on settled changes of that combined state. + * + * @param callback - Function called with the editor and `{ focused, event }`. + * @param editor - The BlockNote editor instance. If omitted, uses the editor + * from the nearest `BlockNoteContext`. + * @param options - See `editor.onFocusChange`. + */ +export function useEditorFocusChange( + callback: Parameters["onFocusChange"]>[0], + editor?: BlockNoteEditor, + options?: Parameters["onFocusChange"]>[1], +) { + const editorContext = useBlockNoteContext(); + const resolvedEditor = editor ?? editorContext?.editor; + + // Latest-ref pattern: the subscription lives as long as the editor does, + // while the callback stays current without retriggering the effect. + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }); + + const includeEditorUI = options?.includeEditorUI ?? false; + + useEffect(() => { + if (!resolvedEditor) { + throw new Error( + "'editor' is required, either from BlockNoteContext or as a function argument", + ); + } + + return resolvedEditor.onFocusChange( + (editorArg, ctx) => callbackRef.current(editorArg, ctx), + { includeEditorUI }, + ); + }, [resolvedEditor, includeEditorUI]); +} diff --git a/packages/react/src/hooks/useEditorSelectionChange.ts b/packages/react/src/hooks/useEditorSelectionChange.ts index 08225fc88f..e443487452 100644 --- a/packages/react/src/hooks/useEditorSelectionChange.ts +++ b/packages/react/src/hooks/useEditorSelectionChange.ts @@ -1,5 +1,5 @@ import type { BlockNoteEditor } from "@blocknote/core"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; /** @@ -23,12 +23,22 @@ export function useEditorSelectionChange( editor = editorContext?.editor; } + // Latest-ref pattern: the subscription lives as long as the editor does, + // while the callback stays current without resubscribing on re-renders. + const callbackRef = useRef(callback); + useEffect(() => { + callbackRef.current = callback; + }); + useEffect(() => { if (!editor) { throw new Error( "'editor' is required, either from BlockNoteContext or as a function argument", ); } - return editor.onSelectionChange(callback, includeSelectionChangedByRemote); - }, [callback, editor, includeSelectionChangedByRemote]); + return editor.onSelectionChange( + () => callbackRef.current(), + includeSelectionChangedByRemote, + ); + }, [editor, includeSelectionChangedByRemote]); } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c8689667b2..e5ba94c223 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -134,6 +134,8 @@ export * from "./hooks/useActiveStyles.js"; export * from "./hooks/useBlockNoteEditor.js"; export * from "./hooks/useCreateBlockNote.js"; export * from "./hooks/useEditorChange.js"; +export * from "./hooks/useEditorFocus.js"; +export * from "./hooks/useEditorFocusChange.js"; export * from "./hooks/useEditorDomElement.js"; export * from "./hooks/useEditorSelectionBoundingBox.js"; export * from "./hooks/useEditorSelectionChange.js"; diff --git a/tests/src/end-to-end/focus/useEditorFocus.test.tsx b/tests/src/end-to-end/focus/useEditorFocus.test.tsx new file mode 100644 index 0000000000..7ec2a416cd --- /dev/null +++ b/tests/src/end-to-end/focus/useEditorFocus.test.tsx @@ -0,0 +1,196 @@ +import { + useCreateBlockNote, + useEditorFocus, + useEditorFocusChange, +} from "@blocknote/react"; +import { BlockNoteView } from "@blocknote/mantine"; +import "@blocknote/mantine/style.css"; +import { useRef, useState } from "react"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { userEvent } from "../../utils/context.js"; +import { EDITOR_SELECTOR } from "../../utils/const.js"; +import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; + +// `useEditorFocus` is the state counterpart to `useEditorFocusChange`. What +// needs proving is that it reports *settled* focus and doesn't re-render on +// every focus event in the page — the reasons it exists rather than each +// consumer wiring up useState + useEffect itself. + +function Probe(props: { includeEditorUI: boolean }) { + const editor = useCreateBlockNote(); + return ( + + + + ); +} + +function Readout(props: { includeEditorUI: boolean }) { + const focused = useEditorFocus({ includeEditorUI: props.includeEditorUI }); + const renders = useRef(0); + renders.current += 1; + return ( +
+ ); +} + +function readout() { + return document.querySelector('[data-test="readout"]')!; +} + +function focusedValue() { + return readout().dataset.focused; +} + +afterEach(() => { + document.querySelectorAll(".zz-outside").forEach((el) => el.remove()); +}); + +function addOutsideInput() { + const input = document.createElement("input"); + input.className = "zz-outside"; + document.body.append(input); + return input; +} + +describe("useEditorFocus", () => { + test("reports content focus and blur", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + expect(focusedValue()).toBe("false"); + + await focusOnEditor(); + await vi.waitFor(() => expect(focusedValue()).toBe("true")); + + addOutsideInput().focus(); + await vi.waitFor(() => expect(focusedValue()).toBe("false")); + }); + + test("with includeEditorUI, stays focused across a handoff into the editor's UI", async () => { + await render(); + const editorElement = await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await vi.waitFor(() => expect(focusedValue()).toBe("true")); + + // A popover input, portalled outside the content area: the portal is the + // container child that isn't an ancestor of the content element. + const container = editorElement.closest(".bn-container")!; + const portal = Array.from(container.children).find( + (child) => !child.contains(editorElement), + ) as HTMLElement; + expect(portal).toBeDefined(); + const popoverInput = document.createElement("input"); + portal.append(popoverInput); + popoverInput.focus(); + + // Give the settle a chance to run, then confirm it never dropped. + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(focusedValue()).toBe("true"); + + addOutsideInput().focus(); + await vi.waitFor(() => expect(focusedValue()).toBe("false")); + popoverInput.remove(); + }); + + test("does not re-render for focus changes elsewhere on the page", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await vi.waitFor(() => expect(focusedValue()).toBe("true")); + + const rendersBefore = Number(readout().dataset.renders); + const a = addOutsideInput(); + const b = addOutsideInput(); + // Focus bouncing between two unrelated inputs: the editor goes unfocused + // once, and must not re-render for every subsequent hop. + a.focus(); + await vi.waitFor(() => expect(focusedValue()).toBe("false")); + const rendersAfterBlur = Number(readout().dataset.renders); + for (let i = 0; i < 5; i++) { + (i % 2 === 0 ? b : a).focus(); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + expect(rendersAfterBlur).toBeGreaterThan(rendersBefore); + expect(Number(readout().dataset.renders)).toBe(rendersAfterBlur); + }); + + test("typing does not re-render the consumer", async () => { + await render(); + await waitForSelector(EDITOR_SELECTOR); + await focusOnEditor(); + await vi.waitFor(() => expect(focusedValue()).toBe("true")); + + const before = Number(readout().dataset.renders); + await userEvent.keyboard("some typing that changes the document"); + + expect(Number(readout().dataset.renders)).toBe(before); + }); +}); + +// `useEditorFocusChange` (the callback counterpart) keeps its subscription +// alive across re-renders via the latest-ref pattern. Without it, an inline +// callback — the common case — has a new identity every render, so the effect +// re-runs and the editor is unsubscribed and resubscribed each time. That +// matters more than it looks: with `includeEditorUI` the subscription is +// reference-counted, so cycling it tears down and re-attaches the document +// focus listeners and resets the settled baseline. Measured: naive +// implementation resubscribes once per render (6 after 5 re-renders), this +// one stays at 1. +describe("useEditorFocusChange", () => { + test("does not resubscribe when the callback identity changes", async () => { + let subscribes = 0; + + function CountingProbe() { + const editor = useCreateBlockNote(); + // Patch once, not on every render. + useState(() => { + const original = editor.onFocusChange.bind(editor); + (editor as any).onFocusChange = (...args: any[]) => { + subscribes += 1; + return (original as any)(...args); + }; + return null; + }); + return ( + + + + ); + } + + function Rerenderer() { + const [n, setN] = useState(0); + useEditorFocusChange(() => { + /* inline: new identity every render */ + }); + return ( + + ); + } + + await render(); + await waitForSelector(EDITOR_SELECTOR); + const afterMount = subscribes; + expect(afterMount).toBe(1); + + const button = document.querySelector( + '[data-test="rerender"]', + )!; + for (let i = 0; i < 5; i++) { + button.click(); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + expect(Number(button.textContent)).toBe(5); + expect(subscribes).toBe(afterMount); + }); +}); From 366979ffb0257ffb623ec7b940e0fcb482dff4fe Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 31 Aug 2026 16:27:12 +0200 Subject: [PATCH 02/14] fix(core): make the focus unsubscribe idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document listeners behind `includeEditorUI` are reference-counted, and the returned unsubscribe decremented that count unconditionally. Calling it twice — which cleanup code does defensively — drove the count negative, so it never reached 1 again and the tracker silently stopped attaching for every later subscriber, with nothing to indicate anything was wrong. Proven across all three engines: subscribing after a double unsubscribe received no events at all. Also collapses the three near-identical copies of the `includeEditorUI` documentation into one exported `EditorFocusOptions` type, so the explanation has a single home rather than three that drift. --- packages/core/src/editor/BlockNoteEditor.ts | 22 ++--------- .../managers/EventManager.browser.test.ts | 22 +++++++++++ .../core/src/editor/managers/EventManager.ts | 39 +++++++++++++------ packages/core/src/editor/managers/index.ts | 1 + 4 files changed, 54 insertions(+), 30 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 83624785fd..095c077aeb 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -49,6 +49,7 @@ import { import type { TextCursorPosition } from "./cursorPositionTypes.js"; import { BlockManager, + EditorFocusOptions, EventManager, ExportManager, ExtensionManager, @@ -814,15 +815,7 @@ export class BlockNoteEditor< ); }; - public isFocused(options?: { - /** - * When true, the editor's own UI (toolbars, menus, popovers — - * everything portalled into `editor.portalElement`) also counts as - * focused, answering "is the user still interacting with this editor?". - * The default reports content-area focus only. - */ - includeEditorUI?: boolean; - }) { + public isFocused(options?: EditorFocusOptions) { if (this.headless) { return false; } @@ -1404,16 +1397,7 @@ export class BlockNoteEditor< editor: BlockNoteEditor, context: { focused: boolean; event: FocusEvent }, ) => void, - options?: { - /** - * When true, the editor's own UI (toolbars, menus, popovers — - * everything portalled into `editor.portalElement`) counts as focused, - * and the callback fires only when that combined focus state actually - * changes, after focus movement has settled. The default reports raw - * content-area focus/blur events. - */ - includeEditorUI?: boolean; - }, + options?: EditorFocusOptions, ) { return this._eventManager.onFocusChange(callback, options); } diff --git a/packages/core/src/editor/managers/EventManager.browser.test.ts b/packages/core/src/editor/managers/EventManager.browser.test.ts index 684c19745c..9cc168b664 100644 --- a/packages/core/src/editor/managers/EventManager.browser.test.ts +++ b/packages/core/src/editor/managers/EventManager.browser.test.ts @@ -269,6 +269,28 @@ describe("Focus events", () => { expect(events.length).toBe(countWhileSubscribed); }); + it("survives an unsubscribe being called twice", async () => { + // The count backing the shared document listeners is reference-counted. + // A second call to the same unsubscribe used to drive it negative, so it + // never reached 1 again and the tracker silently stopped attaching for + // every later subscriber — with no error to notice. + const stale = editor.onFocusChange(() => {}, { includeEditorUI: true }); + stale(); + stale(); + + const events: boolean[] = []; + const unsubscribe = editor.onFocusChange( + (_editor, ctx) => events.push(ctx.focused), + { includeEditorUI: true }, + ); + + editor.focus(); + await settle(); + + expect(events).toEqual([true]); + unsubscribe(); + }); + it("supports several subscribers independently", async () => { const first: boolean[] = []; const second: boolean[] = []; diff --git a/packages/core/src/editor/managers/EventManager.ts b/packages/core/src/editor/managers/EventManager.ts index beed2a5dd2..bb24411a5f 100644 --- a/packages/core/src/editor/managers/EventManager.ts +++ b/packages/core/src/editor/managers/EventManager.ts @@ -19,6 +19,24 @@ export type Unsubscribe = () => void; /** * EventManager is a class which manages the events of the editor */ +/** + * Options shared by the focus APIs (`isFocused`, `onFocusChange`). + */ +export type EditorFocusOptions = { + /** + * When true, the editor's own UI - toolbars, menus and popovers, i.e. + * everything portalled into `editor.portalElement` - counts as focused, + * answering "is the user still interacting with this editor?" rather than + * "does the content area hold DOM focus?". + * + * Events then fire only when that combined state changes, and only once + * focus movement has settled, so a handoff from the content area into a + * popover's input reports no blur at all. The default reports raw + * content-area focus. + */ + includeEditorUI?: boolean; +}; + export class EventManager< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -238,17 +256,7 @@ export class EventManager< editor: BlockNoteEditor, ctx: { focused: boolean; event: FocusEvent }, ) => void, - options?: { - /** - * When true, the editor's own UI (toolbars, menus, popovers — - * everything portalled into `editor.portalElement`) counts as focused, - * and events fire only when that combined focus state actually changes, - * after focus movement has settled. Use this to know whether the user - * is still interacting with the editor; the default reports raw - * content-area focus/blur. - */ - includeEditorUI?: boolean; - }, + options?: EditorFocusOptions, ): Unsubscribe { const cb = ({ focused, @@ -266,7 +274,16 @@ export class EventManager< this.attachUIFocusTracker(); } this.on("onFocusChangeWithinUI", cb); + + // Unsubscribing twice must not double-decrement: the count would go + // negative and never reach 1 again, so the tracker would silently stop + // attaching for every later subscriber. + let unsubscribed = false; return () => { + if (unsubscribed) { + return; + } + unsubscribed = true; this.off("onFocusChangeWithinUI", cb); this.uiFocusSubscriberCount--; if (this.uiFocusSubscriberCount === 0) { diff --git a/packages/core/src/editor/managers/index.ts b/packages/core/src/editor/managers/index.ts index 2986354e1b..b49d599d53 100644 --- a/packages/core/src/editor/managers/index.ts +++ b/packages/core/src/editor/managers/index.ts @@ -1,5 +1,6 @@ export { BlockManager } from "./BlockManager.js"; export { EventManager } from "./EventManager.js"; +export type { EditorFocusOptions } from "./EventManager.js"; export { ExportManager } from "./ExportManager.js"; export { ExtensionManager } from "./ExtensionManager/index.js"; export { SelectionManager } from "./SelectionManager.js"; From fb265797ebcc2a28d3ba5f673a44a7aca086a958 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 31 Aug 2026 18:30:48 +0200 Subject: [PATCH 03/14] fix(react): keep passing the editor to useEditorSelectionChange callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The latest-ref wrapper called the callback with no arguments. The declared type never had any — so typed consumers are unaffected — but the subscription has always passed the editor, and an untyped caller using that argument would have silently received undefined. Forward it as before. --- packages/react/src/hooks/useEditorSelectionChange.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react/src/hooks/useEditorSelectionChange.ts b/packages/react/src/hooks/useEditorSelectionChange.ts index e443487452..5773584c3f 100644 --- a/packages/react/src/hooks/useEditorSelectionChange.ts +++ b/packages/react/src/hooks/useEditorSelectionChange.ts @@ -37,7 +37,11 @@ export function useEditorSelectionChange( ); } return editor.onSelectionChange( - () => callbackRef.current(), + // The declared callback type takes no arguments, but the subscription + // has always passed the editor — keep forwarding it so untyped callers + // that used it don't break. + (editorArg) => + (callbackRef.current as (e?: typeof editorArg) => void)(editorArg), includeSelectionChangedByRemote, ); }, [editor, includeSelectionChangedByRemote]); From c23024555cf7132d12cd15d94b83f53d1cddf00c Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 31 Aug 2026 19:34:25 +0200 Subject: [PATCH 04/14] test(react): colocate the focus-hook tests as a browser unit test Review feedback: these test a specific hook, not an end-to-end flow, so they belong next to the source as a .browser.test file (they still need real focus semantics, so a browser rather than jsdom). Ported off the mantine BlockNoteView onto BlockNoteViewRaw and plain react-dom, since the react package cannot depend on a skin. Also from review: useEditorFocus now uses the EditorFocusOptions type the core API exposes (newly exported publicly) instead of restating it. --- packages/core/src/index.ts | 1 + .../src/hooks/useEditorFocus.browser.test.tsx | 111 ++++++++++-------- packages/react/src/hooks/useEditorFocus.ts | 4 +- 3 files changed, 66 insertions(+), 50 deletions(-) rename tests/src/end-to-end/focus/useEditorFocus.test.tsx => packages/react/src/hooks/useEditorFocus.browser.test.tsx (65%) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b4f220e1e2..de5faa2fc7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,6 +11,7 @@ export * from "./api/nodeUtil.js"; export * from "./api/pmUtil.js"; export * from "./blocks/index.js"; export * from "./editor/BlockNoteEditor.js"; +export type { EditorFocusOptions } from "./editor/managers/EventManager.js"; export * from "./editor/BlockNoteExtension.js"; export * from "./editor/defaultColors.js"; export * from "./editor/selectionTypes.js"; diff --git a/tests/src/end-to-end/focus/useEditorFocus.test.tsx b/packages/react/src/hooks/useEditorFocus.browser.test.tsx similarity index 65% rename from tests/src/end-to-end/focus/useEditorFocus.test.tsx rename to packages/react/src/hooks/useEditorFocus.browser.test.tsx index 7ec2a416cd..0383dbaf05 100644 --- a/tests/src/end-to-end/focus/useEditorFocus.test.tsx +++ b/packages/react/src/hooks/useEditorFocus.browser.test.tsx @@ -1,29 +1,30 @@ -import { - useCreateBlockNote, - useEditorFocus, - useEditorFocusChange, -} from "@blocknote/react"; -import { BlockNoteView } from "@blocknote/mantine"; -import "@blocknote/mantine/style.css"; import { useRef, useState } from "react"; +import { createRoot, Root } from "react-dom/client"; import { afterEach, describe, expect, test, vi } from "vite-plus/test"; -import { render } from "vitest-browser-react"; -import { userEvent } from "../../utils/context.js"; -import { EDITOR_SELECTOR } from "../../utils/const.js"; -import { focusOnEditor, waitForSelector } from "../../utils/editor.js"; +import type { BlockNoteEditor } from "@blocknote/core"; +import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; +import { useCreateBlockNote } from "./useCreateBlockNote.js"; +import { useEditorFocus } from "./useEditorFocus.js"; +import { useEditorFocusChange } from "./useEditorFocusChange.js"; // `useEditorFocus` is the state counterpart to `useEditorFocusChange`. What // needs proving is that it reports *settled* focus and doesn't re-render on // every focus event in the page — the reasons it exists rather than each -// consumer wiring up useState + useEffect itself. +// consumer wiring up useState + useEffect itself. Focus semantics are real +// DOM behaviour, so this is a browser unit test rather than a jsdom one. + +let root: Root | undefined; +let host: HTMLElement | undefined; +let editor: BlockNoteEditor | undefined; function Probe(props: { includeEditorUI: boolean }) { - const editor = useCreateBlockNote(); + const probeEditor = useCreateBlockNote(); + editor = probeEditor; return ( - + - + ); } @@ -40,6 +41,18 @@ function Readout(props: { includeEditorUI: boolean }) { ); } +async function mount(element: React.ReactElement) { + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + root.render(element); + await vi.waitFor(() => { + if (!document.querySelector('[data-test="readout"]')) { + throw new Error("probe never rendered"); + } + }); +} + function readout() { return document.querySelector('[data-test="readout"]')!; } @@ -49,6 +62,11 @@ function focusedValue() { } afterEach(() => { + root?.unmount(); + host?.remove(); + root = undefined; + host = undefined; + editor = undefined; document.querySelectorAll(".zz-outside").forEach((el) => el.remove()); }); @@ -61,11 +79,10 @@ function addOutsideInput() { describe("useEditorFocus", () => { test("reports content focus and blur", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); + await mount(); expect(focusedValue()).toBe("false"); - await focusOnEditor(); + editor!.focus(); await vi.waitFor(() => expect(focusedValue()).toBe("true")); addOutsideInput().focus(); @@ -73,20 +90,13 @@ describe("useEditorFocus", () => { }); test("with includeEditorUI, stays focused across a handoff into the editor's UI", async () => { - await render(); - const editorElement = await waitForSelector(EDITOR_SELECTOR); - await focusOnEditor(); + await mount(); + editor!.focus(); await vi.waitFor(() => expect(focusedValue()).toBe("true")); - // A popover input, portalled outside the content area: the portal is the - // container child that isn't an ancestor of the content element. - const container = editorElement.closest(".bn-container")!; - const portal = Array.from(container.children).find( - (child) => !child.contains(editorElement), - ) as HTMLElement; - expect(portal).toBeDefined(); + // A popover input, portalled outside the content area. const popoverInput = document.createElement("input"); - portal.append(popoverInput); + editor!.portalElement.append(popoverInput); popoverInput.focus(); // Give the settle a chance to run, then confirm it never dropped. @@ -99,9 +109,8 @@ describe("useEditorFocus", () => { }); test("does not re-render for focus changes elsewhere on the page", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); - await focusOnEditor(); + await mount(); + editor!.focus(); await vi.waitFor(() => expect(focusedValue()).toBe("true")); const rendersBefore = Number(readout().dataset.renders); @@ -121,14 +130,14 @@ describe("useEditorFocus", () => { expect(Number(readout().dataset.renders)).toBe(rendersAfterBlur); }); - test("typing does not re-render the consumer", async () => { - await render(); - await waitForSelector(EDITOR_SELECTOR); - await focusOnEditor(); + test("document changes do not re-render the consumer", async () => { + await mount(); + editor!.focus(); await vi.waitFor(() => expect(focusedValue()).toBe("true")); const before = Number(readout().dataset.renders); - await userEvent.keyboard("some typing that changes the document"); + editor!.insertInlineContent("some content that changes the document"); + await new Promise((resolve) => setTimeout(resolve, 40)); expect(Number(readout().dataset.renders)).toBe(before); }); @@ -140,7 +149,7 @@ describe("useEditorFocus", () => { // re-runs and the editor is unsubscribed and resubscribed each time. That // matters more than it looks: with `includeEditorUI` the subscription is // reference-counted, so cycling it tears down and re-attaches the document -// focus listeners and resets the settled baseline. Measured: naive +// focus listeners and resets the settled baseline. Measured: a naive // implementation resubscribes once per render (6 after 5 re-renders), this // one stays at 1. describe("useEditorFocusChange", () => { @@ -148,20 +157,20 @@ describe("useEditorFocusChange", () => { let subscribes = 0; function CountingProbe() { - const editor = useCreateBlockNote(); + const probeEditor = useCreateBlockNote(); // Patch once, not on every render. useState(() => { - const original = editor.onFocusChange.bind(editor); - (editor as any).onFocusChange = (...args: any[]) => { + const original = probeEditor.onFocusChange.bind(probeEditor); + (probeEditor as any).onFocusChange = (...args: any[]) => { subscribes += 1; return (original as any)(...args); }; return null; }); return ( - + - + ); } @@ -177,14 +186,20 @@ describe("useEditorFocusChange", () => { ); } - await render(); - await waitForSelector(EDITOR_SELECTOR); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + root.render(); + const button = await vi.waitFor(() => { + const el = document.querySelector('[data-test="rerender"]'); + if (!el) { + throw new Error("probe never rendered"); + } + return el; + }); const afterMount = subscribes; expect(afterMount).toBe(1); - const button = document.querySelector( - '[data-test="rerender"]', - )!; for (let i = 0; i < 5; i++) { button.click(); await new Promise((resolve) => setTimeout(resolve, 20)); diff --git a/packages/react/src/hooks/useEditorFocus.ts b/packages/react/src/hooks/useEditorFocus.ts index 98cb68014c..981acdfb9f 100644 --- a/packages/react/src/hooks/useEditorFocus.ts +++ b/packages/react/src/hooks/useEditorFocus.ts @@ -1,4 +1,4 @@ -import type { BlockNoteEditor } from "@blocknote/core"; +import type { BlockNoteEditor, EditorFocusOptions } from "@blocknote/core"; import { useCallback, useRef, useSyncExternalStore } from "react"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; @@ -21,7 +21,7 @@ import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; * from the nearest `BlockNoteContext`. */ export function useEditorFocus( - options?: { includeEditorUI?: boolean }, + options?: EditorFocusOptions, editor?: BlockNoteEditor, ): boolean { const editorContext = useBlockNoteContext(); From dd80e8c5e59c2407f9578a6114957bfd052fa4db Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 31 Aug 2026 19:37:46 +0200 Subject: [PATCH 05/14] fix(react): update latest-ref callbacks at layout-effect timing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: the refs behind useEditorChange, useEditorSelectionChange and useEditorFocusChange were updated in a passive effect, so a layout effect firing an editor event right after commit could still reach the previous render's callback. The refs now update in an isomorphic layout effect — extracted from useEditorState, which already had the SSR-safe variant inline. --- packages/react/src/hooks/useEditorChange.ts | 6 +++++- packages/react/src/hooks/useEditorFocusChange.ts | 6 +++++- packages/react/src/hooks/useEditorSelectionChange.ts | 6 +++++- packages/react/src/hooks/useEditorState.ts | 6 ++---- packages/react/src/util/useIsomorphicLayoutEffect.ts | 12 ++++++++++++ 5 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 packages/react/src/util/useIsomorphicLayoutEffect.ts diff --git a/packages/react/src/hooks/useEditorChange.ts b/packages/react/src/hooks/useEditorChange.ts index 0e15c1c7df..f6866419ff 100644 --- a/packages/react/src/hooks/useEditorChange.ts +++ b/packages/react/src/hooks/useEditorChange.ts @@ -1,5 +1,6 @@ import type { BlockNoteEditor } from "@blocknote/core"; import { useEffect, useRef } from "react"; +import { useIsomorphicLayoutEffect } from "../util/useIsomorphicLayoutEffect.js"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; /** @@ -23,7 +24,10 @@ export function useEditorChange( // Latest-ref pattern: the subscription lives as long as the editor does, // while the callback stays current without resubscribing on re-renders. const callbackRef = useRef(callback); - useEffect(() => { + // Layout-effect timing, not passive: a layout effect elsewhere can + // trigger an editor event right after commit, and the subscription must + // not invoke the previous render's callback then. + useIsomorphicLayoutEffect(() => { callbackRef.current = callback; }); diff --git a/packages/react/src/hooks/useEditorFocusChange.ts b/packages/react/src/hooks/useEditorFocusChange.ts index b14b8f514d..2fc8ba9812 100644 --- a/packages/react/src/hooks/useEditorFocusChange.ts +++ b/packages/react/src/hooks/useEditorFocusChange.ts @@ -1,5 +1,6 @@ import type { BlockNoteEditor } from "@blocknote/core"; import { useEffect, useRef } from "react"; +import { useIsomorphicLayoutEffect } from "../util/useIsomorphicLayoutEffect.js"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; /** @@ -29,7 +30,10 @@ export function useEditorFocusChange( // Latest-ref pattern: the subscription lives as long as the editor does, // while the callback stays current without retriggering the effect. const callbackRef = useRef(callback); - useEffect(() => { + // Layout-effect timing, not passive: a layout effect elsewhere can + // trigger an editor event right after commit, and the subscription must + // not invoke the previous render's callback then. + useIsomorphicLayoutEffect(() => { callbackRef.current = callback; }); diff --git a/packages/react/src/hooks/useEditorSelectionChange.ts b/packages/react/src/hooks/useEditorSelectionChange.ts index 5773584c3f..9f5d7688f2 100644 --- a/packages/react/src/hooks/useEditorSelectionChange.ts +++ b/packages/react/src/hooks/useEditorSelectionChange.ts @@ -1,5 +1,6 @@ import type { BlockNoteEditor } from "@blocknote/core"; import { useEffect, useRef } from "react"; +import { useIsomorphicLayoutEffect } from "../util/useIsomorphicLayoutEffect.js"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; /** @@ -26,7 +27,10 @@ export function useEditorSelectionChange( // Latest-ref pattern: the subscription lives as long as the editor does, // while the callback stays current without resubscribing on re-renders. const callbackRef = useRef(callback); - useEffect(() => { + // Layout-effect timing, not passive: a layout effect elsewhere can + // trigger an editor event right after commit, and the subscription must + // not invoke the previous render's callback then. + useIsomorphicLayoutEffect(() => { callbackRef.current = callback; }); diff --git a/packages/react/src/hooks/useEditorState.ts b/packages/react/src/hooks/useEditorState.ts index eb6019a41b..59817ef8e7 100644 --- a/packages/react/src/hooks/useEditorState.ts +++ b/packages/react/src/hooks/useEditorState.ts @@ -1,11 +1,9 @@ import type { BlockNoteEditor } from "@blocknote/core"; import deepEqual from "fast-deep-equal/es6/react.js"; -import { useDebugValue, useEffect, useLayoutEffect, useState } from "react"; +import { useDebugValue, useState } from "react"; import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; - -const useIsomorphicLayoutEffect = - typeof window !== "undefined" ? useLayoutEffect : useEffect; +import { useIsomorphicLayoutEffect } from "../util/useIsomorphicLayoutEffect.js"; export type EditorStateSnapshot< TEditor extends BlockNoteEditor | null = BlockNoteEditor< diff --git a/packages/react/src/util/useIsomorphicLayoutEffect.ts b/packages/react/src/util/useIsomorphicLayoutEffect.ts new file mode 100644 index 0000000000..1714883824 --- /dev/null +++ b/packages/react/src/util/useIsomorphicLayoutEffect.ts @@ -0,0 +1,12 @@ +import { useEffect, useLayoutEffect } from "react"; + +/** + * `useLayoutEffect` in the browser, `useEffect` under SSR — where + * `useLayoutEffect` cannot run and React warns. + * + * Used for latest-ref updates: the ref must be current before any layout + * effect can trigger an editor event, or a subscription could still invoke + * the previous render's callback. + */ +export const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; From c4ebbfeabcaca19e8513c836cb5b387a3388b5ee Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 31 Aug 2026 19:58:28 +0200 Subject: [PATCH 06/14] fix(react): re-read the focus snapshot when its inputs change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: the cached settled value initialized once, so changing the editor or includeEditorUI rendered one frame computed for the old inputs before the new subscription re-synced. The cache is now keyed by both inputs — an input change re-reads live, which is exactly what the first render already did. Proven red-first: flipping the option while focus sits in the editor's UI rendered a stale false frame on all three engines. --- .../src/hooks/useEditorFocus.browser.test.tsx | 48 +++++++++++++++++++ packages/react/src/hooks/useEditorFocus.ts | 40 +++++++++++++--- 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/react/src/hooks/useEditorFocus.browser.test.tsx b/packages/react/src/hooks/useEditorFocus.browser.test.tsx index 0383dbaf05..f6c7a43214 100644 --- a/packages/react/src/hooks/useEditorFocus.browser.test.tsx +++ b/packages/react/src/hooks/useEditorFocus.browser.test.tsx @@ -32,11 +32,16 @@ function Readout(props: { includeEditorUI: boolean }) { const focused = useEditorFocus({ includeEditorUI: props.includeEditorUI }); const renders = useRef(0); renders.current += 1; + // Every value ever rendered, so a single wrong frame is caught even when a + // later render corrects it. + const history = useRef([]); + history.current.push(focused); return (
); } @@ -108,6 +113,49 @@ describe("useEditorFocus", () => { popoverInput.remove(); }); + test("changing includeEditorUI re-reads instead of rendering a stale frame", async () => { + // The cached snapshot is keyed by its inputs. Without that, flipping the + // option while focus sits in the editor's UI renders one frame computed + // for the *old* option (false) before the new subscription re-syncs — + // the history would read "...,false,true" after the flip. + function FlippableProbe() { + const probeEditor = useCreateBlockNote(); + editor = probeEditor; + const [includeEditorUI, setIncludeEditorUI] = useState(false); + return ( + + + + + ); + } + await mount(); + + // Focus the editor's UI: raw content focus reads false, UI focus true. + const popoverInput = document.createElement("input"); + editor!.portalElement.append(popoverInput); + popoverInput.focus(); + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(focusedValue()).toBe("false"); + const historyBefore = readout().dataset.history!; + + document.querySelector('[data-test="flip"]')!.click(); + await vi.waitFor(() => expect(focusedValue()).toBe("true")); + + const flipped = readout() + .dataset.history!.slice(historyBefore.length) + .split(",") + .filter(Boolean); + expect( + flipped, + "the first frame after the flip must already read the new option", + ).not.toContain("false"); + + popoverInput.remove(); + }); + test("does not re-render for focus changes elsewhere on the page", async () => { await mount(); editor!.focus(); diff --git a/packages/react/src/hooks/useEditorFocus.ts b/packages/react/src/hooks/useEditorFocus.ts index 981acdfb9f..98d289c7bd 100644 --- a/packages/react/src/hooks/useEditorFocus.ts +++ b/packages/react/src/hooks/useEditorFocus.ts @@ -43,9 +43,29 @@ export function useEditorFocus( // reading focus state during an arbitrary render can catch a mid-handoff // frame, where `document.activeElement` is transiently `` and the // editor looks unfocused for one frame. - const focused = useRef(undefined); - if (focused.current === undefined) { - focused.current = resolvedEditor.isFocused({ includeEditorUI }); + // + // The cache is keyed by its inputs: when the editor or the option changes, + // the settled value belongs to the *old* source, and rendering it would + // show one wrong frame before the new subscription attaches and re-syncs. + // Re-reading then is the same live read the first render does. + const focused = useRef< + | { + editor: BlockNoteEditor; + includeEditorUI: boolean; + value: boolean; + } + | undefined + >(undefined); + if ( + focused.current === undefined || + focused.current.editor !== resolvedEditor || + focused.current.includeEditorUI !== includeEditorUI + ) { + focused.current = { + editor: resolvedEditor, + includeEditorUI, + value: resolvedEditor.isFocused({ includeEditorUI }), + }; } const subscribe = useCallback( @@ -56,12 +76,20 @@ export function useEditorFocus( // registered before the consistency-check one), so refreshing the // cached value here is enough — but notifying explicitly keeps that // independent of React's internal effect ordering. - focused.current = resolvedEditor.isFocused({ includeEditorUI }); + focused.current = { + editor: resolvedEditor, + includeEditorUI, + value: resolvedEditor.isFocused({ includeEditorUI }), + }; onStoreChange(); return resolvedEditor.onFocusChange( (_editor, ctx) => { - focused.current = ctx.focused; + focused.current = { + editor: resolvedEditor, + includeEditorUI, + value: ctx.focused, + }; onStoreChange(); }, { includeEditorUI }, @@ -70,7 +98,7 @@ export function useEditorFocus( [resolvedEditor, includeEditorUI], ); - const getSnapshot = useCallback(() => focused.current!, []); + const getSnapshot = useCallback(() => focused.current!.value, []); return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } From dd5b4abc4d3110ba7dd15e0f48e9910deb72d057 Mon Sep 17 00:00:00 2001 From: yousefed Date: Mon, 31 Aug 2026 22:06:56 +0200 Subject: [PATCH 07/14] docs(react): state the latest-callback contract in the hook jsdoc The no-resubscribe behaviour was documented on the new focus hooks but only as an implementation comment on the two converted ones; it is part of their public contract, so their jsdoc now says it. --- packages/react/src/hooks/useEditorChange.ts | 3 ++- packages/react/src/hooks/useEditorSelectionChange.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react/src/hooks/useEditorChange.ts b/packages/react/src/hooks/useEditorChange.ts index f6866419ff..a17ecfd0b3 100644 --- a/packages/react/src/hooks/useEditorChange.ts +++ b/packages/react/src/hooks/useEditorChange.ts @@ -6,7 +6,8 @@ import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; /** * Subscribes to editor content changes. The callback is invoked whenever the * editor's document is modified, and the subscription is automatically cleaned - * up when the component unmounts. + * up when the component unmounts. The latest `callback` is always the one + * invoked — passing a new callback identity does not resubscribe. * * @param callback - Function called when the editor content changes. * @param editor - The BlockNote editor instance. If omitted, uses the editor diff --git a/packages/react/src/hooks/useEditorSelectionChange.ts b/packages/react/src/hooks/useEditorSelectionChange.ts index 9f5d7688f2..e2b90c9c2a 100644 --- a/packages/react/src/hooks/useEditorSelectionChange.ts +++ b/packages/react/src/hooks/useEditorSelectionChange.ts @@ -6,7 +6,8 @@ import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; /** * Subscribes to editor selection changes. The callback is invoked whenever the * user's cursor position or text selection changes, and the subscription is - * automatically cleaned up when the component unmounts. + * automatically cleaned up when the component unmounts. The latest `callback` is always the one + * invoked — passing a new callback identity does not resubscribe. * * @param callback - Function called when the selection changes. * @param editor - The BlockNote editor instance. If omitted, uses the editor From b78f14457dbdc8bc8881a49ba85f99c3adf60f03 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 1 Sep 2026 18:49:01 +0200 Subject: [PATCH 08/14] refactor(core): drop the focus tracker's reference counting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review: computeUIFocused duplicated isFocused({ includeEditorUI }) expression-for-expression — the manager now just calls it. The document-level tracker attaches on the first subscriber ever and detaches only when the editor is destroyed: no refcount, no double-unsubscribe guard (unsubscribe is a bare off(), naturally idempotent). A page that never subscribes still pays nothing; once attached, the no-op cost per focus event is too small to be worth tearing down. The settle timeout stays: document.activeElement passes through mid-handoff and some UI libraries restore focus asynchronously (the ariakit and shadcn link popovers both do) — a microtask is verifiably too early, and a frame doesn't run in background tabs. --- .../core/src/editor/managers/EventManager.ts | 51 +++++-------------- 1 file changed, 14 insertions(+), 37 deletions(-) diff --git a/packages/core/src/editor/managers/EventManager.ts b/packages/core/src/editor/managers/EventManager.ts index bb24411a5f..f6a3ad016f 100644 --- a/packages/core/src/editor/managers/EventManager.ts +++ b/packages/core/src/editor/managers/EventManager.ts @@ -96,41 +96,32 @@ export class EventManager< this.emit("onUnmount", { editor }); }); editor._tiptapEditor.on("destroy", () => { - // Subscribers normally detach the tracker when the last one - // unsubscribes; this covers subscribers that outlive the editor. + // The one place the document-level focus tracker detaches. this.detachUIFocusTracker?.(); }); }); } /** - * Settled focus-within-UI tracking. Document-level listeners (attached only - * while someone subscribes with `includeEditorUI`) cover the case tiptap - * events can't: focus moving from the editor's own UI (which lives in - * `editor.portalElement`, outside the content area) to somewhere else - * entirely. Blur-side changes are re-checked a frame later because - * `document.activeElement` transiently becomes `` during focus - * handoffs (and `relatedTarget` is unreliable on mobile). + * Settled focus-within-UI tracking. Document-level listeners (attached on + * the first `includeEditorUI` subscriber, detached when the editor is + * destroyed) cover the case tiptap events can't: focus moving from the + * editor's own UI (which lives in `editor.portalElement`, outside the + * content area) to somewhere else entirely. Blur-side changes are + * re-checked a frame later because `document.activeElement` transiently + * becomes `` during focus handoffs (and `relatedTarget` is + * unreliable on mobile). No reference counting: a page that never + * subscribes pays nothing, and once attached the no-op cost per focus + * event is too small to be worth tearing down. */ private uiFocused = false; - private uiFocusSubscriberCount = 0; - private uiFocusSettleHandle: ReturnType | undefined; private detachUIFocusTracker: (() => void) | undefined; - private computeUIFocused(): boolean { - const active = - typeof document !== "undefined" ? document.activeElement : null; - return ( - this.editor.isFocused() || - (!!active && this.editor.isWithinEditor(active)) - ); - } - private settleUIFocus(event: FocusEvent) { - const focused = this.computeUIFocused(); + const focused = this.editor.isFocused({ includeEditorUI: true }); if (focused !== this.uiFocused) { this.uiFocused = focused; this.emit("onFocusChangeWithinUI", { @@ -145,7 +136,7 @@ export class EventManager< if (typeof document === "undefined") { return; } - this.uiFocused = this.computeUIFocused(); + this.uiFocused = this.editor.isFocused({ includeEditorUI: true }); // On focusin the new element already holds focus, so the state can be // read immediately. const onFocusIn = (event: FocusEvent) => this.settleUIFocus(event); @@ -269,26 +260,12 @@ export class EventManager< }; if (options?.includeEditorUI) { - this.uiFocusSubscriberCount++; - if (this.uiFocusSubscriberCount === 1) { + if (!this.detachUIFocusTracker) { this.attachUIFocusTracker(); } this.on("onFocusChangeWithinUI", cb); - - // Unsubscribing twice must not double-decrement: the count would go - // negative and never reach 1 again, so the tracker would silently stop - // attaching for every later subscriber. - let unsubscribed = false; return () => { - if (unsubscribed) { - return; - } - unsubscribed = true; this.off("onFocusChangeWithinUI", cb); - this.uiFocusSubscriberCount--; - if (this.uiFocusSubscriberCount === 0) { - this.detachUIFocusTracker?.(); - } }; } From 3a4b8bc5f41987e1d18c8875860ab97ae9b81a13 Mon Sep 17 00:00:00 2001 From: yousefed Date: Tue, 1 Sep 2026 18:49:05 +0200 Subject: [PATCH 09/14] refactor(react): drop useEditorFocusChange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review: it had no consumers — focus-as-state is useEditorFocus, and a side effect on focus changes subscribes directly with editor.onFocusChange (whose latest-ref concerns belong to the caller that has them). Smaller API, one less pair to document. --- .../src/hooks/useEditorFocus.browser.test.tsx | 70 +------------------ packages/react/src/hooks/useEditorFocus.ts | 5 +- .../react/src/hooks/useEditorFocusChange.ts | 54 -------------- packages/react/src/index.ts | 1 - 4 files changed, 3 insertions(+), 127 deletions(-) delete mode 100644 packages/react/src/hooks/useEditorFocusChange.ts diff --git a/packages/react/src/hooks/useEditorFocus.browser.test.tsx b/packages/react/src/hooks/useEditorFocus.browser.test.tsx index f6c7a43214..7531e5fb70 100644 --- a/packages/react/src/hooks/useEditorFocus.browser.test.tsx +++ b/packages/react/src/hooks/useEditorFocus.browser.test.tsx @@ -6,9 +6,8 @@ import type { BlockNoteEditor } from "@blocknote/core"; import { BlockNoteViewRaw } from "../editor/BlockNoteView.js"; import { useCreateBlockNote } from "./useCreateBlockNote.js"; import { useEditorFocus } from "./useEditorFocus.js"; -import { useEditorFocusChange } from "./useEditorFocusChange.js"; -// `useEditorFocus` is the state counterpart to `useEditorFocusChange`. What +// `useEditorFocus` returns focus as state. What // needs proving is that it reports *settled* focus and doesn't re-render on // every focus event in the page — the reasons it exists rather than each // consumer wiring up useState + useEffect itself. Focus semantics are real @@ -190,70 +189,3 @@ describe("useEditorFocus", () => { expect(Number(readout().dataset.renders)).toBe(before); }); }); - -// `useEditorFocusChange` (the callback counterpart) keeps its subscription -// alive across re-renders via the latest-ref pattern. Without it, an inline -// callback — the common case — has a new identity every render, so the effect -// re-runs and the editor is unsubscribed and resubscribed each time. That -// matters more than it looks: with `includeEditorUI` the subscription is -// reference-counted, so cycling it tears down and re-attaches the document -// focus listeners and resets the settled baseline. Measured: a naive -// implementation resubscribes once per render (6 after 5 re-renders), this -// one stays at 1. -describe("useEditorFocusChange", () => { - test("does not resubscribe when the callback identity changes", async () => { - let subscribes = 0; - - function CountingProbe() { - const probeEditor = useCreateBlockNote(); - // Patch once, not on every render. - useState(() => { - const original = probeEditor.onFocusChange.bind(probeEditor); - (probeEditor as any).onFocusChange = (...args: any[]) => { - subscribes += 1; - return (original as any)(...args); - }; - return null; - }); - return ( - - - - ); - } - - function Rerenderer() { - const [n, setN] = useState(0); - useEditorFocusChange(() => { - /* inline: new identity every render */ - }); - return ( - - ); - } - - host = document.createElement("div"); - document.body.append(host); - root = createRoot(host); - root.render(); - const button = await vi.waitFor(() => { - const el = document.querySelector('[data-test="rerender"]'); - if (!el) { - throw new Error("probe never rendered"); - } - return el; - }); - const afterMount = subscribes; - expect(afterMount).toBe(1); - - for (let i = 0; i < 5; i++) { - button.click(); - await new Promise((resolve) => setTimeout(resolve, 20)); - } - - expect(Number(button.textContent)).toBe(5); - expect(subscribes).toBe(afterMount); - }); -}); diff --git a/packages/react/src/hooks/useEditorFocus.ts b/packages/react/src/hooks/useEditorFocus.ts index 98d289c7bd..0daa529609 100644 --- a/packages/react/src/hooks/useEditorFocus.ts +++ b/packages/react/src/hooks/useEditorFocus.ts @@ -6,9 +6,8 @@ import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; * Whether the editor is focused, as state — re-rendering the component when * that changes. * - * Use this when focus decides what to *render*. - * {@link useEditorFocusChange} is the counterpart for running a side effect on - * focus changes (the same split as `useEditorState` vs `useEditorChange`). + * Use this when focus decides what to *render*; for running a side effect on + * focus changes, subscribe directly with `editor.onFocusChange`. * * By default this reports raw content-area focus, so `false` may just mean * focus moved into the editor's own UI — a toolbar popover's input, say. Pass diff --git a/packages/react/src/hooks/useEditorFocusChange.ts b/packages/react/src/hooks/useEditorFocusChange.ts deleted file mode 100644 index 2fc8ba9812..0000000000 --- a/packages/react/src/hooks/useEditorFocusChange.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { BlockNoteEditor } from "@blocknote/core"; -import { useEffect, useRef } from "react"; -import { useIsomorphicLayoutEffect } from "../util/useIsomorphicLayoutEffect.js"; -import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; - -/** - * Subscribes to the editor gaining or losing focus. The subscription is - * automatically cleaned up when the component unmounts, and the latest - * `callback` is always invoked without resubscribing on re-renders. - * - * By default this reports raw content-area focus/blur; `focused: false` may - * mean focus moved into the editor's own UI (e.g. a toolbar - * popover's input). Pass `includeEditorUI: true` to instead observe "is the - * user still interacting with this editor" — floating UI counts as focused, - * and the callback fires only on settled changes of that combined state. - * - * @param callback - Function called with the editor and `{ focused, event }`. - * @param editor - The BlockNote editor instance. If omitted, uses the editor - * from the nearest `BlockNoteContext`. - * @param options - See `editor.onFocusChange`. - */ -export function useEditorFocusChange( - callback: Parameters["onFocusChange"]>[0], - editor?: BlockNoteEditor, - options?: Parameters["onFocusChange"]>[1], -) { - const editorContext = useBlockNoteContext(); - const resolvedEditor = editor ?? editorContext?.editor; - - // Latest-ref pattern: the subscription lives as long as the editor does, - // while the callback stays current without retriggering the effect. - const callbackRef = useRef(callback); - // Layout-effect timing, not passive: a layout effect elsewhere can - // trigger an editor event right after commit, and the subscription must - // not invoke the previous render's callback then. - useIsomorphicLayoutEffect(() => { - callbackRef.current = callback; - }); - - const includeEditorUI = options?.includeEditorUI ?? false; - - useEffect(() => { - if (!resolvedEditor) { - throw new Error( - "'editor' is required, either from BlockNoteContext or as a function argument", - ); - } - - return resolvedEditor.onFocusChange( - (editorArg, ctx) => callbackRef.current(editorArg, ctx), - { includeEditorUI }, - ); - }, [resolvedEditor, includeEditorUI]); -} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e5ba94c223..ce56eac806 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -135,7 +135,6 @@ export * from "./hooks/useBlockNoteEditor.js"; export * from "./hooks/useCreateBlockNote.js"; export * from "./hooks/useEditorChange.js"; export * from "./hooks/useEditorFocus.js"; -export * from "./hooks/useEditorFocusChange.js"; export * from "./hooks/useEditorDomElement.js"; export * from "./hooks/useEditorSelectionBoundingBox.js"; export * from "./hooks/useEditorSelectionChange.js"; From c9c09bd723907000372523a5ba50414df291b239 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 2 Sep 2026 13:28:26 +0200 Subject: [PATCH 10/14] refactor: cleanup EventManager --- .../core/src/editor/managers/EventManager.ts | 134 ++++++------------ 1 file changed, 44 insertions(+), 90 deletions(-) diff --git a/packages/core/src/editor/managers/EventManager.ts b/packages/core/src/editor/managers/EventManager.ts index f6a3ad016f..8dfa8f8717 100644 --- a/packages/core/src/editor/managers/EventManager.ts +++ b/packages/core/src/editor/managers/EventManager.ts @@ -16,9 +16,6 @@ import { */ export type Unsubscribe = () => void; -/** - * EventManager is a class which manages the events of the editor - */ /** * Options shared by the focus APIs (`isFocused`, `onFocusChange`). */ @@ -37,6 +34,9 @@ export type EditorFocusOptions = { includeEditorUI?: boolean; }; +/** + * EventManager is a class which manages the events of the editor + */ export class EventManager< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -95,9 +95,15 @@ export class EventManager< editor._tiptapEditor.on("unmount", () => { this.emit("onUnmount", { editor }); }); - editor._tiptapEditor.on("destroy", () => { - // The one place the document-level focus tracker detaches. - this.detachUIFocusTracker?.(); + + let unsubscribeUIFocusTracker: Unsubscribe | undefined; + this.onMount(() => { + unsubscribeUIFocusTracker = this.attachUIFocusTracker(); + }); + this.onUnmount(() => { + if (unsubscribeUIFocusTracker) { + unsubscribeUIFocusTracker(); + } }); }); } @@ -114,32 +120,27 @@ export class EventManager< * subscribes pays nothing, and once attached the no-op cost per focus * event is too small to be worth tearing down. */ - private uiFocused = false; - - private uiFocusSettleHandle: ReturnType | undefined; - - private detachUIFocusTracker: (() => void) | undefined; - - private settleUIFocus(event: FocusEvent) { - const focused = this.editor.isFocused({ includeEditorUI: true }); - if (focused !== this.uiFocused) { - this.uiFocused = focused; - this.emit("onFocusChangeWithinUI", { - editor: this.editor, - focused, - event, - }); - } - } - - private attachUIFocusTracker() { + private attachUIFocusTracker(): Unsubscribe { if (typeof document === "undefined") { - return; + return () => {}; } - this.uiFocused = this.editor.isFocused({ includeEditorUI: true }); + let wasLastFocused = this.editor.isFocused({ includeEditorUI: true }); + let settleUiFocusedTimeout: ReturnType | undefined; + + const settleUIFocus = (event: FocusEvent) => { + const focused = this.editor.isFocused({ includeEditorUI: true }); + if (focused !== wasLastFocused) { + wasLastFocused = focused; + this.emit("onFocusChangeWithinUI", { + editor: this.editor, + focused, + event, + }); + } + }; // On focusin the new element already holds focus, so the state can be // read immediately. - const onFocusIn = (event: FocusEvent) => this.settleUIFocus(event); + const onFocusIn = (event: FocusEvent) => settleUIFocus(event); // On focusout it can't: `document.activeElement` is still the outgoing // element (and passes through `` mid-handoff), and some UI @@ -148,17 +149,16 @@ export class EventManager< // to finish. A microtask is too early (verified: those popover tests go // red), and a frame would work but doesn't run in a background tab. const onFocusOut = (event: FocusEvent) => { - clearTimeout(this.uiFocusSettleHandle); - this.uiFocusSettleHandle = setTimeout(() => this.settleUIFocus(event)); + clearTimeout(settleUiFocusedTimeout); + settleUiFocusedTimeout = setTimeout(() => settleUIFocus(event)); }; document.addEventListener("focusin", onFocusIn, true); document.addEventListener("focusout", onFocusOut, true); - this.detachUIFocusTracker = () => { - clearTimeout(this.uiFocusSettleHandle); + return () => { + clearTimeout(settleUiFocusedTimeout); document.removeEventListener("focusin", onFocusIn, true); document.removeEventListener("focusout", onFocusOut, true); - this.detachUIFocusTracker = undefined; }; } @@ -178,13 +178,7 @@ export class EventManager< */ includeUpdatesFromRemote = true, ): Unsubscribe { - const cb = ({ - transaction, - appendedTransactions, - }: { - transaction: Transaction; - appendedTransactions: Transaction[]; - }) => { + return this.on("onChange", ({ transaction, appendedTransactions }) => { if (!includeUpdatesFromRemote && isRemoteTransaction(transaction)) { // don't trigger the callback if the changes are caused by a remote user return; @@ -197,12 +191,7 @@ export class EventManager< ); }, }); - }; - this.on("onChange", cb); - - return () => { - this.off("onChange", cb); - }; + }); } /** @@ -215,22 +204,16 @@ export class EventManager< */ includeSelectionChangedByRemote = false, ): Unsubscribe { - const cb = (e: { transaction: Transaction }) => { + return this.on("onSelectionChange", ({ transaction }) => { if ( !includeSelectionChangedByRemote && - isRemoteTransaction(e.transaction) + isRemoteTransaction(transaction) ) { // don't trigger the callback if the selection changed because of a remote user return; } callback(this.editor); - }; - - this.on("onSelectionChange", cb); - - return () => { - this.off("onSelectionChange", cb); - }; + }); } /** @@ -249,31 +232,10 @@ export class EventManager< ) => void, options?: EditorFocusOptions, ): Unsubscribe { - const cb = ({ - focused, - event, - }: { - focused: boolean; - event: FocusEvent; - }) => { - callback(this.editor, { focused, event }); - }; - - if (options?.includeEditorUI) { - if (!this.detachUIFocusTracker) { - this.attachUIFocusTracker(); - } - this.on("onFocusChangeWithinUI", cb); - return () => { - this.off("onFocusChangeWithinUI", cb); - }; - } - - this.on("onFocusChange", cb); - - return () => { - this.off("onFocusChange", cb); - }; + return this.on( + options?.includeEditorUI ? "onFocusChangeWithinUI" : "onFocusChange", + ({ focused, event }) => callback(this.editor, { focused, event }), + ); } /** @@ -282,11 +244,7 @@ export class EventManager< public onMount( callback: (ctx: { editor: BlockNoteEditor }) => void, ): Unsubscribe { - this.on("onMount", callback); - - return () => { - this.off("onMount", callback); - }; + return this.on("onMount", callback); } /** @@ -295,11 +253,7 @@ export class EventManager< public onUnmount( callback: (ctx: { editor: BlockNoteEditor }) => void, ): Unsubscribe { - this.on("onUnmount", callback); - - return () => { - this.off("onUnmount", callback); - }; + return this.on("onUnmount", callback); } } From c17894509af1ed006ffd5205ba7979cb35b02a8d Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 2 Sep 2026 13:58:35 +0200 Subject: [PATCH 11/14] feat: add support for the `focus` event type to useEditorState --- packages/react/src/hooks/useEditorState.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react/src/hooks/useEditorState.ts b/packages/react/src/hooks/useEditorState.ts index 59817ef8e7..c6608ab552 100644 --- a/packages/react/src/hooks/useEditorState.ts +++ b/packages/react/src/hooks/useEditorState.ts @@ -44,7 +44,7 @@ export type UseEditorStateOptions< * The event to subscribe to. * @default "all" */ - on?: "all" | "mount" | "selection" | "change"; + on?: "all" | "mount" | "selection" | "change" | "focus"; }; /** @@ -115,7 +115,7 @@ class EditorStateManager< */ watch( nextEditor: BlockNoteEditor | null, - on: "all" | "mount" | "selection" | "change", + on: "all" | "mount" | "selection" | "change" | "focus", ): undefined | (() => void) { this.editor = nextEditor as TEditor; @@ -130,6 +130,10 @@ class EditorStateManager< this.subscribers.forEach((callback) => callback()); }; + if (on === "focus") { + return this.editor.onFocusChange(fn, { includeEditorUI: true }); + } + const currentTiptapEditor = this.editor._tiptapEditor; const EVENT_TYPES = { From a0a19eefce5fccaa80e5b93a42b039513effcda1 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 2 Sep 2026 13:59:04 +0200 Subject: [PATCH 12/14] refactor: base useEditorFocus on useEditorState instead --- packages/react/src/hooks/useEditorFocus.ts | 72 ++-------------------- 1 file changed, 6 insertions(+), 66 deletions(-) diff --git a/packages/react/src/hooks/useEditorFocus.ts b/packages/react/src/hooks/useEditorFocus.ts index 0daa529609..de4390a427 100644 --- a/packages/react/src/hooks/useEditorFocus.ts +++ b/packages/react/src/hooks/useEditorFocus.ts @@ -1,6 +1,6 @@ import type { BlockNoteEditor, EditorFocusOptions } from "@blocknote/core"; -import { useCallback, useRef, useSyncExternalStore } from "react"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; +import { useEditorState } from "./useEditorState.js"; /** * Whether the editor is focused, as state — re-rendering the component when @@ -35,69 +35,9 @@ export function useEditorFocus( ); } - const includeEditorUI = options?.includeEditorUI ?? false; - - // The snapshot is the last *settled* value, never a live read. With - // `includeEditorUI` the editor's own events are already settled, whereas - // reading focus state during an arbitrary render can catch a mid-handoff - // frame, where `document.activeElement` is transiently `` and the - // editor looks unfocused for one frame. - // - // The cache is keyed by its inputs: when the editor or the option changes, - // the settled value belongs to the *old* source, and rendering it would - // show one wrong frame before the new subscription attaches and re-syncs. - // Re-reading then is the same live read the first render does. - const focused = useRef< - | { - editor: BlockNoteEditor; - includeEditorUI: boolean; - value: boolean; - } - | undefined - >(undefined); - if ( - focused.current === undefined || - focused.current.editor !== resolvedEditor || - focused.current.includeEditorUI !== includeEditorUI - ) { - focused.current = { - editor: resolvedEditor, - includeEditorUI, - value: resolvedEditor.isFocused({ includeEditorUI }), - }; - } - - const subscribe = useCallback( - (onStoreChange: () => void) => { - // Re-sync: focus can have changed between the render that produced the - // current snapshot and this subscription attaching. React does compare - // the snapshot again after subscribing (its subscribe effect is - // registered before the consistency-check one), so refreshing the - // cached value here is enough — but notifying explicitly keeps that - // independent of React's internal effect ordering. - focused.current = { - editor: resolvedEditor, - includeEditorUI, - value: resolvedEditor.isFocused({ includeEditorUI }), - }; - onStoreChange(); - - return resolvedEditor.onFocusChange( - (_editor, ctx) => { - focused.current = { - editor: resolvedEditor, - includeEditorUI, - value: ctx.focused, - }; - onStoreChange(); - }, - { includeEditorUI }, - ); - }, - [resolvedEditor, includeEditorUI], - ); - - const getSnapshot = useCallback(() => focused.current!.value, []); - - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + return useEditorState({ + editor, + selector: ({ editor }) => editor.isFocused(options), + on: "focus", + }); } From e1500d1af138be74a033867d308f0725f54096fa Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 2 Sep 2026 16:23:25 +0200 Subject: [PATCH 13/14] fix(react): keep per-option focus semantics under useEditorState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two regressions from basing useEditorFocus on useEditorState, both red-first proven and now pinned by browser tests: - Raw-mode staleness: on: "focus" subscribed with a hardcoded includeEditorUI: true, while the selector read the caller's options. Focus moving from the content area into the editor's own UI changes raw focus but not the combined state — no event, so the raw hook reported true forever. The two are distinct streams (raw fires per focus/blur; combined fires only settled), so "focus" and "focusWithinUI" are now separate on-channels and useEditorFocus picks by its option. - Settled-read erosion: an inline selector re-creates useSyncExternalStoreWithSelector's memo every render, re-running the selector as a live isFocused() read — and a live read during a focus handoff sees the transient frame and renders a one-frame false (proven by forcing a re-render mid-handoff). The selectors are module-level so the memo holds and they run only at event time, which the channels guarantee is settled. Also aligns the focus-tracker comment with the mount/unmount lifecycle it now has. --- .../core/src/editor/managers/EventManager.ts | 16 ++--- .../src/hooks/useEditorFocus.browser.test.tsx | 68 +++++++++++++++++++ packages/react/src/hooks/useEditorFocus.ts | 23 ++++++- packages/react/src/hooks/useEditorState.ts | 17 +++-- 4 files changed, 109 insertions(+), 15 deletions(-) diff --git a/packages/core/src/editor/managers/EventManager.ts b/packages/core/src/editor/managers/EventManager.ts index 8dfa8f8717..dccf1b57f7 100644 --- a/packages/core/src/editor/managers/EventManager.ts +++ b/packages/core/src/editor/managers/EventManager.ts @@ -110,15 +110,13 @@ export class EventManager< /** * Settled focus-within-UI tracking. Document-level listeners (attached on - * the first `includeEditorUI` subscriber, detached when the editor is - * destroyed) cover the case tiptap events can't: focus moving from the - * editor's own UI (which lives in `editor.portalElement`, outside the - * content area) to somewhere else entirely. Blur-side changes are - * re-checked a frame later because `document.activeElement` transiently - * becomes `` during focus handoffs (and `relatedTarget` is - * unreliable on mobile). No reference counting: a page that never - * subscribes pays nothing, and once attached the no-op cost per focus - * event is too small to be worth tearing down. + * editor mount, detached on unmount — a no-op per focus event is too + * cheap to be worth gating on subscribers) cover the case tiptap events + * can't: focus moving from the editor's own UI (which lives in + * `editor.portalElement`, outside the content area) to somewhere else + * entirely. Blur-side changes are re-checked a frame later because + * `document.activeElement` transiently becomes `` during focus + * handoffs (and `relatedTarget` is unreliable on mobile). */ private attachUIFocusTracker(): Unsubscribe { if (typeof document === "undefined") { diff --git a/packages/react/src/hooks/useEditorFocus.browser.test.tsx b/packages/react/src/hooks/useEditorFocus.browser.test.tsx index 7531e5fb70..b83747e141 100644 --- a/packages/react/src/hooks/useEditorFocus.browser.test.tsx +++ b/packages/react/src/hooks/useEditorFocus.browser.test.tsx @@ -1,4 +1,5 @@ import { useRef, useState } from "react"; +import { flushSync } from "react-dom"; import { createRoot, Root } from "react-dom/client"; import { afterEach, describe, expect, test, vi } from "vite-plus/test"; @@ -189,3 +190,70 @@ describe("useEditorFocus", () => { expect(Number(readout().dataset.renders)).toBe(before); }); }); + +// Regression guards for the useEditorState-based implementation. Both went +// red on the refactor that introduced it and pin its two contracts: +// per-option event channels, and settled-only reads. +describe("useEditorFocus regressions", () => { + test("raw focus follows focus moving into the editor's own UI", async () => { + // The raw ("focus") channel must fire on every content focus/blur — a + // subscription to the settled combined channel misses this transition + // entirely (combined state stays true), leaving the raw hook stale. + await mount(); + editor!.focus(); + await vi.waitFor(() => expect(focusedValue()).toBe("true")); + + const uiInput = document.createElement("input"); + document.querySelector(".bn-container")!.append(uiInput); + uiInput.focus(); + expect(editor!.isFocused()).toBe(false); + await vi.waitFor(() => expect(focusedValue()).toBe("false")); + }); + + test("a re-render during a focus handoff never shows a transient false", async () => { + let forceRender: () => void; + function HandoffReadout() { + const focused = useEditorFocus({ includeEditorUI: true }); + const [, bump] = useState(0); + forceRender = () => flushSync(() => bump((n) => n + 1)); + const history = useRef([]); + history.current.push(focused); + return ( +
+ ); + } + function HandoffProbe() { + const probeEditor = useCreateBlockNote(); + editor = probeEditor; + return ( + + + + ); + } + await mount(); + const history = () => readout().dataset.history!; + + editor!.focus(); + await vi.waitFor(() => { + if (!history().endsWith("true")) { + throw new Error("editor focus not settled"); + } + }); + + // The handoff: blur (activeElement passes through ), an unrelated + // re-render mid-window, then the async-style focus restore the ariakit + // and shadcn popovers perform. A live isFocused() read during that + // render would see the transient and paint a one-frame false — the + // stable module-level selectors only run at (settled) event time. + (document.activeElement as HTMLElement).blur(); + forceRender!(); + editor!.focus(); + await new Promise((resolve) => setTimeout(resolve, 50)); + forceRender!(); + + const entries = history().split(","); + const afterSettled = entries.slice(entries.indexOf("true")); + expect(afterSettled).not.toContain("false"); + }); +}); diff --git a/packages/react/src/hooks/useEditorFocus.ts b/packages/react/src/hooks/useEditorFocus.ts index de4390a427..09b7e80604 100644 --- a/packages/react/src/hooks/useEditorFocus.ts +++ b/packages/react/src/hooks/useEditorFocus.ts @@ -37,7 +37,26 @@ export function useEditorFocus( return useEditorState({ editor, - selector: ({ editor }) => editor.isFocused(options), - on: "focus", + selector: options?.includeEditorUI ? selectUIFocus : selectRawFocus, + on: options?.includeEditorUI ? "focusWithinUI" : "focus", }); } + +// Module-level so their identity is stable: an inline selector re-creates +// useSyncExternalStoreWithSelector's memo every render, which re-runs the +// selector as a live `isFocused()` read on every consumer render — and a +// live read during a focus handoff sees the transient `` frame, +// rendering a one-frame `false`. With stable identity the selector runs +// only when the subscribed event bumps the snapshot, and those moments are +// settled by construction. +function selectRawFocus(snapshot: { + editor: BlockNoteEditor | null; +}): boolean { + return snapshot.editor?.isFocused() ?? false; +} + +function selectUIFocus(snapshot: { + editor: BlockNoteEditor | null; +}): boolean { + return snapshot.editor?.isFocused({ includeEditorUI: true }) ?? false; +} diff --git a/packages/react/src/hooks/useEditorState.ts b/packages/react/src/hooks/useEditorState.ts index c6608ab552..9fd5085362 100644 --- a/packages/react/src/hooks/useEditorState.ts +++ b/packages/react/src/hooks/useEditorState.ts @@ -44,7 +44,7 @@ export type UseEditorStateOptions< * The event to subscribe to. * @default "all" */ - on?: "all" | "mount" | "selection" | "change" | "focus"; + on?: "all" | "mount" | "selection" | "change" | "focus" | "focusWithinUI"; }; /** @@ -115,7 +115,7 @@ class EditorStateManager< */ watch( nextEditor: BlockNoteEditor | null, - on: "all" | "mount" | "selection" | "change" | "focus", + on: "all" | "mount" | "selection" | "change" | "focus" | "focusWithinUI", ): undefined | (() => void) { this.editor = nextEditor as TEditor; @@ -130,8 +130,17 @@ class EditorStateManager< this.subscribers.forEach((callback) => callback()); }; - if (on === "focus") { - return this.editor.onFocusChange(fn, { includeEditorUI: true }); + // Two distinct streams, deliberately: "focus" is raw content-area + // focus (fires on every focus/blur), "focusWithinUI" is the settled + // combined state (fires only once focus movement settles, and not at + // all when focus merely moves between the content area and the + // editor's own UI). Subscribing one to the other either misses + // transitions (raw via settled) or breaks settledness (settled via + // raw). + if (on === "focus" || on === "focusWithinUI") { + return this.editor.onFocusChange(fn, { + includeEditorUI: on === "focusWithinUI", + }); } const currentTiptapEditor = this.editor._tiptapEditor; From 1506e07ef1980b2ec0059cfb75fc6e958d425037 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 2 Sep 2026 17:07:40 +0200 Subject: [PATCH 14/14] refactor: put back some changes --- packages/react/src/hooks/useEditorFocus.ts | 90 +++++++++++++++------- packages/react/src/hooks/useEditorState.ts | 17 +--- 2 files changed, 66 insertions(+), 41 deletions(-) diff --git a/packages/react/src/hooks/useEditorFocus.ts b/packages/react/src/hooks/useEditorFocus.ts index 09b7e80604..e6f0e1178e 100644 --- a/packages/react/src/hooks/useEditorFocus.ts +++ b/packages/react/src/hooks/useEditorFocus.ts @@ -1,6 +1,6 @@ import type { BlockNoteEditor, EditorFocusOptions } from "@blocknote/core"; +import { useCallback, useRef, useSyncExternalStore } from "react"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; -import { useEditorState } from "./useEditorState.js"; /** * Whether the editor is focused, as state — re-rendering the component when @@ -27,36 +27,74 @@ export function useEditorFocus( const resolvedEditor = editor ?? editorContext?.editor; if (!resolvedEditor) { - // Thrown during render rather than from an effect: the return value is - // used to render, so a deferred throw would first paint a frame with a - // meaningless value. throw new Error( "'editor' is required, either from BlockNoteContext or as a function argument", ); } - return useEditorState({ - editor, - selector: options?.includeEditorUI ? selectUIFocus : selectRawFocus, - on: options?.includeEditorUI ? "focusWithinUI" : "focus", - }); -} + const includeEditorUI = options?.includeEditorUI ?? false; -// Module-level so their identity is stable: an inline selector re-creates -// useSyncExternalStoreWithSelector's memo every render, which re-runs the -// selector as a live `isFocused()` read on every consumer render — and a -// live read during a focus handoff sees the transient `` frame, -// rendering a one-frame `false`. With stable identity the selector runs -// only when the subscribed event bumps the snapshot, and those moments are -// settled by construction. -function selectRawFocus(snapshot: { - editor: BlockNoteEditor | null; -}): boolean { - return snapshot.editor?.isFocused() ?? false; -} + // The snapshot is the last *settled* value, never a live read. With + // `includeEditorUI` the editor's own events are already settled, whereas + // reading focus state during an arbitrary render can catch a mid-handoff + // frame, where `document.activeElement` is transiently `` and the + // editor looks unfocused for one frame. + // + // The cache is keyed by its inputs: when the editor or the option changes, + // the settled value belongs to the *old* source, and rendering it would + // show one wrong frame before the new subscription attaches and re-syncs. + // Re-reading then is the same live read the first render does. + const focused = useRef< + | { + editor: BlockNoteEditor; + includeEditorUI: boolean; + value: boolean; + } + | undefined + >(undefined); + if ( + focused.current === undefined || + focused.current.editor !== resolvedEditor || + focused.current.includeEditorUI !== includeEditorUI + ) { + focused.current = { + editor: resolvedEditor, + includeEditorUI, + value: resolvedEditor.isFocused({ includeEditorUI }), + }; + } + + const subscribe = useCallback( + (onStoreChange: () => void) => { + // Re-sync: focus can have changed between the render that produced the + // current snapshot and this subscription attaching. React does compare + // the snapshot again after subscribing (its subscribe effect is + // registered before the consistency-check one), so refreshing the + // cached value here is enough — but notifying explicitly keeps that + // independent of React's internal effect ordering. + focused.current = { + editor: resolvedEditor, + includeEditorUI, + value: resolvedEditor.isFocused({ includeEditorUI }), + }; + onStoreChange(); + + return resolvedEditor.onFocusChange( + (_editor, ctx) => { + focused.current = { + editor: resolvedEditor, + includeEditorUI, + value: ctx.focused, + }; + onStoreChange(); + }, + { includeEditorUI }, + ); + }, + [resolvedEditor, includeEditorUI], + ); + + const getSnapshot = useCallback(() => focused.current!.value, []); -function selectUIFocus(snapshot: { - editor: BlockNoteEditor | null; -}): boolean { - return snapshot.editor?.isFocused({ includeEditorUI: true }) ?? false; + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } diff --git a/packages/react/src/hooks/useEditorState.ts b/packages/react/src/hooks/useEditorState.ts index 9fd5085362..59817ef8e7 100644 --- a/packages/react/src/hooks/useEditorState.ts +++ b/packages/react/src/hooks/useEditorState.ts @@ -44,7 +44,7 @@ export type UseEditorStateOptions< * The event to subscribe to. * @default "all" */ - on?: "all" | "mount" | "selection" | "change" | "focus" | "focusWithinUI"; + on?: "all" | "mount" | "selection" | "change"; }; /** @@ -115,7 +115,7 @@ class EditorStateManager< */ watch( nextEditor: BlockNoteEditor | null, - on: "all" | "mount" | "selection" | "change" | "focus" | "focusWithinUI", + on: "all" | "mount" | "selection" | "change", ): undefined | (() => void) { this.editor = nextEditor as TEditor; @@ -130,19 +130,6 @@ class EditorStateManager< this.subscribers.forEach((callback) => callback()); }; - // Two distinct streams, deliberately: "focus" is raw content-area - // focus (fires on every focus/blur), "focusWithinUI" is the settled - // combined state (fires only once focus movement settles, and not at - // all when focus merely moves between the content area and the - // editor's own UI). Subscribing one to the other either misses - // transitions (raw via settled) or breaks settledness (settled via - // raw). - if (on === "focus" || on === "focusWithinUI") { - return this.editor.onFocusChange(fn, { - includeEditorUI: on === "focusWithinUI", - }); - } - const currentTiptapEditor = this.editor._tiptapEditor; const EVENT_TYPES = {