From 659072f8c4c9f25902bbe7baca87dcb476bbbb56 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Tue, 10 Oct 2023 16:09:21 -0700 Subject: [PATCH] refactor(core): better organization of `@defer` runtime code This commit splits the `render3/instructions/defer.ts` file (that contained most of the runtime code) into smalle r files that are easier to maintain. There are no functional changes in this PR, just organizing code. --- packages/core/src/core_private_export.ts | 5 +- packages/core/src/defer/cleanup.ts | 77 ++ packages/core/src/defer/discovery.ts | 60 ++ .../defer_events.ts => defer/dom_triggers.ts} | 128 ++- packages/core/src/defer/idle_scheduler.ts | 128 +++ .../defer.ts => defer/instructions.ts} | 786 +----------------- .../defer.ts => defer/interfaces.ts} | 22 +- packages/core/src/defer/timer_scheduler.ts | 213 +++++ packages/core/src/defer/utils.ts | 159 ++++ packages/core/src/render3/index.ts | 9 +- packages/core/src/render3/instructions/all.ts | 2 +- packages/core/src/render3/interfaces/view.ts | 2 +- 12 files changed, 836 insertions(+), 755 deletions(-) create mode 100644 packages/core/src/defer/cleanup.ts create mode 100644 packages/core/src/defer/discovery.ts rename packages/core/src/{render3/instructions/defer_events.ts => defer/dom_triggers.ts} (56%) create mode 100644 packages/core/src/defer/idle_scheduler.ts rename packages/core/src/{render3/instructions/defer.ts => defer/instructions.ts} (50%) rename packages/core/src/{render3/interfaces/defer.ts => defer/interfaces.ts} (87%) create mode 100644 packages/core/src/defer/timer_scheduler.ts create mode 100644 packages/core/src/defer/utils.ts diff --git a/packages/core/src/core_private_export.ts b/packages/core/src/core_private_export.ts index 8da31672da77d9..74a91175ad85c5 100644 --- a/packages/core/src/core_private_export.ts +++ b/packages/core/src/core_private_export.ts @@ -11,6 +11,9 @@ export {ALLOW_MULTIPLE_PLATFORMS as ɵALLOW_MULTIPLE_PLATFORMS, internalCreateAp export {ENABLED_SSR_FEATURES as ɵENABLED_SSR_FEATURES, IMAGE_CONFIG as ɵIMAGE_CONFIG, IMAGE_CONFIG_DEFAULTS as ɵIMAGE_CONFIG_DEFAULTS, ImageConfig as ɵImageConfig} from './application_tokens'; export {defaultIterableDiffers as ɵdefaultIterableDiffers, defaultKeyValueDiffers as ɵdefaultKeyValueDiffers} from './change_detection/change_detection'; export {Console as ɵConsole} from './console'; +export {DeferBlockDetails as ɵDeferBlockDetails, getDeferBlocks as ɵgetDeferBlocks} from './defer/discovery'; +export {renderDeferBlockState as ɵrenderDeferBlockState, triggerResourceLoading as ɵtriggerResourceLoading} from './defer/instructions'; +export {DeferBlockBehavior as ɵDeferBlockBehavior, DeferBlockConfig as ɵDeferBlockConfig, DeferBlockState as ɵDeferBlockState} from './defer/interfaces'; export {convertToBitFlags as ɵconvertToBitFlags, setCurrentInjector as ɵsetCurrentInjector} from './di/injector_compatibility'; export {getInjectableDef as ɵgetInjectableDef, ɵɵInjectableDeclaration, ɵɵInjectorDef} from './di/interface/defs'; export {InternalEnvironmentProviders as ɵInternalEnvironmentProviders, isEnvironmentProviders as ɵisEnvironmentProviders} from './di/interface/provider'; @@ -29,8 +32,6 @@ export {ComponentFactory as ɵComponentFactory} from './linker/component_factory export {clearResolutionOfComponentResourcesQueue as ɵclearResolutionOfComponentResourcesQueue, isComponentDefPendingResolution as ɵisComponentDefPendingResolution, resolveComponentResources as ɵresolveComponentResources, restoreComponentResolutionQueue as ɵrestoreComponentResolutionQueue} from './metadata/resource_loading'; export {ReflectionCapabilities as ɵReflectionCapabilities} from './reflection/reflection_capabilities'; export {InjectorProfilerContext as ɵInjectorProfilerContext, setInjectorProfilerContext as ɵsetInjectorProfilerContext} from './render3/debug/injector_profiler'; -export {DeferBlockDetails as ɵDeferBlockDetails, getDeferBlocks as ɵgetDeferBlocks, renderDeferBlockState as ɵrenderDeferBlockState, triggerResourceLoading as ɵtriggerResourceLoading} from './render3/instructions/defer'; -export {DeferBlockBehavior as ɵDeferBlockBehavior, DeferBlockConfig as ɵDeferBlockConfig, DeferBlockState as ɵDeferBlockState} from './render3/interfaces/defer'; export {allowSanitizationBypassAndThrow as ɵallowSanitizationBypassAndThrow, BypassType as ɵBypassType, getSanitizationBypassType as ɵgetSanitizationBypassType, SafeHtml as ɵSafeHtml, SafeResourceUrl as ɵSafeResourceUrl, SafeScript as ɵSafeScript, SafeStyle as ɵSafeStyle, SafeUrl as ɵSafeUrl, SafeValue as ɵSafeValue, unwrapSafeValue as ɵunwrapSafeValue} from './sanitization/bypass'; export {_sanitizeHtml as ɵ_sanitizeHtml} from './sanitization/html_sanitizer'; export {_sanitizeUrl as ɵ_sanitizeUrl} from './sanitization/url_sanitizer'; diff --git a/packages/core/src/defer/cleanup.ts b/packages/core/src/defer/cleanup.ts new file mode 100644 index 00000000000000..2f014e4bbd18d3 --- /dev/null +++ b/packages/core/src/defer/cleanup.ts @@ -0,0 +1,77 @@ +/*! + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Injector, ɵɵdefineInjectable} from '../di'; + +import {TDeferBlockDetails} from './interfaces'; + +/** + * Registers a cleanup function associated with a prefetching trigger + * of a given defer block. + */ +export function registerTDetailsCleanup( + injector: Injector, tDetails: TDeferBlockDetails, key: string, cleanupFn: VoidFunction) { + injector.get(DeferBlockCleanupManager).add(tDetails, key, cleanupFn); +} +/** + * Invokes all registered prefetch cleanup triggers + * and removes all cleanup functions afterwards. + */ +export function invokeTDetailsCleanup(injector: Injector, tDetails: TDeferBlockDetails) { + injector.get(DeferBlockCleanupManager).cleanup(tDetails); +} +/** + * Internal service to keep track of cleanup functions associated + * with defer blocks. This class is used to manage cleanup functions + * created for prefetching triggers. + */ +export class DeferBlockCleanupManager { + private blocks = new Map>(); + + add(tDetails: TDeferBlockDetails, key: string, callback: VoidFunction) { + if (!this.blocks.has(tDetails)) { + this.blocks.set(tDetails, new Map()); + } + const block = this.blocks.get(tDetails)!; + if (!block.has(key)) { + block.set(key, []); + } + const callbacks = block.get(key)!; + callbacks.push(callback); + } + + has(tDetails: TDeferBlockDetails, key: string): boolean { + return !!this.blocks.get(tDetails)?.has(key); + } + + cleanup(tDetails: TDeferBlockDetails) { + const block = this.blocks.get(tDetails); + if (block) { + for (const callbacks of Object.values(block)) { + for (const callback of callbacks) { + callback(); + } + } + this.blocks.delete(tDetails); + } + } + + ngOnDestroy() { + for (const [block] of this.blocks) { + this.cleanup(block); + } + this.blocks.clear(); + } + + /** @nocollapse */ + static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({ + token: DeferBlockCleanupManager, + providedIn: 'root', + factory: () => new DeferBlockCleanupManager(), + }); +} diff --git a/packages/core/src/defer/discovery.ts b/packages/core/src/defer/discovery.ts new file mode 100644 index 00000000000000..092aa651eb8468 --- /dev/null +++ b/packages/core/src/defer/discovery.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {CONTAINER_HEADER_OFFSET, LContainer} from '../render3/interfaces/container'; +import {TNode} from '../render3/interfaces/node'; +import {isLContainer, isLView} from '../render3/interfaces/type_checks'; +import {HEADER_OFFSET, LView, TVIEW} from '../render3/interfaces/view'; + +import {TDeferBlockDetails} from './interfaces'; +import {getTDeferBlockDetails, isTDeferBlockDetails} from './utils'; + +/** + * Defer block instance for testing. + */ +export interface DeferBlockDetails { + lContainer: LContainer; + lView: LView; + tNode: TNode; + tDetails: TDeferBlockDetails; +} + +/** + * Retrieves all defer blocks in a given LView. + * + * @param lView lView with defer blocks + * @param deferBlocks defer block aggregator array + */ +export function getDeferBlocks(lView: LView, deferBlocks: DeferBlockDetails[]) { + const tView = lView[TVIEW]; + for (let i = HEADER_OFFSET; i < tView.bindingStartIndex; i++) { + if (isLContainer(lView[i])) { + const lContainer = lView[i]; + // An LContainer may represent an instance of a defer block, in which case + // we store it as a result. Otherwise, keep iterating over LContainer views and + // look for defer blocks. + const isLast = i === tView.bindingStartIndex - 1; + if (!isLast) { + const tNode = tView.data[i] as TNode; + const tDetails = getTDeferBlockDetails(tView, tNode); + if (isTDeferBlockDetails(tDetails)) { + deferBlocks.push({lContainer, lView, tNode, tDetails}); + // This LContainer represents a defer block, so we exit + // this iteration and don't inspect views in this LContainer. + continue; + } + } + for (let i = CONTAINER_HEADER_OFFSET; i < lContainer.length; i++) { + getDeferBlocks(lContainer[i] as LView, deferBlocks); + } + } else if (isLView(lView[i])) { + // This is a component, enter the `getDeferBlocks` recursively. + getDeferBlocks(lView[i], deferBlocks); + } + } +} diff --git a/packages/core/src/render3/instructions/defer_events.ts b/packages/core/src/defer/dom_triggers.ts similarity index 56% rename from packages/core/src/render3/instructions/defer_events.ts rename to packages/core/src/defer/dom_triggers.ts index 9e271d9b6d53d6..b76bf7efa9d1b2 100644 --- a/packages/core/src/render3/instructions/defer_events.ts +++ b/packages/core/src/defer/dom_triggers.ts @@ -6,8 +6,18 @@ * found in the LICENSE file at https://angular.io/license */ -import {inject, Injector, ɵɵdefineInjectable} from '../../di'; -import {NgZone} from '../../zone'; +import {inject, Injector, ɵɵdefineInjectable} from '../di'; +import {afterRender} from '../render3/after_render_hooks'; +import {assertLContainer, assertLView} from '../render3/assert'; +import {CONTAINER_HEADER_OFFSET} from '../render3/interfaces/container'; +import {TNode} from '../render3/interfaces/node'; +import {FLAGS, HEADER_OFFSET, INJECTOR, LView, LViewFlags} from '../render3/interfaces/view'; +import {getNativeByIndex, removeLViewOnDestroy, storeLViewOnDestroy, walkUpViews} from '../render3/util/view_utils'; +import {assertElement, assertEqual} from '../util/assert'; +import {NgZone} from '../zone'; + +import {DEFER_BLOCK_STATE, DeferBlockInternalState, DeferBlockState} from './interfaces'; +import {getLDeferBlockDetails} from './utils'; /** Configuration object used to register passive and capturing events. */ const eventListenerOptions: AddEventListenerOptions = { @@ -205,3 +215,117 @@ class DeferIntersectionManager { } } } + +/** + * 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. + * @param deferredTNode TNode defining the deferred block. + * @param walkUpTimes Number of times to go up in the view hierarchy to find the trigger's view. + * A negative value means that the trigger is inside the block's placeholder, while an undefined + * value means that the trigger is in the same LView as the deferred block. + */ +export function getTriggerLView( + deferredHostLView: LView, deferredTNode: TNode, walkUpTimes: number|undefined): LView|null { + // The trigger is in the same view, we don't need to traverse. + if (walkUpTimes == null) { + return deferredHostLView; + } + + // A positive value or zero means that the trigger is in a parent view. + if (walkUpTimes >= 0) { + return walkUpViews(walkUpTimes, deferredHostLView); + } + + // If the value is negative, it means that the trigger is inside the placeholder. + const deferredContainer = deferredHostLView[deferredTNode.index]; + ngDevMode && assertLContainer(deferredContainer); + const triggerLView = deferredContainer[CONTAINER_HEADER_OFFSET] ?? null; + + // We need to null check, because the placeholder might not have been rendered yet. + if (ngDevMode && triggerLView !== null) { + const lDetails = getLDeferBlockDetails(deferredHostLView, deferredTNode); + const renderedState = lDetails[DEFER_BLOCK_STATE]; + assertEqual( + renderedState, DeferBlockState.Placeholder, + 'Expected a placeholder to be rendered in this defer block.'); + assertLView(triggerLView); + } + + return triggerLView; +} + +/** + * Gets the element that a deferred block's trigger is pointing to. + * @param triggerLView LView in which the trigger is defined. + * @param triggerIndex Index at which the trigger element should've been rendered. + */ +export function getTriggerElement(triggerLView: LView, triggerIndex: number): Element { + const element = getNativeByIndex(HEADER_OFFSET + triggerIndex, triggerLView); + ngDevMode && assertElement(element); + return element as Element; +} + +/** + * Registers a DOM-node based trigger. + * @param initialLView LView in which the defer block is rendered. + * @param tNode TNode representing the defer block. + * @param triggerIndex Index at which to find the trigger element. + * @param walkUpTimes Number of times to go up/down in the view hierarchy to find the trigger. + * @param registerFn Function that will register the DOM events. + * @param callback Callback to be invoked when the trigger receives the event that should render + * the deferred block. + */ +export function registerDomTrigger( + initialLView: LView, tNode: TNode, triggerIndex: number, walkUpTimes: number|undefined, + registerFn: (element: Element, callback: VoidFunction, injector: Injector) => VoidFunction, + callback: VoidFunction) { + const injector = initialLView[INJECTOR]!; + + // Assumption: the `afterRender` reference should be destroyed + // automatically so we don't need to keep track of it. + const afterRenderRef = afterRender(() => { + const lDetails = getLDeferBlockDetails(initialLView, tNode); + const renderedState = lDetails[DEFER_BLOCK_STATE]; + + // If the block was loaded before the trigger was resolved, we don't need to do anything. + if (renderedState !== DeferBlockInternalState.Initial && + renderedState !== DeferBlockState.Placeholder) { + afterRenderRef.destroy(); + return; + } + + const triggerLView = getTriggerLView(initialLView, tNode, walkUpTimes); + + // Keep polling until we resolve the trigger's LView. + // `afterRender` should stop automatically if the view is destroyed. + if (!triggerLView) { + return; + } + + // It's possible that the trigger's view was destroyed before we resolved the trigger element. + if (triggerLView[FLAGS] & LViewFlags.Destroyed) { + afterRenderRef.destroy(); + return; + } + + // TODO: add integration with `DeferBlockCleanupManager`. + const element = getTriggerElement(triggerLView, triggerIndex); + const cleanup = registerFn(element, () => { + callback(); + removeLViewOnDestroy(triggerLView, cleanup); + if (initialLView !== triggerLView) { + removeLViewOnDestroy(initialLView, cleanup); + } + cleanup(); + }, injector); + + afterRenderRef.destroy(); + storeLViewOnDestroy(triggerLView, cleanup); + + // Since the trigger and deferred block might be in different + // views, we have to register the callback in both locations. + if (initialLView !== triggerLView) { + storeLViewOnDestroy(initialLView, cleanup); + } + }, {injector}); +} diff --git a/packages/core/src/defer/idle_scheduler.ts b/packages/core/src/defer/idle_scheduler.ts new file mode 100644 index 00000000000000..714187ec980d2b --- /dev/null +++ b/packages/core/src/defer/idle_scheduler.ts @@ -0,0 +1,128 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {inject, ɵɵdefineInjectable} from '../di'; +import {INJECTOR, LView} from '../render3/interfaces/view'; +import {NgZone} from '../zone'; + +import {wrapWithLViewCleanup} from './utils'; + +/** + * Helper function to schedule a callback to be invoked when a browser becomes idle. + * + * @param callback A function to be invoked when a browser becomes idle. + * @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. + */ +export function onIdle(callback: VoidFunction, lView: LView, withLViewCleanup: boolean) { + const injector = lView[INJECTOR]!; + const scheduler = injector.get(IdleScheduler); + const cleanupFn = () => scheduler.remove(callback); + const wrappedCallback = + withLViewCleanup ? wrapWithLViewCleanup(callback, lView, cleanupFn) : callback; + scheduler.add(wrappedCallback); + return cleanupFn; +} + +/** + * Use shims for the `requestIdleCallback` and `cancelIdleCallback` functions for + * environments where those functions are not available (e.g. Node.js and Safari). + * + * Note: we wrap the `requestIdleCallback` call into a function, so that it can be + * overridden/mocked in test environment and picked up by the runtime code. + */ +const _requestIdleCallback = () => + typeof requestIdleCallback !== 'undefined' ? requestIdleCallback : setTimeout; +const _cancelIdleCallback = () => + typeof requestIdleCallback !== 'undefined' ? cancelIdleCallback : clearTimeout; + +/** + * Helper service to schedule `requestIdleCallback`s for batches of defer blocks, + * to avoid calling `requestIdleCallback` for each defer block (e.g. if + * defer blocks are defined inside a for loop). + */ +export class IdleScheduler { + // Indicates whether current callbacks are being invoked. + executingCallbacks = false; + + // Currently scheduled idle callback id. + idleId: number|null = null; + + // Set of callbacks to be invoked next. + current = new Set(); + + // Set of callbacks collected while invoking current set of callbacks. + // Those callbacks are scheduled for the next idle period. + deferred = new Set(); + + ngZone = inject(NgZone); + + requestIdleCallback = _requestIdleCallback().bind(globalThis); + cancelIdleCallback = _cancelIdleCallback().bind(globalThis); + + add(callback: VoidFunction) { + const target = this.executingCallbacks ? this.deferred : this.current; + target.add(callback); + if (this.idleId === null) { + this.scheduleIdleCallback(); + } + } + + remove(callback: VoidFunction) { + this.current.delete(callback); + this.deferred.delete(callback); + } + + private scheduleIdleCallback() { + const callback = () => { + this.cancelIdleCallback(this.idleId!); + this.idleId = null; + + this.executingCallbacks = true; + + for (const callback of this.current) { + callback(); + } + this.current.clear(); + + this.executingCallbacks = false; + + // If there are any callbacks added during an invocation + // of the current ones - make them "current" and schedule + // a new idle callback. + if (this.deferred.size > 0) { + for (const callback of this.deferred) { + this.current.add(callback); + } + this.deferred.clear(); + this.scheduleIdleCallback(); + } + }; + // Ensure that the callback runs in the NgZone since + // the `requestIdleCallback` is not currently patched by Zone.js. + this.idleId = this.requestIdleCallback(() => this.ngZone.run(callback)) as number; + } + + ngOnDestroy() { + if (this.idleId !== null) { + this.cancelIdleCallback(this.idleId); + this.idleId = null; + } + this.current.clear(); + this.deferred.clear(); + } + + /** @nocollapse */ + static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({ + token: IdleScheduler, + providedIn: 'root', + factory: () => new IdleScheduler(), + }); +} diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/defer/instructions.ts similarity index 50% rename from packages/core/src/render3/instructions/defer.ts rename to packages/core/src/defer/instructions.ts index e7750e4c857056..f00599c32404f1 100644 --- a/packages/core/src/render3/instructions/defer.ts +++ b/packages/core/src/defer/instructions.ts @@ -6,30 +6,47 @@ * found in the LICENSE file at https://angular.io/license */ -import {inject, InjectionToken, Injector, ɵɵdefineInjectable} from '../../di'; -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 {NgZone} from '../../zone'; -import {afterRender} from '../after_render_hooks'; -import {assertIndexInDeclRange, assertLContainer, assertLView, assertTNodeForLView} from '../assert'; -import {bindingUpdated} from '../bindings'; -import {getComponentDef, getDirectiveDef, getPipeDef} from '../definition'; -import {CONTAINER_HEADER_OFFSET, LContainer} from '../interfaces/container'; -import {DEFER_BLOCK_STATE, DeferBlockBehavior, DeferBlockConfig, DeferBlockInternalState, DeferBlockState, DeferBlockTriggers, DeferDependenciesLoadingState, DeferredLoadingBlockConfig, DeferredPlaceholderBlockConfig, DependencyResolverFn, LDeferBlockDetails, LOADING_AFTER_CLEANUP_FN, LOADING_AFTER_SLOT, MINIMUM_SLOT, NEXT_DEFER_BLOCK_STATE, STATE_IS_FROZEN_UNTIL, TDeferBlockDetails} from '../interfaces/defer'; -import {DependencyDef, DirectiveDefList, PipeDefList} from '../interfaces/definition'; -import {TContainerNode, TNode} from '../interfaces/node'; -import {isDestroyed, isLContainer, isLView} from '../interfaces/type_checks'; -import {FLAGS, HEADER_OFFSET, INJECTOR, LView, LViewFlags, PARENT, TVIEW, TView} from '../interfaces/view'; -import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from '../state'; -import {isPlatformBrowser} from '../util/misc_utils'; -import {getConstant, getNativeByIndex, getTNode, removeLViewOnDestroy, storeLViewOnDestroy, walkUpViews} from '../util/view_utils'; -import {addLViewToLContainer, createAndRenderEmbeddedLView, removeLViewFromLContainer, shouldAddViewToDom} from '../view_manipulation'; - -import {onHover, onInteraction, onViewport} from './defer_events'; -import {markViewDirty} from './mark_view_dirty'; -import {ɵɵtemplate} from './template'; +import {InjectionToken, Injector} from '../di'; +import {findMatchingDehydratedView} from '../hydration/views'; +import {populateDehydratedViewsInLContainer} from '../linker/view_container_ref'; +import {assertLContainer, assertTNodeForLView} from '../render3/assert'; +import {bindingUpdated} from '../render3/bindings'; +import {getComponentDef, getDirectiveDef, getPipeDef} from '../render3/definition'; +import {markViewDirty} from '../render3/instructions/mark_view_dirty'; +import {ɵɵtemplate} from '../render3/instructions/template'; +import {LContainer} from '../render3/interfaces/container'; +import {DirectiveDefList, PipeDefList} from '../render3/interfaces/definition'; +import {TContainerNode, TNode} from '../render3/interfaces/node'; +import {isDestroyed} from '../render3/interfaces/type_checks'; +import {HEADER_OFFSET, INJECTOR, LView, PARENT, TVIEW, TView} from '../render3/interfaces/view'; +import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from '../render3/state'; +import {isPlatformBrowser} from '../render3/util/misc_utils'; +import {getConstant, getTNode} from '../render3/util/view_utils'; +import {addLViewToLContainer, createAndRenderEmbeddedLView, removeLViewFromLContainer, shouldAddViewToDom} from '../render3/view_manipulation'; +import {assertDefined, throwError} from '../util/assert'; + +import {DeferBlockCleanupManager, invokeTDetailsCleanup, registerTDetailsCleanup} from './cleanup'; +import {onHover, onInteraction, onViewport, registerDomTrigger} from './dom_triggers'; +import {onIdle} from './idle_scheduler'; +import {DEFER_BLOCK_STATE, DeferBlockBehavior, DeferBlockConfig, DeferBlockDependencyInterceptor, DeferBlockInternalState, DeferBlockState, DeferBlockTriggers, DeferDependenciesLoadingState, DeferredLoadingBlockConfig, DeferredPlaceholderBlockConfig, DependencyResolverFn, LDeferBlockDetails, LOADING_AFTER_CLEANUP_FN, NEXT_DEFER_BLOCK_STATE, STATE_IS_FROZEN_UNTIL, TDeferBlockDetails} from './interfaces'; +import {onTimer, scheduleTimerTrigger} from './timer_scheduler'; +import {addDepsToRegistry, assertDeferredDependenciesLoaded, getLDeferBlockDetails, getLoadingBlockAfter, getMinimumDurationForState, getPrimaryBlockTNode, getTDeferBlockDetails, getTemplateIndexForState, setLDeferBlockDetails, setTDeferBlockDetails} from './utils'; + +/** + * **INTERNAL**, avoid referencing it in application code. + * + * Injector token that allows to provide `DeferBlockDependencyInterceptor` class + * implementation. + */ +export const DEFER_BLOCK_DEPENDENCY_INTERCEPTOR = + new InjectionToken( + ngDevMode ? 'DEFER_BLOCK_DEPENDENCY_INTERCEPTOR' : ''); + +/** + * **INTERNAL**, token used for configuring defer block behavior. + */ +export const DEFER_BLOCK_CONFIG = + new InjectionToken(ngDevMode ? 'DEFER_BLOCK_CONFIG' : ''); /** * Returns whether defer blocks should be triggered. @@ -407,265 +424,6 @@ function scheduleDelayedPrefetching( } } -/** - * 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. - * @param deferredTNode TNode defining the deferred block. - * @param walkUpTimes Number of times to go up in the view hierarchy to find the trigger's view. - * A negative value means that the trigger is inside the block's placeholder, while an undefined - * value means that the trigger is in the same LView as the deferred block. - */ -function getTriggerLView( - deferredHostLView: LView, deferredTNode: TNode, walkUpTimes: number|undefined): LView|null { - // The trigger is in the same view, we don't need to traverse. - if (walkUpTimes == null) { - return deferredHostLView; - } - - // A positive value or zero means that the trigger is in a parent view. - if (walkUpTimes >= 0) { - return walkUpViews(walkUpTimes, deferredHostLView); - } - - // If the value is negative, it means that the trigger is inside the placeholder. - const deferredContainer = deferredHostLView[deferredTNode.index]; - ngDevMode && assertLContainer(deferredContainer); - const triggerLView = deferredContainer[CONTAINER_HEADER_OFFSET] ?? null; - - // We need to null check, because the placeholder might not have been rendered yet. - if (ngDevMode && triggerLView !== null) { - const lDetails = getLDeferBlockDetails(deferredHostLView, deferredTNode); - const renderedState = lDetails[DEFER_BLOCK_STATE]; - assertEqual( - renderedState, DeferBlockState.Placeholder, - 'Expected a placeholder to be rendered in this defer block.'); - assertLView(triggerLView); - } - - return triggerLView; -} - -/** - * Gets the element that a deferred block's trigger is pointing to. - * @param triggerLView LView in which the trigger is defined. - * @param triggerIndex Index at which the trigger element should've been rendered. - */ -function getTriggerElement(triggerLView: LView, triggerIndex: number): Element { - const element = getNativeByIndex(HEADER_OFFSET + triggerIndex, triggerLView); - ngDevMode && assertElement(element); - return element as Element; -} - -/** - * Registers a DOM-node based trigger. - * @param initialLView LView in which the defer block is rendered. - * @param tNode TNode representing the defer block. - * @param triggerIndex Index at which to find the trigger element. - * @param walkUpTimes Number of times to go up/down in the view hierarchy to find the trigger. - * @param registerFn Function that will register the DOM events. - * @param callback Callback to be invoked when the trigger receives the event that should render - * the deferred block. - */ -function registerDomTrigger( - initialLView: LView, tNode: TNode, triggerIndex: number, walkUpTimes: number|undefined, - registerFn: (element: Element, callback: VoidFunction, injector: Injector) => VoidFunction, - callback: VoidFunction) { - const injector = initialLView[INJECTOR]!; - - // Assumption: the `afterRender` reference should be destroyed - // automatically so we don't need to keep track of it. - const afterRenderRef = afterRender(() => { - const lDetails = getLDeferBlockDetails(initialLView, tNode); - const renderedState = lDetails[DEFER_BLOCK_STATE]; - - // If the block was loaded before the trigger was resolved, we don't need to do anything. - if (renderedState !== DeferBlockInternalState.Initial && - renderedState !== DeferBlockState.Placeholder) { - afterRenderRef.destroy(); - return; - } - - const triggerLView = getTriggerLView(initialLView, tNode, walkUpTimes); - - // Keep polling until we resolve the trigger's LView. - // `afterRender` should stop automatically if the view is destroyed. - if (!triggerLView) { - return; - } - - // It's possible that the trigger's view was destroyed before we resolved the trigger element. - if (triggerLView[FLAGS] & LViewFlags.Destroyed) { - afterRenderRef.destroy(); - return; - } - - // TODO: add integration with `DeferBlockCleanupManager`. - const element = getTriggerElement(triggerLView, triggerIndex); - const cleanup = registerFn(element, () => { - callback(); - removeLViewOnDestroy(triggerLView, cleanup); - if (initialLView !== triggerLView) { - removeLViewOnDestroy(initialLView, cleanup); - } - cleanup(); - }, injector); - - afterRenderRef.destroy(); - storeLViewOnDestroy(triggerLView, cleanup); - - // Since the trigger and deferred block might be in different - // views, we have to register the callback in both locations. - if (initialLView !== triggerLView) { - storeLViewOnDestroy(initialLView, cleanup); - } - }, {injector}); -} - -/** - * Helper function to schedule a callback to be invoked when a browser becomes idle. - * - * @param callback A function to be invoked when a browser becomes idle. - * @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 onIdle(callback: VoidFunction, lView: LView, withLViewCleanup: boolean) { - const injector = lView[INJECTOR]!; - const scheduler = injector.get(OnIdleScheduler); - const cleanupFn = () => scheduler.remove(callback); - const wrappedCallback = - withLViewCleanup ? wrapWithLViewCleanup(callback, lView, cleanupFn) : callback; - scheduler.add(wrappedCallback); - 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. - */ -function wrapWithLViewCleanup( - callback: VoidFunction, lView: LView, cleanup: VoidFunction): VoidFunction { - const wrappedCallback = () => { - callback(); - removeLViewOnDestroy(lView, cleanup); - }; - storeLViewOnDestroy(lView, cleanup); - return wrappedCallback; -} - -/** - * Calculates a data slot index for defer block info (either static or - * instance-specific), given an index of a defer instruction. - */ -function getDeferBlockDataIndex(deferBlockIndex: number) { - // Instance state is located at the *next* position - // after the defer block slot in an LView or TView.data. - return deferBlockIndex + 1; -} - -/** Retrieves a defer block state from an LView, given a TNode that represents a block. */ -function getLDeferBlockDetails(lView: LView, tNode: TNode): LDeferBlockDetails { - const tView = lView[TVIEW]; - const slotIndex = getDeferBlockDataIndex(tNode.index); - ngDevMode && assertIndexInDeclRange(tView, slotIndex); - return lView[slotIndex]; -} - -/** Stores a defer block instance state in LView. */ -function setLDeferBlockDetails( - lView: LView, deferBlockIndex: number, lDetails: LDeferBlockDetails) { - const tView = lView[TVIEW]; - const slotIndex = getDeferBlockDataIndex(deferBlockIndex); - ngDevMode && assertIndexInDeclRange(tView, slotIndex); - lView[slotIndex] = lDetails; -} - -/** Retrieves static info about a defer block, given a TView and a TNode that represents a block. */ -function getTDeferBlockDetails(tView: TView, tNode: TNode): TDeferBlockDetails { - const slotIndex = getDeferBlockDataIndex(tNode.index); - ngDevMode && assertIndexInDeclRange(tView, slotIndex); - return tView.data[slotIndex] as TDeferBlockDetails; -} - -/** Stores a defer block static info in `TView.data`. */ -function setTDeferBlockDetails( - tView: TView, deferBlockIndex: number, deferBlockConfig: TDeferBlockDetails) { - const slotIndex = getDeferBlockDataIndex(deferBlockIndex); - ngDevMode && assertIndexInDeclRange(tView, slotIndex); - tView.data[slotIndex] = deferBlockConfig; -} - -function getTemplateIndexForState( - newState: DeferBlockState, hostLView: LView, tNode: TNode): number|null { - const tView = hostLView[TVIEW]; - const tDetails = getTDeferBlockDetails(tView, tNode); - - switch (newState) { - case DeferBlockState.Complete: - return tDetails.primaryTmplIndex; - case DeferBlockState.Loading: - return tDetails.loadingTmplIndex; - case DeferBlockState.Error: - return tDetails.errorTmplIndex; - case DeferBlockState.Placeholder: - return tDetails.placeholderTmplIndex; - default: - ngDevMode && throwError(`Unexpected defer block state: ${newState}`); - return null; - } -} - -/** - * Returns a minimum amount of time that a given state should be rendered for, - * taking into account `minimum` parameter value. If the `minimum` value is - * not specified - returns `null`. - */ -function getMinimumDurationForState( - tDetails: TDeferBlockDetails, currentState: DeferBlockState): number|null { - if (currentState === DeferBlockState.Placeholder) { - return tDetails.placeholderBlockConfig?.[MINIMUM_SLOT] ?? null; - } else if (currentState === DeferBlockState.Loading) { - return tDetails.loadingBlockConfig?.[MINIMUM_SLOT] ?? null; - } - return null; -} - -/** Retrieves the value of the `after` parameter on the @loading block. */ -function getLoadingBlockAfter(tDetails: TDeferBlockDetails): number|null { - return tDetails.loadingBlockConfig?.[LOADING_AFTER_SLOT] ?? null; -} - /** * Transitions a defer block to the new state. Updates the necessary * data structures and renders corresponding block. @@ -921,25 +679,6 @@ export function triggerResourceLoading(tDetails: TDeferBlockDetails, lView: LVie }); } -/** - * Adds downloaded dependencies into a directive or a pipe registry, - * making sure that a dependency doesn't yet exist in the registry. - */ -function addDepsToRegistry(currentDeps: T|null, newDeps: T): T { - if (!currentDeps || currentDeps.length === 0) { - return newDeps; - } - - const currentDepSet = new Set(currentDeps); - for (const dep of newDeps) { - currentDepSet.add(dep); - } - - // If `currentDeps` is the same length, there were no new deps and can - // return the original array. - return (currentDeps.length === currentDepSet.size) ? currentDeps : Array.from(currentDepSet) as T; -} - /** Utility function to render placeholder content (if present) */ function renderPlaceholder(lView: LView, tNode: TNode) { const lContainer = lView[tNode.index]; @@ -974,12 +713,6 @@ function renderDeferStateAfterResourceLoading( }); } -/** Retrieves a TNode that represents main content of a defer block. */ -function getPrimaryBlockTNode(tView: TView, tDetails: TDeferBlockDetails): TContainerNode { - const adjustedIndex = tDetails.primaryTmplIndex + HEADER_OFFSET; - return getTNode(tView, adjustedIndex) as TContainerNode; -} - /** * Attempts to trigger loading of defer block dependencies. * If the block is already in a loading, completed or an error state - @@ -1022,440 +755,3 @@ function triggerDeferBlock(lView: LView, tNode: TNode) { } } } - -/** - * Asserts whether all dependencies for a defer block are loaded. - * Always run this function (in dev mode) before rendering a defer - * block in completed state. - */ -function assertDeferredDependenciesLoaded(tDetails: TDeferBlockDetails) { - assertEqual( - tDetails.loadingState, DeferDependenciesLoadingState.COMPLETE, - 'Expecting all deferred dependencies to be loaded.'); -} - -/** - * **INTERNAL**, avoid referencing it in application code. - * - * Describes a helper class that allows to intercept a call to retrieve current - * dependency loading function and replace it with a different implementation. - * This interceptor class is needed to allow testing blocks in different states - * by simulating loading response. - */ -export interface DeferBlockDependencyInterceptor { - /** - * Invoked for each defer block when dependency loading function is accessed. - */ - intercept(dependencyFn: DependencyResolverFn|null): DependencyResolverFn|null; - - /** - * Allows to configure an interceptor function. - */ - setInterceptor(interceptorFn: (current: DependencyResolverFn) => DependencyResolverFn): void; -} - -/** - * **INTERNAL**, avoid referencing it in application code. - * - * Injector token that allows to provide `DeferBlockDependencyInterceptor` class - * implementation. - */ -export const DEFER_BLOCK_DEPENDENCY_INTERCEPTOR = - new InjectionToken( - ngDevMode ? 'DEFER_BLOCK_DEPENDENCY_INTERCEPTOR' : ''); - -/** - * Determines if a given value matches the expected structure of a defer block - * - * We can safely rely on the primaryTmplIndex because every defer block requires - * that a primary template exists. All the other template options are optional. - */ -function isTDeferBlockDetails(value: unknown): value is TDeferBlockDetails { - return (typeof value === 'object') && - (typeof (value as TDeferBlockDetails).primaryTmplIndex === 'number'); -} - -/** - * Internal token used for configuring defer block behavior. - */ -export const DEFER_BLOCK_CONFIG = - new InjectionToken(ngDevMode ? 'DEFER_BLOCK_CONFIG' : ''); - -/** - * Defer block instance for testing. - */ -export interface DeferBlockDetails { - lContainer: LContainer; - lView: LView; - tNode: TNode; - tDetails: TDeferBlockDetails; -} - -/** - * Retrieves all defer blocks in a given LView. - * - * @param lView lView with defer blocks - * @param deferBlocks defer block aggregator array - */ -export function getDeferBlocks(lView: LView, deferBlocks: DeferBlockDetails[]) { - const tView = lView[TVIEW]; - for (let i = HEADER_OFFSET; i < tView.bindingStartIndex; i++) { - if (isLContainer(lView[i])) { - const lContainer = lView[i]; - // An LContainer may represent an instance of a defer block, in which case - // we store it as a result. Otherwise, keep iterating over LContainer views and - // look for defer blocks. - const isLast = i === tView.bindingStartIndex - 1; - if (!isLast) { - const tNode = tView.data[i] as TNode; - const tDetails = getTDeferBlockDetails(tView, tNode); - if (isTDeferBlockDetails(tDetails)) { - deferBlocks.push({lContainer, lView, tNode, tDetails}); - // This LContainer represents a defer block, so we exit - // this iteration and don't inspect views in this LContainer. - continue; - } - } - for (let i = CONTAINER_HEADER_OFFSET; i < lContainer.length; i++) { - getDeferBlocks(lContainer[i] as LView, deferBlocks); - } - } else if (isLView(lView[i])) { - // This is a component, enter the `getDeferBlocks` recursively. - getDeferBlocks(lView[i], deferBlocks); - } - } -} - -/** - * Registers a cleanup function associated with a prefetching trigger - * of a given defer block. - */ -function registerTDetailsCleanup( - injector: Injector, tDetails: TDeferBlockDetails, key: string, cleanupFn: VoidFunction) { - injector.get(DeferBlockCleanupManager).add(tDetails, key, cleanupFn); -} - -/** - * Invokes all registered prefetch cleanup triggers - * and removes all cleanup functions afterwards. - */ -function invokeTDetailsCleanup(injector: Injector, tDetails: TDeferBlockDetails) { - injector.get(DeferBlockCleanupManager).cleanup(tDetails); -} - -/** - * Internal service to keep track of cleanup functions associated - * with defer blocks. This class is used to manage cleanup functions - * created for prefetching triggers. - */ -class DeferBlockCleanupManager { - private blocks = new Map>(); - - add(tDetails: TDeferBlockDetails, key: string, callback: VoidFunction) { - if (!this.blocks.has(tDetails)) { - this.blocks.set(tDetails, new Map()); - } - const block = this.blocks.get(tDetails)!; - if (!block.has(key)) { - block.set(key, []); - } - const callbacks = block.get(key)!; - callbacks.push(callback); - } - - has(tDetails: TDeferBlockDetails, key: string): boolean { - return !!this.blocks.get(tDetails)?.has(key); - } - - cleanup(tDetails: TDeferBlockDetails) { - const block = this.blocks.get(tDetails); - if (block) { - for (const callbacks of Object.values(block)) { - for (const callback of callbacks) { - callback(); - } - } - this.blocks.delete(tDetails); - } - } - - ngOnDestroy() { - for (const [block] of this.blocks) { - this.cleanup(block); - } - this.blocks.clear(); - } - - /** @nocollapse */ - static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({ - token: DeferBlockCleanupManager, - providedIn: 'root', - factory: () => new DeferBlockCleanupManager(), - }); -} - -/** - * Use shims for the `requestIdleCallback` and `cancelIdleCallback` functions for - * environments where those functions are not available (e.g. Node.js and Safari). - * - * Note: we wrap the `requestIdleCallback` call into a function, so that it can be - * overridden/mocked in test environment and picked up by the runtime code. - */ -const _requestIdleCallback = () => - typeof requestIdleCallback !== 'undefined' ? requestIdleCallback : setTimeout; -const _cancelIdleCallback = () => - typeof requestIdleCallback !== 'undefined' ? cancelIdleCallback : clearTimeout; - -/** - * Helper service to schedule `requestIdleCallback`s for batches of defer blocks, - * to avoid calling `requestIdleCallback` for each defer block (e.g. if - * defer blocks are defined inside a for loop). - */ -class OnIdleScheduler { - // Indicates whether current callbacks are being invoked. - executingCallbacks = false; - - // Currently scheduled idle callback id. - idleId: number|null = null; - - // Set of callbacks to be invoked next. - current = new Set(); - - // Set of callbacks collected while invoking current set of callbacks. - // Those callbacks are scheduled for the next idle period. - deferred = new Set(); - - ngZone = inject(NgZone); - - requestIdleCallback = _requestIdleCallback().bind(globalThis); - cancelIdleCallback = _cancelIdleCallback().bind(globalThis); - - add(callback: VoidFunction) { - const target = this.executingCallbacks ? this.deferred : this.current; - target.add(callback); - if (this.idleId === null) { - this.scheduleIdleCallback(); - } - } - - remove(callback: VoidFunction) { - this.current.delete(callback); - this.deferred.delete(callback); - } - - private scheduleIdleCallback() { - const callback = () => { - this.cancelIdleCallback(this.idleId!); - this.idleId = null; - - this.executingCallbacks = true; - - for (const callback of this.current) { - callback(); - } - this.current.clear(); - - this.executingCallbacks = false; - - // If there are any callbacks added during an invocation - // of the current ones - make them "current" and schedule - // a new idle callback. - if (this.deferred.size > 0) { - for (const callback of this.deferred) { - this.current.add(callback); - } - this.deferred.clear(); - this.scheduleIdleCallback(); - } - }; - // Ensure that the callback runs in the NgZone since - // the `requestIdleCallback` is not currently patched by Zone.js. - this.idleId = this.requestIdleCallback(() => this.ngZone.run(callback)) as number; - } - - ngOnDestroy() { - if (this.idleId !== null) { - this.cancelIdleCallback(this.idleId); - this.idleId = null; - } - this.current.clear(); - this.deferred.clear(); - } - - /** @nocollapse */ - static ɵprov = /** @pureOrBreakMyCode */ ɵɵdefineInjectable({ - token: OnIdleScheduler, - providedIn: 'root', - 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 = []; - - // 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 = []; - - 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, 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, 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(), - }); -} diff --git a/packages/core/src/render3/interfaces/defer.ts b/packages/core/src/defer/interfaces.ts similarity index 87% rename from packages/core/src/render3/interfaces/defer.ts rename to packages/core/src/defer/interfaces.ts index e62492c3e2d540..1ad20286ee888c 100644 --- a/packages/core/src/render3/interfaces/defer.ts +++ b/packages/core/src/defer/interfaces.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import type {DependencyType} from './definition'; +import type {DependencyType} from '../render3/interfaces/definition'; /** * Describes the shape of a function generated by the compiler @@ -204,3 +204,23 @@ export enum DeferBlockBehavior { */ Playthrough, } + +/** + * **INTERNAL**, avoid referencing it in application code. + * + * Describes a helper class that allows to intercept a call to retrieve current + * dependency loading function and replace it with a different implementation. + * This interceptor class is needed to allow testing blocks in different states + * by simulating loading response. + */ +export interface DeferBlockDependencyInterceptor { + /** + * Invoked for each defer block when dependency loading function is accessed. + */ + intercept(dependencyFn: DependencyResolverFn|null): DependencyResolverFn|null; + + /** + * Allows to configure an interceptor function. + */ + setInterceptor(interceptorFn: (current: DependencyResolverFn) => DependencyResolverFn): void; +} diff --git a/packages/core/src/defer/timer_scheduler.ts b/packages/core/src/defer/timer_scheduler.ts new file mode 100644 index 00000000000000..67380726a5d00c --- /dev/null +++ b/packages/core/src/defer/timer_scheduler.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ɵɵdefineInjectable} from '../di'; +import {INJECTOR, LView} from '../render3/interfaces/view'; +import {arrayInsert2, arraySplice} from '../util/array_utils'; + +import {wrapWithLViewCleanup} from './utils'; + +/** + * Returns a function that captures a provided delay. + * Invoking the returned function schedules a trigger. + */ +export 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. + */ +export 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; +} + +/** + * 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). + */ +export 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 = []; + + // 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 = []; + + 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, 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, 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(), + }); +} diff --git a/packages/core/src/defer/utils.ts b/packages/core/src/defer/utils.ts new file mode 100644 index 00000000000000..36e0443672bf75 --- /dev/null +++ b/packages/core/src/defer/utils.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {assertIndexInDeclRange} from '../render3/assert'; +import {DependencyDef} from '../render3/interfaces/definition'; +import {TContainerNode, TNode} from '../render3/interfaces/node'; +import {HEADER_OFFSET, LView, TVIEW, TView} from '../render3/interfaces/view'; +import {getTNode, removeLViewOnDestroy, storeLViewOnDestroy} from '../render3/util/view_utils'; +import {assertEqual, throwError} from '../util/assert'; + +import {DeferBlockState, DeferDependenciesLoadingState, LDeferBlockDetails, LOADING_AFTER_SLOT, MINIMUM_SLOT, TDeferBlockDetails} from './interfaces'; + +/** + * 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. + */ +export function wrapWithLViewCleanup( + callback: VoidFunction, lView: LView, cleanup: VoidFunction): VoidFunction { + const wrappedCallback = () => { + callback(); + removeLViewOnDestroy(lView, cleanup); + }; + storeLViewOnDestroy(lView, cleanup); + return wrappedCallback; +} + +/** + * Calculates a data slot index for defer block info (either static or + * instance-specific), given an index of a defer instruction. + */ +export function getDeferBlockDataIndex(deferBlockIndex: number) { + // Instance state is located at the *next* position + // after the defer block slot in an LView or TView.data. + return deferBlockIndex + 1; +} + +/** Retrieves a defer block state from an LView, given a TNode that represents a block. */ +export function getLDeferBlockDetails(lView: LView, tNode: TNode): LDeferBlockDetails { + const tView = lView[TVIEW]; + const slotIndex = getDeferBlockDataIndex(tNode.index); + ngDevMode && assertIndexInDeclRange(tView, slotIndex); + return lView[slotIndex]; +} + +/** Stores a defer block instance state in LView. */ +export function setLDeferBlockDetails( + lView: LView, deferBlockIndex: number, lDetails: LDeferBlockDetails) { + const tView = lView[TVIEW]; + const slotIndex = getDeferBlockDataIndex(deferBlockIndex); + ngDevMode && assertIndexInDeclRange(tView, slotIndex); + lView[slotIndex] = lDetails; +} + +/** Retrieves static info about a defer block, given a TView and a TNode that represents a block. */ +export function getTDeferBlockDetails(tView: TView, tNode: TNode): TDeferBlockDetails { + const slotIndex = getDeferBlockDataIndex(tNode.index); + ngDevMode && assertIndexInDeclRange(tView, slotIndex); + return tView.data[slotIndex] as TDeferBlockDetails; +} + +/** Stores a defer block static info in `TView.data`. */ +export function setTDeferBlockDetails( + tView: TView, deferBlockIndex: number, deferBlockConfig: TDeferBlockDetails) { + const slotIndex = getDeferBlockDataIndex(deferBlockIndex); + ngDevMode && assertIndexInDeclRange(tView, slotIndex); + tView.data[slotIndex] = deferBlockConfig; +} + +export function getTemplateIndexForState( + newState: DeferBlockState, hostLView: LView, tNode: TNode): number|null { + const tView = hostLView[TVIEW]; + const tDetails = getTDeferBlockDetails(tView, tNode); + + switch (newState) { + case DeferBlockState.Complete: + return tDetails.primaryTmplIndex; + case DeferBlockState.Loading: + return tDetails.loadingTmplIndex; + case DeferBlockState.Error: + return tDetails.errorTmplIndex; + case DeferBlockState.Placeholder: + return tDetails.placeholderTmplIndex; + default: + ngDevMode && throwError(`Unexpected defer block state: ${newState}`); + return null; + } +} + +/** + * Returns a minimum amount of time that a given state should be rendered for, + * taking into account `minimum` parameter value. If the `minimum` value is + * not specified - returns `null`. + */ +export function getMinimumDurationForState( + tDetails: TDeferBlockDetails, currentState: DeferBlockState): number|null { + if (currentState === DeferBlockState.Placeholder) { + return tDetails.placeholderBlockConfig?.[MINIMUM_SLOT] ?? null; + } else if (currentState === DeferBlockState.Loading) { + return tDetails.loadingBlockConfig?.[MINIMUM_SLOT] ?? null; + } + return null; +} + +/** Retrieves the value of the `after` parameter on the @loading block. */ +export function getLoadingBlockAfter(tDetails: TDeferBlockDetails): number|null { + return tDetails.loadingBlockConfig?.[LOADING_AFTER_SLOT] ?? null; +} + +/** + * Adds downloaded dependencies into a directive or a pipe registry, + * making sure that a dependency doesn't yet exist in the registry. + */ +export function addDepsToRegistry(currentDeps: T|null, newDeps: T): T { + if (!currentDeps || currentDeps.length === 0) { + return newDeps; + } + + const currentDepSet = new Set(currentDeps); + for (const dep of newDeps) { + currentDepSet.add(dep); + } + + // If `currentDeps` is the same length, there were no new deps and can + // return the original array. + return (currentDeps.length === currentDepSet.size) ? currentDeps : Array.from(currentDepSet) as T; +} + +/** Retrieves a TNode that represents main content of a defer block. */ +export function getPrimaryBlockTNode(tView: TView, tDetails: TDeferBlockDetails): TContainerNode { + const adjustedIndex = tDetails.primaryTmplIndex + HEADER_OFFSET; + return getTNode(tView, adjustedIndex) as TContainerNode; +} + +/** + * Asserts whether all dependencies for a defer block are loaded. + * Always run this function (in dev mode) before rendering a defer + * block in completed state. + */ +export function assertDeferredDependenciesLoaded(tDetails: TDeferBlockDetails) { + assertEqual( + tDetails.loadingState, DeferDependenciesLoadingState.COMPLETE, + 'Expecting all deferred dependencies to be loaded.'); +} + +/** + * Determines if a given value matches the expected structure of a defer block + * + * We can safely rely on the primaryTmplIndex because every defer block requires + * that a primary template exists. All the other template options are optional. + */ +export function isTDeferBlockDetails(value: unknown): value is TDeferBlockDetails { + return (typeof value === 'object') && + (typeof (value as TDeferBlockDetails).primaryTmplIndex === 'number'); +} diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index a36dcb2612c018..7be5a400c18efc 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -160,11 +160,14 @@ export { ɵsetUnknownElementStrictMode, ɵgetUnknownPropertyStrictMode, ɵsetUnknownPropertyStrictMode, - - DeferBlockDependencyInterceptor as ɵDeferBlockDependencyInterceptor, +} from './instructions/all'; +export { DEFER_BLOCK_DEPENDENCY_INTERCEPTOR as ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, DEFER_BLOCK_CONFIG as ɵDEFER_BLOCK_CONFIG, -} from './instructions/all'; +} from '../defer/instructions'; +export { + DeferBlockDependencyInterceptor as ɵDeferBlockDependencyInterceptor, +} from '../defer/interfaces'; export {ɵɵi18n, ɵɵi18nApply, ɵɵi18nAttributes, ɵɵi18nEnd, ɵɵi18nExp,ɵɵi18nPostprocess, ɵɵi18nStart} from './instructions/i18n'; export {RenderFlags} from './interfaces/definition'; export { diff --git a/packages/core/src/render3/instructions/all.ts b/packages/core/src/render3/instructions/all.ts index 6f1ac7c67ebadd..4a24a997cff6d4 100644 --- a/packages/core/src/render3/instructions/all.ts +++ b/packages/core/src/render3/instructions/all.ts @@ -32,7 +32,7 @@ export * from './change_detection'; export * from './class_map_interpolation'; export * from './component_instance'; export * from './control_flow'; -export * from './defer'; +export * from '../../defer/instructions'; export * from './di'; export * from './di_attr'; export * from './element'; diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index 8067664bfba3b7..69b1de91640c49 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -23,7 +23,7 @@ import {LQueries, TQueries} from './query'; import {Renderer, RendererFactory} from './renderer'; import {RElement} from './renderer_dom'; import {TStylingKey, TStylingRange} from './styling'; -import {TDeferBlockDetails} from './defer'; +import {TDeferBlockDetails} from '../../defer/interfaces';