Which @angular/* package(s) are the source of the bug?
core
Is this a regression?
No
Description
When testing a component that contains @defer (on viewport) with TestBed.configureTestingModule({ deferBlockBehavior: DeferBlockBehavior.Manual }), Angular still attempts to register an IntersectionObserver against the placeholder element via an afterRender hook. In environments where IntersectionObserver is not defined (e.g. Jest + JSDOM, which is the most common Angular unit-test setup), this throws ReferenceError: IntersectionObserver is not defined.
The error is swallowed by Angular's ErrorHandler so tests still pass, but every component creation logs a full stack trace to stderr, polluting test output (e.g. 2 errors per defer block × number of beforeEach runs).
This contradicts what the official testing guide implies. https://angular.dev/guide/templates/defer#testing-defer-blocks documents the manual API (fixture.getDeferBlocks() + block.render(DeferBlockState.Complete)) without mentioning that callers must polyfill IntersectionObserver to use it in JSDOM. The pattern only works cleanly today because users either (a) have a polyfill in their setup, (b) never look at stderr, or (c) write only trivial repros that don't exercise the placeholder path enough to notice.
Root cause
ɵɵdeferOnViewport (and the other ɵɵdeferOn* instructions) unconditionally calls registerDomTrigger, which schedules pollDomTrigger via afterEveryRender. Once the placeholder is rendered, pollDomTrigger resolves the trigger element and calls onViewport(element, ...), which creates a new IntersectionObserver. There is no DeferBlockBehavior.Manual check on this path.
The existing helper shouldTriggerDeferBlock(injector) (which does respect Manual) is consulted only inside triggerDeferBlock — i.e. it gates the content rendering, not the trigger registration. So in Manual mode the trigger never actually fires the defer block, but the observer is still created and a leftover poll keeps running.
In packages/core/src/defer/instructions.ts on main (also reproducible on 18.2.x, 19.x):
export function ɵɵdeferOnViewport(
triggerIndex: number,
walkUpTimes?: number | null,
options?: IntersectionObserverInit,
) {
// ...
// Avoid adding event listeners when this instruction is invoked on the server.
if (!(typeof ngServerMode !== 'undefined' && ngServerMode)) {
registerDomTrigger(
lView, tNode, triggerIndex, walkUpTimes,
onViewportWrapper, // ← creates IntersectionObserver
() => triggerDeferBlock(TriggerType.Regular, lView, tNode),
TriggerType.Regular,
options,
);
}
}
The guard only checks ngServerMode. DeferBlockBehavior.Manual is not consulted.
Suggested fix
Gate registerDomTrigger (and the equivalent registerHover/registerInteraction/etc. paths) on shouldTriggerDeferBlock(injector) — the same check already used inside triggerDeferBlock. In Manual test mode this would skip the registration entirely, so no IntersectionObserver, requestIdleCallback, or DOM event listener gets attached, leaving the testing API (fixture.getDeferBlocks() + block.render(...)) as the sole driver of state transitions.
Pseudocode:
export function ɵɵdeferOnViewport(triggerIndex, walkUpTimes, options) {
const lView = getLView();
const tNode = getCurrentTNode()!;
renderPlaceholder(lView, tNode);
const injector = lView[INJECTOR]!;
if (!shouldTriggerDeferBlock(injector)) return; // ← honor Manual mode + SSR
registerDomTrigger(lView, tNode, triggerIndex, walkUpTimes, onViewportWrapper, /*...*/);
}
This would also let users delete IntersectionObserver / requestIdleCallback polyfills from their Jest setup files when they only use @defer in tests via the official manual API.
Please provide a link to a minimal reproduction of the bug
Minimal repro (no StackBlitz because StackBlitz uses Karma/Chrome, where IntersectionObserver is defined and the bug is invisible — the bug only manifests in Jest + JSDOM):
// component.ts
import { Component } from '@angular/core';
@Component({
standalone: true,
selector: 'app-lazy',
template: `lazy works!`,
})
export class LazyComponent {}
@Component({
standalone: true,
selector: 'app-root',
imports: [LazyComponent],
template: `
@defer (on viewport) {
<app-lazy />
} @placeholder {
<div>placeholder</div>
}
`,
})
export class DummyComponent {}
// component.spec.ts (Jest + JSDOM, jest-preset-angular)
import { ɵDeferBlockState } from '@angular/core';
import { DeferBlockBehavior, TestBed } from '@angular/core/testing';
import { DummyComponent } from './component';
it('should render the defer block on viewport', async () => {
TestBed.configureTestingModule({
deferBlockBehavior: DeferBlockBehavior.Manual,
});
const fixture = TestBed.createComponent(DummyComponent);
fixture.detectChanges();
// Manual API still works and the test passes...
const [block] = await fixture.getDeferBlocks();
await block.render(ɵDeferBlockState.Complete);
expect(fixture.nativeElement.innerHTML).toContain('lazy works!');
// ...but stderr is flooded with:
// ERROR ReferenceError: IntersectionObserver is not defined
// at onViewport (.../@angular/core/fesm2022/core.mjs:19997)
// at Array.pollDomTrigger (.../@angular/core/fesm2022/core.mjs:20107)
// at _AfterRenderImpl.execute (.../@angular/core/fesm2022/core.mjs:19634)
// ...
});
Same behaviour with on hover, on interaction, on idle (which would also fail in JSDOM since requestIdleCallback is missing) — Manual is documented as the workaround for those, yet the underlying browser APIs are still touched.
Please provide the exception or error you saw
ReferenceError: IntersectionObserver is not defined
at onViewport (node_modules/@angular/core/fesm2022/core.mjs:19997:17)
at Array.pollDomTrigger (node_modules/@angular/core/fesm2022/core.mjs:20107:25)
at node_modules/@angular/core/fesm2022/core.mjs:19630:103
at _AfterRenderImpl.execute (node_modules/@angular/core/fesm2022/core.mjs:19634:40)
at _AfterRenderManager.execute (node_modules/@angular/core/fesm2022/core.mjs:19591:20)
at _ApplicationRef.synchronizeOnce (node_modules/@angular/core/fesm2022/core.mjs:32850:37)
at _ApplicationRef.synchronize (node_modules/@angular/core/fesm2022/core.mjs:32799:18)
at _ApplicationRef._tick (node_modules/@angular/core/fesm2022/core.mjs:32768:18)
at _ApplicationRef.tick (node_modules/@angular/core/fesm2022/core.mjs:32757:14)
...
Please provide the environment you discovered this bug in (run ng version)
Angular CLI: 18.2.x
Node: 20.x
Package Manager: pnpm
OS: macOS 14.x
Angular: 18.2.9
... compiler-cli, core, etc.
Package Version
---------------------------------------------------------
@angular/core 18.2.9
@angular/compiler-cli 18.2.9
typescript 5.5.4
rxjs 7.x
zone.js 0.14.10
jest 29.7.0
jest-preset-angular 14.x
jsdom (via jest-environment-jsdom) default
I also checked packages/core/src/defer/instructions.ts on main — the same code path is still present, so this is not a v18-only regression.
Anything else?
Which @angular/* package(s) are the source of the bug?
core
Is this a regression?
No
Description
When testing a component that contains
@defer (on viewport)withTestBed.configureTestingModule({ deferBlockBehavior: DeferBlockBehavior.Manual }), Angular still attempts to register anIntersectionObserveragainst the placeholder element via anafterRenderhook. In environments whereIntersectionObserveris not defined (e.g. Jest + JSDOM, which is the most common Angular unit-test setup), this throwsReferenceError: IntersectionObserver is not defined.The error is swallowed by Angular's
ErrorHandlerso tests still pass, but every component creation logs a full stack trace to stderr, polluting test output (e.g. 2 errors per defer block × number ofbeforeEachruns).This contradicts what the official testing guide implies. https://angular.dev/guide/templates/defer#testing-defer-blocks documents the manual API (
fixture.getDeferBlocks()+block.render(DeferBlockState.Complete)) without mentioning that callers must polyfillIntersectionObserverto use it in JSDOM. The pattern only works cleanly today because users either (a) have a polyfill in their setup, (b) never look at stderr, or (c) write only trivial repros that don't exercise the placeholder path enough to notice.Root cause
ɵɵdeferOnViewport(and the otherɵɵdeferOn*instructions) unconditionally callsregisterDomTrigger, which schedulespollDomTriggerviaafterEveryRender. Once the placeholder is rendered,pollDomTriggerresolves the trigger element and callsonViewport(element, ...), which creates a newIntersectionObserver. There is noDeferBlockBehavior.Manualcheck on this path.The existing helper
shouldTriggerDeferBlock(injector)(which does respectManual) is consulted only insidetriggerDeferBlock— i.e. it gates the content rendering, not the trigger registration. So in Manual mode the trigger never actually fires the defer block, but the observer is still created and a leftover poll keeps running.In
packages/core/src/defer/instructions.tsonmain(also reproducible on 18.2.x, 19.x):The guard only checks
ngServerMode.DeferBlockBehavior.Manualis not consulted.Suggested fix
Gate
registerDomTrigger(and the equivalentregisterHover/registerInteraction/etc. paths) onshouldTriggerDeferBlock(injector)— the same check already used insidetriggerDeferBlock. InManualtest mode this would skip the registration entirely, so noIntersectionObserver,requestIdleCallback, or DOM event listener gets attached, leaving the testing API (fixture.getDeferBlocks()+block.render(...)) as the sole driver of state transitions.Pseudocode:
This would also let users delete
IntersectionObserver/requestIdleCallbackpolyfills from their Jest setup files when they only use@deferin tests via the official manual API.Please provide a link to a minimal reproduction of the bug
Minimal repro (no StackBlitz because StackBlitz uses Karma/Chrome, where
IntersectionObserveris defined and the bug is invisible — the bug only manifests in Jest + JSDOM):Same behaviour with
on hover,on interaction,on idle(which would also fail in JSDOM sincerequestIdleCallbackis missing) —Manualis documented as the workaround for those, yet the underlying browser APIs are still touched.Please provide the exception or error you saw
Please provide the environment you discovered this bug in (run
ng version)I also checked
packages/core/src/defer/instructions.tsonmain— the same code path is still present, so this is not a v18-only regression.Anything else?
Related (closed) issue, scoped to SSR only: Defer block with prefetch on viewport throws because IntersectionObserver is not defined #52304 / PR refactor(core): avoid invoking IntersectionObserver in defer triggers on the server #52306. That fix added an
ngServerModeguard but did not consider testing environments.Workaround for users today: add a no-op
IntersectionObserver(andrequestIdleCallback) stub injest-setup.ts, e.g.This silences the error but really shouldn't be necessary when
DeferBlockBehavior.Manualis already opted in.