From 98eb24cea0498382cc7cf7d7b85cd9ead5ad99ad Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:26:10 -0500 Subject: [PATCH] feat(core): Support optional timeout for idle deferred triggers Allows specifying a timeout parameter for idle-based deferred triggers, enabling more granular control over when deferred actions are executed. Closes angular#67187 --- .../GOLDEN_PARTIAL.js | 39 ++ .../r3_view_compiler_deferred/TEST_CASES.json | 15 + .../deferred_on_idle_with_timeout.ts | 14 + .../deferred_on_idle_with_timeout_template.js | 8 + packages/compiler/src/render3/r3_ast.ts | 13 +- .../src/render3/r3_deferred_triggers.ts | 21 +- .../template/pipeline/ir/src/ops/create.ts | 2 + .../src/template/pipeline/src/ingest.ts | 4 +- .../src/template/pipeline/src/phases/reify.ts | 6 +- .../render3/r3_template_transform_spec.ts | 48 +- packages/core/src/defer/idle_scheduler.ts | 121 +++-- packages/core/src/defer/idle_service.ts | 14 +- packages/core/src/defer/instructions.ts | 24 +- packages/core/test/acceptance/defer_spec.ts | 429 +++++++++++++++++- .../test/incremental_hydration_spec.ts | 108 ++++- 15 files changed, 800 insertions(+), 66 deletions(-) create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_on_idle_with_timeout.ts create mode 100644 packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_on_idle_with_timeout_template.js 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 245ccf0de80..ea4e4912b3b 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 @@ -1385,3 +1385,42 @@ export declare class MyApp { static ɵcmp: i0.ɵɵComponentDeclaration; } +/**************************************************************************************************** + * PARTIAL FILE: deferred_on_idle_with_timeout.js + ****************************************************************************************************/ +import { Component } from '@angular/core'; +import * as i0 from "@angular/core"; +export class MyApp { + message = 'hello'; + static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component }); + static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, isStandalone: true, selector: "ng-component", ngImport: i0, template: ` + @defer (on idle(500ms)) { + {{message}} + } @placeholder { +

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 idle(500ms)) { + {{message}} + } @placeholder { +

Placeholder

+ } + `, + }] + }] }); + +/**************************************************************************************************** + * PARTIAL FILE: deferred_on_idle_with_timeout.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 7c64e7f3e8e..a585560d08d 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 @@ -374,6 +374,21 @@ "failureMessage": "Incorrect template" } ] + }, + { + "description": "should generate a defer block with an `on idle` trigger that has a timeout", + "inputFiles": ["deferred_on_idle_with_timeout.ts"], + "expectations": [ + { + "files": [ + { + "expected": "deferred_on_idle_with_timeout_template.js", + "generated": "deferred_on_idle_with_timeout.js" + } + ], + "failureMessage": "Incorrect template" + } + ] } ] } diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_on_idle_with_timeout.ts b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_on_idle_with_timeout.ts new file mode 100644 index 00000000000..421143086e4 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_on_idle_with_timeout.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; + +@Component({ + template: ` + @defer (on idle(500ms)) { + {{message}} + } @placeholder { +

Placeholder

+ } + `, +}) +export class MyApp { + message = 'hello'; +} diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_on_idle_with_timeout_template.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_on_idle_with_timeout_template.js new file mode 100644 index 00000000000..8e1f65b3467 --- /dev/null +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_deferred/deferred_on_idle_with_timeout_template.js @@ -0,0 +1,8 @@ +function MyApp_Template(rf, ctx) { + if (rf & 1) { + $r3$.ɵɵdomTemplate(0, MyApp_Defer_0_Template, 1, 1)(1, MyApp_DeferPlaceholder_1_Template, 2, 0); + $r3$.ɵɵdefer(2, 0, null, null, 1); + $r3$.ɵɵdeferOnIdle(500); + } + … +} diff --git a/packages/compiler/src/render3/r3_ast.ts b/packages/compiler/src/render3/r3_ast.ts index d570bb13950..332f00e5028 100644 --- a/packages/compiler/src/render3/r3_ast.ts +++ b/packages/compiler/src/render3/r3_ast.ts @@ -208,7 +208,18 @@ export class BoundDeferredTrigger extends DeferredTrigger { export class NeverDeferredTrigger extends DeferredTrigger {} -export class IdleDeferredTrigger extends DeferredTrigger {} +export class IdleDeferredTrigger extends DeferredTrigger { + constructor( + nameSpan: ParseSourceSpan, + sourceSpan: ParseSourceSpan, + prefetchSpan: ParseSourceSpan | null, + onSourceSpan: ParseSourceSpan | null, + hydrateSpan: ParseSourceSpan | null, + public timeout: number | null, + ) { + super(nameSpan, sourceSpan, prefetchSpan, onSourceSpan, hydrateSpan); + } +} export class ImmediateDeferredTrigger extends DeferredTrigger {} diff --git a/packages/compiler/src/render3/r3_deferred_triggers.ts b/packages/compiler/src/render3/r3_deferred_triggers.ts index 834ceec0b7c..f790f821897 100644 --- a/packages/compiler/src/render3/r3_deferred_triggers.ts +++ b/packages/compiler/src/render3/r3_deferred_triggers.ts @@ -488,11 +488,26 @@ function createIdleTrigger( onSourceSpan: ParseSourceSpan | null, hydrateSpan: ParseSourceSpan | null, ): t.IdleDeferredTrigger { - if (parameters.length > 0) { - throw new Error(`"${OnTriggerType.IDLE}" trigger cannot have parameters`); + if (parameters.length > 1) { + throw new Error(`"${OnTriggerType.IDLE}" trigger can only have zero or one parameters`); } - return new t.IdleDeferredTrigger(nameSpan, sourceSpan, prefetchSpan, onSourceSpan, hydrateSpan); + let timeout: number | null = null; + if (parameters[0]) { + timeout = parseDeferredTime(parameters[0].expression); + if (timeout === null) { + throw new Error(`Could not parse time value of trigger "${OnTriggerType.IDLE}"`); + } + } + + return new t.IdleDeferredTrigger( + nameSpan, + sourceSpan, + prefetchSpan, + onSourceSpan, + hydrateSpan, + timeout, + ); } function createTimerTrigger( diff --git a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts index ac6ab218f8a..adc91530357 100644 --- a/packages/compiler/src/template/pipeline/ir/src/ops/create.ts +++ b/packages/compiler/src/template/pipeline/ir/src/ops/create.ts @@ -1382,6 +1382,8 @@ interface DeferTriggerWithTargetBase extends DeferTriggerBase { interface DeferIdleTrigger extends DeferTriggerBase { kind: DeferTriggerKind.Idle; + + timeout: number | null; } interface DeferImmediateTrigger extends DeferTriggerBase { diff --git a/packages/compiler/src/template/pipeline/src/ingest.ts b/packages/compiler/src/template/pipeline/src/ingest.ts index 0b86eb0dd39..733fba84778 100644 --- a/packages/compiler/src/template/pipeline/src/ingest.ts +++ b/packages/compiler/src/template/pipeline/src/ingest.ts @@ -749,7 +749,7 @@ function ingestDeferBlock(unit: ViewCompilationUnit, deferBlock: t.DeferredBlock deferOnOps.push( ir.createDeferOnOp( deferXref, - {kind: ir.DeferTriggerKind.Idle}, + {kind: ir.DeferTriggerKind.Idle, timeout: null}, ir.DeferOpModifierKind.NONE, null!, ), @@ -778,7 +778,7 @@ function ingestDeferTriggers( if (triggers.idle !== undefined) { const deferOnOp = ir.createDeferOnOp( deferXref, - {kind: ir.DeferTriggerKind.Idle}, + {kind: ir.DeferTriggerKind.Idle, timeout: triggers.idle.timeout ?? null}, modifier, triggers.idle.sourceSpan, ); diff --git a/packages/compiler/src/template/pipeline/src/phases/reify.ts b/packages/compiler/src/template/pipeline/src/phases/reify.ts index ca89a722727..ccc71bfde77 100644 --- a/packages/compiler/src/template/pipeline/src/phases/reify.ts +++ b/packages/compiler/src/template/pipeline/src/phases/reify.ts @@ -383,9 +383,13 @@ function reifyCreateOperations(unit: CompilationUnit, ops: ir.OpList { } else if (trigger instanceof t.HoverDeferredTrigger) { this.result.push(['HoverDeferredTrigger', trigger.reference]); } else if (trigger instanceof t.IdleDeferredTrigger) { - this.result.push(['IdleDeferredTrigger']); + if (trigger.timeout != null) { + this.result.push(['IdleDeferredTrigger', trigger.timeout]); + } else { + this.result.push(['IdleDeferredTrigger']); + } } else if (trigger instanceof t.TimerDeferredTrigger) { this.result.push(['TimerDeferredTrigger', trigger.delay]); } else if (trigger instanceof t.InteractionDeferredTrigger) { @@ -1202,6 +1206,28 @@ describe('R3 template transform', () => { ]); }); + it('should parse prefetch `on idle(100)` trigger and preserve timeout', () => { + const html = '@defer (on idle; prefetch on idle(100)){hello}'; + + expectFromHtml(html).toEqual([ + ['DeferredBlock'], + ['IdleDeferredTrigger'], + ['IdleDeferredTrigger', 100], + ['Text', 'hello'], + ]); + }); + + it('should parse hydrate `on idle(100)` trigger and preserve timeout', () => { + const html = '@defer (on idle; hydrate on idle(100)){hello}'; + + expectFromHtml(html).toEqual([ + ['DeferredBlock'], + ['IdleDeferredTrigger', 100], + ['IdleDeferredTrigger'], + ['Text', 'hello'], + ]); + }); + it('should allow arbitrary number of spaces after the `prefetch` keyword', () => { const html = '@defer (on idle; prefetch on viewport(button), hover(button); prefetch when shouldPrefetch()){hello}'; @@ -1494,9 +1520,23 @@ describe('R3 template transform', () => { expect(() => parse('@defer (on viewport[]) {hello}')).toThrowError(/Unexpected token/); }); - it('should report if parameters are passed to `idle` trigger', () => { - expect(() => parse('@defer (on idle(1)) {hello}')).toThrowError( - /"idle" trigger cannot have parameters/, + it('should allow optional parameter on `idle` trigger and parse timeout', () => { + expectFromHtml('@defer (on idle(1)) {hello}').toEqual([ + ['DeferredBlock'], + ['IdleDeferredTrigger', 1], + ['Text', 'hello'], + ]); + }); + + it('should report if `idle` trigger value cannot be parsed', () => { + expect(() => parse('@defer (on idle(123abc)) {hello}')).toThrowError( + /Could not parse time value of trigger "idle"/, + ); + }); + + it('should report if `idle` trigger has more than one parameter', () => { + expect(() => parse('@defer (on idle(a, b)) {hello}')).toThrowError( + /"idle" trigger can only have zero or one parameters/, ); }); diff --git a/packages/core/src/defer/idle_scheduler.ts b/packages/core/src/defer/idle_scheduler.ts index ba410af3b23..cb4303a4345 100644 --- a/packages/core/src/defer/idle_scheduler.ts +++ b/packages/core/src/defer/idle_scheduler.ts @@ -17,87 +17,135 @@ import {ApplicationRef} from '../application/application_ref'; * * @param callback A function to be invoked when a browser becomes idle. * @param injector injector for the app + * @param options Optional options passed to `requestIdleCallback`. */ -export function onIdle(callback: VoidFunction, injector: Injector) { +export function onIdle(callback: VoidFunction, injector: Injector, options?: IdleRequestOptions) { const scheduler = injector.get(IdleScheduler); const cleanupFn = () => scheduler.remove(callback); - scheduler.add(callback); + + scheduler.add(callback, options); return cleanupFn; } +export function onIdleWrapper(options?: IdleRequestOptions) { + return (callback: VoidFunction, injector: Injector) => onIdle(callback, injector, options); +} + +/** + * A bucket groups callbacks sharing the same idle request options into a single + * `requestIdleCallback` invocation. + */ +interface IdleBucket { + idleId: number | null; + readonly queue: Set; +} + /** * 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). + * + * Callbacks are grouped into buckets by their serialized options key. Each bucket is + * scheduled independently with its own `requestIdleCallback` call, so different + * options never interfere with each other. Callbacks that share the same + * options (including no options) are batched together. */ export class IdleScheduler implements OnDestroy { - // Currently scheduled idle callback id. - idleId: number | null = null; + // Serialize options rather than using the object reference to ensure + // that different IdleRequestOptions references with the same values + // are batched into the same bucket. + private readonly buckets = new Map(); - // Queue of callbacks to be invoked next. - queue = new Set(); - applicationRef = inject(ApplicationRef); + // Lookup from callback to its bucket key. + private readonly callbackBucket = new Map(); + private readonly applicationRef = inject(ApplicationRef); - ngZone = inject(NgZone); + private readonly ngZone = inject(NgZone); private readonly idleService = inject(IDLE_SERVICE); - add(callback: VoidFunction) { - this.queue.add(callback); - this.scheduleIdleCallback(); + add(callback: VoidFunction, options?: IdleRequestOptions) { + const key = getIdleRequestKey(options); + this.callbackBucket.set(callback, key); + + let bucket = this.buckets.get(key); + if (bucket == null) { + bucket = {idleId: null, queue: new Set()}; + this.buckets.set(key, bucket); + } + bucket.queue.add(callback); + this.scheduleBucket(bucket, options); } remove(callback: VoidFunction) { - this.queue.delete(callback); + const key = this.callbackBucket.get(callback); + if (key === undefined) return; - // If the last callback was removed and there is a pending + this.callbackBucket.delete(callback); + + const bucket = this.buckets.get(key); + if (!bucket) return; + + bucket.queue.delete(callback); + + // If the last callback in this bucket was removed, cancel the // idle callback - cancel it. - if (this.queue.size === 0) { - this.cancelIdleCallback(); + if (bucket.queue.size === 0) { + this.cancelBucket(bucket); + this.buckets.delete(key); } } - private scheduleIdleCallback() { - if (this.idleId !== null) { + private scheduleBucket(bucket: IdleBucket, options?: IdleRequestOptions) { + if (bucket.idleId !== null) { return; } + const key = getIdleRequestKey(options); const callback = (deadline?: IdleDeadline) => { - this.cancelIdleCallback(); + this.cancelBucket(bucket); - for (const callbackFn of this.queue) { - callbackFn(); + for (const cb of bucket.queue) { + cb(); // _tick here is an optimized change detection check and is safe to call here. // We also account for the time it takes to run change detection // for the newly-created view as a part of the same idle callback. this.applicationRef._tick(); - this.queue.delete(callbackFn); + bucket.queue.delete(cb); + this.callbackBucket.delete(cb); if (deadline && deadline.timeRemaining() === 0 && !deadline.didTimeout) { break; } } - if (this.queue.size > 0) { - this.scheduleIdleCallback(); + if (bucket.queue.size > 0) { + this.scheduleBucket(bucket, options); + } else { + this.buckets.delete(key); } }; + // Ensure that the callback runs in the NgZone since // the `requestIdleCallback` is not currently patched by Zone.js. - this.idleId = this.idleService.requestOnIdle((deadline) => - this.ngZone.run(() => callback(deadline)), - ) as number; + bucket.idleId = this.idleService.requestOnIdle( + (deadline) => this.ngZone.run(() => callback(deadline)), + options, + ); } - private cancelIdleCallback() { - if (this.idleId !== null) { - this.idleService.cancelOnIdle(this.idleId); - this.idleId = null; + private cancelBucket(bucket: IdleBucket) { + if (bucket.idleId !== null) { + this.idleService.cancelOnIdle(bucket.idleId); + bucket.idleId = null; } } ngOnDestroy() { - this.cancelIdleCallback(); - this.queue.clear(); + for (const bucket of this.buckets.values()) { + this.cancelBucket(bucket); + } + this.buckets.clear(); + this.callbackBucket.clear(); } /** @nocollapse */ @@ -107,3 +155,12 @@ export class IdleScheduler implements OnDestroy { factory: () => new IdleScheduler(), }); } + +/** Generates a string that can be used to find identical idle request option objects. */ +function getIdleRequestKey(options?: IdleRequestOptions): string { + if (!options || options.timeout == null) { + return ''; + } + + return `${options.timeout}`; +} diff --git a/packages/core/src/defer/idle_service.ts b/packages/core/src/defer/idle_service.ts index 24217896147..351b30163e5 100644 --- a/packages/core/src/defer/idle_service.ts +++ b/packages/core/src/defer/idle_service.ts @@ -18,13 +18,13 @@ import {makeEnvironmentProviders} from '../di/provider_collection'; * 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 = (): (( - callback: (deadline?: IdleDeadline) => void, - options?: IdleRequestOptions, -) => number) => - typeof requestIdleCallback !== 'undefined' - ? requestIdleCallback.bind(globalThis) - : (callback) => setTimeout(callback) as unknown as number; +type RequestIdle = typeof requestIdleCallback; + +const _requestIdleCallback = () => + (typeof requestIdleCallback !== 'undefined' + ? requestIdleCallback + : (cb: VoidFunction) => setTimeout(cb) as unknown as number + ).bind(globalThis) as RequestIdle; const _cancelIdleCallback = () => (typeof requestIdleCallback !== 'undefined' ? cancelIdleCallback : clearTimeout).bind(globalThis); diff --git a/packages/core/src/defer/instructions.ts b/packages/core/src/defer/instructions.ts index f68636e59d0..d31e23f540d 100644 --- a/packages/core/src/defer/instructions.ts +++ b/packages/core/src/defer/instructions.ts @@ -29,7 +29,7 @@ import {performanceMarkFeature} from '../util/performance'; import {invokeAllTriggerCleanupFns, storeTriggerCleanupFn} from './cleanup'; import {onViewportWrapper, registerDomTrigger} from './dom_triggers'; import {onHover, onInteraction} from '../../primitives/defer/src/triggers'; -import {onIdle} from './idle_scheduler'; +import {onIdleWrapper} from './idle_scheduler'; import { DEFER_BLOCK_STATE, DeferBlockInternalState, @@ -366,46 +366,48 @@ export function ɵɵdeferHydrateNever() { * Sets up logic to handle the `on idle` deferred trigger. * @codeGenApi */ -export function ɵɵdeferOnIdle() { +export function ɵɵdeferOnIdle(timeout?: number) { const lView = getLView(); const tNode = getCurrentTNode()!; - if (ngDevMode) { - trackTriggerForDebugging(lView[TVIEW], tNode, 'on idle'); + const expression = timeout ? `on idle(${timeout})` : 'on idle'; + trackTriggerForDebugging(lView[TVIEW], tNode, expression); } if (!shouldAttachTrigger(TriggerType.Regular, lView, tNode)) return; - scheduleDelayedTrigger(onIdle); + scheduleDelayedTrigger(onIdleWrapper({timeout})); } /** * Sets up logic to handle the `prefetch on idle` deferred trigger. * @codeGenApi */ -export function ɵɵdeferPrefetchOnIdle() { +export function ɵɵdeferPrefetchOnIdle(timeout?: number) { const lView = getLView(); const tNode = getCurrentTNode()!; if (ngDevMode) { - trackTriggerForDebugging(lView[TVIEW], tNode, 'prefetch on idle'); + const expression = timeout ? `prefetch on idle(${timeout})` : 'prefetch on idle'; + trackTriggerForDebugging(lView[TVIEW], tNode, expression); } if (!shouldAttachTrigger(TriggerType.Prefetch, lView, tNode)) return; - scheduleDelayedPrefetching(onIdle); + scheduleDelayedPrefetching(onIdleWrapper({timeout})); } /** * Sets up logic to handle the `on idle` deferred trigger. * @codeGenApi */ -export function ɵɵdeferHydrateOnIdle() { +export function ɵɵdeferHydrateOnIdle(timeout?: number) { const lView = getLView(); const tNode = getCurrentTNode()!; if (ngDevMode) { - trackTriggerForDebugging(lView[TVIEW], tNode, 'hydrate on idle'); + const expression = timeout ? `hydrate on idle(${timeout})` : 'hydrate on idle'; + trackTriggerForDebugging(lView[TVIEW], tNode, expression); } if (!shouldAttachTrigger(TriggerType.Hydrate, lView, tNode)) return; @@ -417,7 +419,7 @@ export function ɵɵdeferHydrateOnIdle() { // We are on the server and SSR for defer blocks is enabled. triggerDeferBlock(TriggerType.Hydrate, lView, tNode); } else { - scheduleDelayedHydrating(onIdle, lView, tNode); + scheduleDelayedHydrating(onIdleWrapper({timeout}), lView, tNode); } } diff --git a/packages/core/test/acceptance/defer_spec.ts b/packages/core/test/acceptance/defer_spec.ts index 41d4b120786..119ebb13a37 100644 --- a/packages/core/test/acceptance/defer_spec.ts +++ b/packages/core/test/acceptance/defer_spec.ts @@ -1578,17 +1578,31 @@ describe('@defer', () => { * Sets up interceptors for when an idle callback is requested * and when it's cancelled. This is needed to keep track of calls * made to `requestIdleCallback` and `cancelIdleCallback` APIs. + * + * The mock enforces the per-bucket invariant: for a given timeout + * value, at most ONE `requestIdleCallback` should be active at a + * time. Different timeout values (buckets) may run concurrently. */ let id = 0; let idleCallbacksRequested: number; let idleCallbacksInvoked: number; let idleCallbacksCancelled: number; const onIdleCallbackQueue: Map = new Map(); + let capturedOptions: IdleRequestOptions | undefined; + + // Tracks active idle callback counts per serialized options key, enforcing + // that each bucket never has more than one concurrent request. + const activePerTimeout = new Map(); + // Reverse lookup: callback id → options key (for cleanup in cancel). + const idToTimeout = new Map(); function resetCounters() { idleCallbacksRequested = 0; idleCallbacksInvoked = 0; idleCallbacksCancelled = 0; + capturedOptions = undefined; + activePerTimeout.clear(); + idToTimeout.clear(); } resetCounters(); @@ -1602,15 +1616,38 @@ describe('@defer', () => { callback: IdleRequestCallback, options?: IdleRequestOptions, ): number => { + capturedOptions = options; onIdleCallbackQueue.set(id, callback); - expect(idleCallbacksRequested).toBe(0); expect(NgZone.isInAngularZone()).toBe(true); + + // Enforce per-bucket invariant: a given options key must not + // already have an active requestIdleCallback. + const timeoutKey = options?.timeout != null ? `${options.timeout}` : ''; + const activeCount = activePerTimeout.get(timeoutKey) ?? 0; + expect(activeCount) + .withContext( + `Expected 0 active idle callbacks for key='${timeoutKey}', ` + + `but found ${activeCount}. Each options bucket should have at most one.`, + ) + .toBe(0); + activePerTimeout.set(timeoutKey, activeCount + 1); + idToTimeout.set(id, timeoutKey); + idleCallbacksRequested++; return id++; }; const mockCancelIdleCallback = (id: number) => { onIdleCallbackQueue.delete(id); + + // Decrement per-bucket active count. + const timeoutKey = idToTimeout.get(id); + if (timeoutKey !== undefined) { + const count = activePerTimeout.get(timeoutKey) ?? 0; + activePerTimeout.set(timeoutKey, Math.max(0, count - 1)); + idToTimeout.delete(id); + } + idleCallbacksRequested--; idleCallbacksCancelled++; }; @@ -1899,7 +1936,10 @@ describe('@defer', () => { class CustomIdleService implements IdleService { private callbacks: Array<((deadline?: IdleDeadline) => void) | undefined> = []; - requestOnIdle(callback: (deadline?: IdleDeadline) => void): number { + requestOnIdle( + callback: (deadline?: IdleDeadline) => void, + options?: IdleRequestOptions, + ): number { return this.callbacks.push(callback) - 1; } @@ -2038,6 +2078,180 @@ describe('@defer', () => { expect(loadingFnInvokedTimes).toBe(1); }); + it('should trigger prefetching based on `on idle()` with timeout only once', async () => { + @Component({ + selector: 'nested-cmp', + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; + } + + @Component({ + selector: 'root-app', + imports: [NestedCmp], + template: ` + @for (item of items; track item) { + @defer (when deferCond; prefetch on idle(1500)) { + + } @placeholder { + Placeholder with timeout \`{{ item }}\` + } + } + `, + }) + class RootCmp { + deferCond = false; + items = ['x', 'y', 'z']; + } + + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [dynamicImportOf(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 with timeout `x`'); + expect(fixture.nativeElement.outerHTML).toContain('Placeholder with timeout `y`'); + expect(fixture.nativeElement.outerHTML).toContain('Placeholder with timeout `z`'); + + // Verify that requestIdleCallback was called with timeout for prefetch + expect(idleCallbacksRequested).toBe(1); + expect(capturedOptions).toBeDefined(); + expect(capturedOptions!.timeout).toBe(1500); + + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); + + triggerIdleCallbacks(); + await allPendingDynamicImports(); + fixture.detectChanges(); + + // Expect that the loading resources function was invoked once for prefetch. + expect(loadingFnInvokedTimes).toBe(1); + + // Expect that placeholder content is still rendered after prefetch. + expect(fixture.nativeElement.outerHTML).toContain('Placeholder with timeout `x`'); + + // Trigger main content. + fixture.componentInstance.deferCond = true; + fixture.detectChanges(); + + await allPendingDynamicImports(); + fixture.detectChanges(); + + // Verify primary blocks content with prefetched resources. + expect(fixture.nativeElement.outerHTML).toContain( + 'Rendering primary for `x` with timeout block', + ); + expect(fixture.nativeElement.outerHTML).toContain( + 'Rendering primary for `y` with timeout block', + ); + expect(fixture.nativeElement.outerHTML).toContain( + 'Rendering primary for `z` with timeout block', + ); + + // Expect that the loading resources function was not invoked again (counter remains 1). + expect(loadingFnInvokedTimes).toBe(1); + }); + + it('should support mixed prefetch triggers with and without timeout', async () => { + @Component({ + selector: 'nested-cmp', + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; + } + + @Component({ + selector: 'root-app', + imports: [NestedCmp], + template: ` + @defer (when loadFirst; prefetch on idle) { + + } @placeholder { + No Timeout Placeholder + } + + @defer (when loadSecond; prefetch on idle(3000)) { + + } @placeholder { + With Timeout Placeholder + } + `, + }) + class RootCmp { + loadFirst = false; + loadSecond = false; + } + + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [dynamicImportOf(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('No Timeout Placeholder'); + expect(fixture.nativeElement.outerHTML).toContain('With Timeout Placeholder'); + + // Verify that idle callbacks were requested (one bucket per distinct timeout) + expect(idleCallbacksRequested).toBe(2); + + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); + + triggerIdleCallbacks(); + await allPendingDynamicImports(); + fixture.detectChanges(); + + // Expect that invoked onIdle trigger for prefetching both blocks + expect(loadingFnInvokedTimes).toBe(2); + + // Both placeholders should still be visible after prefetch + expect(fixture.nativeElement.outerHTML).toContain('No Timeout Placeholder'); + expect(fixture.nativeElement.outerHTML).toContain('With Timeout Placeholder'); + + // Trigger first block + fixture.componentInstance.loadFirst = true; + fixture.detectChanges(); + + await allPendingDynamicImports(); + fixture.detectChanges(); + + // Verify first block is rendered + expect(fixture.nativeElement.outerHTML).toContain( + 'Rendering no-timeout block.', + ); + expect(fixture.nativeElement.outerHTML).toContain('With Timeout Placeholder'); + }); + it('should trigger fetching based on `on idle` only once', async () => { @Component({ selector: 'nested-cmp', @@ -2106,6 +2320,85 @@ describe('@defer', () => { expect(loadingFnInvokedTimes).toBe(1); }); + it('should support `prefetch on idle(3000)` condition with timeout', async () => { + @Component({ + selector: 'nested-cmp', + template: 'Rendering {{ block }} block.', + }) + class NestedCmp { + @Input() block!: string; + } + + @Component({ + selector: 'root-app', + imports: [NestedCmp], + template: ` + @defer (when deferCond; prefetch on idle(3000)) { + + } @placeholder { + Placeholder for prefetch idle timeout test + } + `, + }) + class RootCmp { + deferCond = false; + } + + let loadingFnInvokedTimes = 0; + const deferDepsInterceptor = { + intercept() { + return () => { + loadingFnInvokedTimes++; + return [dynamicImportOf(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 for prefetch idle timeout test', + ); + + // Make sure loading function is not yet invoked. + expect(loadingFnInvokedTimes).toBe(0); + + triggerIdleCallbacks(); + await allPendingDynamicImports(); + fixture.detectChanges(); + + // Expect that the loading resources function was invoked once (prefetch). + expect(loadingFnInvokedTimes).toBe(1); + + // Expect that placeholder content is still rendered after prefetch. + expect(fixture.nativeElement.outerHTML).toContain( + 'Placeholder for prefetch idle timeout test', + ); + + // Trigger main content. + fixture.componentInstance.deferCond = true; + fixture.detectChanges(); + + await allPendingDynamicImports(); + fixture.detectChanges(); + + // Verify primary block content with prefetched resources. + const primaryBlockHTML = fixture.nativeElement.outerHTML; + expect(primaryBlockHTML).toContain( + 'Rendering prefetched-with-timeout block.', + ); + + // Expect that the loading resources function was not invoked again (counter remains 1). + expect(loadingFnInvokedTimes).toBe(1); + }); + it('should support `prefetch on immediate` condition', async () => { @Component({ selector: 'nested-cmp', @@ -4538,8 +4831,11 @@ describe('IdleScheduler', () => { class CustomIdleService implements IdleService { requestOnIdleSpy = jasmine.createSpy('requestOnIdleFn'); - requestOnIdle(callback: (deadline?: IdleDeadline) => void): number { - return this.requestOnIdleSpy(callback); + requestOnIdle( + callback: (deadline?: IdleDeadline) => void, + options?: IdleRequestOptions, + ): number { + return this.requestOnIdleSpy(callback, options); } cancelOnIdle(id: number): void {} @@ -4702,4 +4998,129 @@ describe('IdleScheduler', () => { capturedCb!(undefined); expect(cb1).toHaveBeenCalledTimes(1); }); + + it('should pass timeout option to idleService.requestOnIdle', () => { + let capturedOptions: any = undefined; + + customIdleService.requestOnIdleSpy.and.callFake((cb: any, options: any) => { + capturedOptions = options; + return 1; + }); + + scheduler.add(jasmine.createSpy('cb'), {timeout: 500}); + + expect(customIdleService.requestOnIdleSpy).toHaveBeenCalledTimes(1); + expect(capturedOptions).toEqual({timeout: 500}); + }); + + it('should not pass timeout option when timeout is not specified', () => { + let capturedOptions: any = 'NOT_CALLED'; + + customIdleService.requestOnIdleSpy.and.callFake((cb: any, options: any) => { + capturedOptions = options; + return 1; + }); + + scheduler.add(jasmine.createSpy('cb')); + + expect(customIdleService.requestOnIdleSpy).toHaveBeenCalledTimes(1); + expect(capturedOptions).toBeUndefined(); + }); + + it('should create independent buckets for different timeouts', () => { + let capturedCbs: Array<(deadline: any) => void> = []; + let capturedOptions: any[] = []; + let ricCount = 0; + + customIdleService.requestOnIdleSpy.and.callFake((cb: any, options: any) => { + ricCount++; + capturedCbs.push(cb); + capturedOptions.push(options); + return 100 + ricCount; + }); + + const cb1 = jasmine.createSpy('cb1'); + const cb2 = jasmine.createSpy('cb2'); + + // First callback with 1000ms timeout + scheduler.add(cb1, {timeout: 1000}); + expect(ricCount).toBe(1); + expect(capturedOptions[0]).toEqual({timeout: 1000}); + + // Second callback with 200ms timeout → separate bucket, separate requestIdleCallback + scheduler.add(cb2, {timeout: 200}); + expect(ricCount).toBe(2); + expect(capturedOptions[1]).toEqual({timeout: 200}); + + // Fire the 200ms bucket first (browser would call this sooner) + capturedCbs[1]({didTimeout: true, timeRemaining: () => 0}); + expect(cb2).toHaveBeenCalledTimes(1); + expect(cb1).toHaveBeenCalledTimes(0); // Not in this bucket + + // Fire the 1000ms bucket + capturedCbs[0]({didTimeout: true, timeRemaining: () => 0}); + expect(cb1).toHaveBeenCalledTimes(1); + }); + + it('should batch callbacks with the same timeout into one bucket', () => { + let capturedCb: ((deadline: any) => void) | null = null; + let capturedOptions: any[] = []; + let ricCount = 0; + + customIdleService.requestOnIdleSpy.and.callFake((cb: any, options: any) => { + ricCount++; + capturedCb = cb; + capturedOptions.push(options); + return 100 + ricCount; + }); + + const cb1 = jasmine.createSpy('cb1'); + const cb2 = jasmine.createSpy('cb2'); + + // Both callbacks with the same 500ms timeout → same bucket + scheduler.add(cb1, {timeout: 500}); + scheduler.add(cb2, {timeout: 500}); + expect(ricCount).toBe(1); // Only one requestIdleCallback + expect(capturedOptions[0]).toEqual({timeout: 500}); + + // Fire the bucket — both should run + capturedCb!({didTimeout: true, timeRemaining: () => 0}); + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + }); + + it('should keep no-timeout bucket independent from timeout buckets', () => { + let capturedCbs: Array<(deadline: any) => void> = []; + let capturedOptions: any[] = []; + let ricCount = 0; + + customIdleService.requestOnIdleSpy.and.callFake((cb: any, options: any) => { + ricCount++; + capturedCbs.push(cb); + capturedOptions.push(options); + return 100 + ricCount; + }); + + const cb1 = jasmine.createSpy('cb1'); + const cb2 = jasmine.createSpy('cb2'); + + // No timeout + scheduler.add(cb1); + expect(ricCount).toBe(1); + expect(capturedOptions[0]).toBeUndefined(); + + // With 300ms timeout → separate bucket + scheduler.add(cb2, {timeout: 300}); + expect(ricCount).toBe(2); + expect(capturedOptions[1]).toEqual({timeout: 300}); + + // Fire the 300ms bucket + capturedCbs[1]({didTimeout: true, timeRemaining: () => 0}); + expect(cb2).toHaveBeenCalledTimes(1); + expect(cb1).toHaveBeenCalledTimes(0); + + // Fire the no-timeout bucket + capturedCbs[0]({didTimeout: false, timeRemaining: () => 10}); + expect(cb1).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/platform-server/test/incremental_hydration_spec.ts b/packages/platform-server/test/incremental_hydration_spec.ts index 1e2a6b0d7b7..166184b6cb8 100644 --- a/packages/platform-server/test/incremental_hydration_spec.ts +++ b/packages/platform-server/test/incremental_hydration_spec.ts @@ -1384,6 +1384,10 @@ describe('platform-server partial hydration integration', () => { * Sets up interceptors for when an idle callback is requested * and when it's cancelled. This is needed to keep track of calls * made to `requestIdleCallback` and `cancelIdleCallback` APIs. + * + * The mock enforces the per-bucket invariant: for a given timeout + * value, at most ONE `requestIdleCallback` should be active at a + * time */ let id = 0; let idleCallbacksRequested: number; @@ -1391,10 +1395,16 @@ describe('platform-server partial hydration integration', () => { let idleCallbacksCancelled: number; const onIdleCallbackQueue: Map = new Map(); + // Tracks active idle callback counts per serialized options key. + const activePerTimeout = new Map(); + const idToTimeout = new Map(); + function resetCounters() { idleCallbacksRequested = 0; idleCallbacksInvoked = 0; idleCallbacksCancelled = 0; + activePerTimeout.clear(); + idToTimeout.clear(); } resetCounters(); @@ -1409,13 +1419,35 @@ describe('platform-server partial hydration integration', () => { options?: IdleRequestOptions, ): number => { onIdleCallbackQueue.set(id, callback); - expect(idleCallbacksRequested).toBe(0); + + // Enforce per-bucket invariant: a given options key must not + // already have an active requestIdleCallback. + const optionsKey = options?.timeout != null ? `${options.timeout}` : ''; + const activeCount = activePerTimeout.get(optionsKey) ?? 0; + expect(activeCount) + .withContext( + `Expected 0 active idle callbacks for key='${optionsKey}', ` + + `but found ${activeCount}. Each options bucket should have at most one.`, + ) + .toBe(0); + activePerTimeout.set(optionsKey, activeCount + 1); + idToTimeout.set(id, optionsKey); + idleCallbacksRequested++; return id++; }; const mockCancelIdleCallback = (id: number) => { onIdleCallbackQueue.delete(id); + + // Decrement per-bucket active count. + const optionsKey = idToTimeout.get(id); + if (optionsKey !== undefined) { + const count = activePerTimeout.get(optionsKey) ?? 0; + activePerTimeout.set(optionsKey, Math.max(0, count - 1)); + idToTimeout.delete(id); + } + idleCallbacksRequested--; idleCallbacksCancelled++; }; @@ -1512,6 +1544,80 @@ describe('platform-server partial hydration integration', () => { expect(appHostNode.outerHTML).toContain('end'); }); + + it('idle with timeout', async () => { + @Component({ + selector: 'app', + template: ` +
+ @defer (hydrate on idle(2500)) { +
+ defer block rendered with timeout! + {{ value() }} +
+ } @placeholder { + Outer block placeholder + } +
+ `, + }) + class SimpleComponent { + value = signal('start'); + fnA() {} + fnB() { + this.value.set('end'); + } + } + + const appId = 'custom-app-id'; + const providers = [{provide: APP_ID, useValue: appId}]; + const hydrationFeatures = () => [withIncrementalHydration()]; + + const html = await ssr(SimpleComponent, {envProviders: providers, hydrationFeatures}); + const ssrContents = getAppContents(html); + + //
uses "eager" `custom-app-id` namespace. + expect(ssrContents).toContain('
start'); + + const testElement = doc.getElementById('test')!; + const clickEvent2 = new CustomEvent('click'); + testElement.dispatchEvent(clickEvent2); + + appRef.tick(); + + expect(appHostNode.outerHTML).toContain('end'); + }); }); describe('timer', () => {