Skip to content

Commit 8fe6fa7

Browse files
authored
perf(core): add document-level and block-level caching to pm→markdown serialization (#1047)
Two-level caching in `MarkdownSerializer`: - **Document-level:** when `serialize()` is called with the same `doc` node and equivalent `options`, returns the cached result without re-serialization - **Top-level block cache:** when the document changes, only modified top-level blocks are re-serialized; unchanged blocks are served from a `WeakMap` cache
1 parent a84854a commit 8fe6fa7

3 files changed

Lines changed: 347 additions & 8 deletions

File tree

packages/editor/src/core/markdown/Markdown.test.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,4 +420,226 @@ describe('markdown (fork-specific)', () => {
420420
);
421421
});
422422
});
423+
424+
describe('document-level cache', () => {
425+
let renderTopLevelContentCallCount: number;
426+
const origRenderTopLevelContent = MarkdownSerializerState.prototype.renderTopLevelContent;
427+
428+
beforeEach(() => {
429+
serializer.clearCache();
430+
renderTopLevelContentCallCount = 0;
431+
MarkdownSerializerState.prototype.renderTopLevelContent = function (parent) {
432+
renderTopLevelContentCallCount++;
433+
return origRenderTopLevelContent.call(this, parent);
434+
};
435+
});
436+
437+
afterEach(() => {
438+
MarkdownSerializerState.prototype.renderTopLevelContent = origRenderTopLevelContent;
439+
serializer.clearCache();
440+
});
441+
442+
it('returns cached result for the same doc node', () => {
443+
const document = doc(p('hello'));
444+
const result1 = serializer.serialize(document);
445+
renderTopLevelContentCallCount = 0;
446+
const result2 = serializer.serialize(document);
447+
expect(result2).toBe('hello');
448+
expect(result2).toBe(result1);
449+
expect(renderTopLevelContentCallCount).toBe(0);
450+
});
451+
452+
it('re-serializes when doc node changes', () => {
453+
const doc1 = doc(p('hello'));
454+
const doc2 = doc(p('world'));
455+
const result1 = serializer.serialize(doc1);
456+
const result2 = serializer.serialize(doc2);
457+
expect(result1).toBe('hello');
458+
expect(result2).toBe('world');
459+
});
460+
461+
it('cache hit when options objects differ by reference but equal by content', () => {
462+
const document = doc(p('hello'));
463+
serializer.serialize(document, {tightLists: true});
464+
renderTopLevelContentCallCount = 0;
465+
const result = serializer.serialize(document, {tightLists: true});
466+
expect(result).toBe('hello');
467+
expect(renderTopLevelContentCallCount).toBe(0);
468+
});
469+
470+
it('cache miss when options actually differ', () => {
471+
const document = doc(p('hello'));
472+
serializer.serialize(document, {strict: true});
473+
renderTopLevelContentCallCount = 0;
474+
serializer.serialize(document, {strict: false});
475+
expect(renderTopLevelContentCallCount).toBeGreaterThan(0);
476+
});
477+
478+
it('cache hit when RegExp options are equal but different objects', () => {
479+
const document = doc(p('hello'));
480+
serializer.serialize(document, {commonEscape: /[abc]/g});
481+
renderTopLevelContentCallCount = 0;
482+
serializer.serialize(document, {commonEscape: /[abc]/g});
483+
expect(renderTopLevelContentCallCount).toBe(0);
484+
});
485+
486+
it('cache miss when RegExp options differ', () => {
487+
const document = doc(p('hello'));
488+
serializer.serialize(document, {commonEscape: /[abc]/g});
489+
renderTopLevelContentCallCount = 0;
490+
serializer.serialize(document, {commonEscape: /[xyz]/g});
491+
expect(renderTopLevelContentCallCount).toBeGreaterThan(0);
492+
});
493+
494+
it('clearCache forces re-serialization', () => {
495+
const document = doc(p('hello'));
496+
serializer.serialize(document);
497+
serializer.clearCache();
498+
renderTopLevelContentCallCount = 0;
499+
serializer.serialize(document);
500+
expect(renderTopLevelContentCallCount).toBeGreaterThan(0);
501+
});
502+
503+
it('cache hit with same options reference (fast path)', () => {
504+
const document = doc(p('hello'));
505+
const opts = {tightLists: true, commonEscape: /test/g};
506+
serializer.serialize(document, opts);
507+
renderTopLevelContentCallCount = 0;
508+
serializer.serialize(document, opts);
509+
expect(renderTopLevelContentCallCount).toBe(0);
510+
});
511+
});
512+
513+
describe('top-level node cache', () => {
514+
let renderCallCount: number;
515+
let renderedNodeTypes: string[];
516+
const origRender = MarkdownSerializerState.prototype.render;
517+
518+
beforeEach(() => {
519+
serializer.clearCache();
520+
renderCallCount = 0;
521+
renderedNodeTypes = [];
522+
MarkdownSerializerState.prototype.render = function (node, parent, index) {
523+
renderCallCount++;
524+
renderedNodeTypes.push(node.type.name);
525+
return origRender.call(this, node, parent, index);
526+
};
527+
});
528+
529+
afterEach(() => {
530+
MarkdownSerializerState.prototype.render = origRender;
531+
serializer.clearCache();
532+
});
533+
534+
it('uses cache for unchanged blocks when one block changes', () => {
535+
const block1 = p('hello');
536+
const block2 = p('world');
537+
serializer.serialize(doc(block1, block2));
538+
539+
// Change only block2 — block1 should come from cache
540+
const block2new = p('changed');
541+
renderCallCount = 0;
542+
renderedNodeTypes = [];
543+
const result = serializer.serialize(doc(block1, block2new));
544+
545+
expect(result).toBe('hello\n\nchanged');
546+
// Only block2new is re-rendered (paragraph + its inline text child)
547+
expect(renderedNodeTypes).toStrictEqual(['paragraph', 'text']);
548+
});
549+
550+
it('produces correct separators between blocks on cache hit', () => {
551+
const block1 = p('one');
552+
const block2 = p('two');
553+
const block3 = p('three');
554+
serializer.serialize(doc(block1, block2, block3));
555+
556+
// New doc with same blocks — document-level miss, top-level cache hit
557+
renderCallCount = 0;
558+
const result = serializer.serialize(doc(block1, block2, block3));
559+
expect(result).toBe('one\n\ntwo\n\nthree');
560+
// All blocks from top-level cache, none re-rendered
561+
expect(renderCallCount).toBe(0);
562+
});
563+
564+
it('correct separator for two consecutive lists of the same type', () => {
565+
const list1 = ul(li(p('a')));
566+
const list2 = ul(li(p('b')));
567+
const result = serializer.serialize(doc(list1, list2));
568+
expect(result).toBe('* a\n\n\n* b');
569+
570+
// New doc, same blocks — top-level cache hit
571+
const result2 = serializer.serialize(doc(list1, list2));
572+
expect(result2).toBe('* a\n\n\n* b');
573+
});
574+
575+
it('handles mixed block types correctly', () => {
576+
const heading = h1('Title');
577+
const paragraph = p('text');
578+
const list = ul(li(p('item')));
579+
const codeBlock = pre('code\n');
580+
serializer.serialize(doc(heading, paragraph, list, codeBlock));
581+
582+
// Change only the paragraph, others from cache
583+
const newParagraph = p('new text');
584+
renderCallCount = 0;
585+
renderedNodeTypes = [];
586+
const result = serializer.serialize(doc(heading, newParagraph, list, codeBlock));
587+
588+
expect(result).toBe('# Title\n\nnew text\n\n* item\n\n```\ncode\n```');
589+
// Only newParagraph is re-rendered (paragraph + its inline text child)
590+
expect(renderedNodeTypes).toStrictEqual(['paragraph', 'text']);
591+
});
592+
593+
it('invalidates cache when block order changes (prevClosedTypeName differs)', () => {
594+
const heading = h1('Title');
595+
const paragraph = p('text');
596+
serializer.serialize(doc(heading, paragraph));
597+
598+
// Swap order — prevClosedTypeName changes for both blocks
599+
renderCallCount = 0;
600+
const result = serializer.serialize(doc(paragraph, heading));
601+
602+
expect(result).toBe('text\n\n# Title');
603+
expect(renderCallCount).toBeGreaterThanOrEqual(2);
604+
});
605+
606+
it('clearCache resets top-level node cache', () => {
607+
const block = p('hello');
608+
serializer.serialize(doc(block));
609+
610+
serializer.clearCache();
611+
renderCallCount = 0;
612+
serializer.serialize(doc(block));
613+
expect(renderCallCount).toBeGreaterThan(0);
614+
});
615+
616+
it('invalidates top-level node cache when options change', () => {
617+
const block = p('hello');
618+
serializer.serialize(doc(block), {strict: true});
619+
620+
renderCallCount = 0;
621+
serializer.serialize(doc(block), {strict: false});
622+
expect(renderCallCount).toBeGreaterThan(0);
623+
});
624+
625+
it('does not cache nested nodes (non top-level)', () => {
626+
// paragraph is nested inside list_item — it should not be cached
627+
const paragraph = p('nested');
628+
const list1 = ul(li(paragraph));
629+
serializer.serialize(doc(list1));
630+
631+
// New list with the same paragraph node inside — list is new,
632+
// so top-level cache misses. The nested paragraph must be
633+
// re-rendered, not taken from cache.
634+
const list2 = ul(li(paragraph));
635+
renderCallCount = 0;
636+
renderedNodeTypes = [];
637+
const result = serializer.serialize(doc(list2));
638+
639+
expect(result).toBe('* nested');
640+
// paragraph and text must be re-rendered (not cached)
641+
expect(renderedNodeTypes).toContain('paragraph');
642+
expect(renderedNodeTypes).toContain('text');
643+
});
644+
});
423645
});

0 commit comments

Comments
 (0)