Skip to content

Bug: <ViewTransition> name-prop change does not trigger view transition without child DOM mutation #35983

@kjanat

Description

@kjanat

A <ViewTransition> whose name prop changes between renders does not trigger the view transition update pipeline unless a child DOM mutation also occurs. Hero morphs via name-prop toggling on mounted components silently produce no animation.

React version

  • react: 19.3.0-canary-46103596-20260305
  • react-dom: 19.3.0-canary-46103596-20260305

Steps to reproduce

  1. Mount two <ViewTransition> boundaries wrapping a button and a dialog
  2. Toggle the name prop between them (one gets "camera-hero", the other undefined) via startTransition + addTransitionType
  3. No child DOM mutations occur during the transition

Reproduction repo: https://github.com/kjanat/react-canary-19.3.0-view-transition-bug
Live preview: https://kjanat.github.io/react-canary-19.3.0-view-transition-bug/
CodeSandbox: https://codesandbox.io/p/sandbox/yx5sdy

Toggle between Broken and Workaround modes to see the difference.

Minimal repro component:

<ViewTransition
  name={heroOwner === 'button' ? 'camera-hero' : undefined}
  share="camera-hero-morph"
  default="none"
>
  <button>Use live camera</button>
</ViewTransition>

<ViewTransition
  name={heroOwner === 'dialog' ? 'camera-hero' : undefined}
  share="camera-hero-morph"
  default="none"
>
  <dialog ref={dialogRef}>{modalContent}</dialog>
</ViewTransition>

The heroOwner state toggles between 'button' and 'dialog' inside startTransition with addTransitionType('camera-modal').

Expected behavior

Opening the modal morphs smoothly from the trigger button to the dialog (shared camera-hero element with CSS view-transition animations).

Actual behavior

Modal appears instantly -- no hero morph animation. See root cause §1 for the code path that leads to a zero-opacity cancellation.

Workaround

Two changes required simultaneously (visible in workaround mode in the reproduction):

  1. update prop -- unblocks VT processing (otherwise default='none' gates it out):
    update={{ default: 'none', 'camera-modal': 'camera-hero-morph' }}
  2. Forced child DOM mutation -- sets the internal Update flag on the VT fiber:
    <button data-hero-owner={heroOwner}>Use live camera</button>

The data-hero-owner attribute serves no purpose other than forcing a child DOM mutation that sets a flag which logically should depend on the name change itself.

Root cause (source references)

All references below are GitHub permalinks to React commit 4610359651fa10247159e2050f8ec222cb7faa91, matching react-dom@19.3.0-canary-46103596-20260305.

1. Name-only changes do not produce Update

In measureViewTransitionHostInstancesRecursive, the Update flag gates whether applyViewTransitionName fires for the new snapshot:

(parentViewTransition.flags & Update) !== NoFlags
  && applyViewTransitionName(instance, newName, className);

In this code path, the Update flag is set in the mutation phase (ReactFiberCommitWork.js#L2634-L2645) when viewTransitionMutationContext is true:

viewTransitionMutationContext && (finishedWork.flags |= Update);

viewTransitionMutationContext becomes true when a DOM mutation (attribute change, text change, element insertion/removal) occurs within the VT's child subtree during the mutation commit. The Update flag can also be set during measurement if hasInstanceChanged() detects a bounding-rect change (L676-681), but in this scenario (name-only prop change, no layout shift), measurements are identical and this path returns false.

When only the VT's name prop changes, with no child mutations and no layout change, the flag stays NoFlags. Without Update, the instance is not passed to applyViewTransitionName but is instead pushed to viewTransitionCancelableChildren, which leads to a zero-opacity cancellation path in ReactFiberConfigDOM.js#L1616-L1640:

current.animate(
  { opacity: [0, 0], pointerEvents: ['none', 'none'] },
  { duration: 0, fill: 'forwards', pseudoElement: '::view-transition-group(' + oldName + ')' },
);

Note: this cancellation path is only reachable when the update prop unblocks before-mutation snapshot capture and after-mutation measurement. With default='none' and no update prop, host instances never receive VT names in the first place, so they never reach viewTransitionCancelableChildren. The zero-opacity cancellation manifests specifically in the partial-workaround state (setting update without also forcing a DOM mutation).

React already detects the name change in beginWork at ReactFiberBeginWork.js#L3617-L3622:

if (current !== null && current.memoizedProps.name !== pendingProps.name) {
  workInProgress.flags |= Ref | RefStatic;
}

This sets Ref | RefStatic for ref lifecycle, but it does not propagate into the commit phase as a VT update signal.

2. default='none' gates out the update path (compounding)

The share prop name suggests it controls shared-element/hero morphs, but in the source it is consulted in the enter/exit pair path (commitEnterViewTransitions and commitExitViewTransitions) and not in the update path. When both VTs stay mounted and only change their name prop, React routes through the update path, which calls getViewTransitionClassName(props.default, props.update) in two phases:

Phase Source permalink Gate
Before-mutation ReactFiberCommitViewTransitions.js#L489-L513 Class name === 'none' → skip old-snapshot VT name application
After-mutation ReactFiberCommitViewTransitions.js#L768-L803 Class name === 'none' → skip measurement and new-snapshot VT name

The mutation phase (ReactFiberCommitWork.js#L2623-L2633) also checks the class name to set inUpdateViewTransition, but this variable controls Portal context propagation in the traced code path -- the Update flag assignment at L2634-L2645 is gated by viewTransitionMutationContext, not by the class name.
So default='none' blocks the before/after-mutation phases, while the missing DOM mutation independently blocks the Update flag.

3. Separate observation: share not consulted in update path

The share prop is only consulted in the enter/exit pair path and not in the update path. This may be by-design (the update path handles DOM mutations, not shared-element pairing), but the documentation is silent on the expected behavior when only name changes on a mounted <ViewTransition>.

Suggested fix

The name-change detection in beginWork (current.memoizedProps.name !== pendingProps.name) could additionally set the Update flag or an equivalent signal, so the commit phase treats the VT as updated even without child DOM mutations.

A cleaner alternative is to keep the fix local to the mutation-phase commit handler for ViewTransitionComponent, adding a name-change check alongside viewTransitionMutationContext:

} else if (
  viewTransitionMutationContext
  || current.memoizedProps.name !== finishedWork.memoizedProps.name
) {
  finishedWork.flags |= Update;
}

This would keep the Update flag closer to its apparent current usage (set during mutation phase, consumed in measurement) without changing beginWork flag behavior.

Additionally, the share className may need to be consulted in the update path when a name change is detected, not only in the enter/exit pair path.

Docs note

The <ViewTransition> documentation distinguishes share (enter/exit pairing) from update (DOM mutations / layout effects), but does not clarify what should happen when only the name prop changes across already-mounted <ViewTransition> boundaries. It's unclear whether mounted name toggling is expected to produce a transition or is intentionally unsupported.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Status: UnconfirmedA potential issue that we haven't yet confirmed as a bug

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions