FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

perf(core): cache media query matches between device changes by edusperoni · Pull Request #11512 · NativeScript/NativeScript · GitHub

Repository navigation

Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .ts  (8) All 1 file type selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
25 changes: 25 additions & 0 deletions packages/core/application/helpers-common.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { setToggleApplicationEventListenersCallback, toggleApplicationEventListeners } from './helpers-common';

describe('toggleApplicationEventListeners', () => {
it('replays listeners requested before the platform handler is installed', () => {
const handled: Array<[boolean, (args: any) => void]> = [];
const early = () => {};
const withdrawn = () => {};

toggleApplicationEventListeners(true, early);
toggleApplicationEventListeners(true, withdrawn);
toggleApplicationEventListeners(false, withdrawn);

setToggleApplicationEventListenersCallback((toAdd, callback) => handled.push([toAdd, callback]));

expect(handled).toEqual([[true, early]]);

const late = () => {};
toggleApplicationEventListeners(true, late);

expect(handled).toEqual([
[true, early],
[true, late],
]);
});
});
11 changes: 11 additions & 0 deletions packages/core/application/helpers-common.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,24 @@ export function setAppMainEntry(entry: any /* NavigationEntry */) {

// Aids avoiding circular dependencies by allowing the application event listeners to be toggled
let _toggleApplicationEventListenersHandler: (toAdd: boolean, callback: (args: any) => void) => void;
// Listeners requested before the platform installs its handler
const _pendingApplicationEventListeners = new Set<(args: any) => void>();
export function toggleApplicationEventListeners(toAdd: boolean, callback: (args: any) => void) {
if (_toggleApplicationEventListenersHandler) {
_toggleApplicationEventListenersHandler(toAdd, callback);
} else if (toAdd) {
_pendingApplicationEventListeners.add(callback);
} else {
_pendingApplicationEventListeners.delete(callback);
}
}
export function setToggleApplicationEventListenersCallback(callback: (toAdd: boolean, callback: (args: any) => void) => void) {
_toggleApplicationEventListenersHandler = callback;

for (const listener of _pendingApplicationEventListeners) {
callback(true, listener);
}
_pendingApplicationEventListeners.clear();
}

// Aids avoiding circular dependencies by allowing the application properties to be retrieved
Expand Down
22 changes: 21 additions & 1 deletion packages/core/css-mediaquery/index.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
import { MediaQueryType, matchQuery, parseQuery } from '.';
import { MediaQueryType, isEvaluableMediaQuery, matchQuery, parseQuery } from '.';

describe('css-mediaquery', () => {
describe('isEvaluableMediaQuery', () => {
it('accepts queries built only from evaluable features', () => {
expect(isEvaluableMediaQuery('(orientation: landscape)')).toBe(true);
expect(isEvaluableMediaQuery('only screen and (min-width: 100) and (max-height: 200)')).toBe(true);
expect(isEvaluableMediaQuery('(prefers-color-scheme: dark), (device-width: 1024)')).toBe(true);
expect(isEvaluableMediaQuery('screen')).toBe(true);
});

it('rejects queries with a feature the runtime cannot evaluate', () => {
expect(isEvaluableMediaQuery('(color-gamut: p3)')).toBe(false);
expect(isEvaluableMediaQuery('(orientation: landscape) and (hover: hover)')).toBe(false);
expect(isEvaluableMediaQuery('(orientation: landscape), (prefers-reduced-motion: reduce)')).toBe(false);
});

it('rejects invalid queries', () => {
expect(isEvaluableMediaQuery('some crap')).toBe(false);
expect(isEvaluableMediaQuery('48em')).toBe(false);
});
});

describe('parseQuery', () => {
it('should parse media queries without expressions', () => {
expect(parseQuery('screen')).toEqual([
Expand Down
32 changes: 31 additions & 1 deletion packages/core/css-mediaquery/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,38 @@ export interface MediaQueryFeature {
value: string;
}

const EVALUABLE_FEATURES = new Set<string>(['width', 'height', 'device-width', 'device-height', 'orientation', 'prefers-color-scheme']);

const parsedQueryCache = new Map<string, MediaQueryExpression[]>();

function getParsedQuery(mediaQuery: string): MediaQueryExpression[] {
let expressions = parsedQueryCache.get(mediaQuery);
if (!expressions) {
expressions = parseQuery(mediaQuery);
parsedQueryCache.set(mediaQuery, expressions);
}

return expressions;
}

/**
* Whether a media query is valid and uses only features the runtime can evaluate.
* @param mediaQuery The media query string.
*/
export function isEvaluableMediaQuery(mediaQuery: string): boolean {
let expressions: MediaQueryExpression[];

try {
expressions = getParsedQuery(mediaQuery);
} catch {
return false;
}

return expressions.every((query) => query.features.every((feature) => EVALUABLE_FEATURES.has(feature.property)));
}

export function matchQuery(mediaQuery: string, values: MediaQueryEnvironmentParams): boolean {
const expressions = parseQuery(mediaQuery);
const expressions = getParsedQuery(mediaQuery);

return expressions.some((query) => {
const { type, inverse, features } = query;
Expand Down
79 changes: 64 additions & 15 deletions packages/core/media-query-list/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,35 @@ import { EventData, Observable } from '../data/observable';
import { Screen } from '../platform/screen';
import { getApplicationProperties, toggleApplicationEventListeners } from '../application/helpers-common';
import type { ApplicationEventData } from '../application/application-interfaces';
import { matchQuery, MediaQueryType } from '../css-mediaquery';
import { isEvaluableMediaQuery, matchQuery, MediaQueryType } from '../css-mediaquery';
import { Trace } from '../trace';

const mediaQueryLists: MediaQueryListImpl[] = [];

// In browser, developers cannot create MediaQueryList instances without calling matchMedia
let isMediaInitializationEnabled: boolean = false;

// Query results only change with orientation, appearance or screen metrics.
const matchResultCache = new Map<string, boolean>();
let isCacheInvalidationRegistered = false;

function clearMatchResultCache() {
matchResultCache.clear();
}

function registerCacheInvalidation() {
if (isCacheInvalidationRegistered) {
return;
}

isCacheInvalidationRegistered = true;
toggleApplicationEventListeners(true, clearMatchResultCache);
}

function onDeviceChange(args: ApplicationEventData) {
// Listener order is not guaranteed, so never re-evaluate against stale results
clearMatchResultCache();

for (const mql of mediaQueryLists) {
const matches = checkIfMediaQueryMatches(mql.media);
if (mql.matches !== matches) {
Expand All @@ -27,26 +47,55 @@ function onDeviceChange(args: ApplicationEventData) {
}

function checkIfMediaQueryMatches(mediaQueryString: string): boolean {
const { widthPixels, heightPixels } = Screen.mainScreen;
const cachedResult = matchResultCache.get(mediaQueryString);
if (cachedResult !== undefined) {
return cachedResult;
}

let matches: boolean;

try {
const appProperties = getApplicationProperties();
matches = matchQuery(mediaQueryString, {
type: MediaQueryType.screen,
width: widthPixels,
height: heightPixels,
'device-width': widthPixels,
'device-height': heightPixels,
orientation: appProperties.orientation,
'prefers-color-scheme': appProperties.systemAppearance,
});
} catch (err) {
if (isEvaluableMediaQuery(mediaQueryString)) {
registerCacheInvalidation();

// Native reads are deferred until a feature in the query asks for them
let screenPixels: { width: number; height: number };
let appProperties: ReturnType<typeof getApplicationProperties>;
const getScreenPixels = () => (screenPixels ??= { width: Screen.mainScreen.widthPixels, height: Screen.mainScreen.heightPixels });
const getAppProperties = () => (appProperties ??= getApplicationProperties());

try {
matches = matchQuery(mediaQueryString, {
type: MediaQueryType.screen,
get width() {
return getScreenPixels().width;
},
get height() {
return getScreenPixels().height;
},
get 'device-width'() {
return getScreenPixels().width;
},
get 'device-height'() {
return getScreenPixels().height;
},
get orientation() {
return getAppProperties().orientation;
},
get 'prefers-color-scheme'() {
return getAppProperties().systemAppearance;
},
});
} catch (err) {
matches = false;
Trace.write(err, Trace.categories.MediaQuery, Trace.messageType.error);
}
} else {
matches = false;
Trace.write(err, Trace.categories.MediaQuery, Trace.messageType.error);
Trace.write(`Media query '${mediaQueryString}' cannot be evaluated and will never match`, Trace.categories.MediaQuery, Trace.messageType.warn);
}

matchResultCache.set(mediaQueryString, matches);

return matches;
}

Expand Down
104 changes: 104 additions & 0 deletions packages/core/media-query-list/match-cache.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { vi } from 'vitest';

const device = vi.hoisted(() => ({
orientation: 'portrait',
listeners: new Set<(args: any) => void>(),
propertyReads: 0,
}));

vi.mock('../application/helpers-common', async (importOriginal) => ({
...(await importOriginal<typeof import('../application/helpers-common')>()),
getApplicationProperties: () => {
device.propertyReads++;

return { orientation: device.orientation, systemAppearance: 'light' };
},
toggleApplicationEventListeners: (toAdd: boolean, callback: (args: any) => void) => {
if (toAdd) {
device.listeners.add(callback);
} else {
device.listeners.delete(callback);
}
},
}));

import { checkIfMediaQueryMatches, matchMedia } from '.';
import { Screen } from '../platform/screen';

function notifyDeviceChange() {
for (const listener of [...device.listeners]) {
listener({ eventName: 'orientationChanged' });
}
}

describe('media-query-list match cache', () => {
const portraitQuery = '(orientation: portrait)';

beforeEach(() => {
device.orientation = 'portrait';
notifyDeviceChange();
});

it('reuses a result until the device changes', () => {
expect(checkIfMediaQueryMatches(portraitQuery)).toBe(true);

const readsAfterFirstCheck = device.propertyReads;
device.orientation = 'landscape';

expect(checkIfMediaQueryMatches(portraitQuery)).toBe(true);
expect(device.propertyReads).toBe(readsAfterFirstCheck);

notifyDeviceChange();

expect(checkIfMediaQueryMatches(portraitQuery)).toBe(false);
expect(device.propertyReads).toBe(readsAfterFirstCheck + 1);
});

it('reads no device state for a query it cannot evaluate', () => {
const unsupportedQuery = '(color-gamut: p3)';
const screenReads = vi.spyOn(Screen.mainScreen, 'widthPixels', 'get');
const readsBefore = device.propertyReads;

expect(checkIfMediaQueryMatches(unsupportedQuery)).toBe(false);
expect(checkIfMediaQueryMatches(unsupportedQuery)).toBe(false);
expect(device.propertyReads).toBe(readsBefore);
expect(screenReads).not.toHaveBeenCalled();

screenReads.mockRestore();
});

it('reads only the device state the query names', () => {
const widthQuery = `(max-width: ${Screen.mainScreen.widthDIPs})`;
const screenReads = vi.spyOn(Screen.mainScreen, 'widthPixels', 'get');
const readsBefore = device.propertyReads;

expect(checkIfMediaQueryMatches(widthQuery)).toBe(true);
expect(device.propertyReads).toBe(readsBefore);
expect(screenReads).toHaveBeenCalled();

screenReads.mockClear();

expect(checkIfMediaQueryMatches('(orientation: portrait)')).toBe(true);
expect(device.propertyReads).toBe(readsBefore + 1);
expect(screenReads).not.toHaveBeenCalled();

screenReads.mockRestore();
});

it('keeps MediaQueryList change notifications in sync with the cache', () => {
expect(checkIfMediaQueryMatches(portraitQuery)).toBe(true);

const mql = matchMedia(portraitQuery);
let notifiedMatches: boolean | undefined;
mql.addEventListener('change', (data: any) => {
notifiedMatches = data.matches;
});

device.orientation = 'landscape';
notifyDeviceChange();

expect(mql.matches).toBe(false);
expect(notifiedMatches).toBe(false);
expect(checkIfMediaQueryMatches(portraitQuery)).toBe(false);
});
});
35 changes: 34 additions & 1 deletion packages/core/ui/styling/style-scope.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, it, expect, beforeAll } from 'vitest';

import { StyleScope, applyInlineStyle, addTaggedAdditionalCSS, removeTaggedAdditionalCSS } from './style-scope';
import { StyleScope, _populateRules, applyInlineStyle, addTaggedAdditionalCSS, removeTaggedAdditionalCSS } from './style-scope';
import { parse } from '../../css/reworkcss.js';
import type { RuleSet } from './css-selector';
import { StackLayout } from '../layouts/stack-layout';
import { Label } from '../label';

Expand Down Expand Up @@ -393,3 +395,34 @@ describe('CssState.onChange subscriptions', () => {
expect(view.style.color.toString()).toBe('#0000FF');
});
});

describe('_populateRules media scopes', () => {
function rulesetsOf(css: string): RuleSet[] {
const rulesets: RuleSet[] = [];
_populateRules(parse(css, { source: 'style-scope.spec.ts' }).stylesheet.rules, rulesets, []);

return rulesets;
}

it('drops media blocks the runtime cannot evaluate and keeps the rest', () => {
const rulesets = rulesetsOf(`
label { color: red; }
@media (color-gamut: p3) { label { color: blue; } }
@media (orientation: landscape) { label { color: green; } }
`);

expect(rulesets.map((ruleset) => ruleset.mediaQueryString)).toEqual([undefined, '(orientation: landscape)']);
});

it('drops an unevaluable block nested inside an evaluable one', () => {
const rulesets = rulesetsOf(`
@media (orientation: landscape) {
label { color: red; }
@media (hover: hover) { label { color: blue; } }
}
`);

expect(rulesets).toHaveLength(1);
expect(rulesets[0].mediaQueryString).toBe('(orientation: landscape)');
});
});
7 changes: 7 additions & 0 deletions packages/core/ui/styling/style-scope.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as ReworkCSS from '../../css';

import { RuleSet, StyleSheetSelectorScope, SelectorCore, SelectorTier, SelectorsMatch, ChangeMap, Changes, fromAstNode, Node, matchMediaQueryString, matchSelectorCandidates } from './css-selector';
import { Trace } from './styling-shared';
import { isEvaluableMediaQuery } from '../../css-mediaquery';
import { File, knownFolders, path } from '../../file-system';
import { Application, CssChangedEventData, LoadAppCSSEventData } from '../../application';
import { profile } from './styling-profile';
Expand Down Expand Up @@ -432,6 +433,12 @@ export function _populateRules(nodes: ReworkCSS.Node[], rulesets: RuleSet[], key

keyframes.push(keyframeRule);
} else if (isMedia(node)) {
// A query the runtime cannot evaluate never matches, so its rules are dead weight
if (!isEvaluableMediaQuery(node.media)) {
Trace.write(`Dropping '@media ${node.media}': the runtime cannot evaluate it`, Trace.categories.MediaQuery, Trace.messageType.warn);
continue;
}

// Media query can be an array of strings in case of nested queries
let compositeMediaQuery: string | string[];

Expand Down
Loading

Back | FazBrowse Home | New Git URL