Fix ViewTransition jank
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-09-20 20:05:50 +03:00
parent f2915a384c
commit 7e95ae2eb3
9 changed files with 163 additions and 17 deletions

View File

@@ -8,6 +8,10 @@ import {
type SendouButtonProps,
} from "~/components/elements/Button";
import { useHydrated } from "~/hooks/useHydrated";
import {
useReportModalOpen,
useTopLayerViewTransitionStyle,
} from "~/utils/view-transition";
import styles from "./Dialog.module.css";
interface DialogElementProps {
@@ -35,6 +39,7 @@ interface DialogElementProps {
*/
export function SendouModal({ ref, ...rest }: DialogElementProps) {
const isHydrated = useHydrated();
useReportModalOpen(isHydrated);
if (!isHydrated) return null;
return createPortal(
@@ -67,10 +72,13 @@ function DialogElement({
children,
ref,
}: DialogElementProps) {
const topLayerStyle = useTopLayerViewTransitionStyle();
return (
<dialog
ref={ref}
id={id}
style={topLayerStyle}
className={clsx(className, {
[styles.blurredBackdrop]: blurredBackdrop,
})}
@@ -187,6 +195,7 @@ function TriggeredDialog({
const dialogId = React.useId();
const dialogRef = React.useRef<HTMLDialogElement>(null);
const [open, setOpen] = React.useState(false);
useReportModalOpen(open);
const [contentKey, remountContent] = React.useReducer(
(key: number) => key + 1,

View File

@@ -6,6 +6,7 @@ import {
focusMoveForKey,
rovingFocusIndex,
} from "~/utils/roving-focus";
import { useTopLayerViewTransitionStyle } from "~/utils/view-transition";
import { Image } from "../Image";
import { useAnchorPositioning } from "./anchor-positioning";
import styles from "./Menu.module.css";
@@ -44,6 +45,7 @@ export function SendouMenu({
eager,
}: SendouMenuProps) {
const uid = useAnchorSafeId();
const topLayerStyle = useTopLayerViewTransitionStyle();
const popoverId = `${uid}-menu`;
const anchorName = `--menu-anchor-${uid}`;
@@ -132,7 +134,12 @@ export function SendouMenu({
[styles.scrolling]: scrolling,
[styles.opensLeft]: opensLeft,
})}
style={{ positionAnchor: anchorName } as React.CSSProperties}
style={
{
positionAnchor: anchorName,
...topLayerStyle,
} as React.CSSProperties
}
data-placement={placement}
onBeforeToggle={onBeforeToggle}
onToggle={onToggle}

View File

@@ -2,6 +2,7 @@ import clsx from "clsx";
import * as React from "react";
import { flushSync } from "react-dom";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import { useTopLayerViewTransitionStyle } from "~/utils/view-transition";
import {
type AnchorPlacement,
useAnchorPositioning,
@@ -117,6 +118,7 @@ export function SendouPopover({
const popoverRef = React.useRef<HTMLDivElement>(null);
const triggerContainerRef = React.useRef<HTMLSpanElement>(null);
const topLayerStyle = useTopLayerViewTransitionStyle();
const setOpen = (next: boolean) => {
if (!isControlled) {
@@ -200,7 +202,12 @@ export function SendouPopover({
id={popoverId}
popover="auto"
className={clsx(styles.content, popoverClassName)}
style={{ positionAnchor: anchorName } as React.CSSProperties}
style={
{
positionAnchor: anchorName,
...topLayerStyle,
} as React.CSSProperties
}
role="dialog"
tabIndex={-1}
data-placement={placement}
@@ -232,6 +239,7 @@ export function SendouAnchoredPopover({
const anchorName = `--popover-anchor-${uid}`;
const popoverRef = React.useRef<HTMLDivElement>(null);
const topLayerStyle = useTopLayerViewTransitionStyle();
// before the positioning effect, so the content is placed by its first paint
useIsomorphicLayoutEffect(() => {
@@ -278,7 +286,9 @@ export function SendouAnchoredPopover({
ref={popoverRef}
popover="auto"
className={styles.content}
style={{ positionAnchor: anchorName } as React.CSSProperties}
style={
{ positionAnchor: anchorName, ...topLayerStyle } as React.CSSProperties
}
role="dialog"
tabIndex={-1}
aria-label={ariaLabel}

View File

@@ -6,6 +6,7 @@ import { SendouBottomTexts } from "~/components/elements/BottomTexts";
import { SendouButton } from "~/components/elements/Button";
import { useIsomorphicLayoutEffect } from "~/hooks/useIsomorphicLayoutEffect";
import { type FocusMove, rovingFocusIndex } from "~/utils/roving-focus";
import { useTopLayerViewTransitionStyle } from "~/utils/view-transition";
import { Image } from "../Image";
import { useAnchorPositioning } from "./anchor-positioning";
import {
@@ -144,6 +145,7 @@ export function SendouSelect<T extends object>({
}: SendouSelectProps<T>) {
const { t } = useTranslation(["common"]);
const uid = useAnchorSafeId();
const topLayerStyle = useTopLayerViewTransitionStyle();
const popoverId = `${uid}-select-popover`;
const listboxId = `${uid}-select-listbox`;
const anchorName = `--select-anchor-${uid}`;
@@ -573,7 +575,12 @@ export function SendouSelect<T extends object>({
id={popoverId}
popover="auto"
className={clsx(styles.popover, popoverClassName)}
style={{ positionAnchor: anchorName } as React.CSSProperties}
style={
{
positionAnchor: anchorName,
...topLayerStyle,
} as React.CSSProperties
}
onBeforeToggle={onPopoverBeforeToggle}
onToggle={onPopoverToggle}
onKeyDown={onPopoverKeyDown}

View File

@@ -30,7 +30,10 @@ import {
TIERS_PAGE,
tierImageUrl,
} from "~/utils/urls";
import { finishUpdateIfUnmoved } from "~/utils/view-transition";
import {
finishUpdateIfUnmoved,
usePageViewTransitionClass,
} from "~/utils/view-transition";
import type {
SQGroup,
SQGroupMember,
@@ -307,10 +310,12 @@ function GroupCardContainer({
isOwnGroup: boolean;
children: React.ReactNode;
}) {
const updateClass = usePageViewTransitionClass("card-update");
if (isOwnGroup) return <>{children}</>;
return (
<ViewTransition update="card-update" onUpdate={finishUpdateIfUnmoved}>
<ViewTransition update={updateClass} onUpdate={finishUpdateIfUnmoved}>
{children}
</ViewTransition>
);

View File

@@ -24,7 +24,10 @@ import type { UnifiedLanguageCode } from "~/modules/i18n/config";
import { languagesUnified } from "~/modules/i18n/config";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { navIconUrl } from "~/utils/urls";
import { finishUpdateIfUnmoved } from "~/utils/view-transition";
import {
finishUpdateIfUnmoved,
usePageViewTransitionClass,
} from "~/utils/view-transition";
import {
lookingSchema,
updateGroupFormSchema,
@@ -182,10 +185,12 @@ function LFGGroupCardContainer({
isOwnGroup: boolean;
children: React.ReactNode;
}) {
const updateClass = usePageViewTransitionClass("card-update");
if (isOwnGroup) return <>{children}</>;
return (
<ViewTransition update="card-update" onUpdate={finishUpdateIfUnmoved}>
<ViewTransition update={updateClass} onUpdate={finishUpdateIfUnmoved}>
{children}
</ViewTransition>
);

View File

@@ -82,6 +82,10 @@ import "~/styles/common.css";
import "~/styles/utils.css";
import "~/styles/flags.css";
import "nprogress/nprogress.css";
import {
OpenModalsContext,
useHoverCursorForViewTransitions,
} from "~/utils/view-transition";
const PRELOAD_TRANSLATION_TIMEOUT_MS = 3000;
@@ -199,6 +203,7 @@ function Document({
usePreloadTranslation();
useLoadingIndicator();
useTriggerToasts();
useHoverCursorForViewTransitions();
const htmlStyle: Record<string, string | number> = {
...Object.fromEntries(customThemeStyle),
@@ -427,6 +432,7 @@ function useCustomThemeVars() {
export default function App() {
const rootData = useLoaderData<RootLoaderData>();
const [openModals, setOpenModals] = React.useState(0);
// Move overflow:hidden from html to body to allow position: sticky and position: fixed
// elements to work properly when a React Aria Component disabled scrolling
@@ -467,14 +473,16 @@ export default function App() {
}, []);
return (
<ThemeProvider
specifiedTheme={isTheme(rootData.theme) ? rootData.theme : null}
themeSource="user-preference"
>
<Document data={rootData}>
<Outlet />
</Document>
</ThemeProvider>
<OpenModalsContext value={{ count: openModals, setCount: setOpenModals }}>
<ThemeProvider
specifiedTheme={isTheme(rootData.theme) ? rootData.theme : null}
themeSource="user-preference"
>
<Document data={rootData}>
<Outlet />
</Document>
</ThemeProvider>
</OpenModalsContext>
);
}

View File

@@ -17,12 +17,31 @@
pointer-events: none;
}
/* captured elements are not hit tested while they animate, keep the cursor they had (see useHoverCursorForViewTransitions) */
html:active-view-transition {
cursor: var(--hover-cursor, auto);
}
/* a card React decided not to animate is left as an old-only snapshot that would still
fade for the full duration, holding the next render for a transition of no change */
::view-transition-old(.card-update):only-child {
animation-duration: 0s;
}
/* top layer elements (see useTopLayerViewTransitionStyle) show their live state above the rest */
::view-transition-group(.top-layer) {
z-index: 2;
animation: none;
}
::view-transition-old(.top-layer) {
display: none;
}
::view-transition-new(.top-layer) {
animation: none;
}
body {
width: 100%;
min-height: 100vh;

View File

@@ -1,4 +1,5 @@
import type { ViewTransitionInstance } from "react";
import * as React from "react";
interface PseudoElement {
getAnimations(): Animation[];
@@ -11,6 +12,16 @@ type PseudoElements = Record<
>;
const MEASURED_PROPERTIES = ["transform", "width", "height"] as const;
const NUMBER_PATTERN = /-?\d*\.?\d+/g;
const HOVER_CURSOR_PROPERTY = "--hover-cursor";
/** How many modal dialogs are open, provided by the root. */
export const OpenModalsContext = React.createContext<{
count: number;
setCount: React.Dispatch<React.SetStateAction<number>>;
}>({ count: 0, setCount: () => {} });
/** Keyframes and computed style serialize lengths at different precisions (209.328px vs 209.312px). */
const SUBPIXEL_TOLERANCE = 0.5;
/**
* `onUpdate` handler for a `<ViewTransition>` that ends its animation right away when the
@@ -53,6 +64,71 @@ function hasMoved(groupAnimation: Animation, group: PseudoElement) {
groupAnimation.currentTime = startTime;
return MEASURED_PROPERTIES.some(
(property, i) => property in from && from[property] !== to[i],
(property, i) =>
property in from && !withinTolerance(String(from[property]), to[i]),
);
}
function withinTolerance(a: string, b: string) {
if (a.replace(NUMBER_PATTERN, "") !== b.replace(NUMBER_PATTERN, "")) {
return false;
}
const aNumbers = a.match(NUMBER_PATTERN) ?? [];
const bNumbers = b.match(NUMBER_PATTERN) ?? [];
return aNumbers.every(
(value, i) =>
Math.abs(Number(value) - Number(bNumbers[i])) <= SUBPIXEL_TOLERANCE,
);
}
/**
* Inline style for an element shown in the top layer (a popover or dialog). Snapshots paint above
* the top layer, so unless the element is captured on its own the page's transitions cover it.
* `common.css` keeps the capture static and on top of the others.
*/
export function useTopLayerViewTransitionStyle(): React.CSSProperties {
const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
return {
viewTransitionName: `top-layer-${uid}`,
viewTransitionClass: "top-layer",
};
}
/**
* Records the cursor of the hovered element on the root. While a transition runs the browser
* skips the captured elements when hit testing, so the cursor would fall back to the page's for
* a few frames; `common.css` shows the recorded one instead.
*/
export function useHoverCursorForViewTransitions() {
React.useEffect(() => {
const onPointerOver = (event: PointerEvent) => {
if (!(event.target instanceof Element)) return;
document.documentElement.style.setProperty(
HOVER_CURSOR_PROPERTY,
getComputedStyle(event.target).cursor,
);
};
document.addEventListener("pointerover", onPointerOver, { passive: true });
return () => document.removeEventListener("pointerover", onPointerOver);
}, []);
}
/** Counts the calling dialog as an open modal while `isOpen`, see `usePageViewTransitionClass`. */
export function useReportModalOpen(isOpen: boolean) {
const { setCount } = React.useContext(OpenModalsContext);
React.useEffect(() => {
if (!isOpen) return;
setCount((count) => count + 1);
return () => setCount((count) => count - 1);
}, [isOpen, setCount]);
}
/**
* The `<ViewTransition>` class for an element of the page: `"none"` while a modal dialog is open.
* Snapshots paint above the dialog's backdrop, so a transition under it would show the page
* unblurred for a few frames, and there is nothing worth animating under a modal anyway.
*/
export function usePageViewTransitionClass(className: string) {
return React.useContext(OpenModalsContext).count > 0 ? "none" : className;
}