@@ -495,21 +495,36 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
495495 return results ;
496496}
497497
498- export function renderAriaTree ( ariaSnapshot : AriaSnapshot , publicOptions : AriaTreeOptions ) : string {
498+ function buildByRefMap ( root : AriaNode | undefined , map : Map < string , AriaNode > = new Map ( ) ) : Map < string , AriaNode > {
499+ if ( root ?. ref )
500+ map . set ( root . ref , root ) ;
501+ for ( const child of root ?. children || [ ] ) {
502+ if ( typeof child !== 'string' )
503+ buildByRefMap ( child , map ) ;
504+ }
505+ return map ;
506+ }
507+
508+ function arePropsEqual ( a : AriaNode , b : AriaNode ) : boolean {
509+ const aKeys = Object . keys ( a . props ) ;
510+ const bKeys = Object . keys ( b . props ) ;
511+ return aKeys . length === bKeys . length && aKeys . every ( k => a . props [ k ] === b . props [ k ] ) ;
512+ }
513+
514+ export function renderAriaTree ( ariaSnapshot : AriaSnapshot , publicOptions : AriaTreeOptions , previous ?: AriaSnapshot ) : string {
499515 const options = toInternalOptions ( publicOptions ) ;
500516 const lines : string [ ] = [ ] ;
501517 const includeText = options . renderStringsAsRegex ? textContributesInfo : ( ) => true ;
502518 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 ;
511- }
519+ const previousByRef = buildByRefMap ( previous ?. root ) ;
512520
521+ const visitText = ( text : string , indent : string ) => {
522+ const escaped = yamlEscapeValueIfNeeded ( renderString ( text ) ) ;
523+ if ( escaped )
524+ lines . push ( indent + '- text: ' + escaped ) ;
525+ } ;
526+
527+ const createKey = ( ariaNode : AriaNode , renderCursorPointer : boolean ) : string => {
513528 let key = ariaNode . role ;
514529 // Yaml has a limit of 1024 characters per key, and we leave some space for role and attributes.
515530 if ( ariaNode . name && ariaNode . name . length <= 900 ) {
@@ -538,41 +553,84 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
538553 if ( ariaNode . selected === true )
539554 key += ` [selected]` ;
540555
541- let inCursorPointer = false ;
542556 if ( ariaNode . ref ) {
543557 key += ` [ref=${ ariaNode . ref } ]` ;
544- if ( renderCursorPointer && hasPointerCursor ( ariaNode ) ) {
545- inCursorPointer = true ;
558+ if ( renderCursorPointer && hasPointerCursor ( ariaNode ) )
546559 key += ' [cursor=pointer]' ;
547- }
548560 }
561+ return key ;
562+ } ;
563+
564+ const getSingleInlinedTextChild = ( ariaNode : AriaNode | undefined ) : string | undefined => {
565+ return ariaNode ?. children . length === 1 && typeof ariaNode . children [ 0 ] === 'string' && ! Object . keys ( ariaNode . props ) . length ? ariaNode . children [ 0 ] : undefined ;
566+ } ;
549567
568+ const visit = ( ariaNode : AriaNode , indent : string , renderCursorPointer : boolean , previousNode : AriaNode | undefined ) : { unchanged : boolean } => {
569+ if ( ariaNode . ref )
570+ previousNode = previousByRef . get ( ariaNode . ref ) ;
571+
572+ const linesBefore = lines . length ;
573+ const key = createKey ( ariaNode , renderCursorPointer ) ;
550574 const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded ( key ) ;
551- const hasProps = ! ! Object . keys ( ariaNode . props ) . length ;
552- if ( ! ariaNode . children . length && ! hasProps ) {
575+ const inCursorPointer = renderCursorPointer && ! ! ariaNode . ref && hasPointerCursor ( ariaNode ) ;
576+ const singleInlinedTextChild = getSingleInlinedTextChild ( ariaNode ) ;
577+
578+ // Whether ariaNode's subtree is the same as previousNode's, and can be replaced with just a ref.
579+ let unchanged = ! ! previousNode && key === createKey ( previousNode , renderCursorPointer ) && arePropsEqual ( ariaNode , previousNode ) ;
580+
581+ if ( ! ariaNode . children . length && ! Object . keys ( ariaNode . props ) . length ) {
582+ // Leaf node without children.
553583 lines . push ( escapedKey ) ;
554- } 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 ;
556- if ( text )
557- lines . push ( escapedKey + ': ' + yamlEscapeValueIfNeeded ( text ) ) ;
584+ } else if ( singleInlinedTextChild !== undefined ) {
585+ // Leaf node with just some text inside.
586+ // Unchanged when the previous node also had the same single text child.
587+ unchanged = unchanged && getSingleInlinedTextChild ( previousNode ) === singleInlinedTextChild ;
588+
589+ const shouldInclude = includeText ( ariaNode , singleInlinedTextChild ) ;
590+ if ( shouldInclude )
591+ lines . push ( escapedKey + ': ' + yamlEscapeValueIfNeeded ( renderString ( singleInlinedTextChild ) ) ) ;
558592 else
559593 lines . push ( escapedKey ) ;
560594 } else {
595+ // Node with (optional) props and some children.
561596 lines . push ( escapedKey + ':' ) ;
562597 for ( const [ name , value ] of Object . entries ( ariaNode . props ) )
563598 lines . push ( indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded ( value ) ) ;
564- for ( const child of ariaNode . children || [ ] )
565- visit ( child , ariaNode , indent + ' ' , renderCursorPointer && ! inCursorPointer ) ;
599+
600+ // All children must be the same.
601+ unchanged = unchanged && previousNode ?. children . length === ariaNode . children . length ;
602+
603+ const childIndent = indent + ' ' ;
604+ for ( let childIndex = 0 ; childIndex < ariaNode . children . length ; childIndex ++ ) {
605+ const child = ariaNode . children [ childIndex ] ;
606+ if ( typeof child === 'string' ) {
607+ unchanged = unchanged && previousNode ?. children [ childIndex ] === child ;
608+ if ( includeText ( ariaNode , child ) )
609+ visitText ( child , childIndent ) ;
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
569- const ariaNode = ariaSnapshot . root ;
570- if ( ariaNode . role === 'fragment' ) {
571- // Render fragment.
572- for ( const child of ariaNode . children || [ ] )
573- visit ( child , ariaNode , '' , ! ! options . renderCursorPointer ) ;
574- } else {
575- visit ( ariaNode , null , '' , ! ! options . renderCursorPointer ) ;
627+ // Do not render the root fragment, just its children.
628+ const nodesToRender = ariaSnapshot . root . role === 'fragment' ? ariaSnapshot . root . children : [ ariaSnapshot . root ] ;
629+ for ( const nodeToRender of nodesToRender ) {
630+ if ( typeof nodeToRender === 'string' )
631+ visitText ( nodeToRender , '' ) ;
632+ else
633+ visit ( nodeToRender , '' , ! ! options . renderCursorPointer , undefined ) ;
576634 }
577635 return lines . join ( '\n' ) ;
578636}
@@ -636,5 +694,5 @@ function textContributesInfo(node: AriaNode, text: string): boolean {
636694}
637695
638696function hasPointerCursor ( ariaNode : AriaNode ) : boolean {
639- return ariaNode . box . style ?. cursor === 'pointer' ;
697+ return ariaNode . box . cursor === 'pointer' ;
640698}
0 commit comments