From 23433cdd3c56dfdd9515ab2140b4061d96db5cc0 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 5 Aug 2026 14:50:29 -0500 Subject: [PATCH 1/2] Enhance useExperimentVariant and useEditableDotCMSPage hooks to handle undefined data - Updated to accept as a valid input, allowing it to function correctly while waiting for the UVE editor to resolve draft pages. - Added a test case to ensure returns false when data is undefined. - Modified to handle cases where the page response can be undefined or only contain GraphQL data, improving its robustness in draft scenarios. - Updated related components and contexts to reflect the new handling of undefined page assets, ensuring consistent behavior across the application. This change improves the handling of draft pages and enhances the user experience during content editing. --- .../lib/hooks/useExperimentVariant.spec.tsx | 8 ++ .../src/lib/hooks/useExperimentVariant.ts | 12 ++- .../hook/useEditableDotCMSPage.test.tsx | 81 +++++++++++++++++++ .../next/components/Container/Container.tsx | 7 +- .../DotCMSLayoutBody/DotCMSLayoutBody.tsx | 7 +- .../DotCMSLayoutBody/DotCMSPageProvider.tsx | 2 +- .../lib/next/contexts/DotCMSPageContext.tsx | 8 +- .../lib/next/hooks/useEditableDotCMSPage.ts | 36 +++++---- .../libs/sdk/uve/src/lib/core/core.spec.ts | 16 +++- .../libs/sdk/uve/src/lib/core/core.utils.ts | 33 ++++++++ .../libs/sdk/uve/src/lib/editor/public.ts | 7 +- core-web/libs/sdk/uve/src/script/utils.ts | 2 +- examples/nextjs/src/app/[[...slug]]/page.tsx | 13 ++- examples/nextjs/src/app/blog/page.tsx | 18 ++++- .../src/app/blog/post/[[...slug]]/page.tsx | 13 ++- examples/nextjs/src/utils/getDotCMSPage.ts | 14 +++- examples/nextjs/src/utils/pageResponse.ts | 4 +- examples/nextjs/src/views/BlogListingPage.tsx | 8 +- examples/nextjs/src/views/DetailPage.tsx | 4 +- examples/nextjs/src/views/Page.tsx | 2 +- 20 files changed, 250 insertions(+), 45 deletions(-) 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..f492991fc195 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,23 @@ 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 => { + pageResponse: DotCMSComposedPageResponse | Pick | undefined +): DotCMSComposedPageResponse | undefined => { + const pageData = pageResponse && 'pageAsset' in pageResponse ? pageResponse : undefined; + const [updatedPageResponse, setUpdatedPageResponse] = - useState>(pageResponse); + useState | undefined>(pageData); useEffect(() => { if (!getUVEState()) { @@ -108,18 +116,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 +136,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) { )} -
+
Date: Wed, 5 Aug 2026 15:14:07 -0500 Subject: [PATCH 2/2] Fixed format issue --- .../sdk/react/src/lib/next/hooks/useEditableDotCMSPage.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 f492991fc195..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 @@ -107,8 +107,9 @@ export const useEditableDotCMSPage = ( ): DotCMSComposedPageResponse | undefined => { const pageData = pageResponse && 'pageAsset' in pageResponse ? pageResponse : undefined; - const [updatedPageResponse, setUpdatedPageResponse] = - useState | undefined>(pageData); + const [updatedPageResponse, setUpdatedPageResponse] = useState< + DotCMSComposedPageResponse | undefined + >(pageData); useEffect(() => { if (!getUVEState()) {