December 05, 2025
How <Activity> Works During React’s Render and Commit
In react 19.2, <Activity> allows certain parts of the UI to be pre-rendered and state-preserved.
I’m curious about how it works internally. It’s kind of like <KeepAlive>, which has sparked lively discussion and implementation in the community.
The official doc basically tell the truth
When an Activity boundary is hidden during its initial render, its children won’t be visible on the page — but they will still be rendered, albeit at a lower priority than the visible content, and without mounting their Effects.
But what exactly happens under the hood when switching mode between "hidden" and "visible"?
To understand clearly, let's begin with React’s standard render and commit flow.
A standard render and commit
Assume we have a minimal React project example as our baseline example:
1// index.jsx
2import ReactDOM from "react-dom/client";
3import App from "./App";
4const root = ReactDOM.createRoot(document.getElementById("root"));
5root.render(<App />);
6
7
8// App.jsx
9import { useState } from "react";
10
11const App = () => {
12 const [name, setName] = useState("");
13 const handleConnect = () => {
14 setName(currentName ? "" : "Yvaine");
15 };
16
17 return (
18 <div>
19 <p>{name ? `Hello ${name}.` : "Who are you?"}</p>
20 <button type="button" className="button" onClick={handleConnect}>
21 {name ? "Disconnect" : "Connect"}
22 </button>
23 </div>
24 );
25};
26
27export default App;The initial render always begins with a call to
1ReactDOM.createRoot(document.getElementById("root")).render(<App />)This initializes the root and instructs React to build the DOM tree from the given React component and append it to the given DOM element.
Root construction
After calling ReactDOM.createRoot(document.getElementById("root")), React sets up a root structure like this:
FiberRoot serves as the scheduler’s central hub, bridges the fiber tree with the element(div#root), and underpins every render and commit flow.
HostRootFiber (created via
createHostRootHiber)
is the root fiber of the entire fiber tree.React defines many other types of fibers as
well, which you can explore in
ReactFiber.js!
Trigger a render
The initial render is triggered by fiberRoot.render(<App/>)。
An update containing <App/> is produced via createUpdate, looking like this:
1const update = {
2 lane,
3 tag: UpdateState,
4 payload: {
5 element: <App />,
6 },
7 callback: null,
8 next: null,
9}Then, this update object and its associated fiber info are stored into concurrentQueues via enqueueConcurrentClassUpdate.
1// In enqueueUpdate
2concurrentQueues[concurrentQueuesIndex++] = fiber
3concurrentQueues[concurrentQueuesIndex++] = queue
4concurrentQueues[concurrentQueuesIndex++] = update
5concurrentQueues[concurrentQueuesIndex++] = laneNext, call scheduleUpdateOnFiber to schedule an update on the FiberRoot, which will consume the update object.
Scheduler
When scheduleUpdateOnFiber is called, React doesn't start the render and commit flow immediately. Instead, it appends the current fiber root to the scheduled FiberRoot chain, and then schedules a microtask.
In that microtask, loop over all scheduled FiberRoots and assign each of them an appropriately prioritized task. For every root that needs work, React registers a macro task (via scheduleCallback) that will trigger a render and commit flow after the current paint, without blocking it.
-
Schedule FiberRoot via
ensureRootIsScheduledReact selects the highest-priority pending lane, updates or replaces the root’s scheduled callback, links the root into the global scheduled-roots queue, and finally posts a microtask so multiple updates can be batched before actual scheduling begins.
-
Loop over scheduled FiberRoot chain in a microtask
In the microtask (via
processRootScheduleInMicrotask), React loops over all scheduled roots. For each root that still has pending work, callsscheduleTaskForRootDuringMicrotask, which actually enqueue a task to Scheduler. -
Execute the actual render and commit in a macrotask
A new task is pushed into taskQueue via
schedulerCallback. The Scheduler’s work loop then picks it up according to its priority and executes its callback (performWorkOnRootViaSchedulerTask), the entry point into the render and commit flow.
You can simplify the event-loop concept as follows:
within a single macrotask, tasks can register microtasks (via
Promise.then(),queueMicrotask(), etc.). After the macrotask finishes, the engine executes all queued microtasks. Once both the macrotask and its microtasks are done, the browser may proceed to render (if there are pending UI changes).
Within Scheduler, a task like following is created. callback points to performWorkOnRootViaSchedulerTask, which triggers the render and commit flow.
1
2// from
3const newCallbackNode = scheduleCallback(
4 schedulerPriorityLevel,
5 performWorkOnRootViaSchedulerTask.bind(null, root),
6)
7
8
9// to
10var newTask: Task = {
11 id: taskIdCounter++,
12 callback,
13 priorityLevel,
14 startTime,
15 expirationTime,
16 sortIndex: -1,
17}Finally, performWorkOnRoot is called, which consists of two main phases:
-
render phase via
renderRootConcurrentperform a work loop to build the fiber tree based on the React component
-
commit phase via
finishConcurrentRendercomplete dom mutation and effects execution
Render phase
renderRootConcurrent is the entry point to the render phase. Before entering the work loop, it first calls prepareFreshStack to initiate a fresh render environment:
- Set up global variables such as workInProgressRoot, workInProgressRootRenderLanes
- Create a workInProgress HostRootFiber based on the current HostRootFiber.
- Call
finishQueueingConcurrentUpdatesto access concurrentQueues, and insert the update object intoHostRootFiber.updateQueue.shared.pending.
During the render phase, there are two fiber trees:
- the one currently used to display the DOM is called the current tree
- the one being constructed during render phase is called the workInProgress tree.
After completing dom mutation in commit phase, workInProgress becomes the new current, handing off control and waiting for the next render cycle.
So, in the initial render, the workInProgress fiber tree is built from scratch, they have no corresponding DOM nodes yet, meaning there’s no current fiber to reference. In contrast, during subsequent render, the construction of workInProgress fibers is based on the existing current fibers.
workLoopConcurrentByScheduler is central to React’s concurrent rendering.
Inside this loop, if shouldYield() returns true (for example, every ~5 ms), the loop is broken, letting the browser process user interactions or paint. Because of workInProgress !== null, the render phase is marked as "RootInProgress".
On the next scheduled turn, if the same root and lanes are being worked on, React can reuse the existing workInProgress fiber tree rather than calling prepareFreshStack again. This resumable workInProgress mechanism lies at the heart of React’s time sliced (interruptible) rendering.
1function workLoopConcurrentByScheduler() {
2// Perform work until Scheduler asks us to yield
3 while (workInProgress !== null && !shouldYield()) {
4 // $FlowFixMe[incompatible-call] flow doesn't know that shouldYield() is side-effect free
5 performUnitOfWork(workInProgress);
6 }
7
8}
9
10
11const frameYieldMs = 5;
12
13let frameInterval: number = frameYieldMs;
14
15function shouldYieldToHost(): boolean {
16
17 if (!enableAlwaysYieldScheduler && enableRequestPaint && needsPaint) {
18 // Yield now.
19 return true;
20 }
21
22 const timeElapsed = getCurrentTime() - startTime;
23
24 if (timeElapsed < frameInterval) {
25 // The main thread has only been blocked for a really short amount of time;
26 // smaller than a single frame. Don't yield yet.
27 return false;
28 }
29 // Yield now.
30 return true;
31}Starting from the workInProgress HostRootFiber, the render phase work loop repeatedly calls performUnitOfWork. Each call runs beginWork then completeWork, performing a depth-first traversal to build the entire workInProgress tree.
beginWork
The main task of beginWork is to reconcile the children of the current fiber and either create or reuse(in subsequent render) the corresponding child fibers.
Then,
- Set the parent fiber’s
.childpointer to the first child fiber - Link each sibling child via the
.siblingpointer - ensures each child’s
.returnpointer refers to the parent fiber
After these, fiber.child will be workInProgress fiber and call beginWork again.
In addition, there is special handling based on the fiber’s tag
HostRootFiber
Before calling reconcileChildren, processUpdateQueue is called to extract the <App/> update from HostRootFiber.updateQueue.shared.pending and assigns it to HostRootFiber.memoizedState.element.
Only then can reconcileChildren correctly access the <App /> component and create the corresponding FunctionComponentFiber.
FunctionComponentFiber(App) becomes the next workInProgress fiber.
FunctionComponentFiber
Before calling reconcileChildren, for a function component, renderWithHooks needs to be called to perform three tasks:
-
Initialize a hook queue
create a hook object for each hook call(e.g.
useState,useRef,useEffect) in the function component. The first one is stored onfiber.memoizedState, and subsequent ones are linked via the.nextpointer in order. -
Initialized a effect circular queue
for hooks that involve side effects(eg:
useLayoutEffect,useEffect), create an effect object for each one. These effects are linked using a circular queue: each effect’s.nextpointer points to the next effect, and the last effect’s.nextpoints back to the first. thefiber.updateQueue.lastEffectpoints to the last effect in the list. -
Return React element
1const hook: Hook = {
2 memoizedState: null,
3 baseState: null,
4 baseQueue: null,
5 queue: null,
6 next: null,
7};
8
9const effect: Effect = {
10 tag,
11 create,
12 deps,
13 inst,
14 // Circular
15 next: (null: any),
16};
17A function component with three hooks with side effect looks like below
With
useLayoutEffect, the<App/>fiber gets UpdateEffect and LayoutStaticEffect flags; withuseEffect, it gets PassiveEffect and PassiveStaticEffect flags. These flags determine which effect setup runs during the commit phase.In the commit phase, HookPassive effects are scheduled via
scheduleCallbackand run in the next macrotask, after the current paint. In contrast, HookLayout effects run immediately, before paint.The timing of
useEffectis more complicated than you might expect, and this article explans it well.
Next, reconcileChildren is called to create the HostComponentFiber corresponding to <div/>.
1<div>
2 <p>{name ? `Hello ${name}.` : "Who are you?"}</p>
3 <button type="button" className="button" onClick={handleConnect}>
4 {name ? "Disconnect" : "Connect"}
5 </button>
6</div>Then,
FunctionComponentFiber(App).child = HostComponentFiber(div)
workInProgress = HostComponentFiber(div)
HostComponentFiber
For native DOM elements (like div, p, span, etc.), React checks whether the children contain only plain text. If the content is purely text, nextChildren is set to null, meaning no child fibers will be created. Only when the children include non-text elements will React create corresponding child fibers.
When a node’s text content changes, React marks the fiber with a ContentReset flag to update textContent directly, avoiding extra fibers and improving performance.
For HostComponentFiber(div), reconcileChildrenArray creates a fiber for each child and links them via .sibling pointer. The first child is set as workInProgress.child and becomes the next workInProgress.
For <p> with only text, no child fibers are created, reconcileChildren returns null, and workInProgress becomes null for the first time.
completeWork
The main job of completeWork is to create the DOM node for the current fiber and assign it to fiber.stateNode. The inner loop, handled by completeUnitOfWork, works as follows:
- If the fiber has a sibling,
fiber.siblingbecomes the next workInProgress, and control returns to the main loop outside to runbeginWorkon it. - If there’s no sibling,
fiber.returnbecomes the next workInProgress and will runcompleteWorknext.
Additionally, completeWork includes special handling based on the fiber’s tag.
HostComponentFiber
call createInstance (different between react-dom and react-native) to create a real DOM element, storing it in fiber.stateNode.
Then, if this type of fiber has child fibers, append all their DOM elements to this element via appendAllChildren. This is how the fiber tree is translated into an actual DOM subtree.
FunctionComponentFiber
This type of fiber doesn’t correspond to a real DOM node. Its stateNode is effectively a reference to the nearest ancestor HostComponentFiber.stateNode or a collection of them.
HostRootFiber
Although it doesn’t directly contribute to the DOM tree, as the root fiber, it handles various coordination tasks.
For all fiber types, React eventually calls bubbleProperties, which aggregates flags and subtreeFlags from child fibers into the current fiber’s subtreeFlags. This is later used during the commit phase to decide whether to traverse a fiber’s subtree.
For example, in this case, the divFiber collects flags during bubbleProperties as follows:
1let subtreeFlags: Flags = NoFlags;
2
3
4subtreeFlags |= pFiber.subtreeFlags (NoFlags)
5subtreeFlags |= pFiber.flags (Placement)
6subtreeFlags |= buttonFiber.subtreeFlags (NoFlags)
7subtreeFlags |= buttonFiber.flags (Placement)
8
9divFiber.subtreeFlags = Placement
bubblePropertiesis a key part of completeWork.It bubbles up flags and lanes from child fibers to their parent, eventually reaching the root. This enables React to quickly determine whether a fiber’s subtree contains work to do, such as DOM mutations or pending updates, which is especially important for triggering a second render in features like
<Activity>.
At this point, we have a fiber tree that reflects the current React element tree, along with a scattered set of DOM nodes waiting to be appended to the document.
Commit phase
After renderRootConcurrent, React enters the commit phase via finishConcurrentRender by calling commitRoot. This phase synchronizes the DOM with the workInProgress fiber tree.
The commit phase performs the following key operations:
-
Mark completed work and preserve remaining work via
markRootFinishedfiberRoot.pendingLanes = mergeLanes(rootFiber.lanes, rootFiber.childLanes) -
⭐️ Schedule passive effects via Scheduler
Passive effects (eg:
useEffect) are scheduled to run asynchronously after paint. -
⭐️ Execute DOM mutations
Apply all DOM changes accumulated during the render phase. For function components, execute layout effect cleanups.
-
⭐️ Execute layout effects
Execute layout effect(eg:
useLayoutEffect) setups on fibers with the Update flag. -
🚀 Request paint
Set
needsPaint = trueto break Scheduler's work loop and yield to the browser, ensuring visual updates appear immediately before continuing with passive effects. -
Check for remaining work
check if
root.pendingLaneshas remaining work and schedule another render if needed.
For
Activity, the OffscreenLane bubbled up from OffscreenComponentFiber to HostRootFiber during render phase.In step 1, FiberRoot gets OffscreenLane as the
pendingLanes.In step 6, schedule another render at OffscreenLane priority
Dom Mutation
React performs DOM mutations via flushMutationEffects performing a depth-first walk of the finished fiber tree.
Starting from HostRootFiber, if its subtreeFlags contains any
mutation
flags(eg: Placement), call recursivelyTraverseMutationEffects, which performs a depth-first traversal.
For each visited fiber,
- if
fiber.deletionsexists, callcommitDeletionEffectsto remove DOM nodes and execute layout effect cleanups if needed. - call
commitMutationEffectsOnFiberto handle the fiber based on its tag. Commonly, traverse its child fibers if its subtreeFlags contains mutation flags. After the traversal, callcommitReconciliationEffects. - If it contains Placement flag, calls
commitPlacementto insert the associated DOM node(fiber.stateNode) into its host parent.
This bottom-up approach ensures child DOM nodes mount to their parents first, propagating upward until the entire DOM tree attaches to the root container, element(div#root).
Not all fibers correspond to DOM nodes. For example, FunctionComponent fibers, don't represent real DOM nodes. Therefore, when executing commitPlacement, React must:
-
Find the nearest host parent
Walk up the fiber tree to find the nearest ancestor that can hold DOM nodes (eg: HostComponentFiber, HostRootFiber).
-
Find the actual DOM nodes to mount
Walk down the fiber tree, skipping over non-host fibers (eg: FunctionComponentFiber) until reaching terminal host nodes.
Eventually, the host parent relationships are as follows:
Besides, other work needs to do based on the fiber's tag:
HostComponentFiber
Find the nearest host parent, then append the DOM nodes to it.
Just note, during the render phase, when executing completeWork on HostComponentFiber(div), element(p) and element(button) have been appended to element(div). So this DOM insertion in the commit phase is essentially a no-op.
If the given child is a reference to an existing node in the document,
appendChild()moves it from its current position to the new position. More details from MDN
FunctionComponentFiber
This type of fiber doesn't have a stateNode. When walking down to find actual DOM nodes to mount, it is simply skipped.
If the fiber contains Update flag, call commitHookEffectListUnmount to execute its layout effect cleanups.
HostRootFiber
This type of fiber is special and never triggers commitPlacement This type of fiber is special and never executes commitPlacement because prepareFreshStack initializes the workInProgress HostRootFiber with NoFlags.
Now that all DOM nodes are mounted under element(div#root), fiberRoot.current is set to the workInProgress HostRootFiber, completing the handoff from the workInProgress tree to the current tree.
Layout Effects Execution
After DOM mutations complete, executes layout effects by calling flushLayoutEffects. At this point, since DOM nodes are mounted to the document, refs can be attached, and layout effects can safely read DOM layout.
Starting from HostRootFiber, if its subtreeFlags contains any layout effect flags, call recursivelyTraverseLayoutEffects to perform a depth-first traversal.
For each visited fiber,
-
call
commitLayoutEffectOnFiberto handle the fiber based on its tag. Commonly, traverse its child fibers if its subtreeFlags contains layout effect flags. -
if if contains Update flag, call
commitHookEffectListMountto execute its layout effect setups.Basically, traverses the circular effect queue from
fiber.updateQueue.lastEffect, executeseffect.create()for matching effects, and saves the returned cleanup function toeffect.inst.destroyfor the next render.
Also, let's what's the difference between these fibers:
HostComponentFiber
No layout effects to execute, but since the DOM is now updated in the document, DOM-specific operations like attaching refs can be performed.
FunctionComponentFiber
Call commitHookEffectListMount to if needed.
HostRootFiber
No layout effects to execute.
Both passive effects (
useEffect) and layout effects (useLayoutEffect) are executed through the same function:commitHookEffectListMount.When traversing the effect queue, each effect is executed only if
effect.tag & flags === flags, allowing selective execution based on the effect type.
Request Paint
At this point, the browser can paint the updated DOM tree.
requestPaint() is called to set needsPaint = true, signaling Scheduler to yield the main thread.
1// Tell Scheduler to yield at the end of the frame, so the browser has an
2// opportunity to paint.
3requestPaint()While this may seem unnecessary in simple cases, it's crucial for performance in real-world scenarios.
Consider a "search long list" feature: each keystroke triggers state updates that rebuild large fiber trees and mutate massive DOM nodes. Without yielding, heavy JS execution blocks browser painting, making UI updates (like the input box) appear sluggish.
By calling requestPaint() at the end of commit phase, React tells Scheduler: "I've finished a batch of visual DOM changes, please give the browser a chance to paint immediately." This yields the main thread to the browser, allowing users to see their input updates faster.
Additionally, this scenario also involves task priority evaluation, interrupting old tasks, and scheduling new ones, which will be covered in a future post.
Toggle UI via setState
Usually, we can switch content visibility by setState.
1const [open, setOpen] = useState(false)
2
3const App = () => {
4 return <>{open && <Content />}</>
5}
6
7const Content = () => {
8 return (
9 <div>
10 <p>{name ? `Hello ${name}.` : "Who are you?"}</p>
11 <button type="button" className="button" onClick={handleConnect}>
12 {name ? "Disconnect" : "Connect"}
13 </button>
14 </div>
15 )
16}During the initial render, FunctionComponentFiber(App) has open = false in beginWork, so no child fibers, no DOM nodes are created as well.
When executing setOpen(true), internally, dispatchSetState creates an update object.
1const update: Update<S, A> = {
2 lane,
3 revertLane: NoLane,
4 gesture: null,
5 action,
6 hasEagerState: false,
7 eagerState: null,
8 next: (null: any),
9 };Similar to the initial render, this update object and its associated fiber info are stored into concurrentQueues via enqueueConcurrentHookUpdate.
Then, call scheduleUpdateOnFiber to trigger the subsequent render.
Before building the fiber tree, during prepareFreshStack,
- Insert the update object to
FunctionComponentFiber(App).updateQueue.shared.pending. - Call
markUpdateLaneFromFiberToRoot, which walks from the source fiber up to the root, marking the source fiber’s lanes and setting childLanes on each ancestor. This way, when executingbeginWork, usingincludesSomeLaneto check if the children have any pending work.
During the render phase, when calling beginWork on FunctionComponentFiber(App), renderWithHooks is called to apply the update's action to baseState, and the new baseState and memoizedState are computed.
1 if (update.hasEagerState) {
2 // If this update is a state update (not a reducer) and was processed eagerly,
3 // we can use the eagerly computed state
4 newState = ((update.eagerState: any): S);
5} else {
6 newState = reducer(newState, action);
7}In other words,
useStateis actually a special case ofuseReducer.
Since open = true, executing Component(props, secondArg) returns <Content/>, and reconcileChildren creates the corresponding fiber subtree. Later in commit phase, the DOM subtree is created and mounted.
Conversely, when open changes from "true" to "false", FunctionComponentFiber(Content) is stored in its parentFiber.deletions, waiting to remove associated DOM node during commit phase. Of course, the fiber's memoizedState and updateQueue are cleared.
Re-showing the component means rebuilding the entire fiber tree and associated state from scratch.
This approach has two problems:
-
State is lost
In scenarios like tab switching, we want component state to persist when hidden and then re-shown.
-
Performance impact
When the user makes the component visible again, React must rebuild the entire fiber tree and DOM subtree before displaying it on screen, which can cause noticeable delays and jank.
Toggle UI using <Activity>
mode = "hidden"
first render and commit flow
let's see what happens underneath <Activity> with mode = "hidden"
1const App = () => {
2 return (
3 <Activity mode={open ? "visible" : "hidden"}>
4 <Content />
5 </Activity>
6 )
7}During the render phase, before executing beginWork on FunctionComponentFiber(App), the flow follows the same pattern described above.
After executing Component(...), React encounters the <Activity> element and creates an ActivityComponentFiber, returning it as the next workInProgress.
When it's time to execute beginWork on ActivityComponentFiber, updateActivityComponent is called to create an OffscreenComponentFiber. The OffscreenComponentFiber's pendingProps are constructed from Activity's props:
1const offscreenChildProps: OffscreenProps = {
2 mode: nextProps.mode, // "hidden" or "visible"
3 children: nextProps.children // <Content /> and its children
4};Next, during OffscreenComponentFiber beginWork execution, updateOffscreenComponent is called. Since mode = "hidden" and no OffscreenLane in this render, the subtree is deferred:
1// In updateOffscreenComponent
2if (!includesSomeLane(renderLanes, OffscreenLane)) {
3 // Schedule this fiber to re-render at Offscreen priority
4 workInProgress.lanes = laneToLanes(OffscreenLane);
5
6 // Set childLanes and return null to skip children
7 workInProgress.childLanes = laneToLanes(OffscreenLane);
8 return null; // Skip render children for now
9}By returning null, React treats the OffscreenComponentFiber as having no children to process during this render. The work loop moves to completeWork(OffscreenComponentFiber), then continues building the remaining fiber tree.
However, the OffscreenLane set on workInProgress.childLanes bubbles up through each completeWork via bubbleProperties:
1// In bubbleProperties
2newChildLanes = mergeLanes(
3 newChildLanes,
4 mergeLanes(child.lanes, child.childLanes), // Includes OffscreenLane
5)
6completedWork.childLanes = newChildLanesThis bubbling continues up the fiber tree until it reaches HostRootFiber.childLanes,
Then, in commit phase, before dom mutation,
After calling requestPaint, ensureRootIsScheduled(root) is called to check for remaining work.
Since root.pendingLanes !== NoLanes (it contains the OffscreenLane that bubbled up from the OffscreenComponentFiber's childLanes), getNextLanes returns OffscreenLane as the next work to perform. React then schedules another task via Scheduler, entering a second render and commit flow at OffscreenLane priority.
This second render will handle the previously deferred OffscreenComponentFiber, building the fiber tree and DOM for the hidden content before it becomes visible.
This is the core mechanism of Activity's pre-rendering, keeping component state alive and preparing the DOM in advance.
second render and commit flow
In the render phase, during OffscreenComponentFiber’s beginWork, React detects OffscreenLane and proceeds to build the <Content /> fiber subtree as usual, even though the mode is "hidden".
In the commit phase, when commitMutationEffectsOnFiber hits the OffscreenComponentFiber, because of mode="hidden", it calls hideOrUnhideAllChildren, which sets the subtree’s host DOM nodes to display: "none".
When commitLayoutEffectsOnFiber runs, because of mode = "hidden", skip over its layout effects.
Since switching mode between "visible" and "hidden" doesn’t remove the fiber but only updates CSS and triggers effect setups and cleanups, the component’s state is preserved, enabling state restoration.
hidden -> visible
Similar to fiber creation and deletion, switching mode between "hidden" and "visible" also needs to trigger hook setup and cleanup for function components.
In render phase, when completeWork(OffscreenComponentFiber) runs, it detects the mode change and sets OffscreenComponentFiber.flags |= Visibility. This flag eventually bubbles up to rootFiber.subtreeFlags.
In commit phase, since the fiber contains passive effect flags(yeah, Visibility), it schedules passive effects via Scheduler.
During the passive effects execution, call recursivelyTraverseReconnectPassiveEffects to recursively executes all passive effect setups in the subtree.
During dom mutation stage, when running commitMutationEffectsOnFiber(OffscreenComponentFiber) , enable the dom subtree visible via hideOrUnhideAllChildren .
During layout effects execution, when running commitLayoutEffectsOnFiber, since the fiber contains layout effect flags(yeah, Visibility, again),call recursivelyTraverseReappearLayoutEffects to trigger layout effect setups for all FunctionComponentFibers in the subtree.
visible -> hidden
in commit phase, call recursivelyTraverseDisconnectPassiveEffects to recursively executes all passive effect cleanups in the subtree.
During dom mutation stage, when running commitMutationEffectsOnFiber(OffscreenComponentFiber) , recursivelyTraverseDisappearLayoutEffects is called to recursively trigger layout effect cleanups for all FunctionComponentFiber(s) in the subtree.
At the end, enable the dom subtree hidden via hideOrUnhideAllChildren .
Wrapping up
<Activity> implicitly creates an OffscreenComponentFiber and marks root.childLanes with OffscreenLane, enabling a second render after paint to pre-render hidden content at lower priority while keeping it hidden.
When switching between "visible" and "hidden", React preserves the fiber tree and component state while trigger side effect setups/cleanups, avoiding expensive unmount/remount operations.