@@ -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 : / [ a b c ] / g} ) ;
481+ renderTopLevelContentCallCount = 0 ;
482+ serializer . serialize ( document , { commonEscape : / [ a b c ] / 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 : / [ a b c ] / g} ) ;
489+ renderTopLevelContentCallCount = 0 ;
490+ serializer . serialize ( document , { commonEscape : / [ x y z ] / 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 : / t e s t / 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