perf(app): stop per-frame style recalculation in skeletons and member list (#1681)

This commit is contained in:
Hampus authored and GitHub committed 2026-08-16 22:00:10 +02:00
1 parent 170ca805c5
commit 438f11adda
11 files changed
+434 -93

No files matched your search

+2
View File
@@ -32,6 +32,7 @@ import {startDesktopLocaleBridge} from '@app/features/platform/utils/DesktopLoca
import {PremiumCheckoutReturnWatcher} from '@app/features/premium/components/PremiumCheckoutReturnWatcher';
import {QUICK_SWITCHER_PORTAL_ID} from '@app/features/search/components/quick_switcher/QuickSwitcherConstants';
import {useCustomThemeStyle} from '@app/features/theme/hooks/useCustomThemeStyle';
import {useRemScaleTracking} from '@app/features/theme/hooks/useRemScaleTracking';
import {useThemeCssVariables} from '@app/features/theme/hooks/useThemeCssVariables';
import Theme from '@app/features/theme/state/Theme';
import ThemeLibrary from '@app/features/theme/state/ThemeLibrary';
@@ -169,6 +170,7 @@ export const AppWrapper = observer(({children}: AppWrapperProps) => {
}, [isNative, firstClickPassThroughWhenUnfocused]);
useDesktopAllowTransparency(isNative);
useWindowEventListeners({preventDocumentScroll: !isNative});
useRemScaleTracking();
usePlatformClasses(platform, isNative);
useThemeCssVariables({
effectiveTheme: Theme.effectiveTheme,
@@ -24,6 +24,7 @@ import {
CompassIcon,
DownloadSimpleIcon,
type Icon,
type IconWeight,
PlusIcon,
QuestionMarkIcon,
StarIcon,
@@ -118,7 +119,15 @@ const InlineDMPlaceholder = ({selected, unread}: {readonly selected: boolean; re
</flx-app-guild-rail-skeleton-item>
);
const RailActionButton = ({icon: Icon, selected = false}: {readonly icon: Icon; readonly selected?: boolean}) => (
const RailActionButton = ({
icon: Icon,
selected = false,
weight = 'bold',
}: {
readonly icon: Icon;
readonly selected?: boolean;
readonly weight?: IconWeight;
}) => (
<flx-app-guild-rail-skeleton-item
className={flxElementClassName(styles.item)}
data-selected={selectedAttribute(selected)}
@@ -129,7 +138,7 @@ const RailActionButton = ({icon: Icon, selected = false}: {readonly icon: Icon;
data-flx="app.skeleton.guild-rail-skeleton.rail-action-button.action-icon"
>
<Icon
weight="bold"
weight={weight}
className={styles.actionIconGlyph}
data-flx="app.skeleton.guild-rail-skeleton.rail-action-button.icon"
/>
@@ -578,6 +587,7 @@ export const GuildRailSkeleton: React.FC<GuildRailSkeletonProps> = ({
{discoveryVisible && (
<RailActionButton
icon={CompassIcon}
weight="fill"
selected={isDiscoverySelected}
data-flx="app.skeleton.guild-rail-skeleton.rail-action-button"
/>
@@ -1,11 +1,5 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
@property --skeleton-shimmer-position {
syntax: '<percentage>';
inherits: true;
initial-value: -150%;
}
.skeleton {
--skeleton-opacity: var(--skeleton-opacity-default, 0.45);
--skeleton-width: 100%;
@@ -13,13 +7,13 @@
background-color: var(--background-modifier-accent);
background-image: linear-gradient(
105deg,
transparent 46%,
transparent 0%,
color-mix(in srgb, var(--text-primary) 7%, transparent) 50%,
transparent 54%
transparent 100%
);
background-size: 200vw 100%;
background-position: var(--skeleton-shimmer-position) 0;
background-attachment: fixed;
background-size: 200% 100%;
background-position: -50% 0;
animation: skeleton-shimmer 3.2s linear infinite;
opacity: var(--skeleton-opacity);
pointer-events: none;
user-select: none;
@@ -30,35 +24,25 @@
max-width: 100%;
}
:global(html):has(.skeleton) {
animation: skeleton-shimmer 3.2s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
from {
--skeleton-shimmer-position: -150%;
background-position: -50% 0;
}
to {
--skeleton-shimmer-position: 150%;
background-position: 150% 0;
}
}
@media (prefers-reduced-motion: reduce) {
:global(html):has(.skeleton) {
animation: none;
}
.skeleton {
animation: none;
background-image: none;
}
}
:global(html.reduced-motion):has(.skeleton) {
animation: none;
}
:global(html.reduced-motion) .skeleton {
animation: none;
background-image: none;
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {clearRemScaleCache} from '@app/features/theme/layout/RemFromPx';
import type {NativePlatform} from '@app/features/ui/utils/NativeUtils';
import {useLayoutEffect} from 'react';
@@ -8,8 +9,10 @@ export function usePlatformClasses(platform: NativePlatform, isNative: boolean):
const htmlNode = document.documentElement;
const platformClasses = [isNative ? 'platform-native' : 'platform-web', `platform-${platform}`];
htmlNode.classList.add(...platformClasses);
clearRemScaleCache();
return () => {
htmlNode.classList.remove(...platformClasses);
clearRemScaleCache();
};
}, [isNative, platform]);
}
@@ -95,6 +95,41 @@ function measureMemberGroupHeadingWidthPx(element: HTMLElement | null): number {
return Math.round((right - left) / getRemScaleForDocument(element.ownerDocument));
}
function createGroupHeadingRegistrar(groupHeadingWidths: Map<string, number>): (node: HTMLDivElement | null) => void {
return (node) => {
if (node == null) {
return;
}
const groupId = node.dataset.memberGroupId;
if (groupId == null) {
return;
}
const widthPx = measureMemberGroupHeadingWidthPx(node);
if (widthPx !== SKELETON_UNMEASURED_WIDTH_PX) {
groupHeadingWidths.set(groupId, widthPx);
}
};
}
interface GroupHeadingWidthTracking {
contentKey: string;
groupHeadingWidths: Map<string, number>;
registerGroupHeading: (node: HTMLDivElement | null) => void;
}
function useGroupHeadingWidthTracking(contentKey: string): GroupHeadingWidthTracking {
const trackingRef = useRef<GroupHeadingWidthTracking | null>(null);
if (trackingRef.current == null || trackingRef.current.contentKey !== contentKey) {
const groupHeadingWidths = trackingRef.current?.groupHeadingWidths ?? new Map<string, number>();
trackingRef.current = {
contentKey,
groupHeadingWidths,
registerGroupHeading: createGroupHeadingRegistrar(groupHeadingWidths),
};
}
return trackingRef.current;
}
interface MemberListGroupHeaderContentProps {
name: string;
count: number;
@@ -129,13 +164,14 @@ interface GroupDMMemberListGroupProps {
group: GroupDMMemberGroup;
channelId: string;
ownerId: string | null;
onHeadingRef: (groupId: string, node: HTMLDivElement | null) => void;
onHeadingRef: (node: HTMLDivElement | null) => void;
}
const GroupDMMemberListGroup = observer(({group, channelId, ownerId, onHeadingRef}: GroupDMMemberListGroupProps) => (
<div className={styles.groupContainer} data-flx="channel.channel-members.group-dm-member-list-group.group-container">
<div
ref={(node) => onHeadingRef(group.id, node)}
ref={onHeadingRef}
data-member-group-id={group.id}
className={styles.groupHeader}
data-flx="channel.channel-members.group-dm-member-list-group.group-header"
>
@@ -196,13 +232,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
const groups = memberListState?.groups ?? [];
const populatedGroups = useMemo(() => groups.filter((group) => group.count > 0), [groups]);
const memberGroupCountsKey = populatedGroups.map((group) => `${group.id}:${group.count}`).join(',');
const groupHeadingWidthsRef = useRef(new Map<string, number>());
const registerGroupHeading = useCallback((groupId: string, node: HTMLDivElement | null) => {
const widthPx = measureMemberGroupHeadingWidthPx(node);
if (widthPx !== SKELETON_UNMEASURED_WIDTH_PX) {
groupHeadingWidthsRef.current.set(groupId, widthPx);
}
}, []);
const {groupHeadingWidths, registerGroupHeading} = useGroupHeadingWidthTracking(memberGroupCountsKey);
const memberSurfaceKind =
channel.type === ChannelTypes.GUILD_VOICE ? SkeletonMemberSurfaceKind.GUILD_VOICE : SkeletonMemberSurfaceKind.GUILD;
const rememberedMemberGroups = getRememberedSkeletonMemberGroups(channel.id, memberSurfaceKind);
@@ -255,7 +285,7 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
memberSurfaceKind,
populatedGroups.map((group) => ({
rowCount: group.count,
headingWidthPx: groupHeadingWidthsRef.current.get(group.id) ?? SKELETON_UNMEASURED_WIDTH_PX,
headingWidthPx: groupHeadingWidths.get(group.id) ?? SKELETON_UNMEASURED_WIDTH_PX,
})),
);
},
@@ -511,7 +541,8 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
virtualRows.push(
<div
key={`group-${rowIndex}-${group.id}`}
ref={(node) => registerGroupHeading(group.id, node)}
ref={registerGroupHeading}
data-member-group-id={group.id}
className={clsx(styles.virtualRow, styles.virtualGroupRow)}
style={rowStyle}
role="button"
@@ -533,7 +564,8 @@ const LazyMemberList = observer(function LazyMemberList({guild, channel}: LazyMe
virtualRows.push(
<div
key={`group-${rowIndex}-${group.id}`}
ref={(node) => registerGroupHeading(group.id, node)}
ref={registerGroupHeading}
data-member-group-id={group.id}
className={clsx(styles.virtualRow, styles.virtualGroupRow)}
style={rowStyle}
data-flx="channel.channel-members.lazy-member-list.virtual-row.group"
@@ -633,25 +665,21 @@ const GroupDMChannelMembers = observer(function GroupDMChannelMembers({channel}:
const allUserIds = currentUserId ? [currentUserId, ...channel.recipientIds] : channel.recipientIds;
const users = allUserIds.map((id) => Users.getUser(id)).filter((user): user is User => user != null);
const memberGroups = MemberListUtils.getGroupDMMemberGroups(users);
const memberGroupCountsKey = memberGroups.map((group) => `${group.id}:${group.count}`).join(',');
const groupHeadingWidthsRef = useRef(new Map<string, number>());
const registerGroupHeading = useCallback((groupId: string, node: HTMLDivElement | null) => {
const widthPx = measureMemberGroupHeadingWidthPx(node);
if (widthPx !== SKELETON_UNMEASURED_WIDTH_PX) {
groupHeadingWidthsRef.current.set(groupId, widthPx);
}
}, []);
const memberGroupContentKey = memberGroups
.map((group) => `${group.id}:${group.displayName}:${group.count}`)
.join(',');
const {groupHeadingWidths, registerGroupHeading} = useGroupHeadingWidthTracking(memberGroupContentKey);
useSkeletonLayoutReport(() => {
reportSkeletonMemberLayout(
channel.id,
SkeletonMemberSurfaceKind.GROUP_DM,
memberGroups.map((group) => ({
rowCount: group.count,
headingWidthPx: groupHeadingWidthsRef.current.get(group.id) ?? SKELETON_UNMEASURED_WIDTH_PX,
headingWidthPx: groupHeadingWidths.get(group.id) ?? SKELETON_UNMEASURED_WIDTH_PX,
subtextFlags: group.users.map((user) => hasVisibleCompactMemberCustomStatus(Presence.getCustomStatus(user.id))),
})),
);
}, `${channel.id}|${memberGroupCountsKey}`);
}, `${channel.id}|${memberGroupContentKey}`);
return (
<OutlineFrame hideTopBorder data-flx="channel.channel-members.outline-frame">
<MemberListContainer channelId={channel.id} data-flx="channel.channel-members.member-list-container">
@@ -129,6 +129,7 @@ export const CallParticipantsRow = observer(
return () => resizeObserver.disconnect();
}, [participants.length]);
const avatarSize = layoutMetrics.avatarSize;
const scaledAvatarSize = avatarSize / getAppRemScale();
const callRippleStyle = useMemo(
() =>
({
@@ -175,7 +176,7 @@ export const CallParticipantsRow = observer(
>
<AvatarWithPresence
user={user}
size={avatarSize / getAppRemScale()}
size={scaledAvatarSize}
speaking={participantEntry?.speaking}
muted={participantEntry?.selfMute}
deafened={participantEntry?.selfDeaf}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Logger} from '@app/features/platform/utils/AppLogger';
import {clearRemScaleCache} from '@app/features/theme/layout/RemFromPx';
import type {ThemeLibraryAsset, ThemeLibraryLocalFileReference} from '@app/features/theme/state/ThemeLibrary';
import {resolveThemeCssReferences} from '@app/features/theme/utils/ThemeCssUtils';
import {useEffect} from 'react';
@@ -32,10 +33,15 @@ export function useCustomThemeStyle({
if (!css) {
if (existing?.parentNode) {
existing.parentNode.removeChild(existing);
clearRemScaleCache();
}
return;
}
const styleElement = existing ?? document.createElement('style');
const applyThemeCss = (value: string): void => {
styleElement.textContent = value;
clearRemScaleCache();
};
styleElement.id = STYLE_ELEMENT_ID;
let disposed = false;
let objectUrls: Array<string> = [];
@@ -48,7 +54,7 @@ export function useCustomThemeStyle({
return;
}
objectUrls = resolved.objectUrls;
styleElement.textContent = resolved.css;
applyThemeCss(resolved.css);
if (resolved.missingAssetReferences.length > 0 || resolved.missingLocalFileReferences.length > 0) {
logger.warn('Custom theme CSS has unresolved file references', {
assets: resolved.missingAssetReferences,
@@ -58,7 +64,7 @@ export function useCustomThemeStyle({
})
.catch((error) => {
logger.error('Failed to resolve custom theme CSS references', error);
styleElement.textContent = css;
applyThemeCss(css);
});
if (!existing) {
document.head.appendChild(styleElement);
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {startRemScaleTracking} from '@app/features/theme/layout/RemFromPx';
import {useLayoutEffect} from 'react';
export function useRemScaleTracking(): void {
useLayoutEffect(() => startRemScaleTracking(document), []);
}
@@ -1,8 +1,101 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {REM_BASE_PX, remFromPx} from '@app/features/theme/layout/RemFromPx';
import {
clearRemScaleCache,
getRemScaleForDocument,
REM_BASE_PX,
remFromPx,
startRemScaleTracking,
} from '@app/features/theme/layout/RemFromPx';
import {describe, expect, it} from 'vitest';
interface StubDocumentHarness {
ownerDocument: Document;
setRootFontSize: (fontSize: string) => void;
getComputedStyleCallCount: () => number;
animationFrameRequestCount: () => number;
resizeListenerCount: () => number;
resolutionListenerCount: () => number;
watchedResolutionQueries: () => Array<string>;
dispatchResize: () => void;
setDevicePixelRatio: (devicePixelRatio: number) => void;
setInnerWidth: (innerWidth: number) => void;
setResolutionQueryMatches: (matches: boolean) => void;
dispatchResolutionChange: () => void;
}
function createStubDocumentHarness(initialRootFontSize: string): StubDocumentHarness {
let rootFontSize = initialRootFontSize;
let getComputedStyleCallCount = 0;
let animationFrameRequestCount = 0;
let resolutionQueryMatches = true;
const resizeListeners = new Set<() => void>();
const resolutionQueries = new Map<string, Set<() => void>>();
const documentElement = {} as HTMLElement;
const ownerWindow = {
devicePixelRatio: 1,
innerWidth: 1280,
getComputedStyle: () => {
getComputedStyleCallCount += 1;
return {fontSize: rootFontSize} as CSSStyleDeclaration;
},
requestAnimationFrame: () => {
animationFrameRequestCount += 1;
return 0;
},
cancelAnimationFrame: () => {},
addEventListener: (type: string, listener: () => void) => {
if (type !== 'resize') return;
resizeListeners.add(listener);
},
removeEventListener: (type: string, listener: () => void) => {
if (type !== 'resize') return;
resizeListeners.delete(listener);
},
matchMedia: (media: string) => {
const listeners = resolutionQueries.get(media) ?? new Set<() => void>();
resolutionQueries.set(media, listeners);
return {
media,
matches: resolutionQueryMatches,
addEventListener: (_type: string, listener: () => void) => listeners.add(listener),
removeEventListener: (_type: string, listener: () => void) => listeners.delete(listener),
} as unknown as MediaQueryList;
},
};
const ownerDocument = {documentElement, defaultView: ownerWindow} as unknown as Document;
return {
ownerDocument,
setRootFontSize: (fontSize) => {
rootFontSize = fontSize;
},
getComputedStyleCallCount: () => getComputedStyleCallCount,
animationFrameRequestCount: () => animationFrameRequestCount,
resizeListenerCount: () => resizeListeners.size,
resolutionListenerCount: () =>
[...resolutionQueries.values()].reduce((total, listeners) => total + listeners.size, 0),
watchedResolutionQueries: () =>
[...resolutionQueries.entries()].filter(([, listeners]) => listeners.size > 0).map(([media]) => media),
dispatchResize: () => {
for (const listener of [...resizeListeners]) listener();
},
setDevicePixelRatio: (devicePixelRatio) => {
ownerWindow.devicePixelRatio = devicePixelRatio;
},
setInnerWidth: (innerWidth) => {
ownerWindow.innerWidth = innerWidth;
},
setResolutionQueryMatches: (matches) => {
resolutionQueryMatches = matches;
},
dispatchResolutionChange: () => {
for (const listeners of [...resolutionQueries.values()]) {
for (const listener of [...listeners]) listener();
}
},
};
}
describe('remFromPx', () => {
it('uses a 16px base', () => {
expect(REM_BASE_PX).toBe(16);
@@ -32,3 +125,146 @@ describe('remFromPx', () => {
}
});
});
describe('getRemScaleForDocument', () => {
it('reports the root font size relative to the rem base', () => {
const defaultRoot = createStubDocumentHarness('16px');
const scaledRoot = createStubDocumentHarness('20px');
const zoomedRoot = createStubDocumentHarness('24px');
expect(getRemScaleForDocument(defaultRoot.ownerDocument)).toBe(1);
expect(getRemScaleForDocument(scaledRoot.ownerDocument)).toBe(1.25);
expect(getRemScaleForDocument(zoomedRoot.ownerDocument)).toBe(1.5);
});
it('reads the computed root font size once for repeated calls', () => {
const harness = createStubDocumentHarness('16px');
for (let call = 0; call < 200; call += 1) {
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
}
expect(harness.getComputedStyleCallCount()).toBe(1);
});
it('never schedules per-frame invalidation work', () => {
const harness = createStubDocumentHarness('16px');
getRemScaleForDocument(harness.ownerDocument);
getRemScaleForDocument(harness.ownerDocument);
expect(harness.animationFrameRequestCount()).toBe(0);
});
it('re-reads after a resize that changes the device pixel ratio', () => {
const harness = createStubDocumentHarness('16px');
startRemScaleTracking(harness.ownerDocument);
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
harness.setRootFontSize('20px');
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
harness.setDevicePixelRatio(1.25);
harness.dispatchResize();
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1.25dppx)']);
});
it('re-reads after a resize that changes the viewport width', () => {
const harness = createStubDocumentHarness('16px');
startRemScaleTracking(harness.ownerDocument);
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
harness.setRootFontSize('20px');
harness.setInnerWidth(720);
harness.dispatchResize();
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
});
it('ignores resizes that change neither the device pixel ratio nor the viewport width', () => {
const harness = createStubDocumentHarness('16px');
startRemScaleTracking(harness.ownerDocument);
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
harness.setRootFontSize('20px');
for (let frame = 0; frame < 60; frame += 1) {
harness.dispatchResize();
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
}
expect(harness.getComputedStyleCallCount()).toBe(1);
});
it('re-reads after a browser zoom changes the device pixel ratio', () => {
const harness = createStubDocumentHarness('16px');
startRemScaleTracking(harness.ownerDocument);
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1dppx)']);
harness.setRootFontSize('24px');
harness.setDevicePixelRatio(1.5);
harness.dispatchResolutionChange();
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.5);
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1.5dppx)']);
expect(harness.resolutionListenerCount()).toBe(1);
});
it('re-binds the resolution query from a resize when the query never matched', () => {
const harness = createStubDocumentHarness('16px');
harness.setResolutionQueryMatches(false);
startRemScaleTracking(harness.ownerDocument);
expect(harness.resolutionListenerCount()).toBe(0);
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
harness.setRootFontSize('20px');
harness.dispatchResize();
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
harness.setDevicePixelRatio(1.5);
harness.setResolutionQueryMatches(true);
harness.dispatchResize();
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
expect(harness.watchedResolutionQueries()).toEqual(['(resolution: 1.5dppx)']);
expect(harness.resolutionListenerCount()).toBe(1);
});
it('re-reads after an explicit cache clear', () => {
const harness = createStubDocumentHarness('16px');
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
harness.setRootFontSize('12px');
clearRemScaleCache(harness.ownerDocument);
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(0.75);
expect(harness.getComputedStyleCallCount()).toBe(2);
});
it('releases every listener when tracking is disposed', () => {
const harness = createStubDocumentHarness('16px');
const stopTracking = startRemScaleTracking(harness.ownerDocument);
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1);
expect(harness.resizeListenerCount()).toBe(1);
expect(harness.resolutionListenerCount()).toBe(1);
stopTracking();
expect(harness.resizeListenerCount()).toBe(0);
expect(harness.resolutionListenerCount()).toBe(0);
harness.setRootFontSize('20px');
expect(getRemScaleForDocument(harness.ownerDocument)).toBe(1.25);
expect(harness.resizeListenerCount()).toBe(0);
expect(harness.resolutionListenerCount()).toBe(0);
});
it('never registers listeners as a side effect of reading', () => {
const harness = createStubDocumentHarness('16px');
for (let call = 0; call < 5; call += 1) {
getRemScaleForDocument(harness.ownerDocument);
}
expect(harness.resizeListenerCount()).toBe(0);
expect(harness.resolutionListenerCount()).toBe(0);
});
it('invalidates every document when cleared without a target', () => {
const main = createStubDocumentHarness('16px');
const popout = createStubDocumentHarness('16px');
expect(getRemScaleForDocument(main.ownerDocument)).toBe(1);
expect(getRemScaleForDocument(popout.ownerDocument)).toBe(1);
main.setRootFontSize('20px');
popout.setRootFontSize('20px');
clearRemScaleCache();
expect(getRemScaleForDocument(main.ownerDocument)).toBe(1.25);
expect(getRemScaleForDocument(popout.ownerDocument)).toBe(1.25);
});
it('falls back to the rem base when the document has no view or no usable root font size', () => {
const detachedDocument = {documentElement: {} as HTMLElement, defaultView: null} as unknown as Document;
const brokenRoot = createStubDocumentHarness('not-a-length');
expect(getRemScaleForDocument(null)).toBe(1);
expect(getRemScaleForDocument(detachedDocument)).toBe(1);
expect(getRemScaleForDocument(brokenRoot.ownerDocument)).toBe(1);
});
});
@@ -17,10 +17,16 @@ export function remFromPx(px: number): `${number}rem` {
return `${rounded}rem`;
}
export function getRemScaleForDocument(ownerDocument: Document | null): number {
if (ownerDocument == null) {
return 1;
}
interface CachedRemScale {
scale: number;
generation: number;
}
const cachedRemScales = new WeakMap<Document, CachedRemScale>();
const remScaleDisposers = new WeakMap<Document, () => void>();
let remScaleGeneration = 0;
function readRemScaleFromDocument(ownerDocument: Document): number {
const ownerWindow = ownerDocument.defaultView;
if (ownerWindow == null) {
return 1;
@@ -31,3 +37,92 @@ export function getRemScaleForDocument(ownerDocument: Document | null): number {
}
return rootFontSize / REM_BASE_PX;
}
export function getRemScaleForDocument(ownerDocument: Document | null): number {
if (ownerDocument == null) {
return 1;
}
const cached = cachedRemScales.get(ownerDocument);
if (cached != null && cached.generation === remScaleGeneration) {
return cached.scale;
}
const scale = readRemScaleFromDocument(ownerDocument);
cachedRemScales.set(ownerDocument, {scale, generation: remScaleGeneration});
return scale;
}
export function clearRemScaleCache(ownerDocument?: Document | null): void {
if (ownerDocument == null) {
remScaleGeneration += 1;
return;
}
cachedRemScales.delete(ownerDocument);
}
export function startRemScaleTracking(ownerDocument: Document): () => void {
remScaleDisposers.get(ownerDocument)?.();
const ownerWindow = ownerDocument.defaultView;
if (ownerWindow == null) {
return () => {};
}
return trackRemScaleForWindow(ownerDocument, ownerWindow);
}
function trackRemScaleForWindow(ownerDocument: Document, ownerWindow: Window): () => void {
let resolutionQuery: MediaQueryList | null = null;
let trackedDevicePixelRatio = ownerWindow.devicePixelRatio;
let trackedInnerWidth = ownerWindow.innerWidth;
function invalidate(): void {
cachedRemScales.delete(ownerDocument);
}
function stopWatchingResolution(): void {
resolutionQuery?.removeEventListener('change', invalidateForRootFontSizeInputs);
resolutionQuery = null;
}
function watchResolution(): void {
if (typeof ownerWindow.matchMedia !== 'function') {
return;
}
const nextQuery = ownerWindow.matchMedia(`(resolution: ${ownerWindow.devicePixelRatio}dppx)`);
if (!nextQuery.matches) {
stopWatchingResolution();
return;
}
stopWatchingResolution();
resolutionQuery = nextQuery;
resolutionQuery.addEventListener('change', invalidateForRootFontSizeInputs);
}
function invalidateForRootFontSizeInputs(): void {
trackedDevicePixelRatio = ownerWindow.devicePixelRatio;
trackedInnerWidth = ownerWindow.innerWidth;
invalidate();
watchResolution();
}
function handleResize(): void {
if (ownerWindow.devicePixelRatio === trackedDevicePixelRatio && ownerWindow.innerWidth === trackedInnerWidth) {
return;
}
invalidateForRootFontSizeInputs();
}
ownerWindow.addEventListener('resize', handleResize);
watchResolution();
invalidate();
const dispose = (): void => {
if (remScaleDisposers.get(ownerDocument) !== dispose) {
return;
}
ownerWindow.removeEventListener('resize', handleResize);
stopWatchingResolution();
remScaleDisposers.delete(ownerDocument);
cachedRemScales.delete(ownerDocument);
};
remScaleDisposers.set(ownerDocument, dispose);
return dispose;
}
@@ -1,9 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
const cachedRemScales = new WeakMap<Document, number>();
const clearRemScaleCacheRafIds = new WeakMap<Document, number>();
import {clearRemScaleCache, getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
export interface AppZoomPoint {
x: number;
@@ -24,41 +21,12 @@ function getDefaultDocument(): Document | null {
return document;
}
function scheduleRemScaleCacheClear(ownerDocument: Document): void {
const ownerWindow = ownerDocument.defaultView;
if (ownerWindow == null || typeof ownerWindow.requestAnimationFrame !== 'function') {
cachedRemScales.delete(ownerDocument);
return;
}
if (clearRemScaleCacheRafIds.has(ownerDocument)) return;
const rafId = ownerWindow.requestAnimationFrame(() => {
clearRemScaleCacheRafIds.delete(ownerDocument);
cachedRemScales.delete(ownerDocument);
});
clearRemScaleCacheRafIds.set(ownerDocument, rafId);
}
export function clearAppZoomCache(ownerDocument: Document | null = getDefaultDocument()): void {
if (ownerDocument == null) return;
cachedRemScales.delete(ownerDocument);
const ownerWindow = ownerDocument.defaultView;
if (ownerWindow == null) return;
const remScaleRafId = clearRemScaleCacheRafIds.get(ownerDocument);
if (remScaleRafId == null) return;
if (typeof ownerWindow.cancelAnimationFrame === 'function') {
ownerWindow.cancelAnimationFrame(remScaleRafId);
}
clearRemScaleCacheRafIds.delete(ownerDocument);
export function clearAppZoomCache(): void {
clearRemScaleCache();
}
export function getAppRemScale(ownerDocument: Document | null = getDefaultDocument()): number {
if (ownerDocument == null) return 1;
const cachedRemScale = cachedRemScales.get(ownerDocument);
if (cachedRemScale != null) return cachedRemScale;
const remScale = getRemScaleForDocument(ownerDocument);
cachedRemScales.set(ownerDocument, remScale);
scheduleRemScaleCacheClear(ownerDocument);
return remScale;
return getRemScaleForDocument(ownerDocument);
}
export function applyAppZoomToDocument(zoomPercent: number, electronApi?: AppZoomElectronApi | null): void {