Skip to content

injectTable eagerly initializes options on a microtask, throwing NG0950 when the initializer reads a required signal input #6530

Description

@michael-dg

Summary

injectTable() force-initializes its options via queueMicrotask, independently of whether the returned proxy is ever accessed. When the options initializer reads an Angular input.required(), that microtask can run before Angular has applied the binding, throwing NG0950: Input "data" is required but no value is available yet as an uncaught exception. A secondary issue compounds it: the isMount guard in the options-update effect swallows the first update, so a table that does construct before its inputs are bound keeps those pre-binding options.

Description

injectTable wraps its initializer in lazyInit, defined in packages/angular-table/src/lazySignalInitializer.ts:

export function lazyInit<T extends object>(initializer: () => T): T {
  let object: T | null = null

  const initializeObject = () => {
    if (!object) {
      object = untracked(() => initializer())
    }
  }

  queueMicrotask(() => initializeObject())   // <-- eager, not lazy

  const table = () => {}

  return new Proxy<T>(table as T, {
    apply(...) { initializeObject(); /* ... */ },
    get(_, prop, receiver) { initializeObject(); return Reflect.get(object as T, prop, receiver) },
    has(_, prop) { initializeObject(); return Reflect.has(object as T, prop) },
    ownKeys() { initializeObject(); return Reflect.ownKeys(object as T) },
    // ...
  })
}

The Proxy traps make access-triggered initialization lazy, which is correct. The queueMicrotask line defeats that: initialization is attempted unconditionally on the next microtask, whether or not anything touched the table.

That is fine when the component's inputs are already bound. It is not fine for a component whose options initializer reads a required input:

export class DataTableComponent<T extends RowData = any> {
  data = input.required<T[]>();

  table = injectTable<typeof features, T>(() => ({
    features,
    data: this.data(),          // throws NG0950 if the microtask wins the race
    columns: this.tanstackColumns(),
  }));
}

Angular splits this across two phases: a template's creation pass instantiates child components (leaving their inputs unset), and the following update pass applies the bindings. In an application both phases run inside the same synchronous change-detection cycle, so the microtask drains after the inputs are bound and nothing goes wrong. It breaks whenever an await separates the two — which is the default shape of an Angular TestBed suite, where TestBed.createComponent performs the creation pass and the first detectChanges() performs the update pass:

beforeEach(async () => {
  await TestBed.configureTestingModule({ imports: [DataTableComponent] })
    .compileComponents();

  fixture = TestBed.createComponent(DataTableComponent<TestData>);
  component = fixture.componentInstance;
});           // <-- await boundary: microtask drains here, before any setInput

it('renders rows', () => {
  fixture.componentRef.setInput('data', mockData);   // too late
  fixture.detectChanges();
});

Every assertion still passes, because the throw leaves object === null, so the Proxy retries initializeObject() on first template access and succeeds with the correct data. But the throw escapes as an unhandled error — one per fixture. In a 16-file, 349-test package containing one 60-test spec built this way:

Vitest caught 60 unhandled errors during the test run.

Error: NG0950: Input "data" is required but no value is available yet.
 ❯ _DataTableComponent.inputValueFn [as data] @angular/core
 ❯ packages/ui/src/lib/data-table.component.ts:256:16
 ❯ @tanstack/angular-table/dist/fesm2022/tanstack-angular-table.mjs:1264:16
 ❯ initializeObject @tanstack/angular-table/dist/fesm2022/tanstack-angular-table.mjs:1095:22

Test Files 16 passed (16) / Tests 340 passed | 9 skipped (349) — and a non-zero exit code. The suite is red on noise alone.

Secondary issue: isMount swallows the first options update

In packages/angular-table/src/injectTable.ts:

let isMount = true
effect(
  () => {
    const newOptions = options()
    if (isMount) {
      isMount = false
      return
    }
    untracked(() =>
      table.setOptions((previous) => ({ ...previous, ...newOptions })),
    )
  },
  { injector, debugName: 'tableOptionsUpdate' },
)

This assumes the effect's first run coincides with construction. Because construction happens in a microtask outside any change-detection pass, the first effect run can land after the consumer has already changed its inputs — and that change is then discarded.

This is what makes the obvious workaround fail. Giving data a default so the microtask cannot throw is worse than the throw: the table constructs successfully with empty data, the first real update is swallowed by isMount, and the table renders nothing. The NG0950 throw is, accidentally, load-bearing.

Note on lazyInit's origin

lazySignalInitializer.ts carries a header comment stating it is an implementation ported from @tanstack/angular-query. The same queueMicrotask shape is present there, so the eager-init half of this report may apply to that package as well. We have not reproduced it against angular-query and are not reporting it there.

Reproduction

  1. Create a standalone component that calls injectTable() in a class field and reads an input.required() inside the options initializer.
  2. Write a spec whose beforeEach is async (any await, e.g. compileComponents()), calls TestBed.createComponent, and binds the required input inside the it body via componentRef.setInput.
  3. Run under vitest. Assertions pass; the run reports one uncaught NG0950 per test and exits non-zero.

Reproduced on @tanstack/angular-table 9.0.0 and 9.1.0 (latest at time of writing, published 2026-08-07) with @tanstack/table-core 9.1.0, Angular 22.2.0-next.0, and vitest via @angular/build:unit-test. The dist/fesm2022 bundles of 9.0.0 and 9.1.0 are byte-identical (53 883 bytes), and both code paths above are unchanged on main — no released or merged fix exists.

Workaround

Bind the component's inputs through a test host template, and run the first detectChanges() synchronously inside beforeEach, before that hook's own await boundary.

A host alone is not enough, which is worth stating because it is the intuitive fix and it fails: the creation pass still runs inside TestBed.createComponent, so the child exists unbound and the microtask still wins. Measured — adding four host-bound tests raised the unhandled-error count from 60 to 64, one per new test.

What makes the host necessary is the early detectChanges() it enables. A host can supply empty defaults for the required inputs, so that first pass is harmless; a detached fixture cannot, because there is nothing to bind them from. That empty first pass also absorbs the options effect's discarded first run, so values set afterwards propagate normally. With both pieces in place the unhandled errors go to zero, with the same number of passing assertions.

Three alternatives were evaluated and rejected: patching the shipped bundle to remove the queueMicrotask line (correct, but a patched dependency to maintain across every release); relaxing the inputs off input.required plus an internal mirror signal to defeat the isMount skip (weakens a shared component's public contract and depends on effect-flush ordering); and suppressing unhandled errors in the vitest config (hides a real fragility).

Proposed Change

Any one of:

  1. Drop the queueMicrotask eager-init in lazySignalInitializer.ts. The Proxy traps already provide initialization-on-first-use, which is what the helper advertises. Removing the line makes injectTable genuinely lazy and the NG0950 race disappears.
  2. Defer to the first change detection instead of a microtask, so bindings are guaranteed applied.
  3. Replace isMount with a value comparison in injectTable.ts (or seed it from the options snapshot captured at construction), so a first update carrying genuinely new options is applied rather than skipped. This is worth doing regardless of (1), since it is the reason a defaulted-input workaround silently renders an empty table.

Fix (1) alone resolves the reported symptom; (1) + (3) makes the pairing of injectTable with signal inputs robust.

References

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions