diff --git a/src/hooks/Cache.test.ts b/src/hooks/Cache.test.ts index 05f03180..4223cf37 100644 --- a/src/hooks/Cache.test.ts +++ b/src/hooks/Cache.test.ts @@ -181,11 +181,10 @@ describe('Cache', () => { promise = result; } - await expect(promise).resolves.toBeUndefined(); + await expect(promise).resolves.toBe('fallback'); expect(cache.load(options)).toEqual('fallback'); - // Should cache the result but not the fallback value expect(cache.load({...options, fallback: 'error'})).toEqual('error'); expect(loader).toHaveBeenCalledTimes(1); diff --git a/src/hooks/Cache.ts b/src/hooks/Cache.ts index ffaa1b00..6f3f72ce 100644 --- a/src/hooks/Cache.ts +++ b/src/hooks/Cache.ts @@ -38,7 +38,9 @@ export class Cache { return fallback; } - throw cachedEntry.error; + if (cachedEntry.result === undefined) { + throw cachedEntry.error; + } } if (cachedEntry.result !== undefined) { @@ -68,7 +70,10 @@ export class Cache { return result; }) .catch(error => { + entry.result = fallback; entry.error = error; + + return fallback; }) .finally(() => { entry.dispose(); diff --git a/src/hooks/useContent.ssr.test.ts b/src/hooks/useContent.ssr.test.ts index c096fe23..01d36177 100644 --- a/src/hooks/useContent.ssr.test.ts +++ b/src/hooks/useContent.ssr.test.ts @@ -1,4 +1,5 @@ import {renderHook} from '@testing-library/react'; +import {getSlotContent} from '@croct/content'; import {useContent} from './useContent'; jest.mock( @@ -9,7 +10,19 @@ jest.mock( }), ); +jest.mock( + '@croct/content', + () => ({ + __esModule: true, + getSlotContent: jest.fn().mockReturnValue(null), + }), +); + describe('useContent (SSR)', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should render the initial value on the server-side', () => { const {result} = renderHook(() => useContent('slot-id', {initial: 'foo'})); @@ -20,4 +33,35 @@ describe('useContent (SSR)', () => { expect(() => useContent('slot-id')) .toThrow('The initial content is required for server-side rendering (SSR).'); }); + + it('should use the default content as initial value on the server-side if not provided', () => { + const content = {foo: 'bar'}; + const slotId = 'slot-id'; + const preferredLocale = 'en'; + + jest.mocked(getSlotContent).mockReturnValue(content); + + const {result} = renderHook(() => useContent(slotId, {preferredLocale: preferredLocale})); + + expect(getSlotContent).toHaveBeenCalledWith(slotId, preferredLocale); + + expect(result.current).toBe(content); + }); + + it('should use the provided initial value on the server-side', () => { + const initial = null; + const slotId = 'slot-id'; + const preferredLocale = 'en'; + + jest.mocked(getSlotContent).mockReturnValue(null); + + const {result} = renderHook( + () => useContent(slotId, { + preferredLocale: preferredLocale, + initial: initial, + }), + ); + + expect(result.current).toBe(initial); + }); }); diff --git a/src/hooks/useContent.test.ts b/src/hooks/useContent.test.ts index 2f1fcc69..02429019 100644 --- a/src/hooks/useContent.test.ts +++ b/src/hooks/useContent.test.ts @@ -1,4 +1,5 @@ import {renderHook, waitFor} from '@testing-library/react'; +import {getSlotContent} from '@croct/content'; import {Plug} from '@croct/plug'; import {useCroct} from './useCroct'; import {useLoader} from './useLoader'; @@ -19,6 +20,14 @@ jest.mock( }), ); +jest.mock( + '@croct/content', + () => ({ + __esModule: true, + getSlotContent: jest.fn().mockReturnValue(null), + }), +); + describe('useContent (CSR)', () => { beforeEach(() => { jest.resetAllMocks(); @@ -54,9 +63,6 @@ describe('useContent (CSR)', () => { expect(useCroct).toHaveBeenCalled(); expect(useLoader).toHaveBeenCalledWith({ cacheKey: hash(`useContent:${cacheKey}:${slotId}:${preferredLocale}:${JSON.stringify(attributes)}`), - fallback: { - title: 'error', - }, expiration: 50, loader: expect.any(Function), }); @@ -67,6 +73,7 @@ describe('useContent (CSR)', () => { .loader(); expect(fetch).toHaveBeenCalledWith(slotId, { + fallback: {title: 'error'}, preferredLocale: 'en', attributes: attributes, }); @@ -180,4 +187,107 @@ describe('useContent (CSR)', () => { await waitFor(() => expect(result.current).toEqual({title: 'second'})); }); + + it('should use the default content as initial value if not provided', () => { + const content = {foo: 'bar'}; + const slotId = 'slot-id'; + const preferredLocale = 'en'; + + jest.mocked(getSlotContent).mockReturnValue(content); + + renderHook(() => useContent(slotId, {preferredLocale: preferredLocale})); + + expect(getSlotContent).toHaveBeenCalledWith(slotId, preferredLocale); + + expect(useLoader).toHaveBeenCalledWith( + expect.objectContaining({ + initial: content, + }), + ); + }); + + it('should use the provided initial value', () => { + const initial = null; + const slotId = 'slot-id'; + const preferredLocale = 'en'; + + jest.mocked(getSlotContent).mockReturnValue(null); + + renderHook( + () => useContent(slotId, { + preferredLocale: preferredLocale, + initial: initial, + }), + ); + + expect(useLoader).toHaveBeenCalledWith( + expect.objectContaining({ + initial: initial, + }), + ); + }); + + it('should use the default content as fallback value if not provided', () => { + const content = {foo: 'bar'}; + const slotId = 'slot-id'; + const preferredLocale = 'en'; + + const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({ + content: {}, + }); + + jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug); + + jest.mocked(getSlotContent).mockReturnValue(content); + + renderHook( + () => useContent(slotId, { + preferredLocale: preferredLocale, + fallback: content, + }), + ); + + expect(getSlotContent).toHaveBeenCalledWith(slotId, preferredLocale); + + jest.mocked(useLoader) + .mock + .calls[0][0] + .loader(); + + expect(fetch).toHaveBeenCalledWith(slotId, { + fallback: content, + preferredLocale: preferredLocale, + }); + }); + + it('should use the provided fallback value', () => { + const fallback = null; + const slotId = 'slot-id'; + const preferredLocale = 'en'; + + const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({ + content: {}, + }); + + jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug); + + jest.mocked(getSlotContent).mockReturnValue(null); + + renderHook( + () => useContent(slotId, { + preferredLocale: preferredLocale, + fallback: fallback, + }), + ); + + jest.mocked(useLoader) + .mock + .calls[0][0] + .loader(); + + expect(fetch).toHaveBeenCalledWith(slotId, { + fallback: fallback, + preferredLocale: preferredLocale, + }); + }); }); diff --git a/src/hooks/useContent.ts b/src/hooks/useContent.ts index 850ce0f7..c49b571f 100644 --- a/src/hooks/useContent.ts +++ b/src/hooks/useContent.ts @@ -1,13 +1,15 @@ import {SlotContent, VersionedSlotId, VersionedSlotMap} from '@croct/plug/slot'; import {JsonObject} from '@croct/plug/sdk/json'; import {FetchOptions} from '@croct/plug/plug'; -import {useEffect, useState} from 'react'; +import {useEffect, useMemo, useState} from 'react'; +import {getSlotContent} from '@croct/content'; import {useLoader} from './useLoader'; import {useCroct} from './useCroct'; import {isSsr} from '../ssr-polyfills'; import {hash} from '../hash'; export type UseContentOptions = FetchOptions & { + fallback?: F, initial?: I, cacheKey?: string, expiration?: number, @@ -19,16 +21,24 @@ function useCsrContent( options: UseContentOptions = {}, ): SlotContent | I | F { const { - fallback, cacheKey, expiration, + fallback: fallbackContent, initial: initialContent, staleWhileLoading = false, ...fetchOptions } = options; - const [initial, setInitial] = useState(initialContent); const {preferredLocale} = fetchOptions; + const defaultContent = useMemo( + () => getSlotContent(id, preferredLocale) as SlotContent|null ?? undefined, + [id, preferredLocale], + ); + const fallback = fallbackContent === undefined ? defaultContent : fallbackContent; + const [initial, setInitial] = useState( + () => (initialContent === undefined ? defaultContent : initialContent), + ); + const croct = useCroct(); const result: SlotContent | I | F = useLoader({ @@ -38,9 +48,8 @@ function useCsrContent( + `:${preferredLocale ?? ''}` + `:${JSON.stringify(fetchOptions.attributes ?? {})}`, ), - loader: () => croct.fetch(id, fetchOptions).then(({content}) => content), + loader: () => croct.fetch(id, {...fetchOptions, fallback: fallback}).then(({content}) => content), initial: initial, - fallback: fallback, expiration: expiration, }); @@ -63,17 +72,21 @@ function useCsrContent( } function useSsrContent( - _: VersionedSlotId, - {initial}: UseContentOptions = {}, + slotId: VersionedSlotId, + {initial, preferredLocale}: UseContentOptions = {}, ): SlotContent | I | F { - if (initial === undefined) { + const resolvedInitialContent = initial === undefined + ? getSlotContent(slotId, preferredLocale) as I|null ?? undefined + : initial; + + if (resolvedInitialContent === undefined) { throw new Error( 'The initial content is required for server-side rendering (SSR). ' + 'For help, see https://croct.help/sdk/react/missing-slot-content', ); } - return initial; + return resolvedInitialContent; } type UseContentHook = {