Skip to content
Closed
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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@
"@ember/routing/router-service.js": "ember-source/@ember/routing/router-service.js",
"@ember/routing/router.js": "ember-source/@ember/routing/router.js",
"@ember/runloop/index.js": "ember-source/@ember/runloop/index.js",
"@ember/scheduler/index.js": "ember-source/@ember/scheduler/index.js",
"@ember/scheduler/strategy.js": "ember-source/@ember/scheduler/strategy.js",
"@ember/service/index.js": "ember-source/@ember/service/index.js",
"@ember/template-compilation/index.js": "ember-source/@ember/template-compilation/index.js",
"@ember/template-compiler/-internal-primitives.js": "ember-source/@ember/template-compiler/-internal-primitives.js",
Expand Down
209 changes: 168 additions & 41 deletions packages/@ember/-internals/glimmer/lib/base-renderer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { ENV } from '@ember/-internals/environment/lib/env';
import type { InternalOwner } from '@ember/-internals/owner';
import { assert } from '@ember/debug';
import { _backburner, _getCurrentRunLoop } from '@ember/runloop';
import {
associateDestroyableChild,
destroy,
Expand All @@ -28,14 +26,16 @@ import { renderComponent as glimmerRenderComponent } from '@glimmer/runtime/lib/
import { CURRENT_TAG, validateTag, valueForTag } from '@glimmer/validator/lib/validators';
import type { SimpleDocument, SimpleElement } from '@simple-dom/interface';
import { hasDOM } from '../../browser-environment';
import { EmberEnvironmentDelegate } from './environment';
import {
EmberEnvironmentDelegate,
_setNotifyRevalidate,
_drainScheduledDestroys,
} from './environment';
import ResolverImpl from './resolver';
import { EvaluationContextImpl } from '@glimmer/opcode-compiler/lib/program-context';

export type IBuilder = (env: Environment, cursor: Cursor) => TreeBuilder;

const NO_OP = () => {};

// This wrapper logic prevents us from rerendering in case of a hard failure
// during render. This prevents infinite revalidation type loops from occuring,
// and ensures that errors are not swallowed by subsequent follow on failures.
Expand Down Expand Up @@ -151,11 +151,15 @@ function deregister(renderer: BaseRenderer): void {
renderers.splice(index, 1);
}

function loopBegin(): void {
// SPIKE (RFC 957 end state): tag invalidation notifies the renderer's
// scheduler directly -- there is no runloop in the render path. This
// replaces the classic wiring where every dirty tag spun up a
// backburner autorun whose `begin` hook rerendered the renderers.
_setNotifyRevalidate(() => {
for (let renderer of renderers) {
renderer.rerender();
}
}
});

interface RenderSettledDeferred {
promise: Promise<void>;
Expand All @@ -166,8 +170,9 @@ let renderSettledDeferred: RenderSettledDeferred | null = null;
/*
Returns a promise which will resolve when rendering has settled. Settled in
this context is defined as when all of the tags in use are "current" (e.g.
`renderers.every(r => r._isValid())`). When this is checked at the _end_ of
the run loop, this essentially guarantees that all rendering is completed.
`renderers.every(r => r._isValid())`). Resolution is attempted at the end
of every scheduler flush; if nothing is dirty when this is called, it
settles on a microtask.

@method renderSettled
@returns {Promise<void>} a promise which fulfills when rendering has settled
Expand All @@ -177,46 +182,23 @@ export function renderSettled() {
let resolve!: () => void;
let promise = new Promise<void>((r) => (resolve = r));
renderSettledDeferred = { promise, resolve };
// if there is no current runloop, the promise created above will not have
// a chance to resolve (because its resolved in backburner's "end" event)
if (!_getCurrentRunLoop()) {
// ensure a runloop has been kicked off
_backburner.schedule('actions', null, NO_OP);
}
queueMicrotask(resolveRenderPromiseIfSettled);
}

return renderSettledDeferred.promise;
}

function resolveRenderPromise() {
if (renderSettledDeferred !== null) {
let resolve = renderSettledDeferred.resolve;
renderSettledDeferred = null;

_backburner.join(null, resolve);
}
}
function resolveRenderPromiseIfSettled() {
if (renderSettledDeferred === null) return;

let loops = 0;
function loopEnd() {
for (let renderer of renderers) {
if (!renderer.isValid()) {
if (loops > ENV._RERENDER_LOOP_LIMIT) {
loops = 0;
// TODO: do something better
renderer.destroy();
throw new Error('infinite rendering invalidation detected');
}
loops++;
return _backburner.join(null, NO_OP);
}
if (!renderer.isValid()) return;
}
loops = 0;
resolveRenderPromise();
}

_backburner.on('begin', loopBegin);
_backburner.on('end', loopEnd);
let resolve = renderSettledDeferred.resolve;
renderSettledDeferred = null;
resolve();
}

type Resolver = ClassicResolver;

Expand Down Expand Up @@ -368,8 +350,153 @@ export class RendererState {
}
}

#flushScheduled = false;
#renderer: BaseRenderer | null = null;
#rafHandle: number | null = null;
#channel: MessageChannel | null = null;
#channelArmed = false;
#flushesThisFrame = 0;
#lastFlushEnd = 0;
#lastFrameAt = 0;
#viaStream = false;

// scheduling must not allocate per dirt event: dependent chains
// (render -> effect -> set) re-enter scheduleRevalidate once per step,
// and per-step closures were measurable GC pressure in exactly that
// case, so every callback here is persistent
#microtaskFlush = () => this.#flush(false);

#frameFlush = () => {
this.#lastFrameAt = performance.now();
this.#flush(true);
};

#revalidateUntilStable = () => {
const renderer = this.#renderer;

if (renderer === null) return;

this.revalidate(renderer);

// dirt produced synchronously by the render itself (e.g. an
// after-render effect advancing a loop) flushes in the same task;
// only dirt arriving between tasks waits
let guard = 0;

while (!this.isValid() && guard++ < 1_000_000) {
this.revalidate(renderer);
}
};

#flush(viaFrame: boolean): void {
if (!this.#flushScheduled) return;

if (viaFrame) {
this.#flushesThisFrame = 0;
this.#rafHandle = null;
} else {
if (this.#viaStream) {
// only stream-scheduled flushes count toward the rAF
// stand-down: chain flushes are same-task and invisible to
// frames, and counting them would starve later stream dirt of
// its macrotask leg
this.#flushesThisFrame++;
}
if (this.#rafHandle !== null) {
cancelAnimationFrame(this.#rafHandle);
this.#rafHandle = null;
}
}

this.#viaStream = false;

this.#revalidateUntilStable();

// the flag clears after revalidation so dirt produced mid-flush
// dedupes into the running flush's stability loop, but before the
// destroy drain, whose destructors may legitimately dirty state
// that needs a new flush
this.#flushScheduled = false;

_drainScheduledDestroys();

this.#lastFlushEnd = performance.now();

resolveRenderPromiseIfSettled();
}

/**
* SPIKE: task-coalesced flushing, zoneless-Angular shaped but
* adaptive:
*
* - a flush is scheduled as a race between an UNCLAMPED macrotask
* (MessageChannel -- setTimeout's 4ms nesting clamp would make
* render->microtask->set chains crawl) and requestAnimationFrame
* - every update inside the current task + microtasks coalesces into
* one flush; awaited (microtask) update loops stop paying a render
* per resume
* - adaptive frame alignment: when several flushes land within one
* frame (a sustained external stream like a worker firehose), the
* macrotask leg stands down and flushes ride rAF until the burst
* subsides -- unless rAF itself has stopped being serviced (see
* below)
*/
scheduleRevalidate(renderer: BaseRenderer): void {
_backburner.scheduleOnce('render', this, this.revalidate, renderer);
if (this.#flushScheduled) {
return;
}

this.#renderer = renderer;
this.#flushScheduled = true;

// no paint to schedule against in SSR environments; flush on a
// microtask so rendering completes within the current task's drain
if (typeof requestAnimationFrame !== 'function') {
queueMicrotask(this.#microtaskFlush);
return;
}

const now = performance.now();

// Distinguish dependent chains from external streams: chain dirt
// (render -> microtask -> set) arrives ~immediately after the last
// flush; it flushes at MICROTASK speed (same task turn, like the
// classic runloop) so sequential render-coupled loops don't pay a
// task hop per step. This cannot defeat coalescing of awaited
// update loops: those drain their entire microtask chain before
// their FIRST flush ever runs. Stream dirt (worker messages)
// arrives whole milliseconds later in fresh tasks: it flushes on a
// race of unclamped macrotask + rAF, standing the macrotask leg
// down under sustained bursts so flushes ride the frame.
if (now - this.#lastFlushEnd < 1) {
queueMicrotask(this.#microtaskFlush);
return;
}

this.#viaStream = true;
this.#rafHandle = requestAnimationFrame(this.#frameFlush);

// The stand-down only applies while rAF is actually being serviced:
// backgrounded/occluded pages stop firing rAF entirely, and since
// the per-frame counter is only reset by a frame firing, standing
// the macrotask leg down there would strand all rendering until the
// tab becomes visible again.
const rafStarved = now - this.#lastFrameAt > 250;

if (this.#flushesThisFrame < 3 || rafStarved) {
if (this.#channel === null) {
this.#channel = new MessageChannel();
this.#channel.port1.onmessage = () => {
this.#channelArmed = false;
this.#flush(false);
};
}

if (!this.#channelArmed) {
this.#channelArmed = true;
this.#channel.port2.postMessage(null);
}
}
}

isValid(): boolean {
Expand Down
66 changes: 62 additions & 4 deletions packages/@ember/-internals/glimmer/lib/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import getDebugName from '@ember/-internals/utils/lib/get-debug-name';
import { constructStyleDeprecationMessage } from '@ember/-internals/views/lib/system/utils';
import { assert, deprecate, warn } from '@ember/debug';
import type { DeprecationOptions } from '@ember/debug/lib/deprecate';
import { schedule, _backburner } from '@ember/runloop';
import { DEBUG } from '@glimmer/env';
import setGlobalContext from '@glimmer/global-context';
import type { EnvironmentDelegate } from '@glimmer/runtime/lib/environment';
Expand All @@ -17,11 +16,65 @@ import toBool from './utils/to-bool';

///////////

// SPIKE (RFC 957 end state): tag invalidation and destruction no longer
// flow through the runloop. Invalidation notifies the renderer's
// scheduler directly; destruction work queues here and is drained by
// the scheduler's flush (or a fallback microtask when nothing is
// rendering). The setter indirection exists only to avoid a module
// cycle with the renderer.

let notifyRevalidate: () => void = () => {};

export function _setNotifyRevalidate(fn: () => void): void {
notifyRevalidate = fn;
}

interface ScheduledDestructor {
destroyable: object;
destructor: (destroyable: object) => void;
}

const scheduledDestructors: ScheduledDestructor[] = [];
const scheduledFinalizers: Array<() => void> = [];

let destroyDrainArmed = false;

/**
* Runs pending destructors, then finalizers -- the classic
* actions-before-destroy queue ordering. Destruction can schedule
* further destruction, so drain until quiet.
*/
export function _drainScheduledDestroys(): void {
destroyDrainArmed = false;

while (scheduledDestructors.length > 0 || scheduledFinalizers.length > 0) {
const destructors = scheduledDestructors.splice(0);
for (const { destroyable, destructor } of destructors) {
destructor(destroyable);
}

const finalizers = scheduledFinalizers.splice(0);
for (const finalize of finalizers) {
finalize();
}
}
}

function armDestroyDrain(): void {
if (destroyDrainArmed) return;
destroyDrainArmed = true;
queueMicrotask(() => {
if (destroyDrainArmed) {
_drainScheduledDestroys();
}
});
}

// Setup global context

setGlobalContext({
scheduleRevalidate() {
_backburner.ensureInstance();
notifyRevalidate();
},

toBool,
Expand All @@ -33,11 +86,16 @@ setGlobalContext({
setPath: set,

scheduleDestroy(destroyable, destructor) {
schedule('actions', null, destructor, destroyable);
scheduledDestructors.push({
destroyable,
destructor: destructor as (destroyable: object) => void,
});
armDestroyDrain();
},

scheduleDestroyed(finalizeDestructor) {
schedule('destroy', null, finalizeDestructor);
scheduledFinalizers.push(finalizeDestructor);
armDestroyDrain();
},

warnIfStyleNotTrusted(value: unknown) {
Expand Down
Loading
Loading