From 438f11adda7fb5725cae79c4cca91175d8fe87ee Mon Sep 17 00:00:00 2001 From: Hampus Date: Sun, 16 Aug 2026 22:00:10 +0200 Subject: [PATCH] perf(app): stop per-frame style recalculation in skeletons and member list (#1681) --- fluxer_app/src/app/App.tsx | 2 + .../components/skeleton/GuildRailSkeleton.tsx | 14 +- .../components/skeleton/Skeleton.module.css | 34 +-- .../features/app/hooks/usePlatformClasses.ts | 3 + .../channel/components/ChannelMembers.tsx | 72 ++++-- .../dm_channel_view/CallParticipantsRow.tsx | 3 +- .../theme/hooks/useCustomThemeStyle.ts | 10 +- .../theme/hooks/useRemScaleTracking.ts | 8 + .../features/theme/layout/RemFromPx.test.ts | 238 +++++++++++++++++- .../src/features/theme/layout/RemFromPx.ts | 103 +++++++- .../src/features/ui/utils/AppZoomUtils.ts | 40 +-- 11 files changed, 434 insertions(+), 93 deletions(-) create mode 100644 fluxer_app/src/features/theme/hooks/useRemScaleTracking.ts diff --git a/fluxer_app/src/app/App.tsx b/fluxer_app/src/app/App.tsx index 4780d216c..670c91a1e 100644 --- a/fluxer_app/src/app/App.tsx +++ b/fluxer_app/src/app/App.tsx @@ -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, diff --git a/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.tsx b/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.tsx index 4eaaa8381..3a71b05e8 100644 --- a/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.tsx +++ b/fluxer_app/src/features/app/components/skeleton/GuildRailSkeleton.tsx @@ -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 ); -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; +}) => ( @@ -578,6 +587,7 @@ export const GuildRailSkeleton: React.FC = ({ {discoveryVisible && ( diff --git a/fluxer_app/src/features/app/components/skeleton/Skeleton.module.css b/fluxer_app/src/features/app/components/skeleton/Skeleton.module.css index 86fd83b23..a9c4e639f 100644 --- a/fluxer_app/src/features/app/components/skeleton/Skeleton.module.css +++ b/fluxer_app/src/features/app/components/skeleton/Skeleton.module.css @@ -1,11 +1,5 @@ /* SPDX-License-Identifier: AGPL-3.0-or-later */ -@property --skeleton-shimmer-position { - syntax: ''; - 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; } diff --git a/fluxer_app/src/features/app/hooks/usePlatformClasses.ts b/fluxer_app/src/features/app/hooks/usePlatformClasses.ts index 557394a9d..fb68675cf 100644 --- a/fluxer_app/src/features/app/hooks/usePlatformClasses.ts +++ b/fluxer_app/src/features/app/hooks/usePlatformClasses.ts @@ -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]); } diff --git a/fluxer_app/src/features/channel/components/ChannelMembers.tsx b/fluxer_app/src/features/channel/components/ChannelMembers.tsx index 0f035df00..3fae482a9 100644 --- a/fluxer_app/src/features/channel/components/ChannelMembers.tsx +++ b/fluxer_app/src/features/channel/components/ChannelMembers.tsx @@ -95,6 +95,41 @@ function measureMemberGroupHeadingWidthPx(element: HTMLElement | null): number { return Math.round((right - left) / getRemScaleForDocument(element.ownerDocument)); } +function createGroupHeadingRegistrar(groupHeadingWidths: Map): (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; + registerGroupHeading: (node: HTMLDivElement | null) => void; +} + +function useGroupHeadingWidthTracking(contentKey: string): GroupHeadingWidthTracking { + const trackingRef = useRef(null); + if (trackingRef.current == null || trackingRef.current.contentKey !== contentKey) { + const groupHeadingWidths = trackingRef.current?.groupHeadingWidths ?? new Map(); + 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) => (
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()); - 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(
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(
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()); - 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 ( diff --git a/fluxer_app/src/features/channel/components/channel_view/dm_channel_view/CallParticipantsRow.tsx b/fluxer_app/src/features/channel/components/channel_view/dm_channel_view/CallParticipantsRow.tsx index f9fe907d6..2e389b3b9 100644 --- a/fluxer_app/src/features/channel/components/channel_view/dm_channel_view/CallParticipantsRow.tsx +++ b/fluxer_app/src/features/channel/components/channel_view/dm_channel_view/CallParticipantsRow.tsx @@ -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( > { + styleElement.textContent = value; + clearRemScaleCache(); + }; styleElement.id = STYLE_ELEMENT_ID; let disposed = false; let objectUrls: Array = []; @@ -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); diff --git a/fluxer_app/src/features/theme/hooks/useRemScaleTracking.ts b/fluxer_app/src/features/theme/hooks/useRemScaleTracking.ts new file mode 100644 index 000000000..3d9ff1802 --- /dev/null +++ b/fluxer_app/src/features/theme/hooks/useRemScaleTracking.ts @@ -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), []); +} diff --git a/fluxer_app/src/features/theme/layout/RemFromPx.test.ts b/fluxer_app/src/features/theme/layout/RemFromPx.test.ts index 08e3a36c9..c3e84b415 100644 --- a/fluxer_app/src/features/theme/layout/RemFromPx.test.ts +++ b/fluxer_app/src/features/theme/layout/RemFromPx.test.ts @@ -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; + 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 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); + }); +}); diff --git a/fluxer_app/src/features/theme/layout/RemFromPx.ts b/fluxer_app/src/features/theme/layout/RemFromPx.ts index 1065a8fa2..61054abc4 100644 --- a/fluxer_app/src/features/theme/layout/RemFromPx.ts +++ b/fluxer_app/src/features/theme/layout/RemFromPx.ts @@ -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(); +const remScaleDisposers = new WeakMap 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; +} diff --git a/fluxer_app/src/features/ui/utils/AppZoomUtils.ts b/fluxer_app/src/features/ui/utils/AppZoomUtils.ts index 17fa2ee9b..15b35d2ce 100644 --- a/fluxer_app/src/features/ui/utils/AppZoomUtils.ts +++ b/fluxer_app/src/features/ui/utils/AppZoomUtils.ts @@ -1,9 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx'; - -const cachedRemScales = new WeakMap(); -const clearRemScaleCacheRafIds = new WeakMap(); +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 {