diff --git a/packages/next/src/views/Root/SyncClientConfig.tsx b/packages/next/src/views/Root/SyncClientConfig.tsx deleted file mode 100644 index 46579be35..000000000 --- a/packages/next/src/views/Root/SyncClientConfig.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client' -import type { ClientConfig } from 'payload' - -import { useConfig } from '@payloadcms/ui' -import { useEffect } from 'react' - -/** - * This component is required in order for the _page_ to be able to refresh the client config, - * which may have been cached on the _layout_ level, where the ConfigProvider is managed. - * Since the layout does not re-render on page navigation / authentication, we need to manually - * update the config, as the user may have been authenticated in the process, which affects the client config. - */ -export const SyncClientConfig = ({ clientConfig }: { clientConfig: ClientConfig }) => { - const { setConfig } = useConfig() - - useEffect(() => { - setConfig(clientConfig) - }, [clientConfig, setConfig]) - - return null -} diff --git a/packages/next/src/views/Root/index.tsx b/packages/next/src/views/Root/index.tsx index 4de69cfb0..5b6a9b90e 100644 --- a/packages/next/src/views/Root/index.tsx +++ b/packages/next/src/views/Root/index.tsx @@ -10,6 +10,7 @@ import type { SanitizedGlobalConfig, } from 'payload' +import { RootPageConfigProvider } from '@payloadcms/ui' import { RenderServerComponent } from '@payloadcms/ui/elements/RenderServerComponent' import { getClientConfig } from '@payloadcms/ui/utilities/getClientConfig' import { notFound, redirect } from 'next/navigation.js' @@ -27,7 +28,6 @@ import { isCustomAdminView } from '../../utilities/isCustomAdminView.js' import { isPublicAdminRoute } from '../../utilities/isPublicAdminRoute.js' import { getCustomViewByRoute } from './getCustomViewByRoute.js' import { getRouteData } from './getRouteData.js' -import { SyncClientConfig } from './SyncClientConfig.js' export type GenerateViewMetadata = (args: { config: SanitizedConfig @@ -300,8 +300,7 @@ export const RootPage = async ({ }) return ( - - + {!templateType && {RenderedView}} {templateType === 'minimal' && ( {RenderedView} @@ -332,6 +331,6 @@ export const RootPage = async ({ {RenderedView} )} - + ) } diff --git a/packages/payload/src/config/client.ts b/packages/payload/src/config/client.ts index 09ba5d756..7d410ecdd 100644 --- a/packages/payload/src/config/client.ts +++ b/packages/payload/src/config/client.ts @@ -12,7 +12,6 @@ import type { import { type ClientCollectionConfig, - createClientCollectionConfig, createClientCollectionConfigs, } from '../collections/config/client.js' import { createClientBlocks } from '../fields/config/client.js' @@ -43,16 +42,6 @@ export type ServerOnlyRootProperties = keyof Pick< export type ServerOnlyRootAdminProperties = keyof Pick -export type UnsanitizedClientConfig = { - admin: { - livePreview?: Omit - } & Omit - blocks: ClientBlock[] - collections: ClientCollectionConfig[] - custom?: Record - globals: ClientGlobalConfig[] -} & Omit - export type ClientConfig = { admin: { livePreview?: Omit @@ -62,6 +51,7 @@ export type ClientConfig = { collections: ClientCollectionConfig[] custom?: Record globals: ClientGlobalConfig[] + unauthenticated?: boolean } & Omit export type UnauthenticatedClientConfig = { @@ -73,6 +63,7 @@ export type UnauthenticatedClientConfig = { globals: [] routes: ClientConfig['routes'] serverURL: ClientConfig['serverURL'] + unauthenticated: ClientConfig['unauthenticated'] } export const serverOnlyAdminConfigProperties: readonly Partial[] = [] @@ -132,6 +123,7 @@ export const createUnauthenticatedClientConfig = ({ globals: [], routes: clientConfig.routes, serverURL: clientConfig.serverURL, + unauthenticated: true, } } @@ -189,6 +181,17 @@ export const createClientConfig = ({ importMap, }).filter((block) => typeof block !== 'string') as ClientBlock[] + clientConfig.blocksMap = {} + if (clientConfig.blocks?.length) { + for (const block of clientConfig.blocks) { + if (!block?.slug) { + continue + } + + clientConfig.blocksMap[block.slug] = block as ClientBlock + } + } + break } diff --git a/packages/payload/src/index.ts b/packages/payload/src/index.ts index f45e75c0a..7a95952f8 100644 --- a/packages/payload/src/index.ts +++ b/packages/payload/src/index.ts @@ -1274,7 +1274,6 @@ export { serverOnlyAdminConfigProperties, serverOnlyConfigProperties, type UnauthenticatedClientConfig, - type UnsanitizedClientConfig, } from './config/client.js' export { defaults } from './config/defaults.js' diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 243ed854b..ad62e9f3c 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -303,7 +303,7 @@ export { RouteTransitionProvider, useRouteTransition, } from '../../providers/RouteTransition/index.js' -export { ConfigProvider, useConfig } from '../../providers/Config/index.js' +export { ConfigProvider, RootPageConfigProvider, useConfig } from '../../providers/Config/index.js' export { DocumentEventsProvider, useDocumentEvents } from '../../providers/DocumentEvents/index.js' export { DocumentInfoProvider, useDocumentInfo } from '../../providers/DocumentInfo/index.js' export { useDocumentTitle } from '../../providers/DocumentTitle/index.js' diff --git a/packages/ui/src/providers/Config/index.tsx b/packages/ui/src/providers/Config/index.tsx index 4014a0480..20e3bfc8b 100644 --- a/packages/ui/src/providers/Config/index.tsx +++ b/packages/ui/src/providers/Config/index.tsx @@ -6,7 +6,6 @@ import type { ClientGlobalConfig, CollectionSlug, GlobalSlug, - UnsanitizedClientConfig, } from 'payload' import React, { createContext, use, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -40,36 +39,14 @@ export type ClientConfigContext = { const RootConfigContext = createContext(undefined) -function sanitizeClientConfig( - unSanitizedConfig: ClientConfig | UnsanitizedClientConfig, -): ClientConfig { - if (!unSanitizedConfig?.blocks?.length || (unSanitizedConfig as ClientConfig).blocksMap) { - ;(unSanitizedConfig as ClientConfig).blocksMap = {} - return unSanitizedConfig as ClientConfig - } - const sanitizedConfig: ClientConfig = { ...unSanitizedConfig } as ClientConfig - - sanitizedConfig.blocksMap = {} - - for (const block of unSanitizedConfig.blocks) { - sanitizedConfig.blocksMap[block.slug] = block - } - - return sanitizedConfig -} - export const ConfigProvider: React.FC<{ readonly children: React.ReactNode - readonly config: ClientConfig | UnsanitizedClientConfig + readonly config: ClientConfig }> = ({ children, config: configFromProps }) => { - const [config, setConfigFn] = useState(() => sanitizeClientConfig(configFromProps)) + const [config, setConfig] = useState(configFromProps) const isFirstRenderRef = useRef(true) - const setConfig = useCallback((newConfig: ClientConfig | UnsanitizedClientConfig) => { - setConfigFn(sanitizeClientConfig(newConfig)) - }, []) - // Need to update local config state if config from props changes, for HMR. // That way, config changes will be updated in the UI immediately without needing a refresh. useEffect(() => { @@ -112,9 +89,52 @@ export const ConfigProvider: React.FC<{ [collectionsBySlug, globalsBySlug], ) - const value = useMemo(() => ({ config, getEntityConfig, setConfig }), [config, getEntityConfig]) + const value = useMemo( + () => ({ config, getEntityConfig, setConfig }), + [config, getEntityConfig, setConfig], + ) return {children} } export const useConfig = (): ClientConfigContext => use(RootConfigContext) + +/** + * This provider shadows the ConfigProvider on the _page_ level, allowing us to + * update the config when needed, e.g. after authentication. + * The layout ConfigProvider is not updated on page navigation / authentication, + * as the layout does not re-render in those cases. + * + * If the config here has the same reference as the config from the layout, we + * simply reuse the context from the layout to avoid unnecessary re-renders. + */ +export const RootPageConfigProvider: React.FC<{ + readonly children: React.ReactNode + readonly config: ClientConfig +}> = ({ children, config: configFromProps }) => { + const rootLayoutConfig = useConfig() + const { config, getEntityConfig, setConfig } = rootLayoutConfig + + /** + * This useEffect is required in order for the _page_ to be able to refresh the client config, + * which may have been cached on the _layout_ level, where the ConfigProvider is managed. + * Since the layout does not re-render on page navigation / authentication, we need to manually + * update the config, as the user may have been authenticated in the process, which affects the client config. + */ + useEffect(() => { + setConfig(configFromProps) + }, [configFromProps, setConfig]) + + if (config !== configFromProps && config.unauthenticated !== configFromProps.unauthenticated) { + // Between the unauthenticated config becoming authenticated (or the other way around) and the useEffect + // running, the config will be stale. In order to avoid having the wrong config in the context in that + // brief moment, we shadow the context here on the _page_ level and provide the updated config immediately. + return ( + + {children} + + ) + } + + return {children} +} diff --git a/test/auth/e2e.spec.ts b/test/auth/e2e.spec.ts index 53aa87b23..c6217a172 100644 --- a/test/auth/e2e.spec.ts +++ b/test/auth/e2e.spec.ts @@ -52,6 +52,7 @@ describe('Auth', () => { page = await context.newPage() initPageConsoleErrorCatch(page) }) + describe('create first user', () => { beforeAll(async () => { await reInitializeDB({ @@ -386,6 +387,36 @@ describe('Auth', () => { await saveDocAndAssert(page) }) + + test('ensure login page with redirect to users document redirects properly after login, without client error', async () => { + await page.goto(url.admin) + + await page.goto(`${serverURL}/admin/logout`) + + await expect(page.locator('.login')).toBeVisible() + + const users = await payload.find({ + collection: slug, + limit: 1, + }) + const userDocumentRoute = `${serverURL}/admin/collections/users/${users?.docs?.[0]?.id}` + + await page.goto(userDocumentRoute) + + await expect(page.locator('#field-email')).toBeVisible() + await expect(page.locator('#field-password')).toBeVisible() + + await page.locator('.form-submit > button').click() + + // Expect to be redirected to the correct page + await expect + .poll(() => page.url(), { timeout: POLL_TOPASS_TIMEOUT }) + .toBe(userDocumentRoute) + + // Previously, this would crash the page with a "Cannot read properties of undefined (reading 'match')" error + + await expect(page.locator('#field-roles')).toBeVisible() + }) }) }) })