mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
perf(app): stop per-frame style recalculation in skeletons and member list (#1681)
This commit is contained in:
1 parent
170ca805c5
commit
438f11adda
11 files changed
+434
-93
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user