Skip to content

Commit 5a7c1e6

Browse files
authored
feat(core): add ability to cache resources for SSR
This commit adds a `transferCacheKey` option to enable easy caching for `resource`/ `rxResource`.
1 parent b8d3f36 commit 5a7c1e6

10 files changed

Lines changed: 279 additions & 6 deletions

File tree

goldens/public-api/core/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export interface AttributeDecorator {
189189
export interface BaseResourceOptions<T, R> {
190190
defaultValue?: NoInfer<T>;
191191
equal?: ValueEqualityFn<T>;
192+
id?: string;
192193
injector?: Injector;
193194
params?: (ctx: ResourceParamsContext) => R;
194195
}

packages/common/http/src/resource.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ class HttpResourceImpl<T>
434434
equal,
435435
debugName,
436436
injector,
437+
undefined,
437438
getInitialStream,
438439
);
439440
this.client = injector.get(HttpClient);

packages/core/rxjs-interop/test/rx_resource_spec.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,24 @@
88

99
import {timeout} from '@angular/private/testing';
1010
import {BehaviorSubject, EMPTY, Observable, of, Subscriber, throwError} from 'rxjs';
11-
import {ApplicationRef, Injector, signal} from '../../src/core';
11+
import {
12+
ApplicationRef,
13+
ɵCACHE_ACTIVE as CACHE_ACTIVE,
14+
Injector,
15+
makeStateKey,
16+
signal,
17+
TransferState,
18+
} from '../../src/core';
1219
import {TestBed} from '../../testing';
1320
import {rxResource} from '../src';
1421

1522
describe('rxResource()', () => {
23+
beforeEach(() => {
24+
TestBed.configureTestingModule({
25+
providers: [{provide: CACHE_ACTIVE, useValue: {isActive: true}}],
26+
});
27+
});
28+
1629
it('should fetch data using an observable loader', async () => {
1730
const injector = TestBed.inject(Injector);
1831
const res = rxResource({
@@ -176,10 +189,110 @@ describe('rxResource()', () => {
176189
expect(res.error()).toBeInstanceOf(Error);
177190
expect(() => res.value()).toThrowError(/bad news/);
178191
});
192+
193+
describe('with TransferState', () => {
194+
let transferState: TransferState;
195+
196+
beforeEach(() => {
197+
TestBed.configureTestingModule({providers: [TransferState]});
198+
transferState = TestBed.inject(TransferState);
199+
});
200+
201+
afterEach(() => {
202+
(globalThis as any).ngServerMode = undefined;
203+
});
204+
205+
it('should read from TransferState if a key is present', async () => {
206+
const key = makeStateKey<number>('test-key');
207+
transferState.set(key, 123);
208+
209+
const injector = TestBed.inject(Injector);
210+
const testResource = rxResource({
211+
stream: () => of(456),
212+
id: key,
213+
injector,
214+
});
215+
216+
// Should be synchronously resolved from cache
217+
expect(testResource.status()).toBe('resolved');
218+
expect(testResource.value()).toBe(123);
219+
220+
// Should prevent loader from running
221+
await flushMicrotasks();
222+
expect(testResource.value()).toBe(123);
223+
});
224+
225+
it('should write to TransferState on server when resolved (sync)', async () => {
226+
(globalThis as any).ngServerMode = true;
227+
const key = makeStateKey<number>('server-key');
228+
229+
const injector = TestBed.inject(Injector);
230+
const testResource = rxResource({
231+
stream: () => of(789),
232+
id: key,
233+
injector,
234+
});
235+
236+
expect(testResource.status()).toBe('loading');
237+
238+
await flushMicrotasks();
239+
240+
expect(testResource.status()).toBe('resolved');
241+
expect(testResource.value()).toBe(789);
242+
expect(transferState.get(key, null!)).toBe(789);
243+
});
244+
245+
it('should write to TransferState on server when resolved (async)', async () => {
246+
(globalThis as any).ngServerMode = true;
247+
const key = makeStateKey<number>('server-async-key');
248+
249+
const injector = TestBed.inject(Injector);
250+
const testResource = rxResource({
251+
stream: () =>
252+
new Observable<number>((sub) => {
253+
Promise.resolve().then(() => {
254+
sub.next(101112);
255+
sub.complete();
256+
});
257+
}),
258+
id: key,
259+
injector,
260+
});
261+
262+
expect(testResource.status()).toBe('loading');
263+
264+
await waitFor(() => testResource.status() === 'resolved');
265+
266+
expect(testResource.value()).toBe(101112);
267+
expect(transferState.get(key, null!)).toBe(101112);
268+
});
269+
270+
it('should not write to TransferState on client when resolved', async () => {
271+
(globalThis as any).ngServerMode = false;
272+
const key = makeStateKey<number>('client-key');
273+
274+
const injector = TestBed.inject(Injector);
275+
const testResource = rxResource({
276+
stream: () => of(131415),
277+
id: key,
278+
injector,
279+
});
280+
281+
await flushMicrotasks();
282+
283+
expect(testResource.status()).toBe('resolved');
284+
expect(testResource.value()).toBe(131415);
285+
expect(transferState.hasKey(key)).toBeFalse();
286+
});
287+
});
179288
});
180289

181290
async function waitFor(fn: () => boolean): Promise<void> {
182291
while (!fn()) {
183292
await timeout(1);
184293
}
185294
}
295+
296+
function flushMicrotasks(): Promise<void> {
297+
return new Promise((resolve) => setTimeout(resolve, 0));
298+
}

packages/core/src/core_private_export.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export {
9595
withI18nSupport as ɵwithI18nSupport,
9696
withIncrementalHydration as ɵwithIncrementalHydration,
9797
} from './hydration/api';
98+
export {CACHE_ACTIVE as ɵCACHE_ACTIVE} from './hydration/cache';
9899
export {withEventReplay as ɵwithEventReplay} from './hydration/event_replay';
99100
export {
100101
EVENT_REPLAY_QUEUE as ɵEVENT_REPLAY_QUEUE,
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {InjectionToken} from '../di';
10+
11+
/**
12+
* Token used to the determine if the transfer cache should be used, for example for resources.
13+
*/
14+
export const CACHE_ACTIVE = new InjectionToken<{isActive: boolean}>(
15+
typeof ngDevMode !== 'undefined' && ngDevMode ? 'STATE_CACHE_ACTIVE' : '',
16+
);

packages/core/src/resource/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,12 @@ export interface BaseResourceOptions<T, R> {
231231
* Overrides the `Injector` used by `resource`.
232232
*/
233233
injector?: Injector;
234+
235+
/**
236+
* Identifier used to cache the resource data in the `TransferState` during server-side rendering and to retrieve it on the client side.
237+
* This value value needs to be identical for both the client and server.
238+
*/
239+
id?: string;
234240
}
235241

236242
/**

packages/core/src/resource/resource.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ import {assertInInjectionContext} from '../di/contextual';
3030
import {Injector} from '../di/injector';
3131
import {inject} from '../di/injector_compatibility';
3232
import {RuntimeError, RuntimeErrorCode} from '../errors';
33+
import {CACHE_ACTIVE} from '../hydration/cache';
3334
import {DestroyRef} from '../linker/destroy_ref';
3435
import {PendingTasks} from '../pending_tasks';
3536
import {linkedSignal} from '../render3/reactivity/linked_signal';
37+
import {StateKey, TransferState} from '../transfer_state';
3638

3739
/**
3840
* Constructs a `Resource` that projects a reactive request to an asynchronous operation defined by
@@ -78,6 +80,7 @@ export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T |
7880
options.equal ? wrapEqualityFn(options.equal) : undefined,
7981
options.debugName,
8082
options.injector ?? inject(Injector),
83+
options.id as StateKey<T>,
8184
);
8285
}
8386

@@ -195,6 +198,7 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
195198

196199
override readonly status: Signal<ResourceStatus>;
197200
override readonly error: Signal<Error | undefined>;
201+
private readonly transferState: TransferState | undefined;
198202

199203
constructor(
200204
request: (ctx: ResourceParamsContext) => R,
@@ -203,6 +207,7 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
203207
private readonly equal: ValueEqualityFn<T> | undefined,
204208
private readonly debugName: string | undefined,
205209
injector: Injector,
210+
private transferCacheKey: StateKey<T> | undefined,
206211
getInitialStream?: (request: R) => Signal<ResourceStreamItem<T>> | undefined,
207212
) {
208213
if (isInParamsFunction()) {
@@ -236,6 +241,10 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
236241
debugName,
237242
);
238243

244+
const cacheState = injector.get(CACHE_ACTIVE, undefined, {optional: true}) ?? {isActive: false};
245+
246+
this.transferState = injector.get(TransferState, undefined, {optional: true}) ?? undefined;
247+
239248
this.extRequest = linkedSignal<WrappedRequest>(
240249
() => {
241250
try {
@@ -274,7 +283,21 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
274283
);
275284
} else if (!status) {
276285
if (!previous) {
277-
stream = getInitialStream?.(extRequest.request as R);
286+
const transferState = this.transferState;
287+
const cacheKey = this.transferCacheKey;
288+
if (cacheState.isActive && cacheKey && transferState && request !== undefined) {
289+
const key = this.transferCacheKey;
290+
if (transferState.hasKey(cacheKey)) {
291+
stream = signal(
292+
{value: transferState.get(cacheKey, defaultValue)},
293+
ngDevMode ? createDebugNameObject(this.debugName, 'stream') : undefined,
294+
);
295+
}
296+
}
297+
298+
if (!stream) {
299+
stream = getInitialStream?.(extRequest.request as R);
300+
}
278301
// Clear getInitialStream so it doesn't hold onto memory
279302
getInitialStream = undefined;
280303
status = request === undefined ? 'idle' : stream ? 'resolved' : 'loading';
@@ -446,6 +469,11 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
446469
previousStatus: 'resolved',
447470
stream,
448471
});
472+
473+
const result = untracked(stream);
474+
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
475+
saveToTransferState(result, this.transferCacheKey, this.transferState);
476+
}
449477
} else {
450478
const resolvedStream = await stream;
451479
if (shouldDiscard()) {
@@ -458,6 +486,12 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
458486
previousStatus: 'resolved',
459487
stream: resolvedStream,
460488
});
489+
490+
// Use a local variable for the result so TypeScript can narrow `resolvedStream` correctly.
491+
const result = resolvedStream ? untracked(resolvedStream) : undefined;
492+
if (typeof ngServerMode !== 'undefined' && ngServerMode) {
493+
saveToTransferState(result, this.transferCacheKey, this.transferState);
494+
}
461495
}
462496
} catch (err) {
463497
rethrowFatalErrors(err);
@@ -491,6 +525,16 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
491525
}
492526
}
493527

528+
function saveToTransferState<R, T>(
529+
result: ResourceStreamItem<T> | undefined,
530+
transferCacheKey: StateKey<T> | undefined,
531+
transferState: TransferState | undefined,
532+
): void {
533+
if (transferCacheKey && transferState && result && isResolved(result)) {
534+
transferState.set(transferCacheKey, result.value);
535+
}
536+
}
537+
494538
/**
495539
* Wraps an equality function to handle either value being `undefined`.
496540
*/

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"BUBBLE_EVENT_TYPES",
3030
"BehaviorSubject",
3131
"BrowserDomAdapter",
32+
"CACHE_ACTIVE",
3233
"CACHE_OPTIONS",
3334
"CAPTURE_EVENT_TYPES",
3435
"CHILD_HEAD",

0 commit comments

Comments
 (0)