Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
Comment thread
dmytrokirpa marked this conversation as resolved.
"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"
}
Original file line number Diff line number Diff line change
@@ -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(<Avatar name={name} />);
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));
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
Expand Down
Loading