mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-26 13:17:15 -05:00
Fix ViewTransition jank
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
24
app/root.tsx
24
app/root.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user