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:

jsx
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

jsx
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:

placeholder

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:

js
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.

js
1// In enqueueUpdate 2concurrentQueues[concurrentQueuesIndex++] = fiber 3concurrentQueues[concurrentQueuesIndex++] = queue 4concurrentQueues[concurrentQueuesIndex++] = update 5concurrentQueues[concurrentQueuesIndex++] = lane

Next, 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.

  1. Schedule FiberRoot via ensureRootIsScheduled

    React 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.

  2. 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, calls scheduleTaskForRootDuringMicrotask, which actually enqueue a task to Scheduler.

  3. 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.

placeholder

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.

js
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:

placeholder

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:

  1. Set up global variables such as workInProgressRoot, workInProgressRootRenderLanes
  2. Create a workInProgress HostRootFiber based on the current HostRootFiber.
  3. Call finishQueueingConcurrentUpdates to access concurrentQueues, and insert the update object into HostRootFiber.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.

js
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.

placeholder

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,

  1. Set the parent fiber’s .child pointer to the first child fiber
  2. Link each sibling child via the .sibling pointer
  3. ensures each child’s .return pointer 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

placeholder

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:

  1. 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 on fiber.memoizedState, and subsequent ones are linked via the .next pointer in order.

  2. 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 .next pointer points to the next effect, and the last effect’s .next points back to the first. the fiber.updateQueue.lastEffect points to the last effect in the list.

  3. Return React element

js
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}; 17

A function component with three hooks with side effect looks like below

placeholder

With useLayoutEffect, the <App/> fiber gets UpdateEffect and LayoutStaticEffect flags; with useEffect, 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 scheduleCallback and run in the next macrotask, after the current paint. In contrast, HookLayout effects run immediately, before paint.

The timing of useEffect is more complicated than you might expect, and this article explans it well.

Next, reconcileChildren is called to create the HostComponentFiber corresponding to <div/>.

jsx
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:

  1. If the fiber has a sibling, fiber.sibling becomes the next workInProgress, and control returns to the main loop outside to run beginWork on it.
  2. If there’s no sibling, fiber.return becomes the next workInProgress and will run completeWork next.

Additionally, completeWork includes special handling based on the fiber’s tag.

placeholder

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.

placeholder

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:

js
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

bubbleProperties is 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:

  1. Mark completed work and preserve remaining work via markRootFinished

    fiberRoot.pendingLanes = mergeLanes(rootFiber.lanes, rootFiber.childLanes)
  2. ⭐️ Schedule passive effects via Scheduler

    Passive effects (eg: useEffect) are scheduled to run asynchronously after paint.

  3. ⭐️ Execute DOM mutations

    Apply all DOM changes accumulated during the render phase. For function components, execute layout effect cleanups.

  4. ⭐️ Execute layout effects

    Execute layout effect(eg: useLayoutEffect) setups on fibers with the Update flag.

  5. 🚀 Request paint

    Set needsPaint = true to break Scheduler's work loop and yield to the browser, ensuring visual updates appear immediately before continuing with passive effects.

  6. Check for remaining work

    check if root.pendingLanes has 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,

  1. if fiber.deletions exists, call commitDeletionEffects to remove DOM nodes and execute layout effect cleanups if needed.
  2. call commitMutationEffectsOnFiber to handle the fiber based on its tag. Commonly, traverse its child fibers if its subtreeFlags contains mutation flags. After the traversal, call commitReconciliationEffects.
  3. If it contains Placement flag, calls commitPlacement to insert the associated DOM node(fiber.stateNode) into its host parent.
commitReconciliationEffects -> commitPlacement
commitReconciliationEffects -> commitPlacement

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:

  1. Find the nearest host parent

    Walk up the fiber tree to find the nearest ancestor that can hold DOM nodes (eg: HostComponentFiber, HostRootFiber).

  2. 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:

placeholder

Besides, other work needs to do based on the fiber's tag:

placeholder

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.

placeholder

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,

  1. call commitLayoutEffectOnFiber to handle the fiber based on its tag. Commonly, traverse its child fibers if its subtreeFlags contains layout effect flags.

  2. if if contains Update flag, call commitHookEffectListMount to execute its layout effect setups.

    Basically, traverses the circular effect queue from fiber.updateQueue.lastEffect, executes effect.create() for matching effects, and saves the returned cleanup function to effect.inst.destroy for the next render.

Also, let's what's the difference between these fibers:

placeholder

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.

js
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.

jsx
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.

js
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

  1. Insert the update object to FunctionComponentFiber(App).updateQueue.shared.pending.
  2. 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 executing beginWork , using includesSomeLane to 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.

js
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, useState is actually a special case of useReducer .

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

placeholder

let's see what happens underneath <Activity> with mode = "hidden"

jsx
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:

js
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:

js
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:

js
1// In bubbleProperties 2newChildLanes = mergeLanes( 3 newChildLanes, 4 mergeLanes(child.lanes, child.childLanes), // Includes OffscreenLane 5) 6completedWork.childLanes = newChildLanes

This bubbling continues up the fiber tree until it reaches HostRootFiber.childLanes,

Then, in commit phase, before dom mutation,

root.pendingLanes = HostRootFiber.childLanes

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

placeholder

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.

© 2025 Yvaine