diff --git a/.eslintrc.json b/.eslintrc.json index e93d64d2dc..743d7ebb2f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -39,7 +39,11 @@ "@typescript-eslint/no-this-alias": "warn", "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-inferrable-types": "off", - "@typescript-eslint/no-extra-semi": "error", + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-unsafe-function-type": "off", + "@typescript-eslint/no-wrapper-object-types": "off", + "@typescript-eslint/no-unsafe-declaration-merging": "off", + "@typescript-eslint/no-unused-expressions": ["error", { "allowShortCircuit": true, "allowTernary": true }], "no-extra-semi": "off" } }, @@ -47,7 +51,6 @@ "files": ["*.js", "*.jsx"], "extends": ["plugin:@nx/javascript"], "rules": { - "@typescript-eslint/no-extra-semi": "error", "no-extra-semi": "off" } }, diff --git a/packages/core/.eslintrc.json b/packages/core/.eslintrc.json index 0673f3d061..820ab5a91d 100644 --- a/packages/core/.eslintrc.json +++ b/packages/core/.eslintrc.json @@ -1,15 +1,8 @@ { "extends": "../../.eslintrc.json", "rules": {}, - "ignorePatterns": ["!**/*", "**/global-types.d.ts", "**/node_modules/**/*", "**/__tests__/**/*", "**/vite.config.*.timestamp*", "**/vitest.config.*.timestamp*"], + "ignorePatterns": ["!**/*", "**/global-types.d.ts", "**/node_modules/**/*", "**/__tests__/**/*", "**/platforms/**/*", "**/css/lib/**/*", "**/vite.config.*.timestamp*", "**/vitest.config.*.timestamp*"], "overrides": [ - { - "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], - "parserOptions": { - "project": ["packages/core/tsconfig.*?.json"] - }, - "rules": {} - }, { "files": ["*.ts", "*.tsx"], "rules": {} diff --git a/packages/core/__tests__/benchmarks/css-selector.bench.ts b/packages/core/__tests__/benchmarks/css-selector.bench.ts new file mode 100644 index 0000000000..4558c5cfb2 --- /dev/null +++ b/packages/core/__tests__/benchmarks/css-selector.bench.ts @@ -0,0 +1,108 @@ +import { bench, describe } from 'vitest'; +import { parse } from '../../css/reworkcss.js'; +import { AttributeSelector, createSelector, RuleSet, StyleSheetSelectorScope } from '../../ui/styling/css-selector'; +import { _populateRules } from '../../ui/styling/style-scope'; + +function createScope(css: string): StyleSheetSelectorScope { + const parsed = parse(css, { source: 'css-selector.bench.ts' }); + const rulesets: RuleSet[] = []; + _populateRules(parsed.stylesheet.rules, rulesets, []); + + return new StyleSheetSelectorScope(rulesets); +} + +// Build a stylesheet shaped like a typical themed app (theme-core ships +// thousands of selectors): universal rules, sizable per-type buckets, many +// class-variant buckets, ids and a couple of media queries. +function buildStylesheet(): string { + const parts: string[] = ['* { font-family: sans-serif; }', '* { font-size: 14; }', 'view { color: black; }']; + + const types = ['button', 'label', 'stacklayout', 'gridlayout', 'image', 'textfield', 'listview', 'scrollview']; + for (const type of types) { + for (let i = 0; i < 12; i++) { + parts.push(`${type}.variant-${i} { color: red; margin: ${i}; }`); + } + parts.push(`${type} { color: red; }`); + parts.push(`${type}.accent { color: blue; }`); + } + + for (let i = 0; i < 60; i++) { + for (let j = 0; j < 6; j++) { + parts.push(`.cls-${i}.mod-${j} { margin: ${i + j}; }`); + } + parts.push(`.cls-${i} { margin: ${i}; }`); + parts.push(`.cls-${i} .child-${i} { padding: ${i}; }`); + } + + for (let i = 0; i < 10; i++) { + parts.push(`#id-${i} { color: green; }`); + } + + parts.push('@media only screen and (min-width: 400) { button { color: purple; } .cls-1 { color: orange; } }'); + parts.push('@media only screen and (min-width: 400) and (max-width: 5000) { label { color: yellow; } }'); + + return parts.join('\n'); +} + +const scope = createScope(buildStylesheet()); + +const buttonNode = { + cssType: 'button', + id: 'id-5', + cssClasses: new Set(['accent', 'cls-1', 'cls-2', 'cls-30']), + cssPseudoClasses: new Set(), +}; + +const plainLabelNode = { + cssType: 'label', + cssClasses: new Set(), + cssPseudoClasses: new Set(), +}; + +// Simulates list recycling: rows cycle through a small set of distinct +// (type, classes) signatures, so candidate lookups repeat constantly. +const recycledRows = Array.from({ length: 20 }, (_, i) => ({ + cssType: i % 2 === 0 ? 'label' : 'stacklayout', + cssClasses: new Set(['accent', `cls-${i % 5}`, `variant-${i % 3}`, `variant-${7 + (i % 2)}`]), + cssPseudoClasses: new Set(), +})); + +describe('StyleSheetSelectorScope.query', () => { + bench('query - button with id and 4 classes', () => { + scope.query(buttonNode); + }); + + bench('query - plain label', () => { + scope.query(plainLabelNode); + }); + + bench('query - 20 recycled list rows', () => { + for (let i = 0; i < recycledRows.length; i++) { + scope.query(recycledRows[i]); + } + }); +}); + +describe('selector match', () => { + const sequenceSelector = createSelector('button.accent.cls-1'); + const sequenceNode = { cssType: 'button', cssClasses: new Set(['accent', 'cls-1']) }; + + bench('SimpleSelectorSequence.match', () => { + sequenceSelector.match(sequenceNode); + }); + + const complexSelector = createSelector('stacklayout > label.title'); + const parentNode = { cssType: 'stacklayout', cssClasses: new Set() }; + const childNode = { cssType: 'label', cssClasses: new Set(['title']), parent: parentNode }; + + bench('ComplexSelector.match (child combinator)', () => { + complexSelector.match(childNode); + }); + + const attributeSelector = new AttributeSelector('testAttr', 'equals', 'SOME-value', true); + const attributeNode = { cssType: 'button', testAttr: 'some-VALUE' }; + + bench('AttributeSelector.match (ignoreCase)', () => { + attributeSelector.match(attributeNode); + }); +}); diff --git a/packages/core/__tests__/benchmarks/observable.bench.ts b/packages/core/__tests__/benchmarks/observable.bench.ts new file mode 100644 index 0000000000..b14e10e5ba --- /dev/null +++ b/packages/core/__tests__/benchmarks/observable.bench.ts @@ -0,0 +1,78 @@ +import { bench, describe } from 'vitest'; +import { Observable, EventData } from '../../data/observable'; + +class TestObservable extends Observable {} + +function noop(_data: EventData) { + // listener body intentionally empty +} + +describe('Observable.notify', () => { + const singleListener = new TestObservable(); + singleListener.on('tick', noop); + + bench('notify - single listener (no thisArg)', () => { + singleListener.notify({ eventName: 'tick', object: singleListener }); + }); + + const singleListenerThisArg = new TestObservable(); + const ctx = { name: 'ctx' }; + singleListenerThisArg.on('tick', noop, ctx); + + bench('notify - single listener (with thisArg)', () => { + singleListenerThisArg.notify({ eventName: 'tick', object: singleListenerThisArg }); + }); + + const multiListener = new TestObservable(); + multiListener.on('tick', noop, { id: 1 }); + multiListener.on('tick', noop, { id: 2 }); + multiListener.on('tick', noop, { id: 3 }); + + bench('notify - three listeners (with thisArg)', () => { + multiListener.notify({ eventName: 'tick', object: multiListener }); + }); + + const noListeners = new TestObservable(); + + bench('notify - no listeners', () => { + noListeners.notify({ eventName: 'tick', object: noListeners }); + }); + + const propertyChangeTarget = new TestObservable(); + propertyChangeTarget.on(Observable.propertyChangeEvent, noop); + + bench('notifyPropertyChange', () => { + propertyChangeTarget.notifyPropertyChange('value', 1, 0); + }); +}); + +const ctxLast = { id: 'last' }; + +describe('Observable listener management', () => { + const observable = new TestObservable(); + + bench('hasListeners - present', () => { + observable.hasListeners('registered'); + }); + + observable.on('registered', noop); + + bench('hasListeners - absent', () => { + observable.hasListeners('unregistered'); + }); + + bench('on/off cycle', () => { + observable.on('cycle', noop); + observable.off('cycle', noop); + }); + + const manyListeners = new TestObservable(); + for (let i = 0; i < 10; i++) { + manyListeners.on('busy', noop, { id: i }); + } + + bench('on/off cycle - 10 existing listeners', () => { + manyListeners.on('busy', noop, ctxLast); + manyListeners.off('busy', noop, ctxLast); + }); +}); diff --git a/packages/core/__tests__/benchmarks/properties.bench.ts b/packages/core/__tests__/benchmarks/properties.bench.ts new file mode 100644 index 0000000000..582f7c9490 --- /dev/null +++ b/packages/core/__tests__/benchmarks/properties.bench.ts @@ -0,0 +1,30 @@ +import { bench, describe } from 'vitest'; +import { isCssWideKeyword, isResetValue, unsetValue } from '../../ui/core/properties/property-shared'; + +const objectValue = { some: 'value' }; + +describe('property value helpers', () => { + bench('isResetValue - number value', () => { + isResetValue(42); + }); + + bench('isResetValue - object value', () => { + isResetValue(objectValue); + }); + + bench('isResetValue - regular string value', () => { + isResetValue('center'); + }); + + bench('isResetValue - unsetValue', () => { + isResetValue(unsetValue); + }); + + bench('isCssWideKeyword - number value', () => { + isCssWideKeyword(42); + }); + + bench('isCssWideKeyword - regular string value', () => { + isCssWideKeyword('center'); + }); +}); diff --git a/packages/core/css/CSS3Parser.ts b/packages/core/css/CSS3Parser.ts index fdad43a61b..fffededeb1 100644 --- a/packages/core/css/CSS3Parser.ts +++ b/packages/core/css/CSS3Parser.ts @@ -669,7 +669,8 @@ export class CSS3Parser { text: undefined, components: [], }; - do { + // eslint-disable-next-line no-constant-condition + while (true) { if (this.nextInputCodePointIndex >= this.text.length) { funcToken.text = name + '(' + this.text.substring(start); @@ -692,6 +693,6 @@ export class CSS3Parser { } // TODO: Else we won't advance } - } while (true); + } } } diff --git a/packages/core/data/observable/index.spec.ts b/packages/core/data/observable/index.spec.ts index 8bee8f10b8..72502d807b 100644 --- a/packages/core/data/observable/index.spec.ts +++ b/packages/core/data/observable/index.spec.ts @@ -56,4 +56,107 @@ describe('Observable', () => { expect(callCount2).toBe(1); }); }); + + describe('notify', () => { + it('calls listeners in registration order with the event data', () => { + const observable = new Observable(); + const calls: string[] = []; + observable.on('test', (data) => calls.push('first:' + data.eventName)); + observable.on('test', (data) => calls.push('second:' + data.eventName)); + observable.notify({ eventName: 'test', object: observable }); + expect(calls).toEqual(['first:test', 'second:test']); + }); + + it('binds thisArg as the callback context', () => { + const observable = new Observable(); + const ctx = { name: 'ctx' }; + let receivedThis: any; + let receivedData: any; + observable.on( + 'test', + function (this: any, data) { + receivedThis = this; + receivedData = data; + }, + ctx, + ); + observable.notify({ eventName: 'test', object: observable }); + expect(receivedThis).toBe(ctx); + expect(receivedData.eventName).toBe('test'); + expect(receivedData.object).toBe(observable); + }); + + it('allows a listener to remove itself during dispatch without affecting other listeners', () => { + const observable = new Observable(); + const calls: string[] = []; + const first = () => { + calls.push('first'); + observable.off('test', first); + }; + const second = () => calls.push('second'); + observable.on('test', first); + observable.on('test', second); + observable.notify({ eventName: 'test', object: observable }); + observable.notify({ eventName: 'test', object: observable }); + expect(calls).toEqual(['first', 'second', 'second']); + }); + }); + + describe('hasListeners', () => { + it('reflects listener registration and removal', () => { + const observable = new Observable(); + expect(observable.hasListeners('test')).toBe(false); + const handler = () => { + // no-op + }; + observable.on('test', handler); + expect(observable.hasListeners('test')).toBe(true); + observable.off('test', handler); + expect(observable.hasListeners('test')).toBe(false); + }); + }); + + describe('static (global) event handlers', () => { + afterEach(() => { + Observable.removeEventListener('globalTest'); + }); + + it('notifies global handlers registered on Observable for any instance', () => { + const calls: string[] = []; + Observable.addEventListener('globalTest', () => calls.push('global')); + + const observable = new Observable(); + observable.on('globalTest', () => calls.push('instance')); + observable.notify({ eventName: 'globalTest', object: observable }); + + expect(calls).toEqual(['instance', 'global']); + }); + + it('notifies eventNameFirst global handlers before instance listeners', () => { + const calls: string[] = []; + Observable.addEventListener('globalTestFirst', () => calls.push('globalFirst')); + + const observable = new Observable(); + observable.on('globalTest', () => calls.push('instance')); + observable.notify({ eventName: 'globalTest', object: observable }); + + Observable.removeEventListener('globalTestFirst'); + + expect(calls).toEqual(['globalFirst', 'instance']); + }); + + it('stops notifying after global handler removal', () => { + let callCount = 0; + const handler = () => callCount++; + Observable.addEventListener('globalTest', handler); + + const observable = new Observable(); + observable.notify({ eventName: 'globalTest', object: observable }); + expect(callCount).toBe(1); + + Observable.removeEventListener('globalTest', handler); + observable.notify({ eventName: 'globalTest', object: observable }); + expect(callCount).toBe(1); + }); + }); }); diff --git a/packages/core/data/observable/index.ts b/packages/core/data/observable/index.ts index 24b40b14f0..e04e12253c 100644 --- a/packages/core/data/observable/index.ts +++ b/packages/core/data/observable/index.ts @@ -94,6 +94,13 @@ const _globalEventHandlers: { }; } = {}; +// Tracks how many listeners are registered through the deprecated static +// event-handling APIs so that `notify` can skip the global-handler lookups +// entirely in the common case where none are used. The count may overshoot +// when a `once` global listener is spliced during dispatch — that only causes +// harmless extra lookups, never skipped notifications. +let _globalEventHandlersCount = 0; + /** * Observable is used when you want to be notified when a change occurs. Use on/off methods to add/remove listener. * Please note that should you be using the `new Observable({})` constructor, it is **obsolete** since v3.0, @@ -331,7 +338,9 @@ export class Observable { return; } + const countBeforeRemoval = entries.length; Observable.innerRemoveEventListener(entries, callback, thisArg); + _globalEventHandlersCount -= countBeforeRemoval - entries.length; if (!entries.length) { // Clear all entries of this type @@ -376,6 +385,7 @@ export class Observable { } _globalEventHandlers[eventClass][eventName].push({ callback, thisArg, once }); + _globalEventHandlersCount++; } private _globalNotify(eventClass: string, eventType: string, data: T): void { @@ -411,6 +421,17 @@ export class Observable { data.object = data.object || this; const dataWithObject = data as EventData; + // Fast path: no listeners registered through the deprecated static + // event-handling APIs, so only instance observers need to be notified. + if (_globalEventHandlersCount === 0) { + const observers = this._observers[data.eventName]; + if (observers) { + Observable._fireEvent(observers, dataWithObject); + } + + return; + } + const eventClass = this.constructor.name; this._globalNotify(eventClass, 'First', dataWithObject); @@ -452,7 +473,7 @@ export class Observable { observers.splice(index, 1); } - const returnValue = entry.thisArg ? entry.callback.apply(entry.thisArg, [data]) : entry.callback(data); + const returnValue = entry.thisArg ? entry.callback.call(entry.thisArg, data) : entry.callback(data); // This ensures errors thrown inside asynchronous functions do not get swallowed if (returnValue instanceof Promise) { @@ -474,7 +495,7 @@ export class Observable { * @param eventName The name of the event to check for. */ public hasListeners(eventName: string): boolean { - return eventName in this._observers; + return this._observers[eventName] !== undefined; } /** @@ -511,7 +532,14 @@ export class Observable { private static _indexOfListener(list: Array, callback: (data: EventData) => void, thisArg?: any): number { thisArg = thisArg || undefined; - return list.findIndex((entry) => entry.callback === callback && entry.thisArg === thisArg); + for (let i = 0, length = list.length; i < length; i++) { + const entry = list[i]; + if (entry.callback === callback && entry.thisArg === thisArg) { + return i; + } + } + + return -1; } } diff --git a/packages/core/file-system/index.d.ts b/packages/core/file-system/index.d.ts index 7511bdf46c..44c3656060 100644 --- a/packages/core/file-system/index.d.ts +++ b/packages/core/file-system/index.d.ts @@ -287,7 +287,7 @@ export class Folder extends FileSystemEntity { /** * Provides access to the top-level Folders instances that are accessible from the application. Use these as entry points to access the FileSystem. */ -export module knownFolders { +export namespace knownFolders { /** * Gets the Documents folder available for the current application. This Folder is private for the application and not accessible from Users/External apps. */ @@ -314,7 +314,7 @@ export module knownFolders { /** * Contains iOS-specific known folders. */ - module ios { + namespace ios { /** * Gets the NSLibraryDirectory. Note that the folder will not be created if it did not exist. */ @@ -360,7 +360,7 @@ export module knownFolders { /** * Enables path-specific operations like join, extension, etc. */ -export module path { +export namespace path { /** * Normalizes a path, taking care of occurrances like ".." and "//". * @param path The path to be normalized. diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index adc95121cb..93539deb49 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -1,3 +1,4 @@ +// eslint-disable-next-line @typescript-eslint/triple-slash-reference /// /** * IMPORTANT: this is not generated automatically due to this issue: diff --git a/packages/core/inspector_modules.ts b/packages/core/inspector_modules.ts index 2c25d04178..80dbc50795 100644 --- a/packages/core/inspector_modules.ts +++ b/packages/core/inspector_modules.ts @@ -183,7 +183,7 @@ function remapStack(raw: string): string { const lines = raw.split('\n'); const out = lines.map((line) => { // 1) Parenthesized frame: at fn (file:...:L:C) - let m = /\((.+):(\d+):(\d+)\)/.exec(line); + const m = /\((.+):(\d+):(\d+)\)/.exec(line); if (m) { try { const [_, file, l, c] = m; diff --git a/packages/core/trace/index.d.ts b/packages/core/trace/index.d.ts index 665a9dcc57..e13162c051 100644 --- a/packages/core/trace/index.d.ts +++ b/packages/core/trace/index.d.ts @@ -93,7 +93,7 @@ export namespace Trace { export function removeEventListener(listener: TraceEventListener): void; - export module messageType { + export namespace messageType { export const log = 0; export const info = 1; export const warn = 2; @@ -103,7 +103,7 @@ export namespace Trace { /** * all predefined categories. */ - export module categories { + export namespace categories { export const VisualTreeEvents = 'VisualTreeEvents'; export const Layout = 'Layout'; export const Style = 'Style'; diff --git a/packages/core/ui/core/properties/property-shared.ts b/packages/core/ui/core/properties/property-shared.ts index 4618c78e59..b91226b031 100644 --- a/packages/core/ui/core/properties/property-shared.ts +++ b/packages/core/ui/core/properties/property-shared.ts @@ -54,10 +54,21 @@ export function isCssUnsetValue(value: any): boolean { return value === 'unset' || value === 'revert'; } +// These run at the top of every property setter, so the common case of setting +// a non-string value (number, boolean, Color, object) must bail out after a +// single typeof check instead of comparing against each reset keyword. export function isResetValue(value: any): boolean { - return value === unsetValue || value === 'initial' || value === 'inherit' || isCssUnsetValue(value); + if (typeof value !== 'string') { + return value === unsetValue; + } + + return value === 'initial' || value === 'inherit' || value === 'unset' || value === 'revert'; } export function isCssWideKeyword(value: any): boolean { - return value === 'initial' || value === 'inherit' || isCssUnsetValue(value); + if (typeof value !== 'string') { + return false; + } + + return value === 'initial' || value === 'inherit' || value === 'unset' || value === 'revert'; } diff --git a/packages/core/ui/core/view-base/index.ts b/packages/core/ui/core/view-base/index.ts index d4f7b3fd2c..4f46311794 100644 --- a/packages/core/ui/core/view-base/index.ts +++ b/packages/core/ui/core/view-base/index.ts @@ -174,7 +174,7 @@ export function getViewByDomId(view: ViewBase, domId: number): ViewBase { let retVal: ViewBase; const descendantsCallback = function (child: ViewBase): boolean { - if (view._domId === domId) { + if (child._domId === domId) { retVal = child; // break the iteration by returning false @@ -304,6 +304,32 @@ const DEFAULT_VIEW_PADDINGS: Map = new Map(); * * @nsView ViewBase */ +// Shared traversal callbacks for onLoaded/onUnloaded so each load/unload pass +// does not allocate a fresh closure per view. Dispatching through the child's +// parent preserves loadView/unloadView overrides (e.g. TabView); the fallback +// mirrors the base implementations for the theoretical parentless case. +const loadedTraversalCallback = (child: ViewBase): boolean => { + const parent = child.parent; + if (parent) { + parent.loadView(child); + } else if (!child.isLoaded) { + child.callLoaded(); + } + + return true; +}; + +const unloadedTraversalCallback = (child: ViewBase): boolean => { + const parent = child.parent; + if (parent) { + parent.unloadView(child); + } else if (child.isLoaded) { + child.callUnloaded(); + } + + return true; +}; + export abstract class ViewBase extends Observable { /** * String value used when hooking to loaded event. @@ -722,11 +748,7 @@ export abstract class ViewBase extends Observable { this._cssState.onLoaded(); this._resumeNativeUpdates(SuspendType.Loaded); - this.eachChild((child) => { - this.loadView(child); - - return true; - }); + this.eachChild(loadedTraversalCallback); this._emit('loaded'); } @@ -740,11 +762,7 @@ export abstract class ViewBase extends Observable { this._suspendNativeUpdates(SuspendType.Loaded); - this.eachChild((child) => { - this.unloadView(child); - - return true; - }); + this.eachChild(unloadedTraversalCallback); this._isLoaded = false; this._cssState.onUnloaded(); @@ -842,16 +860,18 @@ export abstract class ViewBase extends Observable { highlighted: ['active', 'pressed'], }; - private getAllAliasedStates(name: string): string[] { - const allStates: string[] = [name]; - - if (name in this.pseudoClassAliases) { - for (let i = 0, length = this.pseudoClassAliases[name].length; i < length; i++) { - allStates.push(this.pseudoClassAliases[name][i]); - } + private addSinglePseudoClass(name: string): void { + if (!this.cssPseudoClasses.has(name)) { + this.cssPseudoClasses.add(name); + this.notifyPseudoClassChanged(name); } + } - return allStates; + private deleteSinglePseudoClass(name: string): void { + if (this.cssPseudoClasses.has(name)) { + this.cssPseudoClasses.delete(name); + this.notifyPseudoClassChanged(name); + } } /** @@ -861,11 +881,12 @@ export abstract class ViewBase extends Observable { */ @profile public addPseudoClass(name: string): void { - const allStates = this.getAllAliasedStates(name); - for (let i = 0, length = allStates.length; i < length; i++) { - if (!this.cssPseudoClasses.has(allStates[i])) { - this.cssPseudoClasses.add(allStates[i]); - this.notifyPseudoClassChanged(allStates[i]); + this.addSinglePseudoClass(name); + + const aliases = this.pseudoClassAliases[name]; + if (aliases) { + for (let i = 0, length = aliases.length; i < length; i++) { + this.addSinglePseudoClass(aliases[i]); } } } @@ -877,11 +898,12 @@ export abstract class ViewBase extends Observable { */ @profile public deletePseudoClass(name: string): void { - const allStates = this.getAllAliasedStates(name); - for (let i = 0, length = allStates.length; i < length; i++) { - if (this.cssPseudoClasses.has(allStates[i])) { - this.cssPseudoClasses.delete(allStates[i]); - this.notifyPseudoClassChanged(allStates[i]); + this.deleteSinglePseudoClass(name); + + const aliases = this.pseudoClassAliases[name]; + if (aliases) { + for (let i = 0, length = aliases.length; i < length; i++) { + this.deleteSinglePseudoClass(aliases[i]); } } } @@ -1606,10 +1628,15 @@ export const classNameProperty = new Property({ cssClasses.add(CSSUtils.ROOT_VIEW_CSS_CLASS); } - rootViewsCssClasses.forEach((c) => cssClasses.add(c)); + for (let i = 0, length = rootViewsCssClasses.length; i < length; i++) { + cssClasses.add(rootViewsCssClasses[i]); + } if (typeof newValue === 'string' && newValue !== '') { - newValue.split(' ').forEach((c) => cssClasses.add(c)); + const classes = newValue.split(' '); + for (let i = 0, length = classes.length; i < length; i++) { + cssClasses.add(classes[i]); + } } view._onCssStateChange(); diff --git a/packages/core/ui/layouts/layout-base-common.ts b/packages/core/ui/layouts/layout-base-common.ts index 0a240601ed..c21d08e784 100644 --- a/packages/core/ui/layouts/layout-base-common.ts +++ b/packages/core/ui/layouts/layout-base-common.ts @@ -138,14 +138,18 @@ export class LayoutBaseCommon extends CustomLayoutView implements LayoutBaseDefi public eachLayoutChild(callback: (child: View, isLast: boolean) => void): void { let lastChild: View = null; - this.eachChildView((cv) => { - cv._eachLayoutView((lv) => { - if (lastChild && !lastChild.isCollapsed) { - callback(lastChild, false); - } + // Single shared callback instead of one closure per child — this runs on + // every measure and every layout pass + const trackLayoutView = (lv: View) => { + if (lastChild && !lastChild.isCollapsed) { + callback(lastChild, false); + } - lastChild = lv; - }); + lastChild = lv; + }; + + this.eachChildView((cv) => { + cv._eachLayoutView(trackLayoutView); return true; }); diff --git a/packages/core/ui/styling/css-selector.spec.ts b/packages/core/ui/styling/css-selector.spec.ts index ab99aeacbb..3beef7d6b4 100644 --- a/packages/core/ui/styling/css-selector.spec.ts +++ b/packages/core/ui/styling/css-selector.spec.ts @@ -279,6 +279,83 @@ describe('css-selector', () => { //expect(rule.selectors[0].specificity).toEqual(0); }); + it('attribute selector with case-insensitive flag matches repeatedly', () => { + const rule = createOne(`button[testAttr='VaLuE' i] { color: red; }`); + const matching = { cssType: 'button', testAttr: 'vAlUe' }; + const nonMatching = { cssType: 'button', testAttr: 'other' }; + + // Run multiple times to ensure matching does not depend on per-match state + for (let i = 0; i < 3; i++) { + expect(rule.selectors[0].match(matching)).toBe(true); + expect(rule.selectors[0].match(nonMatching)).toBe(false); + } + }); + + it('query returns selectors sorted by specificity then position', () => { + const { selectorScope } = create(` + button { color: red; } + .login { color: blue; } + button.login { color: green; } + #main { color: yellow; } + `); + + const { selectors } = selectorScope.query({ cssType: 'button', id: 'main', cssClasses: new Set(['login']) }); + expect(selectors.length).toBe(4); + expect(selectors.map((sel) => sel.toString().trim())).toEqual(['button', '.login', 'button.login', '#main']); + }); + + describe('repeated query consistency', () => { + it('returns identical results for repeated and structurally identical queries', () => { + const { selectorScope } = create(` + button { color: red; } + .login { color: blue; } + #main { color: yellow; } + `); + + const nodeA = { cssType: 'button', cssClasses: new Set(['login']) }; + const nodeB = { cssType: 'button', cssClasses: new Set(['login']) }; + + const first = selectorScope.query(nodeA).selectors.map((sel) => sel.toString()); + const repeat = selectorScope.query(nodeA).selectors.map((sel) => sel.toString()); + const structural = selectorScope.query(nodeB).selectors.map((sel) => sel.toString()); + + expect(first.length).toBe(2); + expect(repeat).toEqual(first); + expect(structural).toEqual(first); + }); + + it('reflects class changes on the same node between queries', () => { + const { selectorScope } = create(` + button { color: red; } + .login { color: blue; } + `); + + const node = { cssType: 'button', cssClasses: new Set() }; + expect(selectorScope.query(node).selectors.length).toBe(1); + + node.cssClasses.add('login'); + expect(selectorScope.query(node).selectors.length).toBe(2); + + node.cssClasses.delete('login'); + expect(selectorScope.query(node).selectors.length).toBe(1); + }); + + it('does not accumulate media-query selectors across repeated queries', () => { + const { widthDIPs } = Screen.mainScreen; + const { selectorScope } = create(` + button { color: red; } + @media only screen and (max-width: ${widthDIPs}) { + button { color: green; } + } + `); + + const node = { cssType: 'button' }; + expect(selectorScope.query(node).selectors.length).toBe(2); + expect(selectorScope.query(node).selectors.length).toBe(2); + expect(selectorScope.query(node).selectors.length).toBe(2); + }); + }); + describe('media queries', () => { const { widthDIPs } = Screen.mainScreen; diff --git a/packages/core/ui/styling/css-selector.ts b/packages/core/ui/styling/css-selector.ts index 601797706c..88c9244646 100644 --- a/packages/core/ui/styling/css-selector.ts +++ b/packages/core/ui/styling/css-selector.ts @@ -306,6 +306,11 @@ export class AttributeSelector extends SimpleSelector { public ignoreCase: boolean, ) { super(); + + // Normalize once at construction instead of on every match + if (ignoreCase && value) { + this.value = value.toLowerCase(); + } } public toString(): string { return `[${this.attribute}${wrap(AttributeSelectorOperator[this.test] ?? this.test)}${this.value || ''}]${wrap(this.combinator)}`; @@ -326,7 +331,6 @@ export class AttributeSelector extends SimpleSelector { if (this.ignoreCase) { attr = attr.toLowerCase(); - this.value = this.value.toLowerCase(); } // = @@ -495,13 +499,30 @@ export class SimpleSelectorSequence extends SimpleSelector { return `${this.selectors.join('')}${wrap(this.combinator)}`; } public match(node: Node): boolean { - return this.selectors.every((sel) => sel.match(node)); + const selectors = this.selectors; + for (let i = 0, length = selectors.length; i < length; i++) { + if (!selectors[i].match(node)) { + return false; + } + } + + return true; } public mayMatch(node: Node): boolean { - return this.selectors.every((sel) => sel.mayMatch(node)); + const selectors = this.selectors; + for (let i = 0, length = selectors.length; i < length; i++) { + if (!selectors[i].mayMatch(node)) { + return false; + } + } + + return true; } public trackChanges(node: Node, map: ChangeAccumulator): void { - this.selectors.forEach((sel) => sel.trackChanges(node, map)); + const selectors = this.selectors; + for (let i = 0, length = selectors.length; i < length; i++) { + selectors[i].trackChanges(node, map); + } } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { this.head.lookupSort(sorter, base || this); @@ -564,22 +585,31 @@ export class ComplexSelector extends SelectorCore { } public match(node: Node): boolean { - return this.groups.every((group, i) => { + const groups = this.groups; + for (let i = 0, length = groups.length; i < length; i++) { + const group = groups[i]; if (i === 0) { node = group.getMatchingNode(node, true); - - return !!node; + if (!node) { + return false; + } } else { let ancestor = node; + let matched = false; while ((ancestor = ancestor.parent ?? ancestor._modalParent)) { if ((node = group.getMatchingNode(ancestor, true))) { - return true; + matched = true; + break; } } - return false; + if (!matched) { + return false; + } } - }); + } + + return true; } public mayMatch(node: Node): boolean { @@ -587,7 +617,10 @@ export class ComplexSelector extends SelectorCore { } public trackChanges(node: Node, map: ChangeAccumulator): void { - this.selectors.forEach((sel) => sel.trackChanges(node, map)); + const selectors = this.selectors; + for (let i = 0, length = selectors.length; i < length; i++) { + selectors[i].trackChanges(node, map); + } } public lookupSort(sorter: LookupSorter, base?: SelectorCore): void { @@ -659,8 +692,23 @@ export namespace Selector { } public getMatchingNode(node: Node, strict: boolean) { - const funcName = strict ? 'match' : 'mayMatch'; - return this.selectors.every((sel, i) => (node = i === 0 ? node : node.parent) && sel[funcName](node)) ? node : null; + const selectors = this.selectors; + for (let i = 0, length = selectors.length; i < length; i++) { + if (i !== 0) { + node = node.parent; + } + + if (!node) { + return null; + } + + const sel = selectors[i]; + if (!(strict ? sel.match(node) : sel.mayMatch(node))) { + return null; + } + } + + return node; } public match(node: Node): boolean { @@ -949,30 +997,54 @@ function isDeclaration(node: ReworkCSS.Node): node is ReworkCSS.Declaration { return node.type === 'declaration'; } +// Media query strings come from parsed stylesheets, so the distinct set is small +// and stable. Cache the split results as splitting happens on every style query. +const splitMediaQueryCache = new Map(); + +function splitMediaQueryString(mediaQueryString: string): string[] { + let mediaQueryStrings = splitMediaQueryCache.get(mediaQueryString); + if (!mediaQueryStrings) { + mediaQueryStrings = mediaQueryString.split(MEDIA_QUERY_SEPARATOR); + splitMediaQueryCache.set(mediaQueryString, mediaQueryStrings); + } + + return mediaQueryStrings; +} + export function matchMediaQueryString(mediaQueryString: string, cachedQueries: string[]): boolean { // It can be a single or multiple queries in case of nested media queries - const mediaQueryStrings = mediaQueryString.split(MEDIA_QUERY_SEPARATOR); + const mediaQueryStrings = splitMediaQueryString(mediaQueryString); - return mediaQueryStrings.every((mq) => { - let isMatching: boolean; + for (let i = 0, length = mediaQueryStrings.length; i < length; i++) { + const mq = mediaQueryStrings[i]; // Query has already been validated if (cachedQueries.includes(mq)) { - isMatching = true; - } else { - isMatching = checkIfMediaQueryMatches(mq); - if (isMatching) { - cachedQueries.push(mq); - } + continue; } - return isMatching; - }); + + if (!checkIfMediaQueryMatches(mq)) { + return false; + } + + cachedQueries.push(mq); + } + + return true; } interface SelectorMap { [key: string]: SelectorCore[]; } +function appendSelectorCandidates(candidates: SelectorCore[], selectors: SelectorCore[] | undefined): void { + if (selectors) { + for (let i = 0, length = selectors.length; i < length; i++) { + candidates.push(selectors[i]); + } + } +} + export abstract class SelectorScope implements LookupSorter { private id: SelectorMap = {}; private class: SelectorMap = {}; @@ -983,13 +1055,17 @@ export abstract class SelectorScope implements LookupSorter { getSelectorCandidates(node: T) { const { cssClasses, id, cssType } = node; - const selectorClasses = [this.universal, this.id[id], this.type[cssType]]; + const candidates: SelectorCore[] = []; + + appendSelectorCandidates(candidates, this.universal); + appendSelectorCandidates(candidates, this.id[id]); + appendSelectorCandidates(candidates, this.type[cssType]); if (cssClasses && cssClasses.size) { - cssClasses.forEach((c) => selectorClasses.push(this.class[c])); + cssClasses.forEach((c) => appendSelectorCandidates(candidates, this.class[c])); } - return selectorClasses.reduce((cur, next) => cur.concat(next || []), []); + return candidates; } sortById(id: string, sel: SelectorCore): void { @@ -1101,8 +1177,7 @@ export class StyleSheetSelectorScope extends SelectorScope { const isMatchingAllQueries = matchMediaQueryString(selectorScope.mediaQueryString, validatedMediaQueries); if (isMatchingAllQueries) { - const mediaQuerySelectors = selectorScope.getSelectorCandidates(node); - selectors.push(...mediaQuerySelectors); + appendSelectorCandidates(selectors, selectorScope.getSelectorCandidates(node)); } } } diff --git a/packages/core/ui/styling/style-scope.ts b/packages/core/ui/styling/style-scope.ts index 81a1616e85..18c406b66a 100644 --- a/packages/core/ui/styling/style-scope.ts +++ b/packages/core/ui/styling/style-scope.ts @@ -54,6 +54,7 @@ let currentScopeTag: string = null; const animationsSymbol = Symbol('animations'); const kebabCasePattern = /-([a-z])/g; +const kebabCaseReplacementFunc = (g: string) => g[1].toUpperCase(); const pattern = /('|")(.*?)\1/; /** @@ -606,7 +607,14 @@ export class CssState { return; } - const matchingSelectors = this._match.selectors.filter((sel) => (sel.dynamic ? sel.match(view) : true)); + const selectors = this._match.selectors; + const matchingSelectors: SelectorCore[] = []; + for (let i = 0, length = selectors.length; i < length; i++) { + const sel = selectors[i]; + if (!sel.dynamic || sel.match(view)) { + matchingSelectors.push(sel); + } + } // Ideally we should return here if there are no matching selectors, however // if there are property removals, returning here would not remove them @@ -699,7 +707,6 @@ export class CssState { const valuesToApply = {}; const cssExpsProperties = {}; - const replacementFunc = (g) => g[1].toUpperCase(); for (const property in newPropertyValues) { const value = cleanupImportantFlags(newPropertyValues[property], property); @@ -711,34 +718,44 @@ export class CssState { cssExpsProperties[property] = value; continue; } - delete oldProperties[property]; - if (property in oldProperties && oldProperties[property] === value) { - // Skip unchanged values - continue; - } if (isCssVariable(property)) { + // Scoped css-variables were reset above, so always re-register them + // even when the value is unchanged + delete oldProperties[property]; view.style.setScopedCssVariable(property, value); delete newPropertyValues[property]; continue; } + const unchanged = property in oldProperties && oldProperties[property] === value; + delete oldProperties[property]; + if (unchanged) { + // Skip unchanged values + continue; + } valuesToApply[property] = value; } //we need to parse CSS vars first before evaluating css expressions for (const property in cssExpsProperties) { - delete oldProperties[property]; const value = evaluateCssExpressions(view, property, cssExpsProperties[property]); - if (property in oldProperties && oldProperties[property] === value) { - // Skip unchanged values - continue; - } if (value === unsetValue) { delete newPropertyValues[property]; } if (isCssVariable(property)) { + // Scoped css-variables were reset above, so always re-register them + // even when the value is unchanged + delete oldProperties[property]; view.style.setScopedCssVariable(property, value); delete newPropertyValues[property]; - } + valuesToApply[property] = value; + continue; + } + const unchanged = property in oldProperties && oldProperties[property] === value; + delete oldProperties[property]; + if (unchanged) { + // Skip unchanged values + continue; + } valuesToApply[property] = value; } @@ -747,7 +764,7 @@ export class CssState { if (property in view.style) { view.style[`css:${property}`] = unsetValue; } else { - const camelCasedProperty = property.replace(kebabCasePattern, replacementFunc); + const camelCasedProperty = property.replace(kebabCasePattern, kebabCaseReplacementFunc); view[camelCasedProperty] = unsetValue; } } @@ -758,7 +775,7 @@ export class CssState { if (property in view.style) { view.style[`css:${property}`] = value; } else { - const camelCasedProperty = property.replace(kebabCasePattern, replacementFunc); + const camelCasedProperty = property.replace(kebabCasePattern, kebabCaseReplacementFunc); view[camelCasedProperty] = value; } } catch (e) { diff --git a/packages/core/utils/layout-helper/index.ios.ts b/packages/core/utils/layout-helper/index.ios.ts index 11fced01b5..ec12062824 100644 --- a/packages/core/utils/layout-helper/index.ios.ts +++ b/packages/core/utils/layout-helper/index.ios.ts @@ -1,6 +1,16 @@ import * as layoutCommon from './layout-helper-common'; import { ios as iosUtils } from '../native-helper'; +// Cache the screen scale to avoid repeated marshalling calls into UIKit +// (window resolution + screen + scale) on every dp/px conversion during +// measure and layout passes. The scale of the device's main screen never +// changes at runtime, and `Screen.mainScreen` relies on the same invariant. +let mainScreenScale = 0; + +function getMainScreenScale(): number { + return mainScreenScale || (mainScreenScale = iosUtils.getMainScreen().scale); +} + export namespace layout { // cache the MeasureSpec constants here, to prevent extensive marshaling calls to and from Objective C // TODO: While this boosts the performance it is error-prone in case Google changes these constants @@ -37,15 +47,15 @@ export namespace layout { } export function getDisplayDensity(): number { - return iosUtils.getMainScreen().scale; + return getMainScreenScale(); } export function toDevicePixels(value: number): number { - return value * iosUtils.getMainScreen().scale; + return value * getMainScreenScale(); } export function toDeviceIndependentPixels(value: number): number { - return value / iosUtils.getMainScreen().scale; + return value / getMainScreenScale(); } export function round(value: number) {