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 @@
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Positioning 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 616 Changed
vr-tests-react-components/ProgressBar converged 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - High Contrast.default.chromium.png 50 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 34 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 160 Changed
vr-tests-react-components/TagPicker 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - High Contrast.chromium.png 1319 Changed
vr-tests-react-components/TagPicker.disabled - RTL.chromium.png 635 Changed
vr-tests-react-components/TagPicker.disabled.chromium.png 677 Changed

There were 3 duplicate changes discarded. Check the build logs for more information.

"type": "patch",
"comment": "fix: cancel the aside-width animation frame from the observer ref's detach path instead of a passive-effect cleanup, so the frame's lifecycle is tied to the observation it belongs to",
"packageName": "@fluentui/react-tag-picker",
"email": "array.knight@gmail.com",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// @ts-check

/** @type {import('@fluentui/react-integration-tester').Config} */
const config = {
react: {
18: {
runConfig: {
test: {
// Include the StrictMode regression in the React 18 integration suite.
configPath: 'jest.config.cjs',
},
},
},
},
};

module.exports = config;
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { render } from '@testing-library/react';
import { act, render } from '@testing-library/react';
import { isConformant } from '../../testing/isConformant';
import { TagPickerControl } from './TagPickerControl';

Expand All @@ -16,4 +16,110 @@ describe('TagPickerControl', () => {
const result = render(<TagPickerControl>Default PickerControl</TagPickerControl>);
expect(result.container).toMatchSnapshot();
});

describe('the aside width custom property', () => {
// useTagPickerControl schedules the write of --fui-TagPickerControl-aside-width from the
// ResizeObserver callback, and cancels that frame when the observer's callback ref
// receives null (element unmount), alongside ResizeObserver.disconnect() -- not from a
// passive effect's cleanup. That keeps the frame's lifecycle tied to the same event that
// owns it (the ref attach/detach that starts and stops the observation) instead of an
// effect whose cleanup timing is independent of it. A ResizeObserver whose `observe()`
// invokes its callback synchronously reproduces, deterministically, a frame already being
// in flight by the time the ref detaches -- without depending on real async timing, which
// jsdom cannot reproduce.
const realRaf = window.requestAnimationFrame;
const realCaf = window.cancelAnimationFrame;
const realResizeObserver = window.ResizeObserver;

const ASIDE_WIDTH = 18;
let frames: { id: number; callback: FrameRequestCallback }[] = [];
let cancelledIds: number[] = [];

function flushFrames() {
const queuedFrames = frames;
frames = [];
for (const { id, callback } of queuedFrames) {
if (!cancelledIds.includes(id)) {
callback(0);
}
}
}

beforeEach(() => {
frames = [];
cancelledIds = [];
// Zero is a valid animation-frame handle, not the absence of a pending frame.
let nextId = 0;
window.requestAnimationFrame = (callback: FrameRequestCallback) => {
const id = nextId++;
frames.push({ id, callback });
return id;
};
window.cancelAnimationFrame = (id: number) => {
cancelledIds.push(id);
};
window.ResizeObserver = class implements ResizeObserver {
constructor(private callback: ResizeObserverCallback) {}
public observe(element: Element) {
this.callback([{ target: element, contentRect: { width: ASIDE_WIDTH } }] as never, this);
}
public unobserve() {
/* no-op */
}
public disconnect() {
/* no-op */
}
};
});

afterEach(() => {
window.requestAnimationFrame = realRaf;
window.cancelAnimationFrame = realCaf;
window.ResizeObserver = realResizeObserver;
});

it('does not cancel the pending frame on mount, so the property is written', () => {
const result = render(<TagPickerControl>Default PickerControl</TagPickerControl>);

expect(frames).toHaveLength(1);
expect(cancelledIds).not.toContain(frames[0].id);

act(flushFrames);

const control = result.container.querySelector('.fui-TagPickerControl') as HTMLElement;
expect(control.style.getPropertyValue('--fui-TagPickerControl-aside-width')).toBe(`${ASIDE_WIDTH}px`);
});

it('cancels a still-pending frame on unmount', () => {
const result = render(<TagPickerControl>Default PickerControl</TagPickerControl>);

expect(frames).toHaveLength(1);

// Snapshot before unmounting: a cancel that already happened on mount would make the
// assertion below pass vacuously, which is exactly what the defective form did.
const cancelledBeforeUnmount = [...cancelledIds];
result.unmount();

expect(cancelledBeforeUnmount).not.toContain(frames[0].id);
expect(cancelledIds).toContain(frames[0].id);
});

it('writes the property after mount inside React.StrictMode', () => {
// Regression test for https://github.com/microsoft/fluentui/pull/36667#discussion_r3925809333:
// React 18 replays effects without replaying callback refs. Effect cleanup would cancel
// the initial frame with no ref reattachment to schedule a replacement. React 19 also
// replays refs, so run this with the React 18 integration target as well as the default
// tests. Only uncancelled frames may run: executing cancelled callbacks hides the bug.
const result = render(
<React.StrictMode>
<TagPickerControl>Default PickerControl</TagPickerControl>
</React.StrictMode>,
);

act(flushFrames);

const control = result.container.querySelector('.fui-TagPickerControl') as HTMLElement;
expect(control.style.getPropertyValue('--fui-TagPickerControl-aside-width')).toBe(`${ASIDE_WIDTH}px`);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,23 @@ export const useTagPickerControlBase_unstable = (
expandIcon.ref = expandIconMergeRef;
}

const handleAsideDetach = useEventCallback(() => {
if (rafIdRef.current !== null && targetDocument?.defaultView) {
targetDocument.defaultView.cancelAnimationFrame(rafIdRef.current);
}
rafIdRef.current = null;
});

const observerRef = useResizeObserverRef<HTMLSpanElement>(([entry]) => {
const targetWindow = targetDocument?.defaultView;

if (targetWindow) {
rafIdRef.current = targetWindow.requestAnimationFrame(() => {
innerRef.current?.style.setProperty(tagPickerControlAsideWidthToken, `${entry.contentRect.width}px`);
rafIdRef.current = null;
});
}
});
}, handleAsideDetach);
const aside = slot.optional<ExtractSlotProps<Slot<'span'>>>(undefined, {
elementType: 'span',
renderByDefault: Boolean(secondaryAction || expandIcon),
Expand Down Expand Up @@ -141,12 +149,6 @@ export const useTagPickerControlBase_unstable = (
state.expandIcon.ref = expandIconLabelMergeRef;
}

React.useEffect(() => {
if (rafIdRef.current && targetDocument?.defaultView) {
targetDocument.defaultView.cancelAnimationFrame(rafIdRef.current);
}
}, [targetDocument]);

return state;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,19 @@
import * as React from 'react';
import { useFluent_unstable as useFluent } from '@fluentui/react-shared-contexts';

export const useResizeObserverRef = <E extends HTMLElement>(callback: ResizeObserverCallback): React.Ref<E> => {
/**
* @param callback - invoked by the ResizeObserver with the observed entries.
* @param onDetach - invoked when the ref is detached (element unmounts or is swapped for
* null), immediately before `disconnect()`. Use it to tear down anything the observer
* callback scheduled (e.g. a pending `requestAnimationFrame`) so it shares the observer's
* own attach/detach lifecycle instead of an unrelated effect's. Must be a stable reference
* (e.g. via `useEventCallback`) -- it participates in the ref callback's memoization, so an
* identity that changes every render would detach and reattach the observer every render.
*/
export const useResizeObserverRef = <E extends HTMLElement>(
callback: ResizeObserverCallback,
onDetach?: () => void,
): React.Ref<E> => {
const { targetDocument } = useFluent();
const [observer] = React.useState(() => {
const ResizeObserverConstructor = targetDocument?.defaultView?.ResizeObserver;
Expand All @@ -16,10 +28,11 @@ export const useResizeObserverRef = <E extends HTMLElement>(callback: ResizeObse
if (element) {
observer?.observe(element);
} else {
onDetach?.();
observer?.disconnect();
}
},
[observer],
[observer, onDetach],
);
return ref;
};