feat: add admin adapter pattern types and contracts#16575
Draft
r1tsuu wants to merge 227 commits into
Draft
Conversation
Add RouterAdapter, ServerAdapter, ComponentRenderer, and DevReloadStrategy type contracts in packages/payload/src/admin/adapters.ts. These types form the foundation for decoupling the admin panel from Next.js.
…imports - Create RouterAdapter pattern: adapter is a React component that wraps children and populates RouterAdapterContext with framework-specific values - Replace all 41 files importing from next/navigation.js, next/link.js, and next/dist/* with framework-agnostic RouterAdapter equivalents - Replace AppRouterInstance type with RouterAdapterRouter from payload - Replace ReadonlyRequestCookies with CookieStore from payload - Replace LinkProps from next/link with LinkAdapterProps from payload - Remove next from packages/ui peerDependencies - Wire RouterAdapter component into RootProvider - Export RouterAdapterContext from client entrypoint
- Create NextRouterAdapter component that calls Next.js hooks (useRouter, usePathname, useSearchParams, useParams) and populates the framework-agnostic RouterAdapterContext - Wire NextRouterAdapter into RootLayout as the RouterAdapter prop - Export NextRouterAdapter from @payloadcms/next/client
Move pure routing utilities from packages/next/src/views/Root/ to packages/ui/src/utilities/routeResolution/: - isPathMatchingRoute, getDocumentViewInfo, attachViewActions - getCustomViewByKey, getCustomViewByRoute - Shared ViewFromConfig type Original files in packages/next re-export from @payloadcms/ui for backward compatibility. getRouteData.ts updated to import from shared.
Move framework-agnostic presentational components from packages/next: - MinimalTemplate (template + styles) → packages/ui/src/templates/Minimal/ - FormHeader (element + styles) → packages/ui/src/elements/FormHeader/ Original locations in packages/next now re-export for backward compat.
Create serverFunctionRegistry in packages/ui with framework-agnostic handlers (form-state, table-state, copy-data-from-locale, etc.). packages/next handleServerFunctions now spreads the shared registry and adds RSC-specific handlers (render-document, render-list, etc.).
Create a client-only component renderer that treats all components as client components and never passes serverProps. This is the alternative to RenderServerComponent for frameworks without RSC support.
Add candidateDirectories parameter to resolveImportMapFilePath, allowing framework adapters to specify their own directory patterns instead of defaulting to Next.js app/(payload) convention. The default behavior is unchanged for backward compatibility.
Remove import of Metadata from 'next' in packages/payload config types. Define AdminMeta type that covers the commonly-used metadata subset (title, description, openGraph, icons, twitter, keywords). MetaConfig now intersects with AdminMeta instead of Next.js Metadata. The Next.js adapter can map AdminMeta to Next.js Metadata as needed.
Replace @next/env dependency with dotenv + dotenv-expand for framework-agnostic .env file loading. The new implementation supports the same file priority convention (.env.local, .env.development, etc.) without requiring Next.js packages.
Replace hardcoded Next.js webpack-hmr WebSocket with a DevReloadStrategy interface. getPayload() now accepts an optional devReloadStrategy parameter. The default fallback preserves the current Next.js HMR behavior. Framework adapters can provide their own strategy (e.g., Vite HMR for TanStack Start).
Replace ReadonlyRequestCookies from next/dist with CookieStore from the framework adapter contract in getRequestLanguage.ts. packages/payload now has zero imports from next/ or @next/.
Introduce PAYLOAD_FRAMEWORK env variable to control which framework adapter the dev server starts with. Extract Next.js-specific startup into test/adapters/nextDevServer.ts. The dev.ts script dispatches to the appropriate adapter based on PAYLOAD_FRAMEWORK (defaults to 'next'). This enables future adapters (e.g., tanstack-start) to add their own dev server module and be selected via PAYLOAD_FRAMEWORK=tanstack-start.
Thread a `renderComponent: ComponentRenderer` parameter through the entire form state and table state pipelines instead of hardcoding `RenderServerComponent` imports. Files modified: - renderField.tsx: accepts renderComponent param instead of importing directly - buildColumnState/index.tsx, renderCell.tsx: accept renderComponent param - renderTable.tsx, renderFilters: accept renderComponent param - buildFormState.ts, buildTableState.ts: pass RenderServerComponent as default - iterateFields.ts, addFieldStatePromise.ts: thread renderComponent through - fieldSchemasToFormState/index.tsx: accept and forward renderComponent - renderFieldServerFn.ts: pass RenderServerComponent explicitly - richtext-lexical rscEntry.tsx, buildInitialState.ts: thread renderComponent Non-RSC adapters can now pass RenderClientComponent instead.
Move framework-agnostic Nav, DocumentHeader, and Logo elements from packages/next to packages/ui. Replace next/navigation hooks with RouterAdapter hooks. Replace @payloadcms/ui barrel imports with direct source imports. Leave re-exports in packages/next for backward compatibility.
Move the Default template (Wrapper, NavHamburger) from packages/next to packages/ui. Replace @payloadcms/ui barrel imports with direct source imports. Leave re-exports in packages/next.
Move the following view helpers from packages/next to packages/ui: - Version/RenderFieldsToDiff (entire directory, 22+ files) - Version/fetchVersions.ts, VersionPillLabel/ - Versions/buildColumns.tsx, cells/, types.ts - Dashboard/ (entire tree, 18+ files) - Document/ helpers (getDocumentData, getDocumentPermissions, etc.) - List/ helpers (handleGroupBy, renderListViewSlots, etc.) All @payloadcms/ui imports converted to relative paths. Re-exports left in packages/next for backward compatibility.
Remove outdated TODO comments in PerPage and Autosave components that referenced next/navigation abstraction - these components already use RouterAdapter or don't need navigation hooks at all.
Move the following auth-related view components to packages/ui: - Login/LoginForm, Login/LoginField, Login styles - ForgotPassword (full view + ForgotPasswordForm) - ResetPassword (full view + ResetPasswordForm) - CreateFirstUser (full view + client component) - Verify (full view + client component) - Logout (full view + LogoutClient) - Unauthorized (full view) All next/navigation imports switched to RouterAdapter. All @payloadcms/ui barrel imports converted to relative paths. Re-exports left in packages/next for backward compatibility. Login entry point stays in packages/next (uses redirect()).
Move APIView, APIViewClient, RenderJSON, LocaleSelector and styles from packages/next to packages/ui. Switch useSearchParams from next/navigation to RouterAdapter. Convert @payloadcms/ui barrel imports to direct relative paths. Re-exports left in packages/next.
Move AccountClient, Settings, LanguageSelector, ToggleTheme, and ResetPreferences from packages/next to packages/ui. Account entry point stays in packages/next (uses notFound()). All @payloadcms/ui barrel imports converted to relative paths.
Move DefaultVersionView, Restore, SelectComparison, SelectLocales, VersionDrawer, VersionDrawerCreatedAtCell, SelectedLocalesContext, SetStepNav, and VersionsViewClient to packages/ui. All next/navigation imports switched to RouterAdapter. All @payloadcms/ui barrel imports converted to relative paths. Re-exports created in packages/next for backward compatibility. Also fixes missing B3 re-exports for VersionPillLabel, Versions buildColumns/cells, and RenderFieldsToDiff.
Move NotFoundClient and styles to packages/ui. The NotFoundPage entry point stays in packages/next (uses initReq, Metadata). Re-export created in packages/next for backward compatibility.
Replace all remaining real code files in packages/next that were already copied to packages/ui with thin re-exports: - Dashboard/Default/ and ModularDashboard/ sub-components - RenderFieldsToDiff internal field components and utilities - Remove .spec.ts test files (tests live with source in ui) - Remove orphaned SCSS files - Add corresponding package.json exports in packages/ui
Remove 9 SCSS files left behind in packages/next after their parent components were moved to packages/ui in earlier phases. The .tsx re-exports don't reference these styles (styles are bundled with the source in packages/ui).
…es eagerly
With `router: { autoCodeSplitting: true }` TanStack Router fetches each
route's `component` lazily via `?tsr-split=component` after the initial
SSR HTML is streamed. Until that lazy chunk lands the rendered admin
tree has no client React attached, so any button click (e.g. the
`#toggle-list-filters` chevron in `<ListControls />`) hits a static DOM,
the click is dropped, and once the chunk finally loads the component
re-mounts with its initial `useState` instead of the toggled value.
That single behaviour was the root cause of every "page renders but
nothing is interactive" tanstack-start E2E failure I traced - the
`#list-controls-where` filter never opened, `[data-lexical-editor]`
never rendered after a navigation, and lock-state didn't propagate to
the lexical editor for the same reason.
Trade-off: a slightly larger initial client bundle, in exchange for
the admin actually being interactive on first paint. That's the
contract every Payload admin component is written against.
…adapter-pattern # Conflicts: # packages/ui/src/elements/Card/index.scss # packages/ui/src/widgets/CollectionCards/index.scss # packages/ui/src/widgets/CollectionCards/index.tsx
Stylesheets were renamed during the merge from origin/main but the client-side variant of CollectionCards was still importing the deleted .scss file, which broke the tanstack-app rolldown build.
Resolved conflicts caused by two main-side changes: 1. SCSS-to-CSS conversion of Default template + ModularDashboard. Our branch had moved both modules from `packages/next` to `packages/ui`, so reapplied the same .scss -> .css swap inside the new home and copied over main's css contents. 2. AppHeader reskin (#16619) trimmed `CustomIcon` from `<AppHeader>`'s Props. Drop the now-unused prop from `DefaultTemplate` so ui builds pass. For the Nav / Default / ModularDashboard tsx conflicts kept our branch's delegation pattern - the next-side wrappers re-export the rebuilt UI versions from `@payloadcms/ui`, which already pick up main's behaviour through the dependency graph.
Disabling autoCodeSplitting eagerly bundles route components, which pulls user-supplied custom components (e.g. `test/access-control/CustomDashboard.tsx` re-exporting `@payloadcms/next/views`) into the browser bundle. That transitively imports `@payloadcms/ui/assets`, whose `./payload-favicon.svg` default-export crashes at runtime in the browser: Error: The requested module './payload-favicon.svg' does not provide an export named 'default' That single regression failed every tanstack-start E2E (105 jobs, all red), so back the previous setting out while we look for a less invasive fix for the click-not-attached issue.
Our Node ESM loader silenced \`ERR_UNKNOWN_FILE_EXTENSION\` for asset
imports during SSR, but it returned a literally empty module. That made
\`import logo from './payload-favicon.svg'\` blow up immediately with:
SyntaxError: The requested module './payload-favicon.svg'
does not provide an export named 'default'
Which surfaced inside getLayoutDataFn, killed the SSR tree, and
re-exploded in the browser console - that single regression was the
root cause of every \"Browser console error: ... payload-favicon.svg\"
tanstack-start E2E failure (60+ specs across access-control, fields,
lexical, locked-documents, etc.).
The stub now exposes the specifier as both default and a Next-style
\`StaticImageData\` shape (\`src\`, \`width\`, \`height\`, blur fields)
so any consumer that does
import logo from './logo.png'
or
import { src } from './photo.jpg'
sees a value SSR can render to HTML without crashing. Stylesheets
still resolve to an empty module (matches Vite's extracted-css
behaviour).
Two complementary fixes for the remaining tanstack-start E2E failures: 1. Disable TanStack Router's autoCodeSplitting again, now that the asset-import regression that originally forced it back on (favicon default-export crash) is fixed by the cssLoader stub. Playwright traces for the JSON / lexical / locked-document tests show the "Download the React DevTools" message landing *after* the click, i.e. React hydrated only once the lazy `?tsr-split=component` chunk arrived, and the click that flipped \`visibleDrawer\` to \`'where'\` landed on a static SSR DOM with no handlers attached. Eager-loading the route component fixes every "page renders but nothing is interactive" symptom (#list-controls-where, [data-lexical-editor], etc.). 2. Add server-only stubs for \`docAccessOperation\` / \`docAccessOperationGlobal\` to \`payload/shared.ts\`. Server-only utilities in \`@payloadcms/ui\` (e.g. \`getDocumentPermissions\`) import them from \`payload\`, the browser condition resolves to \`payload/shared\`, and without those names the dev server crashed the moment a client chunk that transitively pulled \`getDocumentPermissions\` hit the browser. Pulling the real implementation in would drag the whole server graph (operation pipelines, transactions, etc.) into the client bundle, so we expose throw-on-call stubs - if anything ever does invoke them client-side we want a loud, traceable failure rather than a silent \`undefined\`.
…ttingOptions
The previous attempt to disable TanStack Router's `?tsr-split=component` lazy
chunks via `router.autoCodeSplitting: false` was a no-op: `tanstackStart`'s
schema does `tsrConfig = configSchema.omit({ autoCodeSplitting: true, target:
true })`, so the user-supplied flag is silently dropped before it ever reaches
`getConfig`. The TanStack Start vite plugin then unconditionally installs the
router code-splitter regardless, so every route component kept getting fetched
lazily after the initial SSR HTML.
Trace from CI confirms the symptom on `fields/JSON > WhereBuilder`:
116814 ms goto /admin/collections/json-fields
118304 ms click #toggle-list-filters <-- happens here
118785 ms console.info "Download the React DevTools..."
The "React DevTools" banner is React's first client-side log, so the click
landed ~480 ms before React was even alive. After hydration the `?tsr-split=
component` chunk replaces the route's `component` reference, which forces a
remount and resets the `useState` that the click was supposed to flip - the
filter drawer therefore never opens and the test times out on
`#list-controls-where.rah-static--height-auto`.
The only knob the start plugin actually honours is
`router.codeSplittingOptions.defaultBehavior`. Setting it to `[]` makes the
splitter walk each `createFileRoute(...)` file but emit no virtual `?tsr-
split=...` modules, so the route component ships in the initial bundle and
hydration starts on first paint. We keep `autoCodeSplitting: false` as a
belt-and-braces signal in case start-plugin-core ever stops dropping it.
The smoke test is updated to assert the new shape so we don't silently
regress this again.
…test app
The plugin-import-export E2E suite was failing with:
[getLayoutDataFn] Error: Cannot find module
'@payloadcms/plugin-import-export/rsc' imported from
'/home/runner/work/payload/payload/tanstack-app/src/importMap.js'
The suite's `payload.config.ts` registers the plugin, which causes
`generateImportMap` to write `import {...} from '@payloadcms/plugin-
import-export/rsc'` into `tanstack-app/src/importMap.js`. The Node-side
server function then dynamically imports that file, but `@payloadcms/
plugin-import-export` was not declared as a dependency of the
`tanstack-app` workspace package, so pnpm never linked it under
`tanstack-app/node_modules` and Node ESM resolution fell off the cliff.
`plugin-form-builder`, `plugin-multi-tenant`, etc. were already declared
- this was the only one missing from the list. `plugin-nested-docs` /
`plugin-redirects` are not added because their suites pass without them
(their RSC entries aren't referenced by their generated import maps).
… latency Network traces from a fresh CI run show the admin page firing ~150 individual \`node_modules/.pnpm/@TanStack+*/.../dist/esm/*.js?v=...\` requests before the first React hydration tick, e.g. /node_modules/.pnpm/@TanStack+react-router@.../dist/esm/awaited.js /node_modules/.pnpm/@TanStack+react-router@.../dist/esm/useNavigate.js /node_modules/.pnpm/@TanStack+router-core@.../dist/esm/router.js ... (~150 more) Each TanStack package is intentionally distributed as a fine-grained ESM tree so app bundlers can tree-shake aggressively, but in dev mode Vite serves every leaf module as its own request. On a cold ubuntu CI runner this pushes \`React DevTools\` (the first client-side log, emitted from \`hydrateRoot\`) ~1.5-2s past Playwright's first \`click\`, and any synthetic event that lands on the static SSR DOM gets dropped. That single behaviour explains the long tail of "page renders but nothing is interactive" failures (#list-controls-where, #list-menu, [data-lexical-editor], etc.) that the recent \`tsr-split\` removal commit only partially addressed. Listing the package roots in \`optimizeDeps.include\` makes Vite roll each TanStack package into a single pre-bundled chunk in \`node_modules/.vite/deps/\`, so hydration finishes well before the first interactive assertion. The runtime cost on dev startup is a single up-front esbuild pass.
The `installTanStackHydrationGotoWait` patch was causing every `page.goto` in `ensureCompilationIsDone` to wait 15s for a hydration marker that can never appear while the dev server is still compiling. With 15 compilation attempts, this burned up to 225s of the 240s beforeAll hook timeout, causing nearly all TanStack E2E suites to fail with "beforeAll hook timeout exceeded". Add a `__payloadSkipHydrationWait` flag that `ensureCompilationIsDone` sets during its polling loop, so the patched `goto` skips the `waitForFunction` call during warmup and restores normal behaviour once compilation succeeds.
1. Add runtime-throwing stubs to payload/exports/shared.ts for `executeAuthStrategies`, `getAccessResults`, `getPayload`, `getRequestLanguage`, and `docAccessOperationGlobal`. These are server-only functions that get resolved via the "browser" export condition when Vite's client bundler walks through @payloadcms/tanstack-start's entry. Without the stubs, the client module crashes with SyntaxError "does not provide an export named". 2. Add a no-op .catch() to the `busboyFinished` promise in processMultipart.ts. When abortOnLimit rejects `allFilesComplete` (which throws at the await on line ~241) before execution reaches `await busboyFinished`, the busboy error becomes an unhandled rejection that crashes the TanStack Start server process.
Create a dedicated server barrel export for getDocumentPermissions, getDocumentViewData, and getCollectionCardsData. This allows TanStack Start's import protection to block these from the client module graph, eliminating the need for runtime-throwing stubs in payload/exports/shared.ts.
…n custom components - Add handleGraphQL server utility mirroring @payloadcms/next behaviour - Serialize beforeLogin/afterLogin admin components and render them in LoginViewContent - Allow imports from /payload/dist/ and @payloadcms/ui rsc exports through import protection - Skip generated importMap and *.server-function.ts files from TanStack Router routesDirectory - Regenerate importMap and payload-types
The dotenv-based loader from `feat: replace @next/env with dotenv in packages/payload` (ad1632c) was accidentally reverted to `@next/env` during conflict resolution in `59bbd8349c merge issues` when merging main into the branch. Restore the dotenv implementation (matching what PR #16575 ships) and re-add `dotenv` / `dotenv-expand` while removing `@next/env` from the payload package's dependencies.
Contributor
📦 esbuild Bundle Analysis for payloadThis analysis was generated by esbuild-bundle-analyzer. 🤖
Largest pathsThese visualization shows top 20 largest paths in the bundle.Meta file: packages/next/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js
Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js
DetailsNext to the size is how much the size has increased or decreased compared with the base branch of this PR.
|
149a4c4 to
709d8f4
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Introduce framework-agnostic type contracts that decouple the admin panel from Next.js-specific APIs, enabling alternative framework adapters.
Key changes: