diff --git a/.changeset/mosaic-fix-freeze.md b/.changeset/mosaic-fix-freeze.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-fix-freeze.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/mosaic/src/primitives/tabs/tabs.test.tsx b/packages/mosaic/src/primitives/tabs/tabs.test.tsx
index 06553bf7bee..75aee175160 100644
--- a/packages/mosaic/src/primitives/tabs/tabs.test.tsx
+++ b/packages/mosaic/src/primitives/tabs/tabs.test.tsx
@@ -1,6 +1,6 @@
import { act, cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { createRef, useState } from 'react';
+import { createRef, startTransition, useState } from 'react';
import { renderToString } from 'react-dom/server';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -155,6 +155,39 @@ describe('Tabs', () => {
expect(screen.getByText('Settings content')).toBeVisible();
});
+ it('switches panels when the value changes inside a transition', async () => {
+ function Controlled() {
+ const [value, setValue] = useState('tab1');
+ return (
+ <>
+
+
+
+ Account
+ Settings
+
+ Account content
+ Settings content
+
+ >
+ );
+ }
+ render();
+
+ await act(() => screen.getByText('Go to settings').click());
+
+ expect(screen.getByText('Settings')).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getByText('Settings content')).toBeVisible();
+ });
+
it('marks selected tab with data-selected', () => {
renderTabs();
expect(screen.getByText('Account')).toHaveAttribute('data-selected', '');
diff --git a/packages/mosaic/src/primitives/utils/freeze.test.tsx b/packages/mosaic/src/primitives/utils/freeze.test.tsx
index b3f8f3773e6..55eb8770636 100644
--- a/packages/mosaic/src/primitives/utils/freeze.test.tsx
+++ b/packages/mosaic/src/primitives/utils/freeze.test.tsx
@@ -116,4 +116,268 @@ describe('Freeze', () => {
expect(screen.getByText('Globex')).toBeInTheDocument();
});
+
+ describe('inside a transition', () => {
+ function Harness() {
+ const [frozen, setFrozen] = React.useState(false);
+ const [label, setLabel] = React.useState('Acme');
+ return (
+ <>
+
+
+
+
+ {label}
+ >
+ );
+ }
+
+ it('holds the frame from before the flip, and lets the transition commit around it', async () => {
+ render();
+
+ await act(() => screen.getByText('freeze').click());
+ expect(screen.getByRole('status').textContent).toBe('frozen');
+ expect(screen.getByText('Acme')).toBeInTheDocument();
+ expect(screen.queryByText('Globex')).toBeNull();
+
+ await act(() => screen.getByText('relabel').click());
+ expect(screen.getByText('Acme')).toBeInTheDocument();
+ expect(screen.queryByText('Initech')).toBeNull();
+
+ await act(() => screen.getByText('thaw').click());
+ expect(screen.getByText('Initech')).toBeInTheDocument();
+ });
+ });
+
+ describe('under scheduling', () => {
+ function Child({ label, onEffect }: { label: string; onEffect: (label: string) => void }) {
+ React.useEffect(() => {
+ onEffect(label);
+ }, [label, onEffect]);
+ return {label};
+ }
+
+ function Scheduled({ onEffect, strict = false }: { onEffect?: (label: string) => void; strict?: boolean }) {
+ const [frozen, setFrozen] = React.useState(false);
+ const [label, setLabel] = React.useState('Acme');
+ const [tick, setTick] = React.useState(0);
+ const noteEffect = React.useCallback((value: string) => onEffect?.(value), [onEffect]);
+ const tree = (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ return strict ? {tree} : tree;
+ }
+
+ const click = (name: string) => act(() => screen.getByText(name).click());
+ const status = () => screen.getByRole('status').textContent;
+
+ it('does not run the effects of the frame it holds back', async () => {
+ const effects: string[] = [];
+ render( effects.push(label)} />);
+ effects.length = 0;
+
+ await click('freeze later');
+ expect(status()).toBe('frozen:0');
+ expect(screen.getByText('Acme')).toBeInTheDocument();
+ expect(effects).toEqual([]);
+
+ await click('thaw later');
+ expect(screen.getByText('Globex')).toBeInTheDocument();
+ expect(effects).toEqual(['Globex']);
+ });
+
+ it('releases a hold whose transition was thawed before it committed', async () => {
+ render();
+
+ await act(async () => {
+ screen.getByText('freeze later').click();
+ await Promise.resolve();
+ screen.getByText('thaw now').click();
+ });
+
+ expect(status()).toBe('live:0');
+ expect(screen.getByText('Globex')).toBeVisible();
+ });
+
+ it('keeps the transition alive through an unrelated sync update while it is pending', async () => {
+ render();
+
+ await act(async () => {
+ screen.getByText('freeze later').click();
+ await Promise.resolve();
+ screen.getByText('tick').click();
+ });
+
+ expect(status()).toBe('frozen:1');
+ expect(screen.getByText('Acme')).toBeInTheDocument();
+ expect(screen.queryByText('Globex')).toBeNull();
+
+ await click('thaw later');
+ expect(screen.getByText('Globex')).toBeVisible();
+ });
+
+ it('holds the frame committed by a thaw when frozen again right after it', async () => {
+ render();
+ await click('freeze now');
+
+ await click('thaw now');
+ await click('relabel now');
+ expect(screen.getByText('Initech')).toBeVisible();
+
+ await click('freeze now');
+ await click('freeze later');
+
+ expect(status()).toBe('frozen:0');
+ expect(screen.getByText('Initech')).toBeInTheDocument();
+ expect(screen.queryByText('Globex')).toBeNull();
+ });
+
+ it('does not commit a thaw that is re-frozen in the same update', async () => {
+ render();
+ await click('freeze now');
+
+ await act(() => {
+ screen.getByText('thaw now').click();
+ screen.getByText('relabel now').click();
+ screen.getByText('freeze now').click();
+ });
+
+ expect(status()).toBe('frozen:0');
+ expect(screen.getByText('Acme')).toBeInTheDocument();
+ expect(screen.queryByText('Initech')).toBeNull();
+ });
+
+ it('holds the frame from before a transition flip under StrictMode too', async () => {
+ render();
+
+ await click('freeze later');
+ expect(status()).toBe('frozen:0');
+ expect(screen.getByText('Acme')).toBeInTheDocument();
+ expect(screen.queryByText('Globex')).toBeNull();
+
+ await click('thaw now');
+ expect(screen.getByText('Globex')).toBeVisible();
+ });
+
+ it('can unmount while holding', async () => {
+ const { unmount } = render();
+ await click('freeze later');
+ expect(() => unmount()).not.toThrow();
+ });
+
+ it('keeps an inner hold while the outer thaws', async () => {
+ function Nested() {
+ const [outer, setOuter] = React.useState(false);
+ const [inner, setInner] = React.useState(false);
+ const [label, setLabel] = React.useState('Acme');
+ return (
+ <>
+
+
+
+
+ {label}
+
+ >
+ );
+ }
+ render();
+
+ await click('freeze inner');
+ await click('freeze outer');
+ await click('thaw outer and relabel');
+
+ expect(screen.getByText('Acme')).toBeInTheDocument();
+ expect(screen.queryByText('Globex')).toBeNull();
+ });
+ });
});
diff --git a/packages/mosaic/src/primitives/utils/freeze.tsx b/packages/mosaic/src/primitives/utils/freeze.tsx
index bdb5469ebf6..790a986c330 100644
--- a/packages/mosaic/src/primitives/utils/freeze.tsx
+++ b/packages/mosaic/src/primitives/utils/freeze.tsx
@@ -1,58 +1,208 @@
'use client';
+import { useSafeLayoutEffect } from '@clerk/shared/react';
import * as React from 'react';
-/**
- * Never settles. Throwing it suspends the enclosing boundary indefinitely: React keeps
- * rendering the subtree but holds the commit, so the DOM keeps painting its last frame.
- */
-const never = new Promise(() => {});
+interface Flag {
+ subscribe: (listener: () => void) => () => void;
+ read: () => boolean;
+ request: () => void;
+ ack: () => void;
+ release: () => void;
+}
+
+/*
+ The component freezes the last frame seen before frozen flipped true, this
+ is useful to preserve the DOM while exit animations are running.
+
+ All of this is very hacky and I'd rather not rely on it, but the fact is we've built
+ ourselves into a corner relying on this so this is a best effort attempt to make it
+ work for transitions. I don't recommend anyone copying this.
+
+ The React solution for this is ViewTransitions, which shipped in 19.3, but we can't
+ rely on that yet.
+
+ ---
+
+ React has different lanes, sync for immediate updates and async used for concurrent
+ work/transitions, and the Freeze needs to work for both.
+
+ Suspense acts differently depending on which lane it happens in. In the sync
+ lane, the fallback is shown immediately, but for the async one the content
+ is kept on screen. A boundary that is already in a fallback state does not block
+ neither sync nor async lanes.
+
+ The original Freeze version we had (which is commonly shared on the internet),
+ would suspend forever, the async lane would never commit and it would hang forever.
+
+ This createFlag store is the key for working around that by facilitating communication
+ between the sync lane and the async one.
-function Suspend(): null {
+ - `current` is the value `read()` returns and `useSyncExternalStore` hands back as `held`.
+ - `pending` is true while a notify queued by `request` has not fired yet.
+
+ How it works:
+
+ - In render, for both sync and async lanes, request() gets called
+ - This is a side effect in render, but it's at least idempotent
+ - The current/held value is updated immediately, so the same render will suspend
+ - In the sync lane
+ - The insertion effect acks() the request and notify never fires
+ - The suspend triggers the fallback immediately and everything proceeds
+ - In the async lane
+ - The suspend triggers, the old content is kept on screen and the lane won't commit
+ until the promise resolves, this was the original bug
+ - Listeners are notified in a microtask so it doesn't happen in
+ render, but still inside the same JS frame
+ - The notify triggers an immediate sync lane update via uSES, this suspends,
+ which in the sync lane means showing the fallback immediately
+ - The async lane restarts, this time the fallback is already showing
+ and things proceed without blocking the lane
+
+ See the comments further down at the effects to see how the release works.
+*/
+function createFlag(initial: boolean): Flag {
+ let current = initial;
+ let pending = false;
+ const listeners = new Set<() => void>();
+
+ function notify(): void {
+ listeners.forEach(listener => listener());
+ }
+
+ function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+ }
+
+ function read(): boolean {
+ return current;
+ }
+
+ function request(): void {
+ if (current) {
+ return;
+ }
+ current = true;
+ if (pending) {
+ return;
+ }
+ pending = true;
+ queueMicrotask(() => {
+ if (!pending) {
+ return;
+ }
+ pending = false;
+ notify();
+ });
+ }
+
+ function ack(): void {
+ pending = false;
+ }
+
+ function release(): void {
+ if (!current) {
+ return;
+ }
+ current = false;
+ notify();
+ }
+
+ return { subscribe, read, request, ack, release };
+}
+
+function Suspend({ promise }: { promise: Promise }): null {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Suspending is React's thrown-thenable protocol, not an error. `React.use()` would say this more plainly but needs React 19.2; this package supports React 18.
- throw never;
+ throw promise;
}
export interface FreezeProps {
- /** While `true`, the DOM below holds whatever it last committed. */
+ /** While true, the DOM below holds the frame last committed before it became true */
frozen: boolean;
children?: React.ReactNode;
}
-/**
- * Holds its subtree's DOM at the last committed frame while `frozen`. Renders keep
- * happening, they just don't reach the DOM; the pending one commits when `frozen` flips
- * back to `false`.
- *
- * Use it to stop content from visibly changing under an exit animation — a popover that
- * closes because the thing it was showing changed would otherwise swap its contents on the
- * way out.
- */
export function Freeze({ frozen, children }: FreezeProps) {
+ const [flag] = React.useState(() => createFlag(frozen));
+ // One promise per instance: React attaches a retry listener to every promise a
+ // boundary throws, and the listener holds that boundary's fiber and DOM. A shared
+ // promise never settles, so it would keep every Freeze that ever froze alive after
+ // it unmounted. A per-instance promise is collected with the instance.
+ const [never] = React.useState(() => new Promise(() => {}));
+
+ if (frozen) {
+ flag.request();
+ }
+
+ const held = React.useSyncExternalStore(flag.subscribe, flag.read, flag.read);
const contentRef = React.useRef(null);
+ const startedRef = React.useRef(false);
- // Hold onto the node ourselves rather than reading a plain ref: hiding a boundary's children
- // detaches their refs, so by the time the effect below runs a normal ref reads `null`.
+ // This has to be a callback ref. Hiding a boundary detaches the refs below it,
+ // so a plain ref reads null by the time the insertion effect runs
const setContent = React.useCallback((node: HTMLDivElement | null) => {
if (node) {
contentRef.current = node;
}
}, []);
- // React hides a suspended boundary's host children with `display: none !important`, which is
- // the opposite of what this is for. Undo it on the commit that applies it: insertion effects
- // run after the boundary's mutation and before paint, so the held frame never blinks out.
- // `display: contents` is also what the wrapper renders with, so React puts it back on unfreeze
- // and the wrapper stays out of the layout it is spliced into.
+ // This needs to happen before other components run their layout effects, especially
+ // the un-hiding of the content as layout effects might depend on that
React.useInsertionEffect(() => {
- if (frozen) {
+ if (held) {
+ // If this effect is running we are already committing so there is no need to notify,
+ // as this would only trigger and waste a new render we don't need
+ flag.ack();
+ // This is the Freeze hack, unhide the content React hid to show the fallback instead
contentRef.current?.style.setProperty('display', 'contents');
+ // The layout effect needs to know if we are inside the commit that started the hold
+ startedRef.current = true;
+ }
+ }, [held]);
+
+ // This is triggering an update on release, which insertion effects are not allowed to do,
+ // but we still want it in a layout effect and not an effect, a plain effect might happen
+ // after paint has already happened
+ useSafeLayoutEffect(() => {
+ const started = startedRef.current;
+ startedRef.current = false;
+
+ /*
+ If the held was set from an async lane, frozen was also set to true inside
+ that async lane, so back in the sync lane it's still false. This would release
+ in the same commit as we froze, so we use the started guard to say
+ "don't release in the same commit as we held, even if frozen is false".
+
+ The tradeoff of that is that if another sync update happens while the transition
+ is pending, started is now false and frozen is still false, so we release too early.
+ Sync updates always restart the transition, which again requests a hold and we'll
+ be back where we started, just with a few wasted cycles.
+
+ Two tempting fixes for the early release both break abandonment, which is a
+ transition that requests a hold and is then aborted: frozen flips false -> true
+ -> false, but only the two false ever commit, so no commit ever carries true.
+
+ 1. Keeping started true until a commit with frozen true has happened. That commit
+ never comes, so every later commit skips the release and the hold is stuck.
+ 2. Giving this effect a dependency array of held and frozen. Both are the same in
+ the second false commit as in the hold's own commit, so the effect never runs
+ again and the hold is stuck.
+
+ So the effect runs on every commit and the rule stays blunt: a committed frozen
+ false releases, unless this is the commit that started the hold.
+ */
+ const shouldRelease = !frozen && !started;
+ if (held && shouldRelease) {
+ // The release happens synchronously (which is fine as this is not inside render),
+ // so before anything has a chance to painth, React will re-render as unfrozen
+ flag.release();
}
- }, [frozen]);
+ });
return (
- {frozen ? : null}
+ {held ? : null}