Compare commits

...

2 Commits

Author SHA1 Message Date
Samuel Susla
a0213dee8a Move offscreen action attachment to separate function 2022-09-11 10:17:04 +01:00
Samuel Susla
eb6dc4d43f Add ref to Offscreen component 2022-09-11 09:56:54 +01:00
13 changed files with 567 additions and 12 deletions

View File

@@ -723,7 +723,11 @@ export function createFiberFromOffscreen(
pendingMarkers: null,
retryCache: null,
transitions: null,
detach: () => {},
attach: () => {},
_isDetached: false,
};
fiber.stateNode = primaryChildInstance;
return fiber;
}
@@ -744,6 +748,13 @@ export function createFiberFromLegacyHidden(
pendingMarkers: null,
transitions: null,
retryCache: null,
detach: () => {
// noop
},
attach: () => {
// noop
},
_isDetached: false,
};
fiber.stateNode = instance;
return fiber;

View File

@@ -723,7 +723,11 @@ export function createFiberFromOffscreen(
pendingMarkers: null,
retryCache: null,
transitions: null,
detach: () => {},
attach: () => {},
_isDetached: false,
};
fiber.stateNode = primaryChildInstance;
return fiber;
}
@@ -744,6 +748,13 @@ export function createFiberFromLegacyHidden(
pendingMarkers: null,
transitions: null,
retryCache: null,
detach: () => {
// noop
},
attach: () => {
// noop
},
_isDetached: false,
};
fiber.stateNode = instance;
return fiber;

View File

@@ -677,9 +677,13 @@ function updateOffscreenComponent(
const prevState: OffscreenState | null =
current !== null ? current.memoizedState : null;
markRef(current, workInProgress);
if (
nextProps.mode === 'hidden' ||
(enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding')
(enableLegacyHidden &&
nextProps.mode === 'unstable-defer-without-hiding') ||
workInProgress.stateNode._isDetached
) {
// Rendering a hidden tree.

View File

@@ -677,9 +677,13 @@ function updateOffscreenComponent(
const prevState: OffscreenState | null =
current !== null ? current.memoizedState : null;
markRef(current, workInProgress);
if (
nextProps.mode === 'hidden' ||
(enableLegacyHidden && nextProps.mode === 'unstable-defer-without-hiding')
(enableLegacyHidden &&
nextProps.mode === 'unstable-defer-without-hiding') ||
workInProgress.stateNode._isDetached
) {
// Rendering a hidden tree.

View File

@@ -18,6 +18,7 @@ import type {
import type {Fiber} from './ReactInternalTypes';
import type {FiberRoot} from './ReactInternalTypes';
import type {Lanes} from './ReactFiberLane.new';
import {NoLanes} from './ReactFiberLane.new';
import type {SuspenseState} from './ReactFiberSuspenseComponent.new';
import type {UpdateQueue} from './ReactFiberClassUpdateQueue.new';
import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.new';
@@ -29,6 +30,7 @@ import type {
} from './ReactFiberOffscreenComponent';
import type {HookFlags} from './ReactHookEffectTags';
import type {Cache} from './ReactFiberCacheComponent.new';
import {scheduleMicrotask} from './ReactFiberHostConfig';
import type {RootState} from './ReactFiberRoot.new';
import type {
Transition,
@@ -154,6 +156,7 @@ import {
setIsRunningInsertionEffect,
getExecutionContext,
CommitContext,
RenderContext,
NoContext,
} from './ReactFiberWorkLoop.new';
import {
@@ -1078,7 +1081,9 @@ function commitLayoutEffectOnFiber(
case OffscreenComponent: {
const isModernRoot = (finishedWork.mode & ConcurrentMode) !== NoMode;
if (isModernRoot) {
const isHidden = finishedWork.memoizedState !== null;
const isHidden =
finishedWork.memoizedState !== null ||
finishedWork.stateNode._isDetached;
const newOffscreenSubtreeIsHidden =
isHidden || offscreenSubtreeIsHidden;
if (newOffscreenSubtreeIsHidden) {
@@ -1116,6 +1121,14 @@ function commitLayoutEffectOnFiber(
offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
}
if (finishedWork.pendingProps.mode === null) {
if (flags & Ref) {
safelyAttachRef(finishedWork, finishedWork.return);
}
} else if (finishedWork.pendingProps.mode !== undefined) {
safelyDetachRef(finishedWork, finishedWork.return);
}
} else {
recursivelyTraverseLayoutEffects(
finishedRoot,
@@ -2247,6 +2260,30 @@ function getRetryCache(finishedWork) {
}
}
function attachOffscreenActions(offscreenFiber: Fiber, root: FiberRoot) {
offscreenFiber.stateNode.detach = () => {
const executionContext = getExecutionContext();
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
scheduleMicrotask(() => {
offscreenFiber.stateNode._isDetached = true;
disappearLayoutEffects(offscreenFiber);
disconnectPassiveEffect(offscreenFiber);
});
} else {
offscreenFiber.stateNode._isDetached = true;
disappearLayoutEffects(offscreenFiber);
disconnectPassiveEffect(offscreenFiber);
}
};
offscreenFiber.stateNode.attach = () => {
// TODO: does not handle when attach is called from effect or when tree is rendered.
offscreenFiber.stateNode._isDetached = false;
reappearLayoutEffects(root, null, offscreenFiber, false);
reconnectPassiveEffects(root, offscreenFiber, NoLanes, null, false);
};
}
function attachSuspenseRetryListeners(
finishedWork: Fiber,
wakeables: Set<Wakeable>,
@@ -2625,6 +2662,7 @@ function commitMutationEffectsOnFiber(
}
commitReconciliationEffects(finishedWork);
attachOffscreenActions(finishedWork, root);
if (flags & Visibility) {
const offscreenInstance: OffscreenInstance = finishedWork.stateNode;
@@ -2651,7 +2689,7 @@ function commitMutationEffectsOnFiber(
}
}
if (supportsMutation) {
if (supportsMutation && offscreenInstance._isDetached !== true) {
// TODO: This needs to run whenever there's an insertion or update
// inside a hidden Offscreen tree.
hideOrUnhideAllChildren(offscreenBoundary, isHidden);

View File

@@ -18,6 +18,7 @@ import type {
import type {Fiber} from './ReactInternalTypes';
import type {FiberRoot} from './ReactInternalTypes';
import type {Lanes} from './ReactFiberLane.old';
import {NoLanes} from './ReactFiberLane.old';
import type {SuspenseState} from './ReactFiberSuspenseComponent.old';
import type {UpdateQueue} from './ReactFiberClassUpdateQueue.old';
import type {FunctionComponentUpdateQueue} from './ReactFiberHooks.old';
@@ -29,6 +30,7 @@ import type {
} from './ReactFiberOffscreenComponent';
import type {HookFlags} from './ReactHookEffectTags';
import type {Cache} from './ReactFiberCacheComponent.old';
import {scheduleMicrotask} from './ReactFiberHostConfig';
import type {RootState} from './ReactFiberRoot.old';
import type {
Transition,
@@ -154,6 +156,7 @@ import {
setIsRunningInsertionEffect,
getExecutionContext,
CommitContext,
RenderContext,
NoContext,
} from './ReactFiberWorkLoop.old';
import {
@@ -1078,7 +1081,9 @@ function commitLayoutEffectOnFiber(
case OffscreenComponent: {
const isModernRoot = (finishedWork.mode & ConcurrentMode) !== NoMode;
if (isModernRoot) {
const isHidden = finishedWork.memoizedState !== null;
const isHidden =
finishedWork.memoizedState !== null ||
finishedWork.stateNode._isDetached;
const newOffscreenSubtreeIsHidden =
isHidden || offscreenSubtreeIsHidden;
if (newOffscreenSubtreeIsHidden) {
@@ -1116,6 +1121,14 @@ function commitLayoutEffectOnFiber(
offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
}
if (finishedWork.pendingProps.mode === null) {
if (flags & Ref) {
safelyAttachRef(finishedWork, finishedWork.return);
}
} else if (finishedWork.pendingProps.mode !== undefined) {
safelyDetachRef(finishedWork, finishedWork.return);
}
} else {
recursivelyTraverseLayoutEffects(
finishedRoot,
@@ -2247,6 +2260,30 @@ function getRetryCache(finishedWork) {
}
}
function attachOffscreenActions(offscreenFiber: Fiber, root: FiberRoot) {
offscreenFiber.stateNode.detach = () => {
const executionContext = getExecutionContext();
if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
scheduleMicrotask(() => {
offscreenFiber.stateNode._isDetached = true;
disappearLayoutEffects(offscreenFiber);
disconnectPassiveEffect(offscreenFiber);
});
} else {
offscreenFiber.stateNode._isDetached = true;
disappearLayoutEffects(offscreenFiber);
disconnectPassiveEffect(offscreenFiber);
}
};
offscreenFiber.stateNode.attach = () => {
// TODO: does not handle when attach is called from effect or when tree is rendered.
offscreenFiber.stateNode._isDetached = false;
reappearLayoutEffects(root, null, offscreenFiber, false);
reconnectPassiveEffects(root, offscreenFiber, NoLanes, null, false);
};
}
function attachSuspenseRetryListeners(
finishedWork: Fiber,
wakeables: Set<Wakeable>,
@@ -2625,6 +2662,7 @@ function commitMutationEffectsOnFiber(
}
commitReconciliationEffects(finishedWork);
attachOffscreenActions(finishedWork, root);
if (flags & Visibility) {
const offscreenInstance: OffscreenInstance = finishedWork.stateNode;
@@ -2651,7 +2689,7 @@ function commitMutationEffectsOnFiber(
}
}
if (supportsMutation) {
if (supportsMutation && offscreenInstance._isDetached !== true) {
// TODO: This needs to run whenever there's an insertion or update
// inside a hidden Offscreen tree.
hideOrUnhideAllChildren(offscreenBoundary, isHidden);

View File

@@ -302,7 +302,6 @@ if (supportsMutation) {
};
} else if (supportsPersistence) {
// Persistent host tree mode
appendAllChildren = function(
parent: Instance,
workInProgress: Fiber,
@@ -410,7 +409,14 @@ if (supportsMutation) {
if (child !== null) {
child.return = node;
}
appendAllChildrenToContainer(containerChildSet, node, true, true);
// Detached tree is hidden from user space.
const _needsVisibilityToggle = node.stateNode._isDetached === false;
appendAllChildrenToContainer(
containerChildSet,
node,
_needsVisibilityToggle,
true,
);
} else if (node.child !== null) {
node.child.return = node;
node = node.child;

View File

@@ -302,7 +302,6 @@ if (supportsMutation) {
};
} else if (supportsPersistence) {
// Persistent host tree mode
appendAllChildren = function(
parent: Instance,
workInProgress: Fiber,
@@ -410,7 +409,14 @@ if (supportsMutation) {
if (child !== null) {
child.return = node;
}
appendAllChildrenToContainer(containerChildSet, node, true, true);
// Detached tree is hidden from user space.
const _needsVisibilityToggle = node.stateNode._isDetached === false;
appendAllChildrenToContainer(
containerChildSet,
node,
_needsVisibilityToggle,
true,
);
} else if (node.child !== null) {
node.child.return = node;
node = node.child;

View File

@@ -52,4 +52,7 @@ export type OffscreenInstance = {
pendingMarkers: Set<TracingMarkerInstance> | null,
transitions: Set<Transition> | null,
retryCache: WeakSet<Wakeable> | Set<Wakeable> | null,
detach: () => void,
attach: () => void,
_isDetached: boolean,
};

View File

@@ -277,7 +277,7 @@ type ExecutionContext = number;
export const NoContext = /* */ 0b000;
const BatchedContext = /* */ 0b001;
const RenderContext = /* */ 0b010;
export const RenderContext = /* */ 0b010;
export const CommitContext = /* */ 0b100;
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;

View File

@@ -277,7 +277,7 @@ type ExecutionContext = number;
export const NoContext = /* */ 0b000;
const BatchedContext = /* */ 0b001;
const RenderContext = /* */ 0b010;
export const RenderContext = /* */ 0b010;
export const CommitContext = /* */ 0b100;
type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;

View File

@@ -0,0 +1,65 @@
let React;
let ReactDOMClient;
let Offscreen;
let container;
let act;
let useRef;
describe('ReactOffscreen', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactDOMClient = require('react-dom/client');
Offscreen = React.unstable_Offscreen;
act = require('jest-react').act;
useRef = React.useRef;
container = document.createElement('div');
document.body.appendChild(container);
});
afterEach(() => {
document.body.removeChild(container);
});
// @gate enableOffscreen
xit('does not attach event handlers by default', async () => {
const onClick = jest.fn();
let offscreenRef;
function App({mode}) {
offscreenRef = useRef(null);
return (
<Offscreen ref={offscreenRef} mode={null}>
<span id="span-1" onClick={onClick} />
</Offscreen>
);
}
const root = ReactDOMClient.createRoot(container);
await act(async () => {
root.render(<App mode={'visible'} />);
});
function click() {
container
.querySelector('#span-1')
.dispatchEvent(
new MouseEvent('click', {bubbles: true, cancelable: true}),
);
}
expect(offscreenRef.current).not.toBeNull();
click();
expect(onClick.mock.calls.length).toBe(1);
offscreenRef.current.detach();
click();
expect(onClick.mock.calls.length).toBe(1);
});
});

View File

@@ -5,6 +5,7 @@ let act;
let LegacyHidden;
let Offscreen;
let useState;
let useRef;
let useLayoutEffect;
let useEffect;
let useMemo;
@@ -24,6 +25,7 @@ describe('ReactOffscreen', () => {
useLayoutEffect = React.useLayoutEffect;
useEffect = React.useEffect;
useMemo = React.useMemo;
useRef = React.useRef;
startTransition = React.startTransition;
});
@@ -1259,4 +1261,371 @@ describe('ReactOffscreen', () => {
</div>,
);
});
// @gate enableOffscreen
it('defers updates in hidden tree', async () => {
let updateChildState;
let updateHighPriorityComponentState;
function Child() {
const [state, _stateUpdate] = useState(0);
updateChildState = _stateUpdate;
const text = 'Child ' + state;
return <Text text={text} />;
}
function HighPriorityComponent(props) {
const [state, _stateUpdate] = useState(0);
updateHighPriorityComponentState = _stateUpdate;
const text = 'HighPriorityComponent ' + state;
return (
<>
<Text text={text} />
{props.children}
</>
);
}
const root = ReactNoop.createRoot();
// Mount hidden tree.
await act(async () => {
root.render(
<>
<HighPriorityComponent>
<Offscreen mode="hidden">
<Child />
</Offscreen>
</HighPriorityComponent>
</>,
);
});
expect(Scheduler).toHaveYielded(['HighPriorityComponent 0', 'Child 0']);
expect(root).toMatchRenderedOutput(
<>
<span prop="HighPriorityComponent 0" />
<span hidden={true} prop="Child 0" />
</>,
);
await act(async () => {
updateChildState(1);
updateHighPriorityComponentState(1);
expect(Scheduler).toFlushUntilNextPaint(['HighPriorityComponent 1']);
expect(root).toMatchRenderedOutput(
<>
<span prop="HighPriorityComponent 1" />
<span hidden={true} prop="Child 0" />
</>,
);
});
expect(Scheduler).toHaveYielded(['Child 1']);
expect(root).toMatchRenderedOutput(
<>
<span prop="HighPriorityComponent 1" />
<span hidden={true} prop="Child 1" />
</>,
);
});
describe('manual interactivity', () => {
// @gate enableOffscreen
it('should attach ref only for mode null', async () => {
let offscreenRef;
function App({mode}) {
offscreenRef = useRef(null);
return (
<Offscreen
mode={mode}
ref={ref => {
offscreenRef.current = ref;
}}>
<div />
</Offscreen>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App mode={null} />);
});
expect(offscreenRef.current).not.toBeNull();
await act(async () => {
root.render(<App mode={'visible'} />);
});
expect(offscreenRef.current).toBeNull();
await act(async () => {
root.render(<App mode={'hidden'} />);
});
expect(offscreenRef.current).toBeNull();
await act(async () => {
root.render(<App mode={null} />);
});
expect(offscreenRef.current).not.toBeNull();
});
// @gate enableOffscreen
it('should lower update priority for detached Offscreen', async () => {
let updateChildState;
let updateHighPriorityComponentState;
let offscreenRef;
function Child() {
const [state, _stateUpdate] = useState(0);
updateChildState = _stateUpdate;
const text = 'Child ' + state;
return <Text text={text} />;
}
function HighPriorityComponent(props) {
const [state, _stateUpdate] = useState(0);
updateHighPriorityComponentState = _stateUpdate;
const text = 'HighPriorityComponent ' + state;
return (
<>
<Text text={text} />
{props.children}
</>
);
}
function App() {
offscreenRef = useRef(null);
return (
<>
<HighPriorityComponent>
<Offscreen mode={null} ref={offscreenRef}>
<Child />
</Offscreen>
</HighPriorityComponent>
</>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App />);
});
expect(Scheduler).toHaveYielded(['HighPriorityComponent 0', 'Child 0']);
expect(root).toMatchRenderedOutput(
<>
<span prop="HighPriorityComponent 0" />
<span prop="Child 0" />
</>,
);
expect(offscreenRef.current).not.toBeNull();
expect(offscreenRef.current.detach).not.toBeNull();
// Offscreen is attached by default. State updates from offscreen are **not defered**.
await act(async () => {
updateChildState(1);
updateHighPriorityComponentState(1);
expect(Scheduler).toFlushUntilNextPaint([
'HighPriorityComponent 1',
'Child 1',
]);
expect(root).toMatchRenderedOutput(
<>
<span prop="HighPriorityComponent 1" />
<span prop="Child 1" />
</>,
);
});
// detaching offscreen.
offscreenRef.current.detach();
// Offscreen is detached. State updates from offscreen are **defered**.
await act(async () => {
updateChildState(2);
updateHighPriorityComponentState(2);
expect(Scheduler).toFlushUntilNextPaint(['HighPriorityComponent 2']);
expect(root).toMatchRenderedOutput(
<>
<span prop="HighPriorityComponent 2" />
<span prop="Child 1" />
</>,
);
});
expect(Scheduler).toHaveYielded(['Child 2']);
expect(root).toMatchRenderedOutput(
<>
<span prop="HighPriorityComponent 2" />
<span prop="Child 2" />
</>,
);
});
// @gate enableOffscreen
it('defers detachment if called during commit', async () => {
let updateChildState;
let updateHighPriorityComponentState;
let offscreenRef;
let nextRenderTriggerDetach = false;
function Child() {
const [state, _stateUpdate] = useState(0);
updateChildState = _stateUpdate;
const text = 'Child ' + state;
return <Text text={text} />;
}
function HighPriorityComponent(props) {
const [state, _stateUpdate] = useState(0);
updateHighPriorityComponentState = _stateUpdate;
const text = 'HighPriorityComponent ' + state;
useLayoutEffect(() => {
if (nextRenderTriggerDetach) {
offscreenRef.current.detach();
_stateUpdate(state + 1);
updateChildState(state + 1);
nextRenderTriggerDetach = false;
}
});
return (
<>
<Text text={text} />
{props.children}
</>
);
}
function App() {
offscreenRef = useRef(null);
return (
<>
<HighPriorityComponent>
<Offscreen mode={null} ref={offscreenRef}>
<Child />
</Offscreen>
</HighPriorityComponent>
</>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App />);
});
expect(Scheduler).toHaveYielded(['HighPriorityComponent 0', 'Child 0']);
nextRenderTriggerDetach = true;
// Offscreen is attached. State updates from offscreen are **not defered**.
// Offscreen is detached inside useLayoutEffect;
await act(async () => {
updateChildState(1);
updateHighPriorityComponentState(1);
expect(Scheduler).toFlushUntilNextPaint([
'HighPriorityComponent 1',
'Child 1',
'HighPriorityComponent 2',
'Child 2',
]);
expect(root).toMatchRenderedOutput(
<>
<span prop="HighPriorityComponent 2" />
<span prop="Child 2" />
</>,
);
});
// Offscreen is detached. State updates from offscreen are **defered**.
await act(async () => {
updateChildState(3);
updateHighPriorityComponentState(3);
expect(Scheduler).toFlushUntilNextPaint(['HighPriorityComponent 3']);
expect(root).toMatchRenderedOutput(
<>
<span prop="HighPriorityComponent 3" />
<span prop="Child 2" />
</>,
);
});
expect(Scheduler).toHaveYielded(['Child 3']);
expect(root).toMatchRenderedOutput(
<>
<span prop="HighPriorityComponent 3" />
<span prop="Child 3" />
</>,
);
});
// @gate enableOffscreen
it('does not mount tree until attach is called', async () => {
let offscreenRef;
let spanRef;
function Child() {
spanRef = useRef(null);
useEffect(() => {
Scheduler.unstable_yieldValue('Mount Child');
return () => {
Scheduler.unstable_yieldValue('Unmount Child');
};
});
useLayoutEffect(() => {
Scheduler.unstable_yieldValue('Mount Layout Child');
return () => {
Scheduler.unstable_yieldValue('Unmount Layout Child');
};
});
return <span ref={spanRef}>Child</span>;
}
function App() {
return (
<>
<Offscreen mode={null} ref={el => (offscreenRef = el)}>
<Child />
</Offscreen>
</>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App />);
});
expect(offscreenRef).not.toBeNull();
expect(spanRef.current).not.toBeNull();
expect(Scheduler).toHaveYielded(['Mount Layout Child', 'Mount Child']);
offscreenRef.detach();
expect(spanRef.current).toBeNull();
expect(Scheduler).toHaveYielded([
'Unmount Layout Child',
'Unmount Child',
]);
offscreenRef.attach();
expect(spanRef.current).not.toBeNull();
expect(Scheduler).toHaveYielded(['Mount Layout Child', 'Mount Child']);
});
});
});