#70728·angular

core: IdleScheduler permanently strands a bucket after a callback or ApplicationRef._tick throws

Author: COOLakCreated Sep 15, 2026Updated Sep 17, 2026
Labelsarea: corestate: has PRgemini-triaged

core: IdleScheduler permanently strands a bucket after a callback or ApplicationRef._tick throws

Which @angular/* package(s) are the source of the bug?

core

Is this a regression?

Yes. An isolated execution of the upstream source recovers when fresh work is added at parent commit 0ddbc47e7f6a4eced3a1a986b260747dc37ee9c9, but remains stuck after c2b14b7ab44003d6fd1154300c5cc483644d80e6 and at current main c60c41e29aa175ca25b0e91a40f5ab115b46139f (checked September 15, 2026).

Description

If an idle callback or the subsequent ApplicationRef._tick() throws, IdleScheduler.scheduleBucket() exits before clearing bucket.idleId. The browser's one-shot idle callback has already fired, but Angular retains its handle as if it were still pending. The current callback also remains in both bookkeeping collections.

Subsequent add() calls with the same idle options append work but return early from scheduling because idleId !== null. Even after the original error is no longer present, no further native callback is requested for that bucket. An unrelated deferred UI can therefore remain unloaded for the lifetime of the bucket.

The regression is in the change that keeps the handle set during draining. Its re-entrancy protection makes sense, but the exceptional path also needs to release the in-flight marker and reconcile callback bookkeeping. Please preserve error reporting and re-entrant scheduling protection when addressing this; blindly retrying a permanently throwing callback could introduce a loop.

Expected: a reported rendering/callback error should not leave a completed native callback marked pending or permanently prevent later work in the same bucket from scheduling.

Actual: after a single synthetic error and a later add(), the bucket retains three callbacks and the expired handle, while there are zero native callbacks pending.

Minimal reproduction

The self-contained Node script below fetches the exact upstream TypeScript source at three pinned revisions, strips TypeScript syntax, and supplies mocked DI, zone, and one-shot idle service dependencies. The scheduler implementation is not rewritten. This is an isolated scheduler-source reproduction, not an Angular CLI/browser integration test.

Save as angular-idle-repro.mjs and run node angular-idle-repro.mjs with Node 22.23.2. No packages, browser, account, extensions, or AdGuard are needed. The assertions confirm the regression in all 18 combinations: three revisions, normal/callback-error/tick-error, and an idle deadline versus the no-deadline timeout fallback.

Representative result after a one-time _tick() error:

Revision Native callbacks after later add Callbacks that ran Remaining queue Retained idle handle
Parent 1 first, first, second, later 0 none
Regression commit 0 first 3 1 (already fired)
Current main 0 first 3 1 (already fired)

The no-error controls complete normally at every revision. Both error sites and both idle-service modes exhibit the same regression. The parent is only a baseline showing recovery on a fresh add; replaying the first callback there is not proposed as ideal error handling.

Exception or error

The minimal reproduction deliberately throws Error('synthetic render error') once, then stops throwing. The observable bug is the persistent scheduler stall afterward.

Environment

  • Angular source: exact commits listed above, current source idle_scheduler.ts.
  • Node: v22.23.2, using the built-in experimental stripTypeScriptTypes API.
  • OS: macOS 26.6.2 (25G83).
  • No Angular CLI app is used in this isolated reproduction, so an installed Angular release version is not claimed.

Anything else?

This was investigated after Gemini Apps' file-attachment button displayed an empty menu in a Safari web app. An unsupported legacy citation produced an empty source-card array; a citation template accessed the first card's metadata and threw during the scheduler's change-detection call. The production bundle exhibited the same stuck-bucket behavior. The application-specific citation error is being reported separately through Gemini's official feedback channel. No private conversation data is needed to reproduce the framework issue above.

Searches of this tracker for IdleScheduler, idle scheduler, defer exception, defer idleId, and defer stuck did not locate an existing matching report.

Complete executable reproduction
// Isolated reproduction of Angular's unmodified IdleScheduler source.
// Node 22.23.2; uses built-in TypeScript stripping and mocked DI/browser callbacks.
// No Gemini account, conversation, browser extension, or AdGuard is involved.
import {stripTypeScriptTypes} from 'node:module';
import {runInNewContext} from 'node:vm';
import assert from 'node:assert/strict';

const revisions = {
  before: '0ddbc47e7f6a4eced3a1a986b260747dc37ee9c9',
  after: 'c2b14b7ab44003d6fd1154300c5cc483644d80e6',
  current: 'c60c41e29aa175ca25b0e91a40f5ab115b46139f',
};

for (const [revision, sha] of Object.entries(revisions)) {
  const url = `https://raw.githubusercontent.com/angular/angular/${sha}/packages/core/src/defer/idle_scheduler.ts`;
  const response = await fetch(url);
  assert.equal(response.status, 200);
  // Only replace imports/exports to supply controlled dependencies. Scheduler body is unchanged.
  const source = (await response.text()).replace(/^import .*;\n/gm, '').replace(/^export /gm, '');
  const compiled = stripTypeScriptTypes(source);
  for (const errorSite of ['none', 'callback', 'tick']) {
    for (const mode of ['idle-deadline', 'timeout-fallback']) {
      const native = new Map();
      let nextId = 0, shouldThrow = true;
      const ran = [];
      const ApplicationRef = {}, NgZone = {}, IDLE_SERVICE = {};
      const error = new Error('synthetic render error');
      const dependencies = new Map([
        [ApplicationRef, {_tick() {
          if (errorSite === 'tick' && shouldThrow) { shouldThrow = false; throw error; }
        }}],
        [NgZone, {run: fn => fn()}],
        [IDLE_SERVICE, {
          requestOnIdle(fn) { native.set(++nextId, fn); return nextId; },
          cancelOnIdle(id) { native.delete(id); },
        }],
      ]);
      const Scheduler = runInNewContext(compiled + '\nIdleScheduler;', {
        ApplicationRef, NgZone, IDLE_SERVICE,
        inject: token => dependencies.get(token),
        ɵɵdefineInjectable: value => value,
      });
      const scheduler = new Scheduler();
      scheduler.add(() => {
        ran.push('first');
        if (errorSite === 'callback' && shouldThrow) { shouldThrow = false; throw error; }
      });
      scheduler.add(() => ran.push('second'));
      function fire() {
        const [id, fn] = native.entries().next().value;
        native.delete(id); // A browser callback is one-shot, even if it throws.
        fn(mode === 'idle-deadline' ? {timeRemaining: () => 50, didTimeout: false} : undefined);
      }
      if (errorSite === 'none') fire();
      else assert.throws(fire, value => value === error);
      scheduler.add(() => ran.push('later')); // Fresh work after the error.
      const scheduledAfterAdd = native.size;
      let budget = 10;
      while (native.size && budget-- > 0) fire();
      assert.ok(budget > 0);
      const bucket = scheduler.buckets.get('');
      const result = {
        revision, errorSite, mode, scheduledAfterAdd, ran,
        pending: bucket?.queue.size ?? 0,
        idleId: bucket?.idleId ?? null,
        callbackBookkeeping: scheduler.callbackBucket.size,
      };
      const regression = revision !== 'before' && errorSite !== 'none';
      assert.equal(ran.includes('later'), !regression);
      assert.equal(result.pending, regression ? 3 : 0);
      console.log(JSON.stringify(result));
      scheduler.ngOnDestroy();
    }
  }
}