Skip to content

Validate async signal leaks resource internals #70884

Open

Description

@benjavicente

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

forms

Is this a regression?

No

Description

Let's say we have a function like this:

type Result = { loading: true } | { loading: false; value: string }

function injectResult(value: () => string): Signal<Result> {
  // The implementation could be more complex than this,
  // but the issue still happens with only this code.
  return computed(() => ({ loading: false, value: value() }))
}

const value = signal('Hello Angular!')

const result: Signal<Result> = injectResult(() => value())

We can convert it to a resource like this:

function toSnapshot(result: Result) {
  if (result.loading) {
    return { status: 'loading', value: undefined }
  }
  return { status: 'resolved', value: result.value }
}

function toResource(result: Signal<Result>) {
  return resourceFromSnapshots(() => toSnapshot(result()))
}

Since validateAsync works with the Resource interface (not the API), one might expect this to work:

@Component({ ... })
export class Registration {
  registrationModel = signal({ username: '' })
  registrationForm = form(this.registrationModel, (schemaPath) => {
    validateAsync(schemaPath.username, {
      params: ({ value }) => value(),
      factory: (value) => {
        const result = injectResult(value)
        return toResource(result)
      },
      onSuccess: () => {},
      onError: () => {},
    })
  })
}

This works with those options, but if we add debounce to the validateAsync options, we get:

ERROR _ResourceParamsStatus: LOADING

The workaround is to wrap the validation signal in a resource to catch the internal sentinel error:

factory: (value) => {
  const settled = resource({
    params: () => value(),
    loader: async ({ params }) => params,
  })

  const result = injectResult(() => {
    return settled.hasValue() ? settled.value() : ''
  })
  return toResource(result)
}

The expectation is that the signal passed to factory contains the debounced value, rather than a signal that may throw.

Requiring that signal to be used with the resource API (not just the interface) makes it harder for libraries that do not use that API to integrate with Signal Forms, but can provide a Resource Interface. For example, libraries like a resouce-like API for localstorage and query/sync libraries.

Please provide a link to a minimal reproduction of the bug

https://github.com/benjavicente/angular-validate-async-resource-repro

Please provide the exception or error you saw


Please provide the environment you discovered this bug in (run ng version)

┌───────────────────────────┬───────────────────┬───────────────────┐
│ Package                   │ Installed Version │ Requested Version │
├───────────────────────────┼───────────────────┼───────────────────┤
│ @angular/build            │ 22.1.8            │ ^22.0.0           │
│ @angular/cli              │ 22.1.8            │ ^22.0.0           │
│ @angular/common           │ 22.1.7            │ ^22.0.0           │
│ @angular/compiler         │ 22.1.7            │ ^22.0.0           │
│ @angular/compiler-cli     │ 22.1.7            │ ^22.0.0           │
│ @angular/core             │ 22.1.7            │ ^22.0.0           │
│ @angular/forms            │ 22.1.7            │ ^22.0.0           │
│ @angular/platform-browser │ 22.1.7            │ ^22.0.0           │
│ rxjs                      │ 7.8.2             │ ~7.8.0            │
│ typescript                │ 6.0.3             │ ~6.0.2            │
└───────────────────────────┴───────────────────┴───────────────────┘

Anything else?

No response

Activity

  1. added this to the needsTriage milestone on Sep 23, 2026
  2. added 4 commits that reference this issue on Sep 26, 2026
    d8c6b96
    134dfcd
    772830e
    55e0c59
  3. JeanMeche commented on Sep 29, 2026

    @JeanMeche
    Member

    The root cause here can be illustrated as following with resource chaining + debounced.

    it('should reproduce issue #70884 where chain throws when debounced is loading inside resourceFromSnapshots', async () => {
      const source = signal('initial');
      const deb = debounced(source, 10, {injector: TestBed.inject(Injector)});
      
      const chained = computed(() => paramsContext.chain(deb));
      
      const res = resourceFromSnapshots(() => {
        // Intentionally omitting try/catch for ResourceParamsStatus.LOADING
        // to reproduce the crash from #70884
        return {status: 'resolved', value: chained()};
      });
    
      TestBed.tick();
      await timeout(10);
      expect(res.status()).toBe('resolved');
      expect(res.value()).toBe('initial');
    
      source.set('updated');
      TestBed.tick();
      
      // deb is now loading. 
      // chained() will throw ResourceParamsStatus.LOADING when evaluated.
      // resourceFromSnapshots does not catch this, so it bubbles up.
      expect(() => res.status()).toThrow(ResourceParamsStatus.LOADING);
    });
  4. benjavicente commented on Sep 29, 2026

    @benjavicente
    Author

    I would add the case where the chained signal is used in an effect:

    iit('should reproduce #70884 when an effect reads debounced params', () => {
      const injector = TestBed.inject(Injector);
    
      function createResource(value: () => string) {
        effect(() => {
          value(); // e.g. synchronize an external store's options
        }, {injector});
    
        return resourceFromSnapshots(() => {
          return { status: 'resolved', value: value() }
        );
      }
    
      const source = signal('initial');
      const deb = debounced(source, 10, {injector});
      // My impressions is that the chained below will not work as is
      const chained = computed(() => paramsContext.chain(deb));
      const res = createResource(chained);
    
      TestBed.tick();
      expect(res.value()).toBe('initial');
    
      source.set('updated');
    
      // Throws inside the effect, before any resource read.
      expect(() => TestBed.tick()).toThrow(ResourceParamsStatus.LOADING);
    });
  5. JeanMeche commented on Sep 29, 2026

    @JeanMeche
    Member

    I did miss it earlier, but our docs specifically mentions that chain can and will throw when the resource is loading and the consequence here is that reading the factory value is also unsafe in case of a debounce.

    There would be different ways to tackle this:

    • provide a dedicated function for those cases the value is unsafe to read
    • consider this a documentation issue and leave the implementer handle those cases
    • change the whole implementation of chaining to prevent throwing (and use another marker).
    • Something else ?
  6. benjavicente commented on Sep 30, 2026

    @benjavicente
    Author

    I think the issue is that chain is assumed to be called only in the params function, so valdiateAsync breaks that promise by chaining a resource outside of it to be passed as a signal to the validation factory. It seems like it's the only API that calls the chain API that way.

    So not something related to chain itself, but the implementation/API shape of validateAsync specifically.


    In normal uses cases of chain, it is controlled when you create the resource itself, so the user has full control of it:

    const userResource = resource({
      params: () => ({id: getUserId()}),
      loader: ({params}) => fetchUser(params),
    });
    
    const companyResource = resource({
      // Given a resource, **I** can chain it with the params to create a new resource
      params: ({chain}) => chain(userResource)?.companyId,
      loader: ({params: companyId}) => fetchCompany(companyId),
    });

    But in validate valdiateAsync, the chain is hidden and not part of any params function:

    // forms/signals/src/api/rules/validation/validate_async.ts@validateAsync
    const RESOURCE = createManagedMetadataKey<ReturnType<typeof opts.factory>, TParams | undefined>(
      (_state, params) => {
        if (opts.debounce !== undefined) {
          // When debounced, it is **Signal Forms that calls the chain** function, 
          // so I don't have control of how it will be chained in the resource I create
          const debouncedResource = debounced(() => params(), opts.debounce);
          const wrappedParams = computed(() => ɵchain(debouncedResource));
          return opts.factory(wrappedParams);
        }
        return opts.factory(params);
      },
    );

    My impressions is that if valdiateAsync wanted to support resource chaining it should have passed a resource into the factory. Since the API does not, it worked around that by doing an implicit chain, witch works if the user uses only the resource API and breaks with any other API like effects. To fix that it seems to need a utility to compose the resources in a safe way since it cain chain implicitly without leaking the error from chain (since, it's only the params function from the resource api that should catch it, not a factory or resoruce).

    const RESOURCE = createManagedMetadataKey<ReturnType<typeof opts.factory>, TParams | undefined>(
      (_state, params) => {
        if (opts.debounce !== undefined) {
          const debouncedResource = debounced(() => params(), opts.debounce);
          // Gets a value that can be passed safely to the factory 
          const debouncedParams = debouncedResource.value
          // Chain it safely instead of passing a signal that might thow
          // Note that in this case, the factory can't known if the signal is "pending"  
          return safeChain(debouncedResource, opts.factory(debouncedParams));
        }
        return opts.factory(params);
      },
    );

    This is essentially what PRs that have linked to this issue did

  7. added a commit that references this issue on Oct 17, 2026
    f4227ac
  8. benjavicente commented on Oct 26, 2026

    @benjavicente
    Author

    Bringin here the comments of #71414 (comment) and #71613 (comment) here for visibility


    The usage of ɵchain inside the implementation of validateAsync should be removed. Why: In the docs chain, is available in the params context object of the resource API. By calling it internally in signal forms, it forces consumers to use the Resource API:

    validateAsync(path.username, {
       params: ({value}) => value(),
       debounce: 300,
       factory: (params) =>
         resource({
           // Implicitly catches `chain`, contrasting with how other resources are chained
           // For users, this might seem redundant with params above doing the same thing.
           // Why can't I read params directly in loader, if it is a Signal?
           params: () => params(),
           loader: ({params}) => checkUsername(params),
         }),
     });

    Factories that return the Resource Interface but do not use the Resource API don't work with validateAsync because they don't have a way of catching the implicit chain. No other utility in Angular calls ɵchain outside of the params function, all other examples of chain call that function inside the given params function. validateAsync is the only place it doesn't.

    So how params are given to the factory should change if that API should maintain its interface as is.

    Option 1: Don't throw and retain the previous value: Doesn't work because it introduces behavioural issues, like on A->B->A chains.

    Option 2: Don't throw and return undefined: Doesn't work because it will make resource start in an idle state, breaking current tests, because it makes the resulting resource idle instead of loading.

    Option 3: Throw, and add something to catch it: There could be mechanisms to catch the pending state. Automatically handling these markers across reactive consumers would be a broader architectural change (like Solid v2), something Angular does not do at the moment. So instead there could be an explicit function, let's say wrapChained that takes a chained signal and returns a Resource, where the usage of that would only be required in validateAsync for non resource API usage. Both options don't seem great from an API design standpoint.

    For option 1 and 2, there is also other issues with how chaining works, like the forwarding issues.

    Those are the only 3 options that look available without changing the API shape. So the preferable path forward might be to create a new API that does not use chain internally, allowing resources to be composed by the user of the validation utility.


    PR #71613 presents the idea of a API with the following shape:

    validateResource(schemaPath.username, {
      params: ({value}) => value(),
      debounce: 300,
      // Factory gets a resource, since the validation needs to be attached to async reactivity
      factory: (paramsResource) =>
        resource({
          // Chaining is explicit and happens inside resource params.
          params: ({chain}) => chain(paramsResource),
          loader: ({params}) => checkUsername(params),
        }),
      onSuccess: (available) => (available ? undefined : {kind: 'usernameTaken'}),
      onError: () => ({kind: 'serverError'}),
    });

    Chaining is explicit, and validateResource does an additional "safe-chain" internally to ensure the correct resource state is used.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions