diff --git a/.github/workflows/e2e.config.ts b/.github/workflows/e2e.config.ts index d637ce6b70c..d7478820402 100644 --- a/.github/workflows/e2e.config.ts +++ b/.github/workflows/e2e.config.ts @@ -118,6 +118,7 @@ const nextSuites: TestConfig[] = [ */ const tanstackSuites: TestConfig[] = [ { file: '_community', framework: 'tanstack-start', optional: false, shards: 1 }, + { file: 'auth', framework: 'tanstack-start', optional: false, shards: 1 }, ] export default createE2EConfig([...nextSuites, ...tanstackSuites]) diff --git a/app-tanstack/app/__root.tsx b/app-tanstack/app/__root.tsx index a0a7c33009c..80b75da678d 100644 --- a/app-tanstack/app/__root.tsx +++ b/app-tanstack/app/__root.tsx @@ -1,8 +1,6 @@ import { withPayloadRoot } from '@payloadcms/tanstack-start/client' import { createRootRoute, HeadContent, Scripts } from '@tanstack/react-router' -import { HydrationMarker } from '../components/HydrationMarker/index.js' - export const Route = createRootRoute({ head: () => ({ links: [ @@ -35,7 +33,6 @@ function MarketingRoot({ children }: { children: React.ReactNode }) { {children} - diff --git a/app-tanstack/app/_payload.tsx b/app-tanstack/app/_payload.tsx index 1d40a5d39a8..0601fca760c 100644 --- a/app-tanstack/app/_payload.tsx +++ b/app-tanstack/app/_payload.tsx @@ -5,7 +5,6 @@ import '@payloadcms/ui/css/app.css' // `(payload)/custom.css` so the shared "custom CSS" e2e passes on both adapters. import './custom.css' -import { HydrationMarker } from '../components/HydrationMarker/index.js' import { getLayoutDataFn, serverFunctionHandler } from './_payload/server.functions.js' const { component: PayloadProviders, loader } = payloadLayoutRoute({ @@ -14,18 +13,6 @@ const { component: PayloadProviders, loader } = payloadLayoutRoute({ }) export const Route = createFileRoute('/_payload')({ - component: PayloadLayout, + component: PayloadProviders, loader, }) - -// `withPayloadRoot` swaps `__root`'s shell (and its ``) for the -// Payload admin document on `/admin` routes, so the marker must be re-mounted here -// for the Playwright hydration-wait wrapper to fire on admin pages. -function PayloadLayout() { - return ( - <> - - - - ) -} diff --git a/app-tanstack/components/HydrationMarker/index.tsx b/app-tanstack/components/HydrationMarker/index.tsx deleted file mode 100644 index fc834528089..00000000000 --- a/app-tanstack/components/HydrationMarker/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useEffect } from 'react' - -/** - * Sets `window.__TANSTACK_HYDRATED__ = true` after the first effect runs, - * which is the earliest point at which React's event handlers are guaranteed - * to be attached. The Playwright `goto` wrapper in `initPageConsoleErrorCatch` - * waits for this flag before returning, which prevents the very common race - * where a Playwright click lands on the SSR'd DOM before React finishes - * hydrating (the click focuses the button but the React `onClick` handler - * never fires). - * - * Production users do not see this marker because the bundle hash differs; - * tests opt-in via the Playwright wrapper and consult the global directly. - */ -export function HydrationMarker() { - useEffect(() => { - ;(window as unknown as { __TANSTACK_HYDRATED__?: boolean }).__TANSTACK_HYDRATED__ = true - }, []) - return null -} diff --git a/test/__helpers/components/HydrationMarker/index.tsx b/test/__helpers/components/HydrationMarker/index.tsx new file mode 100644 index 00000000000..055135e145e --- /dev/null +++ b/test/__helpers/components/HydrationMarker/index.tsx @@ -0,0 +1,40 @@ +'use client' + +import { useRouterState } from '@tanstack/react-router' +import { useEffect } from 'react' + +/** + * Publishes admin-route readiness on `window.__TANSTACK_HYDRATED__`, which the Playwright + * `goto`/`reload` wrapper installed by `initPage` waits for. + * + * Shell hydration is not a sufficient signal. `AdminPage` renders an RSC payload fetched by + * the route loader, so on a full document load the SSR'd view is torn down ~30ms in and + * re-mounted ~300ms later as different DOM nodes. An interaction landing in that window is + * lost outright — the element it targeted no longer exists — and unlike Next.js there is no + * dehydrated boundary for React to replay the event into. + * + * `status === 'idle'` with no in-flight load means loaders resolved and matches committed, + * i.e. the payload is mounted and owned by React. Tracking router state rather than latching + * on first mount also keeps the flag honest across subsequent navigations. + * + * Production users never see this marker; tests opt in via the Playwright wrapper and read + * the global directly. + */ +export function HydrationMarker() { + // `select` must return a primitive. `useRouterState` re-renders on every reference + // change, so returning an object here loops. + const isReady = useRouterState({ + select: (state) => state.status === 'idle' && !state.isLoading && !state.isTransitioning, + }) + + useEffect(() => { + ;(window as unknown as { __TANSTACK_HYDRATED__?: boolean }).__TANSTACK_HYDRATED__ = isReady + }, [isReady]) + + // Rendered rather than set on `window` so it is in the server HTML from the first byte. The + // Playwright wrapper needs to know it is driving the TanStack app *before* any script has + // run, since that is precisely when it has to decide whether to wait. Only the TanStack test + // apps render this, so it also stands in for `PAYLOAD_FRAMEWORK`, which the CLI runner sets + // but editor test runners do not. + return