diff --git a/change/@fluentui-react-avatar-43a7ed01-2573-4a4a-9a5b-6b56387b4e86.json b/change/@fluentui-react-avatar-43a7ed01-2573-4a4a-9a5b-6b56387b4e86.json new file mode 100644 index 00000000000000..1646eaf1fe0646 --- /dev/null +++ b/change/@fluentui-react-avatar-43a7ed01-2573-4a4a-9a5b-6b56387b4e86.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "fix(react-avatar): avoid repeated scanning of unmatched name enclosures when generating initials", + "packageName": "@fluentui/react-avatar", + "email": "223556219+Copilot@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/react-components/react-avatar/library/src/utils/getInitials.enclosures.test.tsx b/packages/react-components/react-avatar/library/src/utils/getInitials.enclosures.test.tsx new file mode 100644 index 00000000000000..3a6d2c8a0e164c --- /dev/null +++ b/packages/react-components/react-avatar/library/src/utils/getInitials.enclosures.test.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; +import { performance } from 'node:perf_hooks'; +import { runInNewContext } from 'node:vm'; +import { render, screen } from '@testing-library/react'; +import { Avatar } from '../components/Avatar/Avatar'; +import { getInitials } from './getInitials'; + +describe('Avatar name enclosure cleanup', () => { + it('preserves enclosure, initials, and Unicode behavior', () => { + for (const name of [ + 'Ada Lovelace', + 'Ada (Team) Lovelace', + 'Ada [Team] Lovelace', + 'Ada {Team} Lovelace', + 'Ada [Team) Lovelace', + 'Ada [[Team] Lovelace', + 'Ada [[[ Lovelace', + ]) { + expect(getInitials(name, false)).toBe('AL'); + expect(getInitials(name, true)).toBe('LA'); + expect(getInitials(name, false, { firstInitialOnly: true })).toBe('A'); + } + + expect(getInitials('\u00cdrissa \u00de\u00f3r\u00f0ard\u00f3ttir', false)).toBe('\u00cd\u00de'); + expect(getInitials('\u{20000} [Team]', false)).toBe('\u{20000}'); + expect(getInitials('\u6842\u82f1', false)).toBe(''); + expect(getInitials('\uac15\ud604', false)).toBe(''); + expect(getInitials('\u062e\u0633\u0631\u0648', true)).toBe(''); + expect(getInitials('+1 (555) 123-4567 ext.4567', false)).toBe(''); + }); + + it('derives initials from a public name containing unmatched brackets', () => { + // A Jest timeout alone cannot interrupt synchronous regex execution. + runInNewContext( + 'renderAvatar()', + { + renderAvatar: () => { + const name = `Ada ${'['.repeat(128)} Lovelace`; + render(); + expect(screen.getByText('AL')).toBeTruthy(); + expect(screen.getByRole('img').getAttribute('aria-label')).toBe(name); + }, + }, + { timeout: 1000 }, + ); + }); + + it('avoids superlinear growth when opening brackets have no closing enclosure', () => { + const samples: { + length: number; + squareMs: number; + parenthesisMs: number; + braceMs: number; + balancedMs: number; + nonBracketMs: number; + extendedNameMs: number; + }[] = []; + let ordinaryNameMs = 0; + + const measureName = (name: string, expected: string): number => { + for (let warmup = 0; warmup < 2; warmup++) { + expect(getInitials(name, false)).toBe(expected); + } + + const durations: number[] = []; + for (let sample = 0; sample < 5; sample++) { + let initials = ''; + const start = performance.now(); + for (let call = 0; call < 3; call++) { + initials = getInitials(name, false); + } + durations.push((performance.now() - start) / 3); + expect(initials).toBe(expected); + } + return durations.sort((a, b) => a - b)[2]; + }; + + // These are experiment bounds, not an application name-length policy. + runInNewContext( + 'measure()', + { + measure: () => { + ordinaryNameMs = measureName('Ada Lovelace', 'AL'); + for (const length of [64, 128, 256, 512, 1024, 2048, 4096]) { + samples.push({ + length, + squareMs: measureName('['.repeat(length), ''), + parenthesisMs: measureName('('.repeat(length), ''), + braceMs: measureName('{'.repeat(length), ''), + balancedMs: measureName('['.repeat(length / 2) + ']'.repeat(length / 2), ''), + nonBracketMs: measureName('A'.repeat(length), 'A'), + extendedNameMs: measureName('A'.repeat(length - 9) + ' Lovelace', 'AL'), + }); + } + }, + }, + { timeout: 2500 }, + ); + + console.info( + 'Avatar enclosure measurements (milliseconds per call):', + JSON.stringify({ ordinaryNameMs, sampleCount: 5, callsPerSample: 3, maxNameLength: 4096, samples }), + ); + + const small = samples.find(sample => sample.length === 1024); + const large = samples.find(sample => sample.length === 4096); + if (!small || !large) { + throw new Error('The bounded scaling measurement did not produce both required input lengths.'); + } + + // Allow twice linear growth for a fourfold input increase, with a timer-noise floor. + expect(large.squareMs).toBeLessThanOrEqual(8 * Math.max(small.squareMs, 0.1)); + expect(large.parenthesisMs).toBeLessThanOrEqual(8 * Math.max(small.parenthesisMs, 0.1)); + expect(large.braceMs).toBeLessThanOrEqual(8 * Math.max(small.braceMs, 0.1)); + }); +}); diff --git a/packages/react-components/react-avatar/library/src/utils/getInitials.test.ts b/packages/react-components/react-avatar/library/src/utils/getInitials.test.ts index 473568a882c9e5..abdbeb2fc8eb90 100644 --- a/packages/react-components/react-avatar/library/src/utils/getInitials.test.ts +++ b/packages/react-components/react-avatar/library/src/utils/getInitials.test.ts @@ -60,6 +60,66 @@ describe('getInitials', () => { expect(result).toEqual('DG'); }); + it.each([ + ['(', ')'], + ['(', ']'], + ['(', '}'], + ['[', ')'], + ['[', ']'], + ['[', '}'], + ['{', ')'], + ['{', ']'], + ['{', '}'], + ])('ends an enclosure opened with %s at the first %s', (opening, closing) => { + const name = `${opening}Team ${opening}Inner${closing} Grace${closing} Hopper`; + expect(getInitials(name, false)).toBe('GH'); + expect(getInitials(name, true)).toBe('HG'); + }); + + it.each([ + ['(Team)Ada[Role]Lovelace', 'A'], + ['Ada (Team) []{}(Role) Lovelace', 'AL'], + ['Ada (Team [Inner] Hopper)', 'AH'], + ['[Team {Inner) Grace] Hopper', 'GH'], + ['[Ada [Grace] Hopper', 'H'], + ['Ada (Grace [Hopper', 'AH'], + ['[Team] Ada [Grace Hopper', 'AH'], + ['Ada )Grace] Hopper}', 'AH'], + ['Ada [Team\n[Inner] Hopper]', 'AH'], + ['Ada [Team\u2028Role] Lovelace', 'AL'], + ['Ada [Grace\n', 'AG'], + [' \tAda\u00a0[Team]\u2003Lovelace \n', 'AL'], + ['[Team] \u{20000} \u{20001}', '\u{20000}\u{20001}'], + ['\ud800[Team]\udc00 Lovelace', '\u{10000}L'], + ['[Team] \u6842\u82f1', ''], + ['[Team] \uac15\ud604', ''], + ['[Team] \u062e\u0633\u0631\u0648', ''], + ])('preserves initials and direction after cleaning %s', (name, expected) => { + expect(getInitials(name, false)).toBe(expected); + expect(getInitials(name, true)).toBe([...expected].reverse().join('')); + expect(getInitials(name, false, { firstInitialOnly: true })).toBe([...expected][0] ?? ''); + expect(getInitials(name, true, { firstInitialOnly: true })).toBe([...expected][0] ?? ''); + }); + + it('matches the original enclosure semantics for all short delimiter combinations', () => { + const tokens = ['(', '[', '{', ')', ']', '}', 'A', 'B', ' ']; + const compare = (name: string, remaining: number): void => { + // Bound the original regex to at most four characters, then remove leftover delimiters + // so the reference initials do not depend on the new enclosure implementation. + const cleanedName = name.replace(/[\(\[\{][^\)\]\}]*[\)\]\}]/g, '').replace(/[\(\)\[\]\{\}]/g, ''); + expect(getInitials(name, false)).toBe(getInitials(cleanedName, false)); + expect(getInitials(name, true)).toBe(getInitials(cleanedName, true)); + + if (remaining > 0) { + for (const token of tokens) { + compare(name + token, remaining - 1); + } + } + }; + + compare('', 4); + }); + it('calculates an expected initials in RTL if one was not specified', () => { const result = getInitials('Kat Larrson', true); expect(result).toEqual('LK'); diff --git a/packages/react-components/react-avatar/library/src/utils/getInitials.ts b/packages/react-components/react-avatar/library/src/utils/getInitials.ts index 54e1c955e3a1fa..7add4ae7865824 100644 --- a/packages/react-components/react-avatar/library/src/utils/getInitials.ts +++ b/packages/react-components/react-avatar/library/src/utils/getInitials.ts @@ -2,12 +2,6 @@ * Regular expressions matching characters to ignore when calculating the initials. */ -/** - * Regular expression matching characters within various types of enclosures, including the enclosures themselves - * so for example, (xyz) [xyz] {xyz} all would be ignored - */ -const UNWANTED_ENCLOSURES_REGEX: RegExp = /[\(\[\{][^\)\]\}]*[\)\]\}]/g; - /** * Regular expression matching special ASCII characters except space, plus some unicode special characters. * Applies after unwanted enclosures have been removed. @@ -72,8 +66,45 @@ function getInitialsLatin(displayName: string, isRtl: boolean, firstInitialOnly? return initials; } +/** + * Removes each span from the first opening delimiter through the next closing delimiter, + * regardless of nesting or delimiter type. Unterminated spans are retained. + */ +function removeEnclosures(displayName: string): string { + let openingIndex = -1; + let segmentStart = 0; + let segments: string[] | undefined; + + for (let index = 0; index < displayName.length; index++) { + const charCode = displayName.charCodeAt(index); + if (openingIndex === -1) { + // (, [, { + if (charCode === 40 || charCode === 91 || charCode === 123) { + openingIndex = index; + } + } else if (charCode === 41 || charCode === 93 || charCode === 125) { + // ), ], } + segments ??= []; + if (segmentStart < openingIndex) { + segments.push(displayName.slice(segmentStart, openingIndex)); + } + segmentStart = index + 1; + openingIndex = -1; + } + } + + if (!segments) { + return displayName; + } + + if (segmentStart < displayName.length) { + segments.push(displayName.slice(segmentStart)); + } + return segments.join(''); +} + function cleanupDisplayName(displayName: string): string { - displayName = displayName.replace(UNWANTED_ENCLOSURES_REGEX, ''); + displayName = removeEnclosures(displayName); displayName = displayName.replace(UNWANTED_CHARS_REGEX, ''); displayName = displayName.replace(MULTIPLE_WHITESPACES_REGEX, ' '); displayName = displayName.trim();