Skip to content

DeferBlockBehavior.Manual still registers IntersectionObserver for @defer (on viewport), breaking JSDOM tests #68800

@fredericojesus

Description

@fredericojesus

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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: testingIssues related to Angular testing features, such as TestBedgemini-triagedLabel noting that an issue has been triaged by geministate: has PR

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions