Skip to content
Merged
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
150 changes: 150 additions & 0 deletions packages/react-dom/src/__tests__/ReactDOMFragmentRefs-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ let act;
let container;
let Fragment;
let Activity;
let Scheduler;
let mockIntersectionObserver;
let simulateIntersection;
let setClientRects;
let mockRangeClientRects;
let assertConsoleErrorDev;
let assertConsoleWarnDev;
let assertLog;

function Wrapper({children}) {
return children;
Expand All @@ -38,6 +40,7 @@ describe('FragmentRefs', () => {
ReactDOM = require('react-dom');
createPortal = ReactDOM.createPortal;
act = require('internal-test-utils').act;
Scheduler = require('scheduler');
const IntersectionMocks = require('./utils/IntersectionMocks');
mockIntersectionObserver = IntersectionMocks.mockIntersectionObserver;
simulateIntersection = IntersectionMocks.simulateIntersection;
Expand All @@ -46,6 +49,7 @@ describe('FragmentRefs', () => {
assertConsoleErrorDev =
require('internal-test-utils').assertConsoleErrorDev;
assertConsoleWarnDev = require('internal-test-utils').assertConsoleWarnDev;
assertLog = require('internal-test-utils').assertLog;

container = document.createElement('div');
document.body.innerHTML = '';
Expand Down Expand Up @@ -153,6 +157,152 @@ describe('FragmentRefs', () => {
expect(childD.reactFragments.has(fragmentParentRef.current)).toBe(true);
});

// @gate enableFragmentRefs
it('runs the ref cleanup when an inline ref callback changes identity', async () => {
const fragmentInstances = [];
let rerender;

function Test() {
const [step, setStep] = React.useState(0);
rerender = () => {
setStep(p => p + 1);
};

return (
<Fragment
ref={fragmentInstance => {
fragmentInstances.push(fragmentInstance);
Scheduler.log(`fragment attach ${step}`);
return () => {
Scheduler.log(`fragment cleanup ${step}`);
};
}}>
<div
id="child"
ref={() => {
Scheduler.log(`host attach ${step}`);
return () => {
Scheduler.log(`host cleanup ${step}`);
};
}}
/>
</Fragment>
);
}

const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<Test />));
assertLog(['fragment attach 0', 'host attach 0']);

await act(rerender);
// Both refs are inlined, so both change identity and are detached before
// being re-attached. The Fragment detaches ahead of its children, which is
// the same order it uses when the Fragment itself is deleted.
assertLog([
'fragment cleanup 0',
'host cleanup 0',
'fragment attach 1',
'host attach 1',
]);

await act(() => root.render(null));
// The cleanups created by the final render run on unmount.
assertLog(['fragment cleanup 1', 'host cleanup 1']);

// The same FragmentInstance is handed to every attach, so a callback that
// registers event listeners or observers on it can rely on its cleanup to
// unregister them again.
expect(fragmentInstances).toHaveLength(2);
expect(fragmentInstances[0]).toBe(fragmentInstances[1]);
});

// @gate enableFragmentRefs
it('runs the ref cleanup when the ref is removed from a mounted Fragment', async () => {
function Test({withRef}) {
return (
<Fragment
ref={
withRef
? () => {
Scheduler.log('attach');
return () => {
Scheduler.log('cleanup');
};
}
: null
}>
<div id="child" />
</Fragment>
);
}

const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<Test withRef={true} />));
assertLog(['attach']);

// The Fragment stays mounted and only the ref goes away. commitAttachRef
// bails out on a null ref, so the detach is the only thing that can run the
// cleanup here.
await act(() => root.render(<Test withRef={false} />));
assertLog(['cleanup']);

// Nothing is left to clean up by the time the Fragment is deleted.
await act(() => root.render(null));
assertLog([]);
});

// @gate enableFragmentRefs
it('detaches and reattaches Fragment refs when StrictMode double invokes', async () => {
// This one collects its own log rather than using Scheduler.log, because
// setIsStrictModeForDevtools disables yield values for the duration of the
// double invoke to keep StrictMode tests quiet, which would hide the very
// detach and reattach this test is here to observe.
const logs = [];
let rerender;

function Test() {
const [step, setStep] = React.useState(0);
rerender = () => {
setStep(p => p + 1);
};

return (
<Fragment
ref={() => {
logs.push(`attach ${step}`);
return () => {
logs.push(`cleanup ${step}`);
};
}}>
<div id="child" />
</Fragment>
);
}

const root = ReactDOMClient.createRoot(container);
await act(() =>
root.render(
<React.StrictMode>
<Test />
</React.StrictMode>,
),
);
if (__DEV__) {
// The double invoke goes through disappearLayoutEffects and
// reappearLayoutEffects rather than through the mutation and layout
// phases, so it exercises a separate pair of Fragment cases.
expect(logs).toEqual(['attach 0', 'cleanup 0', 'attach 0']);
} else {
expect(logs).toEqual(['attach 0']);
}

// The double invoke only applies to newly mounted fibers, so an update
// detaches and reattaches once in both environments.
logs.length = 0;
await act(rerender);
expect(logs).toEqual(['cleanup 0', 'attach 1']);
});

describe('focus methods', () => {
describe('focus()', () => {
// @gate enableFragmentRefs
Expand Down
5 changes: 5 additions & 0 deletions packages/react-reconciler/src/ReactFiberCommitWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -2780,6 +2780,11 @@ function commitMutationEffectsOnFiber(
}
case Fragment:
if (enableFragmentRefs) {
if (flags & Ref) {
if (!offscreenSubtreeWasHidden && current !== null) {
safelyDetachRef(current, current.return);
}
}
if (current && current.stateNode !== null) {
updateFragmentInstanceFiber(finishedWork, current.stateNode);
}
Expand Down
Loading