');
});
it('should allow a renderer that accepts both the initial and fallback values for mapped slots', () => {
const code: CodeOptions = {
code: `
- {(params: {title: string}|boolean|number) => typeof params}
+ {(params: {content: {title: string}|boolean|number}) => typeof params.content}
;
`,
mapping: true,
@@ -344,7 +361,7 @@ describe(' typing', () => {
const code: CodeOptions = {
code: `
- {(params: {title: string}|boolean) => typeof params}
+ {(params: {content: {title: string}|boolean}) => typeof params.content}
;
`,
mapping: true,
@@ -357,7 +374,7 @@ describe(' typing', () => {
const code: CodeOptions = {
code: `
- {(params: {title: string}|number) => typeof params}
+ {(params: {content: {title: string}|number}) => typeof params.content}
;
`,
mapping: true,
diff --git a/src/components/Slot/index.test.tsx b/src/components/Slot/index.test.tsx
index a3bf31d4..97377670 100644
--- a/src/components/Slot/index.test.tsx
+++ b/src/components/Slot/index.test.tsx
@@ -1,6 +1,6 @@
import {render, screen} from '@testing-library/react';
import {Slot, SlotProps} from './index';
-import {useContent} from '../../hooks';
+import {FetchResponse, useContent} from '../../hooks';
import '@testing-library/jest-dom';
jest.mock(
@@ -14,11 +14,18 @@ describe('', () => {
it('should fetch and render a slot', () => {
const {id, children, ...options}: SlotProps<{title: string}> = {
id: 'home-banner',
- children: jest.fn(({title}) => title),
+ children: jest.fn(({content: {title}}) => title),
fallback: {title: 'fallback'},
};
- const result = {title: 'result'};
+ const result = {
+ metadata: {
+ version: '1.0',
+ },
+ content: {
+ title: 'result',
+ },
+ } satisfies FetchResponse;
jest.mocked(useContent).mockReturnValue(result);
@@ -30,6 +37,6 @@ describe('', () => {
expect(useContent).toHaveBeenCalledWith(id, options);
expect(children).toHaveBeenCalledWith(result);
- expect(screen.getByText(result.title)).toBeInTheDocument();
+ expect(screen.getByText(result.content.title)).toBeInTheDocument();
});
});
diff --git a/src/components/Slot/index.tsx b/src/components/Slot/index.tsx
index 06f9cbcc..cdaffa6f 100644
--- a/src/components/Slot/index.tsx
+++ b/src/components/Slot/index.tsx
@@ -1,39 +1,54 @@
'use client';
import {Fragment, ReactElement, ReactNode} from 'react';
-import {SlotContent, VersionedSlotId, VersionedSlotMap} from '@croct/plug/slot';
+import {DynamicSlotId, SlotContent, VersionedSlotId, VersionedSlotMap} from '@croct/plug/slot';
import {JsonObject} from '@croct/plug/sdk/json';
-import {useContent, UseContentOptions} from '../../hooks';
+import {FetchResponseOptions} from '@croct/sdk/contentFetcher';
+import {FetchResponse, useContent, UseContentOptions} from '../../hooks';
type Renderer = (props: P) => ReactNode;
-export type SlotProps
= UseContentOptions & {
+export type SlotProps<
+ P,
+ I = P,
+ F = P,
+ S extends VersionedSlotId = VersionedSlotId,
+ O extends FetchResponseOptions = FetchResponseOptions
+> = O & UseContentOptions & {
id: S,
- children: Renderer
,
+ children: Renderer>,
};
type SlotComponent = {
- (
+
(
props:
Extract
extends never
- ? SlotProps
- : SlotProps
+ ? SlotProps
+ : SlotProps
): ReactElement,
- (props: SlotProps, never, never, S>): ReactElement,
+ (
+ props: SlotProps, never, never, S, O>
+ ): ReactElement,
- (props: SlotProps, I, never, S>): ReactElement,
+ (
+ props: SlotProps, I, never, S, O>
+ ): ReactElement,
- (props: SlotProps, never, F, S>): ReactElement,
+ (
+ props: SlotProps, never, F, S, O>
+ ): ReactElement,
- (props: SlotProps, I, F, S>): ReactElement,
+ (
+ props: SlotProps, I, F, S, O>
+ ): ReactElement,
(props: SlotProps): ReactElement,
};
export const Slot: SlotComponent = (props: SlotProps): ReactElement => {
const {id, children, ...options} = props;
- const data = useContent(id, options);
+ const data = useContent(id, options);
return {children(data)};
};
diff --git a/src/hooks/useContent.d.test.tsx b/src/hooks/useContent.d.test.tsx
index e6d7ee8b..ccb39997 100644
--- a/src/hooks/useContent.d.test.tsx
+++ b/src/hooks/useContent.d.test.tsx
@@ -107,6 +107,23 @@ describe('useContent typing', () => {
return info.name;
}
+ it('should infer whether the schema is requested', () => {
+ const code: CodeOptions = {
+ code: `
+ useContent('home-banner', {schema: true});
+ `,
+ mapping: true,
+ };
+
+ expect(() => compileCode(code)).not.toThrow();
+
+ expect(getTypeName(code)).toBe(
+ 'useContent<"home-banner", {schema: true;}>',
+ );
+
+ expect(getReturnType(code)).toBe('FetchResponse');
+ });
+
it('should define the return type as a JSON object by default for unmapped slots', () => {
const code: CodeOptions = {
code: `
@@ -118,10 +135,10 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
expect(getTypeName(code)).toBe(
- 'useContent',
+ 'useContent',
);
- expect(getReturnType(code)).toBe('JsonObject');
+ expect(getReturnType(code)).toBe('FetchResponse');
});
it('should define the return type as an union of component for unknown slots', () => {
@@ -135,11 +152,12 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
expect(getTypeName(code)).toBe(
- 'useContent',
+ 'useContent',
);
expect(getReturnType(code)).toBe(
- '(Banner & {_component: "banner@1" | null;}) | (Carousel & {...;})',
+ 'FetchResponse<(Banner & {_component: "banner@1" | null;}) | '
+ + '(Carousel & {_component: "carousel@1" | null;}), FetchResponseOptions>',
);
});
@@ -154,10 +172,10 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
expect(getTypeName(code)).toBe(
- 'useContent',
+ 'useContent',
);
- expect(getReturnType(code)).toBe('boolean | JsonObject');
+ expect(getReturnType(code)).toBe('FetchResponse');
});
it('should include the type of the fallback value on the return type for unmapped slots', () => {
@@ -171,10 +189,10 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
expect(getTypeName(code)).toBe(
- 'useContent',
+ 'useContent',
);
- expect(getReturnType(code)).toBe('number | JsonObject');
+ expect(getReturnType(code)).toBe('FetchResponse');
});
it('should include the types of both the initial and fallback values on the return type for unmapped slots', () => {
@@ -188,10 +206,10 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
expect(getTypeName(code)).toBe(
- 'useContent',
+ 'useContent',
);
- expect(getReturnType(code)).toBe('number | ... 1 more ... | JsonObject');
+ expect(getReturnType(code)).toBe('FetchResponse');
});
it('should allow narrowing the return type for unmapped slots', () => {
@@ -205,10 +223,10 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
expect(getTypeName(code)).toBe(
- 'useContent<{foo: string;}, {foo: string;}, {foo: string;}>',
+ 'useContent<{foo: string;}, {foo: string;}, {foo: string;}, FetchResponseOptions>',
);
- expect(getReturnType(code)).toBe('{foo: string;}');
+ expect(getReturnType(code)).toBe('FetchResponse<{foo: string;}, FetchResponseOptions>');
});
it('should allow specifying the initial value type for mapped slots', () => {
@@ -222,10 +240,10 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
expect(getTypeName(code)).toBe(
- 'useContent<{foo: string;}, boolean, {foo: string;}>',
+ 'useContent<{foo: string;}, boolean, {foo: string;}, FetchResponseOptions>',
);
- expect(getReturnType(code)).toBe('boolean | {foo: string;}');
+ expect(getReturnType(code)).toBe('FetchResponse');
});
it('should allow specifying the fallback value type for mapped slots', () => {
@@ -239,10 +257,10 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
expect(getTypeName(code)).toBe(
- 'useContent<{foo: string;}, never, number>',
+ 'useContent<{foo: string;}, never, number, FetchResponseOptions>',
);
- expect(getReturnType(code)).toBe('number | {foo: string;}');
+ expect(getReturnType(code)).toBe('FetchResponse');
});
it('show allow specifying the initial and fallback value types for mapped slots', () => {
@@ -256,10 +274,10 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
expect(getTypeName(code)).toBe(
- 'useContent<{foo: string;}, boolean, number>',
+ 'useContent<{foo: string;}, boolean, number, FetchResponseOptions>',
);
- expect(getReturnType(code)).toBe('number | ... 1 more ... | {foo: string;}');
+ expect(getReturnType(code)).toBe('FetchResponse');
});
it('should require specifying JSON object as return type for mapped slots', () => {
@@ -283,9 +301,9 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
- expect(getTypeName(code)).toBe('useContent<"home-banner">');
+ expect(getTypeName(code)).toBe('useContent<"home-banner", FetchResponseOptions>');
- expect(getReturnType(code)).toBe('HomeBannerV1');
+ expect(getReturnType(code)).toBe('FetchResponse');
});
it('should include the type of the initial value on the return type for mapped slots', () => {
@@ -298,9 +316,9 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
- expect(getTypeName(code)).toBe('useContent');
+ expect(getTypeName(code)).toBe('useContent');
- expect(getReturnType(code)).toBe('boolean | HomeBannerV1');
+ expect(getReturnType(code)).toBe('FetchResponse');
});
it('should include the type of the fallback value on the return type for mapped slots', () => {
@@ -313,9 +331,9 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
- expect(getTypeName(code)).toBe('useContent');
+ expect(getTypeName(code)).toBe('useContent');
- expect(getReturnType(code)).toBe('number | HomeBannerV1');
+ expect(getReturnType(code)).toBe('FetchResponse');
});
it('should include the types of both the initial and fallback values on the return type for mapped slots', () => {
@@ -328,9 +346,9 @@ describe('useContent typing', () => {
expect(() => compileCode(code)).not.toThrow();
- expect(getTypeName(code)).toBe('useContent');
+ expect(getTypeName(code)).toBe('useContent');
- expect(getReturnType(code)).toBe('number | boolean | HomeBannerV1');
+ expect(getReturnType(code)).toBe('FetchResponse');
});
it('should not allow overriding the return type for mapped slots', () => {
diff --git a/src/hooks/useContent.ssr.test.ts b/src/hooks/useContent.ssr.test.ts
index fee2cab0..d6beacad 100644
--- a/src/hooks/useContent.ssr.test.ts
+++ b/src/hooks/useContent.ssr.test.ts
@@ -26,7 +26,9 @@ describe('useContent (SSR)', () => {
it('should render the initial value on the server-side', () => {
const {result} = renderHook(() => useContent('slot-id', {initial: 'foo'}));
- expect(result.current).toBe('foo');
+ expect(result.current).toEqual({
+ content: 'foo',
+ });
});
it('should require an initial value for server-side rending', () => {
@@ -45,7 +47,9 @@ describe('useContent (SSR)', () => {
expect(getSlotContent).toHaveBeenCalledWith(slotId, preferredLocale);
- expect(result.current).toBe(content);
+ expect(result.current).toEqual({
+ content: content,
+ });
});
it('should use the provided initial value on the server-side', () => {
@@ -62,7 +66,9 @@ describe('useContent (SSR)', () => {
}),
);
- expect(result.current).toBe(initial);
+ expect(result.current).toEqual({
+ content: initial,
+ });
});
it('should normalize an empty preferred locale to undefined', () => {
diff --git a/src/hooks/useContent.test.ts b/src/hooks/useContent.test.ts
index 202e9bf0..8770252c 100644
--- a/src/hooks/useContent.test.ts
+++ b/src/hooks/useContent.test.ts
@@ -3,7 +3,7 @@ import {getSlotContent} from '@croct/content';
import {Plug} from '@croct/plug';
import {useCroct} from './useCroct';
import {useLoader} from './useLoader';
-import {useContent} from './useContent';
+import {FetchResponse, useContent} from './useContent';
import {hash} from '../hash';
jest.mock(
@@ -34,14 +34,19 @@ describe('useContent (CSR)', () => {
});
it('should fetch the content', () => {
- const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({
- content: {},
- });
+ const fetch: Plug['fetch'] = jest.fn();
+
+ const response: FetchResponse<{title: string}> = {
+ metadata: {
+ version: '1.0',
+ },
+ content: {
+ title: 'foo',
+ },
+ };
jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
- jest.mocked(useLoader).mockReturnValue({
- title: 'foo',
- });
+ jest.mocked(useLoader).mockReturnValue(response);
const slotId = 'home-banner@1';
const preferredLocale = 'en';
@@ -78,7 +83,7 @@ describe('useContent (CSR)', () => {
attributes: attributes,
});
- expect(result.current).toEqual({title: 'foo'});
+ expect(result.current).toEqual(response);
});
it('should use the initial value when the cache key changes if the stale-while-loading flag is false', async () => {
@@ -86,12 +91,12 @@ describe('useContent (CSR)', () => {
current: 'initial',
};
- const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({content: {}});
+ const fetch: Plug['fetch'] = jest.fn();
jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
jest.mocked(useLoader).mockImplementation(
- () => ({title: key.current === 'initial' ? 'first' : 'second'}),
+ (): FetchResponse => ({content: {title: key.current === 'initial' ? 'first' : 'second'}}),
);
const slotId = 'home-banner@1';
@@ -110,11 +115,17 @@ describe('useContent (CSR)', () => {
expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({
cacheKey: hash(`useContent:${key.current}:${slotId}::${JSON.stringify({})}`),
initial: {
- title: 'initial',
+ content: {
+ title: 'initial',
+ },
},
}));
- await waitFor(() => expect(result.current).toEqual({title: 'first'}));
+ await waitFor(
+ () => expect(result.current).toEqual({
+ content: {title: 'first'},
+ }),
+ );
key.current = 'next';
@@ -123,11 +134,17 @@ describe('useContent (CSR)', () => {
expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({
cacheKey: hash(`useContent:${key.current}:${slotId}::${JSON.stringify({})}`),
initial: {
- title: 'initial',
+ content: {
+ title: 'initial',
+ },
},
}));
- await waitFor(() => expect(result.current).toEqual({title: 'second'}));
+ await waitFor(
+ () => expect(result.current).toEqual({
+ content: {title: 'second'},
+ }),
+ );
});
it('should use the last fetched content as initial value if the stale-while-loading flag is true', async () => {
@@ -135,7 +152,7 @@ describe('useContent (CSR)', () => {
current: 'initial',
};
- const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({content: {}});
+ const fetch: Plug['fetch'] = jest.fn();
jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
@@ -168,7 +185,9 @@ describe('useContent (CSR)', () => {
expect(useLoader).toHaveBeenCalledWith(expect.objectContaining({
cacheKey: hash(`useContent:${key.current}:${slotId}::${JSON.stringify({})}`),
initial: {
- title: 'initial',
+ content: {
+ title: 'initial',
+ },
},
}));
@@ -201,7 +220,9 @@ describe('useContent (CSR)', () => {
expect(useLoader).toHaveBeenCalledWith(
expect.objectContaining({
- initial: content,
+ initial: {
+ content: content,
+ },
}),
);
});
@@ -222,7 +243,9 @@ describe('useContent (CSR)', () => {
expect(useLoader).toHaveBeenCalledWith(
expect.objectContaining({
- initial: initial,
+ initial: {
+ content: initial,
+ },
}),
);
});
@@ -265,9 +288,7 @@ describe('useContent (CSR)', () => {
const slotId = 'slot-id';
const preferredLocale = 'en';
- const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({
- content: {},
- });
+ const fetch: Plug['fetch'] = jest.fn();
jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
@@ -292,9 +313,7 @@ describe('useContent (CSR)', () => {
});
it('should normalize an empty preferred locale to undefined', () => {
- const fetch: Plug['fetch'] = jest.fn().mockResolvedValue({
- content: {},
- });
+ const fetch: Plug['fetch'] = jest.fn();
jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
@@ -311,4 +330,35 @@ describe('useContent (CSR)', () => {
expect(jest.mocked(fetch).mock.calls[0][1]).toStrictEqual({});
});
+
+ it('should return the metadata along with the content', () => {
+ const fetch: Plug['fetch'] = jest.fn();
+
+ const response: FetchResponse<{title: string}> = {
+ metadata: {
+ version: '1.0',
+ },
+ content: {
+ title: 'foo',
+ },
+ };
+
+ jest.mocked(useCroct).mockReturnValue({fetch: fetch} as Plug);
+ jest.mocked(useLoader).mockReturnValue(response);
+
+ const slotId = 'home-banner@1';
+
+ const {result} = renderHook(
+ () => useContent<{title: string}>(slotId),
+ );
+
+ jest.mocked(useLoader)
+ .mock
+ .calls[0][0]
+ .loader();
+
+ expect(fetch).toHaveBeenCalledWith(slotId, {});
+
+ expect(result.current).toEqual(response);
+ });
});
diff --git a/src/hooks/useContent.ts b/src/hooks/useContent.ts
index f261f9e3..9973e1fd 100644
--- a/src/hooks/useContent.ts
+++ b/src/hooks/useContent.ts
@@ -5,11 +5,15 @@ import {JsonObject} from '@croct/plug/sdk/json';
import {FetchOptions} from '@croct/plug/plug';
import {useEffect, useMemo, useState} from 'react';
import {getSlotContent} from '@croct/content';
+import {FetchResponse as BaseFetchResponse, FetchResponseOptions} from '@croct/sdk/contentFetcher';
+import {Optional} from '@croct/sdk/utilityTypes';
import {useLoader} from './useLoader';
import {useCroct} from './useCroct';
import {isSsr} from '../ssr-polyfills';
import {hash} from '../hash';
+export type FetchResponse = Optional, 'metadata'>;
+
export type UseContentOptions = FetchOptions & {
initial?: I,
cacheKey?: string,
@@ -17,10 +21,10 @@ export type UseContentOptions = FetchOptions & {
staleWhileLoading?: boolean,
};
-function useCsrContent(
+function useCsrContent(
id: VersionedSlotId,
options: UseContentOptions = {},
-): SlotContent | I | F {
+): FetchResponse {
const {
cacheKey,
expiration,
@@ -37,24 +41,32 @@ function useCsrContent(
[id, normalizedLocale],
);
const fallback = fallbackContent === undefined ? defaultContent : fallbackContent;
- const [initial, setInitial] = useState(
- () => (initialContent === undefined ? defaultContent : initialContent),
+ const [initial, setInitial] = useState(
+ () => {
+ const content = initialContent === undefined ? defaultContent : initialContent;
+
+ if (content === undefined) {
+ return undefined;
+ }
+
+ return {content: content};
+ },
);
const croct = useCroct();
- const result: SlotContent | I | F = useLoader({
+ const result = useLoader({
cacheKey: hash(
`useContent:${cacheKey ?? ''}`
+ `:${id}`
+ `:${normalizedLocale ?? ''}`
- + `:${JSON.stringify(fetchOptions.attributes ?? {})}`,
+ + `:${JSON.stringify(fetchOptions?.attributes ?? {})}`,
),
- loader: () => croct.fetch(id, {
+ loader: () => croct.fetch(id, {
...fetchOptions,
...(normalizedLocale !== undefined ? {preferredLocale: normalizedLocale} : {}),
...(fallback !== undefined ? {fallback: fallback} : {}),
- }).then(({content}) => content),
+ }),
initial: initial,
expiration: expiration,
});
@@ -77,10 +89,11 @@ function useCsrContent(
return result;
}
-function useSsrContent(
+function useSsrContent(
slotId: VersionedSlotId,
- {initial, preferredLocale}: UseContentOptions = {},
-): SlotContent | I | F {
+ options: UseContentOptions = {},
+): FetchResponse {
+ const {initial, preferredLocale} = options;
const resolvedInitialContent = initial === undefined
? getSlotContent(slotId, normalizePreferredLocale(preferredLocale)) as I|null ?? undefined
: initial;
@@ -92,7 +105,7 @@ function useSsrContent(
);
}
- return resolvedInitialContent;
+ return {content: resolvedInitialContent};
}
function normalizePreferredLocale(preferredLocale: string|undefined): string|undefined {
@@ -100,30 +113,30 @@ function normalizePreferredLocale(preferredLocale: string|undefined): string|und
}
type UseContentHook = {
- (
+
(
id: keyof VersionedSlotMap extends never ? string : never,
- options?: UseContentOptions
- ): P | I | F,
+ options?: O & UseContentOptions
+ ): FetchResponse
,
- (
+ (
id: S,
- options?: UseContentOptions
- ): SlotContent,
+ options?: O & UseContentOptions
+ ): FetchResponse, O>,
- (
+ (
id: S,
- options?: UseContentOptions
- ): SlotContent | I,
+ options?: O & UseContentOptions
+ ): FetchResponse | I, O>,
- (
+ (
id: S,
- options?: UseContentOptions
- ): SlotContent | F,
+ options?: O & UseContentOptions
+ ): FetchResponse | F, O>,
- (
+ (
id: S,
- options?: UseContentOptions
- ): SlotContent | I | F,
+ options?: O & UseContentOptions
+ ): FetchResponse | I | F, O>,
};
export const useContent: UseContentHook = isSsr() ? useSsrContent : useCsrContent;