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
- Create a standalone component that calls
injectTable() in a class field and reads an input.required() inside the options initializer.
- 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.
- 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:
- 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.
- Defer to the first change detection instead of a microtask, so bindings are guaranteed applied.
- 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
Summary
injectTable()force-initializes its options viaqueueMicrotask, independently of whether the returned proxy is ever accessed. When the options initializer reads an Angularinput.required(), that microtask can run before Angular has applied the binding, throwingNG0950: Input "data" is required but no value is available yetas an uncaught exception. A secondary issue compounds it: theisMountguard 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
injectTablewraps its initializer inlazyInit, defined inpackages/angular-table/src/lazySignalInitializer.ts:The
Proxytraps make access-triggered initialization lazy, which is correct. ThequeueMicrotaskline 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:
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
awaitseparates the two — which is the default shape of an AngularTestBedsuite, whereTestBed.createComponentperforms the creation pass and the firstdetectChanges()performs the update pass:Every assertion still passes, because the throw leaves
object === null, so theProxyretriesinitializeObject()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: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:
isMountswallows the first options updateIn
packages/angular-table/src/injectTable.ts: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
dataa default so the microtask cannot throw is worse than the throw: the table constructs successfully with empty data, the first real update is swallowed byisMount, and the table renders nothing. TheNG0950throw is, accidentally, load-bearing.Note on
lazyInit's originlazySignalInitializer.tscarries a header comment stating it is an implementation ported from@tanstack/angular-query. The samequeueMicrotaskshape is present there, so the eager-init half of this report may apply to that package as well. We have not reproduced it againstangular-queryand are not reporting it there.Reproduction
injectTable()in a class field and reads aninput.required()inside the options initializer.beforeEachisasync(anyawait, e.g.compileComponents()), callsTestBed.createComponent, and binds the required input inside theitbody viacomponentRef.setInput.NG0950per test and exits non-zero.Reproduced on
@tanstack/angular-table9.0.0 and 9.1.0 (latest at time of writing, published 2026-08-07) with@tanstack/table-core9.1.0, Angular 22.2.0-next.0, and vitest via@angular/build:unit-test. Thedist/fesm2022bundles of 9.0.0 and 9.1.0 are byte-identical (53 883 bytes), and both code paths above are unchanged onmain— no released or merged fix exists.Workaround
Bind the component's inputs through a test host template, and run the first
detectChanges()synchronously insidebeforeEach, before that hook's ownawaitboundary.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
queueMicrotaskline (correct, but a patched dependency to maintain across every release); relaxing the inputs offinput.requiredplus an internal mirror signal to defeat theisMountskip (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:
queueMicrotaskeager-init inlazySignalInitializer.ts. TheProxytraps already provide initialization-on-first-use, which is what the helper advertises. Removing the line makesinjectTablegenuinely lazy and theNG0950race disappears.isMountwith a value comparison ininjectTable.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
injectTablewith signal inputs robust.References
packages/angular-table/src/lazySignalInitializer.ts— thequeueMicrotaskeager-initpackages/angular-table/src/injectTable.ts— theisMountguard in the options-update effectlazyInitorigin: https://github.com/TanStack/query/blob/main/packages/angular-query-experimental/src/util/lazy-init/lazy-init.tsNG0950: https://angular.dev/errors/NG0950