@croct/plug-react 0.5.0-next.1 → 0.5.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CroctProvider.js.map +1 -1
  2. package/api/evaluate.js.map +1 -1
  3. package/api/fetchContent.js +4 -1
  4. package/api/fetchContent.js.map +1 -1
  5. package/api/index.js.map +1 -1
  6. package/components/Personalization/index.js.map +1 -1
  7. package/components/Slot/index.js.map +1 -1
  8. package/components/index.js.map +1 -1
  9. package/hooks/Cache.js.map +1 -1
  10. package/hooks/index.js.map +1 -1
  11. package/hooks/useContent.js.map +1 -1
  12. package/hooks/useCroct.js.map +1 -1
  13. package/hooks/useEvaluation.js.map +1 -1
  14. package/hooks/useLoader.js.map +1 -1
  15. package/index.js.map +1 -1
  16. package/package.json +5 -5
  17. package/src/api/evaluate.test.ts +59 -0
  18. package/src/api/evaluate.ts +19 -0
  19. package/src/api/fetchContent.test.ts +134 -0
  20. package/src/api/fetchContent.ts +47 -0
  21. package/src/api/index.ts +2 -0
  22. package/src/components/index.ts +2 -0
  23. package/src/global.d.ts +7 -0
  24. package/src/hooks/Cache.test.ts +280 -0
  25. package/src/hooks/Cache.ts +97 -0
  26. package/src/hooks/index.ts +3 -0
  27. package/src/hooks/useContent.ssr.test.ts +23 -0
  28. package/src/hooks/useContent.test.ts +66 -0
  29. package/src/hooks/useContent.ts +69 -0
  30. package/src/hooks/useCroct.ts +13 -0
  31. package/src/hooks/useEvaluation.ssr.test.ts +23 -0
  32. package/src/hooks/useEvaluation.test.ts +92 -0
  33. package/src/hooks/useEvaluation.ts +58 -0
  34. package/src/hooks/useLoader.test.ts +320 -0
  35. package/src/hooks/useLoader.ts +50 -0
  36. package/src/index.ts +5 -0
  37. package/src/react-app-env.d.ts +1 -0
  38. package/src/ssr-polyfills.ssr.test.ts +46 -0
  39. package/src/ssr-polyfills.test.ts +65 -0
  40. package/src/ssr-polyfills.ts +68 -0
  41. package/ssr-polyfills.js.map +1 -1
@@ -0,0 +1,280 @@
1
+ import {Cache, EntryOptions} from './Cache';
2
+
3
+ describe('Cache', () => {
4
+ afterEach(() => {
5
+ jest.clearAllTimers();
6
+ jest.resetAllMocks();
7
+ });
8
+
9
+ it('should load and cache the value for the default cache time', async () => {
10
+ jest.useFakeTimers();
11
+
12
+ const cache = new Cache(10);
13
+
14
+ const loader = jest.fn()
15
+ .mockResolvedValueOnce('result1')
16
+ .mockResolvedValueOnce('result2');
17
+
18
+ const options: EntryOptions<string> = {
19
+ cacheKey: 'key',
20
+ loader: loader,
21
+ };
22
+
23
+ let promise: Promise<any>|undefined;
24
+
25
+ try {
26
+ cache.load(options);
27
+ } catch (result: any|undefined) {
28
+ promise = result;
29
+ }
30
+
31
+ await expect(promise).resolves.toEqual('result1');
32
+
33
+ expect(cache.load(options)).toEqual('result1');
34
+
35
+ expect(loader).toHaveBeenCalledTimes(1);
36
+
37
+ jest.advanceTimersByTime(10);
38
+
39
+ try {
40
+ cache.load(options);
41
+ } catch (result: any|undefined) {
42
+ promise = result;
43
+ }
44
+
45
+ await expect(promise).resolves.toEqual('result2');
46
+
47
+ expect(loader).toHaveBeenCalledTimes(2);
48
+ });
49
+
50
+ it('should load the value once before expiration', async () => {
51
+ jest.useFakeTimers();
52
+
53
+ const cache = new Cache(10);
54
+
55
+ const loader = jest.fn(
56
+ () => new Promise<string>(resolve => {
57
+ setTimeout(() => resolve('done'), 10);
58
+ }),
59
+ );
60
+
61
+ const options: EntryOptions<string> = {
62
+ cacheKey: 'key',
63
+ loader: loader,
64
+ };
65
+
66
+ let promise1: Promise<any>|undefined;
67
+
68
+ try {
69
+ cache.load(options);
70
+ } catch (result: any|undefined) {
71
+ promise1 = result;
72
+ }
73
+
74
+ let promise2: Promise<any>|undefined;
75
+
76
+ try {
77
+ cache.load(options);
78
+ } catch (result: any|undefined) {
79
+ promise2 = result;
80
+ }
81
+
82
+ expect(promise1).toBe(promise2);
83
+
84
+ jest.advanceTimersByTime(10);
85
+
86
+ await expect(promise1).resolves.toEqual('done');
87
+ await expect(promise2).resolves.toEqual('done');
88
+
89
+ expect(loader).toHaveBeenCalledTimes(1);
90
+ });
91
+
92
+ it('should load and cache the value for the specified time', async () => {
93
+ jest.useFakeTimers();
94
+
95
+ const cache = new Cache(10);
96
+
97
+ const loader = jest.fn()
98
+ .mockResolvedValueOnce('result1')
99
+ .mockResolvedValueOnce('result2');
100
+
101
+ const options: EntryOptions<string> = {
102
+ cacheKey: 'key',
103
+ loader: loader,
104
+ expiration: 15,
105
+ };
106
+
107
+ let promise: Promise<any>|undefined;
108
+
109
+ try {
110
+ cache.load(options);
111
+ } catch (result: any|undefined) {
112
+ promise = result;
113
+ }
114
+
115
+ await expect(promise).resolves.toEqual('result1');
116
+
117
+ expect(cache.load(options)).toEqual('result1');
118
+
119
+ expect(loader).toHaveBeenCalledTimes(1);
120
+
121
+ jest.advanceTimersByTime(15);
122
+
123
+ try {
124
+ cache.load(options);
125
+ } catch (result: any|undefined) {
126
+ promise = result;
127
+ }
128
+
129
+ await expect(promise).resolves.toEqual('result2');
130
+
131
+ expect(loader).toHaveBeenCalledTimes(2);
132
+ });
133
+
134
+ it('should load and cache the value for undetermined time', async () => {
135
+ jest.useFakeTimers();
136
+
137
+ const cache = new Cache(10);
138
+
139
+ const loader = jest.fn()
140
+ .mockResolvedValueOnce('result1')
141
+ .mockResolvedValueOnce('result2');
142
+
143
+ const options: EntryOptions<string> = {
144
+ cacheKey: 'key',
145
+ loader: loader,
146
+ expiration: -1,
147
+ };
148
+
149
+ let promise: Promise<any>|undefined;
150
+
151
+ try {
152
+ cache.load(options);
153
+ } catch (result: any|undefined) {
154
+ promise = result;
155
+ }
156
+
157
+ await expect(promise).resolves.toEqual('result1');
158
+
159
+ jest.advanceTimersByTime(60_000);
160
+
161
+ expect(cache.load(options)).toEqual('result1');
162
+
163
+ expect(loader).toHaveBeenCalledTimes(1);
164
+ });
165
+
166
+ it('should return the fallback value on error', async () => {
167
+ const cache = new Cache(10);
168
+
169
+ const loader = jest.fn().mockRejectedValue(new Error('failed'));
170
+ const options: EntryOptions<string> = {
171
+ cacheKey: 'key',
172
+ loader: loader,
173
+ fallback: 'fallback',
174
+ };
175
+
176
+ let promise: Promise<any>|undefined;
177
+
178
+ try {
179
+ cache.load(options);
180
+ } catch (result: any|undefined) {
181
+ promise = result;
182
+ }
183
+
184
+ await expect(promise).resolves.toBeUndefined();
185
+
186
+ expect(cache.load(options)).toEqual('fallback');
187
+
188
+ // Should cache the result but not the fallback value
189
+ expect(cache.load({...options, fallback: 'error'})).toEqual('error');
190
+
191
+ expect(loader).toHaveBeenCalledTimes(1);
192
+ });
193
+
194
+ it('should throw the error if no fallback is specified', async () => {
195
+ const cache = new Cache(10);
196
+
197
+ const error = new Error('failed');
198
+
199
+ const loader = jest.fn().mockRejectedValue(error);
200
+ const options: EntryOptions<string> = {
201
+ cacheKey: 'key',
202
+ loader: loader,
203
+ };
204
+
205
+ let promise: Promise<any>|undefined;
206
+
207
+ try {
208
+ cache.load(options);
209
+ } catch (result: any|undefined) {
210
+ promise = result;
211
+ }
212
+
213
+ await expect(promise).resolves.toBeUndefined();
214
+
215
+ await expect(() => cache.load(options)).toThrow(error);
216
+ });
217
+
218
+ it('should cache the error', async () => {
219
+ const cache = new Cache(10);
220
+
221
+ const error = new Error('error');
222
+ const loader = jest.fn().mockRejectedValue(error);
223
+ const options: EntryOptions<string> = {
224
+ cacheKey: 'key',
225
+ loader: loader,
226
+ };
227
+
228
+ let promise: Promise<any>|undefined;
229
+
230
+ try {
231
+ cache.load(options);
232
+ } catch (result: any|undefined) {
233
+ promise = result;
234
+ }
235
+
236
+ await expect(promise).resolves.toBeUndefined();
237
+
238
+ expect(() => cache.load(options)).toThrow(error);
239
+ expect(cache.get(options.cacheKey)?.error).toBe(error);
240
+ });
241
+
242
+ it('should provide the cached values', async () => {
243
+ jest.useFakeTimers();
244
+
245
+ const cache = new Cache(10);
246
+
247
+ const loader = jest.fn().mockResolvedValue('loaded');
248
+ const options: EntryOptions<string> = {
249
+ cacheKey: 'key',
250
+ loader: loader,
251
+ };
252
+
253
+ let promise: Promise<any>|undefined;
254
+
255
+ try {
256
+ cache.load(options);
257
+ } catch (result: any|undefined) {
258
+ promise = result;
259
+ }
260
+
261
+ await promise;
262
+
263
+ jest.advanceTimersByTime(9);
264
+
265
+ const entry = cache.get(options.cacheKey);
266
+
267
+ expect(entry?.result).toBe('loaded');
268
+ expect(entry?.promise).toBe(promise);
269
+ expect(entry?.timeout).not.toBeUndefined();
270
+ expect(entry?.error).toBeUndefined();
271
+
272
+ entry?.dispose();
273
+
274
+ jest.advanceTimersByTime(9);
275
+
276
+ expect(cache.get(options.cacheKey)).toBe(entry);
277
+
278
+ expect(loader).toHaveBeenCalledTimes(1);
279
+ });
280
+ });
@@ -0,0 +1,97 @@
1
+ export type EntryLoader<R> = (...args: any) => Promise<R>;
2
+
3
+ export type EntryOptions<R> = {
4
+ cacheKey: string,
5
+ loader: EntryLoader<R>,
6
+ fallback?: R,
7
+ expiration?: number,
8
+ };
9
+
10
+ type Entry<R = any> = {
11
+ promise: Promise<any>,
12
+ result?: R,
13
+ dispose: () => void,
14
+ timeout?: number,
15
+ error?: any,
16
+ };
17
+
18
+ export class Cache {
19
+ private readonly cache: Record<string, Entry> = {};
20
+
21
+ private readonly defaultExpiration: number;
22
+
23
+ public constructor(defaultExpiration: number) {
24
+ this.defaultExpiration = defaultExpiration;
25
+ }
26
+
27
+ public load<R>(configuration: EntryOptions<R>): R {
28
+ const {cacheKey, loader, fallback, expiration = this.defaultExpiration} = configuration;
29
+
30
+ const cachedEntry = this.get<R>(cacheKey);
31
+
32
+ if (cachedEntry !== undefined) {
33
+ if (cachedEntry.error !== undefined) {
34
+ if (fallback !== undefined) {
35
+ return fallback;
36
+ }
37
+
38
+ throw cachedEntry.error;
39
+ }
40
+
41
+ if (cachedEntry.result !== undefined) {
42
+ return cachedEntry.result;
43
+ }
44
+
45
+ throw cachedEntry.promise;
46
+ }
47
+
48
+ const entry: Entry<R> = {
49
+ dispose: () => {
50
+ if (entry.timeout !== undefined || expiration < 0) {
51
+ return;
52
+ }
53
+
54
+ entry.timeout = window.setTimeout(
55
+ (): void => {
56
+ delete this.cache[cacheKey];
57
+ },
58
+ expiration,
59
+ );
60
+ },
61
+ promise: loader()
62
+ .then((result): R => {
63
+ entry.result = result;
64
+
65
+ return result;
66
+ })
67
+ .catch(error => {
68
+ entry.error = error;
69
+ })
70
+ .finally(() => {
71
+ entry.dispose();
72
+ }),
73
+ };
74
+
75
+ this.cache[cacheKey] = entry;
76
+
77
+ throw entry.promise;
78
+ }
79
+
80
+ public get<R>(cacheKey: string): Entry<R>|undefined {
81
+ const entry = this.cache[cacheKey];
82
+
83
+ if (entry === undefined) {
84
+ return undefined;
85
+ }
86
+
87
+ if (entry.timeout !== undefined) {
88
+ clearTimeout(entry.timeout);
89
+
90
+ delete entry.timeout;
91
+
92
+ entry.dispose();
93
+ }
94
+
95
+ return entry;
96
+ }
97
+ }
@@ -0,0 +1,3 @@
1
+ export * from './useEvaluation';
2
+ export * from './useContent';
3
+ export * from './useCroct';
@@ -0,0 +1,23 @@
1
+ import {renderHook} from '@testing-library/react';
2
+ import {useContent} from './useContent';
3
+
4
+ jest.mock(
5
+ '../ssr-polyfills',
6
+ () => ({
7
+ __esModule: true,
8
+ isSsr: (): boolean => true,
9
+ }),
10
+ );
11
+
12
+ describe('useContent (SSR)', () => {
13
+ it('should render the initial value on the server-side', () => {
14
+ const {result} = renderHook(() => useContent('slot-id', {initial: 'foo'}));
15
+
16
+ expect(result.current).toBe('foo');
17
+ });
18
+
19
+ it('should require an initial value for server-side rending', () => {
20
+ expect(() => useContent('slot-id'))
21
+ .toThrow(new Error('The initial value is required for server-side rendering (SSR).'));
22
+ });
23
+ });
@@ -0,0 +1,66 @@
1
+ import {renderHook} from '@testing-library/react';
2
+ import {Plug} from '@croct/plug';
3
+ import {useCroct} from './useCroct';
4
+ import {useLoader} from './useLoader';
5
+ import {useContent} from './useContent';
6
+
7
+ jest.mock(
8
+ './useCroct',
9
+ () => ({
10
+ useCroct: jest.fn(),
11
+ }),
12
+ );
13
+
14
+ jest.mock(
15
+ './useLoader',
16
+ () => ({
17
+ useLoader: jest.fn(),
18
+ }),
19
+ );
20
+
21
+ describe('useContent (CSR)', () => {
22
+ it('should evaluate fetch the content', () => {
23
+ const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({
24
+ payload: {
25
+ title: 'loaded',
26
+ },
27
+ });
28
+
29
+ jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
30
+ jest.mocked(useLoader).mockReturnValue('foo');
31
+
32
+ const slotId = 'home-banner@1';
33
+
34
+ const {result} = renderHook(
35
+ () => useContent<{title: string}>(slotId, {
36
+ preferredLocale: 'en',
37
+ cacheKey: 'unique',
38
+ fallback: {
39
+ title: 'error',
40
+ },
41
+ expiration: 50,
42
+ }),
43
+ );
44
+
45
+ expect(useCroct).toHaveBeenCalled();
46
+ expect(useLoader).toHaveBeenCalledWith({
47
+ cacheKey: `useContent:unique:${slotId}`,
48
+ fallback: {
49
+ title: 'error',
50
+ },
51
+ expiration: 50,
52
+ loader: expect.any(Function),
53
+ });
54
+
55
+ jest.mocked(useLoader)
56
+ .mock
57
+ .calls[0][0]
58
+ .loader();
59
+
60
+ expect(fetch).toHaveBeenCalledWith(slotId, {
61
+ preferredLocale: 'en',
62
+ });
63
+
64
+ expect(result.current).toBe('foo');
65
+ });
66
+ });
@@ -0,0 +1,69 @@
1
+ import {SlotContent, VersionedSlotId, VersionedSlotMap} from '@croct/plug/slot';
2
+ import {JsonObject} from '@croct/plug/sdk/json';
3
+ import {FetchOptions} from '@croct/plug/plug';
4
+ import {useLoader} from './useLoader';
5
+ import {useCroct} from './useCroct';
6
+ import {isSsr} from '../ssr-polyfills';
7
+
8
+ export type UseContentOptions<I, F> = FetchOptions & {
9
+ fallback?: F,
10
+ initial?: I,
11
+ cacheKey?: string,
12
+ expiration?: number,
13
+ };
14
+
15
+ function useCsrContent<I, F>(
16
+ id: VersionedSlotId,
17
+ options: UseContentOptions<I, F> = {},
18
+ ): SlotContent<VersionedSlotId> | I | F {
19
+ const {fallback, initial, cacheKey, expiration, ...fetchOptions} = options;
20
+ const croct = useCroct();
21
+
22
+ return useLoader({
23
+ cacheKey: `useContent:${cacheKey ?? ''}:${id}`,
24
+ loader: () => croct.fetch(id, fetchOptions).then(({content}) => content),
25
+ initial: initial,
26
+ fallback: fallback,
27
+ expiration: expiration,
28
+ });
29
+ }
30
+
31
+ function useSsrContent<I, F>(
32
+ _: VersionedSlotId,
33
+ {initial}: UseContentOptions<I, F> = {},
34
+ ): SlotContent<VersionedSlotId> | I | F {
35
+ if (initial === undefined) {
36
+ throw new Error('The initial value is required for server-side rendering (SSR).');
37
+ }
38
+
39
+ return initial;
40
+ }
41
+
42
+ type UseContentHook = {
43
+ <P extends JsonObject, I = P, F = P>(
44
+ id: keyof VersionedSlotMap extends never ? string : never,
45
+ options?: UseContentOptions<I, F>
46
+ ): P | I | F,
47
+
48
+ <S extends VersionedSlotId>(
49
+ id: S,
50
+ options?: UseContentOptions<never, never>
51
+ ): SlotContent<S>,
52
+
53
+ <I, S extends VersionedSlotId>(
54
+ id: S,
55
+ options?: UseContentOptions<I, never>
56
+ ): SlotContent<S> | I,
57
+
58
+ <F, S extends VersionedSlotId>(
59
+ id: S,
60
+ options?: UseContentOptions<never, F>
61
+ ): SlotContent<S> | F,
62
+
63
+ <I, F, S extends VersionedSlotId>(
64
+ id: S,
65
+ options?: UseContentOptions<I, F>
66
+ ): SlotContent<S> | I | F,
67
+ };
68
+
69
+ export const useContent: UseContentHook = isSsr() ? useSsrContent : useCsrContent;
@@ -0,0 +1,13 @@
1
+ import {Plug} from '@croct/plug';
2
+ import {useContext} from 'react';
3
+ import {CroctContext} from '../CroctProvider';
4
+
5
+ export function useCroct(): Plug {
6
+ const context = useContext(CroctContext);
7
+
8
+ if (context === null) {
9
+ throw new Error('useCroct() can only be used in the context of a <CroctProvider> component.');
10
+ }
11
+
12
+ return context.plug;
13
+ }
@@ -0,0 +1,23 @@
1
+ import {renderHook} from '@testing-library/react';
2
+ import {useEvaluation} from './useEvaluation';
3
+
4
+ jest.mock(
5
+ '../ssr-polyfills',
6
+ () => ({
7
+ __esModule: true,
8
+ isSsr: (): boolean => true,
9
+ }),
10
+ );
11
+
12
+ describe('useEvaluation (SSR)', () => {
13
+ it('should render the initial value on the server-side', () => {
14
+ const {result} = renderHook(() => useEvaluation('location', {initial: 'foo'}));
15
+
16
+ expect(result.current).toBe('foo');
17
+ });
18
+
19
+ it('should require an initial value for server-side rending', () => {
20
+ expect(() => useEvaluation('location'))
21
+ .toThrow(new Error('The initial value is required for server-side rendering (SSR).'));
22
+ });
23
+ });
@@ -0,0 +1,92 @@
1
+ import {renderHook} from '@testing-library/react';
2
+ import {EvaluationOptions} from '@croct/sdk/facade/evaluatorFacade';
3
+ import {Plug} from '@croct/plug';
4
+ import {useEvaluation} from './useEvaluation';
5
+ import {useCroct} from './useCroct';
6
+ import {useLoader} from './useLoader';
7
+
8
+ jest.mock(
9
+ './useCroct',
10
+ () => ({
11
+ useCroct: jest.fn(),
12
+ }),
13
+ );
14
+
15
+ jest.mock(
16
+ './useLoader',
17
+ () => ({
18
+ useLoader: jest.fn(),
19
+ }),
20
+ );
21
+
22
+ describe('useEvaluation', () => {
23
+ beforeEach(() => {
24
+ jest.resetModules();
25
+ jest.resetAllMocks();
26
+ });
27
+
28
+ it('should evaluate a query', () => {
29
+ const evaluationOptions: EvaluationOptions = {
30
+ timeout: 100,
31
+ attributes: {
32
+ foo: 'bar',
33
+ },
34
+ };
35
+
36
+ const evaluate: Plug['evaluate'] = jest.fn();
37
+
38
+ jest.mocked(useCroct).mockReturnValue({evaluate: evaluate} as Plug);
39
+ jest.mocked(useLoader).mockReturnValue('foo');
40
+
41
+ const query = 'location';
42
+
43
+ const {result} = renderHook(
44
+ () => useEvaluation(query, {
45
+ ...evaluationOptions,
46
+ cacheKey: 'unique',
47
+ fallback: 'error',
48
+ expiration: 50,
49
+ }),
50
+ );
51
+
52
+ expect(useCroct).toHaveBeenCalled();
53
+ expect(useLoader).toHaveBeenCalledWith({
54
+ cacheKey: 'useEvaluation:unique:location:{"foo":"bar"}',
55
+ fallback: 'error',
56
+ expiration: 50,
57
+ loader: expect.any(Function),
58
+ });
59
+
60
+ jest.mocked(useLoader)
61
+ .mock
62
+ .calls[0][0]
63
+ .loader();
64
+
65
+ expect(evaluate).toHaveBeenCalledWith(query, evaluationOptions);
66
+
67
+ expect(result.current).toBe('foo');
68
+ });
69
+
70
+ it('should remove undefined evaluation options', () => {
71
+ const evaluationOptions: EvaluationOptions = {
72
+ timeout: undefined,
73
+ attributes: undefined,
74
+ };
75
+
76
+ const evaluate: Plug['evaluate'] = jest.fn();
77
+
78
+ jest.mocked(useCroct).mockReturnValue({evaluate: evaluate} as Plug);
79
+ jest.mocked(useLoader).mockReturnValue('foo');
80
+
81
+ const query = 'location';
82
+
83
+ renderHook(() => useEvaluation(query, evaluationOptions));
84
+
85
+ jest.mocked(useLoader)
86
+ .mock
87
+ .calls[0][0]
88
+ .loader();
89
+
90
+ expect(evaluate).toHaveBeenCalledWith(query, {});
91
+ });
92
+ });