Skip to content

bug(angular): httpResource: multi-content-type resources don't re-read params/path/query signals reactively #3713

Description

@the-ult

Summary

For an operation with multiple response content types (e.g. Accept-negotiated application/json vs text/plain vs application/xml), the generated Angular httpResource function reads all of its reactive inputs — path params, query params, anything embedded in the route — once, synchronously, at call time, instead of inside the httpResource(() => ...) factory callback. Angular's httpResource only tracks signal reads that happen during the factory function's execution; reads that happen before the factory is even constructed create no reactive dependency. The result: these resources fetch once on creation and never refetch when their input signals change.

Reproduction (on current master)

samples/angular-app/src/api/http-resource/pets/pets.service.ts (generated from tests/specifications/petstore.yaml's listPets operation, which has both application/json and application/xml responses):

export function listPetsResource(
  accept: ListPetsAccept = 'application/json',
  params?: Signal<ListPetsParams>,
  version?: Signal<number>,
  options?: OrvalHttpResourceOptions<Pets, unknown> | OrvalHttpResourceOptions<string, string>,
): HttpResourceRef<Pets | string | undefined> {
  const request = {
    url: `/v${version?.() ?? 1}/pets`,
    params: filterParams(params?.() ?? {}, new Set<string>([])),
  };
  const normalizedRequest: HttpResourceRequest = request;
  const headers = normalizedRequest.headers instanceof HttpHeaders
    ? normalizedRequest.headers.set('Accept', accept)
    : { ...(normalizedRequest.headers ?? {}), Accept: accept };

  if (accept.includes('json') || accept.includes('+json')) {
    return httpResource<Pets>(() => ({
      ...normalizedRequest,
      headers,
    }), options as unknown as OrvalHttpResourceOptions<Pets, unknown>);
  }
  ...
}

version?.() and params?.() are called while building request, before httpResource(...) is invoked. The factory passed to httpResource just spreads the already-resolved normalizedRequest — it performs no signal reads of its own. Compare with the single-content-type searchPetsResource in the same file, generated correctly:

export function searchPetsResource(...): HttpResourceRef<Pets | undefined> {
  return httpResource<Pets>(() => {
    const request = {
      url: `/v${version?.() ?? 1}/search`,
      params: filterParams(params?.() ?? {}, new Set<string>([...])),
    };
    return request;
  }, options);
}

Here version?.()/params?.() are read inside the factory, so Angular's dependency tracking picks them up and the resource refetches whenever either signal changes.

Root cause

packages/angular/src/http-resource.ts, the multi-content-type resource builder (~lines 1035–1085): const request = ... and the subsequent normalizedRequest/headers construction happen at the top of the function body, before the accept-based branching that decides which httpResource.xxx variant to call (httpResource, .text, .arrayBuffer, .blob). This hoisting is done because that branch selection must happen once, synchronously, based on the plain accept argument (not a signal) — you can't reactively swap which resource-creating function was called after the fact. But the hoisting incidentally drags every signal read needed to build request/route (path params, query params — anything produced by buildResourceRequest, packages/angular/src/http-resource.ts:505-528) out of any reactive context along with it.

This is not limited to query params: buildResourceRequest embeds the full route template literal (including path-param signal calls like petId()) into request regardless of the isUrlOnly flag (line 522), so operations with path parameters and no query params in this shape are affected identically — confirmed the same non-reactive pattern applies whenever a route segment or query object depends on a caller-supplied signal, for any HTTP verb.

Proposed fix

Keep the accept-based branch selection eager (it has to be), but stop pre-computing the request payload. Wrap request/normalizedRequest/headers construction in a local closure and pass that closure as the factory, instead of spreading a frozen object literal:

export function listPetsResource(
  accept: ListPetsAccept = 'application/json',
  params?: Signal<ListPetsParams>,
  version?: Signal<number>,
  options?: ...,
): HttpResourceRef<Pets | string | undefined> {
  const buildRequest = () => {
    const request = {
      url: `/v${version?.() ?? 1}/pets`,
      params: filterParams(params?.() ?? {}, new Set<string>([])),
    };
    const normalizedRequest: HttpResourceRequest = request;
    return {
      ...normalizedRequest,
      headers: normalizedRequest.headers instanceof HttpHeaders
        ? normalizedRequest.headers.set('Accept', accept)
        : { ...(normalizedRequest.headers ?? {}), Accept: accept },
    };
  };

  if (accept.includes('json') || accept.includes('+json')) {
    return httpResource<Pets>(buildRequest, options as unknown as OrvalHttpResourceOptions<Pets, unknown>);
  }
  ...
}

buildRequest is invoked fresh every time httpResource's reactive machinery re-runs the factory (creation and every subsequent signal change), so version?.()/params?.()/path-param reads happen at the right time — while accept (a plain argument, not a signal) still only needs to be inspected once to choose which httpResource.xxx call to make. headers moves inside the closure too since it depends on normalizedRequest.headers, which is itself now recomputed per-invocation (options?.headers — if user-supplied — is presumably also signal-backed via options, so recomputing is strictly more correct, not just neutral).

Test coverage

Regression coverage should assert, for a generated multi-content-type resource, that params()/version()/path-param signal reads occur textually inside the arrow function passed to httpResource/httpResource.text/etc., not before it — e.g. a snapshot assertion plus (if feasible in the packages/angular test harness) a TestBed-based reactivity test: update an input signal after resource creation and assert a new HTTP request fires with the updated value.

Affected areas

  • packages/angular/src/http-resource.ts (the multi-content-type resource builder, ~lines 960–1085, and buildResourceRequest, ~lines 505–528)

Related issues

Metadata

Metadata

Assignees

Labels

angularRelated to Angular generation issues

Type

No type

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions