diff --git a/packages/@ember/-internals/glimmer/lib/base-renderer.ts b/packages/@ember/-internals/glimmer/lib/base-renderer.ts index 955511c4389..824ebee8f20 100644 --- a/packages/@ember/-internals/glimmer/lib/base-renderer.ts +++ b/packages/@ember/-internals/glimmer/lib/base-renderer.ts @@ -24,8 +24,11 @@ import { artifacts } from '@glimmer/program/lib/helpers'; import { RuntimeOpImpl } from '@glimmer/program/lib/opcode'; import { clientBuilder } from '@glimmer/runtime/lib/vm/element-builder'; import { inTransaction, runtimeOptions } from '@glimmer/runtime/lib/environment'; +import { drainInvalidationQueue } from '@glimmer/runtime/lib/vm/update'; +import { beginTrackFrame, endTrackFrame } from '@glimmer/validator/lib/tracking'; +import type { Tag } from '@glimmer/interfaces'; import { renderComponent as glimmerRenderComponent } from '@glimmer/runtime/lib/render'; -import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; +import { consumeUnsubscribedDirt, CURRENT_TAG, subscribeToTag, unsubscribeFromTags, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; import type { SimpleDocument, SimpleElement } from '@simple-dom/interface'; import { hasDOM } from '../../browser-environment'; import { EmberEnvironmentDelegate } from './environment'; @@ -134,6 +137,13 @@ export class ComponentRootState implements RendererRoot { } } +/** SPIKE push-invalidation: roots whose own deps changed. */ +const queuedRoots = new Set(); +const rootSubscriptions = new WeakMap< + RendererRoot, + { leaves: Tag[]; enqueue: () => void } +>(); + const renderers: BaseRenderer[] = []; export function _resetRenderers() { @@ -197,10 +207,28 @@ function resolveRenderPromise() { } } +/** + * SPIKE: revalidation deferred to an animation frame is *expected* to + * leave the renderer invalid at runloop end -- the frame will handle + * it. Without this, loopEnd spins NO_OP runloops (recursing via join) + * until the loop guard throws. + */ +let framePending = false; + +export function setFramePending(value: boolean) { + framePending = value; +} + let loops = 0; function loopEnd() { for (let renderer of renderers) { if (!renderer.isValid()) { + if (framePending) { + // the scheduled frame will revalidate; its own runloop will + // re-enter loopEnd and resolve the render promise + return; + } + if (loops > ENV._RERENDER_LOOP_LIMIT) { loops = 0; // TODO: do something better @@ -348,7 +376,7 @@ export class RendererState { continue; } - root.render(); + this.#renderRootSubscribed(root); } this.#lastRevision = valueForTag(CURRENT_TAG); @@ -368,8 +396,25 @@ export class RendererState { } } + #frameScheduled = false; + + /** SPIKE: coalesce all invalidation delivery to one drain per frame. */ scheduleRevalidate(renderer: BaseRenderer): void { - _backburner.scheduleOnce('render', this, this.revalidate, renderer); + if (typeof requestAnimationFrame === 'function') { + if (this.#frameScheduled) { + return; + } + + this.#frameScheduled = true; + setFramePending(true); + requestAnimationFrame(() => { + this.#frameScheduled = false; + setFramePending(false); + _backburner.join(() => this.revalidate(renderer)); + }); + } else { + _backburner.scheduleOnce('render', this, this.revalidate, renderer); + } } isValid(): boolean { @@ -378,11 +423,66 @@ export class RendererState { ); } + /** + * SPIKE push-invalidation v5, walk-free: subscription coverage is + * complete (roots + blocks + items), so unsubscribed dirt affects + * nothing rendered and is deliberately ignored. The only "walk" is + * re-rendering a root whose own (non-delegated) deps changed -- + * which is the minimal correct response, not a fallback. + */ revalidate(renderer: BaseRenderer): void { if (this.isValid()) { return; } - this.#renderRootsTransaction(renderer); + + const stats = ((globalThis as any).__pushStats ??= { drains: 0, rootRenders: 0 }); + + stats.drains++; + consumeUnsubscribedDirt(); + + inTransaction(this.context.env, () => { + if (queuedRoots.size > 0) { + const roots = [...queuedRoots]; + + queuedRoots.clear(); + + for (const root of roots) { + if (root.destroyed) continue; + + stats.rootRenders++; + this.#renderRootSubscribed(root); + } + } + + drainInvalidationQueue(this.context.env); + }); + + this.#lastRevision = valueForTag(CURRENT_TAG); + consumeUnsubscribedDirt(); + } + + /** + * Render one root inside a tracking frame and keep its subscription + * pointed at what it actually read. Items and blocks do not + * propagate their deps upward, so this collects only the root's own + * non-delegated dependencies. + */ + #renderRootSubscribed(root: RendererRoot): void { + beginTrackFrame(); + + try { + root.render(); + } finally { + const tag = endTrackFrame(); + const previous = rootSubscriptions.get(root); + const enqueue = previous?.enqueue ?? (() => queuedRoots.add(root)); + + if (previous !== undefined) { + unsubscribeFromTags(previous.leaves, previous.enqueue); + } + + rootSubscriptions.set(root, { leaves: subscribeToTag(tag, enqueue), enqueue }); + } } clearAllRoots(renderer: BaseRenderer): void { diff --git a/packages/@ember/-internals/metal/lib/property_get.ts b/packages/@ember/-internals/metal/lib/property_get.ts index e76c9c14064..62397d6896d 100644 --- a/packages/@ember/-internals/metal/lib/property_get.ts +++ b/packages/@ember/-internals/metal/lib/property_get.ts @@ -111,24 +111,13 @@ export function _getProp(obj: unknown, keyName: string) { value = (obj as any)[keyName]; } - if ( - value === undefined && - typeof obj === 'object' && - !(keyName in obj) && - hasUnknownProperty(obj) - ) { - value = obj.unknownProperty(keyName); - } - - if (isTracking()) { - consumeTag(tagFor(obj, keyName)); - - if (Array.isArray(value) || isEmberArray(value)) { - // Add the tag of the returned value if it is an array, since arrays - // should always cause updates if they are consumed and then changed - consumeTag(tagFor(value, '[]')); - } - } + // SPIKE: deleted legacy read-path support: + // - unknownProperty (ObjectProxy / EmberObject) + // - per-(object, key) tag consumption on arbitrary objects, which + // existed so Ember.set() on POJOs invalidates renders + // - the '[]' EmberArray tag consume for array-valued reads + // Modern semantics: plain-data reads don't entangle; reactivity + // comes from @tracked, tracked collections, and value replacement. } else { // SAFETY: It should be ok to access properties on any non-nullish value value = (obj as any)[keyName]; diff --git a/packages/@glimmer/reference/lib/iterable.ts b/packages/@glimmer/reference/lib/iterable.ts index 71134eb5c2b..120d18114a7 100644 --- a/packages/@glimmer/reference/lib/iterable.ts +++ b/packages/@glimmer/reference/lib/iterable.ts @@ -19,6 +19,12 @@ export interface IterationItem { export interface AbstractIterator> { isEmpty(): boolean; next(): Nullable; + /** + * SPIKE: allocation-free iteration -- writes into `target` and returns + * it, instead of allocating a fresh item per step. Optional; callers + * must not retain the returned object across steps. + */ + nextInto?(target: V): Nullable; } export type OpaqueIterationItem = IterationItem; @@ -263,4 +269,24 @@ class ArrayIterator implements OpaqueIterator { return { key, value, memo }; } + + nextInto(target: IterationItem): Nullable> { + let value: unknown; + + let current = this.current; + if (current.kind === 'first') { + this.current = { kind: 'progress' }; + value = current.value; + } else if (this.pos >= this.iterator.length - 1) { + return null; + } else { + value = this.iterator[++this.pos]; + } + + target.key = this.keyFor(value, this.pos); + target.value = value; + target.memo = this.pos; + + return target; + } } diff --git a/packages/@glimmer/reference/lib/reference.ts b/packages/@glimmer/reference/lib/reference.ts index c7232d0469a..a235e07de50 100644 --- a/packages/@glimmer/reference/lib/reference.ts +++ b/packages/@glimmer/reference/lib/reference.ts @@ -180,6 +180,11 @@ export function valueForRef(_ref: Reference): T { return lastValue as T; } +/** SPIKE push-invalidation: the tag a ref last computed with, if any. */ +export function tagOfRef(_ref: Reference): Tag | null { + return (_ref as ReferenceImpl).tag; +} + export function updateRef(_ref: Reference, value: unknown) { const ref = _ref as ReferenceImpl; diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index 92981cc0531..ce5a20b7f8d 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -16,14 +16,15 @@ import type { } from '@glimmer/interfaces'; import type { OpaqueIterationItem, OpaqueIterator } from '@glimmer/reference/lib/iterable'; import type { Reference } from '@glimmer/reference/lib/reference'; +import type { Revision, Tag } from '@glimmer/interfaces'; import { expect, unwrap } from '@glimmer/debug-util/lib/platform-utils'; -import { associateDestroyableChild, destroy, destroyChildren } from '@glimmer/destroyable'; +import { associateDestroyableChild, destroy, destroyChildren, isDestroyed, isDestroying, registerDestructor } from '@glimmer/destroyable'; import { LOCAL_DEBUG } from '@glimmer/local-debug-flags'; -import { updateRef, valueForRef } from '@glimmer/reference/lib/reference'; +import { tagOfRef, updateRef, valueForRef } from '@glimmer/reference/lib/reference'; import { logStep } from '@glimmer/util/lib/debug-steps'; -import { StackImpl as Stack } from '@glimmer/util/lib/collections'; import { debug } from '@glimmer/validator/lib/debug'; -import { resetTracking } from '@glimmer/validator/lib/tracking'; +import { beginTrackFrame, consumeTag, endTrackFrame, resetTracking } from '@glimmer/validator/lib/tracking'; +import { CONSTANT_TAG, INITIAL, markUnsubscribedDirt, subscribeToTag, unsubscribeFromTags, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; import type { Closure } from './append'; import type { AppendingBlockList } from './element-builder'; @@ -36,7 +37,15 @@ export class UpdatingVM implements IUpdatingVM { public dom: GlimmerTreeChanges; public alwaysRevalidate: boolean; - private frameStack: Stack = new Stack(); + /** + * SPIKE: a flat frame stack (parallel arrays indexed by depth) + * instead of allocating an UpdatingVMFrame per block per render. + */ + #ops: UpdatingOpcode[][] = []; + #current: number[] = []; + #handlers: Nullable[] = []; + #finalizers: (((didError: boolean) => void) | undefined)[] = []; + #depth = -1; constructor(env: Environment, { alwaysRevalidate = false }) { this.env = env; @@ -69,37 +78,100 @@ export class UpdatingVM implements IUpdatingVM { } private _execute(opcodes: UpdatingOpcode[], handler: ExceptionHandler) { - let { frameStack } = this; - this.try(opcodes, handler); - while (!frameStack.isEmpty()) { - let opcode = this.frame.nextStatement(); + while (this.#depth >= 0) { + let depth = this.#depth; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- depth checked + let ops = this.#ops[depth]!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- depth checked + let index = this.#current[depth]!; - if (opcode === undefined) { - frameStack.pop(); + if (index >= ops.length) { + this.#pop(false); continue; } - opcode.evaluate(this); + this.#current[depth] = index + 1; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked + ops[index]!.evaluate(this); } } - private get frame() { - return expect(this.frameStack.current, 'bug: expected a frame'); + #pop(didError: boolean) { + let depth = this.#depth; + let finalizer = this.#finalizers[depth]; + + // release references so retained arrays don't leak between renders + this.#ops[depth] = EMPTY_OPS; + this.#handlers[depth] = null; + this.#finalizers[depth] = undefined; + this.#depth = depth - 1; + + finalizer?.(didError); } goto(index: number) { - this.frame.goto(index); + this.#current[this.#depth] = index; } - try(ops: UpdatingOpcode[], handler: Nullable) { - this.frameStack.push(new UpdatingVMFrame(ops, handler)); + try(ops: UpdatingOpcode[], handler: Nullable, finalizer?: (didError: boolean) => void) { + let depth = ++this.#depth; + + this.#ops[depth] = ops; + this.#current[depth] = 0; + this.#handlers[depth] = handler; + this.#finalizers[depth] = finalizer; } throw() { - this.frame.handleException(); - this.frameStack.pop(); + this.#handlers[this.#depth]?.handleException(); + this.#pop(true); + } +} + +const EMPTY_OPS: UpdatingOpcode[] = []; + +/** + * SPIKE push-invalidation: opcodes queued directly by tag dirtying. + * The render flush drains this instead of walking the whole tree, + * falling back to a full walk when unsubscribed dirt was seen. + */ +const queuedBlocks = new Set(); +const queuedItems = new Set(); + +export function hasQueuedInvalidations(): boolean { + return queuedBlocks.size > 0 || queuedItems.size > 0; +} + +const NOOP_HANDLER: ExceptionHandler = { + handleException() {}, +}; + +export function drainInvalidationQueue(env: Environment): void { + // blocks first: membership syncs may destroy queued items + while (queuedBlocks.size > 0 || queuedItems.size > 0) { + if (queuedBlocks.size > 0) { + const blocks = [...queuedBlocks]; + + queuedBlocks.clear(); + + for (const block of blocks) { + if (isDestroyed(block) || isDestroying(block)) continue; + + block.pushEvaluate(env); + } + } else { + const items = [...queuedItems]; + + queuedItems.clear(); + + for (const item of items) { + if (isDestroyed(item) || isDestroying(item)) continue; + + new UpdatingVM(env, {}).execute([item], item); + } + } } } @@ -178,6 +250,19 @@ export class ListItemOpcode extends TryOpcode { public retained = false; public index = -1; + /** + * Everything this item's subtree consumed during its last update, + * combined. When still valid, the whole subtree is skipped -- one tag + * validation instead of walking every opcode in the item. + */ + private subtreeTag: Nullable = null; + private subtreeRevision: Revision = INITIAL; + /** SPIKE push-invalidation */ + private subscribedLeaves: Nullable = null; + private enqueue = () => { + queuedItems.add(this); + }; + constructor( state: Closure, context: EvaluationContext, @@ -187,6 +272,68 @@ export class ListItemOpcode extends TryOpcode { public value: Reference ) { super(state, context, bounds, []); + + registerDestructor(this, () => { + if (this.subscribedLeaves !== null) { + unsubscribeFromTags(this.subscribedLeaves, this.enqueue); + this.subscribedLeaves = null; + } + queuedItems.delete(this); + }); + } + + override evaluate(vm: UpdatingVM) { + // SPIKE push-invalidation: every item collects and subscribes (the + // triviality gate is incompatible with push -- unsubscribed items + // would silently go stale). + let { subtreeTag } = this; + + if ( + subtreeTag !== null && + !vm.alwaysRevalidate && + validateTag(subtreeTag, this.subtreeRevision) + ) { + // push-invalidation owns delivery: do NOT propagate item deps + // upward, or enclosing subscriptions (the root's) would fire on + // every item change and re-walk everything + return; + } + + beginTrackFrame(); + vm.try(this.children, this, (didError) => { + // always balance beginTrackFrame, even when unwinding + let tag = endTrackFrame(); + + if (didError) return; + + this.subtreeTag = tag; + this.subtreeRevision = valueForTag(tag); + + // keep the push-invalidation subscription pointing at the leaves + // this subtree actually read + if (this.subscribedLeaves !== null) { + unsubscribeFromTags(this.subscribedLeaves, this.enqueue); + } + this.subscribedLeaves = subscribeToTag(tag, this.enqueue); + }); + } + + override handleException() { + // children are about to be rebuilt; the collected tag and + // subscriptions no longer describe them + this.subtreeTag = null; + + if (this.subscribedLeaves !== null) { + unsubscribeFromTags(this.subscribedLeaves, this.enqueue); + this.subscribedLeaves = null; + } + + super.handleException(); + } + + /** push bootstrap: items that never collected must be walked once */ + get needsCollection(): boolean { + return this.subtreeTag === null; } shouldRemove(): boolean { @@ -206,6 +353,12 @@ export class ListBlockOpcode extends BlockOpcode { private marker: SimpleComment | null = null; private lastIterator: OpaqueIterator; + /** SPIKE push-invalidation */ + private subscribedLeaves: Nullable = null; + private enqueueBlock = () => { + queuedBlocks.add(this); + }; + declare protected readonly bounds: AppendingBlockList; constructor( @@ -217,6 +370,28 @@ export class ListBlockOpcode extends BlockOpcode { ) { super(state, context, bounds, children); this.lastIterator = valueForRef(iterableRef); + this.resubscribeTo(CONSTANT_TAG); + + registerDestructor(this, () => { + if (this.subscribedLeaves !== null) { + unsubscribeFromTags(this.subscribedLeaves, this.enqueueBlock); + this.subscribedLeaves = null; + } + queuedBlocks.delete(this); + }); + } + + private resubscribeTo(syncTag: Tag) { + if (this.subscribedLeaves !== null) { + unsubscribeFromTags(this.subscribedLeaves, this.enqueueBlock); + } + + let leaves = subscribeToTag(syncTag, this.enqueueBlock); + let refTag = tagOfRef(this.iterableRef); + + if (refTag !== null) subscribeToTag(refTag, this.enqueueBlock, leaves); + + this.subscribedLeaves = leaves; } initializeChild(opcode: ListItemOpcode) { @@ -224,29 +399,174 @@ export class ListBlockOpcode extends BlockOpcode { this.opcodeMap.set(opcode.key, opcode); } + /** + * SPIKE push-invalidation: membership sync only -- children are NOT + * walked; changed items enqueue themselves via their own + * subscriptions and are processed by the drain. Falls back to a full + * walk (via the unsubscribed-dirt flag) when the list transitions + * between empty and non-empty, because the surrounding Enter/Assert + * opcodes this path skips are what rebuild that region. + */ + pushEvaluate(env: Environment) { + let wasEmpty = this.children.length === 0; + + beginTrackFrame(); + + try { + let iterator = valueForRef(this.iterableRef); + + if (this.lastIterator !== iterator) { + if (wasEmpty !== iterator.isEmpty()) { + markUnsubscribedDirt(); + return; + } + + let buffered = this.tryFastSync(iterator); + + if (buffered !== null) { + let { bounds } = this; + let dom = env.getDOM(); + + let marker = (this.marker = dom.createComment('')); + dom.insertAfter( + bounds.parentElement(), + marker, + expect(bounds.lastNode(), "can't insert after an empty bounds") + ); + + this.sync(new PrefixedIterator(buffered, iterator)); + + this.parentElement().removeChild(marker); + this.marker = null; + } + + this.lastIterator = iterator; + } + } finally { + // iteration consumes collection cell tags lazily (e.g. a tracked + // array proxy read during next()), so the subscription must come + // from what the sync actually read, not just the iterable ref + this.resubscribeTo(endTrackFrame()); + this.enqueueUncollectedItems(); + } + } + + private enqueueUncollectedItems() { + for (const item of this.children) { + if (item.needsCollection) queuedItems.add(item); + } + } + override evaluate(vm: UpdatingVM) { + beginTrackFrame(); + + try { + this.evaluateSync(vm); + } finally { + this.resubscribeTo(endTrackFrame()); + this.enqueueUncollectedItems(); + } + + // Run now-updated updating opcodes + super.evaluate(vm); + } + + private evaluateSync(vm: UpdatingVM) { let iterator = valueForRef(this.iterableRef); if (this.lastIterator !== iterator) { - let { bounds } = this; - let { dom } = vm; + // SPIKE: deriving a fresh array from tracked state is the idiomatic + // pattern, so iterator identity changes every render even when the + // list's keys did not. When items match the existing children in + // order and count, just update the item refs -- no diff + // bookkeeping, no marker DOM, no children rebuild. + let buffered = this.tryFastSync(iterator); + + if (buffered !== null) { + let { bounds } = this; + let { dom } = vm; + + let marker = (this.marker = dom.createComment('')); + dom.insertAfter( + bounds.parentElement(), + marker, + expect(bounds.lastNode(), "can't insert after an empty bounds") + ); - let marker = (this.marker = dom.createComment('')); - dom.insertAfter( - bounds.parentElement(), - marker, - expect(bounds.lastNode(), "can't insert after an empty bounds") - ); + this.sync(new PrefixedIterator(buffered, iterator)); - this.sync(iterator); + this.parentElement().removeChild(marker); + this.marker = null; + } - this.parentElement().removeChild(marker); - this.marker = null; this.lastIterator = iterator; } + } - // Run now-updated updating opcodes - super.evaluate(vm); + /** + * Streaming compare of the new iteration against existing children, + * applied as it matches: allocation-free on the happy path (a shared + * scratch item via nextInto). Returns null when everything matched in + * order; otherwise reconstructs the already-applied prefix (reading + * the just-updated refs back) plus the mismatched item, so the full + * sync can replay them. + */ + private tryFastSync(iterator: OpaqueIterator): Nullable { + let { children } = this; + let matched = 0; + + while (true) { + let item = + iterator.nextInto !== undefined ? iterator.nextInto(SCRATCH_ITEM) : iterator.next(); + + if (item === null) { + if (matched === children.length) return null; + + // the list shrank; replay the matched prefix through full sync + return this.reconstructPrefix(matched, null); + } + + let opcode = children[matched]; + + if (opcode === undefined || opcode.key !== item.key) { + return this.reconstructPrefix(matched, { + key: item.key, + value: item.value, + memo: item.memo, + }); + } + + updateRef(opcode.memo, item.memo); + updateRef(opcode.value, item.value); + matched++; + } + } + + /** + * The matched prefix was already applied to the item refs, so its + * items can be reconstructed from the opcodes themselves. + */ + private reconstructPrefix( + matched: number, + mismatch: Nullable + ): OpaqueIterationItem[] { + let { children } = this; + let prefix: OpaqueIterationItem[] = []; + + for (let i = 0; i < matched; i++) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked + let opcode = children[i]!; + + prefix.push({ + key: opcode.key, + value: valueForRef(opcode.value), + memo: valueForRef(opcode.memo), + }); + } + + if (mismatch !== null) prefix.push(mismatch); + + return prefix; } private sync(iterator: OpaqueIterator) { @@ -423,25 +743,29 @@ export class ListBlockOpcode extends BlockOpcode { } } -class UpdatingVMFrame { - private current = 0; +/** Shared scratch for allocation-free fast-path iteration. */ +const SCRATCH_ITEM: OpaqueIterationItem = { key: null, value: null, memo: null }; + +/** Replays already-consumed items before draining the rest. */ +class PrefixedIterator implements OpaqueIterator { + private index = 0; constructor( - private ops: UpdatingOpcode[], - private exceptionHandler: Nullable + private prefix: OpaqueIterationItem[], + private inner: OpaqueIterator ) {} - goto(index: number) { - this.current = index; - } - - nextStatement(): UpdatingOpcode | undefined { - return this.ops[this.current++]; + isEmpty(): boolean { + return this.index >= this.prefix.length && this.inner.isEmpty(); } - handleException() { - if (this.exceptionHandler) { - this.exceptionHandler.handleException(); + next(): Nullable { + if (this.index < this.prefix.length) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- bounds checked + return this.prefix[this.index++]!; } + + return this.inner.next(); } } + diff --git a/packages/@glimmer/validator/lib/validators.ts b/packages/@glimmer/validator/lib/validators.ts index 66c7f1ac1cb..5f4966a4e6c 100644 --- a/packages/@glimmer/validator/lib/validators.ts +++ b/packages/@glimmer/validator/lib/validators.ts @@ -76,6 +76,10 @@ export function validateTag(tag: Tag, snapshot: Revision): boolean { const TYPE: TagTypeSymbol = Symbol('TAG_TYPE') as TagTypeSymbol; +// SPIKE push-invalidation (declared early: the module warm-up below +// dirties tags during evaluation) +let sawUnsubscribedDirt = false; + // this is basically a const export let ALLOW_CYCLES: WeakMap | undefined; @@ -99,8 +103,37 @@ class MonomorphicTagImpl { case 1: return tags[0] as Tag; default: { + // SPIKE: flatten nested combinators (and drop constants) so + // validating a combined tag is one flat loop instead of a + // pointer-chasing tree walk. Capped so pathological frames + // don't build giant arrays. + let flattened: Tag[] = []; + let budget = 64; + + for (const t of tags) { + const impl = t as MonomorphicTagImpl; + + if (impl === CONSTANT_TAG) continue; + + if ( + impl[TYPE] === COMBINATOR_TAG_ID && + Array.isArray(impl.subtag) && + impl.subtag.length <= budget + ) { + for (const sub of impl.subtag) { + if (sub !== CONSTANT_TAG) flattened.push(sub); + } + budget -= impl.subtag.length; + } else { + flattened.push(t); + } + } + + if (flattened.length === 0) return CONSTANT_TAG; + if (flattened.length === 1) return flattened[0] as Tag; + let tag: MonomorphicTagImpl = new MonomorphicTagImpl(COMBINATOR_TAG_ID); - tag.subtag = tags; + tag.subtag = flattened; return tag; } } @@ -110,6 +143,9 @@ class MonomorphicTagImpl { private lastChecked = INITIAL; private lastValue = INITIAL; + /** SPIKE push-invalidation: callbacks to run when this tag dirties. */ + subscribers: Set<() => void> | null = null; + private isUpdating = false; public subtag: Tag | Tag[] | null = null; private subtagBufferCache: Revision | null = null; @@ -223,6 +259,18 @@ class MonomorphicTagImpl { (tag as MonomorphicTagImpl).revision = ++$REVISION; + // SPIKE push-invalidation: notify subscribers right here; dirt on + // an unsubscribed tag means the next flush cannot use the push + // path and must fall back to a full revalidation walk. + let subscribers = (tag as MonomorphicTagImpl).subscribers; + + if (subscribers !== null && subscribers.size > 0) { + for (const callback of subscribers) callback(); + } else { + sawUnsubscribedDirt = true; + (globalThis as any).__dirtHook?.(); + } + scheduleRevalidate(); } } @@ -297,3 +345,61 @@ UPDATE_TAG(tag1, tag3); valueForTag(tag1); DIRTY_TAG(tag3); valueForTag(tag1); + +////////// +// SPIKE push-invalidation + +export function markUnsubscribedDirt(): void { + sawUnsubscribedDirt = true; +} + +export function consumeUnsubscribedDirt(): boolean { + let saw = sawUnsubscribedDirt; + sawUnsubscribedDirt = false; + return saw; +} + +/** + * Attach `callback` to every dirtyable leaf reachable from `tag`. + * Combinators are walked; constants are skipped. Returns the leaves so + * the caller can unsubscribe the same set later (a combined tag is an + * immutable snapshot, so the set is stable). + */ +export function subscribeToTag(tag: Tag, callback: () => void, leaves: Tag[] = []): Tag[] { + const impl = tag as MonomorphicTagImpl; + + if (impl === (CONSTANT_TAG as unknown as MonomorphicTagImpl)) return leaves; + + const type = impl[TYPE]; + + if (type === COMBINATOR_TAG_ID) { + const subtag = impl.subtag; + + if (Array.isArray(subtag)) { + for (const sub of subtag as Tag[]) subscribeToTag(sub, callback, leaves); + } else if (subtag !== null) { + subscribeToTag(subtag, callback, leaves); + } + + return leaves; + } + + if (type === DIRYTABLE_TAG_ID || type === UPDATABLE_TAG_ID) { + (impl.subscribers ??= new Set()).add(callback); + leaves.push(tag); + + // updatable tags can be re-pointed at another tag (UPDATE_TAG); + // walk the current target too so its leaves notify as well + if (type === UPDATABLE_TAG_ID && impl.subtag !== null && !Array.isArray(impl.subtag)) { + subscribeToTag(impl.subtag, callback, leaves); + } + } + + return leaves; +} + +export function unsubscribeFromTags(leaves: Tag[], callback: () => void): void { + for (const leaf of leaves) { + (leaf as MonomorphicTagImpl).subscribers?.delete(callback); + } +}