Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,14 @@ describe('useExperimentVariant', () => {
expect(shouldWaitForVariant).toBe(false);
});

it(' if data is undefined (e.g. waiting on the UVE editor to resolve a draft page)', () => {
jest.spyOn(uve, 'getUVEState').mockReturnValue(undefined);

const { result } = renderHook(() => useExperimentVariant(undefined));

expect(result.current.shouldWaitForVariant).toBe(false);
});

it(' if `runningExperimentId` is undefined', () => {
const mockData = {
viewAs: { variantId: EXPERIMENT_DEFAULT_VARIANT_NAME }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,19 @@ import DotExperimentsContext from '../contexts/DotExperimentsContext';
* Similarly, if the assigned variant matches the requested one, it signals not to wait for the variant.
* By default, the hook signals to wait for the variant.
*
* @param {Object} data - An object containing the runningExperimentId and viewAs (containing variantId).
* @param {Object} [data] - An object containing the runningExperimentId and viewAs (containing
* variantId). Can be `undefined` while `useEditableDotCMSPage` is still waiting on the UVE editor
* to resolve a draft/non-live page - treated the same as "no running experiment" (don't wait).
* @returns {Object} An object with a function `shouldWaitForVariant` that, when called, returns `true` if it should wait for the correct variant, `false` otherwise.
*/
export const useExperimentVariant = (data: DotCMSPageAsset): { shouldWaitForVariant: boolean } => {
export const useExperimentVariant = (
data: DotCMSPageAsset | undefined
): { shouldWaitForVariant: boolean } => {
const dotExperimentInstance = useContext(DotExperimentsContext);

const { runningExperimentId, viewAs } = data;
const runningExperimentId = data?.runningExperimentId;

const variantId = viewAs?.variantId;
const variantId = data?.viewAs?.variantId;

// By default, wait for the variant
const [shouldWaitForVariant, setShouldWaitForVariant] = useState<boolean>(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,87 @@ describe('useEditableDotCMSPage', () => {
expect(updateNavigationMock).not.toHaveBeenCalled();
});

test('should still initialize UVE and subscribe to content changes when pageResponse is undefined', () => {
getUVEStateMock.mockReturnValue({ mode: 'EDIT' });

renderHook(() => useEditableDotCMSPage(undefined));

expect(initUVEMock).toHaveBeenCalledWith(undefined);
expect(updateNavigationMock).not.toHaveBeenCalled();
expect(createUVESubscriptionMock).toHaveBeenCalledWith(
UVEEventType.CONTENT_CHANGES,
expect.any(Function)
);
});

test('should update editable page once content arrives via postMessage after mounting with an undefined pageResponse', () => {
getUVEStateMock.mockReturnValue({ mode: 'EDIT' });

let contentChangesCallback: (payload: DotCMSPageResponse) => void;

createUVESubscriptionMock.mockImplementation((eventType, callback) => {
if (eventType === UVEEventType.CONTENT_CHANGES) {
contentChangesCallback = callback;
}

return { unsubscribe: mockUnsubscribe };
});

const { result } = renderHook(() => useEditableDotCMSPage(undefined));

expect(result.current).toBeUndefined();

act(() => {
contentChangesCallback(mockPageResponse);
});

expect(result.current).toEqual(mockPageResponse);
});

test('should forward a `{ graphql }`-only bootstrap (e.g. from a caught DotErrorPage) to initUVE, so the editor can retry the fetch', () => {
getUVEStateMock.mockReturnValue({ mode: 'EDIT' });

const graphql = {
query: 'query { page(url: "/draft-page") { ... } }',
variables: { url: '/draft-page' }
};

const { result } = renderHook(() => useEditableDotCMSPage({ graphql }));

expect(result.current).toBeUndefined();
expect(initUVEMock).toHaveBeenCalledWith({ graphql });
expect(updateNavigationMock).not.toHaveBeenCalled();
});

test('should update editable page once the editor delivers the real content after a `{ graphql }`-only bootstrap was passed in', () => {
getUVEStateMock.mockReturnValue({ mode: 'EDIT' });

let contentChangesCallback: (payload: DotCMSPageResponse) => void;

createUVESubscriptionMock.mockImplementation((eventType, callback) => {
if (eventType === UVEEventType.CONTENT_CHANGES) {
contentChangesCallback = callback;
}

return { unsubscribe: mockUnsubscribe };
});

const graphql = {
query: 'query { page(url: "/draft-page") { ... } }',
variables: { url: '/draft-page' }
};

const { result } = renderHook(() => useEditableDotCMSPage({ graphql }));

expect(result.current).toBeUndefined();

act(() => {
contentChangesCallback(mockPageResponse);
});

expect(result.current).toEqual(mockPageResponse);
});

test('should cleanup subscriptions on unmount', () => {
getUVEStateMock.mockReturnValue({ mode: 'EDIT' });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,15 @@ export function Container({ container }: DotCMSContainerRendererProps) {
const { pageAsset } = useContext(DotCMSPageContext);
const isDevMode = useIsDevMode();

// pageAsset is undefined while useEditableDotCMSPage is still waiting on the UVE editor to
// resolve a draft/non-live page - Container never actually renders in that state (its parent
// DotCMSLayoutBody shows ErrorMessage instead), but the guard keeps this honest either way.
const containerData = useMemo(
() => getContainersData(pageAsset, container),
() => (pageAsset ? getContainersData(pageAsset, container) : null),
[pageAsset, container]
);
const contentlets = useMemo(
() => getContentletsInContainer(pageAsset, container),
() => (pageAsset ? getContentletsInContainer(pageAsset, container) : []),
[pageAsset, container]
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import { Row } from '../Row/Row';
export interface DotCMSLayoutBodyProps<
TContentlet extends DotCMSBasicContentlet = DotCMSBasicContentlet
> {
page: DotCMSPageAsset;
/**
* The DotCMS page asset. Can be `undefined` while `useEditableDotCMSPage` is still waiting
* on the UVE editor to resolve a draft/non-live page — the component renders `ErrorMessage`
* until it arrives.
*/
page: DotCMSPageAsset | undefined;
components: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: React.ComponentType<TContentlet> | React.ComponentType<any>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { DotCMSPageAsset, DotCMSPageRendererMode } from '@dotcms/types';
import { DotCMSPageContext } from '../../contexts/DotCMSPageContext';

interface DotCMSPageProviderProps {
page: DotCMSPageAsset;
page: DotCMSPageAsset | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
components: Record<string, React.ComponentType<any>>;
mode: DotCMSPageRendererMode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import { DotCMSBasicContentlet, DotCMSPageAsset, DotCMSPageRendererMode } from '
* @property {Record<string, ReactNode>} [slots] - Pre-rendered server component nodes keyed by contentlet identifier
*/
export interface DotCMSPageContextProps {
pageAsset: DotCMSPageAsset;
/**
* Can be `undefined` while `useEditableDotCMSPage` is still waiting on the UVE editor to
* resolve a draft/non-live page, or when permissions leave it unset outside the editor.
*/
pageAsset: DotCMSPageAsset | undefined;
mode: DotCMSPageRendererMode;
userComponents: Record<string, React.ComponentType<DotCMSBasicContentlet>>;
slots?: Record<string, ReactNode>;
Expand All @@ -27,7 +31,7 @@ export interface DotCMSPageContextProps {
* @category Contexts
*/
export const DotCMSPageContext = createContext<DotCMSPageContextProps>({
pageAsset: {} as DotCMSPageAsset,
pageAsset: undefined,
mode: 'production',
userComponents: {},
slots: {}
Expand Down
39 changes: 23 additions & 16 deletions core-web/libs/sdk/react/src/lib/next/hooks/useEditableDotCMSPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';

import {
DotCMSComposedPageResponse,
DotCMSPageResponse,
UVEEventType,
DotCMSExtendedPageResponse
} from '@dotcms/types';
Expand Down Expand Up @@ -91,35 +92,41 @@ import { registerStyleEditorSchemas } from '@dotcms/uve/internal';
* </div>
* );
* ```
* @param {DotCMSPageResponse} pageResponse - The initial editable page data from client.page.get().
* @param {DotCMSPageResponse | Pick<DotCMSPageResponse, 'graphql'>} [pageResponse] - The page data
* from client.page.get(). If that call threw (draft/non-live page, permissions), pass
* `{ graphql: error.graphql }` instead — `DotErrorPage` always carries the GraphQL query it
* attempted, even on failure. Forwarding it here lets the editor retry the fetch with edit-mode
* permissions and deliver the real content via `postMessage`; pass `undefined` and there's nothing
* for the editor to retry.
*
* @returns {DotCMSPageResponse} The updated editable page state that reflects any changes made in the UVE.
* The structure includes page data and any GraphQL content that was requested.
*/
export const useEditableDotCMSPage = <T extends DotCMSExtendedPageResponse>(
pageResponse: DotCMSComposedPageResponse<T>
): DotCMSComposedPageResponse<T> => {
const [updatedPageResponse, setUpdatedPageResponse] =
useState<DotCMSComposedPageResponse<T>>(pageResponse);
pageResponse: DotCMSComposedPageResponse<T> | Pick<DotCMSPageResponse, 'graphql'> | undefined
): DotCMSComposedPageResponse<T> | undefined => {
const pageData = pageResponse && 'pageAsset' in pageResponse ? pageResponse : undefined;

const [updatedPageResponse, setUpdatedPageResponse] = useState<
DotCMSComposedPageResponse<T> | undefined
>(pageData);

useEffect(() => {
if (!getUVEState()) {
// Outside UVE, state only ever comes from props - keep it in sync with
// whatever pageResponse the parent re-renders with (e.g. after a client-side
// navigation refetches page data), since the initial useState value above is
// otherwise frozen after the first render.
setUpdatedPageResponse(pageResponse);

return;
}

if (!pageResponse) {
console.warn('[useEditableDotCMSPage]: No DotCMSPageResponse provided');
setUpdatedPageResponse(pageData);

return;
}

const pageURI = pageResponse?.pageAsset?.page?.pageURI;
// Inside UVE, pageResponse can be undefined, or just `{ graphql }` (e.g. a draft/non-live
// page the customer's own fetch treated as a 404, but still knows the GraphQL query it
// attempted) - forward it to initUVE either way so the editor has something to retry with
// edit-mode permissions. Without a query to retry, CONTENT_CHANGES never arrives.
const pageURI = pageData?.pageAsset?.page?.pageURI;

const { destroyUVESubscriptions } = initUVE(pageResponse);

Expand All @@ -130,14 +137,14 @@ export const useEditableDotCMSPage = <T extends DotCMSExtendedPageResponse>(
updateNavigation(pageURI);
}

if (pageResponse.styleEditorSchemas?.length) {
registerStyleEditorSchemas(pageResponse.styleEditorSchemas);
if (pageData?.styleEditorSchemas?.length) {
registerStyleEditorSchemas(pageData.styleEditorSchemas);
}

return () => {
destroyUVESubscriptions();
};
}, [pageResponse]);
}, [pageResponse, pageData]);

useEffect(() => {
const { unsubscribe } = createUVESubscription(
Expand Down
16 changes: 15 additions & 1 deletion core-web/libs/sdk/uve/src/lib/core/core.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from '@jest/gl
import { UVE_MODE, UVEEventType } from '@dotcms/types';
import { __DOTCMS_UVE_EVENT__ } from '@dotcms/types/internal';

import { createUVESubscription, getUVEState } from './core.utils';
import { createUVESubscription, getUVEState, isRequestFromUVE } from './core.utils';

describe('getUVEStatus', () => {
beforeAll(() => {
Expand Down Expand Up @@ -441,3 +441,17 @@ describe('createUVESubscription', () => {
);
});
});

describe('isRequestFromUVE', () => {
it('should return true when dotCMSHost is present', () => {
expect(isRequestFromUVE({ dotCMSHost: 'https://demo.dotcms.com' })).toBe(true);
});

it('should return false when dotCMSHost is absent', () => {
expect(isRequestFromUVE({})).toBe(false);
});

it('should return false when dotCMSHost is an empty string', () => {
expect(isRequestFromUVE({ dotCMSHost: '' })).toBe(false);
});
});
33 changes: 33 additions & 0 deletions core-web/libs/sdk/uve/src/lib/core/core.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,39 @@ export function getUVEState(): UVEState | undefined {
};
}

/**
* Detects whether a request's query parameters came through the UVE iframe - for server-only
* contexts (e.g. a Next.js Server Component fetching data before any client code runs) where
* `getUVEState()` can't be used, since it reads `window.location` and there is no `window` yet.
*
* UVE always appends `dotCMSHost` to the iframed URL, so its presence is the signal checked here.
*
* @remarks
* This is a heuristic based on a single query parameter, not a security boundary - it can be
* forced onto any URL. A forced/spoofed request only ever gets a blank/placeholder response
* instead of a real 404 or error page: the actual page content still only ever arrives through a
* genuine UVE editor `postMessage`, so nothing sensitive leaks. If a wrong HTTP status in that
* forced case matters for your use case (SEO, monitoring), validate the request further
* server-side (e.g. the `Sec-Fetch-Dest: iframe` header) before trusting this.
*
* @param searchParams - The request's query parameters, as a plain object (e.g. a Next.js
* Server Component's `searchParams` prop).
*
* @example
* ```ts
* export default async function Page({ searchParams }) {
* const sp = await searchParams;
* const insideUVE = isRequestFromUVE(sp);
* // ...decide whether to bail to notFound() or render the page shell
* }
* ```
*/
export function isRequestFromUVE(
searchParams: Record<string, string | string[] | undefined>
): boolean {
return Boolean(searchParams['dotCMSHost']);
}

/**
* Creates a subscription to a UVE event.
*
Expand Down
7 changes: 6 additions & 1 deletion core-web/libs/sdk/uve/src/lib/editor/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,11 @@ export function createContentlet(contentType: string): void {
* - Client ready state
* - UVE event subscriptions
*
* @param {Partial<DotCMSPageResponse>} [config] - Sent as-is to the editor as the CLIENT_READY
* payload. Accepts a partial shape (e.g. just `{ graphql }`) for callers that don't yet have a
* full page response — a failed/draft page fetch, for instance — since this only ever forwards
* `config` to the editor and never reads any of its fields itself.
*
* @returns {Object} An object containing the cleanup function
* @returns {Function} destroyUVESubscriptions - Function to clean up all UVE event subscriptions
*
Expand All @@ -187,7 +192,7 @@ export function createContentlet(contentType: string): void {
* destroyUVESubscriptions();
* ```
*/
export function initUVE(config: DotCMSPageResponse = {} as DotCMSPageResponse): {
export function initUVE(config: Partial<DotCMSPageResponse> = {}): {
destroyUVESubscriptions: () => void;
} {
addClassToEmptyContentlets();
Expand Down
2 changes: 1 addition & 1 deletion core-web/libs/sdk/uve/src/script/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export function registerUVEEvents() {
* This is typically called after all UVE event handlers and DOM listeners
* have been set up successfully.
*/
export function setClientIsReady(config?: DotCMSPageResponse): void {
export function setClientIsReady(config?: Partial<DotCMSPageResponse>): void {
sendMessageToUVE({
action: DotCMSUVEAction.CLIENT_READY,
payload: config
Expand Down
Loading
Loading