Skip to content

Prevent view flattening for direct host children of ViewTransition#690

Open
everettbu wants to merge 16 commits intomainfrom
viewtransition-child-non-collapsible
Open

Prevent view flattening for direct host children of ViewTransition#690
everettbu wants to merge 16 commits intomainfrom
viewtransition-child-non-collapsible

Conversation

@everettbu
Copy link
Copy Markdown

Mirror of facebook/react#36012
Original author: zeyap


Summary

  • Adds finalizeViewTransitionChild(type, props) renderer config function, called during completeWork for HostComponents that are direct children of a <ViewTransition> boundary
  • Fabric implements it by injecting collapsable: false to prevent native view flattening, which would remove the view from the platform hierarchy and break view transition animations
  • DOM renderer returns props unchanged (no-op) since view flattening is a React Native concept
  • Uses a viewTransitionCursor stack cursor to efficiently track whether the current fiber is inside a ViewTransition — ViewTransitionComponent pushes true, HostComponent pushes false (acting as a boundary so only direct host children are affected)

Test plan

  • Run reconciler tests
  • Run React Native renderer tests
  • Verify view transitions animate correctly on RN with Fabric

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 11, 2026

Greptile Summary

This PR introduces finalizeViewTransitionChild — a new renderer config hook called during completeWork for HostComponent fibers that are direct children of a <ViewTransition> boundary. Fabric implements it by injecting collapsable: false to prevent native view flattening, while the DOM renderer returns props unchanged. A new viewTransitionCursor stack cursor in ReactFiberHostContext efficiently tracks whether the current fiber is a direct host child of a ViewTransition, using ViewTransitionComponent's begin work to push true and HostComponent's begin work to push false as a boundary. The change also consolidates all per-renderer view-transition no-op implementations into a shared ReactFiberConfigWithNoViewTransition module.

Key observations:

  • The reconciler-side stack cursor logic (pushViewTransitionContext / popViewTransitionContext) is implemented correctly, including the early-bailout path in attemptEarlyBailoutIfNoScheduledUpdate and both unwindWork / unwindInterruptedWork unwind paths.
  • addViewTransitionFinishedListener in the Fabric config calls the cleanup callback synchronously rather than chaining to transition.finished. The reconciler uses this to invoke user-supplied view-transition event cleanup functions; firing them before the animation completes can cause premature teardown while the transition is still in flight.
  • ReactFiberConfigWithNoViewTransition is missing GestureTimeline and getCurrentGestureOffset. ReactFiberConfigART and ReactFiberConfigNative previously defined these directly; they are now removed and replaced by a re-export from this file, which doesn't include them. Neither renderer exports from ReactFiberConfigWithNoMutation (where the fallback shim now lives), so both renderers silently lose this export, degrading from a helpful thrown error to an opaque TypeError at runtime.
  • Several other issues in ReactFiberConfigFabricWithViewTransition.js were flagged in earlier review threads (wrong arity on fabricStartViewTransition, DOM-specific types in the RN interface).

Confidence Score: 2/5

  • This PR has functional issues in the Fabric renderer that could cause premature cleanup of running animations and degrade developer experience for non-Fabric RN/ART renderers.
  • Two new issues are identified beyond those already flagged in prior threads: (1) addViewTransitionFinishedListener fires user cleanup callbacks immediately instead of after the animation, which can cause visual glitches for Fabric-rendered view transitions; (2) getCurrentGestureOffset is silently dropped from ART and Native OSS module exports, converting a clear "not supported" error into an opaque TypeError. Combined with the previously flagged issues (wrong fabricStartViewTransition arity/return type, DOM types in RN interface), the Fabric view-transition implementation has several correctness gaps that need resolution before this is safe to ship.
  • packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js requires the most attention; packages/react-reconciler/src/ReactFiberConfigWithNoViewTransition.js needs the GestureTimeline/getCurrentGestureOffset additions.

Important Files Changed

Filename Overview
packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js New file implementing Fabric view transition support; contains multiple known issues (DOM types in RN interface, wrong arity for fabricStartViewTransition — flagged in prior threads) plus a new bug where addViewTransitionFinishedListener calls the cleanup callback immediately instead of waiting for the animation to finish.
packages/react-reconciler/src/ReactFiberConfigWithNoViewTransition.js New shim file for renderers without view-transition support; exports finalizeViewTransitionChild as a shim, but is missing GestureTimeline type and getCurrentGestureOffset — both previously defined in ART and Native OSS configs that now re-export from this file.
packages/react-reconciler/src/ReactFiberCompleteWork.js Correctly integrates finalizeViewTransitionChild into the HostComponent complete path — called after popHostContext so the cursor already reflects the parent's ViewTransition state; memoizedProps is updated when instanceProps diverges from newProps.
packages/react-reconciler/src/ReactFiberHostContext.js viewTransitionCursor stack logic is correct: ViewTransition pushes true, HostComponent pushes false (boundary), so getIsInViewTransition() reads true only for direct host children of a ViewTransition as intended.
packages/react-reconciler/src/ReactFiberUnwindWork.js Both unwindWork and unwindInterruptedWork correctly call popViewTransitionContext for ViewTransitionComponent; HostComponent unwind already pops via popHostContext which handles the viewTransitionCursor.
packages/react-reconciler/src/ReactFiberBeginWork.js Correctly adds pushViewTransitionContext in both the normal update path (updateViewTransition) and the early-bailout path (attemptEarlyBailoutIfNoScheduledUpdate) for ViewTransitionComponent.
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js Adds supportsViewTransition = true and a correct no-op finalizeViewTransitionChild for DOM, since view flattening is a React Native-only concept.
packages/react-art/src/ReactFiberConfigART.js Removes inline view-transition no-ops and re-exports from ReactFiberConfigWithNoViewTransition; as a result loses getCurrentGestureOffset and GestureTimeline which were previously defined directly.
packages/react-native-renderer/src/ReactFiberConfigNative.js Same as ART: removes inline view-transition implementations in favour of re-exporting from ReactFiberConfigWithNoViewTransition, but loses getCurrentGestureOffset and GestureTimeline in the process.
packages/react-reconciler/src/ReactFiberCommitViewTransitions.js Adds enableViewTransitionForPersistenceMode gating and imports; refactoring looks correct but depends on the new supportsViewTransition flag rather than supportsMutation.
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js Correctly adds supportsViewTransition and finalizeViewTransitionChild delegations to $$$config for third-party renderer consumers.

Comments Outside Diff (1)

  1. packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js, line 331-336 (link)

    addViewTransitionFinishedListener ignores the running transition

    The transition argument is completely ignored — callback() is called synchronously, before the animation has completed. This is used by the reconciler to invoke user-supplied view-transition event cleanup functions (the return value of onUpdate/onEnter etc. handlers), which are expected to run after the animation finishes. Calling them immediately means any teardown that depends on the animation being done (e.g. removing snapshot data, restoring styles) will fire while the animation is still in flight.

    Compare to startViewTransition, which correctly chains onto the Promise:

    transition.finished.finally(() => {
      passiveCallback();
    });

    The same pattern should be applied here:

Fix All in Claude Code Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js
Line: 331-336

Comment:
**`addViewTransitionFinishedListener` ignores the running transition**

The `transition` argument is completely ignored — `callback()` is called synchronously, before the animation has completed. This is used by the reconciler to invoke user-supplied view-transition event cleanup functions (the return value of `onUpdate`/`onEnter` etc. handlers), which are expected to run *after* the animation finishes. Calling them immediately means any teardown that depends on the animation being done (e.g. removing snapshot data, restoring styles) will fire while the animation is still in flight.

Compare to `startViewTransition`, which correctly chains onto the Promise:
```js
transition.finished.finally(() => {
  passiveCallback();
});
```

The same pattern should be applied here:
```suggestion
export function addViewTransitionFinishedListener(
  transition: RunningViewTransition,
  callback: () => void,
): void {
  transition.finished.then(callback);
}
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: packages/react-reconciler/src/ReactFiberConfigWithNoViewTransition.js
Line: 43

Comment:
**Missing `GestureTimeline` type and `getCurrentGestureOffset` export**

`ReactFiberConfigART.js` and `ReactFiberConfigNative.js` both previously defined `GestureTimeline = null` and `getCurrentGestureOffset` (which threw a descriptive "not supported" error) as direct exports. This PR removes both and replaces them with a re-export from this file — but this file doesn't include either export.

Because neither renderer exports from `ReactFiberConfigWithNoMutation` (where `getCurrentGestureOffset = shim` now lives), and `ReactFiberGestureScheduler` imports `getCurrentGestureOffset` from `ReactFiberConfig`, those renderers will now silently get `undefined` at runtime. If `getCurrentGestureOffset` is ever invoked on an ART or Native OSS root, the result will be an unhelpful `TypeError: getCurrentGestureOffset is not a function` instead of the previous explicit error message.

Adding these to this file would restore the previous behavior:
```js
export type GestureTimeline = null;
export const getCurrentGestureOffset = shim;
```

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 0d55141

Comment on lines +239 to +268
const transition = fabricStartViewTransition(
// mutation
() => {
mutationCallback(); // completeRoot should run here
layoutCallback();
afterMutationCallback();
},
);

if (transition == null) {
if (__DEV__) {
console.warn(
"startViewTransition didn't kick off transition in Fabric, the ViewTransition ReactNativeFeatureFlag might not be enabled.",
);
}
// Flush remaining work synchronously.
mutationCallback();
layoutCallback();
// Skip afterMutationCallback(). We don't need it since we're not animating.
spawnedWorkCallback();
// Skip passiveCallback(). Spawned work will schedule a task.
return null;
}

transition.ready.then(() => {
spawnedWorkCallback();
});

transition.finished.finally(() => {
passiveCallback();
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

fabricStartViewTransition called with wrong arity and return type mismatch

The Flow type added in scripts/flow/react-native-host-hooks.js declares nativeFabricUIManager.startViewTransition as:

startViewTransition: (
  mutationCallback: () => void,
  onReady: () => void,
  onComplete: () => void,
) => boolean

But fabricStartViewTransition is called here with only 1 argument, omitting onReady and onComplete. If the native side invokes those callbacks, it will call undefined() and crash.

More critically, the return type is declared as boolean, but the code immediately treats it as an object with .ready and .finished Promise properties. A boolean true (successful transition) would pass the transition == null check (since true != null), and then transition.ready.then(...) on line 263 would throw a TypeError: Cannot read properties of true (reading 'ready').

Either the Flow type declaration for startViewTransition is incorrect (it should return RunningViewTransition | null and accept 1 callback), or the call site here is missing the two callback arguments. The type and the usage need to be reconciled.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js
Line: 239-268

Comment:
**`fabricStartViewTransition` called with wrong arity and return type mismatch**

The Flow type added in `scripts/flow/react-native-host-hooks.js` declares `nativeFabricUIManager.startViewTransition` as:
```
startViewTransition: (
  mutationCallback: () => void,
  onReady: () => void,
  onComplete: () => void,
) => boolean
```

But `fabricStartViewTransition` is called here with only **1 argument**, omitting `onReady` and `onComplete`. If the native side invokes those callbacks, it will call `undefined()` and crash.

More critically, the return type is declared as `boolean`, but the code immediately treats it as an object with `.ready` and `.finished` Promise properties. A boolean `true` (successful transition) would pass the `transition == null` check (since `true != null`), and then `transition.ready.then(...)` on line 263 would throw a `TypeError: Cannot read properties of true (reading 'ready')`.

Either the Flow type declaration for `startViewTransition` is incorrect (it should return `RunningViewTransition | null` and accept 1 callback), or the call site here is missing the two callback arguments. The type and the usage need to be reconciled.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Comment on lines +43 to +57
interface ViewTransitionPseudoElementType extends mixin$Animatable {
_scope: HTMLElement;
_selector: string;
getComputedStyle(): CSSStyleDeclaration;
}

function ViewTransitionPseudoElement(
this: ViewTransitionPseudoElementType,
pseudo: string,
name: string,
) {
// TODO: Get the owner document from the root container.
this._pseudo = pseudo;
this._name = name;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

DOM types and mismatched property names in RN interface

This ViewTransitionPseudoElementType interface references HTMLElement and CSSStyleDeclaration, which are DOM-specific types that do not exist in the React Native environment. Beyond the type-checking errors this will produce under Flow, the property names in the interface (_scope, _selector) don't match the properties the constructor actually sets: this._pseudo and this._name. The interface is effectively describing a different object than what ViewTransitionPseudoElement constructs.

This looks like it was copied from the DOM view-transition implementation without being adapted for React Native. The interface should either be removed or updated to reflect the actual RN structure (using _pseudo: string and _name: string).

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-native-renderer/src/ReactFiberConfigFabricWithViewTransition.js
Line: 43-57

Comment:
**DOM types and mismatched property names in RN interface**

This `ViewTransitionPseudoElementType` interface references `HTMLElement` and `CSSStyleDeclaration`, which are DOM-specific types that do not exist in the React Native environment. Beyond the type-checking errors this will produce under Flow, the property names in the interface (`_scope`, `_selector`) don't match the properties the constructor actually sets: `this._pseudo` and `this._name`. The interface is effectively describing a different object than what `ViewTransitionPseudoElement` constructs.

This looks like it was copied from the DOM view-transition implementation without being adapted for React Native. The interface should either be removed or updated to reflect the actual RN structure (using `_pseudo: string` and `_name: string`).

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Comment on lines +1449 to +1453
// Ensure memoizedProps reflects the finalized props so that
// future renders diff against the correct props.
if (instanceProps !== newProps) {
workInProgress.memoizedProps = instanceProps;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Silent override of user-specified collapsable prop

finalizeViewTransitionChild in the Fabric config uses Object.assign({}, props, {collapsable: false}), which unconditionally overrides any collapsable prop the user may have explicitly set on their component. A developer who writes <View collapsable={true}> as a direct child of <ViewTransition> will silently have their prop ignored — the renderer will receive collapsable: false without any indication.

A __DEV__-only warning when the user's original props contain collapsable: true (i.e., explicitly opting into flattening) would help surface this conflict to developers during development.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/react-reconciler/src/ReactFiberCompleteWork.js
Line: 1449-1453

Comment:
**Silent override of user-specified `collapsable` prop**

`finalizeViewTransitionChild` in the Fabric config uses `Object.assign({}, props, {collapsable: false})`, which unconditionally overrides any `collapsable` prop the user may have explicitly set on their component. A developer who writes `<View collapsable={true}>` as a direct child of `<ViewTransition>` will silently have their prop ignored — the renderer will receive `collapsable: false` without any indication.

A `__DEV__`-only warning when the user's original props contain `collapsable: true` (i.e., explicitly opting into flattening) would help surface this conflict to developers during development.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

zeyap added 16 commits March 11, 2026 15:35
Wherever the reconciler checks supportsMutation before running view
transition logic, add an else-if branch gated on
enableViewTransitionForPersistenceMode for persistent renderers (Fabric).

This follows the pattern that supportsMutation guards mutation-mode-only
logic, and persistent mode should have its own branch rather than being
lumped under a single supportsViewTransition capability flag.

For now the persistent mode branches duplicate the mutation logic.
The flag defaults to false in all channels.
Summary:
- turn on enableViewTransition feature
- stub shim
- run some mutation config fn at persistence mode too
Better reflects that this signals the transition has started rather than
initiating it.
Add finalizeViewTransitionChild renderer config function called during
completeWork for HostComponents that are direct children of a
ViewTransition boundary. Fabric implements it by injecting
collapsable: false to prevent native view flattening. DOM is a no-op.

Uses a stack cursor (viewTransitionCursor) to track whether the current
fiber is inside a ViewTransition — ViewTransition pushes true,
HostComponent pushes false (acting as a boundary).
@everettbu everettbu force-pushed the viewtransition-child-non-collapsible branch from 85b3cb9 to 0d55141 Compare March 11, 2026 23:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants