1414 * limitations under the License.
1515 */
1616
17+ import { ariaPropsEqual } from '@isomorphic/ariaSnapshot' ;
1718import { escapeRegExp , longestCommonSubstring , normalizeWhiteSpace } from '@isomorphic/stringUtils' ;
1819
1920import { computeBox , getElementComputedStyle , isElementVisible } from './domUtils' ;
@@ -23,6 +24,7 @@ import { yamlEscapeKeyIfNeeded, yamlEscapeValueIfNeeded } from './yaml';
2324import type { AriaProps , AriaRegex , AriaTextValue , AriaRole , AriaTemplateNode } from '@isomorphic/ariaSnapshot' ;
2425import type { Box } from './domUtils' ;
2526
27+ // Note: please keep in sync with ariaNodesEqual() below.
2628export type AriaNode = AriaProps & {
2729 role : AriaRole | 'fragment' | 'iframe' ;
2830 name : string ;
@@ -34,6 +36,16 @@ export type AriaNode = AriaProps & {
3436 props : Record < string , string > ;
3537} ;
3638
39+ function ariaNodesEqual ( a : AriaNode , b : AriaNode ) : boolean {
40+ if ( a . role !== b . role || a . name !== b . name )
41+ return false ;
42+ if ( ! ariaPropsEqual ( a , b ) || hasPointerCursor ( a ) !== hasPointerCursor ( b ) )
43+ return false ;
44+ const aKeys = Object . keys ( a . props ) ;
45+ const bKeys = Object . keys ( b . props ) ;
46+ return aKeys . length === bKeys . length && aKeys . every ( k => a . props [ k ] === b . props [ k ] ) ;
47+ }
48+
3749export type AriaSnapshot = {
3850 root : AriaNode ;
3951 elements : Map < string , Element > ;
@@ -495,7 +507,7 @@ function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode, collectAll:
495507 return results ;
496508}
497509
498- function buildByRefMap ( root : AriaNode | undefined , map : Map < string , AriaNode > = new Map ( ) ) : Map < string , AriaNode > {
510+ function buildByRefMap ( root : AriaNode | undefined , map : Map < string | undefined , AriaNode > = new Map ( ) ) : Map < string | undefined , AriaNode > {
499511 if ( root ?. ref )
500512 map . set ( root . ref , root ) ;
501513 for ( const child of root ?. children || [ ] ) {
@@ -505,27 +517,44 @@ function buildByRefMap(root: AriaNode | undefined, map: Map<string, AriaNode> =
505517 return map ;
506518}
507519
508- function hasIframeNodes ( root : AriaNode ) : boolean {
509- if ( root . role === 'iframe' )
510- return true ;
511- return ( root . children || [ ] ) . some ( child => typeof child !== 'string' && hasIframeNodes ( child ) ) ;
512- }
520+ function compareSnapshots ( ariaSnapshot : AriaSnapshot , previousSnapshot : AriaSnapshot | undefined ) : Map < AriaNode , 'skip' | 'same' | 'changed' > {
521+ const previousByRef = buildByRefMap ( previousSnapshot ?. root ) ;
522+ const result = new Map < AriaNode , 'same' | 'changed' > ( ) ;
513523
514- function arePropsEqual ( a : AriaNode , b : AriaNode ) : boolean {
515- const aKeys = Object . keys ( a . props ) ;
516- const bKeys = Object . keys ( b . props ) ;
517- return aKeys . length === bKeys . length && aKeys . every ( k => a . props [ k ] === b . props [ k ] ) ;
518- }
524+ // Returns whether ariaNode is the same as previousNode.
525+ const visit = ( ariaNode : AriaNode , previousNode : AriaNode | undefined ) : boolean => {
526+ let same : boolean = ariaNode . children . length === previousNode ?. children . length && ariaNodesEqual ( ariaNode , previousNode ) ;
527+ if ( ariaNode . role === 'iframe' )
528+ same = false ;
519529
520- export function renderAriaTree ( ariaSnapshot : AriaSnapshot , publicOptions : AriaTreeOptions , previous ?: AriaSnapshot ) : string {
521- if ( hasIframeNodes ( ariaSnapshot . root ) )
522- previous = undefined ;
530+ for ( let childIndex = 0 ; childIndex < ariaNode . children . length ; childIndex ++ ) {
531+ const child = ariaNode . children [ childIndex ] ;
532+ const previousChild = previousNode ?. children [ childIndex ] ;
533+ if ( typeof child === 'string' ) {
534+ same &&= child === previousChild ;
535+ } else {
536+ let previous = typeof previousChild !== 'string' ? previousChild : undefined ;
537+ if ( child . ref )
538+ previous = previousByRef . get ( child . ref ) ;
539+ const sameChild = visit ( child , previous ) ;
540+ same &&= ( sameChild && previous === previousChild ) ;
541+ }
542+ }
523543
544+ result . set ( ariaNode , same ? 'same' : 'changed' ) ;
545+ return same ;
546+ } ;
547+
548+ visit ( ariaSnapshot . root , previousByRef . get ( previousSnapshot ?. root ?. ref ) ) ;
549+ return result ;
550+ }
551+
552+ export function renderAriaTree ( ariaSnapshot : AriaSnapshot , publicOptions : AriaTreeOptions , previousSnapshot ?: AriaSnapshot ) : string {
524553 const options = toInternalOptions ( publicOptions ) ;
525554 const lines : string [ ] = [ ] ;
526555 const includeText = options . renderStringsAsRegex ? textContributesInfo : ( ) => true ;
527556 const renderString = options . renderStringsAsRegex ? convertToBestGuessRegex : ( str : string ) => str ;
528- const previousByRef = buildByRefMap ( previous ?. root ) ;
557+ const statusMap = compareSnapshots ( ariaSnapshot , previousSnapshot ) ;
529558
530559 const visitText = ( text : string , indent : string ) => {
531560 const escaped = yamlEscapeValueIfNeeded ( renderString ( text ) ) ;
@@ -574,27 +603,23 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
574603 return ariaNode ?. children . length === 1 && typeof ariaNode . children [ 0 ] === 'string' && ! Object . keys ( ariaNode . props ) . length ? ariaNode . children [ 0 ] : undefined ;
575604 } ;
576605
577- const visit = ( ariaNode : AriaNode , indent : string , renderCursorPointer : boolean , previousNode : AriaNode | undefined ) : { unchanged : boolean } => {
578- if ( ariaNode . ref )
579- previousNode = previousByRef . get ( ariaNode . ref ) ;
606+ const visit = ( ariaNode : AriaNode , indent : string , renderCursorPointer : boolean ) => {
607+ const status = statusMap . get ( ariaNode ) ;
580608
581- const linesBefore = lines . length ;
582- const key = createKey ( ariaNode , renderCursorPointer ) ;
583- const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded ( key ) ;
584- const inCursorPointer = renderCursorPointer && ! ! ariaNode . ref && hasPointerCursor ( ariaNode ) ;
585- const singleInlinedTextChild = getSingleInlinedTextChild ( ariaNode ) ;
609+ // Replace the whole subtree with a single reference when possible.
610+ if ( status === 'same' && ariaNode . ref ) {
611+ lines . push ( indent + `- ref= ${ ariaNode . ref } [unchanged]` ) ;
612+ return ;
613+ }
586614
587- // Whether ariaNode's subtree is the same as previousNode's, and can be replaced with just a ref.
588- let unchanged = ! ! previousNode && key === createKey ( previousNode , renderCursorPointer ) && arePropsEqual ( ariaNode , previousNode ) ;
615+ const escapedKey = indent + '- ' + yamlEscapeKeyIfNeeded ( createKey ( ariaNode , renderCursorPointer ) ) ;
616+ const singleInlinedTextChild = getSingleInlinedTextChild ( ariaNode ) ;
589617
590618 if ( ! ariaNode . children . length && ! Object . keys ( ariaNode . props ) . length ) {
591619 // Leaf node without children.
592620 lines . push ( escapedKey ) ;
593621 } else if ( singleInlinedTextChild !== undefined ) {
594622 // Leaf node with just some text inside.
595- // Unchanged when the previous node also had the same single text child.
596- unchanged = unchanged && getSingleInlinedTextChild ( previousNode ) === singleInlinedTextChild ;
597-
598623 const shouldInclude = includeText ( ariaNode , singleInlinedTextChild ) ;
599624 if ( shouldInclude )
600625 lines . push ( escapedKey + ': ' + yamlEscapeValueIfNeeded ( renderString ( singleInlinedTextChild ) ) ) ;
@@ -605,32 +630,18 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
605630 lines . push ( escapedKey + ':' ) ;
606631 for ( const [ name , value ] of Object . entries ( ariaNode . props ) )
607632 lines . push ( indent + ' - /' + name + ': ' + yamlEscapeValueIfNeeded ( value ) ) ;
608-
609- // All children must be the same.
610- unchanged = unchanged && previousNode ?. children . length === ariaNode . children . length ;
611-
612- const childIndent = indent + ' ' ;
613- for ( let childIndex = 0 ; childIndex < ariaNode . children . length ; childIndex ++ ) {
614- const child = ariaNode . children [ childIndex ] ;
615- if ( typeof child === 'string' ) {
616- unchanged = unchanged && previousNode ?. children [ childIndex ] === child ;
617- if ( includeText ( ariaNode , child ) )
618- visitText ( child , childIndent ) ;
619- } else {
620- const previousChild = previousNode ?. children [ childIndex ] ;
621- const childResult = visit ( child , childIndent , renderCursorPointer && ! inCursorPointer , typeof previousChild !== 'string' ? previousChild : undefined ) ;
622- unchanged = unchanged && childResult . unchanged ;
623- }
624- }
625633 }
626634
627- if ( unchanged && ariaNode . ref ) {
628- // Replace the whole subtree with a single reference.
629- lines . splice ( linesBefore ) ;
630- lines . push ( indent + `- ref=${ ariaNode . ref } [unchanged]` ) ;
635+ indent += ' ' ;
636+ if ( singleInlinedTextChild === undefined ) {
637+ const inCursorPointer = ! ! ariaNode . ref && renderCursorPointer && hasPointerCursor ( ariaNode ) ;
638+ for ( const child of ariaNode . children ) {
639+ if ( typeof child === 'string' )
640+ visitText ( includeText ( ariaNode , child ) ? child : '' , indent ) ;
641+ else
642+ visit ( child , indent , renderCursorPointer && ! inCursorPointer ) ;
643+ }
631644 }
632-
633- return { unchanged } ;
634645 } ;
635646
636647 // Do not render the root fragment, just its children.
@@ -639,7 +650,7 @@ export function renderAriaTree(ariaSnapshot: AriaSnapshot, publicOptions: AriaTr
639650 if ( typeof nodeToRender === 'string' )
640651 visitText ( nodeToRender , '' ) ;
641652 else
642- visit ( nodeToRender , '' , ! ! options . renderCursorPointer , undefined ) ;
653+ visit ( nodeToRender , '' , ! ! options . renderCursorPointer ) ;
643654 }
644655 return lines . join ( '\n' ) ;
645656}
0 commit comments