diff --git a/core-web/libs/sdk/experiments/src/lib/hooks/useExperimentVariant.spec.tsx b/core-web/libs/sdk/experiments/src/lib/hooks/useExperimentVariant.spec.tsx index fb0023f06ef2..ec41e91fb765 100644 --- a/core-web/libs/sdk/experiments/src/lib/hooks/useExperimentVariant.spec.tsx +++ b/core-web/libs/sdk/experiments/src/lib/hooks/useExperimentVariant.spec.tsx @@ -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 } diff --git a/core-web/libs/sdk/experiments/src/lib/hooks/useExperimentVariant.ts b/core-web/libs/sdk/experiments/src/lib/hooks/useExperimentVariant.ts index 2694aa321728..68f4bab8fa0e 100644 --- a/core-web/libs/sdk/experiments/src/lib/hooks/useExperimentVariant.ts +++ b/core-web/libs/sdk/experiments/src/lib/hooks/useExperimentVariant.ts @@ -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(true); diff --git a/core-web/libs/sdk/react/src/lib/next/__test__/hook/useEditableDotCMSPage.test.tsx b/core-web/libs/sdk/react/src/lib/next/__test__/hook/useEditableDotCMSPage.test.tsx index dcc367432b90..9ff706fd69e3 100644 --- a/core-web/libs/sdk/react/src/lib/next/__test__/hook/useEditableDotCMSPage.test.tsx +++ b/core-web/libs/sdk/react/src/lib/next/__test__/hook/useEditableDotCMSPage.test.tsx @@ -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' }); diff --git a/core-web/libs/sdk/react/src/lib/next/components/Container/Container.tsx b/core-web/libs/sdk/react/src/lib/next/components/Container/Container.tsx index e430e0ddba72..e2c1af588a39 100644 --- a/core-web/libs/sdk/react/src/lib/next/components/Container/Container.tsx +++ b/core-web/libs/sdk/react/src/lib/next/components/Container/Container.tsx @@ -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] ); diff --git a/core-web/libs/sdk/react/src/lib/next/components/DotCMSLayoutBody/DotCMSLayoutBody.tsx b/core-web/libs/sdk/react/src/lib/next/components/DotCMSLayoutBody/DotCMSLayoutBody.tsx index 730278df234f..b290ad5859ca 100644 --- a/core-web/libs/sdk/react/src/lib/next/components/DotCMSLayoutBody/DotCMSLayoutBody.tsx +++ b/core-web/libs/sdk/react/src/lib/next/components/DotCMSLayoutBody/DotCMSLayoutBody.tsx @@ -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 | React.ComponentType; diff --git a/core-web/libs/sdk/react/src/lib/next/components/DotCMSLayoutBody/DotCMSPageProvider.tsx b/core-web/libs/sdk/react/src/lib/next/components/DotCMSLayoutBody/DotCMSPageProvider.tsx index ff96fd1a1671..8f3f3277cef1 100644 --- a/core-web/libs/sdk/react/src/lib/next/components/DotCMSLayoutBody/DotCMSPageProvider.tsx +++ b/core-web/libs/sdk/react/src/lib/next/components/DotCMSLayoutBody/DotCMSPageProvider.tsx @@ -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>; mode: DotCMSPageRendererMode; diff --git a/core-web/libs/sdk/react/src/lib/next/contexts/DotCMSPageContext.tsx b/core-web/libs/sdk/react/src/lib/next/contexts/DotCMSPageContext.tsx index 6d3eabfad553..aabd8ba638b9 100644 --- a/core-web/libs/sdk/react/src/lib/next/contexts/DotCMSPageContext.tsx +++ b/core-web/libs/sdk/react/src/lib/next/contexts/DotCMSPageContext.tsx @@ -15,7 +15,11 @@ import { DotCMSBasicContentlet, DotCMSPageAsset, DotCMSPageRendererMode } from ' * @property {Record} [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>; slots?: Record; @@ -27,7 +31,7 @@ export interface DotCMSPageContextProps { * @category Contexts */ export const DotCMSPageContext = createContext({ - pageAsset: {} as DotCMSPageAsset, + pageAsset: undefined, mode: 'production', userComponents: {}, slots: {} diff --git a/core-web/libs/sdk/react/src/lib/next/hooks/useEditableDotCMSPage.ts b/core-web/libs/sdk/react/src/lib/next/hooks/useEditableDotCMSPage.ts index 1d9fcdbf1c04..c61120d655d6 100644 --- a/core-web/libs/sdk/react/src/lib/next/hooks/useEditableDotCMSPage.ts +++ b/core-web/libs/sdk/react/src/lib/next/hooks/useEditableDotCMSPage.ts @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { DotCMSComposedPageResponse, + DotCMSPageResponse, UVEEventType, DotCMSExtendedPageResponse } from '@dotcms/types'; @@ -91,16 +92,24 @@ import { registerStyleEditorSchemas } from '@dotcms/uve/internal'; * * ); * ``` - * @param {DotCMSPageResponse} pageResponse - The initial editable page data from client.page.get(). + * @param {DotCMSPageResponse | Pick} [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 = ( - pageResponse: DotCMSComposedPageResponse -): DotCMSComposedPageResponse => { - const [updatedPageResponse, setUpdatedPageResponse] = - useState>(pageResponse); + pageResponse: DotCMSComposedPageResponse | Pick | undefined +): DotCMSComposedPageResponse | undefined => { + const pageData = pageResponse && 'pageAsset' in pageResponse ? pageResponse : undefined; + + const [updatedPageResponse, setUpdatedPageResponse] = useState< + DotCMSComposedPageResponse | undefined + >(pageData); useEffect(() => { if (!getUVEState()) { @@ -108,18 +117,16 @@ export const useEditableDotCMSPage = ( // 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); @@ -130,14 +137,14 @@ export const useEditableDotCMSPage = ( 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( diff --git a/core-web/libs/sdk/uve/src/lib/core/core.spec.ts b/core-web/libs/sdk/uve/src/lib/core/core.spec.ts index f75fafa85551..514e8560b72e 100644 --- a/core-web/libs/sdk/uve/src/lib/core/core.spec.ts +++ b/core-web/libs/sdk/uve/src/lib/core/core.spec.ts @@ -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(() => { @@ -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); + }); +}); diff --git a/core-web/libs/sdk/uve/src/lib/core/core.utils.ts b/core-web/libs/sdk/uve/src/lib/core/core.utils.ts index a1aad10ad73c..ebd3b18d5655 100644 --- a/core-web/libs/sdk/uve/src/lib/core/core.utils.ts +++ b/core-web/libs/sdk/uve/src/lib/core/core.utils.ts @@ -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 +): boolean { + return Boolean(searchParams['dotCMSHost']); +} + /** * Creates a subscription to a UVE event. * diff --git a/core-web/libs/sdk/uve/src/lib/editor/public.ts b/core-web/libs/sdk/uve/src/lib/editor/public.ts index 24787833f067..7ad2d4833bd5 100644 --- a/core-web/libs/sdk/uve/src/lib/editor/public.ts +++ b/core-web/libs/sdk/uve/src/lib/editor/public.ts @@ -176,6 +176,11 @@ export function createContentlet(contentType: string): void { * - Client ready state * - UVE event subscriptions * + * @param {Partial} [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 * @@ -187,7 +192,7 @@ export function createContentlet(contentType: string): void { * destroyUVESubscriptions(); * ``` */ -export function initUVE(config: DotCMSPageResponse = {} as DotCMSPageResponse): { +export function initUVE(config: Partial = {}): { destroyUVESubscriptions: () => void; } { addClassToEmptyContentlets(); diff --git a/core-web/libs/sdk/uve/src/script/utils.ts b/core-web/libs/sdk/uve/src/script/utils.ts index 00353d57b496..0a2131c40208 100644 --- a/core-web/libs/sdk/uve/src/script/utils.ts +++ b/core-web/libs/sdk/uve/src/script/utils.ts @@ -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): void { sendMessageToUVE({ action: DotCMSUVEAction.CLIENT_READY, payload: config diff --git a/examples/nextjs/src/app/[[...slug]]/page.tsx b/examples/nextjs/src/app/[[...slug]]/page.tsx index 9c74a5a4f4b7..c4034f973f2d 100644 --- a/examples/nextjs/src/app/[[...slug]]/page.tsx +++ b/examples/nextjs/src/app/[[...slug]]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { isRequestFromUVE } from "@dotcms/uve"; import { redirect } from "next/navigation"; import NotFound from "@/app/not-found"; @@ -9,6 +10,7 @@ import { Page } from "@/views/Page"; interface SlugPageProps { params: Promise<{ slug?: string[] }>; + searchParams: Promise>; } function getPath(slug?: string[]) { @@ -28,12 +30,19 @@ export async function generateMetadata({ return { title: getPageTitle(pageContent, "Not Found") }; } -export default async function Home({ params }: SlugPageProps) { +export default async function Home({ params, searchParams }: SlugPageProps) { const { slug } = await params; + const sp = await searchParams; const path = getPath(slug); const pageContent = await getDotCMSPage(path); + const insideUVE = isRequestFromUVE(sp); if (isPageError(pageContent)) { + if (insideUVE) { + const { graphql } = pageContent; + return ; + } + return ; } @@ -44,7 +53,7 @@ export default async function Home({ params }: SlugPageProps) { redirect(vanityUrl.forwardTo); } - if (!pageContent.pageAsset) { + if (!pageContent.pageAsset && !insideUVE) { return ; } diff --git a/examples/nextjs/src/app/blog/page.tsx b/examples/nextjs/src/app/blog/page.tsx index 0a99697dd6bc..782b7d137606 100644 --- a/examples/nextjs/src/app/blog/page.tsx +++ b/examples/nextjs/src/app/blog/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { isRequestFromUVE } from "@dotcms/uve"; import { redirect } from "next/navigation"; import NotFound from "@/app/not-found"; @@ -17,10 +18,21 @@ export async function generateMetadata(): Promise { return { title: `${getPageTitle(pageResponse, "Not Found")} - Blog` }; } -export default async function Home() { +interface BlogPageProps { + searchParams: Promise>; +} + +export default async function Home({ searchParams }: BlogPageProps) { + const sp = await searchParams; const pageResponse = await getDotCMSPage(`/blog`); + const insideUVE = isRequestFromUVE(sp); if (isPageError(pageResponse)) { + if (insideUVE) { + const { graphql } = pageResponse; + return ; + } + return ; } @@ -31,9 +43,9 @@ export default async function Home() { redirect(vanityUrl.forwardTo); } - if (!pageResponse.pageAsset) { + if (!pageResponse.pageAsset && !insideUVE) { return ; } - return ; + return ; } diff --git a/examples/nextjs/src/app/blog/post/[[...slug]]/page.tsx b/examples/nextjs/src/app/blog/post/[[...slug]]/page.tsx index 14baf4e2cf21..fbdf63c57236 100644 --- a/examples/nextjs/src/app/blog/post/[[...slug]]/page.tsx +++ b/examples/nextjs/src/app/blog/post/[[...slug]]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { isRequestFromUVE } from "@dotcms/uve"; import { redirect } from "next/navigation"; import NotFound from "@/app/not-found"; @@ -8,6 +9,7 @@ import { isPageError } from "@/utils/pageResponse"; interface PostPageProps { params: Promise<{ slug?: string[] }>; + searchParams: Promise>; } export async function generateMetadata({ @@ -36,12 +38,19 @@ export async function generateMetadata({ } } -export default async function Home({ params }: PostPageProps) { +export default async function Home({ params, searchParams }: PostPageProps) { const { slug } = await params; + const sp = await searchParams; const path = slug?.[0]; const pageContent = await getDotCMSPage(`/blog/post/${path}`); + const insideUVE = isRequestFromUVE(sp); if (isPageError(pageContent)) { + if (insideUVE) { + const { graphql } = pageContent; + return ; + } + return ; } @@ -52,7 +61,7 @@ export default async function Home({ params }: PostPageProps) { redirect(vanityUrl.forwardTo); } - if (!pageContent.pageAsset) { + if (!pageContent.pageAsset && !insideUVE) { return ; } diff --git a/examples/nextjs/src/utils/getDotCMSPage.ts b/examples/nextjs/src/utils/getDotCMSPage.ts index 6a20bad82bdf..f94fc2adf0b5 100644 --- a/examples/nextjs/src/utils/getDotCMSPage.ts +++ b/examples/nextjs/src/utils/getDotCMSPage.ts @@ -1,5 +1,7 @@ import { cache } from "react"; +import { DotErrorPage } from "@dotcms/types"; + import { dotCMSClient } from "@/lib/dotCMSClient"; import type { PageExtraContent } from "@/types/content"; import { @@ -15,8 +17,11 @@ import { * within a single request (e.g. `generateMetadata` + the page body) share one * network round-trip. * - * On failure it returns `{ error }` so callers can branch without try/catch; - * use the guards in `@/utils/pageResponse` to narrow the result. + * On failure it returns `{ error, graphql }` so callers can branch without try/catch; use the + * guards in `@/utils/pageResponse` to narrow the result. `graphql` is the query dotCMS attempted + * before failing (present whenever the error is a `DotErrorPage`) - inside the UVE editor, passing + * it to `useEditableDotCMSPage` lets the editor retry the fetch with edit-mode permissions and + * deliver a draft/non-live page instead of leaving the request stuck on this failure. */ export const getDotCMSPage = cache(async (path: string) => { try { @@ -31,6 +36,9 @@ export const getDotCMSPage = cache(async (path: string) => { }, }); } catch (error) { - return { error }; + return { + error, + graphql: error instanceof DotErrorPage ? error.graphql : undefined, + }; } }); diff --git a/examples/nextjs/src/utils/pageResponse.ts b/examples/nextjs/src/utils/pageResponse.ts index f6e13e778fdc..056053f501a4 100644 --- a/examples/nextjs/src/utils/pageResponse.ts +++ b/examples/nextjs/src/utils/pageResponse.ts @@ -1,12 +1,12 @@ import { getDotCMSPage } from "@/utils/getDotCMSPage"; -/** Either a composed page response or the `{ error }` shape on failure. */ +/** Either a composed page response or the `{ error, graphql }` shape on failure. */ export type PageResponse = Awaited>; /** Narrows a response to the error branch. */ export function isPageError( pageContent: PageResponse, -): pageContent is { error: unknown } { +): pageContent is Extract { return Boolean(pageContent && "error" in pageContent && pageContent.error); } diff --git a/examples/nextjs/src/views/BlogListingPage.tsx b/examples/nextjs/src/views/BlogListingPage.tsx index 954a1f892edb..edc61efff79c 100644 --- a/examples/nextjs/src/views/BlogListingPage.tsx +++ b/examples/nextjs/src/views/BlogListingPage.tsx @@ -15,8 +15,12 @@ interface SearchBarProps { setSearchQuery: (value: string) => void; } -export function BlogListingPage(pageResponse: Parameters[0]) { - const { content = {} } = useEditableDotCMSPage(pageResponse); +interface BlogListingPageProps { + pageContent: Parameters[0]; +} + +export function BlogListingPage({ pageContent }: BlogListingPageProps) { + const { content = {} } = useEditableDotCMSPage(pageContent) ?? {}; const [searchQuery, setSearchQuery] = useState(''); const [searchResults, setSearchResults] = useState(null); const debouncedSearchQuery = useDebounce(searchQuery, 500); diff --git a/examples/nextjs/src/views/DetailPage.tsx b/examples/nextjs/src/views/DetailPage.tsx index 64cb45e7be21..556ffa524694 100644 --- a/examples/nextjs/src/views/DetailPage.tsx +++ b/examples/nextjs/src/views/DetailPage.tsx @@ -29,7 +29,7 @@ interface ActivityRendererData { } export function DetailPage({ pageContent }: DetailPageProps) { - const { pageAsset, content = {} } = useEditableDotCMSPage(pageContent); + const { pageAsset, content = {} } = useEditableDotCMSPage(pageContent) ?? {}; const urlContentMap = pageAsset?.urlContentMap as | (DotCMSBasicContentlet & { blogContent?: BlockEditorNode }) | undefined; @@ -74,7 +74,7 @@ export function DetailPage({ pageContent }: DetailPageProps) { )} -
+