Skip to content

Commit

Permalink
refactor(core): add prefetch on idle support for defer blocks
Browse files Browse the repository at this point in the history
This commit updates the logic to add `prefetch on idle` support for defer blocks. Previously, the `on idle` logic was already implemented for the main loading and rendering. This commit reuses the same logic to bring it to the prefetching mechanism.
  • Loading branch information
AndrewKushnir committed Sep 1, 2023
1 parent 1aff106 commit f42b56c
Show file tree
Hide file tree
Showing 2 changed files with 149 additions and 36 deletions.
77 changes: 47 additions & 30 deletions packages/core/src/render3/instructions/defer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export function ɵɵdeferPrefetchWhen(rawValue: unknown) {
const tDetails = getTDeferBlockDetails(tView, tNode);
if (value === true && tDetails.loadingState === DeferDependenciesLoadingState.NOT_STARTED) {
// If loading has not been started yet, trigger it now.
triggerResourceLoading(tDetails, getPrimaryBlockTNode(tView, tDetails), lView[INJECTOR]!);
triggerResourceLoading(tDetails, tView, lView);
}
}
}
Expand All @@ -161,28 +161,23 @@ export function ɵɵdeferOnIdle() {
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);
onIdle(lView, () => triggerDeferBlock(lView, tNode));
}

/**
* Creates runtime data structures for the `prefetch on idle` deferred trigger.
* @codeGenApi
*/
export function ɵɵdeferPrefetchOnIdle() {} // TODO: implement runtime logic.
export function ɵɵdeferPrefetchOnIdle() {
const lView = getLView();
const tNode = getCurrentTNode()!;
const tView = lView[TVIEW];
const tDetails = getTDeferBlockDetails(tView, tNode);

if (tDetails.loadingState === DeferDependenciesLoadingState.NOT_STARTED) {
onIdle(lView, () => triggerResourceLoading(tDetails, tView, lView));
}
}

/**
* Creates runtime data structures for the `on immediate` deferred trigger.
Expand Down Expand Up @@ -253,6 +248,26 @@ export function ɵɵdeferPrefetchOnViewport(target?: unknown) {} // TODO: imple

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

/**
* Helper function to schedule a callback to be invoked when a browser becomes idle.
*/
function onIdle(lView: LView, callback: VoidFunction) {
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);
callback();
}) as number;

// Store a cleanup function on LView, so that we cancel idle
// callback in case this LView is destroyed before a callback
// is invoked.
storeLViewOnDestroy(lView, removeIdleCallback);
}

/**
* Calculates a data slot index for defer block info (either static or
* instance-specific), given an index of a defer instruction.
Expand Down Expand Up @@ -347,22 +362,22 @@ function renderDeferBlockState(
* 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.
* @param tView TView of a host view.
* @param lView LView of a host view.
*/
function triggerResourceLoading(
tDetails: TDeferBlockDetails, primaryBlockTNode: TNode, injector: Injector) {
const tView = primaryBlockTNode.tView!;

if (!shouldTriggerDeferBlock(injector)) return;
function triggerResourceLoading(tDetails: TDeferBlockDetails, tView: TView, lView: LView) {
const injector = lView[INJECTOR]!;

if (tDetails.loadingState !== DeferDependenciesLoadingState.NOT_STARTED) {
if (!shouldTriggerDeferBlock(injector) ||
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;
}

const primaryBlockTNode = getPrimaryBlockTNode(tView, tDetails);

// Switch from NOT_STARTED -> IN_PROGRESS state.
tDetails.loadingState = DeferDependenciesLoadingState.IN_PROGRESS;

Expand Down Expand Up @@ -417,13 +432,16 @@ function triggerResourceLoading(
tDetails.loadingState = DeferDependenciesLoadingState.COMPLETE;

// Update directive and pipe registries to add newly downloaded dependencies.
const primaryBlockTView = primaryBlockTNode.tView!;
if (directiveDefs.length > 0) {
tView.directiveRegistry = tView.directiveRegistry ?
[...tView.directiveRegistry, ...directiveDefs] :
primaryBlockTView.directiveRegistry = primaryBlockTView.directiveRegistry ?
[...primaryBlockTView.directiveRegistry, ...directiveDefs] :
directiveDefs;
}
if (pipeDefs.length > 0) {
tView.pipeRegistry = tView.pipeRegistry ? [...tView.pipeRegistry, ...pipeDefs] : pipeDefs;
primaryBlockTView.pipeRegistry = primaryBlockTView.pipeRegistry ?
[...primaryBlockTView.pipeRegistry, ...pipeDefs] :
pipeDefs;
}
}
});
Expand Down Expand Up @@ -496,8 +514,7 @@ function triggerDeferBlock(lView: LView, tNode: TNode) {

switch (tDetails.loadingState) {
case DeferDependenciesLoadingState.NOT_STARTED:
triggerResourceLoading(
tDetails, getPrimaryBlockTNode(lView[TVIEW], tDetails), lView[INJECTOR]!);
triggerResourceLoading(tDetails, lView[TVIEW], lView);

// The `loadingState` might have changed to "loading".
if ((tDetails.loadingState as DeferDependenciesLoadingState) ===
Expand Down
108 changes: 102 additions & 6 deletions packages/core/test/acceptance/defer_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {TestBed} from '@angular/core/testing';
* and can not remove dependencies and their imports in the same way as AOT.
* From JIT perspective, all dependencies inside a defer block remain eager.
* We need to clear this association to run tests that verify loading and
* preloading behavior.
* prefetching behavior.
*/
function clearDirectiveDefs(type: Type<unknown>): void {
const cmpDef = getComponentDef(type);
Expand Down Expand Up @@ -525,7 +525,7 @@ describe('#defer', () => {
selector: 'root-app',
imports: [NestedCmp],
template: `
{#defer when deferCond; prefetch when prefetchCond}
{#defer when deferCond; prefetch when prefetchCond}
<nested-cmp [block]="'primary'" />
{:placeholder}
Placeholder
Expand Down Expand Up @@ -560,6 +560,9 @@ describe('#defer', () => {

expect(fixture.nativeElement.outerHTML).toContain('Placeholder');

// Make sure loading function is not yet invoked.
expect(loadingFnInvokedTimes).toBe(0);

// Trigger prefetching.
fixture.componentInstance.prefetchCond = true;
fixture.detectChanges();
Expand Down Expand Up @@ -589,7 +592,7 @@ describe('#defer', () => {
expect(loadingFnInvokedTimes).toBe(1);
});

it('should handle a case when preloading fails', async () => {
it('should handle a case when prefetching fails', async () => {
@Component({
selector: 'nested-cmp',
standalone: true,
Expand All @@ -604,7 +607,7 @@ describe('#defer', () => {
selector: 'root-app',
imports: [NestedCmp],
template: `
{#defer when deferCond; prefetch when prefetchCond}
{#defer when deferCond; prefetch when prefetchCond}
<nested-cmp [block]="'primary'" />
{:error}
Loading failed
Expand Down Expand Up @@ -641,6 +644,9 @@ describe('#defer', () => {

expect(fixture.nativeElement.outerHTML).toContain('Placeholder');

// Make sure loading function is not yet invoked.
expect(loadingFnInvokedTimes).toBe(0);

// Trigger prefetching.
fixture.componentInstance.prefetchCond = true;
fixture.detectChanges();
Expand All @@ -660,7 +666,7 @@ describe('#defer', () => {

await fixture.whenStable();

// Since preloading failed, expect the `{:error}` state to be rendered.
// Since prefetching failed, expect the `{:error}` state to be rendered.
expect(fixture.nativeElement.outerHTML).toContain('Loading failed');

// Expect that the loading resources function was not invoked again (counter remains 1).
Expand All @@ -682,7 +688,7 @@ describe('#defer', () => {
selector: 'root-app',
imports: [NestedCmp],
template: `
{#defer when deferCond; prefetch when deferCond}
{#defer when deferCond; prefetch when deferCond}
<nested-cmp [block]="'primary'" />
{:error}
Loading failed
Expand Down Expand Up @@ -718,6 +724,9 @@ describe('#defer', () => {

expect(fixture.nativeElement.outerHTML).toContain('Placeholder');

// Make sure loading function is not yet invoked.
expect(loadingFnInvokedTimes).toBe(0);

// Trigger prefetching and loading at the same time.
fixture.componentInstance.deferCond = true;
fixture.detectChanges();
Expand All @@ -733,5 +742,92 @@ describe('#defer', () => {
// Expect the main content to be rendered.
expect(fixture.nativeElement.outerHTML).toContain('Rendering primary block');
});

it('should support `prefetch on idle` condition', async () => {
@Component({
selector: 'nested-cmp',
standalone: true,
template: 'Rendering {{ block }} block.',
})
class NestedCmp {
@Input() block!: string;
}

@Component({
standalone: true,
selector: 'root-app',
imports: [NestedCmp],
template: `
{#defer when deferCond; prefetch on idle}
<nested-cmp [block]="'primary'" />
{:placeholder}
Placeholder
{/defer}
`
})
class RootCmp {
deferCond = false;
}

let loadingFnInvokedTimes = 0;
const deferDepsInterceptor = {
intercept() {
return () => {
loadingFnInvokedTimes++;
return [Promise.resolve(NestedCmp)];
};
}
};

TestBed.configureTestingModule({
providers: [
{provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, useValue: deferDepsInterceptor},
]
});

clearDirectiveDefs(RootCmp);

const fixture = TestBed.createComponent(RootCmp);
fixture.detectChanges();

expect(fixture.nativeElement.outerHTML).toContain('Placeholder');

// Make sure loading function is not yet invoked.
expect(loadingFnInvokedTimes).toBe(0);

// If we are in a browser environment and the `requestIdleCallback` function
// is present - use it, otherwise just invoke the callback function.
const onIdle = typeof requestIdleCallback !== 'undefined' ?
requestIdleCallback :
((callback: Function) => callback());

// Invoke the rest of the test when a browser was in idle state, which is
// needed to trigger defer block loading.
onIdle(async () => {
await fixture.whenStable(); // prefetching dependencies of the defer block
fixture.detectChanges();

// Expect that the loading resources function was invoked once.
expect(loadingFnInvokedTimes).toBe(1);

// Expect that placeholder content is still rendered.
expect(fixture.nativeElement.outerHTML).toContain('Placeholder');

// Trigger main content.
fixture.componentInstance.deferCond = true;
fixture.detectChanges();

await fixture.whenStable();

// Verify primary block content.
const primaryBlockHTML = fixture.nativeElement.outerHTML;
expect(primaryBlockHTML)
.toContain(
'<nested-cmp ng-reflect-block="primary">Rendering primary block.</nested-cmp>');

// Expect that the loading resources function was not invoked again (counter remains 1).
expect(loadingFnInvokedTimes).toBe(1);
});
});
});
});

0 comments on commit f42b56c

Please sign in to comment.