From c4deaac5b0816ebee0cb6f92ba02116bec2ca623 Mon Sep 17 00:00:00 2001 From: Andrew Kushnir Date: Wed, 9 Aug 2023 18:40:10 -0700 Subject: [PATCH] refactor(core): initial implementation of `{#defer}` block runtime (#51347) This commit adds an initial implementation of the `{#defer}` block runtime, which supports the `when` conditions. More conditions and basic prefetching support will be added in followup PRs. PR Close #51347 --- .../GOLDEN_PARTIAL.js | 2 + .../basic_deferred.ts | 1 + .../basic_deferred_template.js | 4 +- .../compiler/src/render3/view/template.ts | 4 + .../core/src/core_render3_private_export.ts | 2 + packages/core/src/render3/assert.ts | 3 +- packages/core/src/render3/index.ts | 5 +- .../core/src/render3/instructions/advance.ts | 4 +- .../core/src/render3/instructions/defer.ts | 446 ++++++++++++++-- packages/core/src/render3/interfaces/defer.ts | 129 +++++ .../core/src/render3/interfaces/definition.ts | 4 +- .../src/render3/interfaces/type_checks.ts | 4 + packages/core/src/render3/interfaces/view.ts | 6 +- packages/core/test/acceptance/defer_spec.ts | 486 ++++++++++++++++++ .../router/bundle.golden_symbols.json | 2 +- 15 files changed, 1062 insertions(+), 40 deletions(-) create mode 100644 packages/core/src/render3/interfaces/defer.ts create mode 100644 packages/core/test/acceptance/defer_spec.ts diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/GOLDEN_PARTIAL.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/GOLDEN_PARTIAL.js index e4bcf5223e2..69fbedda22f 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/GOLDEN_PARTIAL.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/GOLDEN_PARTIAL.js @@ -13,6 +13,7 @@ MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-
{{message}} {#defer}Deferred content{/defer} +

Content after defer block

`, isInline: true }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{ @@ -22,6 +23,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
{{message}} {#defer}Deferred content{/defer} +

Content after defer block

`, }] diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/basic_deferred.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/basic_deferred.ts index 316a889e9cf..9063b510adb 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/basic_deferred.ts +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/basic_deferred.ts @@ -5,6 +5,7 @@ import {Component} from '@angular/core';
{{message}} {#defer}Deferred content{/defer} +

Content after defer block

`, }) diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/basic_deferred_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/basic_deferred_template.js index d28a3a68387..00d4b1884a7 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/basic_deferred_template.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/basic_deferred_template.js @@ -13,7 +13,9 @@ MyApp.ɵcmp = /*@__PURE__*/ $r3$.ɵɵdefineComponent({ $r3$.ɵɵtemplate(2, MyApp_Defer_2_Template, 1, 0); $r3$.ɵɵdefer(3, 2); $r3$.ɵɵdeferOnIdle(); - $r3$.ɵɵelementEnd(); + $r3$.ɵɵelementStart(5, "p"); + $r3$.ɵɵtext(6, "Content after defer block"); + $r3$.ɵɵelementEnd()(); } if (rf & 2) { $r3$.ɵɵadvance(1); diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 8c166438173..36a937e929e 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -1300,6 +1300,10 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver this.createDeferTriggerInstructions(deferredIndex, triggers, false); this.createDeferTriggerInstructions(deferredIndex, prefetchTriggers, true); + + // Allocate an extra data slot right after a defer block slot to store + // instance-specific state of that defer block at runtime. + this.allocateDataSlot(); } private createDeferredDepsFunction(name: string, deferred: t.DeferredBlock) { diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index 60fee54593f..e437c48bfb1 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -68,6 +68,8 @@ export { setClassMetadataAsync as ɵsetClassMetadataAsync, setLocaleId as ɵsetLocaleId, store as ɵstore, + ɵDeferBlockDependencyInterceptor, + ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, ɵɵadvance, ɵɵattribute, ɵɵattributeInterpolate1, diff --git a/packages/core/src/render3/assert.ts b/packages/core/src/render3/assert.ts index c4212adb957..b8de75922a4 100644 --- a/packages/core/src/render3/assert.ts +++ b/packages/core/src/render3/assert.ts @@ -111,8 +111,7 @@ export function assertDirectiveDef(obj: any): asserts obj is DirectiveDef } } -export function assertIndexInDeclRange(lView: LView, index: number) { - const tView = lView[1]; +export function assertIndexInDeclRange(tView: TView, index: number) { assertBetween(HEADER_OFFSET, tView.bindingStartIndex, index); } diff --git a/packages/core/src/render3/index.ts b/packages/core/src/render3/index.ts index 359aead787b..7c40c622d12 100644 --- a/packages/core/src/render3/index.ts +++ b/packages/core/src/render3/index.ts @@ -151,7 +151,10 @@ export { ɵgetUnknownElementStrictMode, ɵsetUnknownElementStrictMode, ɵgetUnknownPropertyStrictMode, - ɵsetUnknownPropertyStrictMode + ɵsetUnknownPropertyStrictMode, + + DeferBlockDependencyInterceptor as ɵDeferBlockDependencyInterceptor, + DEFER_BLOCK_DEPENDENCY_INTERCEPTOR as ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, } from './instructions/all'; export {ɵɵi18n, ɵɵi18nApply, ɵɵi18nAttributes, ɵɵi18nEnd, ɵɵi18nExp,ɵɵi18nPostprocess, ɵɵi18nStart} from './instructions/i18n'; export {RenderFlags} from './interfaces/definition'; diff --git a/packages/core/src/render3/instructions/advance.ts b/packages/core/src/render3/instructions/advance.ts index 8ff38fd1828..23f091f64d7 100644 --- a/packages/core/src/render3/instructions/advance.ts +++ b/packages/core/src/render3/instructions/advance.ts @@ -8,7 +8,7 @@ import {assertGreaterThan} from '../../util/assert'; import {assertIndexInDeclRange} from '../assert'; import {executeCheckHooks, executeInitAndCheckHooks} from '../hooks'; -import {FLAGS, InitPhaseState, LView, LViewFlags, TView} from '../interfaces/view'; +import {FLAGS, InitPhaseState, LView, LViewFlags, TVIEW, TView} from '../interfaces/view'; import {getLView, getSelectedIndex, getTView, isInCheckNoChangesMode, setSelectedIndex} from '../state'; @@ -43,7 +43,7 @@ export function ɵɵadvance(delta: number): void { export function selectIndexInternal( tView: TView, lView: LView, index: number, checkNoChangesMode: boolean) { - ngDevMode && assertIndexInDeclRange(lView, index); + ngDevMode && assertIndexInDeclRange(lView[TVIEW], index); // Flush the initial hooks for elements in the view that have been added up to this point. // PERF WARNING: do NOT extract this to a separate function without running benchmarks diff --git a/packages/core/src/render3/instructions/defer.ts b/packages/core/src/render3/instructions/defer.ts index 3e9b3eebb23..cf3ff2ba926 100644 --- a/packages/core/src/render3/instructions/defer.ts +++ b/packages/core/src/render3/instructions/defer.ts @@ -6,26 +6,43 @@ * found in the LICENSE file at https://angular.io/license */ -import {Type} from '../../interface/type'; +import {InjectionToken, Injector} from '../../di'; +import {assertDefined, assertEqual, throwError} from '../../util/assert'; +import {assertIndexInDeclRange, assertLContainer, assertTNodeForLView} from '../assert'; +import {bindingUpdated} from '../bindings'; +import {getComponentDef, getDirectiveDef, getPipeDef} from '../definition'; +import {LContainer} from '../interfaces/container'; +import {DEFER_BLOCK_STATE, DeferBlockInstanceState, DeferDependenciesLoadingState, DeferredLoadingBlockConfig, DeferredPlaceholderBlockConfig, DependencyResolverFn, LDeferBlockDetails, TDeferBlockDetails} from '../interfaces/defer'; +import {DirectiveDefList, PipeDefList} from '../interfaces/definition'; +import {TContainerNode, TNode} from '../interfaces/node'; +import {isDestroyed} from '../interfaces/type_checks'; +import {HEADER_OFFSET, INJECTOR, LView, PARENT, TVIEW, TView} from '../interfaces/view'; +import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from '../state'; +import {NO_CHANGE} from '../tokens'; +import {getConstant, getTNode, removeLViewOnDestroy, storeLViewOnDestroy} from '../util/view_utils'; +import {addLViewToLContainer, createAndRenderEmbeddedLView, removeLViewFromLContainer} from '../view_manipulation'; -export type DeferredDepsFn = () => Array>|Type>; +import {ɵɵtemplate} from './template'; -/** Configuration object for a `{:loading}` block as it is stored in the component constants. */ -type DeferredLoadingConfig = [minimumTime: number|null, afterTime: number|null]; - -/** Configuration object for a `{:placeholder}` block as it is stored in the component constants. */ -type DeferredPlaceholderConfig = [afterTime: number|null]; +/** + * Shims for the `requestIdleCallback` and `cancelIdleCallback` functions for environments + * where those functions are not available (e.g. Node.js). + */ +const _requestIdleCallback = + typeof requestIdleCallback !== 'undefined' ? requestIdleCallback : setTimeout; +const _cancelIdleCallback = + typeof requestIdleCallback !== 'undefined' ? cancelIdleCallback : clearTimeout; /** * Creates runtime data structures for `{#defer}` blocks. * - * @param deferIndex Index of the underlying deferred block data structure. - * @param primaryTemplateIndex Index of the template function with the block's content. - * @param deferredDepsFn Function that contains dependencies for this defer block - * @param loadingIndex Index of the template with the `{:loading}` block content. - * @param placeholderIndex Index of the template with the `{:placeholder}` block content. - * @param error Index of the template with the `{:error}` block content. - * @param loadingConfigIndex Index in the constants array of the configuration of the `{:loading}` + * @param index Index of the `defer` instruction. + * @param primaryTmplIndex Index of the template with the primary block content. + * @param dependencyResolverFn Function that contains dependencies for this defer block. + * @param loadingTmplIndex Index of the template with the `{:loading}` block content. + * @param placeholderTmplIndex Index of the template with the `{:placeholder}` block content. + * @param errorTmplIndex Index of the template with the `{:error}` block content. + * @param loadingConfigIndex Index in the constants array of the configuration of the `{:loading}`. * block. * @param placeholderConfigIndexIndex in the constants array of the configuration of the * `{:placeholder}` block. @@ -33,21 +50,70 @@ type DeferredPlaceholderConfig = [afterTime: number|null]; * @codeGenApi */ export function ɵɵdefer( - deferIndex: number, - primaryTemplateIndex: number, - deferredDepsFn?: DeferredDepsFn|null, - loadingIndex?: number|null, - placeholderIndex?: number|null, - errorIndex?: number|null, - loadingConfigIndex?: number|null, - placeholderConfigIndex?: number|null, -) {} // TODO: implement runtime logic. + index: number, primaryTmplIndex: number, dependencyResolverFn?: DependencyResolverFn|null, + loadingTmplIndex?: number|null, placeholderTmplIndex?: number|null, + errorTmplIndex?: number|null, loadingConfigIndex?: number|null, + placeholderConfigIndex?: number|null) { + const lView = getLView(); + const tView = getTView(); + const tViewConsts = tView.consts; + const adjustedIndex = index + HEADER_OFFSET; + + ɵɵtemplate(index, null, 0, 0); + + if (tView.firstCreatePass) { + const deferBlockConfig: TDeferBlockDetails = { + primaryTmplIndex, + loadingTmplIndex: loadingTmplIndex ?? null, + placeholderTmplIndex: placeholderTmplIndex ?? null, + errorTmplIndex: errorTmplIndex ?? null, + placeholderBlockConfig: placeholderConfigIndex != null ? + getConstant(tViewConsts, placeholderConfigIndex) : + null, + loadingBlockConfig: loadingConfigIndex != null ? + getConstant(tViewConsts, loadingConfigIndex) : + null, + dependencyResolverFn: dependencyResolverFn ?? null, + loadingState: DeferDependenciesLoadingState.NOT_STARTED, + loadingPromise: null, + }; + + setTDeferBlockDetails(tView, adjustedIndex, deferBlockConfig); + } + + // Init instance-specific defer details and store it. + const lDetails = []; + lDetails[DEFER_BLOCK_STATE] = DeferBlockInstanceState.INITIAL; + setLDeferBlockDetails(lView, adjustedIndex, lDetails as LDeferBlockDetails); +} /** - * Loads the deferred content when a value becomes truthy. + * Loads defer block dependencies when a trigger value becomes truthy. * @codeGenApi */ -export function ɵɵdeferWhen(value: unknown) {} // TODO: implement runtime logic. +export function ɵɵdeferWhen(rawValue: unknown) { + const lView = getLView(); + const bindingIndex = nextBindingIndex(); + + if (bindingUpdated(lView, bindingIndex, rawValue)) { + const value = Boolean(rawValue); // handle truthy or falsy values + const tNode = getSelectedTNode(); + const lDetails = getLDeferBlockDetails(lView, tNode); + const renderedState = lDetails[DEFER_BLOCK_STATE]; + if (value === false && renderedState === DeferBlockInstanceState.INITIAL) { + // If nothing is rendered yet, render a placeholder (if defined). + renderPlaceholder(lView, tNode); + } else if ( + value === true && + (renderedState === DeferBlockInstanceState.INITIAL || + renderedState === DeferBlockInstanceState.PLACEHOLDER)) { + // The `when` condition has changed to `true`, trigger defer block loading + // if the block is either in initial (nothing is rendered) or a placeholder + // state. + triggerDeferBlock(lView, tNode); + } + } +} /** * Prefetches the deferred content when a value becomes truthy. @@ -56,13 +122,33 @@ export function ɵɵdeferWhen(value: unknown) {} // TODO: implement runtime log export function ɵɵdeferPrefetchWhen(value: unknown) {} // TODO: implement runtime logic. /** - * Creates runtime data structures for the `on idle` deferred trigger. + * Sets up handlers that represent `on idle` deferred trigger. * @codeGenApi */ -export function ɵɵdeferOnIdle() {} // TODO: implement runtime logic. +export function ɵɵdeferOnIdle() { + const lView = getLView(); + const tNode = getCurrentTNode()!; + + renderPlaceholder(lView, tNode); + + let id: number; + const removeIdleCallback = () => _cancelIdleCallback(id); + id = _requestIdleCallback(() => { + removeIdleCallback(); + // The idle callback is invoked, we no longer need + // to retain a cleanup callback in an LView. + removeLViewOnDestroy(lView, removeIdleCallback); + triggerDeferBlock(lView, tNode); + }) as number; + + // Store a cleanup function on LView, so that we cancel idle + // callback in case this LView was destroyed before a callback + // was invoked. + storeLViewOnDestroy(lView, removeIdleCallback); +} /** - * Creates runtime data structures for the `prefetech on idle` deferred trigger. + * Creates runtime data structures for the `prefetch on idle` deferred trigger. * @codeGenApi */ export function ɵɵdeferPrefetchOnIdle() {} // TODO: implement runtime logic. @@ -75,7 +161,7 @@ export function ɵɵdeferOnImmediate() {} // TODO: implement runtime logic. /** - * Creates runtime data structures for the `prefetech on immediate` deferred trigger. + * Creates runtime data structures for the `prefetch on immediate` deferred trigger. * @codeGenApi */ export function ɵɵdeferPrefetchOnImmediate() {} // TODO: implement runtime logic. @@ -101,7 +187,7 @@ export function ɵɵdeferPrefetchOnTimer(delay: number) {} // TODO: implement r export function ɵɵdeferOnHover() {} // TODO: implement runtime logic. /** - * Creates runtime data structures for the `prefetech on hover` deferred trigger. + * Creates runtime data structures for the `prefetch on hover` deferred trigger. * @codeGenApi */ export function ɵɵdeferPrefetchOnHover() {} // TODO: implement runtime logic. @@ -133,3 +219,303 @@ export function ɵɵdeferOnViewport(target?: unknown) {} // TODO: implement run * @codeGenApi */ export function ɵɵdeferPrefetchOnViewport(target?: unknown) {} // TODO: implement runtime logic. + +/********** Helper functions **********/ + +/** + * 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; +} + +/** + * Transitions a defer block to the new state. Updates the necessary + * data structures and renders corresponding block. + * + * @param newState New state that should be applied to the defer block. + * @param tNode TNode that represents a defer block. + * @param lContainer Represents an instance of a defer block. + * @param stateTmplIndex Index of a template that should be rendered. + */ +function renderDeferBlockState( + newState: DeferBlockInstanceState, tNode: TNode, lContainer: LContainer, + stateTmplIndex: number|null): void { + const hostLView = lContainer[PARENT]; + + // Check if this view is not destroyed. Since the loading process was async, + // the view might end up being destroyed by the time rendering happens. + if (isDestroyed(hostLView)) return; + + // Make sure this TNode belongs to TView that represents host LView. + ngDevMode && assertTNodeForLView(tNode, hostLView); + + const lDetails = getLDeferBlockDetails(hostLView, tNode); + + ngDevMode && assertDefined(lDetails, 'Expected a defer block state defined'); + + // Note: we transition to the next state if the previous state was represented + // with a number that is less than the next state. For example, if the current + // state is "loading" (represented as `2`), we should not show a placeholder + // (represented as `1`). + if (lDetails[DEFER_BLOCK_STATE] < newState && stateTmplIndex !== null) { + lDetails[DEFER_BLOCK_STATE] = newState; + const hostTView = hostLView[TVIEW]; + const adjustedIndex = stateTmplIndex + HEADER_OFFSET; + const tNode = getTNode(hostTView, adjustedIndex) as TContainerNode; + + // There is only 1 view that can be present in an LContainer that + // represents a `{#defer}` block, so always refer to the first one. + const viewIndex = 0; + removeLViewFromLContainer(lContainer, viewIndex); + const embeddedLView = createAndRenderEmbeddedLView(hostLView, tNode, null); + addLViewToLContainer(lContainer, embeddedLView, viewIndex); + } +} + +/** + * Trigger loading of defer block dependencies if the process hasn't started yet. + * + * @param tDetails Static information about this defer block. + * @param primaryBlockTNode TNode of a primary block template. + * @param injector Environment injector of the application. + */ +function triggerResourceLoading( + tDetails: TDeferBlockDetails, primaryBlockTNode: TNode, injector: Injector) { + const tView = primaryBlockTNode.tView!; + + if (tDetails.loadingState !== DeferDependenciesLoadingState.NOT_STARTED) { + // If the loading status is different from initial one, it means that + // the loading of dependencies is in progress and there is nothing to do + // in this function. All details can be obtained from the `tDetails` object. + return; + } + + // Switch from NOT_STARTED -> IN_PROGRESS state. + tDetails.loadingState = DeferDependenciesLoadingState.IN_PROGRESS; + + // Check if dependency function interceptor is configured. + const deferDependencyInterceptor = + injector.get(DEFER_BLOCK_DEPENDENCY_INTERCEPTOR, null, {optional: true}); + + const dependenciesFn = deferDependencyInterceptor ? + deferDependencyInterceptor.intercept(tDetails.dependencyResolverFn) : + tDetails.dependencyResolverFn; + + // The `dependenciesFn` might be `null` when all dependencies within + // a given `{#defer}` block were eagerly references elsewhere in a file, + // thus no dynamic `import()`s were produced. + if (!dependenciesFn) { + tDetails.loadingPromise = Promise.resolve().then(() => { + tDetails.loadingState = DeferDependenciesLoadingState.COMPLETE; + }); + return; + } + + // Start downloading of defer block dependencies. + tDetails.loadingPromise = Promise.allSettled(dependenciesFn()).then(results => { + let failed = false; + const directiveDefs: DirectiveDefList = []; + const pipeDefs: PipeDefList = []; + + for (const result of results) { + if (result.status === 'fulfilled') { + const dependency = result.value; + const directiveDef = getComponentDef(dependency) || getDirectiveDef(dependency); + if (directiveDef) { + directiveDefs.push(directiveDef); + } else { + const pipeDef = getPipeDef(dependency); + if (pipeDef) { + pipeDefs.push(pipeDef); + } + } + } else { + failed = true; + break; + } + } + + // Loading is completed, we no longer need this Promise. + tDetails.loadingPromise = null; + + if (failed) { + tDetails.loadingState = DeferDependenciesLoadingState.FAILED; + } else { + tDetails.loadingState = DeferDependenciesLoadingState.COMPLETE; + + // Update directive and pipe registries to add newly downloaded dependencies. + if (directiveDefs.length > 0) { + tView.directiveRegistry = tView.directiveRegistry ? + [...tView.directiveRegistry, ...directiveDefs] : + directiveDefs; + } + if (pipeDefs.length > 0) { + tView.pipeRegistry = tView.pipeRegistry ? [...tView.pipeRegistry, ...pipeDefs] : pipeDefs; + } + } + }); +} + +/** 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( + DeferBlockInstanceState.PLACEHOLDER, tNode, lContainer, tDetails.placeholderTmplIndex); +} + +/** + * Subscribes to the "loading" Promise and renders corresponding defer sub-block, + * based on the loading results. + * + * @param lContainer Represents an instance of a defer block. + * @param tNode Represents defer block info shared across all instances. + */ +function renderDeferStateAfterResourceLoading( + tDetails: TDeferBlockDetails, tNode: TNode, lContainer: LContainer) { + ngDevMode && + assertDefined( + tDetails.loadingPromise, 'Expected loading Promise to exist on this defer block'); + + tDetails.loadingPromise!.then(() => { + if (tDetails.loadingState === DeferDependenciesLoadingState.COMPLETE) { + ngDevMode && assertDeferredDependenciesLoaded(tDetails); + + // Everything is loaded, show the primary block content + renderDeferBlockState( + DeferBlockInstanceState.COMPLETE, tNode, lContainer, tDetails.primaryTmplIndex); + + } else if (tDetails.loadingState === DeferDependenciesLoadingState.FAILED) { + renderDeferBlockState( + DeferBlockInstanceState.ERROR, tNode, lContainer, tDetails.errorTmplIndex); + } + }); +} + +/** + * Attempts to trigger loading of defer block dependencies. + * If the block is already in a loading, completed or an error state - + * no additional actions are taken. + */ +function triggerDeferBlock(lView: LView, tNode: TNode) { + const tView = lView[TVIEW]; + const lContainer = lView[tNode.index]; + ngDevMode && assertLContainer(lContainer); + + const tDetails = getTDeferBlockDetails(tView, tNode); + + // Condition is triggered, try to render loading state and start downloading. + // Note: if a block is in a loading, completed or an error state, this call would be a noop. + renderDeferBlockState( + DeferBlockInstanceState.LOADING, tNode, lContainer, tDetails.loadingTmplIndex); + + switch (tDetails.loadingState) { + case DeferDependenciesLoadingState.NOT_STARTED: + const adjustedIndex = tDetails.primaryTmplIndex + HEADER_OFFSET; + const primaryBlockTNode = getTNode(lView[TVIEW], adjustedIndex) as TContainerNode; + triggerResourceLoading(tDetails, primaryBlockTNode, lView[INJECTOR]!); + + // The `loadingState` might have changed to "loading". + if ((tDetails.loadingState as DeferDependenciesLoadingState) === + DeferDependenciesLoadingState.IN_PROGRESS) { + renderDeferStateAfterResourceLoading(tDetails, tNode, lContainer); + } + break; + case DeferDependenciesLoadingState.IN_PROGRESS: + renderDeferStateAfterResourceLoading(tDetails, tNode, lContainer); + break; + case DeferDependenciesLoadingState.COMPLETE: + ngDevMode && assertDeferredDependenciesLoaded(tDetails); + renderDeferBlockState( + DeferBlockInstanceState.COMPLETE, tNode, lContainer, tDetails.primaryTmplIndex); + break; + case DeferDependenciesLoadingState.FAILED: + renderDeferBlockState( + DeferBlockInstanceState.ERROR, tNode, lContainer, tDetails.errorTmplIndex); + break; + default: + if (ngDevMode) { + throwError('Unknown defer block state'); + } + } +} + +/** + * 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' : ''); diff --git a/packages/core/src/render3/interfaces/defer.ts b/packages/core/src/render3/interfaces/defer.ts new file mode 100644 index 00000000000..9fdb0891043 --- /dev/null +++ b/packages/core/src/render3/interfaces/defer.ts @@ -0,0 +1,129 @@ +/** + * @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 type {DependencyType} from './definition'; + +/** + * Describes the shape of a function generated by the compiler + * to download dependencies that can be defer-loaded. + */ +export type DependencyResolverFn = () => Array>; + +/** + * Describes the state of defer block dependency loading. + */ +export const enum DeferDependenciesLoadingState { + /** Initial state, dependency loading is not yet triggered */ + NOT_STARTED, + + /** Dependency loading is in progress */ + IN_PROGRESS, + + /** Dependency loading has completed successfully */ + COMPLETE, + + /** Dependency loading has failed */ + FAILED, +} + +/** Configuration object for a `{:loading}` block as it is stored in the component constants. */ +export type DeferredLoadingBlockConfig = [minimumTime: number|null, afterTime: number|null]; + +/** Configuration object for a `{:placeholder}` block as it is stored in the component constants. */ +export type DeferredPlaceholderBlockConfig = [afterTime: number|null]; + +/** + * Describes the data shared across all instances of a {#defer} block. + */ +export interface TDeferBlockDetails { + /** + * Index in an LView and TData arrays where a template for the primary content + * can be found. + */ + primaryTmplIndex: number; + + /** + * Index in an LView and TData arrays where a template for the `{:loading}` + * block can be found. + */ + loadingTmplIndex: number|null; + + /** + * Extra configuration parameters (such as `after` and `minimum`) + * for the `{:loading}` block. + */ + loadingBlockConfig: DeferredLoadingBlockConfig|null; + + /** + * Index in an LView and TData arrays where a template for the `{:placeholder}` + * block can be found. + */ + placeholderTmplIndex: number|null; + + /** + * Extra configuration parameters (such as `after` and `minimum`) + * for the `{:placeholder}` block. + */ + placeholderBlockConfig: DeferredPlaceholderBlockConfig|null; + + /** + * Index in an LView and TData arrays where a template for the `{:error}` + * block can be found. + */ + errorTmplIndex: number|null; + + /** + * Compiler-generated function that loads all dependencies for a `{#defer}` block. + */ + dependencyResolverFn: DependencyResolverFn|null; + + /** + * Keeps track of the current loading state of defer block dependencies. + */ + loadingState: DeferDependenciesLoadingState; + + /** + * Dependency loading Promise. This Promise is helpful for cases when there + * are multiple instances of a defer block (e.g. if it was used inside of an *ngFor), + * which all await the same set of dependencies. + */ + loadingPromise: Promise|null; +} + +/** + * Describes the current state of this {#defer} block instance. + */ +export const enum DeferBlockInstanceState { + /** Initial state, nothing is rendered yet */ + INITIAL, + + /** The {:placeholder} block content is rendered */ + PLACEHOLDER, + + /** The {:loading} block content is rendered */ + LOADING, + + /** The main content block content is rendered */ + COMPLETE, + + /** The {:error} block content is rendered */ + ERROR +} + +export const DEFER_BLOCK_STATE = 0; + +/** + * Describes instance-specific {#defer} block data. + * + * Note: currently there is only the `state` slot, but more slots + * would be added later to keep track of `after` and `maximum` features + * (which would require per-instance state). + */ +export interface LDeferBlockDetails extends Array { + [DEFER_BLOCK_STATE]: DeferBlockInstanceState; +} diff --git a/packages/core/src/render3/interfaces/definition.ts b/packages/core/src/render3/interfaces/definition.ts index 946053b5af7..43bf1db22f3 100644 --- a/packages/core/src/render3/interfaces/definition.ts +++ b/packages/core/src/render3/interfaces/definition.ts @@ -491,7 +491,9 @@ export type DirectiveTypeList = (DirectiveType|ComponentType| Type/* Type as workaround for: Microsoft/TypeScript/issues/4881 */)[]; -export type DependencyTypeList = (DirectiveType|ComponentType|PipeType|Type)[]; +export type DependencyType = DirectiveType|ComponentType|PipeType|Type; + +export type DependencyTypeList = Array; export type TypeOrFactory = T|(() => T); diff --git a/packages/core/src/render3/interfaces/type_checks.ts b/packages/core/src/render3/interfaces/type_checks.ts index 5d2a67434e8..3239b4cc252 100644 --- a/packages/core/src/render3/interfaces/type_checks.ts +++ b/packages/core/src/render3/interfaces/type_checks.ts @@ -56,3 +56,7 @@ export function isProjectionTNode(tNode: TNode): boolean { export function hasI18n(lView: LView): boolean { return (lView[FLAGS] & LViewFlags.HasI18n) === LViewFlags.HasI18n; } + +export function isDestroyed(lView: LView): boolean { + return (lView[FLAGS] & LViewFlags.Destroyed) === LViewFlags.Destroyed; +} diff --git a/packages/core/src/render3/interfaces/view.ts b/packages/core/src/render3/interfaces/view.ts index 02a35f79550..93d67c94775 100644 --- a/packages/core/src/render3/interfaces/view.ts +++ b/packages/core/src/render3/interfaces/view.ts @@ -23,6 +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'; @@ -908,8 +909,9 @@ export type DestroyHookData = (HookEntry|HookData)[]; * * Injector bloom filters are also stored here. */ -export type TData = (TNode|PipeDef|DirectiveDef|ComponentDef|number|TStylingRange| - TStylingKey|ProviderToken|TI18n|I18nUpdateOpCodes|TIcu|null|string)[]; +export type TData = + (TNode|PipeDef|DirectiveDef|ComponentDef|number|TStylingRange|TStylingKey| + ProviderToken|TI18n|I18nUpdateOpCodes|TIcu|null|string|TDeferBlockDetails)[]; // Note: This hack is necessary so we don't erroneously get a circular dependency // failure based on types. diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts new file mode 100644 index 00000000000..a60479352e8 --- /dev/null +++ b/packages/core/test/acceptance/defer_spec.ts @@ -0,0 +1,486 @@ +/** + * @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 {ɵsetEnabledBlockTypes as setEnabledBlockTypes} from '@angular/compiler/src/jit_compiler_facade'; +import {Component, Input, QueryList, ViewChildren, ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; + +describe('#defer', () => { + beforeEach(() => setEnabledBlockTypes(['defer'])); + afterEach(() => setEnabledBlockTypes([])); + + it('should transition between placeholder, loading and loaded states', async () => { + @Component({ + selector: 'my-lazy-cmp', + standalone: true, + template: 'Hi!', + }) + class MyLazyCmp { + } + + @Component({ + standalone: true, + selector: 'simple-app', + imports: [MyLazyCmp], + template: ` + {#defer when isVisible} + + {:loading} + Loading... + {:placeholder} + Placeholder! + {:error} + Failed to load dependencies :( + {/defer} + ` + }) + class MyCmp { + isVisible = false; + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); + + fixture.componentInstance.isVisible = true; + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML).toContain('Loading'); + + // Wait for dependencies to load. + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML).toContain('Hi!'); + }); + + it('should work when only main block is present', async () => { + @Component({ + selector: 'my-lazy-cmp', + standalone: true, + template: 'Hi!', + }) + class MyLazyCmp { + } + + @Component({ + standalone: true, + selector: 'simple-app', + imports: [MyLazyCmp], + template: ` +

Text outside of a defer block

+ {#defer when isVisible} + + {/defer} + ` + }) + class MyCmp { + isVisible = false; + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML).toContain('Text outside of a defer block'); + + fixture.componentInstance.isVisible = true; + fixture.detectChanges(); + + // Wait for dependencies to load. + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML).toContain('Hi!'); + }); + + + describe('directive matching', () => { + it('should support directive matching in all blocks', async () => { + @Component({ + selector: 'nested-cmp', + standalone: true, + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; + } + + @Component({ + standalone: true, + selector: 'simple-app', + imports: [NestedCmp], + template: ` + {#defer when isVisible} + + {:loading} + Loading... + + {:placeholder} + Placeholder! + + {:error} + Failed to load dependencies :( + + {/defer} + ` + }) + class MyCmp { + isVisible = false; + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML) + .toContain( + 'Rendering placeholder block.'); + + fixture.componentInstance.isVisible = true; + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML) + .toContain( + 'Rendering loading block.'); + + // Wait for dependencies to load. + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML) + .toContain( + 'Rendering primary block.'); + }); + }); + + describe('error handling', () => { + it('should render an error block when loading fails', async () => { + @Component({ + selector: 'nested-cmp', + standalone: true, + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; + } + + @Component({ + standalone: true, + selector: 'simple-app', + imports: [NestedCmp], + template: ` + {#defer when isVisible} + + {:loading} + Loading... + {:placeholder} + Placeholder! + {:error} + Failed to load dependencies :( + + {/defer} + ` + }) + class MyCmp { + isVisible = false; + @ViewChildren(NestedCmp) cmps!: QueryList; + } + + const deferDepsInterceptor = { + intercept() { + // Simulate loading failure. + return () => [Promise.reject()]; + } + }; + + TestBed.configureTestingModule({ + providers: [ + {provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor}, + ] + }); + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML).toContain('Placeholder'); + + fixture.componentInstance.isVisible = true; + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML).toContain('Loading'); + + // Wait for dependencies to load. + await fixture.whenStable(); + fixture.detectChanges(); + + // Verify that the error block is rendered. + // Also verify that selector matching works in an error block. + expect(fixture.nativeElement.outerHTML) + .toContain('Rendering error block.'); + + // Verify that queries work within an error block. + expect(fixture.componentInstance.cmps.length).toBe(1); + expect(fixture.componentInstance.cmps.get(0)?.block).toBe('error'); + }); + }); + + describe('queries', () => { + it('should query for components within each block', async () => { + @Component({ + selector: 'nested-cmp', + standalone: true, + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; + } + + @Component({ + standalone: true, + selector: 'simple-app', + imports: [NestedCmp], + template: ` + {#defer when isVisible} + + {:loading} + Loading... + + {:placeholder} + Placeholder! + + {:error} + Failed to load dependencies :( + + {/defer} + ` + }) + class MyCmp { + isVisible = false; + + @ViewChildren(NestedCmp) cmps!: QueryList; + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + expect(fixture.componentInstance.cmps.length).toBe(1); + expect(fixture.componentInstance.cmps.get(0)?.block).toBe('placeholder'); + expect(fixture.nativeElement.outerHTML) + .toContain( + 'Rendering placeholder block.'); + + fixture.componentInstance.isVisible = true; + fixture.detectChanges(); + + expect(fixture.componentInstance.cmps.length).toBe(1); + expect(fixture.componentInstance.cmps.get(0)?.block).toBe('loading'); + expect(fixture.nativeElement.outerHTML) + .toContain( + 'Rendering loading block.'); + + // Wait for dependencies to load. + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.componentInstance.cmps.length).toBe(1); + expect(fixture.componentInstance.cmps.get(0)?.block).toBe('primary'); + expect(fixture.nativeElement.outerHTML) + .toContain( + 'Rendering primary block.'); + }); + }); + + describe('content projection', () => { + it('should be able to project content into each block', async () => { + @Component({ + selector: 'cmp-a', + standalone: true, + template: 'CmpA', + }) + class CmpA { + } + + @Component({ + selector: 'cmp-b', + standalone: true, + template: 'CmpB', + }) + class CmpB { + } + + @Component({ + selector: 'nested-cmp', + standalone: true, + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; + } + + @Component({ + standalone: true, + selector: 'my-app', + imports: [NestedCmp], + template: ` + {#defer when isVisible} + + + {:loading} + Loading... + + {:placeholder} + Placeholder! + + {:error} + Failed to load dependencies :( + + {/defer} + ` + }) + class MyCmp { + @Input() isVisible = false; + } + + @Component({ + standalone: true, + selector: 'root-app', + imports: [MyCmp, CmpA, CmpB], + template: ` + + Projected content. + Including tags + + {#defer when isInViewport} + + {:placeholder} + Projected defer block placeholder. + {/defer} + + ` + }) + class RootCmp { + isVisible = false; + isInViewport = false; + } + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML) + .toContain( + 'Rendering placeholder block.'); + + fixture.componentInstance.isVisible = true; + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML) + .toContain( + 'Rendering loading block.'); + + // Wait for dependencies to load. + await fixture.whenStable(); + fixture.detectChanges(); + + // Verify primary block content. + const primaryBlockHTML = fixture.nativeElement.outerHTML; + expect(primaryBlockHTML) + .toContain( + 'Rendering primary block.'); + expect(primaryBlockHTML).toContain('Projected content.'); + expect(primaryBlockHTML).toContain('Including tags'); + expect(primaryBlockHTML).toContain('CmpA'); + expect(primaryBlockHTML).toContain('Projected defer block placeholder.'); + + fixture.componentInstance.isInViewport = true; + fixture.detectChanges(); + + // Wait for projected block dependencies to load. + await fixture.whenStable(); + fixture.detectChanges(); + + // Nested defer block was triggered and the `CmpB` content got rendered. + expect(fixture.nativeElement.outerHTML).toContain('CmpB'); + }); + }); + + describe('nested blocks', () => { + it('should be able to have nested blocks', async () => { + @Component({ + selector: 'cmp-a', + standalone: true, + template: 'CmpA', + }) + class CmpA { + } + + @Component({ + selector: 'nested-cmp', + standalone: true, + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; + } + + @Component({ + standalone: true, + selector: 'root-app', + imports: [NestedCmp, CmpA], + template: ` + {#defer when isVisible} + + {#defer when isInViewport} + + {:placeholder} + Nested defer block placeholder. + {/defer} + {:placeholder} + + {/defer} + ` + }) + class RootCmp { + isVisible = false; + isInViewport = false; + } + + const fixture = TestBed.createComponent(RootCmp); + fixture.detectChanges(); + + expect(fixture.nativeElement.outerHTML) + .toContain( + 'Rendering placeholder block.'); + + fixture.componentInstance.isVisible = true; + fixture.detectChanges(); + + await fixture.whenStable(); // loading dependencies for the defer block within MyCmp... + fixture.detectChanges(); + + // Verify primary block content. + const primaryBlockHTML = fixture.nativeElement.outerHTML; + expect(primaryBlockHTML) + .toContain( + 'Rendering primary block.'); + + // Make sure we have a nested block in a placeholder state. + expect(primaryBlockHTML).toContain('Nested defer block placeholder.'); + + // Trigger condition for the nested block. + fixture.componentInstance.isInViewport = true; + fixture.detectChanges(); + + // Wait for nested block dependencies to load. + await fixture.whenStable(); + fixture.detectChanges(); + + // Nested defer block was triggered and the `CmpB` content got rendered. + expect(fixture.nativeElement.outerHTML).toContain('CmpA'); + }); + }); +}); diff --git a/packages/core/test/bundling/router/bundle.golden_symbols.json b/packages/core/test/bundling/router/bundle.golden_symbols.json index c1a7bd5b965..5cc1130e5be 100644 --- a/packages/core/test/bundling/router/bundle.golden_symbols.json +++ b/packages/core/test/bundling/router/bundle.golden_symbols.json @@ -1977,7 +1977,7 @@ "name": "tap" }, { - "name": "throwError5" + "name": "throwError6" }, { "name": "throwIfEmpty"