Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/docs/getting-started/vanilla-js.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 |
| ------------------ | ----------------------------- |
Expand Down
1 change: 0 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/comments/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,9 @@ export const CommentsExtension = createExtension(
threadPositions: new Map<string, { from: number; to: number }>(),
},
{
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));
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/editor/BlockNoteExtension.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/user/UserStore.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
132 changes: 132 additions & 0 deletions packages/core/src/util/Store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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("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[] = [];

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 });
});
});
135 changes: 135 additions & 0 deletions packages/core/src/util/Store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// 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<T> {
readonly prevVal: T;
readonly currentVal: T;
}

/**
* A callback invoked when a {@link Store}'s state changes.
*/
export type Listener<T> = (value: ListenerValue<T>) => void;

/**
* A new state, or a function deriving it from the previous state.
*/
export type Updater<T> = T | ((prev: T) => T);

export interface StoreOptions<TState> {
/**
* 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<TState> {
listeners = new Set<Listener<TState>>();
state: TState;
prevState: TState;
options?: StoreOptions<TState>;

constructor(initialState: TState, options?: StoreOptions<TState>) {
this.prevState = initialState;
this.state = initialState;
this.options = options;
}

subscribe = (listener: Listener<TState>) => {
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<TState>): void {
this.prevState = this.state;
this.state = isUpdaterFunction(updater) ? updater(this.prevState) : updater;

flush(this);
}

/**
* @internal Only to be called by {@link flush}.
*/
_notify() {
const value: ListenerValue<TState> = {
prevVal: this.prevState,
currentVal: this.state,
};
for (const listener of this.listeners) {
listener(value);
}
}
}

function isUpdaterFunction<T>(updater: Updater<T>): updater is (prev: T) => T {
return typeof updater === "function";
}

// 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<Store<any>>();

function flush(store: Store<any>) {
pendingUpdates.add(store);

const isOutermost = !isFlushing;
isFlushing = true;

try {
store.options?.onUpdate?.(store.state, store.prevState);

if (!isOutermost) {
return;
}

while (pendingUpdates.size > 0) {
const stores = Array.from(pendingUpdates);
pendingUpdates.clear();
for (const pendingStore of stores) {
pendingStore._notify();
}
}
} finally {
if (isOutermost) {
isFlushing = false;
// A throwing callback would otherwise strand queued stores, letting them notify
// during an unrelated store's next flush.
pendingUpdates.clear();
}
}
}
1 change: 0 additions & 1 deletion packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/components/Comments/useCommentUsers.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 2 additions & 4 deletions packages/react/src/hooks/useExtension.ts
Original file line number Diff line number Diff line change
@@ -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<T> = ReturnType<typeof createStore<T>>;
import { useStore } from "./useStore.js";

/**
* Use an extension instance
Expand Down
Loading
Loading