): ReactElement {
22 | const {query, children, ...options} = props;
23 | const result = useEvaluation(query, options);
24 |
25 | return ({children(result)});
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/Slot/index.d.test.tsx:
--------------------------------------------------------------------------------
1 | import {join as pathJoin} from 'path';
2 | import {create} from 'ts-node';
3 |
4 | const ts = create({
5 | cwd: __dirname,
6 | transpileOnly: false,
7 | ignore: [
8 | 'lib/slots.d.ts',
9 | ],
10 | });
11 |
12 | const testFilename = pathJoin(__dirname, 'test.tsx');
13 |
14 | describe(' typing', () => {
15 | const header = `
16 | import {Slot} from './index';
17 | `;
18 |
19 | const slotMapping = `
20 | type HomeBanner = {
21 | title: string,
22 | subtitle: string,
23 | };
24 |
25 | declare module '@croct/plug/slot' {
26 | type HomeBannerV1 = HomeBanner & {_component: 'banner@v1' | null};
27 |
28 | interface SlotMap {
29 | 'home-banner': HomeBannerV1;
30 | }
31 | }
32 | `;
33 |
34 | type CodeOptions = {
35 | code: string,
36 | mapping: boolean,
37 | };
38 |
39 | type AssembledCode = {
40 | code: string,
41 | codePosition: number,
42 | };
43 |
44 | function assembleCode({code, mapping}: CodeOptions): AssembledCode {
45 | const prefix = mapping
46 | ? header + slotMapping
47 | : header;
48 |
49 | const fullCode = prefix + code.trim();
50 |
51 | return {
52 | code: fullCode,
53 | codePosition: fullCode.lastIndexOf('=>') + 1,
54 | };
55 | }
56 |
57 | function compileCode(opts: CodeOptions): void {
58 | ts.compile(assembleCode(opts).code, testFilename);
59 | }
60 |
61 | function getParameterType(opts: CodeOptions): string {
62 | const assembledCode = assembleCode(opts);
63 |
64 | const info = ts.getTypeInfo(assembledCode.code, testFilename, assembledCode.codePosition);
65 |
66 | const match = info.name.match(/function\(\w+: (.+?)\):/s);
67 |
68 | if (match !== null) {
69 | return match[1].replace(/\s*\n\s*/g, '');
70 | }
71 |
72 | return info.name;
73 | }
74 |
75 | it('should allow a renderer that accepts JSON objects or covariants for unmapped slots', () => {
76 | const code: CodeOptions = {
77 | code: `
78 |
79 | {(params: {foo: string}) => typeof params}
80 | ;
81 | `,
82 | mapping: false,
83 | };
84 |
85 | expect(() => compileCode(code)).not.toThrow();
86 | });
87 |
88 | it('should require a renderer that accepts JSON objects or covariants for unmapped slots', () => {
89 | const code: CodeOptions = {
90 | code: `
91 |
92 | {(params: true) => typeof params}
93 | ;
94 | `,
95 | mapping: false,
96 | };
97 |
98 | expect(() => compileCode(code)).toThrow();
99 | });
100 |
101 | it('should allow a renderer that accepts the initial value for unmapped slots', () => {
102 | const code: CodeOptions = {
103 | code: `
104 |
105 | {(params: {foo: string}|boolean) => typeof params}
106 | ;
107 | `,
108 | mapping: false,
109 | };
110 |
111 | expect(() => compileCode(code)).not.toThrow();
112 | });
113 |
114 | it('should require a renderer that accepts the initial value for unmapped slots', () => {
115 | const code: CodeOptions = {
116 | code: `
117 |
118 | {(params: {foo: string}) => typeof params}
119 | ;
120 | `,
121 | mapping: false,
122 | };
123 |
124 | expect(() => compileCode(code)).toThrow();
125 | });
126 |
127 | it('should allow a renderer that accepts the fallback value for unmapped slots', () => {
128 | const code: CodeOptions = {
129 | code: `
130 |
131 | {(params: {foo: string}|boolean) => typeof params}
132 | ;
133 | `,
134 | mapping: false,
135 | };
136 |
137 | expect(() => compileCode(code)).not.toThrow();
138 | });
139 |
140 | it('should require a renderer that accepts the fallback value for unmapped slots', () => {
141 | const code: CodeOptions = {
142 | code: `
143 |
144 | {(params: {foo: string}) => typeof params}
145 | ;
146 | `,
147 | mapping: false,
148 | };
149 |
150 | expect(() => compileCode(code)).toThrow();
151 | });
152 |
153 | it('should allow a renderer that accepts both the initial and fallback values for unmapped slots', () => {
154 | const code: CodeOptions = {
155 | code: `
156 |
157 | {(params: {foo: string}|boolean|number) => typeof params}
158 | ;
159 | `,
160 | mapping: false,
161 | };
162 |
163 | expect(() => compileCode(code)).not.toThrow();
164 | });
165 |
166 | it('should require a renderer that accepts both the initial and fallback values for unmapped slots', () => {
167 | const code: CodeOptions = {
168 | code: `
169 |
170 | {(params: {foo: string}|boolean) => typeof params}
171 | ;
172 | `,
173 | mapping: false,
174 | };
175 |
176 | expect(() => compileCode(code)).toThrow();
177 | });
178 |
179 | it('should require a renderer that accepts both the fallback and initial values for unmapped slots', () => {
180 | const code: CodeOptions = {
181 | code: `
182 |
183 | {(params: {foo: string}|number) => typeof params}
184 | ;
185 | `,
186 | mapping: false,
187 | };
188 |
189 | expect(() => compileCode(code)).toThrow();
190 | });
191 |
192 | it('should infer the renderer parameter type for mapped slots', () => {
193 | const code: CodeOptions = {
194 | code: `
195 |
196 | {params => typeof params}
197 | ;
198 | `,
199 | mapping: true,
200 | };
201 |
202 | expect(() => compileCode(code)).not.toThrow();
203 |
204 | expect(getParameterType(code)).toBe('HomeBannerV1');
205 | });
206 |
207 | it('should allow a covariant renderer parameter type for mapped slots', () => {
208 | const code: CodeOptions = {
209 | code: `
210 |
211 | {(params: {title: string}) => typeof params}
212 | ;
213 | `,
214 | mapping: true,
215 | };
216 |
217 | expect(() => compileCode(code)).not.toThrow();
218 | });
219 |
220 | it('should require a compatible renderer for mapped slots', () => {
221 | const code: CodeOptions = {
222 | code: `
223 |
224 | {(params: {foo: string}) => typeof params}
225 | ;
226 | `,
227 | mapping: true,
228 | };
229 |
230 | expect(() => compileCode(code)).toThrow();
231 | });
232 |
233 | it('should infer the renderer parameter type also from the initial value for mapped slots', () => {
234 | const code: CodeOptions = {
235 | code: `
236 |
237 | {params => typeof params}
238 | ;
239 | `,
240 | mapping: true,
241 | };
242 |
243 | expect(() => compileCode(code)).not.toThrow();
244 |
245 | expect(getParameterType(code)).toBe('boolean | HomeBannerV1');
246 | });
247 |
248 | it('should allow a renderer that accepts the initial value for mapped slots', () => {
249 | const code: CodeOptions = {
250 | code: `
251 |
252 | {(params: {title: string}|boolean) => typeof params}
253 | ;
254 | `,
255 | mapping: true,
256 | };
257 |
258 | expect(() => compileCode(code)).not.toThrow();
259 | });
260 |
261 | it('should require a renderer that accepts the initial value for mapped slots', () => {
262 | const code: CodeOptions = {
263 | code: `
264 |
265 | {(params: {title: string}) => typeof params}
266 | ;
267 | `,
268 | mapping: true,
269 | };
270 |
271 | expect(() => compileCode(code)).toThrow();
272 | });
273 |
274 | it('should infer the renderer parameter type also from the fallback value for mapped slots', () => {
275 | const code: CodeOptions = {
276 | code: `
277 |
278 | {params => typeof params}
279 | ;
280 | `,
281 | mapping: true,
282 | };
283 |
284 | expect(() => compileCode(code)).not.toThrow();
285 |
286 | expect(getParameterType(code)).toBe('boolean | HomeBannerV1');
287 | });
288 |
289 | it('should allow a renderer that accepts the fallback value for mapped slots', () => {
290 | const code: CodeOptions = {
291 | code: `
292 |
293 | {(params: {title: string}|boolean) => typeof params}
294 | ;
295 | `,
296 | mapping: true,
297 | };
298 |
299 | expect(() => compileCode(code)).not.toThrow();
300 | });
301 |
302 | it('should require a renderer that accepts the fallback value for mapped slots', () => {
303 | const code: CodeOptions = {
304 | code: `
305 |
306 | {(params: {title: string}) => typeof params}
307 | ;
308 | `,
309 | mapping: true,
310 | };
311 |
312 | expect(() => compileCode(code)).toThrow();
313 | });
314 |
315 | it('should infer the renderer parameter type from both the initial and fallback values for mapped slots', () => {
316 | const code: CodeOptions = {
317 | code: `
318 |
319 | {params => typeof params}
320 | ;
321 | `,
322 | mapping: true,
323 | };
324 |
325 | expect(() => compileCode(code)).not.toThrow();
326 |
327 | expect(getParameterType(code)).toBe('number | boolean | HomeBannerV1');
328 | });
329 |
330 | it('should allow a renderer that accepts both the initial and fallback values for mapped slots', () => {
331 | const code: CodeOptions = {
332 | code: `
333 |
334 | {(params: {title: string}|boolean|number) => typeof params}
335 | ;
336 | `,
337 | mapping: true,
338 | };
339 |
340 | expect(() => compileCode(code)).not.toThrow();
341 | });
342 |
343 | it('should require a renderer that accepts both the initial and fallback values for mapped slots', () => {
344 | const code: CodeOptions = {
345 | code: `
346 |
347 | {(params: {title: string}|boolean) => typeof params}
348 | ;
349 | `,
350 | mapping: true,
351 | };
352 |
353 | expect(() => compileCode(code)).toThrow();
354 | });
355 |
356 | it('should require a renderer that accepts both the fallback and initial values for mapped slots', () => {
357 | const code: CodeOptions = {
358 | code: `
359 |
360 | {(params: {title: string}|number) => typeof params}
361 | ;
362 | `,
363 | mapping: true,
364 | };
365 |
366 | expect(() => compileCode(code)).toThrow();
367 | });
368 | });
369 |
--------------------------------------------------------------------------------
/src/components/Slot/index.test.tsx:
--------------------------------------------------------------------------------
1 | import {render, screen} from '@testing-library/react';
2 | import {Slot, SlotProps} from './index';
3 | import {useContent} from '../../hooks';
4 | import '@testing-library/jest-dom';
5 |
6 | jest.mock(
7 | '../../hooks/useContent',
8 | () => ({
9 | useContent: jest.fn(),
10 | }),
11 | );
12 |
13 | describe('', () => {
14 | it('should fetch and render a slot', () => {
15 | const {id, children, ...options}: SlotProps<{title: string}> = {
16 | id: 'home-banner',
17 | children: jest.fn(({title}) => title),
18 | fallback: {title: 'fallback'},
19 | };
20 |
21 | const result = {title: 'result'};
22 |
23 | jest.mocked(useContent).mockReturnValue(result);
24 |
25 | render(
26 |
27 | {children}
28 | ,
29 | );
30 |
31 | expect(useContent).toHaveBeenCalledWith(id, options);
32 | expect(children).toHaveBeenCalledWith(result);
33 | expect(screen.getByText(result.title)).toBeInTheDocument();
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/components/Slot/index.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {Fragment, ReactElement, ReactNode} from 'react';
4 | import {SlotContent, VersionedSlotId, VersionedSlotMap} from '@croct/plug/slot';
5 | import {JsonObject} from '@croct/plug/sdk/json';
6 | import {useContent, UseContentOptions} from '../../hooks';
7 |
8 | type Renderer = (props: P) => ReactNode;
9 |
10 | export type SlotProps
= UseContentOptions & {
11 | id: S,
12 | children: Renderer
,
13 | };
14 |
15 | type SlotComponent = {
16 |
(
17 | props:
18 | Extract
extends never
19 | ? SlotProps
20 | : SlotProps
21 | ): ReactElement,
22 |
23 | (props: SlotProps, never, never, S>): ReactElement,
24 |
25 | (props: SlotProps, I, never, S>): ReactElement,
26 |
27 | (props: SlotProps, never, F, S>): ReactElement,
28 |
29 | (props: SlotProps, I, F, S>): ReactElement,
30 |
31 | (props: SlotProps): ReactElement,
32 | };
33 |
34 | export const Slot: SlotComponent = (props: SlotProps): ReactElement => {
35 | const {id, children, ...options} = props;
36 | const data = useContent(id, options);
37 |
38 | return {children(data)};
39 | };
40 |
--------------------------------------------------------------------------------
/src/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from './Personalization';
2 | export * from './Slot';
3 |
--------------------------------------------------------------------------------
/src/global.d.ts:
--------------------------------------------------------------------------------
1 | import {EapFeatures} from '@croct/plug/eap';
2 |
3 | declare global {
4 | interface Window {
5 | croctEap?: Partial;
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/hash.test.ts:
--------------------------------------------------------------------------------
1 | import {hash} from './hash';
2 |
3 | describe('hash', () => {
4 | it('should generate a hash from a string', () => {
5 | const result = hash('foo');
6 |
7 | expect(result).toEqual('18cc6');
8 | expect(result).toEqual(hash('foo'));
9 | });
10 |
11 | it('should handle special characters', () => {
12 | expect(hash('✨')).toEqual('2728');
13 | expect(hash('💥')).toEqual('d83d');
14 | expect(hash('✨💥')).toEqual('59615');
15 | });
16 |
17 | it('should generate a hash from an empty string', () => {
18 | const result = hash('');
19 |
20 | expect(result).toEqual('0');
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/hash.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @internal
3 | */
4 | export function hash(value: string): string {
5 | let code = 0;
6 |
7 | for (const char of value) {
8 | const charCode = char.charCodeAt(0);
9 |
10 | code = (code << 5) - code + charCode;
11 | code |= 0; // Convert to 32bit integer
12 | }
13 |
14 | return code.toString(16);
15 | }
16 |
--------------------------------------------------------------------------------
/src/hooks/Cache.test.ts:
--------------------------------------------------------------------------------
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 = {
19 | cacheKey: 'key',
20 | loader: loader,
21 | };
22 |
23 | let promise: Promise|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(resolve => {
57 | setTimeout(() => resolve('done'), 10);
58 | }),
59 | );
60 |
61 | const options: EntryOptions = {
62 | cacheKey: 'key',
63 | loader: loader,
64 | };
65 |
66 | let promise1: Promise|undefined;
67 |
68 | try {
69 | cache.load(options);
70 | } catch (result: any|undefined) {
71 | promise1 = result;
72 | }
73 |
74 | let promise2: Promise|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 = {
102 | cacheKey: 'key',
103 | loader: loader,
104 | expiration: 15,
105 | };
106 |
107 | let promise: Promise|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 = {
144 | cacheKey: 'key',
145 | loader: loader,
146 | expiration: -1,
147 | };
148 |
149 | let promise: Promise|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 = {
171 | cacheKey: 'key',
172 | loader: loader,
173 | fallback: 'fallback',
174 | };
175 |
176 | let promise: Promise|undefined;
177 |
178 | try {
179 | cache.load(options);
180 | } catch (result: any|undefined) {
181 | promise = result;
182 | }
183 |
184 | await expect(promise).resolves.toBe('fallback');
185 |
186 | expect(cache.load(options)).toEqual('fallback');
187 |
188 | expect(cache.load({...options, fallback: 'error'})).toEqual('error');
189 |
190 | expect(loader).toHaveBeenCalledTimes(1);
191 | });
192 |
193 | it('should throw the error if no fallback is specified', async () => {
194 | const cache = new Cache(10);
195 |
196 | const error = new Error('failed');
197 |
198 | const loader = jest.fn().mockRejectedValue(error);
199 | const options: EntryOptions = {
200 | cacheKey: 'key',
201 | loader: loader,
202 | };
203 |
204 | let promise: Promise|undefined;
205 |
206 | try {
207 | cache.load(options);
208 | } catch (result: any|undefined) {
209 | promise = result;
210 | }
211 |
212 | await expect(promise).resolves.toBeUndefined();
213 |
214 | await expect(() => cache.load(options)).toThrow(error);
215 | });
216 |
217 | it('should cache the error', async () => {
218 | const cache = new Cache(10);
219 |
220 | const error = new Error('error');
221 | const loader = jest.fn().mockRejectedValue(error);
222 | const options: EntryOptions = {
223 | cacheKey: 'key',
224 | loader: loader,
225 | };
226 |
227 | let promise: Promise|undefined;
228 |
229 | try {
230 | cache.load(options);
231 | } catch (result: any|undefined) {
232 | promise = result;
233 | }
234 |
235 | await expect(promise).resolves.toBeUndefined();
236 |
237 | expect(() => cache.load(options)).toThrow(error);
238 | expect(cache.get(options.cacheKey)?.error).toBe(error);
239 | });
240 |
241 | it('should provide the cached values', async () => {
242 | jest.useFakeTimers();
243 |
244 | const cache = new Cache(10);
245 |
246 | const loader = jest.fn().mockResolvedValue('loaded');
247 | const options: EntryOptions = {
248 | cacheKey: 'key',
249 | loader: loader,
250 | };
251 |
252 | let promise: Promise|undefined;
253 |
254 | try {
255 | cache.load(options);
256 | } catch (result: any|undefined) {
257 | promise = result;
258 | }
259 |
260 | await promise;
261 |
262 | jest.advanceTimersByTime(9);
263 |
264 | const entry = cache.get(options.cacheKey);
265 |
266 | expect(entry?.result).toBe('loaded');
267 | expect(entry?.promise).toBe(promise);
268 | expect(entry?.timeout).not.toBeUndefined();
269 | expect(entry?.error).toBeUndefined();
270 |
271 | entry?.dispose();
272 |
273 | jest.advanceTimersByTime(9);
274 |
275 | expect(cache.get(options.cacheKey)).toBe(entry);
276 |
277 | expect(loader).toHaveBeenCalledTimes(1);
278 | });
279 | });
280 |
--------------------------------------------------------------------------------
/src/hooks/Cache.ts:
--------------------------------------------------------------------------------
1 | export type EntryLoader = (...args: any) => Promise;
2 |
3 | export type EntryOptions = {
4 | cacheKey: string,
5 | loader: EntryLoader,
6 | fallback?: R,
7 | expiration?: number,
8 | };
9 |
10 | type Entry = {
11 | promise: Promise,
12 | result?: R,
13 | dispose: () => void,
14 | timeout?: number,
15 | error?: any,
16 | };
17 |
18 | /**
19 | * @internal
20 | */
21 | export class Cache {
22 | private readonly cache: Record = {};
23 |
24 | private readonly defaultExpiration: number;
25 |
26 | public constructor(defaultExpiration: number) {
27 | this.defaultExpiration = defaultExpiration;
28 | }
29 |
30 | public load(configuration: EntryOptions): R {
31 | const {cacheKey, loader, fallback, expiration = this.defaultExpiration} = configuration;
32 |
33 | const cachedEntry = this.get(cacheKey);
34 |
35 | if (cachedEntry !== undefined) {
36 | if (cachedEntry.error !== undefined) {
37 | if (fallback !== undefined) {
38 | return fallback;
39 | }
40 |
41 | if (cachedEntry.result === undefined) {
42 | throw cachedEntry.error;
43 | }
44 | }
45 |
46 | if (cachedEntry.result !== undefined) {
47 | return cachedEntry.result;
48 | }
49 |
50 | throw cachedEntry.promise;
51 | }
52 |
53 | const entry: Entry = {
54 | dispose: () => {
55 | if (entry.timeout !== undefined || expiration < 0) {
56 | return;
57 | }
58 |
59 | entry.timeout = window.setTimeout(
60 | (): void => {
61 | delete this.cache[cacheKey];
62 | },
63 | expiration,
64 | );
65 | },
66 | promise: loader()
67 | .then((result): R => {
68 | entry.result = result;
69 |
70 | return result;
71 | })
72 | .catch(error => {
73 | entry.result = fallback;
74 | entry.error = error;
75 |
76 | return fallback;
77 | })
78 | .finally(() => {
79 | entry.dispose();
80 | }),
81 | };
82 |
83 | this.cache[cacheKey] = entry;
84 |
85 | throw entry.promise;
86 | }
87 |
88 | public get(cacheKey: string): Entry|undefined {
89 | const entry = this.cache[cacheKey];
90 |
91 | if (entry === undefined) {
92 | return undefined;
93 | }
94 |
95 | if (entry.timeout !== undefined) {
96 | clearTimeout(entry.timeout);
97 |
98 | delete entry.timeout;
99 |
100 | entry.dispose();
101 | }
102 |
103 | return entry;
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useEvaluation';
2 | export * from './useContent';
3 | export * from './useCroct';
4 |
--------------------------------------------------------------------------------
/src/hooks/useContent.d.test.tsx:
--------------------------------------------------------------------------------
1 | import {join as pathJoin} from 'path';
2 | import {create} from 'ts-node';
3 |
4 | const tsService = create({
5 | cwd: __dirname,
6 | transpileOnly: false,
7 | });
8 |
9 | const testFilename = pathJoin(__dirname, 'test.tsx');
10 |
11 | describe('useContent typing', () => {
12 | const header = `
13 | import {useContent} from './useContent';
14 | `;
15 |
16 | const slotMapping = `
17 | type HomeBanner = {
18 | title: string,
19 | subtitle: string,
20 | };
21 |
22 | type Banner = {
23 | title: string,
24 | subtitle: string,
25 | };
26 |
27 | type Carousel = {
28 | title: string,
29 | subtitle: string,
30 | };
31 |
32 | declare module '@croct/plug/slot' {
33 | type HomeBannerV1 = HomeBanner & {_component: 'banner@v1' | null};
34 |
35 | interface VersionedSlotMap {
36 | 'home-banner': {
37 | 'latest': HomeBannerV1,
38 | '1': HomeBannerV1,
39 | };
40 | }
41 | }
42 |
43 | declare module '@croct/plug/component' {
44 | interface VersionedComponentMap {
45 | 'banner': {
46 | 'latest': Banner,
47 | '1': Banner,
48 | };
49 | 'carousel': {
50 | 'latest': Carousel,
51 | '1': Carousel,
52 | };
53 | }
54 | }
55 | `;
56 |
57 | type CodeOptions = {
58 | code: string,
59 | mapping: boolean,
60 | };
61 |
62 | type AssembledCode = {
63 | code: string,
64 | codePosition: number,
65 | };
66 |
67 | function assembleCode({code, mapping}: CodeOptions): AssembledCode {
68 | const prefix = mapping
69 | ? header + slotMapping
70 | : header;
71 |
72 | return {
73 | code: prefix + code.trim(),
74 | codePosition: prefix.length + 1,
75 | };
76 | }
77 |
78 | function compileCode(opts: CodeOptions): void {
79 | tsService.compile(assembleCode(opts).code, testFilename);
80 | }
81 |
82 | function getTypeName(opts: CodeOptions): string {
83 | const assembledCode = assembleCode(opts);
84 |
85 | const info = tsService.getTypeInfo(assembledCode.code, testFilename, assembledCode.codePosition);
86 |
87 | const match = info.name.match(/^\(alias\) (useContent<.+?>)/s);
88 |
89 | if (match !== null) {
90 | return match[1].replace(/\s*\n\s*/g, '');
91 | }
92 |
93 | return info.name;
94 | }
95 |
96 | function getReturnType(opts: CodeOptions): string {
97 | const assembledCode = assembleCode(opts);
98 |
99 | const info = tsService.getTypeInfo(assembledCode.code, testFilename, assembledCode.codePosition);
100 |
101 | const match = info.name.match(/\): (.+?)(?: \(\+.+\))\nimport useContent$/s);
102 |
103 | if (match !== null) {
104 | return match[1].replace(/\s*\n\s*/g, '');
105 | }
106 |
107 | return info.name;
108 | }
109 |
110 | it('should define the return type as a JSON object by default for unmapped slots', () => {
111 | const code: CodeOptions = {
112 | code: `
113 | useContent('home-banner');
114 | `,
115 | mapping: false,
116 | };
117 |
118 | expect(() => compileCode(code)).not.toThrow();
119 |
120 | expect(getTypeName(code)).toBe(
121 | 'useContent',
122 | );
123 |
124 | expect(getReturnType(code)).toBe('JsonObject');
125 | });
126 |
127 | it('should define the return type as an union of component for unknown slots', () => {
128 | const code: CodeOptions = {
129 | code: `
130 | useContent('dynamic-id' as any);
131 | `,
132 | mapping: true,
133 | };
134 |
135 | expect(() => compileCode(code)).not.toThrow();
136 |
137 | expect(getTypeName(code)).toBe(
138 | 'useContent',
139 | );
140 |
141 | expect(getReturnType(code)).toBe(
142 | '(Banner & {_component: "banner@1" | null;}) | (Carousel & {...;})',
143 | );
144 | });
145 |
146 | it('should include the type of the initial value on the return type for unmapped slots', () => {
147 | const code: CodeOptions = {
148 | code: `
149 | useContent('home-banner', {initial: true});
150 | `,
151 | mapping: false,
152 | };
153 |
154 | expect(() => compileCode(code)).not.toThrow();
155 |
156 | expect(getTypeName(code)).toBe(
157 | 'useContent',
158 | );
159 |
160 | expect(getReturnType(code)).toBe('boolean | JsonObject');
161 | });
162 |
163 | it('should include the type of the fallback value on the return type for unmapped slots', () => {
164 | const code: CodeOptions = {
165 | code: `
166 | useContent('home-banner', {fallback: 1});
167 | `,
168 | mapping: false,
169 | };
170 |
171 | expect(() => compileCode(code)).not.toThrow();
172 |
173 | expect(getTypeName(code)).toBe(
174 | 'useContent',
175 | );
176 |
177 | expect(getReturnType(code)).toBe('number | JsonObject');
178 | });
179 |
180 | it('should include the types of both the initial and fallback values on the return type for unmapped slots', () => {
181 | const code: CodeOptions = {
182 | code: `
183 | useContent('home-banner', {initial: true, fallback: 1});
184 | `,
185 | mapping: false,
186 | };
187 |
188 | expect(() => compileCode(code)).not.toThrow();
189 |
190 | expect(getTypeName(code)).toBe(
191 | 'useContent',
192 | );
193 |
194 | expect(getReturnType(code)).toBe('number | ... 1 more ... | JsonObject');
195 | });
196 |
197 | it('should allow narrowing the return type for unmapped slots', () => {
198 | const code: CodeOptions = {
199 | code: `
200 | useContent<{foo: string}>('home-banner');
201 | `,
202 | mapping: false,
203 | };
204 |
205 | expect(() => compileCode(code)).not.toThrow();
206 |
207 | expect(getTypeName(code)).toBe(
208 | 'useContent<{foo: string;}, {foo: string;}, {foo: string;}>',
209 | );
210 |
211 | expect(getReturnType(code)).toBe('{foo: string;}');
212 | });
213 |
214 | it('should allow specifying the initial value type for mapped slots', () => {
215 | const code: CodeOptions = {
216 | code: `
217 | useContent<{foo: string}, boolean>('home-banner', {initial: true});
218 | `,
219 | mapping: false,
220 | };
221 |
222 | expect(() => compileCode(code)).not.toThrow();
223 |
224 | expect(getTypeName(code)).toBe(
225 | 'useContent<{foo: string;}, boolean, {foo: string;}>',
226 | );
227 |
228 | expect(getReturnType(code)).toBe('boolean | {foo: string;}');
229 | });
230 |
231 | it('should allow specifying the fallback value type for mapped slots', () => {
232 | const code: CodeOptions = {
233 | code: `
234 | useContent<{foo: string}, never, number>('home-banner', {fallback: 1});
235 | `,
236 | mapping: false,
237 | };
238 |
239 | expect(() => compileCode(code)).not.toThrow();
240 |
241 | expect(getTypeName(code)).toBe(
242 | 'useContent<{foo: string;}, never, number>',
243 | );
244 |
245 | expect(getReturnType(code)).toBe('number | {foo: string;}');
246 | });
247 |
248 | it('show allow specifying the initial and fallback value types for mapped slots', () => {
249 | const code: CodeOptions = {
250 | code: `
251 | useContent<{foo: string}, boolean, number>('home-banner', {initial: true, fallback: 1});
252 | `,
253 | mapping: false,
254 | };
255 |
256 | expect(() => compileCode(code)).not.toThrow();
257 |
258 | expect(getTypeName(code)).toBe(
259 | 'useContent<{foo: string;}, boolean, number>',
260 | );
261 |
262 | expect(getReturnType(code)).toBe('number | ... 1 more ... | {foo: string;}');
263 | });
264 |
265 | it('should require specifying JSON object as return type for mapped slots', () => {
266 | const code: CodeOptions = {
267 | code: `
268 | useContent('home-banner');
269 | `,
270 | mapping: false,
271 | };
272 |
273 | expect(() => compileCode(code)).toThrow();
274 | });
275 |
276 | it('should infer the return type for mapped slots', () => {
277 | const code: CodeOptions = {
278 | code: `
279 | useContent('home-banner');
280 | `,
281 | mapping: true,
282 | };
283 |
284 | expect(() => compileCode(code)).not.toThrow();
285 |
286 | expect(getTypeName(code)).toBe('useContent<"home-banner">');
287 |
288 | expect(getReturnType(code)).toBe('HomeBannerV1');
289 | });
290 |
291 | it('should include the type of the initial value on the return type for mapped slots', () => {
292 | const code: CodeOptions = {
293 | code: `
294 | useContent('home-banner', {initial: true});
295 | `,
296 | mapping: true,
297 | };
298 |
299 | expect(() => compileCode(code)).not.toThrow();
300 |
301 | expect(getTypeName(code)).toBe('useContent');
302 |
303 | expect(getReturnType(code)).toBe('boolean | HomeBannerV1');
304 | });
305 |
306 | it('should include the type of the fallback value on the return type for mapped slots', () => {
307 | const code: CodeOptions = {
308 | code: `
309 | useContent('home-banner', {fallback: 1});
310 | `,
311 | mapping: true,
312 | };
313 |
314 | expect(() => compileCode(code)).not.toThrow();
315 |
316 | expect(getTypeName(code)).toBe('useContent');
317 |
318 | expect(getReturnType(code)).toBe('number | HomeBannerV1');
319 | });
320 |
321 | it('should include the types of both the initial and fallback values on the return type for mapped slots', () => {
322 | const code: CodeOptions = {
323 | code: `
324 | useContent('home-banner', {initial: true, fallback: 1});
325 | `,
326 | mapping: true,
327 | };
328 |
329 | expect(() => compileCode(code)).not.toThrow();
330 |
331 | expect(getTypeName(code)).toBe('useContent');
332 |
333 | expect(getReturnType(code)).toBe('number | boolean | HomeBannerV1');
334 | });
335 |
336 | it('should not allow overriding the return type for mapped slots', () => {
337 | const code: CodeOptions = {
338 | code: `
339 | useContent<{title: string}>('home-banner');
340 | `,
341 | mapping: true,
342 | };
343 |
344 | expect(() => compileCode(code)).toThrow();
345 | });
346 | });
347 |
--------------------------------------------------------------------------------
/src/hooks/useContent.ssr.test.ts:
--------------------------------------------------------------------------------
1 | import {renderHook} from '@testing-library/react';
2 | import {getSlotContent} from '@croct/content';
3 | import {useContent} from './useContent';
4 |
5 | jest.mock(
6 | '../ssr-polyfills',
7 | () => ({
8 | __esModule: true,
9 | isSsr: (): boolean => true,
10 | }),
11 | );
12 |
13 | jest.mock(
14 | '@croct/content',
15 | () => ({
16 | __esModule: true,
17 | getSlotContent: jest.fn().mockReturnValue(null),
18 | }),
19 | );
20 |
21 | describe('useContent (SSR)', () => {
22 | beforeEach(() => {
23 | jest.clearAllMocks();
24 | });
25 |
26 | it('should render the initial value on the server-side', () => {
27 | const {result} = renderHook(() => useContent('slot-id', {initial: 'foo'}));
28 |
29 | expect(result.current).toBe('foo');
30 | });
31 |
32 | it('should require an initial value for server-side rending', () => {
33 | expect(() => useContent('slot-id'))
34 | .toThrow('The initial content is required for server-side rendering (SSR).');
35 | });
36 |
37 | it('should use the default content as initial value on the server-side if not provided', () => {
38 | const content = {foo: 'bar'};
39 | const slotId = 'slot-id';
40 | const preferredLocale = 'en';
41 |
42 | jest.mocked(getSlotContent).mockReturnValue(content);
43 |
44 | const {result} = renderHook(() => useContent(slotId, {preferredLocale: preferredLocale}));
45 |
46 | expect(getSlotContent).toHaveBeenCalledWith(slotId, preferredLocale);
47 |
48 | expect(result.current).toBe(content);
49 | });
50 |
51 | it('should use the provided initial value on the server-side', () => {
52 | const initial = null;
53 | const slotId = 'slot-id';
54 | const preferredLocale = 'en';
55 |
56 | jest.mocked(getSlotContent).mockReturnValue(null);
57 |
58 | const {result} = renderHook(
59 | () => useContent(slotId, {
60 | preferredLocale: preferredLocale,
61 | initial: initial,
62 | }),
63 | );
64 |
65 | expect(result.current).toBe(initial);
66 | });
67 |
68 | it('should normalize an empty preferred locale to undefined', () => {
69 | const slotId = 'slot-id';
70 | const preferredLocale = '';
71 |
72 | jest.mocked(getSlotContent).mockReturnValue({
73 | foo: 'bar',
74 | });
75 |
76 | renderHook(
77 | () => useContent(slotId, {
78 | preferredLocale: preferredLocale,
79 | }),
80 | );
81 |
82 | expect(getSlotContent).toHaveBeenCalledWith(slotId, undefined);
83 | });
84 | });
85 |
--------------------------------------------------------------------------------
/src/hooks/useContent.test.ts:
--------------------------------------------------------------------------------
1 | import {renderHook, waitFor} from '@testing-library/react';
2 | import {getSlotContent} from '@croct/content';
3 | import {Plug} from '@croct/plug';
4 | import {useCroct} from './useCroct';
5 | import {useLoader} from './useLoader';
6 | import {useContent} from './useContent';
7 | import {hash} from '../hash';
8 |
9 | jest.mock(
10 | './useCroct',
11 | () => ({
12 | useCroct: jest.fn(),
13 | }),
14 | );
15 |
16 | jest.mock(
17 | './useLoader',
18 | () => ({
19 | useLoader: jest.fn(),
20 | }),
21 | );
22 |
23 | jest.mock(
24 | '@croct/content',
25 | () => ({
26 | __esModule: true,
27 | getSlotContent: jest.fn().mockReturnValue(null),
28 | }),
29 | );
30 |
31 | describe('useContent (CSR)', () => {
32 | beforeEach(() => {
33 | jest.resetAllMocks();
34 | });
35 |
36 | it('should fetch the content', () => {
37 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({
38 | content: {},
39 | });
40 |
41 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
42 | jest.mocked(useLoader).mockReturnValue({
43 | title: 'foo',
44 | });
45 |
46 | const slotId = 'home-banner@1';
47 | const preferredLocale = 'en';
48 | const attributes = {example: 'value'};
49 | const cacheKey = 'unique';
50 |
51 | const {result} = renderHook(
52 | () => useContent<{title: string}>(slotId, {
53 | preferredLocale: preferredLocale,
54 | attributes: attributes,
55 | cacheKey: cacheKey,
56 | fallback: {
57 | title: 'error',
58 | },
59 | expiration: 50,
60 | }),
61 | );
62 |
63 | expect(useCroct).toHaveBeenCalled();
64 | expect(useLoader).toHaveBeenCalledWith({
65 | cacheKey: hash(`useContent:${cacheKey}:${slotId}:${preferredLocale}:${JSON.stringify(attributes)}`),
66 | expiration: 50,
67 | loader: expect.any(Function),
68 | });
69 |
70 | jest.mocked(useLoader)
71 | .mock
72 | .calls[0][0]
73 | .loader();
74 |
75 | expect(fetch).toHaveBeenCalledWith(slotId, {
76 | fallback: {title: 'error'},
77 | preferredLocale: 'en',
78 | attributes: attributes,
79 | });
80 |
81 | expect(result.current).toEqual({title: 'foo'});
82 | });
83 |
84 | it('should use the initial value when the cache key changes if the stale-while-loading flag is false', async () => {
85 | const key = {
86 | current: 'initial',
87 | };
88 |
89 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({content: {}});
90 |
91 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
92 |
93 | jest.mocked(useLoader).mockImplementation(
94 | () => ({title: key.current === 'initial' ? 'first' : 'second'}),
95 | );
96 |
97 | const slotId = 'home-banner@1';
98 |
99 | const {result, rerender} = renderHook(
100 | () => useContent<{title: string}>(slotId, {
101 | cacheKey: key.current,
102 | initial: {
103 | title: 'initial',
104 | },
105 | }),
106 | );
107 |
108 | expect(useCroct).toHaveBeenCalled();
109 |
110 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({
111 | cacheKey: hash(`useContent:${key.current}:${slotId}::${JSON.stringify({})}`),
112 | initial: {
113 | title: 'initial',
114 | },
115 | }));
116 |
117 | await waitFor(() => expect(result.current).toEqual({title: 'first'}));
118 |
119 | key.current = 'next';
120 |
121 | rerender();
122 |
123 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({
124 | cacheKey: hash(`useContent:${key.current}:${slotId}::${JSON.stringify({})}`),
125 | initial: {
126 | title: 'initial',
127 | },
128 | }));
129 |
130 | await waitFor(() => expect(result.current).toEqual({title: 'second'}));
131 | });
132 |
133 | it('should use the last fetched content as initial value if the stale-while-loading flag is true', async () => {
134 | const key = {
135 | current: 'initial',
136 | };
137 |
138 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({content: {}});
139 |
140 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
141 |
142 | const firstResult = {
143 | title: 'first',
144 | };
145 |
146 | const secondResult = {
147 | title: 'second',
148 | };
149 |
150 | jest.mocked(useLoader).mockImplementation(
151 | () => (key.current === 'initial' ? firstResult : secondResult),
152 | );
153 |
154 | const slotId = 'home-banner@1';
155 |
156 | const {result, rerender} = renderHook(
157 | () => useContent<{title: string}>(slotId, {
158 | cacheKey: key.current,
159 | initial: {
160 | title: 'initial',
161 | },
162 | staleWhileLoading: true,
163 | }),
164 | );
165 |
166 | expect(useCroct).toHaveBeenCalled();
167 |
168 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({
169 | cacheKey: hash(`useContent:${key.current}:${slotId}::${JSON.stringify({})}`),
170 | initial: {
171 | title: 'initial',
172 | },
173 | }));
174 |
175 | await waitFor(() => expect(result.current).toEqual({title: 'first'}));
176 |
177 | key.current = 'next';
178 |
179 | rerender();
180 |
181 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({
182 | cacheKey: hash(`useContent:${key.current}:${slotId}::${JSON.stringify({})}`),
183 | initial: {
184 | title: 'first',
185 | },
186 | }));
187 |
188 | await waitFor(() => expect(result.current).toEqual({title: 'second'}));
189 | });
190 |
191 | it('should use the default content as initial value if not provided', () => {
192 | const content = {foo: 'bar'};
193 | const slotId = 'slot-id';
194 | const preferredLocale = 'en';
195 |
196 | jest.mocked(getSlotContent).mockReturnValue(content);
197 |
198 | renderHook(() => useContent(slotId, {preferredLocale: preferredLocale}));
199 |
200 | expect(getSlotContent).toHaveBeenCalledWith(slotId, preferredLocale);
201 |
202 | expect(useLoader).toHaveBeenCalledWith(
203 | expect.objectContaining({
204 | initial: content,
205 | }),
206 | );
207 | });
208 |
209 | it('should use the provided initial value', () => {
210 | const initial = null;
211 | const slotId = 'slot-id';
212 | const preferredLocale = 'en';
213 |
214 | jest.mocked(getSlotContent).mockReturnValue(null);
215 |
216 | renderHook(
217 | () => useContent(slotId, {
218 | preferredLocale: preferredLocale,
219 | initial: initial,
220 | }),
221 | );
222 |
223 | expect(useLoader).toHaveBeenCalledWith(
224 | expect.objectContaining({
225 | initial: initial,
226 | }),
227 | );
228 | });
229 |
230 | it('should use the default content as fallback value if not provided', () => {
231 | const content = {foo: 'bar'};
232 | const slotId = 'slot-id';
233 | const preferredLocale = 'en';
234 |
235 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({
236 | content: {},
237 | });
238 |
239 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
240 |
241 | jest.mocked(getSlotContent).mockReturnValue(content);
242 |
243 | renderHook(
244 | () => useContent(slotId, {
245 | preferredLocale: preferredLocale,
246 | fallback: content,
247 | }),
248 | );
249 |
250 | expect(getSlotContent).toHaveBeenCalledWith(slotId, preferredLocale);
251 |
252 | jest.mocked(useLoader)
253 | .mock
254 | .calls[0][0]
255 | .loader();
256 |
257 | expect(fetch).toHaveBeenCalledWith(slotId, {
258 | fallback: content,
259 | preferredLocale: preferredLocale,
260 | });
261 | });
262 |
263 | it('should use the provided fallback value', () => {
264 | const fallback = null;
265 | const slotId = 'slot-id';
266 | const preferredLocale = 'en';
267 |
268 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({
269 | content: {},
270 | });
271 |
272 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
273 |
274 | jest.mocked(getSlotContent).mockReturnValue(null);
275 |
276 | renderHook(
277 | () => useContent(slotId, {
278 | preferredLocale: preferredLocale,
279 | fallback: fallback,
280 | }),
281 | );
282 |
283 | jest.mocked(useLoader)
284 | .mock
285 | .calls[0][0]
286 | .loader();
287 |
288 | expect(fetch).toHaveBeenCalledWith(slotId, {
289 | fallback: fallback,
290 | preferredLocale: preferredLocale,
291 | });
292 | });
293 |
294 | it('should normalize an empty preferred locale to undefined', () => {
295 | const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({
296 | content: {},
297 | });
298 |
299 | jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
300 |
301 | renderHook(
302 | () => useContent('slot-id', {
303 | preferredLocale: '',
304 | }),
305 | );
306 |
307 | jest.mocked(useLoader)
308 | .mock
309 | .calls[0][0]
310 | .loader();
311 |
312 | expect(jest.mocked(fetch).mock.calls[0][1]).toStrictEqual({});
313 | });
314 | });
315 |
--------------------------------------------------------------------------------
/src/hooks/useContent.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {SlotContent, VersionedSlotId, VersionedSlotMap} from '@croct/plug/slot';
4 | import {JsonObject} from '@croct/plug/sdk/json';
5 | import {FetchOptions} from '@croct/plug/plug';
6 | import {useEffect, useMemo, useState} from 'react';
7 | import {getSlotContent} from '@croct/content';
8 | import {useLoader} from './useLoader';
9 | import {useCroct} from './useCroct';
10 | import {isSsr} from '../ssr-polyfills';
11 | import {hash} from '../hash';
12 |
13 | export type UseContentOptions = FetchOptions & {
14 | initial?: I,
15 | cacheKey?: string,
16 | expiration?: number,
17 | staleWhileLoading?: boolean,
18 | };
19 |
20 | function useCsrContent(
21 | id: VersionedSlotId,
22 | options: UseContentOptions = {},
23 | ): SlotContent | I | F {
24 | const {
25 | cacheKey,
26 | expiration,
27 | fallback: fallbackContent,
28 | initial: initialContent,
29 | staleWhileLoading = false,
30 | preferredLocale,
31 | ...fetchOptions
32 | } = options;
33 |
34 | const normalizedLocale = normalizePreferredLocale(preferredLocale);
35 | const defaultContent = useMemo(
36 | () => getSlotContent(id, normalizedLocale) as SlotContent|null ?? undefined,
37 | [id, normalizedLocale],
38 | );
39 | const fallback = fallbackContent === undefined ? defaultContent : fallbackContent;
40 | const [initial, setInitial] = useState(
41 | () => (initialContent === undefined ? defaultContent : initialContent),
42 | );
43 |
44 | const croct = useCroct();
45 |
46 | const result: SlotContent | I | F = useLoader({
47 | cacheKey: hash(
48 | `useContent:${cacheKey ?? ''}`
49 | + `:${id}`
50 | + `:${normalizedLocale ?? ''}`
51 | + `:${JSON.stringify(fetchOptions.attributes ?? {})}`,
52 | ),
53 | loader: () => croct.fetch(id, {
54 | ...fetchOptions,
55 | ...(normalizedLocale !== undefined ? {preferredLocale: normalizedLocale} : {}),
56 | ...(fallback !== undefined ? {fallback: fallback} : {}),
57 | }).then(({content}) => content),
58 | initial: initial,
59 | expiration: expiration,
60 | });
61 |
62 | useEffect(
63 | () => {
64 | if (staleWhileLoading) {
65 | setInitial(current => {
66 | if (current !== result) {
67 | return result;
68 | }
69 |
70 | return current;
71 | });
72 | }
73 | },
74 | [result, staleWhileLoading],
75 | );
76 |
77 | return result;
78 | }
79 |
80 | function useSsrContent(
81 | slotId: VersionedSlotId,
82 | {initial, preferredLocale}: UseContentOptions = {},
83 | ): SlotContent | I | F {
84 | const resolvedInitialContent = initial === undefined
85 | ? getSlotContent(slotId, normalizePreferredLocale(preferredLocale)) as I|null ?? undefined
86 | : initial;
87 |
88 | if (resolvedInitialContent === undefined) {
89 | throw new Error(
90 | 'The initial content is required for server-side rendering (SSR). '
91 | + 'For help, see https://croct.help/sdk/react/missing-slot-content',
92 | );
93 | }
94 |
95 | return resolvedInitialContent;
96 | }
97 |
98 | function normalizePreferredLocale(preferredLocale: string|undefined): string|undefined {
99 | return preferredLocale !== undefined && preferredLocale !== '' ? preferredLocale : undefined;
100 | }
101 |
102 | type UseContentHook = {
103 | (
104 | id: keyof VersionedSlotMap extends never ? string : never,
105 | options?: UseContentOptions
106 | ): P | I | F,
107 |
108 | (
109 | id: S,
110 | options?: UseContentOptions
111 | ): SlotContent,
112 |
113 | (
114 | id: S,
115 | options?: UseContentOptions
116 | ): SlotContent | I,
117 |
118 | (
119 | id: S,
120 | options?: UseContentOptions
121 | ): SlotContent | F,
122 |
123 | (
124 | id: S,
125 | options?: UseContentOptions
126 | ): SlotContent | I | F,
127 | };
128 |
129 | export const useContent: UseContentHook = isSsr() ? useSsrContent : useCsrContent;
130 |
--------------------------------------------------------------------------------
/src/hooks/useCroct.ssr.test.tsx:
--------------------------------------------------------------------------------
1 | import {renderHook} from '@testing-library/react';
2 | import {useCroct} from './useCroct';
3 | import {CroctProvider} from '../CroctProvider';
4 |
5 | jest.mock(
6 | '../ssr-polyfills',
7 | () => ({
8 | __esModule: true,
9 | ...jest.requireActual('../ssr-polyfills'),
10 | isSsr: (): boolean => true,
11 | }),
12 | );
13 |
14 | describe('useCroct', () => {
15 | it('should not fail on server-side rendering', () => {
16 | const {result} = renderHook(() => useCroct(), {
17 | wrapper: ({children}) => (
18 |
19 | {children}
20 |
21 | ),
22 | });
23 |
24 | expect(result).not.toBeUndefined();
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/src/hooks/useCroct.test.tsx:
--------------------------------------------------------------------------------
1 | import croct from '@croct/plug';
2 | import {renderHook} from '@testing-library/react';
3 | import {useCroct} from './useCroct';
4 | import {CroctContext} from '../CroctProvider';
5 |
6 | describe('useCroct', () => {
7 | it('should fail if used out of the component', () => {
8 | expect(() => renderHook(() => useCroct()))
9 | .toThrow('useCroct() can only be used in the context of a component.');
10 | });
11 |
12 | it('should return the Plug instance', () => {
13 | const {result} = renderHook(() => useCroct(), {
14 | wrapper: ({children}) => (
15 | {children}
16 | ),
17 | });
18 |
19 | expect(result.current).toBe(croct);
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/src/hooks/useCroct.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {Plug} from '@croct/plug';
4 | import {useContext} from 'react';
5 | import {CroctContext} from '../CroctProvider';
6 |
7 | export function useCroct(): Plug {
8 | const context = useContext(CroctContext);
9 |
10 | if (context === null) {
11 | throw new Error(
12 | 'useCroct() can only be used in the context of a component. '
13 | + 'For help, see https://croct.help/sdk/react/missing-provider',
14 | );
15 | }
16 |
17 | return context.plug;
18 | }
19 |
--------------------------------------------------------------------------------
/src/hooks/useEvaluation.d.test.tsx:
--------------------------------------------------------------------------------
1 | import {join as pathJoin} from 'path';
2 | import {create} from 'ts-node';
3 |
4 | const tsService = create({
5 | cwd: __dirname,
6 | transpileOnly: false,
7 | });
8 |
9 | const testFilename = pathJoin(__dirname, 'test.tsx');
10 |
11 | describe('useEvaluation typing', () => {
12 | const header = `
13 | import {useEvaluation} from './useEvaluation';
14 | `;
15 |
16 | function compileCode(code: string): void {
17 | tsService.compile(header + code, testFilename);
18 | }
19 |
20 | function getTypeName(code: string): string {
21 | const info = tsService.getTypeInfo(header + code.trim(), testFilename, header.length + 1);
22 |
23 | const match = info.name.match(/^\(alias\) (useEvaluation<.+?>)/s);
24 |
25 | if (match !== null) {
26 | return match[1].replace(/\s*\n\s*/g, '');
27 | }
28 |
29 | return info.name;
30 | }
31 |
32 | function getReturnType(code: string): string {
33 | const info = tsService.getTypeInfo(header + code.trim(), testFilename, header.length + 1);
34 |
35 | const match = info.name.match(/\): (.+?)(?: \(\+.+\))?\nimport useEvaluation$/s);
36 |
37 | if (match !== null) {
38 | return match[1].replace(/\s*\n\s*/g, '');
39 | }
40 |
41 | return info.name;
42 | }
43 |
44 | it('should define the return type as a JSON object by default', () => {
45 | const code = `
46 | useEvaluation('x');
47 | `;
48 |
49 | expect(() => compileCode(code)).not.toThrow();
50 |
51 | expect(getTypeName(code)).toBe('useEvaluation');
52 |
53 | expect(getReturnType(code)).toBe('JsonValue');
54 | });
55 |
56 | it('should allow narrowing the return type', () => {
57 | const code = `
58 | useEvaluation('x');
59 | `;
60 |
61 | expect(() => compileCode(code)).not.toThrow();
62 |
63 | expect(getTypeName(code)).toBe('useEvaluation');
64 |
65 | expect(getReturnType(code)).toBe('string');
66 | });
67 |
68 | it('should include the type of the initial value on the return type', () => {
69 | const code = `
70 | useEvaluation('x', {initial: undefined});
71 | `;
72 |
73 | expect(() => compileCode(code)).not.toThrow();
74 |
75 | expect(getTypeName(code)).toBe('useEvaluation');
76 |
77 | expect(getReturnType(code)).toBe('JsonValue | undefined');
78 | });
79 |
80 | it('should include the type of the fallback value on the return type', () => {
81 | const code = `
82 | useEvaluation('x', {fallback: new Error()});
83 | `;
84 |
85 | expect(() => compileCode(code)).not.toThrow();
86 |
87 | expect(getTypeName(code)).toBe('useEvaluation');
88 |
89 | expect(getReturnType(code)).toBe('Error | JsonValue');
90 | });
91 |
92 | it('should include the types of both the initial and fallback values on the return type', () => {
93 | const code = `
94 | useEvaluation('x', {initial: undefined, fallback: new Error()});
95 | `;
96 |
97 | expect(() => compileCode(code)).not.toThrow();
98 |
99 | expect(getTypeName(code)).toBe('useEvaluation');
100 |
101 | expect(getReturnType(code)).toBe('Error | JsonValue | undefined');
102 | });
103 |
104 | it('should allow specifying the type of the initial and fallback values', () => {
105 | const code = `
106 | useEvaluation('x', {initial: undefined, fallback: new Error()});
107 | `;
108 |
109 | expect(() => compileCode(code)).not.toThrow();
110 |
111 | expect(getTypeName(code)).toBe('useEvaluation');
112 |
113 | expect(getReturnType(code)).toBe('string | Error | undefined');
114 | });
115 |
116 | it('should require specifying a JSON value as return type', () => {
117 | const code = `
118 | useEvaluation('x');
119 | `;
120 |
121 | expect(() => compileCode(code)).toThrow();
122 | });
123 | });
124 |
--------------------------------------------------------------------------------
/src/hooks/useEvaluation.ssr.test.ts:
--------------------------------------------------------------------------------
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('The initial value is required for server-side rendering (SSR).');
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/hooks/useEvaluation.test.ts:
--------------------------------------------------------------------------------
1 | import {renderHook, waitFor} 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 | import {hash} from '../hash';
8 |
9 | jest.mock(
10 | './useCroct',
11 | () => ({
12 | useCroct: jest.fn(),
13 | }),
14 | );
15 |
16 | jest.mock(
17 | './useLoader',
18 | () => ({
19 | useLoader: jest.fn(),
20 | }),
21 | );
22 |
23 | describe('useEvaluation', () => {
24 | beforeEach(() => {
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 | const cacheKey = 'unique';
43 |
44 | const {result} = renderHook(
45 | () => useEvaluation(query, {
46 | ...evaluationOptions,
47 | cacheKey: cacheKey,
48 | fallback: 'error',
49 | expiration: 50,
50 | }),
51 | );
52 |
53 | expect(useCroct).toHaveBeenCalled();
54 | expect(useLoader).toHaveBeenCalledWith({
55 | cacheKey: hash(`useEvaluation:${cacheKey}:${query}:${JSON.stringify(evaluationOptions.attributes)}`),
56 | fallback: 'error',
57 | expiration: 50,
58 | loader: expect.any(Function),
59 | });
60 |
61 | jest.mocked(useLoader)
62 | .mock
63 | .calls[0][0]
64 | .loader();
65 |
66 | expect(evaluate).toHaveBeenCalledWith(query, evaluationOptions);
67 |
68 | expect(result.current).toBe('foo');
69 | });
70 |
71 | it('should remove undefined evaluation options', () => {
72 | const evaluationOptions: EvaluationOptions = {
73 | timeout: undefined,
74 | attributes: undefined,
75 | };
76 |
77 | const evaluate: Plug['evaluate'] = jest.fn();
78 |
79 | jest.mocked(useCroct).mockReturnValue({evaluate: evaluate} as Plug);
80 | jest.mocked(useLoader).mockReturnValue('foo');
81 |
82 | const query = 'location';
83 |
84 | renderHook(() => useEvaluation(query, evaluationOptions));
85 |
86 | jest.mocked(useLoader)
87 | .mock
88 | .calls[0][0]
89 | .loader();
90 |
91 | expect(evaluate).toHaveBeenCalledWith(query, {});
92 | });
93 |
94 | it('should use the initial value when the cache key changes if the stale-while-loading flag is false', async () => {
95 | const key = {
96 | current: 'initial',
97 | };
98 |
99 | const evaluate: Plug['evaluate'] = jest.fn();
100 |
101 | jest.mocked(useCroct).mockReturnValue({evaluate: evaluate} as Plug);
102 |
103 | jest.mocked(useLoader).mockImplementation(
104 | () => (key.current === 'initial' ? 'first' : 'second'),
105 | );
106 |
107 | const query = 'location';
108 |
109 | const {result, rerender} = renderHook(
110 | () => useEvaluation(query, {
111 | cacheKey: key.current,
112 | initial: 'initial',
113 | }),
114 | );
115 |
116 | expect(useCroct).toHaveBeenCalled();
117 |
118 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({
119 | cacheKey: hash(`useEvaluation:${key.current}:${query}:${JSON.stringify({})}`),
120 | initial: 'initial',
121 | }));
122 |
123 | await waitFor(() => expect(result.current).toEqual('first'));
124 |
125 | key.current = 'next';
126 |
127 | rerender();
128 |
129 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({
130 | cacheKey: hash(`useEvaluation:${key.current}:${query}:${JSON.stringify({})}`),
131 | initial: 'initial',
132 | }));
133 |
134 | await waitFor(() => expect(result.current).toEqual('second'));
135 | });
136 |
137 | it('should use the last evaluation result if the stale-while-loading flag is true', async () => {
138 | const key = {
139 | current: 'initial',
140 | };
141 |
142 | const evaluate: Plug['evaluate'] = jest.fn();
143 |
144 | jest.mocked(useCroct).mockReturnValue({evaluate: evaluate} as Plug);
145 |
146 | jest.mocked(useLoader).mockImplementation(
147 | () => (key.current === 'initial' ? 'first' : 'second'),
148 | );
149 |
150 | const query = 'location';
151 |
152 | const {result, rerender} = renderHook(
153 | () => useEvaluation(query, {
154 | cacheKey: key.current,
155 | initial: 'initial',
156 | staleWhileLoading: true,
157 | }),
158 | );
159 |
160 | expect(useCroct).toHaveBeenCalled();
161 |
162 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({
163 | cacheKey: hash(`useEvaluation:${key.current}:${query}:${JSON.stringify({})}`),
164 | initial: 'initial',
165 | }));
166 |
167 | await waitFor(() => expect(result.current).toEqual('first'));
168 |
169 | key.current = 'next';
170 |
171 | rerender();
172 |
173 | expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({
174 | cacheKey: hash(`useEvaluation:${key.current}:${query}:${JSON.stringify({})}`),
175 | initial: 'first',
176 | }));
177 |
178 | await waitFor(() => expect(result.current).toEqual('second'));
179 | });
180 | });
181 |
--------------------------------------------------------------------------------
/src/hooks/useEvaluation.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {JsonValue} from '@croct/plug/sdk/json';
4 | import {EvaluationOptions} from '@croct/sdk/facade/evaluatorFacade';
5 | import {useEffect, useState} from 'react';
6 | import {useLoader} from './useLoader';
7 | import {useCroct} from './useCroct';
8 | import {isSsr} from '../ssr-polyfills';
9 | import {hash} from '../hash';
10 |
11 | export type UseEvaluationOptions = EvaluationOptions & {
12 | initial?: I,
13 | fallback?: F,
14 | cacheKey?: string,
15 | expiration?: number,
16 | staleWhileLoading?: boolean,
17 | };
18 |
19 | type UseEvaluationHook = (
20 | query: string,
21 | options?: UseEvaluationOptions,
22 | ) => T | I | F;
23 |
24 | function useCsrEvaluation(
25 | query: string,
26 | options: UseEvaluationOptions = {},
27 | ): T | I | F {
28 | const {
29 | cacheKey,
30 | fallback,
31 | expiration,
32 | staleWhileLoading = false,
33 | initial: initialValue,
34 | ...evaluationOptions
35 | } = options;
36 |
37 | const [initial, setInitial] = useState(initialValue);
38 | const croct = useCroct();
39 |
40 | const result = useLoader({
41 | cacheKey: hash(
42 | `useEvaluation:${cacheKey ?? ''}`
43 | + `:${query}`
44 | + `:${JSON.stringify(options.attributes ?? {})}`,
45 | ),
46 | loader: () => croct.evaluate(query, cleanEvaluationOptions(evaluationOptions)),
47 | initial: initial,
48 | fallback: fallback,
49 | expiration: expiration,
50 | });
51 |
52 | useEffect(
53 | () => {
54 | if (staleWhileLoading) {
55 | setInitial(current => {
56 | if (current !== result) {
57 | return result;
58 | }
59 |
60 | return current;
61 | });
62 | }
63 | },
64 | [result, staleWhileLoading],
65 | );
66 |
67 | return result;
68 | }
69 |
70 | function cleanEvaluationOptions(options: EvaluationOptions): EvaluationOptions {
71 | const result: EvaluationOptions = {};
72 |
73 | for (const [key, value] of Object.entries(options) as Array<[keyof EvaluationOptions, any]>) {
74 | if (value !== undefined) {
75 | result[key] = value;
76 | }
77 | }
78 |
79 | return result;
80 | }
81 |
82 | function useSsrEvaluation(
83 | _: string,
84 | {initial}: UseEvaluationOptions = {},
85 | ): T | I | F {
86 | if (initial === undefined) {
87 | throw new Error(
88 | 'The initial value is required for server-side rendering (SSR). '
89 | + 'For help, see https://croct.help/sdk/react/missing-evaluation-result',
90 | );
91 | }
92 |
93 | return initial;
94 | }
95 |
96 | export const useEvaluation: UseEvaluationHook = isSsr() ? useSsrEvaluation : useCsrEvaluation;
97 |
--------------------------------------------------------------------------------
/src/hooks/useLoader.test.ts:
--------------------------------------------------------------------------------
1 | import {act, renderHook, waitFor} from '@testing-library/react';
2 | import {StrictMode} from 'react';
3 | import {useLoader} from './useLoader';
4 |
5 | describe('useLoader', () => {
6 | const cacheKey = {
7 | index: 0,
8 | next: function next(): string {
9 | this.index++;
10 |
11 | return this.current();
12 | },
13 | current: function current(): string {
14 | return `key-${this.index}`;
15 | },
16 | };
17 |
18 | beforeEach(() => {
19 | cacheKey.next();
20 | jest.resetAllMocks();
21 | jest.clearAllTimers();
22 | });
23 |
24 | // Needed to use fake timers and promises:
25 | // https://github.com/testing-library/react-testing-library/issues/244#issuecomment-449461804
26 | function flushPromises(): Promise {
27 | return Promise.resolve();
28 | }
29 |
30 | it('should return the load the value and cache on success', async () => {
31 | const loader = jest.fn().mockResolvedValue('foo');
32 |
33 | const {result, rerender} = renderHook(
34 | () => useLoader({
35 | cacheKey: cacheKey.current(),
36 | loader: loader,
37 | }),
38 | );
39 |
40 | rerender();
41 |
42 | await waitFor(() => expect(result.current).toBe('foo'));
43 |
44 | expect(loader).toHaveBeenCalledTimes(1);
45 | });
46 |
47 | it('should load the value and cache on error', async () => {
48 | const error = new Error('fail');
49 | const loader = jest.fn().mockRejectedValue(error);
50 |
51 | const {result, rerender} = renderHook(
52 | () => useLoader({
53 | cacheKey: cacheKey.current(),
54 | fallback: error,
55 | loader: loader,
56 | }),
57 | );
58 |
59 | rerender();
60 |
61 | await waitFor(() => expect(result.current).toBe(error));
62 |
63 | expect(loader).toHaveBeenCalledTimes(1);
64 | });
65 |
66 | it('should reload the value on error', async () => {
67 | const content = {foo: 'qux'};
68 |
69 | const loader = jest.fn()
70 | .mockImplementationOnce(() => {
71 | throw new Error('fail');
72 | })
73 | .mockImplementationOnce(() => Promise.resolve(content));
74 |
75 | const {result, rerender} = renderHook(
76 | () => useLoader({
77 | cacheKey: cacheKey.current(),
78 | initial: {},
79 | loader: loader,
80 | }),
81 | );
82 |
83 | await act(flushPromises);
84 |
85 | rerender();
86 |
87 | await waitFor(() => expect(result.current).toBe(content));
88 |
89 | expect(loader).toHaveBeenCalledTimes(2);
90 | });
91 |
92 | it('should return the initial state on the initial render', async () => {
93 | const loader = jest.fn(() => Promise.resolve('loaded'));
94 |
95 | const {result} = renderHook(
96 | () => useLoader({
97 | cacheKey: cacheKey.current(),
98 | initial: 'loading',
99 | loader: loader,
100 | }),
101 | );
102 |
103 | expect(result.current).toBe('loading');
104 |
105 | await waitFor(() => expect(result.current).toBe('loaded'));
106 | });
107 |
108 | it('should update the initial state with the fallback state on error', async () => {
109 | const loader = jest.fn().mockRejectedValue(new Error('fail'));
110 |
111 | const {result} = renderHook(
112 | () => useLoader({
113 | cacheKey: cacheKey.current(),
114 | initial: 'loading',
115 | fallback: 'error',
116 | loader: loader,
117 | }),
118 | );
119 |
120 | expect(result.current).toBe('loading');
121 |
122 | await waitFor(() => expect(result.current).toBe('error'));
123 | });
124 |
125 | it('should return the fallback state on error', async () => {
126 | const loader = jest.fn().mockRejectedValue(new Error('fail'));
127 |
128 | const {result} = renderHook(
129 | () => useLoader({
130 | cacheKey: cacheKey.current(),
131 | fallback: 'foo',
132 | loader: loader,
133 | }),
134 | );
135 |
136 | await waitFor(() => expect(result.current).toBe('foo'));
137 |
138 | expect(loader).toHaveBeenCalled();
139 | });
140 |
141 | it('should extend the cache expiration on every render', async () => {
142 | jest.useFakeTimers();
143 |
144 | const loader = jest.fn().mockResolvedValue('foo');
145 |
146 | const {rerender, unmount} = renderHook(
147 | () => useLoader({
148 | cacheKey: cacheKey.current(),
149 | loader: loader,
150 | expiration: 15,
151 | }),
152 | );
153 |
154 | await act(flushPromises);
155 |
156 | jest.advanceTimersByTime(14);
157 |
158 | rerender();
159 |
160 | jest.advanceTimersByTime(14);
161 |
162 | rerender();
163 |
164 | expect(loader).toHaveBeenCalledTimes(1);
165 |
166 | jest.advanceTimersByTime(15);
167 |
168 | unmount();
169 |
170 | renderHook(
171 | () => useLoader({
172 | cacheKey: cacheKey.current(),
173 | loader: loader,
174 | expiration: 15,
175 | }),
176 | );
177 |
178 | await act(flushPromises);
179 |
180 | expect(loader).toHaveBeenCalledTimes(2);
181 | });
182 |
183 | it('should not expire the cache when the expiration is negative', async () => {
184 | jest.useFakeTimers();
185 |
186 | const loader = jest.fn(
187 | () => new Promise(resolve => {
188 | setTimeout(() => resolve('foo'), 10);
189 | }),
190 | );
191 |
192 | const {rerender} = renderHook(
193 | () => useLoader({
194 | cacheKey: cacheKey.current(),
195 | loader: loader,
196 | expiration: -1,
197 | }),
198 | );
199 |
200 | jest.advanceTimersByTime(10);
201 |
202 | await act(flushPromises);
203 |
204 | // First rerender
205 | rerender();
206 |
207 | // Second rerender
208 | rerender();
209 |
210 | expect(loader).toHaveBeenCalledTimes(1);
211 | });
212 |
213 | it('should reload the value when the cache key changes without initial value', async () => {
214 | jest.useFakeTimers();
215 |
216 | const loader = jest.fn()
217 | .mockResolvedValueOnce('foo')
218 | .mockImplementationOnce(
219 | () => new Promise(resolve => {
220 | setTimeout(() => resolve('bar'), 10);
221 | }),
222 | );
223 |
224 | const {result, rerender} = renderHook(
225 | props => useLoader({
226 | cacheKey: cacheKey.current(),
227 | loader: loader,
228 | initial: props?.initial,
229 | }),
230 | );
231 |
232 | await act(flushPromises);
233 |
234 | rerender();
235 |
236 | await waitFor(() => expect(result.current).toBe('foo'));
237 |
238 | expect(loader).toHaveBeenCalledTimes(1);
239 |
240 | cacheKey.next();
241 |
242 | rerender({initial: 'loading'});
243 |
244 | await waitFor(() => expect(result.current).toBe('loading'));
245 |
246 | jest.advanceTimersByTime(10);
247 |
248 | await waitFor(() => expect(result.current).toBe('bar'));
249 |
250 | expect(loader).toHaveBeenCalledTimes(2);
251 | });
252 |
253 | it('should reload the value when the cache key changes with initial value', async () => {
254 | jest.useFakeTimers();
255 |
256 | const loader = jest.fn()
257 | .mockImplementationOnce(
258 | () => new Promise(resolve => {
259 | setTimeout(() => resolve('foo'), 10);
260 | }),
261 | )
262 | .mockImplementationOnce(
263 | () => new Promise(resolve => {
264 | setTimeout(() => resolve('bar'), 10);
265 | }),
266 | );
267 |
268 | const {result, rerender} = renderHook(
269 | props => useLoader({
270 | cacheKey: cacheKey.current(),
271 | initial: props?.initial ?? 'first content',
272 | loader: loader,
273 | }),
274 | );
275 |
276 | await act(flushPromises);
277 |
278 | expect(result.current).toBe('first content');
279 |
280 | jest.advanceTimersByTime(10);
281 |
282 | await act(flushPromises);
283 |
284 | await waitFor(() => expect(result.current).toBe('foo'));
285 |
286 | expect(loader).toHaveBeenCalledTimes(1);
287 |
288 | cacheKey.next();
289 |
290 | rerender({initial: 'second content'});
291 |
292 | await waitFor(() => expect(result.current).toBe('second content'));
293 |
294 | jest.advanceTimersByTime(10);
295 |
296 | await act(flushPromises);
297 |
298 | await waitFor(() => expect(result.current).toBe('bar'));
299 |
300 | expect(loader).toHaveBeenCalledTimes(2);
301 | });
302 |
303 | it.each<[number, number|undefined]>(
304 | [
305 | // [Expected elapsed time, Expiration]
306 | [60_000, undefined],
307 | [15_000, 15_000],
308 | ],
309 | )('should cache the values for %d milliseconds', async (step, expiration) => {
310 | jest.useFakeTimers();
311 |
312 | const delay = 10;
313 | const loader = jest.fn(
314 | () => new Promise(resolve => {
315 | setTimeout(() => resolve('foo'), delay);
316 | }),
317 | );
318 |
319 | const {result: firstTime} = renderHook(
320 | () => useLoader({
321 | cacheKey: cacheKey.current(),
322 | expiration: expiration,
323 | loader: loader,
324 | }),
325 | );
326 |
327 | jest.advanceTimersByTime(delay);
328 |
329 | await act(flushPromises);
330 |
331 | await waitFor(() => expect(firstTime.current).toBe('foo'));
332 |
333 | const {result: secondTime} = renderHook(
334 | () => useLoader({
335 | cacheKey: cacheKey.current(),
336 | expiration: expiration,
337 | loader: loader,
338 | }),
339 | );
340 |
341 | expect(secondTime.current).toBe('foo');
342 |
343 | expect(loader).toHaveBeenCalledTimes(1);
344 |
345 | jest.advanceTimersByTime(step);
346 |
347 | const {result: thirdTime} = renderHook(
348 | () => useLoader({
349 | cacheKey: cacheKey.current(),
350 | expiration: expiration,
351 | loader: loader,
352 | }),
353 | );
354 |
355 | jest.advanceTimersByTime(delay);
356 |
357 | await act(flushPromises);
358 |
359 | await waitFor(() => expect(thirdTime.current).toBe('foo'));
360 |
361 | expect(loader).toHaveBeenCalledTimes(2);
362 | });
363 |
364 | it('should dispose the cache on unmount', async () => {
365 | jest.useFakeTimers();
366 |
367 | const delay = 10;
368 | const loader = jest.fn(
369 | () => new Promise(resolve => {
370 | setTimeout(() => resolve('foo'), delay);
371 | }),
372 | );
373 |
374 | const {unmount} = renderHook(
375 | () => useLoader({
376 | cacheKey: cacheKey.current(),
377 | expiration: 5,
378 | loader: loader,
379 | }),
380 | );
381 |
382 | jest.advanceTimersByTime(delay);
383 |
384 | await act(flushPromises);
385 |
386 | unmount();
387 |
388 | jest.advanceTimersByTime(5);
389 |
390 | await act(flushPromises);
391 |
392 | const {result: secondTime} = renderHook(
393 | () => useLoader({
394 | cacheKey: cacheKey.current(),
395 | expiration: 5,
396 | loader: loader,
397 | }),
398 | );
399 |
400 | jest.advanceTimersByTime(delay);
401 |
402 | await act(flushPromises);
403 |
404 | expect(loader).toHaveBeenCalledTimes(2);
405 |
406 | await waitFor(() => expect(secondTime.current).toBe('foo'));
407 | });
408 |
409 | it('should update the content in StrictMode', async () => {
410 | jest.useFakeTimers();
411 |
412 | const delay = 10;
413 | const loader = jest.fn(
414 | () => new Promise(resolve => {
415 | setTimeout(() => resolve('foo'), delay);
416 | }),
417 | );
418 |
419 | const {result} = renderHook(
420 | () => useLoader({
421 | cacheKey: cacheKey.current(),
422 | loader: loader,
423 | initial: 'bar',
424 | }),
425 | {
426 | wrapper: StrictMode,
427 | },
428 | );
429 |
430 | // Let the loader resolve
431 | await act(async () => {
432 | jest.advanceTimersByTime(delay);
433 | await flushPromises();
434 | });
435 |
436 | await waitFor(() => expect(result.current).toBe('foo'));
437 | });
438 | });
439 |
--------------------------------------------------------------------------------
/src/hooks/useLoader.ts:
--------------------------------------------------------------------------------
1 | import {useCallback, useEffect, useRef, useState} from 'react';
2 | import {Cache, EntryOptions} from './Cache';
3 |
4 | const cache = new Cache(60 * 1000);
5 |
6 | export type CacheOptions = EntryOptions & {
7 | initial?: R,
8 | };
9 |
10 | /**
11 | * @internal
12 | */
13 | export function useLoader({initial, ...currentOptions}: CacheOptions): R {
14 | const optionsRef = useRef(currentOptions);
15 | const [value, setValue] = useState(() => cache.get(currentOptions.cacheKey)?.result ?? initial);
16 | const mountedRef = useRef(true);
17 |
18 | const load = useCallback(
19 | (options: EntryOptions) => {
20 | try {
21 | setValue(cache.load(options));
22 | } catch (result: unknown) {
23 | if (result instanceof Promise) {
24 | result.then((resolvedValue: R) => {
25 | if (mountedRef.current) {
26 | setValue(resolvedValue);
27 | }
28 | });
29 |
30 | return;
31 | }
32 |
33 | setValue(undefined);
34 | }
35 | },
36 | [],
37 | );
38 |
39 | useEffect(
40 | () => {
41 | mountedRef.current = true;
42 |
43 | if (initial !== undefined) {
44 | load(currentOptions);
45 | }
46 |
47 | return () => {
48 | mountedRef.current = false;
49 | };
50 | },
51 | // eslint-disable-next-line react-hooks/exhaustive-deps -- Should run only once
52 | [],
53 | );
54 |
55 | useEffect(
56 | () => {
57 | if (optionsRef.current.cacheKey !== currentOptions.cacheKey) {
58 | setValue(initial);
59 | optionsRef.current = currentOptions;
60 |
61 | if (initial !== undefined) {
62 | load(currentOptions);
63 | }
64 | }
65 | },
66 | [currentOptions, initial, load],
67 | );
68 |
69 | if (value === undefined) {
70 | return cache.load(currentOptions);
71 | }
72 |
73 | return value;
74 | }
75 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from '@croct/plug/sdk/json';
2 | export * from '@croct/plug/slot';
3 | export * from '@croct/plug/component';
4 | export * from './CroctProvider';
5 | export * from './hooks';
6 | export * from './components';
7 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/ssr-polyfills.ssr.test.ts:
--------------------------------------------------------------------------------
1 | /*
2 | * @jest-environment node
3 | */
4 | import croct from '@croct/plug';
5 | import {croct as croctPolyfill, isSsr} from './ssr-polyfills';
6 |
7 | jest.mock(
8 | '@croct/plug',
9 | () => ({
10 | plug: jest.fn(),
11 | unplug: jest.fn(),
12 | }),
13 | );
14 |
15 | describe('Croct polyfill (SSR)', () => {
16 | it('should not plug', () => {
17 | croctPolyfill.plug({appId: '00000000-0000-0000-0000-000000000000'});
18 |
19 | expect(croct.plug).not.toHaveBeenCalled();
20 | });
21 |
22 | it('should not unplug', async () => {
23 | await expect(croctPolyfill.unplug()).resolves.toBeUndefined();
24 |
25 | expect(croct.unplug).not.toHaveBeenCalled();
26 | });
27 |
28 | it('should not initialize', () => {
29 | expect(croctPolyfill.initialized).toBe(false);
30 |
31 | croctPolyfill.plug({appId: '00000000-0000-0000-0000-000000000000'});
32 |
33 | expect(croctPolyfill.initialized).toBe(false);
34 | });
35 |
36 | it('should not allow accessing properties other than plug or unplug', () => {
37 | expect(() => croctPolyfill.user)
38 | .toThrow('Property croct.user is not supported on server-side (SSR).');
39 | });
40 | });
41 |
42 | describe('isSsr', () => {
43 | it('should always return true', () => {
44 | expect(isSsr()).toBe(true);
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/ssr-polyfills.test.ts:
--------------------------------------------------------------------------------
1 | import croct, {Configuration} from '@croct/plug';
2 | import {croct as croctPolyfill, isSsr} from './ssr-polyfills';
3 | import spyOn = jest.spyOn;
4 |
5 | describe('Croct polyfill (CSR)', () => {
6 | beforeAll(() => {
7 | jest.clearAllMocks();
8 | });
9 |
10 | const config: Configuration = {appId: '00000000-0000-0000-0000-000000000000'};
11 |
12 | it('should delay unplugging to avoid reconnections', async () => {
13 | jest.useFakeTimers();
14 |
15 | const unplug = spyOn(croct, 'unplug');
16 |
17 | const plug = spyOn(croct, 'plug');
18 |
19 | croctPolyfill.plug(config);
20 |
21 | expect(plug).toHaveBeenCalledTimes(1);
22 |
23 | // First attempt: cancelling
24 |
25 | const firstAttempt = croctPolyfill.unplug();
26 |
27 | expect(unplug).not.toHaveBeenCalled();
28 |
29 | croctPolyfill.plug(config);
30 |
31 | jest.runOnlyPendingTimers();
32 |
33 | await expect(firstAttempt).rejects.toThrow('Unplug cancelled.');
34 |
35 | expect(unplug).not.toHaveBeenCalled();
36 |
37 | // Second attempt: failing
38 |
39 | unplug.mockRejectedValueOnce(new Error('Unplug failed.'));
40 |
41 | const secondAttempt = croct.unplug();
42 |
43 | jest.runOnlyPendingTimers();
44 |
45 | await expect(secondAttempt).rejects.toThrow('Unplug failed.');
46 |
47 | // Third attempt: succeeding
48 |
49 | unplug.mockResolvedValueOnce();
50 |
51 | const thirdAttempt = croct.unplug();
52 |
53 | jest.runOnlyPendingTimers();
54 |
55 | await expect(thirdAttempt).resolves.toBeUndefined();
56 |
57 | expect(unplug).toHaveBeenCalledTimes(2);
58 | });
59 | });
60 |
61 | describe('isSsr', () => {
62 | it('should always return false', () => {
63 | expect(isSsr()).toBe(false);
64 | });
65 | });
66 |
--------------------------------------------------------------------------------
/src/ssr-polyfills.ts:
--------------------------------------------------------------------------------
1 | import type {Plug} from '@croct/plug';
2 | import {GlobalPlug} from '@croct/plug/plug';
3 |
4 | /**
5 | * @internal
6 | */
7 | export function isSsr(): boolean {
8 | return globalThis.window?.document?.createElement === undefined;
9 | }
10 |
11 | /**
12 | * @internal
13 | */
14 | export const croct: Plug = !isSsr()
15 | ? (function factory(): Plug {
16 | let timeoutId: ReturnType|null = null;
17 | let resolveCallback: () => void;
18 | let rejectCallback: (reason: any) => void;
19 |
20 | return new Proxy(GlobalPlug.GLOBAL, {
21 | get: function getProperty(target, property: keyof Plug): any {
22 | switch (property) {
23 | case 'plug':
24 | if (timeoutId !== null) {
25 | clearTimeout(timeoutId);
26 | timeoutId = null;
27 | rejectCallback?.(new Error('Unplug cancelled.'));
28 | }
29 |
30 | break;
31 |
32 | case 'unplug':
33 | return () => {
34 | // Delay unplugging to avoid reconnections between remounts (e.g. strict mode).
35 | // It can be problematic when aiming to replug the SDK with a different configuration.
36 | // However, since it is an unusual use case and there is a log message to warn about
37 | // the plugin being already plugged, the trade-off is worth it.
38 | timeoutId = setTimeout(() => target.unplug().then(resolveCallback, rejectCallback), 100);
39 |
40 | return new Promise((resolve, reject) => {
41 | resolveCallback = resolve;
42 | rejectCallback = reject;
43 | });
44 | };
45 | }
46 |
47 | return target[property];
48 | },
49 | });
50 | }())
51 | : new Proxy(GlobalPlug.GLOBAL, {
52 | get: function getProperty(_, property: keyof Plug): any {
53 | switch (property) {
54 | case 'initialized':
55 | return false;
56 |
57 | case 'plug':
58 | return () => {
59 | // no-op
60 | };
61 |
62 | case 'unplug':
63 | return () => Promise.resolve();
64 |
65 | default:
66 | throw new Error(
67 | `Property croct.${String(property)} is not supported on server-side (SSR). `
68 | + 'Consider refactoring the logic as a side-effect (useEffect) or a client-side callback '
69 | + '(onClick, onChange, etc). '
70 | + 'For help, see https://croct.help/sdk/react/client-logic-ssr',
71 | );
72 | }
73 | },
74 | });
75 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "ES2020",
4 | "target": "ES2020",
5 | "outDir": "build",
6 | "lib": ["dom"],
7 | "types": [
8 | "jest",
9 | "node"
10 | ],
11 | "moduleResolution": "node",
12 | "jsx": "react-jsx",
13 | "declaration": true,
14 | "esModuleInterop": true,
15 | "noImplicitReturns": true,
16 | "noImplicitThis": true,
17 | "noImplicitAny": true,
18 | "strictNullChecks": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "allowSyntheticDefaultImports": true,
22 | "allowJs": true,
23 | "skipLibCheck": true,
24 | "strict": true,
25 | "forceConsistentCasingInFileNames": true,
26 | "noFallthroughCasesInSwitch": true,
27 | "resolveJsonModule": true,
28 | "isolatedModules": true,
29 | "stripInternal": true,
30 | },
31 | "include": [
32 | "src/**/*.ts",
33 | "src/**/*.tsx"
34 | ],
35 | "exclude": [
36 | "node_modules",
37 | "dist"
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import {defineConfig} from 'tsup';
2 | import {fixImportsPlugin} from 'esbuild-fix-imports-plugin';
3 |
4 | export default defineConfig({
5 | esbuildPlugins: [fixImportsPlugin()],
6 | entry: ['src/**/*.ts', 'src/**/*.tsx', '!src/**/*.test.ts', '!src/**/*.test.tsx'],
7 | dts: true,
8 | clean: true,
9 | sourcemap: false,
10 | outDir: 'build',
11 | splitting: false,
12 | bundle: false,
13 | format: ['cjs', 'esm'],
14 | });
15 |
--------------------------------------------------------------------------------