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 12061454d1f45..1cd8d81f7cb59 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 @@ -702,3 +702,44 @@ export declare class MyApp { static ɵcmp: i0.ɵɵComponentDeclaration; } +/**************************************************************************************************** + * PARTIAL FILE: deferred_with_implicit_triggers.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyApp { + constructor() { + this.message = 'hello'; + } +} +MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component }); +MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, selector: "ng-component", ngImport: i0, template: ` + @defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) { + {{message}} + } @placeholder { + + } + `, isInline: true }); +i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{ + type: Component, + args: [{ + template: ` + @defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) { + {{message}} + } @placeholder { + + } + `, + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: deferred_with_implicit_triggers.d.ts + ****************************************************************************************************/ +import * as i0 from "@angular/core"; +export declare class MyApp { + message: string; + static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; +} + diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json index 9203456a534da..2539552232bc3 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/TEST_CASES.json @@ -255,6 +255,27 @@ } ], "skipForTemplatePipeline": true + }, + { + "description": "should generate a deferred block with implicit trigger references", + "angularCompilerOptions": { + "_enabledBlockTypes": ["defer"] + }, + "inputFiles": [ + "deferred_with_implicit_triggers.ts" + ], + "expectations": [ + { + "files": [ + { + "expected": "deferred_with_implicit_triggers_template.js", + "generated": "deferred_with_implicit_triggers.js" + } + ], + "failureMessage": "Incorrect template" + } + ], + "skipForTemplatePipeline": true } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_implicit_triggers.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_implicit_triggers.ts new file mode 100644 index 0000000000000..aa1041dd24cca --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_implicit_triggers.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; + +@Component({ + template: ` + @defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) { + {{message}} + } @placeholder { + + } + `, +}) +export class MyApp { + message = 'hello'; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_implicit_triggers_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_implicit_triggers_template.js new file mode 100644 index 0000000000000..b8cdcb90de74d --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_with_implicit_triggers_template.js @@ -0,0 +1,20 @@ +function MyApp_DeferPlaceholder_1_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵelementStart(0, "button"); + $r3$.ɵɵtext(1, "Click me"); + $r3$.ɵɵelementEnd(); + } +} +… +function MyApp_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵtemplate(0, MyApp_Defer_0_Template, 1, 1)(1, MyApp_DeferPlaceholder_1_Template, 2, 0); + $r3$.ɵɵdefer(2, 0, null, null, 1); + $r3$.ɵɵdeferOnHover(0, -1); + $r3$.ɵɵdeferOnInteraction(0, -1); + $r3$.ɵɵdeferOnViewport(0, -1); + $r3$.ɵɵdeferPrefetchOnHover(0, -1); + $r3$.ɵɵdeferPrefetchOnInteraction(0, -1); + $r3$.ɵɵdeferPrefetchOnViewport(0, -1); + } +} diff --git a/packages/compiler/src/render3/r3_ast.ts b/packages/compiler/src/render3/r3_ast.ts index 928d49b985e9d..fae9a91e476fa 100644 --- a/packages/compiler/src/render3/r3_ast.ts +++ b/packages/compiler/src/render3/r3_ast.ts @@ -136,7 +136,7 @@ export class IdleDeferredTrigger extends DeferredTrigger {} export class ImmediateDeferredTrigger extends DeferredTrigger {} export class HoverDeferredTrigger extends DeferredTrigger { - constructor(public reference: string, sourceSpan: ParseSourceSpan) { + constructor(public reference: string|null, sourceSpan: ParseSourceSpan) { super(sourceSpan); } } @@ -148,7 +148,7 @@ export class TimerDeferredTrigger extends DeferredTrigger { } export class InteractionDeferredTrigger extends DeferredTrigger { - constructor(public reference: string, sourceSpan: ParseSourceSpan) { + constructor(public reference: string|null, sourceSpan: ParseSourceSpan) { super(sourceSpan); } } diff --git a/packages/compiler/src/render3/r3_deferred_blocks.ts b/packages/compiler/src/render3/r3_deferred_blocks.ts index e7377a6410165..8183172041ca9 100644 --- a/packages/compiler/src/render3/r3_deferred_blocks.ts +++ b/packages/compiler/src/render3/r3_deferred_blocks.ts @@ -44,8 +44,9 @@ export function createDeferredBlock( ast: html.Block, connectedBlocks: html.Block[], visitor: html.Visitor, bindingParser: BindingParser): {node: t.DeferredBlock, errors: ParseError[]} { const errors: ParseError[] = []; - const {triggers, prefetchTriggers} = parsePrimaryTriggers(ast.parameters, bindingParser, errors); const {placeholder, loading, error} = parseConnectedBlocks(connectedBlocks, errors, visitor); + const {triggers, prefetchTriggers} = + parsePrimaryTriggers(ast.parameters, bindingParser, errors, placeholder); const node = new t.DeferredBlock( html.visitAll(visitor, ast.children, ast.children), triggers, prefetchTriggers, placeholder, loading, error, ast.sourceSpan, ast.startSourceSpan, ast.endSourceSpan); @@ -182,7 +183,8 @@ function parseErrorBlock(ast: html.Block, visitor: html.Visitor): t.DeferredBloc } function parsePrimaryTriggers( - params: html.BlockParameter[], bindingParser: BindingParser, errors: ParseError[]) { + params: html.BlockParameter[], bindingParser: BindingParser, errors: ParseError[], + placeholder: t.DeferredBlockPlaceholder|null) { const triggers: t.DeferredBlockTriggers = {}; const prefetchTriggers: t.DeferredBlockTriggers = {}; @@ -192,11 +194,11 @@ function parsePrimaryTriggers( if (WHEN_PARAMETER_PATTERN.test(param.expression)) { parseWhenTrigger(param, bindingParser, triggers, errors); } else if (ON_PARAMETER_PATTERN.test(param.expression)) { - parseOnTrigger(param, triggers, errors); + parseOnTrigger(param, triggers, errors, placeholder); } else if (PREFETCH_WHEN_PATTERN.test(param.expression)) { parseWhenTrigger(param, bindingParser, prefetchTriggers, errors); } else if (PREFETCH_ON_PATTERN.test(param.expression)) { - parseOnTrigger(param, prefetchTriggers, errors); + parseOnTrigger(param, prefetchTriggers, errors, placeholder); } else { errors.push(new ParseError(param.sourceSpan, 'Unrecognized trigger')); } diff --git a/packages/compiler/src/render3/r3_deferred_triggers.ts b/packages/compiler/src/render3/r3_deferred_triggers.ts index 5e9bcc06eb262..c89a0a55fde15 100644 --- a/packages/compiler/src/render3/r3_deferred_triggers.ts +++ b/packages/compiler/src/render3/r3_deferred_triggers.ts @@ -58,7 +58,7 @@ export function parseWhenTrigger( /** Parses an `on` trigger */ export function parseOnTrigger( {expression, sourceSpan}: html.BlockParameter, triggers: t.DeferredBlockTriggers, - errors: ParseError[]): void { + errors: ParseError[], placeholder: t.DeferredBlockPlaceholder|null): void { const onIndex = expression.indexOf('on'); // This is here just to be safe, we shouldn't enter this function @@ -67,7 +67,8 @@ export function parseOnTrigger( errors.push(new ParseError(sourceSpan, `Could not find "on" keyword in expression`)); } else { const start = getTriggerParametersStart(expression, onIndex + 1); - const parser = new OnTriggerParser(expression, start, sourceSpan, triggers, errors); + const parser = + new OnTriggerParser(expression, start, sourceSpan, triggers, errors, placeholder); parser.parse(); } } @@ -79,7 +80,8 @@ class OnTriggerParser { constructor( private expression: string, private start: number, private span: ParseSourceSpan, - private triggers: t.DeferredBlockTriggers, private errors: ParseError[]) { + private triggers: t.DeferredBlockTriggers, private errors: ParseError[], + private placeholder: t.DeferredBlockPlaceholder|null) { this.tokens = new Lexer().tokenize(expression.slice(start)); } @@ -146,7 +148,8 @@ class OnTriggerParser { break; case OnTriggerType.INTERACTION: - this.trackTrigger('interaction', createInteractionTrigger(parameters, sourceSpan)); + this.trackTrigger( + 'interaction', createInteractionTrigger(parameters, sourceSpan, this.placeholder)); break; case OnTriggerType.IMMEDIATE: @@ -154,11 +157,12 @@ class OnTriggerParser { break; case OnTriggerType.HOVER: - this.trackTrigger('hover', createHoverTrigger(parameters, sourceSpan)); + this.trackTrigger('hover', createHoverTrigger(parameters, sourceSpan, this.placeholder)); break; case OnTriggerType.VIEWPORT: - this.trackTrigger('viewport', createViewportTrigger(parameters, sourceSpan)); + this.trackTrigger( + 'viewport', createViewportTrigger(parameters, sourceSpan, this.placeholder)); break; default: @@ -291,15 +295,6 @@ function createTimerTrigger(parameters: string[], sourceSpan: ParseSourceSpan) { return new t.TimerDeferredTrigger(delay, sourceSpan); } -function createInteractionTrigger( - parameters: string[], sourceSpan: ParseSourceSpan): t.InteractionDeferredTrigger { - if (parameters.length !== 1) { - throw new Error(`"${OnTriggerType.INTERACTION}" trigger must have exactly one parameter`); - } - - return new t.InteractionDeferredTrigger(parameters[0], sourceSpan); -} - function createImmediateTrigger( parameters: string[], sourceSpan: ParseSourceSpan): t.ImmediateDeferredTrigger { if (parameters.length > 0) { @@ -310,22 +305,44 @@ function createImmediateTrigger( } function createHoverTrigger( - parameters: string[], sourceSpan: ParseSourceSpan): t.HoverDeferredTrigger { - if (parameters.length !== 1) { - throw new Error(`"${OnTriggerType.HOVER}" trigger must have exactly one parameter`); - } + parameters: string[], sourceSpan: ParseSourceSpan, + placeholder: t.DeferredBlockPlaceholder|null): t.HoverDeferredTrigger { + validateReferenceBasedTrigger(OnTriggerType.HOVER, parameters, placeholder); + return new t.HoverDeferredTrigger(parameters[0] ?? null, sourceSpan); +} - return new t.HoverDeferredTrigger(parameters[0], sourceSpan); +function createInteractionTrigger( + parameters: string[], sourceSpan: ParseSourceSpan, + placeholder: t.DeferredBlockPlaceholder|null): t.InteractionDeferredTrigger { + validateReferenceBasedTrigger(OnTriggerType.INTERACTION, parameters, placeholder); + return new t.InteractionDeferredTrigger(parameters[0] ?? null, sourceSpan); } function createViewportTrigger( - parameters: string[], sourceSpan: ParseSourceSpan): t.ViewportDeferredTrigger { - // TODO: the RFC has some more potential parameters for `viewport`. + parameters: string[], sourceSpan: ParseSourceSpan, + placeholder: t.DeferredBlockPlaceholder|null): t.ViewportDeferredTrigger { + validateReferenceBasedTrigger(OnTriggerType.VIEWPORT, parameters, placeholder); + return new t.ViewportDeferredTrigger(parameters[0] ?? null, sourceSpan); +} + +function validateReferenceBasedTrigger( + type: OnTriggerType, parameters: string[], placeholder: t.DeferredBlockPlaceholder|null) { if (parameters.length > 1) { - throw new Error(`"${OnTriggerType.VIEWPORT}" trigger can only have zero or one parameters`); + throw new Error(`"${type}" trigger can only have zero or one parameters`); } - return new t.ViewportDeferredTrigger(parameters[0] ?? null, sourceSpan); + if (parameters.length === 0) { + if (placeholder === null) { + throw new Error(`"${ + type}" trigger with no parameters can only be placed on an @defer that has a @placeholder block`); + } + + if (placeholder.children.length !== 1 || !(placeholder.children[0] instanceof t.Element)) { + throw new Error( + `"${type}" trigger with no parameters can only be placed on an @defer that has a ` + + `@placeholder block with exactly one root element node`); + } + } } /** Gets the index within an expression at which the trigger parameters start. */ diff --git a/packages/compiler/src/render3/view/t2_binder.ts b/packages/compiler/src/render3/view/t2_binder.ts index dee4d61df5755..7989a984edcba 100644 --- a/packages/compiler/src/render3/view/t2_binder.ts +++ b/packages/compiler/src/render3/view/t2_binder.ts @@ -797,9 +797,15 @@ export class R3BoundTarget implements BoundTar const name = trigger.reference; - // TODO(crisbeto): account for `viewport` trigger without a `reference`. if (name === null) { - return null; + const children = block.placeholder ? block.placeholder.children : null; + + // If the trigger doesn't have a reference, it is inferred as the root element node of the + // placeholder, if it only has one root node. Otherwise it's ambiguous so we don't + // attempt to resolve further. + return children !== null && children.length === 1 && children[0] instanceof Element ? + children[0] : + null; } const outsideRef = this.findEntityInScope(block, name); diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 10097c4d3a67e..5fc3fe17b15c8 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -1378,7 +1378,6 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver } // Note that we generate an implicit `on idle` if the `deferred` block has no triggers. - // TODO(crisbeto): decide if this should be baked into the `defer` instruction. // `deferOnIdle()` if (idle || (!prefetch && Object.keys(triggers).length === 0)) { this.creationInstruction( diff --git a/packages/compiler/test/render3/r3_template_transform_spec.ts b/packages/compiler/test/render3/r3_template_transform_spec.ts index 7463dc8a3e2be..a1d9868756a65 100644 --- a/packages/compiler/test/render3/r3_template_transform_spec.ts +++ b/packages/compiler/test/render3/r3_template_transform_spec.ts @@ -1002,6 +1002,24 @@ describe('R3 template transform', () => { ]); }); + it('should parse triggers with implied target elements', () => { + expectDeferred( + '@defer (on hover, interaction, viewport; prefetch on hover, interaction, viewport) {hello}' + + '@placeholder {}') + .toEqual([ + ['DeferredBlock'], + ['HoverDeferredTrigger', null], + ['InteractionDeferredTrigger', null], + ['ViewportDeferredTrigger', null], + ['HoverDeferredTrigger', null], + ['InteractionDeferredTrigger', null], + ['ViewportDeferredTrigger', null], + ['Text', 'hello'], + ['DeferredBlockPlaceholder'], + ['Element', 'implied-trigger'], + ]); + }); + describe('block validations', () => { it('should report syntax error in `when` trigger', () => { expectDeferredError('@defer (when isVisible#){hello}') @@ -1130,7 +1148,7 @@ describe('R3 template transform', () => { it('should report if `interaction` trigger has more than one parameter', () => { expectDeferredError('@defer (on interaction(a, b)) {hello}') - .toThrowError(/"interaction" trigger must have exactly one parameter/); + .toThrowError(/"interaction" trigger can only have zero or one parameters/); }); it('should report if parameters are passed to `immediate` trigger', () => { @@ -1138,9 +1156,9 @@ describe('R3 template transform', () => { .toThrowError(/"immediate" trigger cannot have parameters/); }); - it('should report if no parameters are passed to `hover` trigger', () => { - expectDeferredError('@defer (on hover) {hello}') - .toThrowError(/"hover" trigger must have exactly one parameter/); + it('should report if `hover` trigger has more than one parameter', () => { + expectDeferredError('@defer (on hover(a, b)) {hello}') + .toThrowError(/"hover" trigger can only have zero or one parameters/); }); it('should report if `viewport` trigger has more than one parameter', () => { @@ -1184,6 +1202,35 @@ describe('R3 template transform', () => { expectDeferredError('@defer {hello} @loading (after 1s; after 500ms) {loading}') .toThrowError(/@loading block can only have one "after" parameter/); }); + + it('should report if reference-based trigger has no reference and there is no placeholder block', + () => { + expectDeferredError('@defer (on viewport) {hello}') + .toThrowError( + /"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block/); + }); + + it('should report if reference-based trigger has no reference and the placeholder is empty', + () => { + expectDeferredError('@defer (on viewport) {hello} @placeholder {}') + .toThrowError( + /"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block with exactly one root element node/); + }); + + it('should report if reference-based trigger has no reference and the placeholder with text at the root', + () => { + expectDeferredError('@defer (on viewport) {hello} @placeholder {placeholder}') + .toThrowError( + /"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block with exactly one root element node/); + }); + + it('should report if reference-based trigger has no reference and the placeholder has multiple root elements', + () => { + expectDeferredError( + '@defer (on viewport) {hello} @placeholder {
}') + .toThrowError( + /"viewport" trigger with no parameters can only be placed on an @defer that has a @placeholder block with exactly one root element node/); + }); }); }); diff --git a/packages/compiler/test/render3/view/binding_spec.ts b/packages/compiler/test/render3/view/binding_spec.ts index 5ddc16bab3e4c..406fddb300e45 100644 --- a/packages/compiler/test/render3/view/binding_spec.ts +++ b/packages/compiler/test/render3/view/binding_spec.ts @@ -522,6 +522,68 @@ describe('t2 binding', () => { expect(triggerEl?.name).toBe('button'); }); + it('should identify an implicit trigger inside the placeholder block', () => { + const template = parseTemplate( + ` +
+ @defer (on viewport) {} @placeholder {} +
+ `, + '', templateOptions); + const binder = new R3TargetBinder(makeSelectorMatcher()); + const bound = binder.bind({template: template.nodes}); + const block = Array.from(bound.getDeferBlocks())[0]; + const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!); + expect(triggerEl?.name).toBe('button'); + }); + + it('should not identify an implicit trigger if the placeholder has multiple root nodes', () => { + const template = parseTemplate( + ` +
+ @defer (on viewport) {} @placeholder {
} +
+ `, + '', templateOptions); + const binder = new R3TargetBinder(makeSelectorMatcher()); + const bound = binder.bind({template: template.nodes}); + const block = Array.from(bound.getDeferBlocks())[0]; + const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!); + expect(triggerEl).toBeNull(); + }); + + it('should not identify an implicit trigger if there is no placeholder', () => { + const template = parseTemplate( + ` +
+ @defer (on viewport) {} + +
+ `, + '', templateOptions); + const binder = new R3TargetBinder(makeSelectorMatcher()); + const bound = binder.bind({template: template.nodes}); + const block = Array.from(bound.getDeferBlocks())[0]; + const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!); + expect(triggerEl).toBeNull(); + }); + + it('should not identify an implicit trigger if the placeholder has a single root text node', + () => { + const template = parseTemplate( + ` +
+ @defer (on viewport) {} @placeholder {hello} +
+ `, + '', templateOptions); + const binder = new R3TargetBinder(makeSelectorMatcher()); + const bound = binder.bind({template: template.nodes}); + const block = Array.from(bound.getDeferBlocks())[0]; + const triggerEl = bound.getDeferredTriggerTarget(block, block.triggers.viewport!); + expect(triggerEl).toBeNull(); + }); + it('should not identify a trigger inside a sibling embedded view', () => { const template = parseTemplate( ` diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index ce20a544ed5b3..ef895f6e1e6e7 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -1256,7 +1256,7 @@ describe('@defer', () => { } @loading { Nested block loading } - + } @placeholder { Root block placeholder } @@ -1431,7 +1431,7 @@ describe('@defer', () => { } @loading { Nested block loading } - + } @placeholder { Root block placeholder } @@ -1786,6 +1786,31 @@ describe('@defer', () => { expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); })); + it('should load the deferred content when an implicit trigger is clicked', fakeAsync(() => { + @Component({ + standalone: true, + template: ` + @defer (on interaction) { + Main content + } @placeholder { + + } + ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + it('should load the deferred content if a child of the trigger is clicked', fakeAsync(() => { @Component({ standalone: true, @@ -1975,6 +2000,55 @@ describe('@defer', () => { expect(loadingFnInvokedTimes).toBe(1); })); + + + it('should prefetch resources on interaction with an implicit trigger', fakeAsync(() => { + @Component({ + standalone: true, + selector: 'root-app', + template: ` + @defer (when isLoaded; prefetch on interaction) { + Main content + } @placeholder { + + } + ` + }) + class MyCmp { + // We need a `when` trigger here so that `on idle` doesn't get added automatically. + readonly isLoaded = false; + } + + let loadingFnInvokedTimes = 0; + + TestBed.configureTestingModule({ + providers: [ + { + provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, + useValue: { + intercept: () => () => { + loadingFnInvokedTimes++; + return []; + } + } + }, + ], + deferBlockBehavior: DeferBlockBehavior.Playthrough, + }); + + clearDirectiveDefs(MyCmp); + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + expect(loadingFnInvokedTimes).toBe(0); + + fixture.nativeElement.querySelector('button').click(); + fixture.detectChanges(); + flush(); + + expect(loadingFnInvokedTimes).toBe(1); + })); }); describe('hover triggers', () => { @@ -2010,6 +2084,37 @@ describe('@defer', () => { expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); })); + it('should load the deferred content with an implicit trigger element', fakeAsync(() => { + // Domino doesn't support creating custom events so we have to skip this test. + if (!isBrowser) { + return; + } + + @Component({ + standalone: true, + template: ` + @defer (on hover) { + Main content + } @placeholder { + + } + ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + button.dispatchEvent(new Event('mouseenter')); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + it('should support multiple deferred blocks with the same hover trigger', fakeAsync(() => { // Domino doesn't support creating custom events so we have to skip this test. if (!isBrowser) { @@ -2201,6 +2306,61 @@ describe('@defer', () => { expect(loadingFnInvokedTimes).toBe(1); })); + + + it('should prefetch resources when an implicit trigger is hovered', fakeAsync(() => { + // Domino doesn't support creating custom events so we have to skip this test. + if (!isBrowser) { + return; + } + + @Component({ + standalone: true, + selector: 'root-app', + template: ` + @defer (when isLoaded; prefetch on hover) { + Main content + } @placeholder { + + } + ` + }) + class MyCmp { + // We need a `when` trigger here so that `on idle` doesn't get added automatically. + readonly isLoaded = false; + } + + let loadingFnInvokedTimes = 0; + + TestBed.configureTestingModule({ + providers: [ + { + provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, + useValue: { + intercept: () => () => { + loadingFnInvokedTimes++; + return []; + } + } + }, + ], + deferBlockBehavior: DeferBlockBehavior.Playthrough, + }); + + clearDirectiveDefs(MyCmp); + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + expect(loadingFnInvokedTimes).toBe(0); + + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + button.dispatchEvent(new Event('mouseenter')); + fixture.detectChanges(); + flush(); + + expect(loadingFnInvokedTimes).toBe(1); + })); }); describe('viewport triggers', () => { @@ -2322,6 +2482,34 @@ describe('@defer', () => { expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); })); + it('should load the deferred content when an implicit trigger is in the viewport', + fakeAsync(() => { + @Component({ + standalone: true, + template: ` + @defer (on viewport) { + Main content + } @placeholder { + + } + ` + }) + class MyCmp { + } + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent.trim()).toBe('Placeholder'); + + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + MockIntersectionObserver.invokeCallbacksForElement(button, true); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent.trim()).toBe('Main content'); + })); + it('should not load the content if the trigger is not in the view yet', fakeAsync(() => { @Component({ standalone: true, @@ -2573,6 +2761,56 @@ describe('@defer', () => { fixture.detectChanges(); flush(); + expect(loadingFnInvokedTimes).toBe(1); + })); + + it('should prefetch resources when an implicit trigger comes into the viewport', + fakeAsync(() => { + @Component({ + standalone: true, + selector: 'root-app', + template: ` + @defer (when isLoaded; prefetch on viewport) { + Main content + } @placeholder { + + } + ` + }) + class MyCmp { + // We need a `when` trigger here so that `on idle` doesn't get added automatically. + readonly isLoaded = false; + } + + let loadingFnInvokedTimes = 0; + + TestBed.configureTestingModule({ + providers: [ + { + provide: ɵDEFER_BLOCK_DEPENDENCY_INTERCEPTOR, + useValue: { + intercept: () => () => { + loadingFnInvokedTimes++; + return []; + } + } + }, + ], + deferBlockBehavior: DeferBlockBehavior.Playthrough, + }); + + clearDirectiveDefs(MyCmp); + + const fixture = TestBed.createComponent(MyCmp); + fixture.detectChanges(); + + expect(loadingFnInvokedTimes).toBe(0); + + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + MockIntersectionObserver.invokeCallbacksForElement(button, true); + fixture.detectChanges(); + flush(); + expect(loadingFnInvokedTimes).toBe(1); })); });