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
Summary
For an operation with multiple response content types (e.g.
Accept-negotiatedapplication/jsonvstext/plainvsapplication/xml), the generated AngularhttpResourcefunction reads all of its reactive inputs — path params, query params, anything embedded in the route — once, synchronously, at call time, instead of inside thehttpResource(() => ...)factory callback. Angular'shttpResourceonly 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 fromtests/specifications/petstore.yaml'slistPetsoperation, which has bothapplication/jsonandapplication/xmlresponses):version?.()andparams?.()are called while buildingrequest, beforehttpResource(...)is invoked. The factory passed tohttpResourcejust spreads the already-resolvednormalizedRequest— it performs no signal reads of its own. Compare with the single-content-typesearchPetsResourcein the same file, generated correctly: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 subsequentnormalizedRequest/headersconstruction happen at the top of the function body, before theaccept-based branching that decides whichhttpResource.xxxvariant to call (httpResource,.text,.arrayBuffer,.blob). This hoisting is done because that branch selection must happen once, synchronously, based on the plainacceptargument (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 buildrequest/route(path params, query params — anything produced bybuildResourceRequest,packages/angular/src/http-resource.ts:505-528) out of any reactive context along with it.This is not limited to query params:
buildResourceRequestembeds the fullroutetemplate literal (including path-param signal calls likepetId()) intorequestregardless of theisUrlOnlyflag (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. Wraprequest/normalizedRequest/headersconstruction in a local closure and pass that closure as the factory, instead of spreading a frozen object literal:buildRequestis invoked fresh every timehttpResource's reactive machinery re-runs the factory (creation and every subsequent signal change), soversion?.()/params?.()/path-param reads happen at the right time — whileaccept(a plain argument, not a signal) still only needs to be inspected once to choose whichhttpResource.xxxcall to make.headersmoves inside the closure too since it depends onnormalizedRequest.headers, which is itself now recomputed per-invocation (options?.headers— if user-supplied — is presumably also signal-backed viaoptions, 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 tohttpResource/httpResource.text/etc., not before it — e.g. a snapshot assertion plus (if feasible in thepackages/angulartest harness) aTestBed-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, andbuildResourceRequest, ~lines 505–528)Related issues
httpResourcecalls an optional request-body signal unconditionally #3700 (open) — same generator file, a different httpResource-factory correctness bug (an optional request-body signal is called unconditionally in mutation-style resources, causing a crash rather than staleness). Same broad theme (httpResource factory-reactivity/correctness gaps), distinct root cause and code path.output.artifacts) — an unrelated feature PR that only emits barrels from already-generated files and cannot fix generator-level reactivity behavior; the affected code (samples/angular-app/src/api/http-resource/pets/pets.service.ts) is unmodified by that PR and reproduces identically on plain master.