mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
fix(core): adds transfer cache to httpResource to fix hydration
This should prevent the microtask problem with hydration and httpResource. fixes: #62897
This commit is contained in:
committed by
Jessica Janiuk
parent
e1bc342856
commit
88685cb3b6
@@ -20,6 +20,8 @@ import {
|
||||
ɵRuntimeError,
|
||||
ɵRuntimeErrorCode,
|
||||
ɵencapsulateResourceError as encapsulateResourceError,
|
||||
TransferState,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import type {Subscription} from 'rxjs';
|
||||
|
||||
@@ -29,6 +31,11 @@ import {HttpErrorResponse, HttpEventType, HttpProgressEvent} from './response';
|
||||
import {HttpHeaders} from './headers';
|
||||
import {HttpParams} from './params';
|
||||
import {HttpResourceRef, HttpResourceOptions, HttpResourceRequest} from './resource_api';
|
||||
import {
|
||||
CACHE_OPTIONS,
|
||||
HTTP_TRANSFER_CACHE_ORIGIN_MAP,
|
||||
retrieveStateFromCache,
|
||||
} from './transfer_cache';
|
||||
|
||||
/**
|
||||
* Type for the `httpRequest` top-level function, which includes the call signatures for the JSON-
|
||||
@@ -234,13 +241,41 @@ function makeHttpResourceFn<TRaw>(responseType: ResponseType) {
|
||||
assertInInjectionContext(httpResource);
|
||||
}
|
||||
const injector = options?.injector ?? inject(Injector);
|
||||
|
||||
const cacheOptions = injector.get(CACHE_OPTIONS, null, {optional: true});
|
||||
const transferState = injector.get(TransferState, null, {optional: true});
|
||||
const originMap = injector.get(HTTP_TRANSFER_CACHE_ORIGIN_MAP, null, {optional: true});
|
||||
|
||||
const getInitialStream = (req: HttpRequest<unknown> | undefined) => {
|
||||
if (cacheOptions && transferState && req) {
|
||||
const cachedResponse = retrieveStateFromCache(req, cacheOptions, transferState, originMap);
|
||||
if (cachedResponse) {
|
||||
try {
|
||||
const body = cachedResponse.body as TRaw;
|
||||
const parsed = options?.parse ? options.parse(body) : (body as unknown as TResult);
|
||||
return signal({value: parsed});
|
||||
} catch (e) {
|
||||
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
||||
console.warn(
|
||||
`Angular detected an error while parsing the cached response for the httpResource at \`${req.url}\`. ` +
|
||||
`The resource will fall back to its default value and try again asynchronously.`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return new HttpResourceImpl(
|
||||
injector,
|
||||
() => normalizeRequest(request, responseType),
|
||||
options?.defaultValue,
|
||||
options?.defaultValue as TResult,
|
||||
options?.debugName,
|
||||
options?.parse as (value: unknown) => TResult,
|
||||
options?.equal as ValueEqualityFn<unknown>,
|
||||
getInitialStream,
|
||||
) as HttpResourceRef<TResult>;
|
||||
};
|
||||
}
|
||||
@@ -321,11 +356,14 @@ class HttpResourceImpl<T>
|
||||
|
||||
constructor(
|
||||
injector: Injector,
|
||||
request: () => HttpRequest<T> | undefined,
|
||||
request: () => HttpRequest<unknown> | undefined,
|
||||
defaultValue: T,
|
||||
debugName?: string,
|
||||
parse?: (value: unknown) => T,
|
||||
equal?: ValueEqualityFn<unknown>,
|
||||
getInitialStream?: (
|
||||
request: HttpRequest<unknown> | undefined,
|
||||
) => Signal<ResourceStreamItem<T>> | undefined,
|
||||
) {
|
||||
super(
|
||||
request,
|
||||
@@ -393,6 +431,7 @@ class HttpResourceImpl<T>
|
||||
equal,
|
||||
debugName,
|
||||
injector,
|
||||
getInitialStream,
|
||||
);
|
||||
this.client = injector.get(HttpClient);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ interface CacheOptions extends HttpTransferCacheOptions {
|
||||
isCacheActive: boolean;
|
||||
}
|
||||
|
||||
const CACHE_OPTIONS = new InjectionToken<CacheOptions>(
|
||||
export const CACHE_OPTIONS = new InjectionToken<CacheOptions>(
|
||||
typeof ngDevMode !== 'undefined' && ngDevMode ? 'HTTP_TRANSFER_STATE_CACHE_OPTIONS' : '',
|
||||
);
|
||||
|
||||
@@ -123,14 +123,10 @@ const CACHE_OPTIONS = new InjectionToken<CacheOptions>(
|
||||
*/
|
||||
const ALLOWED_METHODS = ['GET', 'HEAD'];
|
||||
|
||||
export function transferCacheInterceptorFn(
|
||||
req: HttpRequest<unknown>,
|
||||
next: HttpHandlerFn,
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
const {isCacheActive, ...globalOptions} = inject(CACHE_OPTIONS);
|
||||
function shouldCacheRequest(req: HttpRequest<unknown>, options: CacheOptions): boolean {
|
||||
const {isCacheActive, ...globalOptions} = options;
|
||||
const {transferCache: requestOptions, method: requestMethod} = req;
|
||||
|
||||
// In the following situations we do not want to cache the request
|
||||
if (
|
||||
!isCacheActive ||
|
||||
requestOptions === false ||
|
||||
@@ -141,14 +137,37 @@ export function transferCacheInterceptorFn(
|
||||
(!globalOptions.includeRequestsWithAuthHeaders && hasAuthHeaders(req)) ||
|
||||
globalOptions.filter?.(req) === false
|
||||
) {
|
||||
return next(req);
|
||||
return false;
|
||||
}
|
||||
|
||||
const transferState = inject(TransferState);
|
||||
return true;
|
||||
}
|
||||
|
||||
const originMap: Record<string, string> | null = inject(HTTP_TRANSFER_CACHE_ORIGIN_MAP, {
|
||||
optional: true,
|
||||
});
|
||||
function getHeadersToInclude(
|
||||
options: CacheOptions,
|
||||
requestOptions: HttpTransferCacheOptions | boolean | undefined,
|
||||
): string[] | undefined {
|
||||
const {includeHeaders: globalHeaders} = options;
|
||||
let headersToInclude = globalHeaders;
|
||||
if (typeof requestOptions === 'object' && requestOptions.includeHeaders) {
|
||||
// Request-specific config takes precedence over the global config.
|
||||
headersToInclude = requestOptions.includeHeaders;
|
||||
}
|
||||
return headersToInclude;
|
||||
}
|
||||
|
||||
export function retrieveStateFromCache(
|
||||
req: HttpRequest<unknown>,
|
||||
options: CacheOptions,
|
||||
transferState: TransferState,
|
||||
originMap: Record<string, string> | null,
|
||||
): HttpResponse<unknown> | null {
|
||||
const {transferCache: requestOptions} = req;
|
||||
|
||||
// In the following situations we do not want to cache the request
|
||||
if (!shouldCacheRequest(req, options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof ngServerMode !== 'undefined' && !ngServerMode && originMap) {
|
||||
throw new RuntimeError(
|
||||
@@ -168,11 +187,7 @@ export function transferCacheInterceptorFn(
|
||||
const storeKey = makeCacheKey(req, requestUrl);
|
||||
const response = transferState.get(storeKey, null);
|
||||
|
||||
let headersToInclude = globalOptions.includeHeaders;
|
||||
if (typeof requestOptions === 'object' && requestOptions.includeHeaders) {
|
||||
// Request-specific config takes precedence over the global config.
|
||||
headersToInclude = requestOptions.includeHeaders;
|
||||
}
|
||||
const headersToInclude = getHeadersToInclude(options, requestOptions);
|
||||
|
||||
if (response) {
|
||||
const {
|
||||
@@ -206,15 +221,44 @@ export function transferCacheInterceptorFn(
|
||||
headers = appendMissingHeadersDetection(req.url, headers, headersToInclude ?? []);
|
||||
}
|
||||
|
||||
return of(
|
||||
new HttpResponse({
|
||||
body,
|
||||
headers,
|
||||
status,
|
||||
statusText,
|
||||
url,
|
||||
}),
|
||||
);
|
||||
return new HttpResponse({
|
||||
body,
|
||||
headers,
|
||||
status,
|
||||
statusText,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function transferCacheInterceptorFn(
|
||||
req: HttpRequest<unknown>,
|
||||
next: HttpHandlerFn,
|
||||
): Observable<HttpEvent<unknown>> {
|
||||
const options = inject(CACHE_OPTIONS);
|
||||
const transferState = inject(TransferState);
|
||||
|
||||
const originMap = inject(HTTP_TRANSFER_CACHE_ORIGIN_MAP, {optional: true});
|
||||
|
||||
const cachedResponse = retrieveStateFromCache(req, options, transferState, originMap);
|
||||
if (cachedResponse) {
|
||||
return of(cachedResponse);
|
||||
}
|
||||
|
||||
const {transferCache: requestOptions} = req;
|
||||
const headersToInclude = getHeadersToInclude(options, requestOptions);
|
||||
|
||||
const requestUrl =
|
||||
typeof ngServerMode !== 'undefined' && ngServerMode && originMap
|
||||
? mapRequestOriginUrl(req.url, originMap)
|
||||
: req.url;
|
||||
const storeKey = makeCacheKey(req, requestUrl);
|
||||
|
||||
// In the following situations we do not want to cache the request
|
||||
if (!shouldCacheRequest(req, options)) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const event$ = next(req);
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
HttpResourceRef,
|
||||
} from '../index';
|
||||
import {HttpTestingController, provideHttpClientTesting} from '../testing';
|
||||
import {withHttpTransferCache} from '../src/transfer_cache';
|
||||
import {HttpClient} from '../src/client';
|
||||
|
||||
describe('httpResource', () => {
|
||||
beforeEach(() => {
|
||||
@@ -400,4 +402,59 @@ describe('httpResource', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('TransferCache integration', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), withHttpTransferCache({})],
|
||||
});
|
||||
});
|
||||
|
||||
it('should synchronously resolve with a cached value from TransferState', async () => {
|
||||
globalThis['ngServerMode'] = true;
|
||||
let requestResolved = false;
|
||||
TestBed.inject(HttpClient)
|
||||
.get('/data')
|
||||
.subscribe(() => (requestResolved = true));
|
||||
const req = TestBed.inject(HttpTestingController).expectOne('/data');
|
||||
req.flush([1, 2, 3]);
|
||||
|
||||
expect(requestResolved).toBe(true);
|
||||
|
||||
// Now switch to client mode
|
||||
globalThis['ngServerMode'] = false;
|
||||
|
||||
// Create httpResource. It should immediately read from TransferState.
|
||||
const res = httpResource(() => '/data', {injector: TestBed.inject(Injector)});
|
||||
|
||||
// It should immediately have the value synchronously and status should be resolved
|
||||
expect(res.status()).toBe('resolved');
|
||||
expect(res.hasValue()).toBe(true);
|
||||
expect(res.value()).toEqual([1, 2, 3]);
|
||||
|
||||
// Also no new request should be made
|
||||
TestBed.inject(HttpTestingController).expectNone('/data');
|
||||
});
|
||||
|
||||
it('should not evaluate the request payload during resource initialization', () => {
|
||||
let requestEvaluated = false;
|
||||
const res = httpResource(
|
||||
() => {
|
||||
requestEvaluated = true;
|
||||
return '/data';
|
||||
},
|
||||
{injector: TestBed.inject(Injector)},
|
||||
);
|
||||
|
||||
// Request function should NOT be evaluated during initialization
|
||||
expect(requestEvaluated).toBe(false);
|
||||
|
||||
// Read to trigger it
|
||||
res.status();
|
||||
|
||||
// The request should now have been evaluated
|
||||
expect(requestEvaluated).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,6 +196,7 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
|
||||
private readonly equal: ValueEqualityFn<T> | undefined,
|
||||
private readonly debugName: string | undefined,
|
||||
injector: Injector,
|
||||
getInitialStream?: (request: R) => Signal<ResourceStreamItem<T>> | undefined,
|
||||
) {
|
||||
super(
|
||||
// Feed a computed signal for the value to `BaseWritableResource`, which will upgrade it to a
|
||||
@@ -238,15 +239,20 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
|
||||
source: this.extRequest,
|
||||
// Compute the state of the resource given a change in status.
|
||||
computation: (extRequest, previous) => {
|
||||
const status = extRequest.request === undefined ? 'idle' : 'loading';
|
||||
if (!previous) {
|
||||
const initialStream = getInitialStream?.(extRequest.request as R);
|
||||
// Clear getInitialStream so it doesn't hold onto memory
|
||||
getInitialStream = undefined;
|
||||
const status =
|
||||
extRequest.request === undefined ? 'idle' : initialStream ? 'resolved' : 'loading';
|
||||
return {
|
||||
extRequest,
|
||||
status,
|
||||
previousStatus: 'idle',
|
||||
stream: undefined,
|
||||
stream: initialStream,
|
||||
};
|
||||
} else {
|
||||
const status = extRequest.request === undefined ? 'idle' : 'loading';
|
||||
return {
|
||||
extRequest,
|
||||
status,
|
||||
|
||||
@@ -476,6 +476,7 @@
|
||||
"getFactoryDef",
|
||||
"getFirstLContainer",
|
||||
"getGlobalLocale",
|
||||
"getHeadersToInclude",
|
||||
"getInheritedInjectableDef",
|
||||
"getInitialLViewFlagsFromDef",
|
||||
"getInjectFlag",
|
||||
@@ -722,6 +723,7 @@
|
||||
"resolveForwardRef",
|
||||
"retrieveHydrationInfo",
|
||||
"retrieveHydrationInfoImpl",
|
||||
"retrieveStateFromCache",
|
||||
"retrieveTransferredState",
|
||||
"runAfterLeaveAnimations",
|
||||
"runEffectsInView",
|
||||
@@ -770,6 +772,7 @@
|
||||
"shimHostAttribute",
|
||||
"shimStylesContent",
|
||||
"shouldBeIgnoredByZone",
|
||||
"shouldCacheRequest",
|
||||
"shouldSearchParent",
|
||||
"siblingAfter",
|
||||
"skipTextNodes",
|
||||
|
||||
Reference in New Issue
Block a user