Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ All notable changes to this project will be documented in this file. The format
- Add `logTree` helper to inspect the component tree from the console ([#654](https://github.com/studiometa/js-toolkit/pull/654))
- Add performance benchmarks for tween, animate, transform, services and Base internals ([#722](https://github.com/studiometa/js-toolkit/pull/722))
- Add CodSpeed CI integration for continuous performance tracking ([#723](https://github.com/studiometa/js-toolkit/pull/723))
- Add performance benchmarks for `__parent` resolution and component queries ([4112247e](https://github.com/studiometa/js-toolkit/commit/4112247e), [1ecf9d37](https://github.com/studiometa/js-toolkit/commit/1ecf9d37))

### Changed

Expand All @@ -39,6 +40,9 @@ All notable changes to this project will be documented in this file. The format
- Improve `ScrollService.updateProps` by caching scroll max values and refreshing them on resize and scrolling-element size changes ([#721](https://github.com/studiometa/js-toolkit/pull/721), [001fbbef](https://github.com/studiometa/js-toolkit/commit/001fbbef), [d7352b26](https://github.com/studiometa/js-toolkit/commit/d7352b26), [b8b6cc8e](https://github.com/studiometa/js-toolkit/commit/b8b6cc8e))
- Improve `transform` and `tween` by replacing `isDefined()` with inline `!== undefined` checks ([#721](https://github.com/studiometa/js-toolkit/pull/721), [cdb5db8b](https://github.com/studiometa/js-toolkit/commit/cdb5db8b), [f6276353](https://github.com/studiometa/js-toolkit/commit/f6276353))
- Improve `getAllProperties` by replacing O(n²) array spread with O(n) push ([#721](https://github.com/studiometa/js-toolkit/pull/721), [d52459f6](https://github.com/studiometa/js-toolkit/commit/d52459f6))
- Improve `$parent` and `$root` resolution by walking the DOM ancestors and reading each element's `__base__` map instead of scanning every instance, making it O(tree depth) instead of O(N instances) ([39895c51](https://github.com/studiometa/js-toolkit/commit/39895c51))
- Improve element-scoped `$query` and `$queryAll` (`queryComponent`/`queryComponentAll` with a `from` element) by resolving matches through a scoped DOM traversal instead of a global instance scan ([7f12b364](https://github.com/studiometa/js-toolkit/commit/7f12b364))
- Element-scoped `queryComponent`/`queryComponentAll` now return results in DOM order instead of mount order, and return a single instance when several live components share the same name on the same element ([7f12b364](https://github.com/studiometa/js-toolkit/commit/7f12b364))

### Fixed

Expand Down
37 changes: 22 additions & 15 deletions packages/js-toolkit/Base/Base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import {
addInstance,
deleteInstance,
addToRegistry,
getInstances,
findClosestInstance,
hasInstance,
} from './utils.js';
import {
ChildrenManager,
Expand Down Expand Up @@ -187,22 +186,30 @@ export class Base<T extends BaseProps = BaseProps> {
* @internal
*/
get __parent(): (T['$parent'] & Base) | null {
const parents = new Set<T['$parent'] & Base>();

for (const instance of getInstances()) {
if (!instance.$config.components) continue;

if (
Object.values(instance.$config.components).includes(this.constructor) &&
instance.$el.contains(this.$el)
) {
parents.add(instance);
// Walk up the DOM ancestors and resolve the closest mounted instance whose
// config declares this component as a child, using each element's `__base__`
// map for an O(1)-per-ancestor lookup. This is O(tree depth) instead of the
// O(N_instances) global scan (plus `Set` copy) it replaces.
let el = this.$el.parentElement;

while (el) {
const baseMap = (el as BaseEl).__base__;

if (baseMap) {
for (const instance of baseMap.values()) {
if (instance === 'terminated' || !hasInstance(instance)) continue;

const { components } = instance.$config;
if (components && Object.values(components).includes(this.constructor)) {
return instance as T['$parent'] & Base;
}
}
}
}

const closest = findClosestInstance(this.$el, parents);
el = el.parentElement;
}

return closest ?? null;
return null;
}

/**
Expand Down
9 changes: 9 additions & 0 deletions packages/js-toolkit/Base/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ export function deleteInstance(instance: Base) {
getElementsStorage().delete(instance.$el as HTMLElement & { __base__: Map<string, Base> });
}

/**
* Test whether an instance is currently registered in the global storage.
* Membership is added on `before-mounted` and removed on `after-destroyed`,
* so this is an O(1) check for "is this instance live" without copying the Set.
*/
export function hasInstance(instance: Base): boolean {
return getInstancesStorage().has(instance);
}

const registryKey = '__JS_TOOLKIT_REGISTRY__';

function registry() {
Expand Down
96 changes: 80 additions & 16 deletions packages/js-toolkit/helpers/queryComponent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Base, BaseEl } from '../Base/index.js';
import { getInstances } from '../Base/index.js';
import { getInstances, hasInstance } from '../Base/utils.js';
import { memo } from '../utils/memo.js';
import { getAncestorWhere } from '../utils/dom/ancestors.js';

Expand Down Expand Up @@ -46,6 +46,81 @@ export function instanceIsMatching(instance: Base, parsedQuery: ParsedQuery): bo
return true;
}

/**
* Resolve the live instance attached to an element under a given name.
*
* Reads the element's `__base__` map (the same O(1)-per-element lookup
* `closestComponent` uses) and applies the single matching gate:
* - the entry must exist and not be the literal `'terminated'` string
* written by `$terminate` (`Base.ts`);
* - the instance must still be "live" (registered in the global storage
* between `before-mounted` and `after-destroyed`) — `__base__` keeps
* destroyed-but-not-terminated instances that `getInstances()` has already
* dropped, so `hasInstance` preserves the historical visibility window;
* - it must satisfy `instanceIsMatching` (name, CSS selector, state).
*
* The lookup is keyed on `parsedQuery.name` only — never on a name-derived DOM
* selector — because `__base__` membership is independent of whether the element
* carries a `data-component` attribute or a `w-name` tag.
*/
function resolveMatch<T extends Base>(element: BaseEl, parsedQuery: ParsedQuery): T | undefined {
const baseMap = element.__base__;
if (!baseMap) return undefined;

const instance = baseMap.get(parsedQuery.name);
if (!instance || instance === 'terminated') return undefined;
if (!hasInstance(instance) || !instanceIsMatching(instance, parsedQuery)) return undefined;

return instance as T;
}

/**
* Yield the instances matching the given query within the `from` root.
*
* When `from` is the document, there is no containment/attachment constraint, so
* every live matching instance is returned even if its `$el` is detached from the
* global document — the global instance storage is the source of truth here (a
* `document.querySelectorAll` scan would miss detached elements entirely).
*
* When `from` is an element, matches are resolved via a scoped
* `querySelectorAll('*')` descendant traversal plus an explicit check of `from`
* itself (which `querySelectorAll` excludes), each resolved through its
* `__base__` map. This scales with the size of the `from` subtree instead of the
* global instance count. `'*'` is required — a name-derived selector would miss
* components mounted on elements that carry no matching attribute/tag (e.g.
* `withName(Base, name)` on a plain element, or a component registered under an
* arbitrary CSS selector).
*/
function* queryMatches<T extends Base>(
parsedQuery: ParsedQuery,
from: HTMLElement | Document,
): Generator<T> {
if (from === document) {
for (const instance of getInstances()) {
if (hasInstance(instance) && instanceIsMatching(instance, parsedQuery)) {
yield instance as T;
}
}
return;
}

const seen = new Set<T>();

const selfMatch = resolveMatch<T>(from as BaseEl, parsedQuery);
if (selfMatch) {
seen.add(selfMatch);
yield selfMatch;
}

for (const element of from.querySelectorAll<HTMLElement>('*')) {
const match = resolveMatch<T>(element as BaseEl, parsedQuery);
if (match && !seen.has(match)) {
seen.add(match);
yield match;
}
}
}

/**
* Get the first instance of component with the given query.
*/
Expand All @@ -55,14 +130,12 @@ export function queryComponent<T extends Base = Base>(
): T | undefined {
const parsedQuery = parseQuery(query);
const { from = document } = options;
const instances = getInstances() as ReadonlySet<T>;

for (const instance of instances) {
if (!instanceIsMatching(instance, parsedQuery)) continue;
if (from !== document && !(from === instance.$el || from.contains(instance.$el))) continue;

for (const instance of queryMatches<T>(parsedQuery, from)) {
return instance;
}

return undefined;
}

/**
Expand All @@ -74,17 +147,8 @@ export function queryComponentAll<T extends Base = Base>(
): T[] {
const parsedQuery = parseQuery(query);
const { from = document } = options;
const instances = getInstances() as ReadonlySet<T>;
const selectedInstances = new Set<T>();

for (const instance of instances) {
if (!instanceIsMatching(instance, parsedQuery)) continue;
if (from !== document && !(from === instance.$el || from.contains(instance.$el))) continue;

selectedInstances.add(instance);
}

return Array.from(selectedInstances);
return Array.from(queryMatches<T>(parsedQuery, from));
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/tests/Base/Base.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ describe('A Base instance', () => {

const child = h('div', { dataComponent: 'ChildComponent' });
const div = h('div', [child]);
document.body.append(div);
const component = new Component(div);
await mount(component);

Expand Down
130 changes: 130 additions & 0 deletions packages/tests/__benchmarks__/parent-resolution.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { describe, bench } from 'vitest';
import { Base } from '@studiometa/js-toolkit';
import type { BaseConfig } from '@studiometa/js-toolkit';
import { findClosestInstance } from '#private/Base/utils.js';

/**
* Benchmark for `__parent` resolution strategies.
*
* Current implementation (`Base/Base.ts` `__parent`) resolves the parent by
* scanning the whole global instance set on every access — O(N_instances),
* plus a full `Set` copy in `getInstances()`, plus an `$el.contains()` DOM walk
* for every instance whose config declares `this.constructor` as a child.
*
* The prototype walks the DOM ancestors of `$el` and uses each element's
* `__base__` map (the same O(1)-per-ancestor lookup `closestComponent` already
* uses) — O(tree depth), independent of the total number of mounted instances.
*
* The noise instances are `Parent`s, whose config declares `Child` as a child
* component. This is the worst case for the current algorithm: every noise
* instance passes the `.includes(target.constructor)` guard and therefore pays
* a full `$el.contains()` DOM walk, even though none of them is the real parent.
*
* Each scenario owns its own instance `Set` and its own detached DOM subtree, so
* the scenarios coexist without clobbering shared state and N genuinely varies
* at bench-run time (benches run after all `describe` bodies are collected).
*/

class Child extends Base {
static config: BaseConfig = { name: 'Child' };
}

class Parent extends Base {
static config: BaseConfig = { name: 'Parent', components: { Child } };
}

/** Replicates the current `Base.ts` `__parent` getter, over an explicit set. */
function resolveParentCurrent(target: Base, instances: Set<Base>): Base | null {
const parents = new Set<Base>();

// Mirror `getInstances()` copying the whole Set on every access.
for (const instance of new Set(instances)) {
if (!instance.$config.components) continue;
if (
Object.values(instance.$config.components).includes(target.constructor as never) &&
instance.$el.contains(target.$el)
) {
parents.add(instance);
}
}

return findClosestInstance(target.$el, parents) ?? null;
}

/** Prototype: walk DOM ancestors, resolve via the per-element `__base__` map. */
function resolveParentDomWalk(target: Base): Base | null {
let el: HTMLElement | null = target.$el.parentElement;

while (el) {
const baseMap = (el as Base['$el']).__base__;
if (baseMap) {
for (const instance of baseMap.values()) {
if ((instance as unknown) === 'terminated') continue;
const components = instance.$config.components;
if (components && Object.values(components).includes(target.constructor as never)) {
return instance;
}
}
}
el = el.parentElement;
}

return null;
}

/**
* Build a self-contained scenario: a target Child nested `depth` levels under a
* real Parent, plus `noise` unrelated Parent instances. Everything lives in its
* own detached subtree and its own instance Set.
*/
function buildScenario(noise: number, depth: number): { child: Base; instances: Set<Base> } {
const instances = new Set<Base>();
const root = document.createElement('div');

const parentEl = document.createElement('div');
parentEl.dataset.component = 'Parent';
root.appendChild(parentEl);
instances.add(new Parent(parentEl));

let host = parentEl;
for (let i = 0; i < depth; i++) {
const wrapper = document.createElement('div');
host.appendChild(wrapper);
host = wrapper;
}
const childEl = document.createElement('div');
childEl.dataset.component = 'Child';
host.appendChild(childEl);
const child = new Child(childEl);
instances.add(child);

for (let i = 0; i < noise; i++) {
const noiseEl = document.createElement('div');
noiseEl.dataset.component = 'Parent';
root.appendChild(noiseEl);
instances.add(new Parent(noiseEl));
}

return { child, instances };
}

const DEPTH = 5;

for (const noise of [50, 500, 2000]) {
describe(`__parent resolution — ${noise} instances, depth ${DEPTH}`, () => {
const { child, instances } = buildScenario(noise, DEPTH);

// Sanity: both strategies must agree on the resolved parent.
if (resolveParentCurrent(child, instances) !== resolveParentDomWalk(child)) {
throw new Error('Strategies disagree on the resolved parent');
}

bench('current (global scan)', () => {
resolveParentCurrent(child, instances);
});

bench('prototype (DOM ancestor walk)', () => {
resolveParentDomWalk(child);
});
});
}
Loading
Loading