@@ -495,21 +495,31 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
495495 return results ;
496496}
497497
498- export function renderAriaTree ( ariaSnapshot : AriaSnapshot , publicOptions : AriaTreeOptions ) : string {
498+ export function renderAriaTree ( ariaSnapshot : AriaSnapshot , publicOptions : AriaTreeOptions , previous ?: AriaSnapshot ) : string {
499499 const options = toInternalOptions ( publicOptions ) ;
500500 const lines : string [ ] = [ ] ;
501501 const includeText = options . renderStringsAsRegex ? textContributesInfo : ( ) => true ;
502502 const renderString = options . renderStringsAsRegex ? convertToBestGuessRegex : ( str : string ) => str ;
503- const visit = ( ariaNode : AriaNode | string , parentAriaNode : AriaNode | null , indent : string , renderCursorPointer : boolean ) => {
504- if ( typeof ariaNode === ' string' ) {
505- if ( parentAriaNode && ! includeText ( parentAriaNode , ariaNode ) )
506- return ;
507- const text = yamlEscapeValueIfNeeded ( renderString ( ariaNode ) ) ;
508- if ( text )
509- lines . push ( indent + '- text: ' + text ) ;
510- return ;
503+
504+ const previousByRef = new Map < string , AriaNode > ( ) ;
505+ const visitPrevious = ( ariaNode : AriaNode ) => {
506+ if ( ariaNode . ref )
507+ previousByRef . set ( ariaNode . ref , ariaNode ) ;
508+ for ( const child of ariaNode . children ) {
509+ if ( typeof child !== 'string' )
510+ visitPrevious ( child ) ;
511511 }
512+ } ;
513+ if ( previous )
514+ visitPrevious ( previous . root ) ;
515+
516+ const visitText = ( text : string , indent : string ) => {
517+ const escaped = yamlEscapeValueIfNeeded ( renderString ( text ) ) ;
518+ if ( escaped )
519+ lines . push ( indent + '- text: ' + escaped ) ;
520+ } ;
512521
522+ const createKey = ( ariaNode : AriaNode , renderCursorPointer : boolean ) : string => {
513523 let key = ariaNode . role ;
514524 // Yaml has a limit of 1024 characters per key, and we leave some space for role and attributes.
515525 if ( ariaNode . name && ariaNode . name . length <= 900 ) {
@@ -538,41 +548,93 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
538548 if ( ariaNode . selected === true )
539549 key += ` [selected]` ;
540550
541- let inCursorPointer = false ;
542551 if ( ariaNode . ref ) {
543552 key += ` [ref=${ ariaNode . ref } ]` ;
544- if ( renderCursorPointer && hasPointerCursor ( ariaNode ) ) {
545- inCursorPointer = true ;
553+ if ( renderCursorPointer && hasPointerCursor ( ariaNode ) )
546554 key += ' [cursor=pointer]' ;
547- }
548555 }
556+ return key ;
557+ } ;
558+
559+ const arePropsEqual = ( a : AriaNode , b : AriaNode ) : boolean => {
560+ const aKeys = Object . keys ( a . props ) ;
561+ const bKeys = Object . keys ( b . props ) ;
562+ return aKeys . length === bKeys . length && aKeys . every ( k => a . props [ k ] === b . props [ k ] ) ;
563+ } ;
564+
565+ const visit = ( ariaNode : AriaNode , indent : string , renderCursorPointer : boolean , previousNode : AriaNode | undefined ) : { unchanged : boolean } => {
566+ if ( ariaNode . ref )
567+ previousNode = previousByRef . get ( ariaNode . ref ) ;
568+ const linesBefore = lines . length ;
569+
570+ const key = createKey ( ariaNode , renderCursorPointer ) ;
571+ let unchanged = ! ! previousNode && key === createKey ( previousNode , renderCursorPointer ) && arePropsEqual ( ariaNode , previousNode ) ;
549572
550573 const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded ( key ) ;
551574 const hasProps = ! ! Object . keys ( ariaNode . props ) . length ;
575+ const inCursorPointer = renderCursorPointer && ! ! ariaNode . ref && hasPointerCursor ( ariaNode ) ;
576+
552577 if ( ! ariaNode . children . length && ! hasProps ) {
578+ // Leaf node without children.
553579 lines . push ( escapedKey ) ;
554580 } else if ( ariaNode . children . length === 1 && typeof ariaNode . children [ 0 ] === 'string' && ! hasProps ) {
555- const text = includeText ( ariaNode , ariaNode . children [ 0 ] ) ? renderString ( ariaNode . children [ 0 ] as string ) : null ;
581+ // Leaf node with only some text inside.
582+ const shouldInclude = includeText ( ariaNode , ariaNode . children [ 0 ] ) ;
583+ const text = shouldInclude ? renderString ( ariaNode . children [ 0 ] ) : null ;
556584 if ( text )
557585 lines . push ( escapedKey + ': ' + yamlEscapeValueIfNeeded ( text ) ) ;
558586 else
559587 lines . push ( escapedKey ) ;
588+
589+ // Node is unchanged only when previous node also had the same single text child.
590+ unchanged = unchanged && ! ! previousNode &&
591+ previousNode . children . length === 1 && typeof previousNode . children [ 0 ] === 'string' &&
592+ ! Object . keys ( previousNode . props ) . length && ariaNode . children [ 0 ] === previousNode . children [ 0 ] ;
560593 } else {
594+ // Node with (optional) props and some children.
561595 lines . push ( escapedKey + ':' ) ;
562596 for ( const [ name , value ] of Object . entries ( ariaNode . props ) )
563597 lines . push ( indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded ( value ) ) ;
564- for ( const child of ariaNode . children || [ ] )
565- visit ( child , ariaNode , indent + ' ' , renderCursorPointer && ! inCursorPointer ) ;
598+
599+ // All children must be the same.
600+ unchanged = unchanged && previousNode ?. children . length === ariaNode . children . length ;
601+
602+ const childIndent = indent + ' ' ;
603+ for ( let childIndex = 0 ; childIndex < ariaNode . children . length ; childIndex ++ ) {
604+ const child = ariaNode . children [ childIndex ] ;
605+ if ( typeof child === 'string' ) {
606+ const shouldInclude = includeText ( ariaNode , child ) ;
607+ if ( shouldInclude )
608+ visitText ( child , childIndent ) ;
609+ unchanged = unchanged && previousNode ?. children [ childIndex ] === child && shouldInclude === includeText ( previousNode , child ) ;
610+ } else {
611+ const previousChild = previousNode ?. children [ childIndex ] ;
612+ const childResult = visit ( child , childIndent , renderCursorPointer && ! inCursorPointer , typeof previousChild !== 'string' ? previousChild : undefined ) ;
613+ unchanged = unchanged && childResult . unchanged ;
614+ }
615+ }
566616 }
617+
618+ if ( unchanged && ariaNode . ref ) {
619+ // Replace the whole subtree with a single reference.
620+ lines . splice ( linesBefore ) ;
621+ lines . push ( indent + `- ref=${ ariaNode . ref } [unchanged]` ) ;
622+ }
623+
624+ return { unchanged } ;
567625 } ;
568626
569627 const ariaNode = ariaSnapshot . root ;
570628 if ( ariaNode . role === 'fragment' ) {
571629 // Render fragment.
572- for ( const child of ariaNode . children || [ ] )
573- visit ( child , ariaNode , '' , ! ! options . renderCursorPointer ) ;
630+ for ( const child of ariaNode . children || [ ] ) {
631+ if ( typeof child === 'string' )
632+ visitText ( child , '' ) ;
633+ else
634+ visit ( child , '' , ! ! options . renderCursorPointer , undefined ) ;
635+ }
574636 } else {
575- visit ( ariaNode , null , '' , ! ! options . renderCursorPointer ) ;
637+ visit ( ariaNode , '' , ! ! options . renderCursorPointer , undefined ) ;
576638 }
577639 return lines . join ( '\n' ) ;
578640}
0 commit comments