Skip to content
Closed
108 changes: 104 additions & 4 deletions packages/@ember/-internals/glimmer/lib/base-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@
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';
Expand Down Expand Up @@ -134,6 +137,13 @@
}
}

/** SPIKE push-invalidation: roots whose own deps changed. */
const queuedRoots = new Set<RendererRoot>();
const rootSubscriptions = new WeakMap<
RendererRoot,
{ leaves: Tag[]; enqueue: () => void }
>();

const renderers: BaseRenderer[] = [];

export function _resetRenderers() {
Expand Down Expand Up @@ -197,10 +207,28 @@
}
}

/**
* 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
Expand Down Expand Up @@ -348,7 +376,7 @@
continue;
}

root.render();
this.#renderRootSubscribed(root);
}

this.#lastRevision = valueForTag(CURRENT_TAG);
Expand All @@ -368,8 +396,25 @@
}
}

#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 {
Expand All @@ -378,11 +423,66 @@
);
}

/**
* 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 {

Check failure on line 433 in packages/@ember/-internals/glimmer/lib/base-renderer.ts

View workflow job for this annotation

GitHub Actions / tests / Type Checking (current version)

'renderer' is declared but its value is never read.

Check failure on line 433 in packages/@ember/-internals/glimmer/lib/base-renderer.ts

View workflow job for this annotation

GitHub Actions / tests / Linting

'renderer' is defined but never used. Allowed unused args must match /^_/u
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 {
Expand Down
25 changes: 7 additions & 18 deletions packages/@ember/-internals/metal/lib/property_get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
*/
import type ProxyMixin from '@ember/-internals/runtime/lib/mixins/-proxy';
import { setProxy } from '@ember/-internals/utils/lib/is_proxy';
import { isEmberArray } from '@ember/array/-internals';

Check failure on line 6 in packages/@ember/-internals/metal/lib/property_get.ts

View workflow job for this annotation

GitHub Actions / tests / Linting

'isEmberArray' is defined but never used. Allowed unused vars must match /^_/u
import { assert } from '@ember/debug';
import { DEBUG } from '@glimmer/env';
import { consumeTag, isTracking, track } from '@glimmer/validator/lib/tracking';

Check failure on line 9 in packages/@ember/-internals/metal/lib/property_get.ts

View workflow job for this annotation

GitHub Actions / tests / Linting

'isTracking' is defined but never used. Allowed unused vars must match /^_/u

Check failure on line 9 in packages/@ember/-internals/metal/lib/property_get.ts

View workflow job for this annotation

GitHub Actions / tests / Linting

'consumeTag' is defined but never used. Allowed unused vars must match /^_/u
import { tagFor } from '@glimmer/validator/lib/meta';

Check failure on line 10 in packages/@ember/-internals/metal/lib/property_get.ts

View workflow job for this annotation

GitHub Actions / tests / Linting

'tagFor' is defined but never used. Allowed unused vars must match /^_/u
import { isPath } from './path_cache';

export const PROXY_CONTENT = Symbol('PROXY_CONTENT');
Expand Down Expand Up @@ -111,24 +111,13 @@
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];
Expand Down
26 changes: 26 additions & 0 deletions packages/@glimmer/reference/lib/iterable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ export interface IterationItem<T, U> {
export interface AbstractIterator<T, U, V extends IterationItem<T, U>> {
isEmpty(): boolean;
next(): Nullable<V>;
/**
* 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<V>;
}

export type OpaqueIterationItem = IterationItem<unknown, unknown>;
Expand Down Expand Up @@ -263,4 +269,24 @@ class ArrayIterator implements OpaqueIterator {

return { key, value, memo };
}

nextInto(target: IterationItem<unknown, number>): Nullable<IterationItem<unknown, number>> {
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;
}
}
5 changes: 5 additions & 0 deletions packages/@glimmer/reference/lib/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ export function valueForRef<T>(_ref: Reference<T>): 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;

Expand Down
Loading
Loading