Skip to content

Commit 29762b5

Browse files
feat: Added an rxjs cache operator to allow cache control on observables.
This should probably be worked on more, as the api for it is a bit quarky.
1 parent 69381dc commit 29762b5

File tree

7 files changed

+176
-10
lines changed

7 files changed

+176
-10
lines changed

apps/home/src/app/app.component.html

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ <h1>Home</h1>
1010
<button (click)="filter(3)">Widget 3</button>
1111
<button (click)="filter(undefined)">All</button>
1212
<div class="spacer"></div>
13-
<app-login></app-login>
13+
@defer {
14+
<app-login></app-login>
15+
}
1416
</nav>
1517

1618
<section [@widgets]="{ value: widgets() }" [@.disabled]="animationDisabled()">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { of, throwError } from 'rxjs';
2+
import { delay } from 'rxjs/operators';
3+
import { cache, CacheOptions } from './cache';
4+
5+
describe('cache operator', () => {
6+
it('should cache the result of the observable', (done) => {
7+
const callback = jest.fn(() => of('test').pipe(delay(100)));
8+
const options: CacheOptions = {};
9+
10+
const cached$ = cache(callback, options);
11+
12+
let callCount = 0;
13+
cached$.subscribe((value) => {
14+
expect(value).toBe('test');
15+
expect(callback).toHaveBeenCalledTimes(1);
16+
callCount++;
17+
if (callCount === 2) {
18+
done();
19+
}
20+
});
21+
22+
// Subscribe again to ensure the cached value is used
23+
cached$.subscribe((value) => {
24+
expect(value).toBe('test');
25+
expect(callback).toHaveBeenCalledTimes(1);
26+
callCount++;
27+
if (callCount === 2) {
28+
done();
29+
}
30+
});
31+
});
32+
33+
it('should handle errors correctly', (done) => {
34+
const callback = jest.fn(() => throwError(() => new Error('test error')));
35+
const options: CacheOptions = {};
36+
37+
const cached$ = cache(callback, options);
38+
39+
cached$.subscribe({
40+
next: () => ({}),
41+
error: (err) => {
42+
expect(err.message).toBe('test error');
43+
expect(callback).toHaveBeenCalledTimes(1);
44+
done();
45+
},
46+
complete: () => ({}),
47+
});
48+
});
49+
50+
it('should not call the callback again if the cache is still valid', (done) => {
51+
const callback = jest.fn(() => of('test').pipe(delay(100)));
52+
const options: CacheOptions = {};
53+
54+
const cached$ = cache(callback, options);
55+
56+
cached$.subscribe((value) => {
57+
expect(value).toBe('test');
58+
expect(callback).toHaveBeenCalledTimes(1);
59+
cached$.subscribe((value) => {
60+
expect(value).toBe('test');
61+
expect(callback).toHaveBeenCalledTimes(1);
62+
done();
63+
});
64+
});
65+
});
66+
67+
it('should call the callback again if the cache is invalidated', (done) => {
68+
const callback = jest.fn(() => of('test').pipe(delay(100)));
69+
const options: CacheOptions = { expirationTime: 50 };
70+
71+
const cached$ = cache(callback, options);
72+
73+
cached$.subscribe((value) => {
74+
expect(value).toBe('test');
75+
expect(callback).toHaveBeenCalledTimes(1);
76+
setTimeout(() => {
77+
cached$.subscribe((value) => {
78+
expect(value).toBe('test');
79+
expect(callback).toHaveBeenCalledTimes(2);
80+
done();
81+
});
82+
}, 100);
83+
});
84+
});
85+
});
+75
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Observable } from 'rxjs';
2+
import { shareReplay } from 'rxjs/operators';
3+
4+
const CACHE_SIZE = 1;
5+
6+
export type CacheOptions = {
7+
expirationTime?: number;
8+
cacheKey?: () => string;
9+
};
10+
11+
const cacheMap = new Map<
12+
string,
13+
{ observable: Observable<any>; timestamp: number }
14+
>();
15+
16+
/**
17+
* Cache the result of an observable for a certain amount of time
18+
*
19+
* This will guarantee that the inner observable is only subscribed
20+
* to once and the result is shared between all subscribers.
21+
*
22+
* NOTE: This has a serious flaw that should be addressed in the future:
23+
* If no `cacheKey` is provided, the cache will be based on the
24+
* string representation of the callback function. So if the callback
25+
* is `() => this.http.get('api/widgets' + (request ? '/' + request.id : ''))`
26+
* then the cache will be based on that string. This means that the same
27+
* response will be given regardless of the `request` parameter. So in
28+
* effect, the `cacheKey` is required to make the cache work as expected.
29+
*
30+
* But I have no idea how this should be fixed. One way could be to make
31+
* a `cacheHttp` operator which takes in the url directly and caches the
32+
* response based on that. But that would limit the use of the cache to
33+
* only http requests. So I'm not sure what the best solution is.
34+
*
35+
* @param callback
36+
* @param options
37+
* @returns
38+
*/
39+
export function cache<T>(
40+
callback: () => Observable<T>,
41+
options?: CacheOptions,
42+
): Observable<T> {
43+
const key = options?.cacheKey
44+
? options.cacheKey()
45+
: JSON.stringify(callback.toString()); // FIXME: This should be fixed
46+
47+
return new Observable<T>((observer) => {
48+
const now = Date.now();
49+
let cacheEntry = cacheMap.get(key);
50+
51+
if (
52+
!cacheEntry ||
53+
(options?.expirationTime &&
54+
now - cacheEntry.timestamp > options.expirationTime)
55+
) {
56+
const observable = callback().pipe(shareReplay(CACHE_SIZE));
57+
cacheEntry = { observable, timestamp: now };
58+
cacheMap.set(key, cacheEntry);
59+
}
60+
61+
const { observable } = cacheEntry;
62+
63+
observable.subscribe({
64+
next(value) {
65+
observer.next(value);
66+
},
67+
error(err) {
68+
observer.error(err);
69+
},
70+
complete() {
71+
observer.complete();
72+
},
73+
});
74+
});
75+
}

apps/home/src/app/shared/widget/widget.component.scss

+4-3
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,20 @@
1212
transition-behavior: allow-discrete;
1313
transition:
1414
opacity 0.25s ease-in,
15-
transform 0.25s ease-in,
15+
scale 0.25s ease-in,
1616
display 0.25s ease-in;
1717

1818
// Enter animation
1919
@starting-style {
20-
transform: translateX(-80px) scale(0.7);
20+
scale: 0.7;
2121
opacity: 0;
2222
}
2323

2424
// Exit animation
2525
// This property is not yet supported by web standards
2626
// This is why we are using angular animations to solve this for now
2727
// @exit-style {
28-
// transform: translateX(40px) scale(0.7);
28+
// scale: 0.7;
2929
// opacity: 0;
3030
// display: none;
3131
// }
@@ -45,5 +45,6 @@
4545
section {
4646
padding: 1rem;
4747
background-color: var(--background-color);
48+
view-transition-class: widget-content;
4849
}
4950
}

apps/home/src/app/shared/widget/widget.service.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
22
import { computed, inject, Injectable, resource, signal } from '@angular/core';
33
import { firstValueFrom } from 'rxjs';
4+
import { cache } from '../rxjs/cache';
45

56
export type Widget = {
67
id: number;
@@ -24,10 +25,10 @@ export class WidgetService {
2425
loader: async ({ request }) => {
2526
// Cannot use fetch directly because Angular's SSR does not support it.
2627
// I get a `TypeError: Failed to parse URL` from SSR when using fetch.
28+
const cacheKey = () =>
29+
`/api/widgets${request.id ? '/' + request.id : ''}`;
2730
const widgets = await firstValueFrom(
28-
this.http.get<Widget[]>(
29-
`/api/widgets${request.id ? '/' + request.id : ''}`,
30-
),
31+
cache(() => this.http.get<Widget[]>(cacheKey()), { cacheKey }),
3132
);
3233

3334
// Remove old widgets from the cache

apps/home/src/app/shared/widget/widgets.animation.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { animate, group, query, style, transition } from '@angular/animations';
22

33
// The state of the widget before it enters the view
4-
const hidden = { opacity: 0, translate: '0 -80px', scale: 0.7, width: '0' };
4+
const hidden = { opacity: 0, scale: 0.7, width: '0' };
55
// The state of the widget when it rests in the view
6-
const visible = { opacity: 1, translate: '0 0', scale: 1, width: 'auto' };
6+
const visible = { opacity: 1, scale: 1, width: 'auto' };
77
// // The state of the widget after it leaves the view
88
// const toState = { opacity: 0, translate: '40px 80px', scale: 0.7 };
99
// The time it should take to animate the widget entering or leaving

apps/home/src/app/views/weather.component.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
GeoLocationService,
1717
} from '../shared/geoLocation/geoLocation.service';
1818
import { IconPipe } from '../shared/icons/icon.pipe';
19+
import { cache } from '../shared/rxjs/cache';
1920
import { Widget } from '../shared/widget/widget.service';
2021

2122
@Component({
@@ -100,8 +101,9 @@ export class WeatherComponent {
100101
if (request.location == null) return undefined;
101102
// Fetch weather data for location
102103
const { latitude, longitude } = request.location;
104+
const cacheKey = () => `/api/weather?lat=${latitude}&lon=${longitude}`;
103105
return await firstValueFrom(
104-
this.http.get<any>(`/api/weather?lat=${latitude}&lon=${longitude}`),
106+
cache(() => this.http.get<any>(cacheKey()), { cacheKey }),
105107
);
106108
},
107109
});

0 commit comments

Comments
 (0)