Skip to content

Commit

Permalink
refactor(core): add support for on timer trigger in @defer blocks
Browse files Browse the repository at this point in the history
This commit adds the logic to support `on timer` triggers in `@defer` blocks in both rendering and prefetching conditions.
  • Loading branch information
AndrewKushnir committed Oct 1, 2023
1 parent c4d77fd commit 84b3cd0
Show file tree
Hide file tree
Showing 2 changed files with 541 additions and 31 deletions.
285 changes: 254 additions & 31 deletions packages/core/src/render3/instructions/defer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {InjectionToken, Injector, ɵɵdefineInjectable} from '../../di';
import {inject} from '../../di/injector_compatibility';
import {findMatchingDehydratedView} from '../../hydration/views';
import {populateDehydratedViewsInLContainer} from '../../linker/view_container_ref';
import {arrayInsert2, arraySplice} from '../../util/array_utils';
import {assertDefined, assertElement, assertEqual, throwError} from '../../util/assert';
import {afterRender} from '../after_render_hooks';
import {assertIndexInDeclRange, assertLContainer, assertLView, assertTNodeForLView} from '../assert';
Expand Down Expand Up @@ -157,40 +158,15 @@ export function ɵɵdeferPrefetchWhen(rawValue: unknown) {
* @codeGenApi
*/
export function ɵɵdeferOnIdle() {
const lView = getLView();
const tNode = getCurrentTNode()!;

renderPlaceholder(lView, tNode);
onIdle(() => triggerDeferBlock(lView, tNode), lView, true /* withLViewCleanup */);
scheduleDelayedTrigger(onIdle);
}

/**
* Sets up logic to handle the `prefetch on idle` deferred trigger.
* @codeGenApi
*/
export function ɵɵdeferPrefetchOnIdle() {
const lView = getLView();
const tNode = getCurrentTNode()!;
const tView = lView[TVIEW];
const tDetails = getTDeferBlockDetails(tView, tNode);

if (tDetails.loadingState === DeferDependenciesLoadingState.NOT_STARTED) {
// Prevent scheduling more than one `requestIdleCallback` call
// for each defer block. For this reason we use only a trigger
// identifier in a key, so all instances would use the same key.
const key = String(DeferBlockTriggers.OnIdle);
const injector = lView[INJECTOR]!;
const manager = injector.get(DeferBlockCleanupManager);
if (!manager.has(tDetails, key)) {
// In case of prefetching, we intentionally avoid cancelling resource loading if
// an underlying LView get destroyed (thus passing `null` as a second argument),
// because there might be other LViews (that represent embedded views) that
// depend on resource loading.
const prefetch = () => triggerPrefetching(tDetails, lView);
const cleanupFn = onIdle(prefetch, lView, false /* withLViewCleanup */);
registerTDetailsCleanup(injector, tDetails, key, cleanupFn);
}
}
scheduleDelayedPrefetching(onIdle, DeferBlockTriggers.OnIdle);
}

/**
Expand Down Expand Up @@ -233,14 +209,18 @@ export function ɵɵdeferPrefetchOnImmediate() {
* @param delay Amount of time to wait before loading the content.
* @codeGenApi
*/
export function ɵɵdeferOnTimer(delay: number) {} // TODO: implement runtime logic.
export function ɵɵdeferOnTimer(delay: number) {
scheduleDelayedTrigger(onTimer(delay));
}

/**
* Creates runtime data structures for the `prefetch on timer` deferred trigger.
* @param delay Amount of time to wait before prefetching the content.
* @codeGenApi
*/
export function ɵɵdeferPrefetchOnTimer(delay: number) {} // TODO: implement runtime logic.
export function ɵɵdeferPrefetchOnTimer(delay: number) {
scheduleDelayedPrefetching(onTimer(delay), DeferBlockTriggers.OnTimer);
}

/**
* Creates runtime data structures for the `on hover` deferred trigger.
Expand Down Expand Up @@ -347,6 +327,51 @@ export function ɵɵdeferPrefetchOnViewport(triggerIndex: number, walkUpTimes?:

/********** Helper functions **********/

/**
* Schedules triggering of a defer block for `on idle` and `on timer` conditions.
*/
function scheduleDelayedTrigger(
scheduleFn: (callback: VoidFunction, lView: LView, withLViewCleanup: boolean) => VoidFunction) {
const lView = getLView();
const tNode = getCurrentTNode()!;

renderPlaceholder(lView, tNode);
scheduleFn(() => triggerDeferBlock(lView, tNode), lView, true /* withLViewCleanup */);
}

/**
* Schedules prefetching for `on idle` and `on timer` triggers.
*
* @param scheduleFn A function that does the scheduling.
* @param trigger A trigger that initiated scheduling.
*/
function scheduleDelayedPrefetching(
scheduleFn: (callback: VoidFunction, lView: LView, withLViewCleanup: boolean) => VoidFunction,
trigger: DeferBlockTriggers) {
const lView = getLView();
const tNode = getCurrentTNode()!;
const tView = lView[TVIEW];
const tDetails = getTDeferBlockDetails(tView, tNode);

if (tDetails.loadingState === DeferDependenciesLoadingState.NOT_STARTED) {
// Prevent scheduling more than one prefetch init call
// for each defer block. For this reason we use only a trigger
// identifier in a key, so all instances would use the same key.
const key = String(trigger);
const injector = lView[INJECTOR]!;
const manager = injector.get(DeferBlockCleanupManager);
if (!manager.has(tDetails, key)) {
// In case of prefetching, we intentionally avoid cancelling resource loading if
// an underlying LView get destroyed (thus passing `null` as a second argument),
// because there might be other LViews (that represent embedded views) that
// depend on resource loading.
const prefetch = () => triggerPrefetching(tDetails, lView);
const cleanupFn = scheduleFn(prefetch, lView, false /* withLViewCleanup */);
registerTDetailsCleanup(injector, tDetails, key, cleanupFn);
}
}
}

/**
* Helper function to get the LView in which a deferred block's trigger is rendered.
* @param deferredHostLView LView in which the deferred block is defined.
Expand Down Expand Up @@ -480,6 +505,36 @@ function onIdle(callback: VoidFunction, lView: LView, withLViewCleanup: boolean)
return cleanupFn;
}

/**
* Returns a function that captures a provided delay.
* Invoking the returned function schedules a trigger.
*/
function onTimer(delay: number) {
return (callback: VoidFunction, lView: LView, withLViewCleanup: boolean) =>
scheduleTimerTrigger(delay, callback, lView, withLViewCleanup);
}

/**
* Schedules a callback to be invoked after a given timeout.
*
* @param delay A number of ms to wait until firing a callback.
* @param callback A function to be invoked after a timeout.
* @param lView LView that hosts an instance of a defer block.
* @param withLViewCleanup A flag that indicates whether a scheduled callback
* should be cancelled in case an LView is destroyed before a callback
* was invoked.
*/
function scheduleTimerTrigger(
delay: number, callback: VoidFunction, lView: LView, withLViewCleanup: boolean) {
const injector = lView[INJECTOR]!;
const scheduler = injector.get(TimerScheduler);
const cleanupFn = () => scheduler.remove(callback);
const wrappedCallback =
withLViewCleanup ? wrapWithLViewCleanup(callback, lView, cleanupFn) : callback;
scheduler.add(delay, wrappedCallback);
return cleanupFn;
}

/**
* Wraps a given callback into a logic that registers a cleanup function
* in the LView cleanup slot, to be invoked when an LView is destroyed.
Expand Down Expand Up @@ -708,11 +763,9 @@ export function triggerResourceLoading(tDetails: TDeferBlockDetails, lView: LVie

/** Utility function to render placeholder content (if present) */
function renderPlaceholder(lView: LView, tNode: TNode) {
const tView = lView[TVIEW];
const lContainer = lView[tNode.index];
ngDevMode && assertLContainer(lContainer);

const tDetails = getTDeferBlockDetails(tView, tNode);
renderDeferBlockState(DeferBlockState.Placeholder, tNode, lContainer);
}

Expand Down Expand Up @@ -1056,3 +1109,173 @@ class OnIdleScheduler {
factory: () => new OnIdleScheduler(),
});
}

/**
* Helper service to schedule `setTimeout`s for batches of defer blocks,
* to avoid calling `setTimeout` for each defer block (e.g. if defer blocks
* are created inside a for loop).
*/
class TimerScheduler {
// Indicates whether current callbacks are being invoked.
executingCallbacks = false;

// Currently scheduled `setTimeout` id.
timeoutId: number|null = null;

// When currently scheduled timer would fire.
invokeTimerAt: number|null = null;

// List of callbacks to be invoked.
// For each callback we also store a timestamp on when the callback
// should be invoked. We store timestamps and callback functions
// in a flat array to avoid creating new objects for each entry.
// [timestamp1, callback1, timestamp2, callback2, ...]
current: Array<number|VoidFunction> = [];

// List of callbacks collected while invoking current set of callbacks.
// Those callbacks are added to the "current" queue at the end of
// the current callback invocation. The shape of this list is the same
// as the shape of the `current` list.
deferred: Array<number|VoidFunction> = [];

add(delay: number, callback: VoidFunction) {
const target = this.executingCallbacks ? this.deferred : this.current;
this.addToQueue(target, Date.now() + delay, callback);
this.scheduleTimer();
}

remove(callback: VoidFunction) {
const callbackIndex = this.removeFromQueue(this.current, callback);
if (callbackIndex === -1) {
// Try cleaning up deferred queue only in case
// we didn't find a callback in the "current" queue.
this.removeFromQueue(this.deferred, callback);
}
}

private addToQueue(target: Array<number|VoidFunction>, invokeAt: number, callback: VoidFunction) {
let insertAtIndex = target.length;
for (let i = 0; i < target.length; i += 2) {
const invokeQueuedCallbackAt = target[i] as number;
if (invokeQueuedCallbackAt > invokeAt) {
// We've reached a first timer that is scheduled
// for a later time than what we are trying to insert.
// This is the location at which we need to insert,
// no need to iterate further.
insertAtIndex = i;
break;
}
}
arrayInsert2(target, insertAtIndex, invokeAt, callback);
}

private removeFromQueue(target: Array<number|VoidFunction>, callback: VoidFunction) {
let index = -1;
for (let i = 0; i < target.length; i += 2) {
const queuedCallback = target[i + 1];
if (queuedCallback == callback) {
index = i;
break;
}
}
if (index > -1) {
// Remove 2 elements: a timestamp slot and
// the following slot with a callback function.
arraySplice(target, index, 2);
}
return index;
}

private scheduleTimer() {
const callback = () => {
clearTimeout(this.timeoutId!);
this.timeoutId = null;

this.executingCallbacks = true;

// Invoke callbacks that were scheduled to run
// before the current time.
let now = Date.now();
let lastCallbackIndex: number|null = null;
for (let i = 0; i < this.current.length; i += 2) {
const invokeAt = this.current[i] as number;
const callback = this.current[i + 1] as VoidFunction;
if (invokeAt <= now) {
callback();
// Point at the invoked callback function, which is located
// after the timestamp.
lastCallbackIndex = i + 1;
} else {
// We've reached a timer that should not be invoked yet.
break;
}
}
if (lastCallbackIndex !== null) {
// If last callback index is `null` - no callbacks were invoked,
// so no cleanup is needed. Otherwise, remove invoked callbacks
// from the queue.
arraySplice(this.current, 0, lastCallbackIndex + 1);
}

this.executingCallbacks = false;

// If there are any callbacks added during an invocation
// of the current ones - move them over to the "current"
// queue.
if (this.deferred.length > 0) {
for (let i = 0; i < this.deferred.length; i += 2) {
const invokeAt = this.deferred[i] as number;
const callback = this.deferred[i + 1] as VoidFunction;
this.addToQueue(this.current, invokeAt, callback);
}
this.deferred.length = 0;
}
this.scheduleTimer();
};

// Avoid running timer callbacks more than once per
// average frame duration. This is needed for better
// batching and to avoid kicking off excessive change
// detection cycles.
const FRAME_DURATION_MS = 16; // 1000ms / 60fps

if (this.current.length > 0) {
const now = Date.now();
// First element in the queue points at the timestamp
// of the first (earliest) event.
const invokeAt = this.current[0] as number;
if (!this.timeoutId ||
// Reschedule a timer in case a queue contains an item with
// an earlier timestamp and the delta is more than an average
// frame duration.
(this.invokeTimerAt && (this.invokeTimerAt - invokeAt > FRAME_DURATION_MS))) {
if (this.timeoutId !== null) {
// There was a timeout already, but an earlier event was added
// into the queue. In this case we drop an old timer and setup
// a new one with an updated (smaller) timeout.
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
const timeout = Math.max(invokeAt - now, FRAME_DURATION_MS);
this.invokeTimerAt = invokeAt;
this.timeoutId = setTimeout(callback, timeout) as unknown as number;
}
}
}

ngOnDestroy() {
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
this.current.length = 0;
this.deferred.length = 0;
}

/** @nocollapse */
static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({
token: TimerScheduler,
providedIn: 'root',
factory: () => new TimerScheduler(),
});
}
Loading

0 comments on commit 84b3cd0

Please sign in to comment.