Skip to content

Commit 6af5943

Browse files
committed
feat(core): add ability to cache resources for SSR
This commit adds a `transferCacheKey` option to enable easy caching for `resource`/ `rxResource`. By caching resource data we make sure that resources are not in a loading state during hydration on the client side and responsible for destroying server hydrated DOM. fixes #62897
1 parent 2071eab commit 6af5943

File tree

6 files changed

+220
-2
lines changed

6 files changed

+220
-2
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export interface BaseResourceOptions<T, R> {
186186
equal?: ValueEqualityFn<T>;
187187
injector?: Injector;
188188
params?: (ctx: ResourceParamsContext) => R;
189+
transferCacheKey?: (params: R) => StateKey<T>;
189190
}
190191

191192
// @public

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: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import {BehaviorSubject, EMPTY, Observable, of, Subscriber, throwError} from 'rxjs';
10-
import {ApplicationRef, Injector, signal} from '../../src/core';
10+
import {ApplicationRef, Injector, makeStateKey, signal, TransferState} from '../../src/core';
1111
import {TestBed} from '../../testing';
1212
import {rxResource} from '../src';
1313

@@ -175,10 +175,110 @@ describe('rxResource()', () => {
175175
expect(res.error()).toBeInstanceOf(Error);
176176
expect(() => res.value()).toThrowError(/bad news/);
177177
});
178+
179+
describe('with TransferState', () => {
180+
let transferState: TransferState;
181+
182+
beforeEach(() => {
183+
TestBed.configureTestingModule({providers: [TransferState]});
184+
transferState = TestBed.inject(TransferState);
185+
});
186+
187+
afterEach(() => {
188+
(globalThis as any).ngServerMode = undefined;
189+
});
190+
191+
it('should read from TransferState if a key is present', async () => {
192+
const key = makeStateKey<number>('test-key');
193+
transferState.set(key, 123);
194+
195+
const injector = TestBed.inject(Injector);
196+
const testResource = rxResource({
197+
stream: () => of(456),
198+
transferCacheKey: () => key,
199+
injector,
200+
});
201+
202+
// Should be synchronously resolved from cache
203+
expect(testResource.status()).toBe('resolved');
204+
expect(testResource.value()).toBe(123);
205+
206+
// Should prevent loader from running
207+
await flushMicrotasks();
208+
expect(testResource.value()).toBe(123);
209+
});
210+
211+
it('should write to TransferState on server when resolved (sync)', async () => {
212+
(globalThis as any).ngServerMode = true;
213+
const key = makeStateKey<number>('server-key');
214+
215+
const injector = TestBed.inject(Injector);
216+
const testResource = rxResource({
217+
stream: () => of(789),
218+
transferCacheKey: () => key,
219+
injector,
220+
});
221+
222+
expect(testResource.status()).toBe('loading');
223+
224+
await flushMicrotasks();
225+
226+
expect(testResource.status()).toBe('resolved');
227+
expect(testResource.value()).toBe(789);
228+
expect(transferState.get(key, null!)).toBe(789);
229+
});
230+
231+
it('should write to TransferState on server when resolved (async)', async () => {
232+
(globalThis as any).ngServerMode = true;
233+
const key = makeStateKey<number>('server-async-key');
234+
235+
const injector = TestBed.inject(Injector);
236+
const testResource = rxResource({
237+
stream: () =>
238+
new Observable<number>((sub) => {
239+
Promise.resolve().then(() => {
240+
sub.next(101112);
241+
sub.complete();
242+
});
243+
}),
244+
transferCacheKey: () => key,
245+
injector,
246+
});
247+
248+
expect(testResource.status()).toBe('loading');
249+
250+
await waitFor(() => testResource.status() === 'resolved');
251+
252+
expect(testResource.value()).toBe(101112);
253+
expect(transferState.get(key, null!)).toBe(101112);
254+
});
255+
256+
it('should not write to TransferState on client when resolved', async () => {
257+
(globalThis as any).ngServerMode = false;
258+
const key = makeStateKey<number>('client-key');
259+
260+
const injector = TestBed.inject(Injector);
261+
const testResource = rxResource({
262+
stream: () => of(131415),
263+
transferCacheKey: () => key,
264+
injector,
265+
});
266+
267+
await flushMicrotasks();
268+
269+
expect(testResource.status()).toBe('resolved');
270+
expect(testResource.value()).toBe(131415);
271+
expect(transferState.hasKey(key)).toBeFalse();
272+
});
273+
});
178274
});
179275

180276
async function waitFor(fn: () => boolean): Promise<void> {
181277
while (!fn()) {
182278
await new Promise((resolve) => setTimeout(resolve, 1));
183279
}
184280
}
281+
282+
function flushMicrotasks(): Promise<void> {
283+
return new Promise((resolve) => setTimeout(resolve, 0));
284+
}

packages/core/src/resource/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {Injector} from '../di/injector';
1010
import {Signal, ValueEqualityFn} from '../render3/reactivity/api';
1111
import {WritableSignal} from '../render3/reactivity/signal';
12+
import {StateKey} from '../transfer_state';
1213

1314
/** Error thrown when a `Resource` dependency of another resource errors. */
1415
export class ResourceDependencyError extends Error {
@@ -231,6 +232,11 @@ export interface BaseResourceOptions<T, R> {
231232
* Overrides the `Injector` used by `resource`.
232233
*/
233234
injector?: Injector;
235+
236+
/**
237+
* The transfer cache key used to cache the resource data in the `TransferState` during server-side rendering and to retrieve it on the client side.
238+
*/
239+
transferCacheKey?: (params: R) => StateKey<T>;
234240
}
235241

236242
/**

packages/core/src/resource/resource.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {inject} from '../di/injector_compatibility';
3232
import {DestroyRef} from '../linker/destroy_ref';
3333
import {PendingTasks} from '../pending_tasks';
3434
import {linkedSignal} from '../render3/reactivity/linked_signal';
35+
import {StateKey, TransferState} from '../transfer_state';
3536

3637
/**
3738
* Constructs a `Resource` that projects a reactive request to an asynchronous operation defined by
@@ -77,6 +78,7 @@ export function resource<T, R>(options: ResourceOptions<T, R>): ResourceRef<T |
7778
options.equal ? wrapEqualityFn(options.equal) : undefined,
7879
options.debugName,
7980
options.injector ?? inject(Injector),
81+
options.transferCacheKey,
8082
);
8183
}
8284

@@ -194,6 +196,7 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
194196

195197
override readonly status: Signal<ResourceStatus>;
196198
override readonly error: Signal<Error | undefined>;
199+
private readonly transferState: TransferState | undefined;
197200

198201
constructor(
199202
request: (ctx: ResourceParamsContext) => R,
@@ -202,6 +205,7 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
202205
private readonly equal: ValueEqualityFn<T> | undefined,
203206
private readonly debugName: string | undefined,
204207
injector: Injector,
208+
private transferCacheKey: ((request: R) => StateKey<T>) | undefined,
205209
getInitialStream?: (request: R) => Signal<ResourceStreamItem<T>> | undefined,
206210
) {
207211
super(
@@ -231,6 +235,8 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
231235
debugName,
232236
);
233237

238+
this.transferState = injector.get(TransferState, undefined, {optional: true}) ?? undefined;
239+
234240
this.extRequest = linkedSignal<WrappedRequest>(
235241
() => {
236242
try {
@@ -265,7 +271,19 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
265271
);
266272
} else if (!status) {
267273
if (!previous) {
268-
stream = getInitialStream?.(extRequest.request as R);
274+
if (this.transferCacheKey && this.transferState && request !== undefined) {
275+
const key = this.transferCacheKey(request as R);
276+
if (this.transferState.hasKey(key)) {
277+
stream = signal(
278+
{value: this.transferState.get(key, null!)},
279+
ngDevMode ? createDebugNameObject(this.debugName, 'stream') : undefined,
280+
);
281+
}
282+
}
283+
284+
if (!stream) {
285+
stream = getInitialStream?.(extRequest.request as R);
286+
}
269287
// Clear getInitialStream so it doesn't hold onto memory
270288
getInitialStream = undefined;
271289
status = request === undefined ? 'idle' : stream ? 'resolved' : 'loading';
@@ -437,6 +455,18 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
437455
previousStatus: 'resolved',
438456
stream,
439457
});
458+
459+
const result = untracked(stream);
460+
if (
461+
typeof ngServerMode !== 'undefined' &&
462+
ngServerMode &&
463+
this.transferCacheKey &&
464+
this.transferState &&
465+
isResolved(result)
466+
) {
467+
const key = this.transferCacheKey(extRequest.request as R);
468+
this.transferState.set(key, result.value);
469+
}
440470
} else {
441471
const resolvedStream = await stream;
442472
if (shouldDiscard()) {
@@ -449,6 +479,20 @@ export class ResourceImpl<T, R> extends BaseWritableResource<T> implements Resou
449479
previousStatus: 'resolved',
450480
stream: resolvedStream,
451481
});
482+
483+
// Use a local variable for the result so TypeScript can narrow `resolvedStream` correctly.
484+
const result = resolvedStream ? untracked(resolvedStream) : undefined;
485+
if (
486+
typeof ngServerMode !== 'undefined' &&
487+
ngServerMode &&
488+
this.transferCacheKey &&
489+
this.transferState &&
490+
result &&
491+
isResolved(result)
492+
) {
493+
const key = this.transferCacheKey(extRequest.request as R);
494+
this.transferState.set(key, result.value);
495+
}
452496
}
453497
} catch (err) {
454498
if (abortSignal.aborted || untracked(this.extRequest) !== extRequest) {

packages/core/test/resource/resource_spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ import {
1616
Injector,
1717
Input,
1818
inputBinding,
19+
makeStateKey,
1920
resource,
2021
ResourceRef,
2122
ResourceStatus,
2223
signal,
24+
TransferState,
2325
} from '../../src/core';
2426
import {promiseWithResolvers} from '../../src/util/promise_with_resolvers';
2527
import {TestBed} from '../../testing';
@@ -1117,3 +1119,67 @@ function extractError(fn: () => unknown): Error | undefined {
11171119
return err as Error;
11181120
}
11191121
}
1122+
1123+
describe('with TransferState', () => {
1124+
let transferState: TransferState;
1125+
1126+
beforeEach(() => {
1127+
TestBed.configureTestingModule({providers: [TransferState]});
1128+
transferState = TestBed.inject(TransferState);
1129+
});
1130+
1131+
it('should read from TransferState if a key is present', async () => {
1132+
const key = makeStateKey<number>('test-key');
1133+
transferState.set(key, 123);
1134+
1135+
const testResource = resource({
1136+
loader: async () => 456,
1137+
transferCacheKey: () => key,
1138+
injector: TestBed.inject(Injector),
1139+
});
1140+
1141+
// Should be synchronously resolved from cache
1142+
expect(testResource.status()).toBe('resolved');
1143+
expect(testResource.value()).toBe(123);
1144+
1145+
// Should prevent loader from running
1146+
await flushMicrotasks();
1147+
expect(testResource.value()).toBe(123);
1148+
});
1149+
1150+
it('should write to TransferState on server when resolved', async () => {
1151+
(globalThis as any).ngServerMode = true;
1152+
const key = makeStateKey<number>('server-key');
1153+
1154+
const testResource = resource({
1155+
loader: async () => 789,
1156+
transferCacheKey: () => key,
1157+
injector: TestBed.inject(Injector),
1158+
});
1159+
1160+
expect(testResource.status()).toBe('loading');
1161+
1162+
await flushMicrotasks();
1163+
1164+
expect(testResource.status()).toBe('resolved');
1165+
expect(testResource.value()).toBe(789);
1166+
expect(transferState.get(key, null!)).toBe(789);
1167+
(globalThis as any).ngServerMode = undefined;
1168+
});
1169+
1170+
it('should not write to TransferState on client when resolved', async () => {
1171+
const key = makeStateKey<number>('client-key');
1172+
1173+
const testResource = resource({
1174+
loader: async () => 101112,
1175+
transferCacheKey: () => key,
1176+
injector: TestBed.inject(Injector),
1177+
});
1178+
1179+
await flushMicrotasks();
1180+
1181+
expect(testResource.status()).toBe('resolved');
1182+
expect(testResource.value()).toBe(101112);
1183+
expect(transferState.hasKey(key)).toBeFalse();
1184+
});
1185+
});

0 commit comments

Comments
 (0)