Skip to content

Dialog cannot be closed after a MessageBar inside it unmounts (shared MotionRefForwarder context collision) #35805

@layershifter

Description

@layershifter

Bug Report

Package version

@fluentui/react-components@9.73.1 (works correctly in 9.72.10)

Description

A Dialog becomes impossible to close (both via DialogTrigger close button and programmatic setIsOpen(false)) after a MessageBar rendered inside its content unmounts.

Reproduction

Stackblitz: https://stackblitz.com/github/layershifter/fluentui-motion-ref-collision-repro

Repo: https://github.com/layershifter/fluentui-motion-ref-collision-repro

Inline code
import { useState, useEffect } from "react";
import {
  FluentProvider,
  webLightTheme,
  Dialog,
  DialogTrigger,
  DialogSurface,
  DialogBody,
  DialogTitle,
  DialogContent,
  DialogActions,
  Button,
  MessageBar,
  MessageBarBody,
} from "@fluentui/react-components";

function App() {
  const [isOpen, setIsOpen] = useState(false);
  const [showMessageBar, setShowMessageBar] = useState(true);

  useEffect(() => {
    if (isOpen) {
      setShowMessageBar(true);
      const timer = setTimeout(() => setShowMessageBar(false), 2000);
      return () => clearTimeout(timer);
    }
  }, [isOpen]);

  return (
    <FluentProvider theme={webLightTheme}>
      <Dialog
        open={isOpen}
        onOpenChange={(_event, data) => setIsOpen(data.open)}
      >
        <DialogTrigger disableButtonEnhancement>
          <Button>Open Dialog</Button>
        </DialogTrigger>
        <DialogSurface>
          <DialogBody>
            <DialogTitle>Bug Repro</DialogTitle>
            <DialogContent>
              {showMessageBar && (
                <MessageBar>
                  <MessageBarBody>
                    This message disappears in 2s — then the dialog breaks.
                  </MessageBarBody>
                </MessageBar>
              )}
              <p>After the MessageBar disappears, try closing this dialog.</p>
            </DialogContent>
            <DialogActions>
              <DialogTrigger disableButtonEnhancement>
                <Button appearance="secondary">Close</Button>
              </DialogTrigger>
              <Button appearance="primary" onClick={() => setIsOpen(false)}>
                Action & Close
              </Button>
            </DialogActions>
          </DialogBody>
        </DialogSurface>
      </Dialog>
    </FluentProvider>
  );
}

Steps to reproduce

  1. Open the Dialog
  2. Wait 2 seconds for the MessageBar to unmount
  3. Click "Close" or "Action & Close"

Expected behavior

The Dialog closes normally.

Actual behavior

The Dialog does not close. onOpenChange fires, setIsOpen(false) is called, but the surfaceMotion presence component cannot run the exit animation because childRef.current was set to null when the MessageBar unmounted.

Root Cause

Introduced by #35774 ("feat(react-motion): add MotionRefForwarder helper").

That PR moved MotionRefForwarder and useMotionForwardedRef from private per-package modules to a shared export in @fluentui/react-motion. This means react-dialog and react-message-bar now share the same React.createContext() instance (MotionRefForwarderContext).

Collision mechanism:

  1. renderDialog wraps content with <MotionRefForwarder>, providing the surfaceMotion's ref to the shared context
  2. A standalone <MessageBar> inside the Dialog calls useMotionForwardedRef() in useMessageBar — reads the Dialog's motion ref (nearest provider on the now-shared context)
  3. useMergedRefs(ref, ..., motionRef) in useMessageBar assigns the MessageBar's DOM element to the Dialog's childRef.current, overwriting the Dialog surface element reference
  4. When the MessageBar unmounts → childRef.current is set to null
  5. The Dialog's surfaceMotion presence component can no longer find the element to run the exit animation → Dialog stays open

Previous behavior (9.72.10): Each package had its own private MotionRefForwarderContext (react-dialog/lib/components/MotionRefForwarder.js and react-message-bar/lib/components/MotionRefForwarder.js each called React.createContext() independently). A MessageBar inside a Dialog would call useMotionForwardedRef() on its own private context → no provider → returned undefined → harmlessly ignored by useMergedRefs.

Affected packages

  • @fluentui/react-dialog (9.16.6 → 9.17.1)
  • @fluentui/react-message-bar (9.6.17 → 9.6.19)
  • @fluentui/react-motion (9.11.6 → 9.12.0)

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions