From 489af88e0dd557898ee60362a2469b1d1ecd1a20 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 11 Aug 2026 14:37:26 +0200 Subject: [PATCH 1/2] refactor: vendor @tanstack/store as a first-party Store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BlockNote used only a fraction of @tanstack/store: `state`, `prevState`, `setState`, `subscribe`, and the `onUpdate` option. The library's actual value-add — the `Derived`/`Effect` reactive graph — was never used, and it doesn't tree-shake, since `setState` reaches into the scheduler which imports `Derived` at module top level. The `Store` type is also unavoidably part of BlockNote's published API: every extension exposes one, so the emitted declarations carried 19+ `import("@tanstack/store").Store<...>` references, and the vanilla-JS docs pointed users at tanstack.com/store. That meant every breaking change in a 0.x dependency became a BlockNote breaking change — and upstream has shipped four minor bumps in eleven months, including a full rewrite onto alien-signals (0.9.0) and a React hook replacement (0.11.0), with no 1.0 on the roadmap. Vendoring the ~50 lines we actually use keeps that public surface stable and under our control. Behaviour is unchanged, including the re-entrancy guard that lets a listener write back to the store (e.g. one dispatching a ProseMirror transaction) without recursing. - Add `packages/core/src/util/Store.ts` and export `Store` by name, so consumers can import the type directly instead of only reaching it structurally. - Add `packages/react/src/hooks/useStore.ts`, wrapping the `use-sync-external-store` shim that `@blocknote/react` already depends on. Keeps the `shallow` default comparator that `useCommentUsers` and `useVersionUsers` rely on to avoid re-rendering on unrelated user updates. - Give `onUpdate` the new and previous state, so callers no longer close over the store to read `prevState`. - Drop the `TUpdater` type parameter, which only existed to serve the unused `updateFn` option. Emitted types simplify from `Store T>` to `Store`. - Remove both dependencies. This also closes a version-skew hazard: core allowed `^0.7.7` to float while react pinned `0.7.7` exactly, so a future 0.7.x publish would have installed two copies with instances crossing the boundary. Bundle impact: 340 B gzipped, down from 1128 B for the Store-only tanstack bundle. --- .../docs/getting-started/vanilla-js.mdx | 2 +- packages/core/package.json | 1 - packages/core/src/comments/extension.ts | 6 +- .../core/src/editor/BlockNoteExtension.ts | 2 +- packages/core/src/index.ts | 1 + packages/core/src/user/UserStore.ts | 2 +- packages/core/src/util/Store.test.ts | 105 +++++++++++++++ packages/core/src/util/Store.ts | 123 ++++++++++++++++++ packages/react/package.json | 1 - .../components/Comments/useCommentUsers.ts | 2 +- .../components/Versioning/useVersionUsers.ts | 2 +- packages/react/src/hooks/useExtension.ts | 6 +- packages/react/src/hooks/useStore.ts | 92 +++++++++++++ packages/react/src/index.ts | 1 + pnpm-lock.yaml | 24 ---- 15 files changed, 331 insertions(+), 39 deletions(-) create mode 100644 packages/core/src/util/Store.test.ts create mode 100644 packages/core/src/util/Store.ts create mode 100644 packages/react/src/hooks/useStore.ts diff --git a/docs/content/docs/getting-started/vanilla-js.mdx b/docs/content/docs/getting-started/vanilla-js.mdx index 3735e09fb7..71ef3ee6cd 100644 --- a/docs/content/docs/getting-started/vanilla-js.mdx +++ b/docs/content/docs/getting-started/vanilla-js.mdx @@ -44,7 +44,7 @@ Now, you'll have a plain BlockNote instance on your page. However, it's missing Because you can't use the built-in React [UI Components](/docs/react/components), you'll need to create and register your own UI elements. -Each UI element is backed by an [extension](/docs/features/extensions). You can retrieve an extension instance from the editor with `editor.getExtension(...)`, and each one exposes a [store](https://tanstack.com/store) that holds its current state (visibility, position, and any element-specific data). The available UI element extensions are: +Each UI element is backed by an [extension](/docs/features/extensions). You can retrieve an extension instance from the editor with `editor.getExtension(...)`, and each one exposes a store that holds its current state (visibility, position, and any element-specific data). A store is a small observable container with three members: `state` to read the current value, `setState` to update it, and `subscribe` to be notified of changes. The available UI element extensions are: | UI element | Extension | | ------------------ | ----------------------------- | diff --git a/packages/core/package.json b/packages/core/package.json index 0f5cd1a3d7..b928509796 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -97,7 +97,6 @@ "@emoji-mart/data": "^1.2.1", "@handlewithcare/prosemirror-inputrules": "^0.1.4", "@shikijs/types": "^4", - "@tanstack/store": "^0.7.7", "@tiptap/core": "^3.29.2", "@tiptap/extension-bold": "^3.29.2", "@tiptap/extension-code": "^3.29.2", diff --git a/packages/core/src/comments/extension.ts b/packages/core/src/comments/extension.ts index 2911b3ba89..8b20fc8757 100644 --- a/packages/core/src/comments/extension.ts +++ b/packages/core/src/comments/extension.ts @@ -117,11 +117,9 @@ export const CommentsExtension = createExtension( threadPositions: new Map(), }, { - onUpdate() { + onUpdate(state, prevState) { // If the selected thread id changed, we need to update the decorations - if ( - store.state.selectedThreadId !== store.prevState.selectedThreadId - ) { + if (state.selectedThreadId !== prevState.selectedThreadId) { // So, we issue a transaction to update the decorations editor.transact((tr) => tr.setMeta(PLUGIN_KEY, true)); } diff --git a/packages/core/src/editor/BlockNoteExtension.ts b/packages/core/src/editor/BlockNoteExtension.ts index 7346333990..6fa0ab89e6 100644 --- a/packages/core/src/editor/BlockNoteExtension.ts +++ b/packages/core/src/editor/BlockNoteExtension.ts @@ -1,7 +1,7 @@ -import { Store, StoreOptions } from "@tanstack/store"; import { type AnyExtension } from "@tiptap/core"; import type { Plugin as ProsemirrorPlugin } from "prosemirror-state"; import type { PartialBlockNoDefaults } from "../schema/index.js"; +import { Store, StoreOptions } from "../util/Store.js"; import type { BlockNoteEditor } from "./BlockNoteEditor.js"; import { originalFactorySymbol } from "./managers/ExtensionManager/symbol.js"; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0e83fa76f2..8c9b6066b9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -23,6 +23,7 @@ export * from "./user/index.js"; export * from "./util/browser.js"; export * from "./util/combineByGroup.js"; export * from "./util/expandToWords.js"; +export * from "./util/Store.js"; export * from "./util/string.js"; export * from "./util/table.js"; export * from "./util/typescript.js"; diff --git a/packages/core/src/user/UserStore.ts b/packages/core/src/user/UserStore.ts index eeb7bac749..ad814386c1 100644 --- a/packages/core/src/user/UserStore.ts +++ b/packages/core/src/user/UserStore.ts @@ -1,5 +1,5 @@ -import { Store } from "@tanstack/store"; import { createStore } from "../editor/BlockNoteExtension.js"; +import { Store } from "../util/Store.js"; /** * A collaborator of the document. diff --git a/packages/core/src/util/Store.test.ts b/packages/core/src/util/Store.test.ts new file mode 100644 index 0000000000..4ddb387521 --- /dev/null +++ b/packages/core/src/util/Store.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { Store } from "./Store.js"; + +describe("Store", () => { + it("updates state from a value", () => { + const store = new Store({ count: 0 }); + + store.setState({ count: 1 }); + + expect(store.state).toEqual({ count: 1 }); + }); + + it("updates state from an updater function", () => { + const store = new Store({ count: 0 }); + + store.setState((prev) => ({ count: prev.count + 1 })); + + expect(store.state).toEqual({ count: 1 }); + }); + + it("exposes the previous state after an update", () => { + const store = new Store({ count: 0 }); + + store.setState({ count: 1 }); + expect(store.prevState).toEqual({ count: 0 }); + + store.setState({ count: 2 }); + expect(store.prevState).toEqual({ count: 1 }); + }); + + it("notifies listeners with the previous and current state", () => { + const store = new Store({ count: 0 }); + const listener = vi.fn(); + store.subscribe(listener); + + store.setState({ count: 1 }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith({ + prevVal: { count: 0 }, + currentVal: { count: 1 }, + }); + }); + + it("stops notifying after unsubscribing", () => { + const store = new Store({ count: 0 }); + const listener = vi.fn(); + const unsubscribe = store.subscribe(listener); + + store.setState({ count: 1 }); + unsubscribe(); + store.setState({ count: 2 }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(store.state).toEqual({ count: 2 }); + }); + + it("calls `onUpdate` after the write but before listeners", () => { + const calls: string[] = []; + const store: Store<{ count: number }> = new Store( + { count: 0 }, + { + onUpdate() { + // The new state is already committed by the time `onUpdate` runs, which is + // what the comments & ShowSelection extensions rely on to dispatch a + // transaction reflecting it. + calls.push(`onUpdate:${store.state.count}`); + }, + }, + ); + store.subscribe(() => calls.push("listener")); + + store.setState({ count: 1 }); + + expect(calls).toEqual(["onUpdate:1", "listener"]); + }); + + it("passes the new and previous state to `onUpdate`", () => { + const onUpdate = vi.fn(); + const store = new Store({ count: 0 }, { onUpdate }); + + store.setState({ count: 1 }); + + expect(onUpdate).toHaveBeenCalledWith({ count: 1 }, { count: 0 }); + }); + + it("flattens re-entrant updates instead of recursing", () => { + const store = new Store({ count: 0 }); + const seen: number[] = []; + + store.subscribe(({ currentVal }) => { + seen.push(currentVal.count); + // A listener writing back to the store — e.g. one that dispatches a transaction + // which in turn updates state. This must terminate rather than recurse. + if (currentVal.count < 3) { + store.setState({ count: currentVal.count + 1 }); + } + }); + + store.setState({ count: 1 }); + + expect(seen).toEqual([1, 2, 3]); + expect(store.state).toEqual({ count: 3 }); + }); +}); diff --git a/packages/core/src/util/Store.ts b/packages/core/src/util/Store.ts new file mode 100644 index 0000000000..2a590c7b29 --- /dev/null +++ b/packages/core/src/util/Store.ts @@ -0,0 +1,123 @@ +// Vendored from https://github.com/TanStack/store/blob/main/packages/store/src/store.ts (MIT) +// +// BlockNote only ever used `Store` — never the `Derived`/`Effect` reactive graph that +// makes up the rest of `@tanstack/store`, and that graph isn't tree-shakeable because +// `setState` reaches into the scheduler. Since the `Store` type is part of BlockNote's +// public API (every extension exposes one), owning these ~40 lines lets us keep that +// surface stable instead of tracking a 0.x dependency's breaking changes. +// +// Behaviour matches `@tanstack/store@0.7.7`, minus the dependency graph and the +// `updateFn`/`onSubscribe` options, which nothing used. `onUpdate` additionally receives +// the new and previous state rather than requiring callers to close over the store. + +/** + * The value a {@link Listener} is called with when a {@link Store} updates. + */ +export interface ListenerValue { + readonly prevVal: T; + readonly currentVal: T; +} + +/** + * A callback invoked when a {@link Store}'s state changes. + */ +export type Listener = (value: ListenerValue) => void; + +/** + * A new state, or a function deriving it from the previous state. + */ +export type Updater = T | ((prev: T) => T); + +export interface StoreOptions { + /** + * Called after the state has been updated, before listeners are notified. + */ + onUpdate?: (state: TState, prevState: TState) => void; +} + +/** + * A minimal observable state container. + * + * Extensions expose one of these as their `store` so that both React (via + * `useExtensionState`) and vanilla consumers can read and subscribe to their state. + */ +export class Store { + listeners = new Set>(); + state: TState; + prevState: TState; + options?: StoreOptions; + + constructor(initialState: TState, options?: StoreOptions) { + this.prevState = initialState; + this.state = initialState; + this.options = options; + } + + subscribe = (listener: Listener) => { + this.listeners.add(listener); + + return () => { + this.listeners.delete(listener); + }; + }; + + /** + * Update the store state, either with a new state or a function deriving it from the + * previous one. + */ + setState(updater: (prevState: TState) => TState): void; + setState(updater: TState): void; + setState(updater: Updater): void { + this.prevState = this.state; + this.state = isUpdaterFunction(updater) ? updater(this.prevState) : updater; + + this.options?.onUpdate?.(this.state, this.prevState); + + flush(this); + } + + /** + * @internal Only to be called by {@link flush}. + */ + _notify() { + const value: ListenerValue = { + prevVal: this.prevState, + currentVal: this.state, + }; + for (const listener of this.listeners) { + listener(value); + } + } +} + +function isUpdaterFunction(updater: Updater): updater is (prev: T) => T { + return typeof updater === "function"; +} + +// Notifying listeners can synchronously trigger further `setState` calls — a listener that +// dispatches a ProseMirror transaction, for example. Rather than recursing, re-entrant +// writes are queued and drained by the outermost flush, so a write during notification +// still reaches every listener but the stack stays flat. +let isFlushing = false; +const pendingUpdates = new Set>(); + +function flush(store: Store) { + pendingUpdates.add(store); + + if (isFlushing) { + return; + } + + try { + isFlushing = true; + while (pendingUpdates.size > 0) { + const stores = Array.from(pendingUpdates); + pendingUpdates.clear(); + for (const pendingStore of stores) { + pendingStore._notify(); + } + } + } finally { + isFlushing = false; + } +} diff --git a/packages/react/package.json b/packages/react/package.json index 1a9253e8d0..7e29004226 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -60,7 +60,6 @@ "@blocknote/core": "workspace:^", "@emoji-mart/data": "^1.2.1", "@floating-ui/react": "^0.27.18", - "@tanstack/react-store": "0.7.7", "@tiptap/core": "^3.29.2", "@tiptap/pm": "^3.29.2", "@tiptap/react": "^3.29.2", diff --git a/packages/react/src/components/Comments/useCommentUsers.ts b/packages/react/src/components/Comments/useCommentUsers.ts index 2175732af3..ea59097e61 100644 --- a/packages/react/src/components/Comments/useCommentUsers.ts +++ b/packages/react/src/components/Comments/useCommentUsers.ts @@ -1,9 +1,9 @@ import { User } from "@blocknote/core"; import { CommentsExtension } from "@blocknote/core/comments"; -import { useStore } from "@tanstack/react-store"; import { useEffect } from "react"; import { useExtension } from "../../hooks/useExtension.js"; +import { useStore } from "../../hooks/useStore.js"; /** * Reads users from the comments extension's user store, loading any that aren't diff --git a/packages/react/src/components/Versioning/useVersionUsers.ts b/packages/react/src/components/Versioning/useVersionUsers.ts index 382a722be3..de2b6fbf10 100644 --- a/packages/react/src/components/Versioning/useVersionUsers.ts +++ b/packages/react/src/components/Versioning/useVersionUsers.ts @@ -3,10 +3,10 @@ import { VersioningExtension, VersionSnapshot, } from "@blocknote/core/extensions"; -import { useStore } from "@tanstack/react-store"; import { useEffect } from "react"; import { useExtension } from "../../hooks/useExtension.js"; +import { useStore } from "../../hooks/useStore.js"; /** * Reads users from the versioning extension's user store, loading any that diff --git a/packages/react/src/hooks/useExtension.ts b/packages/react/src/hooks/useExtension.ts index ec8c7a707f..0323feda8c 100644 --- a/packages/react/src/hooks/useExtension.ts +++ b/packages/react/src/hooks/useExtension.ts @@ -1,13 +1,11 @@ import { BlockNoteEditor, - createStore, Extension, ExtensionFactory, + Store, } from "@blocknote/core"; -import { useStore } from "@tanstack/react-store"; import { useBlockNoteEditor } from "./useBlockNoteEditor.js"; - -type Store = ReturnType>; +import { useStore } from "./useStore.js"; /** * Use an extension instance diff --git a/packages/react/src/hooks/useStore.ts b/packages/react/src/hooks/useStore.ts new file mode 100644 index 0000000000..394836a440 --- /dev/null +++ b/packages/react/src/hooks/useStore.ts @@ -0,0 +1,92 @@ +// Vendored from https://github.com/TanStack/store/blob/main/packages/react-store/src/index.ts (MIT) +// +// See `packages/core/src/util/Store.ts` for why the store itself is vendored. This is the +// matching React binding, kept behaviourally identical to `@tanstack/react-store@0.7.7` — +// in particular the `shallow` default comparator, which `useCommentUsers` and +// `useVersionUsers` rely on to avoid re-rendering when an unrelated user resolves. + +import type { Store } from "@blocknote/core"; +import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector"; + +/** + * Subscribe to a {@link Store}, optionally selecting a slice of its state. + * + * The component re-renders only when the selected value changes under a + * {@link shallow} comparison, so selectors are free to build a fresh object, + * `Map` or `Set` on each call. + */ +export function useStore>( + store: Store, + selector: (state: TState) => TSelected = (d) => d as unknown as TSelected, +): TSelected { + return useSyncExternalStoreWithSelector( + store.subscribe, + () => store.state, + () => store.state, + selector, + shallow, + ); +} + +/** + * Compares two values one level deep, with special handling for `Map`, `Set` and `Date`. + */ +export function shallow(objA: T, objB: T): boolean { + if (Object.is(objA, objB)) { + return true; + } + + if ( + typeof objA !== "object" || + objA === null || + typeof objB !== "object" || + objB === null + ) { + return false; + } + + if (objA instanceof Map && objB instanceof Map) { + if (objA.size !== objB.size) { + return false; + } + for (const [k, v] of objA) { + if (!objB.has(k) || !Object.is(v, objB.get(k))) { + return false; + } + } + return true; + } + + if (objA instanceof Set && objB instanceof Set) { + if (objA.size !== objB.size) { + return false; + } + for (const v of objA) { + if (!objB.has(v)) { + return false; + } + } + return true; + } + + if (objA instanceof Date && objB instanceof Date) { + return objA.getTime() === objB.getTime(); + } + + const keysA = getOwnKeys(objA); + if (keysA.length !== getOwnKeys(objB).length) { + return false; + } + + return keysA.every( + (key) => + Object.prototype.hasOwnProperty.call(objB, key) && + Object.is(objA[key as keyof T], objB[key as keyof T]), + ); +} + +function getOwnKeys(obj: T): Array { + return (Object.keys(obj) as Array).concat( + Object.getOwnPropertySymbols(obj), + ); +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 2259babd84..2de5361e99 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -132,6 +132,7 @@ export * from "./hooks/useOnUploadEnd.js"; export * from "./hooks/useOnUploadStart.js"; export * from "./hooks/usePrefersColorScheme.js"; export * from "./hooks/useSelectedBlocks.js"; +export * from "./hooks/useStore.js"; export * from "./hooks/useUploadLoading.js"; export * from "./hooks/useExtension.js"; export * from "./hooks/useEditorState.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4070733d0..331754cad8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4995,9 +4995,6 @@ importers: '@shikijs/types': specifier: ^4 version: 4.0.2 - '@tanstack/store': - specifier: ^0.7.7 - version: 0.7.7 '@tiptap/core': specifier: ^3.29.2 version: 3.29.2(@tiptap/pm@3.29.2) @@ -5180,9 +5177,6 @@ importers: '@floating-ui/react': specifier: ^0.27.18 version: 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - '@tanstack/react-store': - specifier: 0.7.7 - version: 0.7.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tiptap/core': specifier: ^3.29.2 version: 3.29.2(@tiptap/pm@3.29.2) @@ -10011,12 +10005,6 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 - '@tanstack/react-store@0.7.7': - resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -10024,9 +10012,6 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/store@0.7.7': - resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==} - '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} @@ -19872,21 +19857,12 @@ snapshots: tailwindcss: 4.2.2 vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0) - '@tanstack/react-store@0.7.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': - dependencies: - '@tanstack/store': 0.7.7 - react: 19.2.5 - react-dom: 19.2.5(react@19.2.5) - use-sync-external-store: 1.6.0(react@19.2.5) - '@tanstack/react-table@8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/table-core': 8.21.3 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - '@tanstack/store@0.7.7': {} - '@tanstack/table-core@8.21.3': {} '@testing-library/dom@10.4.1': From 2ee606cc8c168d02ac17f5e59af37edd7486a623 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 11 Aug 2026 15:29:04 +0200 Subject: [PATCH 2/2] fix(core): coalesce nested setState from a Store `onUpdate` callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `onUpdate` ran before `flush()`, so a nested `setState` made from inside it saw `isFlushing === false` and drained immediately; the outer `flush()` then drained a second time. Subscribers were notified twice with identical values. Move `onUpdate` inside the flush transaction: the store is queued and the flush marked in progress before the callback fires, so a nested write is coalesced into the in-progress drain. Subscribers now see the settled state exactly once. Ordering is unchanged — `onUpdate` still runs after the write and before listeners. Also clear the pending queue when the outermost flush unwinds, so a throwing callback can't strand a queued store and have it notify during an unrelated store's next flush. Docs: describe the store's `state`/`setState`/`subscribe` as its primary members rather than an exhaustive set of three. --- .../docs/getting-started/vanilla-js.mdx | 2 +- packages/core/src/util/Store.test.ts | 27 +++++++++++++++ packages/core/src/util/Store.ts | 34 +++++++++++++------ 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/docs/content/docs/getting-started/vanilla-js.mdx b/docs/content/docs/getting-started/vanilla-js.mdx index 71ef3ee6cd..2ec0e2fbb5 100644 --- a/docs/content/docs/getting-started/vanilla-js.mdx +++ b/docs/content/docs/getting-started/vanilla-js.mdx @@ -44,7 +44,7 @@ Now, you'll have a plain BlockNote instance on your page. However, it's missing Because you can't use the built-in React [UI Components](/docs/react/components), you'll need to create and register your own UI elements. -Each UI element is backed by an [extension](/docs/features/extensions). You can retrieve an extension instance from the editor with `editor.getExtension(...)`, and each one exposes a store that holds its current state (visibility, position, and any element-specific data). A store is a small observable container with three members: `state` to read the current value, `setState` to update it, and `subscribe` to be notified of changes. The available UI element extensions are: +Each UI element is backed by an [extension](/docs/features/extensions). You can retrieve an extension instance from the editor with `editor.getExtension(...)`, and each one exposes a store that holds its current state (visibility, position, and any element-specific data). A store is a small observable container with these primary members: `state` to read the current value, `setState` to update it, and `subscribe` to be notified of changes. The available UI element extensions are: | UI element | Extension | | ------------------ | ----------------------------- | diff --git a/packages/core/src/util/Store.test.ts b/packages/core/src/util/Store.test.ts index 4ddb387521..c0c2a50c82 100644 --- a/packages/core/src/util/Store.test.ts +++ b/packages/core/src/util/Store.test.ts @@ -84,6 +84,33 @@ describe("Store", () => { expect(onUpdate).toHaveBeenCalledWith({ count: 1 }, { count: 0 }); }); + it("notifies once with the settled state when `onUpdate` writes back", () => { + const seen: Array<{ prev: number; curr: number }> = []; + let nested = false; + const store: Store<{ count: number }> = new Store( + { count: 0 }, + { + onUpdate(state) { + // A callback that writes back to its own store — e.g. one dispatching a + // transaction that settles the state. The nested write must be coalesced + // into the in-progress flush rather than draining on its own. + if (!nested && state.count === 1) { + nested = true; + store.setState({ count: 2 }); + } + }, + }, + ); + store.subscribe(({ prevVal, currentVal }) => + seen.push({ prev: prevVal.count, curr: currentVal.count }), + ); + + store.setState({ count: 1 }); + + expect(seen).toEqual([{ prev: 1, curr: 2 }]); + expect(store.state).toEqual({ count: 2 }); + }); + it("flattens re-entrant updates instead of recursing", () => { const store = new Store({ count: 0 }); const seen: number[] = []; diff --git a/packages/core/src/util/Store.ts b/packages/core/src/util/Store.ts index 2a590c7b29..75d3fde587 100644 --- a/packages/core/src/util/Store.ts +++ b/packages/core/src/util/Store.ts @@ -71,8 +71,6 @@ export class Store { this.prevState = this.state; this.state = isUpdaterFunction(updater) ? updater(this.prevState) : updater; - this.options?.onUpdate?.(this.state, this.prevState); - flush(this); } @@ -94,22 +92,31 @@ function isUpdaterFunction(updater: Updater): updater is (prev: T) => T { return typeof updater === "function"; } -// Notifying listeners can synchronously trigger further `setState` calls — a listener that -// dispatches a ProseMirror transaction, for example. Rather than recursing, re-entrant -// writes are queued and drained by the outermost flush, so a write during notification -// still reaches every listener but the stack stays flat. +// Both `onUpdate` and listener notification can synchronously trigger further `setState` +// calls — a callback that dispatches a ProseMirror transaction, for example. Rather than +// recursing, re-entrant writes are queued and drained by the outermost flush, so a write +// made during a callback still reaches every listener but the stack stays flat and +// subscribers see the settled state once. +// +// `onUpdate` therefore runs *inside* the flush transaction: the store is queued and the +// flush is marked in progress before the callback fires, so a nested write it makes is +// coalesced into this drain rather than starting its own. let isFlushing = false; const pendingUpdates = new Set>(); function flush(store: Store) { pendingUpdates.add(store); - if (isFlushing) { - return; - } + const isOutermost = !isFlushing; + isFlushing = true; try { - isFlushing = true; + store.options?.onUpdate?.(store.state, store.prevState); + + if (!isOutermost) { + return; + } + while (pendingUpdates.size > 0) { const stores = Array.from(pendingUpdates); pendingUpdates.clear(); @@ -118,6 +125,11 @@ function flush(store: Store) { } } } finally { - isFlushing = false; + if (isOutermost) { + isFlushing = false; + // A throwing callback would otherwise strand queued stores, letting them notify + // during an unrelated store's next flush. + pendingUpdates.clear(); + } } }