form icon indicating copy to clipboard operation
form copied to clipboard

[Angular 19] injectStore() runs before input signals are set.

Open JulienLecoq opened this issue 9 months ago • 2 comments

Describe the bug

injectStore() runs before input signals are set which makes it throws an error at runtime if the form is provided as an input signal.

main.ts:8 ERROR RuntimeError: NG0950: Input is required but no value is available yet

Your minimal, reproducible example

https://codesandbox.io/p/devbox/q9w9mv

Steps to reproduce

Open the shared link and see the error in the web dev tools.

The error is caused by the following code:

isValid = injectStore(
    this.form(),
    (state) => state.fieldMeta[this.name()].errors.length === 0
  );

In the component: FirstNameComponent

Expected behavior

The injectStore should call the business logic after input signals are set like Angular Query does.

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

  • OS: MacOS
  • Browser: Safari

TanStack Form adapter

None

TanStack Form version

@tanstack/angular-form v1.3.0

TypeScript version

v5.8.3

Additional context

No response

JulienLecoq avatar Apr 07 '25 16:04 JulienLecoq

This is a common problem with Angular signals. Field initialization runs at "constructor" time, while inputs are set after the component is constructed. So you cannot read an input signal in field initialization. The usual workaround is to defer the injection (injectStore in this case) to ngOnInit providing the injector (injected at construction time). There may be other ways.

The reading of your form signal happens outside (before) injectStore.

flensrocker avatar May 17 '25 06:05 flensrocker

These are two possible solutions, both with tradeoffs.

readonly #injector = inject(Injector);

// Non-Null-Assertion operator :(
// You have to be careful to not access this property too early
isValid!: Signal<boolean>;

ngOnInit(): void {
  this.isValid = runInInjectionContext(this.#injector, () =>
    injectStore(
      this.form(),
      (state) => state.fieldMeta[this.name()].errors.length === 0
    )
  );
}
readonly #injector = inject(Injector);

// Use nested signals
isValid = computed(() => {
  const form = this.form();
  const $isValid = runInInjectionContext(this.#injector, () =>
    injectStore(
      form,
      (state) => state.fieldMeta[this.name()].errors.length === 0
    )
  );
  return $isValid();
});

Possible enhancement for injectStore: accept an Injector, so we don't have to use runInInjectionContext. It's not an uncommon pattern in Angular "inject" functions. (the injectStore function in @tanstack/store package already supports this)

flensrocker avatar May 17 '25 06:05 flensrocker