Skip to content

Commit 1bafe11

Browse files
fix(core): adds transfer cache to httpResource to fix hydration
This should prevent the microtask problem with hydration and httpResource. fixes: #62897
1 parent de0cc81 commit 1bafe11

5 files changed

Lines changed: 141 additions & 24 deletions

File tree

packages/common/http/src/resource.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import {
2020
ɵRuntimeError,
2121
ɵRuntimeErrorCode,
2222
ɵencapsulateResourceError as encapsulateResourceError,
23+
TransferState,
24+
untracked,
2325
} from '@angular/core';
2426
import type {Subscription} from 'rxjs';
2527

@@ -29,6 +31,7 @@ import {HttpErrorResponse, HttpEventType, HttpProgressEvent} from './response';
2931
import {HttpHeaders} from './headers';
3032
import {HttpParams} from './params';
3133
import {HttpResourceRef, HttpResourceOptions, HttpResourceRequest} from './resource_api';
34+
import {CACHE_OPTIONS, HTTP_TRANSFER_CACHE_ORIGIN_MAP, StateFromCache} from './transfer_cache';
3235

3336
/**
3437
* Type for the `httpRequest` top-level function, which includes the call signatures for the JSON-
@@ -234,13 +237,44 @@ function makeHttpResourceFn<TRaw>(responseType: ResponseType) {
234237
assertInInjectionContext(httpResource);
235238
}
236239
const injector = options?.injector ?? inject(Injector);
240+
241+
let defaultValue: TResult | undefined = options?.defaultValue;
242+
const cacheOptions = injector.get(CACHE_OPTIONS, null, {optional: true});
243+
const transferState = injector.get(TransferState, null, {optional: true});
244+
const originMap = injector.get(HTTP_TRANSFER_CACHE_ORIGIN_MAP, null, {optional: true});
245+
246+
let initialStream: Signal<ResourceStreamItem<TResult>> | undefined;
247+
248+
if (cacheOptions && transferState) {
249+
const req = untracked(() => normalizeRequest(request, responseType));
250+
if (req) {
251+
const cachedResponse = StateFromCache(req, cacheOptions, transferState, originMap);
252+
if (cachedResponse) {
253+
try {
254+
const body = cachedResponse.body as TRaw;
255+
defaultValue = options?.parse ? options.parse(body) : (body as unknown as TResult);
256+
initialStream = signal({value: defaultValue});
257+
} catch (e) {
258+
if (typeof ngDevMode === 'undefined' || ngDevMode) {
259+
console.warn(
260+
`Angular detected an error while parsing the cached response for the httpResource at \`${req.url}\`. ` +
261+
`The resource will fall back to its default value and try again asynchronously.`,
262+
e,
263+
);
264+
}
265+
}
266+
}
267+
}
268+
}
269+
237270
return new HttpResourceImpl(
238271
injector,
239272
() => normalizeRequest(request, responseType),
240-
options?.defaultValue,
273+
defaultValue as TResult,
241274
options?.debugName,
242275
options?.parse as (value: unknown) => TResult,
243276
options?.equal as ValueEqualityFn<unknown>,
277+
initialStream,
244278
) as HttpResourceRef<TResult>;
245279
};
246280
}
@@ -326,6 +360,7 @@ class HttpResourceImpl<T>
326360
debugName?: string,
327361
parse?: (value: unknown) => T,
328362
equal?: ValueEqualityFn<unknown>,
363+
initialStream?: Signal<ResourceStreamItem<T>>,
329364
) {
330365
super(
331366
request,
@@ -393,6 +428,7 @@ class HttpResourceImpl<T>
393428
equal,
394429
debugName,
395430
injector,
431+
initialStream,
396432
);
397433
this.client = injector.get(HttpClient);
398434
}

packages/common/http/src/transfer_cache.ts

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ interface CacheOptions extends HttpTransferCacheOptions {
114114
isCacheActive: boolean;
115115
}
116116

117-
const CACHE_OPTIONS = new InjectionToken<CacheOptions>(
117+
export const CACHE_OPTIONS = new InjectionToken<CacheOptions>(
118118
typeof ngDevMode !== 'undefined' && ngDevMode ? 'HTTP_TRANSFER_STATE_CACHE_OPTIONS' : '',
119119
);
120120

@@ -123,11 +123,13 @@ const CACHE_OPTIONS = new InjectionToken<CacheOptions>(
123123
*/
124124
const ALLOWED_METHODS = ['GET', 'HEAD'];
125125

126-
export function transferCacheInterceptorFn(
126+
export function StateFromCache(
127127
req: HttpRequest<unknown>,
128-
next: HttpHandlerFn,
129-
): Observable<HttpEvent<unknown>> {
130-
const {isCacheActive, ...globalOptions} = inject(CACHE_OPTIONS);
128+
options: CacheOptions,
129+
transferState: TransferState,
130+
originMap: Record<string, string> | null,
131+
): HttpResponse<unknown> | null {
132+
const {isCacheActive, ...globalOptions} = options;
131133
const {transferCache: requestOptions, method: requestMethod} = req;
132134

133135
// In the following situations we do not want to cache the request
@@ -141,15 +143,9 @@ export function transferCacheInterceptorFn(
141143
(!globalOptions.includeRequestsWithAuthHeaders && hasAuthHeaders(req)) ||
142144
globalOptions.filter?.(req) === false
143145
) {
144-
return next(req);
146+
return null;
145147
}
146148

147-
const transferState = inject(TransferState);
148-
149-
const originMap: Record<string, string> | null = inject(HTTP_TRANSFER_CACHE_ORIGIN_MAP, {
150-
optional: true,
151-
});
152-
153149
if (typeof ngServerMode !== 'undefined' && !ngServerMode && originMap) {
154150
throw new RuntimeError(
155151
RuntimeErrorCode.HTTP_ORIGIN_MAP_USED_IN_CLIENT,
@@ -206,15 +202,59 @@ export function transferCacheInterceptorFn(
206202
headers = appendMissingHeadersDetection(req.url, headers, headersToInclude ?? []);
207203
}
208204

209-
return of(
210-
new HttpResponse({
211-
body,
212-
headers,
213-
status,
214-
statusText,
215-
url,
216-
}),
217-
);
205+
return new HttpResponse({
206+
body,
207+
headers,
208+
status,
209+
statusText,
210+
url,
211+
});
212+
}
213+
214+
return null;
215+
}
216+
217+
export function transferCacheInterceptorFn(
218+
req: HttpRequest<unknown>,
219+
next: HttpHandlerFn,
220+
): Observable<HttpEvent<unknown>> {
221+
const options = inject(CACHE_OPTIONS);
222+
const transferState = inject(TransferState);
223+
224+
const originMap: Record<string, string> | null = inject(HTTP_TRANSFER_CACHE_ORIGIN_MAP, {
225+
optional: true,
226+
});
227+
228+
const cachedResponse = StateFromCache(req, options, transferState, originMap);
229+
if (cachedResponse) {
230+
return of(cachedResponse);
231+
}
232+
233+
const {isCacheActive, ...globalOptions} = options;
234+
const {transferCache: requestOptions, method: requestMethod} = req;
235+
let headersToInclude = globalOptions.includeHeaders;
236+
if (typeof requestOptions === 'object' && requestOptions.includeHeaders) {
237+
headersToInclude = requestOptions.includeHeaders;
238+
}
239+
240+
const requestUrl =
241+
typeof ngServerMode !== 'undefined' && ngServerMode && originMap
242+
? mapRequestOriginUrl(req.url, originMap)
243+
: req.url;
244+
const storeKey = makeCacheKey(req, requestUrl);
245+
246+
// In the following situations we do not want to cache the request
247+
if (
248+
!isCacheActive ||
249+
requestOptions === false ||
250+
// POST requests are allowed either globally or at request level
251+
(requestMethod === 'POST' && !globalOptions.includePostRequests && !requestOptions) ||
252+
(requestMethod !== 'POST' && !ALLOWED_METHODS.includes(requestMethod)) ||
253+
// Do not cache request that require authorization when includeRequestsWithAuthHeaders is falsey
254+
(!globalOptions.includeRequestsWithAuthHeaders && hasAuthHeaders(req)) ||
255+
globalOptions.filter?.(req) === false
256+
) {
257+
return next(req);
218258
}
219259

220260
const event$ = next(req);

packages/common/http/test/resource_spec.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
HttpResourceRef,
1919
} from '../index';
2020
import {HttpTestingController, provideHttpClientTesting} from '../testing';
21+
import {withHttpTransferCache} from '../src/transfer_cache';
22+
import {HttpClient} from '../src/client';
2123

2224
describe('httpResource', () => {
2325
beforeEach(() => {
@@ -400,4 +402,39 @@ describe('httpResource', () => {
400402
}
401403
});
402404
});
405+
406+
describe('TransferCache integration', () => {
407+
beforeEach(() => {
408+
TestBed.resetTestingModule();
409+
TestBed.configureTestingModule({
410+
providers: [provideHttpClient(), provideHttpClientTesting(), withHttpTransferCache({})],
411+
});
412+
});
413+
414+
it('should synchronously resolve with a cached value from TransferState', async () => {
415+
globalThis['ngServerMode'] = true;
416+
let requestResolved = false;
417+
TestBed.inject(HttpClient)
418+
.get('/data')
419+
.subscribe(() => (requestResolved = true));
420+
const req = TestBed.inject(HttpTestingController).expectOne('/data');
421+
req.flush([1, 2, 3]);
422+
423+
expect(requestResolved).toBe(true);
424+
425+
// Now switch to client mode
426+
globalThis['ngServerMode'] = false;
427+
428+
// Create httpResource. It should immediately read from TransferState.
429+
const res = httpResource(() => '/data', {injector: TestBed.inject(Injector)});
430+
431+
// It should immediately have the value synchronously and status should be resolved
432+
expect(res.status()).toBe('resolved');
433+
expect(res.hasValue()).toBe(true);
434+
expect(res.value()).toEqual([1, 2, 3]);
435+
436+
// Also no new request should be made
437+
TestBed.inject(HttpTestingController).expectNone('/data');
438+
});
439+
});
403440
});

packages/core/src/resource/resource.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
196196
private readonly equal: ValueEqualityFn<T> | undefined,
197197
private readonly debugName: string | undefined,
198198
injector: Injector,
199+
private readonly initialStream?: Signal<ResourceStreamItem<T>>,
199200
) {
200201
super(
201202
// Feed a computed signal for the value to `BaseWritableResource`, which will upgrade it to a
@@ -238,15 +239,17 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
238239
source: this.extRequest,
239240
// Compute the state of the resource given a change in status.
240241
computation: (extRequest, previous) => {
241-
const status = extRequest.request === undefined ? 'idle' : 'loading';
242242
if (!previous) {
243+
const status =
244+
extRequest.request === undefined ? 'idle' : initialStream ? 'resolved' : 'loading';
243245
return {
244246
extRequest,
245247
status,
246248
previousStatus: 'idle',
247-
stream: undefined,
249+
stream: initialStream,
248250
};
249251
} else {
252+
const status = extRequest.request === undefined ? 'idle' : 'loading';
250253
return {
251254
extRequest,
252255
status,

packages/core/test/bundling/hydration/bundle.golden_symbols.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@
214214
"SharedStylesHost",
215215
"SimpleChange",
216216
"StandaloneService",
217+
"StateFromCache",
217218
"Subject",
218219
"Subscriber",
219220
"Subscription",

0 commit comments

Comments
 (0)