From d16358cd1f0f4b3383c3a9c16a9761a6b711df09 Mon Sep 17 00:00:00 2001
From: adarshsm <24850536+adarshsm@users.noreply.github.com>
Date: Sun, 16 Aug 2026 12:34:15 +0530
Subject: [PATCH] fix(xl-docx-exporter): give each list its own numbering
instance
Every bullet/numbered list item was exported with a single shared numbering
reference, so docx assigned them all one w:numId. Word treats a shared numId as
one continued list, which made separate lists carry on each other's numbering
and bullets instead of restarting (#2225).
Assign each maximal run of consecutive same-type sibling list items its own
numbering instance, using a document-global counter, so docx emits a distinct
w:numId per list. Items within a list still share a numId (so they number
continuously), and a nested sub-list is its own list that restarts.
---
.../src/docx/__snapshots__/basic/document.xml | 18 ++---
.../src/docx/defaultSchema/blocks.ts | 8 +-
.../src/docx/docxExporter.test.ts | 76 +++++++++++++++++++
.../xl-docx-exporter/src/docx/docxExporter.ts | 35 ++++++++-
4 files changed, 125 insertions(+), 12 deletions(-)
diff --git a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml
index 4a9074e6eb..32f4ccaedb 100644
--- a/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml
+++ b/packages/xl-docx-exporter/src/docx/__snapshots__/basic/document.xml
@@ -102,7 +102,7 @@
-
+
@@ -114,7 +114,7 @@
-
+
@@ -127,7 +127,7 @@
-
+
@@ -139,7 +139,7 @@
-
+
@@ -151,7 +151,7 @@
-
+
@@ -163,7 +163,7 @@
-
+
@@ -175,7 +175,7 @@
-
+
@@ -192,7 +192,7 @@
-
+
@@ -209,7 +209,7 @@
-
+
diff --git a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts
index bfce5d335d..f7833d36a3 100644
--- a/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts
+++ b/packages/xl-docx-exporter/src/docx/defaultSchema/blocks.ts
@@ -126,23 +126,27 @@ export const docxBlockMappingForDefaultSchema: BlockMapping<
],
});
},
- numberedListItem: (block, exporter, nestingLevel) => {
+ numberedListItem: (block, exporter, nestingLevel, numberingInstance) => {
return new Paragraph({
...blockPropsToStyles(block.props, exporter.options.colors),
children: exporter.transformInlineContent(block.content),
numbering: {
reference: "blocknote-numbered-list",
level: clampListLevel(nestingLevel),
+ // Each distinct list gets its own instance so separate lists don't
+ // continue each other's numbering (see DOCXExporter.transformBlocks).
+ instance: numberingInstance,
},
});
},
- bulletListItem: (block, exporter, nestingLevel) => {
+ bulletListItem: (block, exporter, nestingLevel, numberingInstance) => {
return new Paragraph({
...blockPropsToStyles(block.props, exporter.options.colors),
children: exporter.transformInlineContent(block.content),
numbering: {
reference: "blocknote-bullet-list",
level: clampListLevel(nestingLevel),
+ instance: numberingInstance,
},
});
},
diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
index 16e45a304f..6543b9998f 100644
--- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
+++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
@@ -334,6 +334,82 @@ describe("exporter", () => {
},
);
+ it(
+ "should give each list its own numbering instance",
+ { timeout: 10000 },
+ async () => {
+ const schema = BlockNoteSchema.create({
+ blockSpecs: { ...defaultBlockSpecs },
+ });
+
+ // Two separate numbered lists split by a paragraph, then a bullet list.
+ // Each is a distinct list and must not continue the previous one, so each
+ // needs its own `w:numId`. A nested item stays part of its parent list.
+ const blocks: PartialBlock<
+ typeof schema.blockSchema,
+ typeof schema.inlineContentSchema,
+ typeof schema.styleSchema
+ >[] = [
+ {
+ type: "numberedListItem",
+ content: "list one item one",
+ children: [{ type: "numberedListItem", content: "nested" }],
+ },
+ { type: "numberedListItem", content: "list one item two" },
+ { type: "paragraph", content: "a paragraph breaks the list" },
+ { type: "numberedListItem", content: "list two item one" },
+ { type: "numberedListItem", content: "list two item two" },
+ { type: "bulletListItem", content: "a bullet list" },
+ ];
+
+ const exporter = new DOCXExporter(schema, docxDefaultSchemaMappings, {
+ resolveFileUrl: testResolveFileUrl,
+ });
+
+ const doc = await exporter.toDocxJsDocument(
+ partialBlocksToBlocksForTesting(schema, blocks),
+ { sectionOptions: {}, documentOptions: {}, locale: "en-US" },
+ );
+
+ const documentXml = await getZIPEntryContent(
+ await new ZipReader(
+ new BlobReader(await Packer.toBlob(doc)),
+ ).getEntries(),
+ "word/document.xml",
+ );
+
+ // Paragraphs appear in document order: list-one item one, its nested
+ // child, list-one item two, list-two item one, list-two item two, bullet.
+ const numIds = [
+ ...documentXml.matchAll(//g),
+ ].map((match) => Number(match[1]));
+
+ expect(numIds).toHaveLength(6);
+ const [listOneA, listOneNested, listOneB, listTwoA, listTwoB, bullet] =
+ numIds;
+
+ // Items in the same list at the same level share one numId, so the list
+ // numbers continuously (1, 2) instead of restarting per item.
+ expect(listOneB).toBe(listOneA);
+ expect(listTwoB).toBe(listTwoA);
+
+ // A nested sub-list is its own list: it gets its own numId and restarts,
+ // rather than continuing its parent's numbering.
+ expect(listOneNested).not.toBe(listOneA);
+
+ // Separate lists get separate numIds so they don't continue each other -
+ // this is the actual bug (#2225): before the fix every numbered list
+ // shared one numId and the second list continued 3, 4, ... instead of 1, 2.
+ expect(listTwoA).not.toBe(listOneA);
+ expect(listOneNested).not.toBe(listTwoA);
+
+ // The bullet list is distinct from every numbered list too.
+ expect(bullet).not.toBe(listOneA);
+ expect(bullet).not.toBe(listTwoA);
+ expect(bullet).not.toBe(listOneNested);
+ },
+ );
+
async function exportAndGetStylesEntries(locale?: string) {
const exporter = new DOCXExporter(
BlockNoteSchema.create({
diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts
index f987ad4a7d..7085659138 100644
--- a/packages/xl-docx-exporter/src/docx/docxExporter.ts
+++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts
@@ -104,6 +104,14 @@ export class DOCXExporter<
});
}
+ /**
+ * A document-global counter used to hand every distinct list its own numbering
+ * instance (and therefore its own `w:numId`). Two lists that share a `numId`
+ * are treated by Word as one continued list, so without this all lists in a
+ * document number/bullet as if they were a single list. See issue #2225.
+ */
+ private numberingInstanceCounter = 0;
+
/**
* Mostly for internal use, you probably want to use `toBlob` or `toDocxJsDocument` instead.
*/
@@ -113,7 +121,30 @@ export class DOCXExporter<
): Promise> {
const ret: Array = [];
+ // The top-level call starts a fresh document, so restart instance numbering.
+ if (nestingLevel === 0) {
+ this.numberingInstanceCounter = 0;
+ }
+
+ // A list in Word is a maximal run of consecutive sibling list items of the
+ // same type; a break (any other block) or a switch between bullet/numbered
+ // starts a new list. Each such run gets its own numbering instance so it
+ // renders as a separate list rather than continuing the previous one.
+ let runListType: string | undefined;
+ let runInstance = 0;
+
for (const b of blocks) {
+ let numberingInstance = 0;
+ if (b.type === "bulletListItem" || b.type === "numberedListItem") {
+ if (b.type !== runListType) {
+ runInstance = ++this.numberingInstanceCounter;
+ runListType = b.type;
+ }
+ numberingInstance = runInstance;
+ } else {
+ runListType = undefined;
+ }
+
let children = await this.transformBlocks(b.children, nestingLevel + 1);
if (!["columnList", "column"].includes(b.type)) {
@@ -133,10 +164,12 @@ export class DOCXExporter<
});
}
+ // The `numberedListIndex` slot carries the numbering instance for the docx
+ // block mappings (bullet/numbered list items); other block types ignore it.
const self = await this.mapBlock(
b as any,
nestingLevel,
- 0 /*unused*/,
+ numberingInstance,
children,
); // TODO: any
if (["columnList", "column"].includes(b.type)) {