diff --git a/packages/core/src/schema/schema.test.ts b/packages/core/src/schema/schema.test.ts new file mode 100644 index 0000000000..9e0ac89bd3 --- /dev/null +++ b/packages/core/src/schema/schema.test.ts @@ -0,0 +1,110 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js"; +import { + defaultBlockSpecs, + defaultInlineContentSpecs, + defaultStyleSpecs, +} from "../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { createExtension } from "../editor/BlockNoteExtension.js"; +import { createBlockSpec } from "./blocks/createSpec.js"; +import { createInlineContentSpec } from "./inlineContent/createSpec.js"; +import { createStyleSpec } from "./styles/createSpec.js"; + +const editorsToCleanup: BlockNoteEditor[] = []; + +afterEach(() => { + for (const editor of editorsToCleanup) { + editor.unmount(); + } + editorsToCleanup.length = 0; +}); + +function createMountedEditor( + options: Parameters[0], +) { + const editor = BlockNoteEditor.create(options); + editor.mount(document.createElement("div")); + editorsToCleanup.push(editor); + return editor; +} + +// A block spec that registers an editor extension, like +// `@blocknote/xl-multi-column`'s `ColumnBlock` does with its drop handler. +const CustomBlockExtension = createExtension(() => ({ + key: "customBlockExtension", +})); + +const createCustomBlockSpec = createBlockSpec( + { type: "customBlock", propSchema: {}, content: "none" }, + { render: () => ({ dom: document.createElement("div") }) }, + [CustomBlockExtension()], +); + +const customInlineContent = createInlineContentSpec( + { type: "customInlineContent", propSchema: {}, content: "none" }, + { render: () => ({ dom: document.createElement("span") }) }, +); + +const customStyle = createStyleSpec( + { type: "customStyle", propSchema: "boolean" }, + { render: () => ({ dom: document.createElement("span") }) }, +); + +describe("CustomBlockNoteSchema.extend", () => { + it("does not mutate the default specs shared by every default schema", () => { + const extended = BlockNoteSchema.create().extend({ + blockSpecs: { customBlock: createCustomBlockSpec() }, + inlineContentSpecs: { customInlineContent }, + styleSpecs: { customStyle }, + }); + + // The extended schema has the added specs... + expect(extended.blockSchema).toHaveProperty("customBlock"); + expect(extended.inlineContentSchema).toHaveProperty("customInlineContent"); + expect(extended.styleSchema).toHaveProperty("customStyle"); + + // ...the module-level defaults it was created from are untouched... + expect(defaultBlockSpecs).not.toHaveProperty("customBlock"); + expect(defaultInlineContentSpecs).not.toHaveProperty("customInlineContent"); + expect(defaultStyleSpecs).not.toHaveProperty("customStyle"); + + // ...so a default schema created afterwards doesn't inherit them. + const fresh = BlockNoteSchema.create(); + expect(fresh.blockSchema).not.toHaveProperty("customBlock"); + expect(fresh.inlineContentSchema).not.toHaveProperty("customInlineContent"); + expect(fresh.styleSchema).not.toHaveProperty("customStyle"); + }); + + it("does not mutate the spec objects passed to `create`", () => { + const blockSpecs = { paragraph: defaultBlockSpecs.paragraph }; + + BlockNoteSchema.create({ blockSpecs }).extend({ + blockSpecs: { customBlock: createCustomBlockSpec() }, + }); + + expect(Object.keys(blockSpecs)).toEqual(["paragraph"]); + }); + + it("keeps an extended schema's blocks and their extensions out of later default-schema editors", () => { + // Regression: extending a default schema used to write the added block + // specs into the shared default specs, so every editor created afterwards + // without an explicit schema silently got the extra block types - and the + // editor extensions they register (e.g. multi-column's drop handler). + const extendedEditor = createMountedEditor({ + schema: BlockNoteSchema.create().extend({ + blockSpecs: { customBlock: createCustomBlockSpec() }, + }), + }); + expect(extendedEditor.getExtension(CustomBlockExtension)).toBeDefined(); + + const defaultEditor = createMountedEditor({}); + expect(defaultEditor.schema.blockSchema).not.toHaveProperty("customBlock"); + expect(defaultEditor.pmSchema.nodes).not.toHaveProperty("customBlock"); + expect(defaultEditor.getExtension(CustomBlockExtension)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/schema/schema.ts b/packages/core/src/schema/schema.ts index a7a04e93dc..a9c9f814d3 100644 --- a/packages/core/src/schema/schema.ts +++ b/packages/core/src/schema/schema.ts @@ -201,10 +201,19 @@ export class CustomBlockNoteSchema< [K in keyof AdditionalStyleSpecs]: AdditionalStyleSpecs[K]["config"]; } > { - // Merge the new specs with existing ones - Object.assign(this.opts.blockSpecs, opts.blockSpecs); - Object.assign(this.opts.inlineContentSpecs, opts.inlineContentSpecs); - Object.assign(this.opts.styleSpecs, opts.styleSpecs); + // Merge the new specs with the existing ones into fresh objects. The + // existing spec objects must not be written to: `BlockNoteSchema.create()` + // passes the module-level default specs by reference, so mutating them + // would leak the added specs (and the editor extensions they register) into + // every default-schema editor created afterwards. + this.opts = { + blockSpecs: { ...this.opts.blockSpecs, ...opts.blockSpecs }, + inlineContentSpecs: { + ...this.opts.inlineContentSpecs, + ...opts.inlineContentSpecs, + }, + styleSpecs: { ...this.opts.styleSpecs, ...opts.styleSpecs }, + }; // Reinitialize the block specs with the merged specs const {