working solution

This commit is contained in:
Alessio Gravili
2025-09-01 17:50:30 -07:00
parent 300bc55635
commit 70f22da627
9 changed files with 257 additions and 115 deletions

View File

@@ -7,6 +7,9 @@ import React from 'react'
import { buildEditorState } from '../../utilities/buildEditorState.js'
/**
* @experimental - may break in minor releases
*/
export const RichTextComponentClient: React.FC<{
FieldComponent: React.ReactNode
}> = (props) => {

View File

@@ -9,6 +9,7 @@ import {
} from 'payload'
import {
type DefaultTypedEditorState,
lexicalEditor,
type LexicalFieldAdminProps,
type LexicalRichTextAdapter,
@@ -23,13 +24,31 @@ export type RenderLexicalServerFunctionArgs = {
* @example collections.posts.richText
*/
editorTarget: 'default' | ({} & string)
initialValue?: DefaultTypedEditorState
/**
* Name of the field to render
*/
name: string
/**
* Path to the field to render
* @default field name
*/
path?: string
/**
* Schema path to the field to render.
* @default field name
*/
schemaPath?: string
}
export type RenderLexicalServerFunctionReturnType = { Component: React.ReactNode }
/**
* @experimental - may break in minor releases
*/
export const _internal_renderLexical: ServerFunction<
RenderLexicalServerFunctionArgs,
Promise<RenderLexicalServerFunctionReturnType>
> = async ({ admin, editorTarget, importMap, req }) => {
> = async ({ name, admin, editorTarget, importMap, initialValue, path, req, schemaPath }) => {
if (!req.user) {
throw new Error('Unauthorized')
}
@@ -66,7 +85,7 @@ export const _internal_renderLexical: ServerFunction<
}
const field: RichTextField = {
name: 'richText',
name,
type: 'richText',
editor: sanitizedEditor,
}
@@ -83,7 +102,7 @@ export const _internal_renderLexical: ServerFunction<
importMap,
}) as RichTextFieldClient,
clientFieldSchemaMap: new Map<string, RichTextFieldClient>(),
collectionSlug: 'aa',
collectionSlug: '-',
data: {},
field,
fieldSchemaMap: new Map<string, RichTextField>(),
@@ -91,7 +110,7 @@ export const _internal_renderLexical: ServerFunction<
formState: {},
i18n: req.i18n,
operation: 'create',
path: 'richText',
path: path ?? name,
payload: req.payload,
permissions: true,
preferences: {
@@ -99,8 +118,12 @@ export const _internal_renderLexical: ServerFunction<
},
req,
sanitizedEditorConfig: sanitizedEditor.editorConfig,
schemaPath: 'richText',
siblingData: {},
schemaPath: schemaPath ?? name,
siblingData: initialValue
? {
[name]: initialValue,
}
: {},
user: req.user,
} satisfies {
admin: LexicalFieldAdminProps // <= new in 3.26.0

View File

@@ -1,5 +1,5 @@
'use client'
import { useServerFunctions } from '@payloadcms/ui'
import { FieldContext, type FieldType, useServerFunctions } from '@payloadcms/ui'
import React, { useCallback } from 'react'
import type { DefaultTypedEditorState } from '../../nodeTypes.js'
@@ -8,13 +8,11 @@ import type {
RenderLexicalServerFunctionReturnType,
} from './renderLexical.js'
export const useRenderEditor_internal_ = (
args: {
initialState: DefaultTypedEditorState
name: string
} & RenderLexicalServerFunctionArgs,
) => {
const { name, admin, editorTarget, initialState } = args
/**
* @experimental - may break in minor releases
*/
export const useRenderEditor_internal_ = (args: RenderLexicalServerFunctionArgs) => {
const { name, admin, editorTarget, initialValue, path, schemaPath } = args
const [Component, setComponent] = React.useState<null | React.ReactNode>(null)
const { serverFunction } = useServerFunctions()
@@ -23,15 +21,57 @@ export const useRenderEditor_internal_ = (
const { Component } = (await serverFunction({
name: 'render-lexical',
args: {
name,
admin,
editorTarget,
} as RenderLexicalServerFunctionArgs,
initialValue,
path,
schemaPath,
} satisfies RenderLexicalServerFunctionArgs,
})) as RenderLexicalServerFunctionReturnType
setComponent(Component)
}
void render()
}, [editorTarget, serverFunction, admin])
}, [serverFunction, admin, editorTarget, name, path, schemaPath, initialValue])
return { Component, renderLexical }
const WrappedComponent = React.memo(function WrappedComponent({
setValue,
value,
}: /**
* If value or setValue, or both, is provided, this component will manage its own value.
* If neither is passed, it will rely on the parent form to manage the value.
*/
{
setValue?: FieldType<DefaultTypedEditorState | undefined>['setValue']
value?: FieldType<DefaultTypedEditorState | undefined>['value']
}) {
if (!Component) {
return null
}
if (typeof value === 'undefined' && !setValue) {
return Component
}
return (
<FieldContext
value={
{
disabled: false,
formInitializing: false,
formProcessing: false,
formSubmitted: false,
path: path ?? name,
setValue: setValue ?? (() => undefined),
showError: false,
value,
} satisfies FieldType<DefaultTypedEditorState | undefined>
}
>
{Component}
</FieldContext>
)
})
return { Component: WrappedComponent, renderLexical }
}

View File

@@ -240,7 +240,7 @@ export { RowLabelProvider, useRowLabel } from '../../forms/RowLabel/Context/inde
export { FormSubmit } from '../../forms/Submit/index.js'
export { WatchChildErrors } from '../../forms/WatchChildErrors/index.js'
export { useField } from '../../forms/useField/index.js'
export { FieldContext, useField } from '../../forms/useField/index.js'
export type { FieldType, Options } from '../../forms/useField/types.js'
export { withCondition } from '../../forms/withCondition/index.js'

View File

@@ -24,7 +24,7 @@ import {
} from '../Form/context.js'
import { useFieldPath } from '../RenderFields/context.js'
const useFieldImpl = <TValue,>(options?: Options): FieldType<TValue> => {
const useFieldInForm = <TValue,>(options?: Options): FieldType<TValue> => {
const {
disableFormData = false,
hasRows,
@@ -225,55 +225,39 @@ const useFieldImpl = <TValue,>(options?: Options): FieldType<TValue> => {
return result
}
// 1) A context that provides *which hook to use*.
// Default is the expensive impl, but its still a hook and thats fine.
type UseFieldHook = <TValue>(options?: Options) => FieldType<TValue>
const FieldHookStrategyContext = React.createContext<UseFieldHook>(useFieldImpl)
// 2) Your value context for when a field is externally provided.
/**
* Context to allow providing useField value for fields directly, if managed outside the form
*/
export const FieldContext = React.createContext<FieldType<unknown> | undefined>(undefined)
// 3) A provider that overrides the strategy with a tiny hook
// that just returns the precomputed field value from context.
// Note: this hook still follows rules-of-hooks and does not call useFieldImpl.
export function FieldContextProvider({
children,
value,
}: {
children: React.ReactNode
value: FieldType<unknown>
}) {
// This is the cheap hook used when wrapped.
const useFieldFromContext: UseFieldHook = React.useCallback(<TValue,>(_opts?: Options) => {
// Its a hook because it uses useContext; it returns the provided value.
// No fallback here; the strategy is overridden only when we *know*
// we want to bypass the impl entirely.
const ctx = React.use(FieldContext) as FieldType<TValue> | undefined
if (!ctx) {
// Extremely defensive: if someone mounted this provider without value,
// gracefully fall back to useFieldImpl in a *separate* strategy layer.
// But we wont do conditional calling here. Instead, we rely on
// the default higher up if needed. So just return a minimal object.
// In practice, don't mount the provider without value.
throw new Error('FieldContextProvider requires a `value`.')
}
return ctx
}, [])
return (
<FieldContext value={value}>
<FieldHookStrategyContext value={useFieldFromContext}>{children}</FieldHookStrategyContext>
</FieldContext>
)
}
/**
* Get and set the value of a form field.
*
* @see https://payloadcms.com/docs/admin/react-hooks#usefield
*/
export const useField = <TValue,>(options?: Options): FieldType<TValue> => {
const useFieldStrategy = React.use(FieldHookStrategyContext)
// Always invoke the selected hook. Order is stable.
return useFieldStrategy<TValue>(options)
const ctx = React.use(FieldContext) as FieldType<TValue> | undefined
// Lock the mode on first render so hook order is stable forever. This ensures
// that hooks are called in the same order each time a component renders => should
// not break the rule of hooks.
const modeRef = React.useRef<'context' | 'impl' | null>(null)
if (modeRef.current === null) {
modeRef.current = ctx ? 'context' : 'impl'
}
if (modeRef.current === 'context') {
if (!ctx) {
// Provider was removed after mount. That violates hook guarantees.
throw new Error('FieldContext was removed after mount. This breaks hook ordering.')
}
return ctx
}
// We intentionally guard this hook call with a mode that is fixed on first render.
// The order is consistent across renders. Silence the linters false positive.
// eslint-disable-next-line react-compiler/react-compiler
// eslint-disable-next-line react-hooks/rules-of-hooks
return useFieldInForm<TValue>(options)
}