diff --git a/packages/@glimmer/interfaces/lib/references.d.ts b/packages/@glimmer/interfaces/lib/references.d.ts index 9bb1415eb57..900210fbb23 100644 --- a/packages/@glimmer/interfaces/lib/references.d.ts +++ b/packages/@glimmer/interfaces/lib/references.d.ts @@ -4,19 +4,25 @@ export type ConstantReference = 0; export type ComputeReference = 1; export type UnboundReference = 2; export type InvokableReference = 3; +export type CellReference = 4; +export type PropertyReference = 5; export interface ReferenceTypes { readonly Constant: ConstantReference; readonly Compute: ComputeReference; readonly Unbound: UnboundReference; readonly Invokable: InvokableReference; + readonly Cell: CellReference; + readonly Property: PropertyReference; } export type ReferenceType = | ConstantReference | ComputeReference | UnboundReference - | InvokableReference; + | InvokableReference + | CellReference + | PropertyReference; declare const REFERENCE: unique symbol; export type ReferenceSymbol = typeof REFERENCE; diff --git a/packages/@glimmer/reference/lib/iterable.ts b/packages/@glimmer/reference/lib/iterable.ts index 71134eb5c2b..20d5fe491d0 100644 --- a/packages/@glimmer/reference/lib/iterable.ts +++ b/packages/@glimmer/reference/lib/iterable.ts @@ -3,13 +3,13 @@ import type { Nullable } from '@glimmer/interfaces'; import { getPath, toIterator } from '@glimmer/global-context'; import { EMPTY_ARRAY } from '@glimmer/util/lib/array-utils'; import { isIndexable } from '@glimmer/util/lib/collections'; -import { consumeTag } from '@glimmer/validator/lib/tracking'; -import { createTag, DIRTY_TAG as dirtyTag } from '@glimmer/validator/lib/validators'; import type { Reference, ReferenceEnvironment } from './reference'; import { createComputeRef, valueForRef } from './reference'; +export { createIteratorItemRef } from './reference'; + export interface IterationItem { key: unknown; value: T; @@ -50,28 +50,35 @@ const IDENTITY: KeyFor = (item) => { return item; }; -function keyForPath(path: string): KeyFor { +function pathKeyFor(path: string): KeyFor { if (DEBUG && path[0] === '@') { throw new Error(`invalid keypath: '${path}', valid keys: @index, @identity, or a path`); } - return uniqueKeyFor((item) => { + return (item) => { if (item === null || item === undefined) { return item; } return getPath(item, path); - }); + }; } -function makeKeyFor(key: string) { +/** + * Resolve the key strategy for a `{{#each}}` once, when the iterator reference is + * created — not on every diff. `base` is the stateless function that derives a + * key from an item; `dedup` says whether per-pass duplicate-key tracking is + * needed. `@index`/`@key` produce a unique value per position by construction, so + * they never need deduping; only identity and path keys can collide. + */ +function keyStrategy(key: string): { base: KeyFor; dedup: boolean } { switch (key) { case '@key': - return uniqueKeyFor(KEY); + return { base: KEY, dedup: false }; case '@index': - return uniqueKeyFor(INDEX); + return { base: INDEX, dedup: false }; case '@identity': - return uniqueKeyFor(IDENTITY); + return { base: IDENTITY, dedup: true }; default: - return keyForPath(key); + return { base: pathKeyFor(key), dedup: true }; } } @@ -147,8 +154,11 @@ function identityForNthOccurence(value: unknown, count: number) { * and encounter an item for the nth time, we can get the _same_ key, and let * Glimmer know that it should reuse the DOM for the previous nth occurence. */ -function uniqueKeyFor(keyFor: KeyFor) { - let seen = new WeakMapWithPrimitives(); +function uniqueKeyFor(keyFor: KeyFor): KeyFor { + // Per-pass state, discarded when the iteration completes — a plain `Map` + // (which keys on objects and primitives alike) is enough; the weak-keyed + // dual-map dance is only needed for the long-lived global `IDENTITIES`. + let seen = new Map(); return (value: unknown, memo: unknown) => { let key = keyFor(value, memo); @@ -165,11 +175,15 @@ function uniqueKeyFor(keyFor: KeyFor) { } export function createIteratorRef(listRef: Reference, key: string) { + // Resolve the key strategy once; only the (cheap) per-pass dedup state is + // rebuilt on each evaluation. + const { base, dedup } = keyStrategy(key); + return createComputeRef(() => { // eslint-disable-next-line @typescript-eslint/no-explicit-any let iterable = valueForRef(listRef) as { [Symbol.iterator]: any } | null | false; - let keyFor = makeKeyFor(key); + let keyFor = dedup ? uniqueKeyFor(base) : base; if (Array.isArray(iterable)) { return new ArrayIterator(iterable, keyFor); @@ -185,24 +199,6 @@ export function createIteratorRef(listRef: Reference, key: string) { }); } -export function createIteratorItemRef(_value: unknown) { - let value = _value; - let tag = createTag(); - - return createComputeRef( - () => { - consumeTag(tag); - return value; - }, - (newValue) => { - if (value !== newValue) { - value = newValue; - dirtyTag(tag); - } - } - ); -} - class IteratorWrapper implements OpaqueIterator { constructor( private inner: IteratorDelegate, diff --git a/packages/@glimmer/reference/lib/reference.ts b/packages/@glimmer/reference/lib/reference.ts index c7232d0469a..90744a90fab 100644 --- a/packages/@glimmer/reference/lib/reference.ts +++ b/packages/@glimmer/reference/lib/reference.ts @@ -1,21 +1,30 @@ import { DEBUG } from '@glimmer/env'; import type { + CellReference, ComputeReference, ConstantReference, InvokableReference, Nullable, + PropertyReference, Reference, ReferenceSymbol, ReferenceType, UnboundReference, } from '@glimmer/interfaces'; import type { Revision } from '@glimmer/validator/lib/validators'; -import type { Tag } from '@glimmer/interfaces'; +import type { DirtyableTag, Tag } from '@glimmer/interfaces'; import { expect } from '@glimmer/debug-util/lib/platform-utils'; import { getProp, setProp } from '@glimmer/global-context'; import { isDict } from '@glimmer/util/lib/collections'; -import { CONSTANT_TAG, INITIAL, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; -import { consumeTag, track } from '@glimmer/validator/lib/tracking'; +import { + CONSTANT_TAG, + createTag, + DIRTY_TAG as dirtyTag, + INITIAL, + validateTag, + valueForTag, +} from '@glimmer/validator/lib/validators'; +import { beginTrackFrame, consumeTag, endTrackFrame } from '@glimmer/validator/lib/tracking'; export const REFERENCE: ReferenceSymbol = Symbol('REFERENCE') as ReferenceSymbol; @@ -23,6 +32,8 @@ const CONSTANT: ConstantReference = 0; const COMPUTE: ComputeReference = 1; const UNBOUND: UnboundReference = 2; const INVOKABLE: InvokableReference = 3; +const CELL: CellReference = 4; +const PROPERTY: PropertyReference = 5; export type { Reference as default }; export type { Reference }; @@ -45,6 +56,11 @@ class ReferenceImpl implements Reference { public compute: Nullable<() => T> = null; public update: Nullable<(val: T) => void> = null; + // For PROPERTY references: the parent reference and the property path, stored + // as data instead of being captured in getter/setter closures. + public propertyParent: Nullable = null; + public propertyPath: Nullable = null; + public debugLabel?: string; constructor(type: ReferenceType) { @@ -115,6 +131,28 @@ export function createComputeRef( return ref; } +/** + * A `Cell` reference holds a value directly behind a single dirtyable tag. It is + * the reference used for `{{#each}}` block params (the item value and its index), + * which are created and updated by the millions when rendering large lists. + * + * Unlike a generic compute reference, a cell has no dependencies to discover: its + * value lives on the reference itself and its tag never changes. That lets + * `valueForRef` skip the `track()` frame (a `Tracker` + `Set` allocation per read) + * and lets `updateRef` mutate the value inline, so a cell needs no `compute`/ + * `update` closures at all — just the reference object and its tag. + */ +export function createIteratorItemRef(value: T): Reference { + const ref = new ReferenceImpl(CELL); + const tag = createTag(); + + ref.tag = tag; + ref.lastValue = value; + ref.lastRevision = valueForTag(tag); + + return ref; +} + export function createReadOnlyRef(ref: Reference): Reference { if (!isUpdatableRef(ref)) return ref; @@ -144,8 +182,9 @@ export function isConstRef(_ref: Reference) { export function isUpdatableRef(_ref: Reference) { const ref = _ref as ReferenceImpl; + const type = ref[REFERENCE]; - return ref.update !== null; + return type === CELL || type === PROPERTY || ref.update !== null; } export function valueForRef(_ref: Reference): T { @@ -161,21 +200,46 @@ export function valueForRef(_ref: Reference): T { let lastValue; if (tag === null || !validateTag(tag, lastRevision)) { - const { compute } = ref; - - const newTag = track(() => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme - lastValue = ref.lastValue = compute!(); - }, DEBUG && ref.debugLabel); - - tag = ref.tag = newTag; + if (ref[REFERENCE] === CELL) { + // A cell's value is stored on the reference and gated by a fixed tag, so + // there are no dependencies to (re)discover — read the stored value and + // re-snapshot the tag without opening a tracking frame. + lastValue = ref.lastValue; + ref.lastRevision = valueForTag(tag as Tag); + } else { + // Inlined `track()`: opening the frame directly avoids allocating a thunk + // closure on every (re)compute. This is the hottest path in the VM — every + // reference read that needs evaluation passes through here. + beginTrackFrame(DEBUG && ref.debugLabel); + let newTag!: Tag; + try { + if (ref[REFERENCE] === PROPERTY) { + // A property reference reads `path` off its parent's value. Holding + // the parent + path as data (rather than getter/setter closures) is + // what lets `childRefFor` avoid two closure allocations per access. + const parent = valueForRef(ref.propertyParent as Reference); + lastValue = ref.lastValue = isDict(parent) + ? (getProp(parent, ref.propertyPath as string) as T) + : undefined; + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- @fixme + lastValue = ref.lastValue = ref.compute!(); + } + } finally { + // Always end the frame to keep the tracking stack balanced, but commit + // the new tag/revision only on success (below) — matching `track()`'s + // semantics so a throwing getter leaves the reference's tag untouched. + newTag = endTrackFrame(); + } - ref.lastRevision = valueForTag(newTag); + tag = ref.tag = newTag; + ref.lastRevision = valueForTag(newTag); + } } else { lastValue = ref.lastValue; } - consumeTag(tag); + consumeTag(tag as Tag); return lastValue as T; } @@ -183,6 +247,25 @@ export function valueForRef(_ref: Reference): T { export function updateRef(_ref: Reference, value: unknown) { const ref = _ref as ReferenceImpl; + if (ref[REFERENCE] === CELL) { + // Equality-gated inline update — no closure indirection. Mirrors the old + // `createIteratorItemRef` setter semantics. + if (ref.lastValue !== value) { + ref.lastValue = value; + dirtyTag(ref.tag as DirtyableTag); + } + return; + } + + if (ref[REFERENCE] === PROPERTY) { + // Inline `setProp` on the parent's value — mirrors the old childRefFor setter. + const parent = valueForRef(ref.propertyParent as Reference); + if (isDict(parent)) { + setProp(parent, ref.propertyPath as string, value); + } + return; + } + const update = expect(ref.update, 'called update on a non-updatable reference'); update(value); @@ -216,26 +299,18 @@ export function childRefFor(_parentRef: Reference, path: string): Reference { child = UNDEFINED_REFERENCE; } } else { - child = createComputeRef( - () => { - const parent = valueForRef(parentRef); - - if (isDict(parent)) { - return getProp(parent, path); - } - }, - (val) => { - const parent = valueForRef(parentRef); - - if (isDict(parent)) { - return setProp(parent, path, val); - } - } - ); + // A PROPERTY reference: `getProp`/`setProp` of `path` on the parent's value. + // Storing the parent + path as data (handled inline by valueForRef/updateRef) + // avoids allocating the getter and setter closures this used to need. + const propertyRef = new ReferenceImpl(PROPERTY); + propertyRef.propertyParent = parentRef; + propertyRef.propertyPath = path; if (DEBUG) { - child.debugLabel = `${parentRef.debugLabel}.${path}`; + propertyRef.debugLabel = `${parentRef.debugLabel}.${path}`; } + + child = propertyRef; } children.set(path, child); @@ -260,7 +335,11 @@ if (DEBUG) { const update = isUpdatableRef(inner) ? (value: unknown): void => updateRef(inner, value) : null; const ref = createComputeRef(() => valueForRef(inner), update); - ref[REFERENCE] = inner[REFERENCE]; + // A debug alias is a genuine compute reference (it recomputes through + // `inner`); never inherit the CELL/PROPERTY types, whose fast paths assume + // the value (or parent + path) lives directly on the reference. + const innerType = inner[REFERENCE]; + ref[REFERENCE] = innerType === CELL || innerType === PROPERTY ? COMPUTE : innerType; ref.debugLabel = debugLabel; diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index d94ae456e6b..b4d11890ce1 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -9,34 +9,74 @@ import { combine, CONSTANT_TAG, isConstTag, validateTag, valueForTag } from './v /** * An object that that tracks @tracked properties that were consumed. + * + * The vast majority of tracking frames consume zero or one tag (a single + * `{{property}}` read, a constant, etc.), so the first tag is held in a plain + * field and the `Set` is allocated lazily only when a *second, distinct* tag is + * consumed. Trackers themselves are pooled (see `allocTracker`/`freeTracker`) + * because frames are strictly nested, so the per-frame `new Tracker()` + + * `new Set()` pair — previously allocated on every reference recompute — is + * avoided entirely in the common case. */ class Tracker { - private tags = new Set(); - private last: Tag | null = null; + first: Tag | null = null; + set: Set | null = null; add(tag: Tag) { if (tag === CONSTANT_TAG) return; - this.tags.add(tag); - if (DEBUG) { unwrap(debug.markTagAsConsumed)(tag); } - this.last = tag; + let { set } = this; + + if (set !== null) { + set.add(tag); + return; + } + + let { first } = this; + + if (first === null) { + this.first = tag; + } else if (first !== tag) { + set = this.set = new Set(); + set.add(first); + set.add(tag); + } } combine(): Tag { - let { tags } = this; + let { first, set } = this; - if (tags.size === 0) { + if (set !== null) { + return combine(Array.from(set)); + } else if (first === null) { return CONSTANT_TAG; - } else if (tags.size === 1) { - return this.last as Tag; } else { - return combine(Array.from(this.tags)); + return first; } } + + reset() { + this.first = null; + this.set = null; + } +} + +// Trackers are pooled: a frame's tracker is dead the moment `combine()` has run +// in `endTrackFrame`, and frames are strictly nested (LIFO), so a closed +// tracker can be reset and handed to the next `beginTrackFrame`. +const TRACKER_POOL: Tracker[] = []; + +function allocTracker(): Tracker { + return TRACKER_POOL.pop() ?? new Tracker(); +} + +function freeTracker(tracker: Tracker): void { + tracker.reset(); + TRACKER_POOL.push(tracker); } /** @@ -59,7 +99,7 @@ const OPEN_TRACK_FRAMES: (Tracker | null)[] = []; export function beginTrackFrame(debuggingContext?: string | false): void { OPEN_TRACK_FRAMES.push(CURRENT_TRACKER); - CURRENT_TRACKER = new Tracker(); + CURRENT_TRACKER = allocTracker(); if (DEBUG) { unwrap(debug.beginTrackingTransaction)(debuggingContext); @@ -79,7 +119,10 @@ export function endTrackFrame(): Tag { CURRENT_TRACKER = OPEN_TRACK_FRAMES.pop() || null; - return unwrap(current).combine(); + let tracker = unwrap(current); + let tag = tracker.combine(); + freeTracker(tracker); + return tag; } export function beginUntrackFrame(): void { diff --git a/packages/@glimmer/validator/lib/validators.ts b/packages/@glimmer/validator/lib/validators.ts index a701a133e53..c4c8805ca2a 100644 --- a/packages/@glimmer/validator/lib/validators.ts +++ b/packages/@glimmer/validator/lib/validators.ts @@ -122,6 +122,18 @@ class MonomorphicTagImpl { } [COMPUTE](): Revision { + // Fast path for subtag-less tags (property tags, cell tags, plain + // dirtyable/updatable tags — the overwhelming majority). With no subtags to + // fold in, the value is always just `revision`, which `dirtyTag` keeps + // current, so none of the `lastChecked`/`isUpdating`/cycle machinery below + // (which exists purely to memoize subtag recursion) is needed. This runs on + // every `validateTag`/`valueForTag`, i.e. every reference read. + let { subtag } = this; + + if (subtag === null) { + return this.revision; + } + let { lastChecked } = this; if (this.isUpdating) { @@ -135,24 +147,22 @@ class MonomorphicTagImpl { this.lastChecked = $REVISION; try { - let { subtag, revision } = this; - - if (subtag !== null) { - if (Array.isArray(subtag)) { - for (const tag of subtag) { - let value = tag[COMPUTE](); - revision = Math.max(value, revision); - } + let { revision } = this; + + if (Array.isArray(subtag)) { + for (const tag of subtag) { + let value = tag[COMPUTE](); + revision = Math.max(value, revision); + } + } else { + let subtagValue = subtag[COMPUTE](); + + if (subtagValue === this.subtagBufferCache) { + revision = Math.max(revision, this.lastValue); } else { - let subtagValue = subtag[COMPUTE](); - - if (subtagValue === this.subtagBufferCache) { - revision = Math.max(revision, this.lastValue); - } else { - // Clear the temporary buffer cache - this.subtagBufferCache = null; - revision = Math.max(revision, subtagValue); - } + // Clear the temporary buffer cache + this.subtagBufferCache = null; + revision = Math.max(revision, subtagValue); } }