From 70f22da62765342d1778a9180eed41d8a694246e Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Mon, 1 Sep 2025 17:50:30 -0700 Subject: [PATCH] working solution --- .../RenderLexical/RichTextComponentClient.tsx | 3 + .../src/field/RenderLexical/renderLexical.tsx | 35 ++++- .../field/RenderLexical/useRenderEditor.tsx | 62 +++++++-- packages/ui/src/exports/client/index.ts | 2 +- packages/ui/src/forms/useField/index.tsx | 72 ++++------- .../lexical/collections/OnDemand/OnDemand.tsx | 61 ++++++++- .../collections/OnDemand/OnDemand2.tsx | 12 +- test/lexical/collections/OnDemand/index.ts | 4 +- test/lexical/payload-types.ts | 121 ++++++++++++------ 9 files changed, 257 insertions(+), 115 deletions(-) diff --git a/packages/richtext-lexical/src/field/RenderLexical/RichTextComponentClient.tsx b/packages/richtext-lexical/src/field/RenderLexical/RichTextComponentClient.tsx index 2449bca44b..7657494e42 100644 --- a/packages/richtext-lexical/src/field/RenderLexical/RichTextComponentClient.tsx +++ b/packages/richtext-lexical/src/field/RenderLexical/RichTextComponentClient.tsx @@ -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) => { diff --git a/packages/richtext-lexical/src/field/RenderLexical/renderLexical.tsx b/packages/richtext-lexical/src/field/RenderLexical/renderLexical.tsx index b503c50308..7fcb8e1f40 100644 --- a/packages/richtext-lexical/src/field/RenderLexical/renderLexical.tsx +++ b/packages/richtext-lexical/src/field/RenderLexical/renderLexical.tsx @@ -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 -> = 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(), - collectionSlug: 'aa', + collectionSlug: '-', data: {}, field, fieldSchemaMap: new Map(), @@ -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 diff --git a/packages/richtext-lexical/src/field/RenderLexical/useRenderEditor.tsx b/packages/richtext-lexical/src/field/RenderLexical/useRenderEditor.tsx index afbf2dd249..303205f1b8 100644 --- a/packages/richtext-lexical/src/field/RenderLexical/useRenderEditor.tsx +++ b/packages/richtext-lexical/src/field/RenderLexical/useRenderEditor.tsx @@ -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) 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['setValue'] + + value?: FieldType['value'] + }) { + if (!Component) { + return null + } + if (typeof value === 'undefined' && !setValue) { + return Component + } + return ( + undefined), + showError: false, + value, + } satisfies FieldType + } + > + {Component} + + ) + }) + + return { Component: WrappedComponent, renderLexical } } diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 243ed854b3..f051cba016 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -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' diff --git a/packages/ui/src/forms/useField/index.tsx b/packages/ui/src/forms/useField/index.tsx index b4602ea266..f5e60f211b 100644 --- a/packages/ui/src/forms/useField/index.tsx +++ b/packages/ui/src/forms/useField/index.tsx @@ -24,7 +24,7 @@ import { } from '../Form/context.js' import { useFieldPath } from '../RenderFields/context.js' -const useFieldImpl = (options?: Options): FieldType => { +const useFieldInForm = (options?: Options): FieldType => { const { disableFormData = false, hasRows, @@ -225,55 +225,39 @@ const useFieldImpl = (options?: Options): FieldType => { return result } -// 1) A context that provides *which hook to use*. -// Default is the expensive impl, but it’s still a hook and that’s fine. -type UseFieldHook = (options?: Options) => FieldType -const FieldHookStrategyContext = React.createContext(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 | 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 -}) { - // This is the cheap hook used when wrapped. - const useFieldFromContext: UseFieldHook = React.useCallback((_opts?: Options) => { - // It’s 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 | undefined - if (!ctx) { - // Extremely defensive: if someone mounted this provider without value, - // gracefully fall back to useFieldImpl in a *separate* strategy layer. - // But we won’t 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 ( - - {children} - - ) -} - /** * Get and set the value of a form field. * * @see https://payloadcms.com/docs/admin/react-hooks#usefield */ export const useField = (options?: Options): FieldType => { - const useFieldStrategy = React.use(FieldHookStrategyContext) - // Always invoke the selected hook. Order is stable. - return useFieldStrategy(options) + const ctx = React.use(FieldContext) as FieldType | 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 linter’s false positive. + + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/rules-of-hooks + return useFieldInForm(options) } diff --git a/test/lexical/collections/OnDemand/OnDemand.tsx b/test/lexical/collections/OnDemand/OnDemand.tsx index 318747dafa..90fcc57e86 100644 --- a/test/lexical/collections/OnDemand/OnDemand.tsx +++ b/test/lexical/collections/OnDemand/OnDemand.tsx @@ -1,16 +1,19 @@ 'use client' -import { useRenderEditor_internal_ } from '@payloadcms/richtext-lexical/client' -import { useEffect, useRef } from 'react' +import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical' +import type { JSONFieldClientComponent } from 'payload' -export const OnDemand: React.FC = () => { +import { buildEditorState, useRenderEditor_internal_ } from '@payloadcms/richtext-lexical/client' +import { use, useCallback, useEffect, useRef, useState } from 'react' + +export const OnDemand: JSONFieldClientComponent = (args) => { const { Component, renderLexical } = useRenderEditor_internal_({ name: 'richText', editorTarget: 'default', - initialState: {} as any, }) - const mounted = useRef(false) + // mount the lexical runtime once + const mounted = useRef(false) useEffect(() => { if (mounted.current) { return @@ -18,5 +21,51 @@ export const OnDemand: React.FC = () => { void renderLexical() mounted.current = true }, [renderLexical]) - return
Default Component: {Component ? Component : 'Loading...'}
+ + // build the initial editor state once, with lazy init (no ref reads in render) + const [initialValue] = useState(() => + buildEditorState({ text: 'state default' }), + ) + + // keep latest content in a ref so updates don’t trigger React renders + const latestValueRef = useRef(initialValue) + + // stable setter given to the editor; updates ref only + const setValueStable = useCallback((next: DefaultTypedEditorState | undefined) => { + // absolutely no state set here; no React re-render, no remount + latestValueRef.current = next + // if you later get access to the editor instance, this is where you'd imperatively sync it + }, []) + + // If you need a "reset to default," and the editor doesn't expose an imperative API, + // the only reliable way is a key bump to force a remount ON RESET ONLY. + // This does not affect normal setValue cycles. + const [resetNonce, setResetNonce] = useState(0) + const handleReset = useCallback(() => { + latestValueRef.current = initialValue + // If you have an imperative API: editor.setEditorState(initialValue) + // Otherwise, remount once to guarantee visual reset: + setResetNonce((n) => n + 1) + }, [initialValue]) + + return ( +
+
Default Component:
+ {Component ? ( + + ) : ( + 'Loading...' + )} + + +
+ ) } diff --git a/test/lexical/collections/OnDemand/OnDemand2.tsx b/test/lexical/collections/OnDemand/OnDemand2.tsx index d8fc3e20e5..27f9295e1f 100644 --- a/test/lexical/collections/OnDemand/OnDemand2.tsx +++ b/test/lexical/collections/OnDemand/OnDemand2.tsx @@ -1,17 +1,17 @@ 'use client' -import type { DefaultTypedEditorState } from '@payloadcms/richtext-lexical' +import type { JSONFieldClientComponent } from 'payload' -import { useRenderEditor_internal_ } from '@payloadcms/richtext-lexical/client' +import { buildEditorState, useRenderEditor_internal_ } from '@payloadcms/richtext-lexical/client' import { useEffect, useRef } from 'react' import { lexicalFullyFeaturedSlug } from '../../../lexical/slugs.js' -export const OnDemand: React.FC = () => { +export const OnDemand: JSONFieldClientComponent = (args) => { const { Component, renderLexical } = useRenderEditor_internal_({ - name: 'richText', + name: 'richText2', editorTarget: `collections.${lexicalFullyFeaturedSlug}.richText`, - initialState: {} as DefaultTypedEditorState, + initialValue: buildEditorState({ text: 'defaultValue' }), }) const mounted = useRef(false) @@ -22,5 +22,5 @@ export const OnDemand: React.FC = () => { void renderLexical() mounted.current = true }, [renderLexical]) - return
Fully-Featured Component: {Component ? Component : 'Loading...'}
+ return
Fully-Featured Component: {Component ? : 'Loading...'}
} diff --git a/test/lexical/collections/OnDemand/index.ts b/test/lexical/collections/OnDemand/index.ts index b29e2918d5..3f38647e97 100644 --- a/test/lexical/collections/OnDemand/index.ts +++ b/test/lexical/collections/OnDemand/index.ts @@ -5,7 +5,7 @@ export const OnDemand: CollectionConfig = { fields: [ { name: 'ui', - type: 'ui', + type: 'json', admin: { components: { Field: './collections/OnDemand/OnDemand.js#OnDemand', @@ -14,7 +14,7 @@ export const OnDemand: CollectionConfig = { }, { name: 'ui2', - type: 'ui', + type: 'json', admin: { components: { Field: './collections/OnDemand/OnDemand2.js#OnDemand', diff --git a/test/lexical/payload-types.ts b/test/lexical/payload-types.ts index 63f9ecc249..b1405b7462 100644 --- a/test/lexical/payload-types.ts +++ b/test/lexical/payload-types.ts @@ -97,6 +97,7 @@ export interface Config { 'text-fields': TextField; uploads: Upload; 'array-fields': ArrayField; + onDemand: OnDemand; users: User; 'payload-locked-documents': PayloadLockedDocument; 'payload-preferences': PayloadPreference; @@ -118,13 +119,14 @@ export interface Config { 'text-fields': TextFieldsSelect | TextFieldsSelect; uploads: UploadsSelect | UploadsSelect; 'array-fields': ArrayFieldsSelect | ArrayFieldsSelect; + onDemand: OnDemandSelect | OnDemandSelect; users: UsersSelect | UsersSelect; 'payload-locked-documents': PayloadLockedDocumentsSelect | PayloadLockedDocumentsSelect; 'payload-preferences': PayloadPreferencesSelect | PayloadPreferencesSelect; 'payload-migrations': PayloadMigrationsSelect | PayloadMigrationsSelect; }; db: { - defaultIDType: string; + defaultIDType: number; }; globals: { tabsWithRichText: TabsWithRichText; @@ -164,7 +166,7 @@ export interface UserAuthOperations { * via the `definition` "lexical-fully-featured". */ export interface LexicalFullyFeatured { - id: string; + id: number; richText?: { root: { type: string; @@ -188,7 +190,7 @@ export interface LexicalFullyFeatured { * via the `definition` "lexical-link-feature". */ export interface LexicalLinkFeature { - id: string; + id: number; richText?: { root: { type: string; @@ -212,7 +214,7 @@ export interface LexicalLinkFeature { * via the `definition` "lexical-jsx-converter". */ export interface LexicalJsxConverter { - id: string; + id: number; richText?: { root: { type: string; @@ -236,7 +238,7 @@ export interface LexicalJsxConverter { * via the `definition` "lexical-fields". */ export interface LexicalField { - id: string; + id: number; title: string; lexicalRootEditor?: { root: { @@ -292,7 +294,7 @@ export interface LexicalField { * via the `definition` "lexical-migrate-fields". */ export interface LexicalMigrateField { - id: string; + id: number; title: string; lexicalWithLexicalPluginData?: { root: { @@ -387,7 +389,7 @@ export interface LexicalMigrateField { * via the `definition` "lexical-localized-fields". */ export interface LexicalLocalizedField { - id: string; + id: number; title: string; /** * Non-localized field with localized block subfields @@ -433,7 +435,7 @@ export interface LexicalLocalizedField { * via the `definition` "lexicalObjectReferenceBug". */ export interface LexicalObjectReferenceBug { - id: string; + id: number; lexicalDefault?: { root: { type: string; @@ -472,7 +474,7 @@ export interface LexicalObjectReferenceBug { * via the `definition` "LexicalInBlock". */ export interface LexicalInBlock { - id: string; + id: number; content?: { root: { type: string; @@ -518,7 +520,7 @@ export interface LexicalInBlock { * via the `definition` "lexical-access-control". */ export interface LexicalAccessControl { - id: string; + id: number; title?: string | null; richText?: { root: { @@ -543,7 +545,7 @@ export interface LexicalAccessControl { * via the `definition` "lexical-relationship-fields". */ export interface LexicalRelationshipField { - id: string; + id: number; richText?: { root: { type: string; @@ -582,7 +584,7 @@ export interface LexicalRelationshipField { * via the `definition` "rich-text-fields". */ export interface RichTextField { - id: string; + id: number; title: string; lexicalCustomFields: { root: { @@ -663,7 +665,7 @@ export interface RichTextField { * via the `definition` "text-fields". */ export interface TextField { - id: string; + id: number; text: string; hiddenTextField?: string | null; /** @@ -715,9 +717,9 @@ export interface TextField { * via the `definition` "uploads". */ export interface Upload { - id: string; + id: number; text?: string | null; - media?: (string | null) | Upload; + media?: (number | null) | Upload; updatedAt: string; createdAt: string; url?: string | null; @@ -735,7 +737,7 @@ export interface Upload { * via the `definition` "array-fields". */ export interface ArrayField { - id: string; + id: number; title?: string | null; items: { text: string; @@ -828,12 +830,39 @@ export interface ArrayField { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "onDemand". + */ +export interface OnDemand { + id: number; + ui?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + ui2?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + updatedAt: string; + createdAt: string; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users". */ export interface User { - id: string; + id: number; updatedAt: string; createdAt: string; email: string; @@ -857,72 +886,76 @@ export interface User { * via the `definition` "payload-locked-documents". */ export interface PayloadLockedDocument { - id: string; + id: number; document?: | ({ relationTo: 'lexical-fully-featured'; - value: string | LexicalFullyFeatured; + value: number | LexicalFullyFeatured; } | null) | ({ relationTo: 'lexical-link-feature'; - value: string | LexicalLinkFeature; + value: number | LexicalLinkFeature; } | null) | ({ relationTo: 'lexical-jsx-converter'; - value: string | LexicalJsxConverter; + value: number | LexicalJsxConverter; } | null) | ({ relationTo: 'lexical-fields'; - value: string | LexicalField; + value: number | LexicalField; } | null) | ({ relationTo: 'lexical-migrate-fields'; - value: string | LexicalMigrateField; + value: number | LexicalMigrateField; } | null) | ({ relationTo: 'lexical-localized-fields'; - value: string | LexicalLocalizedField; + value: number | LexicalLocalizedField; } | null) | ({ relationTo: 'lexicalObjectReferenceBug'; - value: string | LexicalObjectReferenceBug; + value: number | LexicalObjectReferenceBug; } | null) | ({ relationTo: 'LexicalInBlock'; - value: string | LexicalInBlock; + value: number | LexicalInBlock; } | null) | ({ relationTo: 'lexical-access-control'; - value: string | LexicalAccessControl; + value: number | LexicalAccessControl; } | null) | ({ relationTo: 'lexical-relationship-fields'; - value: string | LexicalRelationshipField; + value: number | LexicalRelationshipField; } | null) | ({ relationTo: 'rich-text-fields'; - value: string | RichTextField; + value: number | RichTextField; } | null) | ({ relationTo: 'text-fields'; - value: string | TextField; + value: number | TextField; } | null) | ({ relationTo: 'uploads'; - value: string | Upload; + value: number | Upload; } | null) | ({ relationTo: 'array-fields'; - value: string | ArrayField; + value: number | ArrayField; + } | null) + | ({ + relationTo: 'onDemand'; + value: number | OnDemand; } | null) | ({ relationTo: 'users'; - value: string | User; + value: number | User; } | null); globalSlug?: string | null; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; updatedAt: string; createdAt: string; @@ -932,10 +965,10 @@ export interface PayloadLockedDocument { * via the `definition` "payload-preferences". */ export interface PayloadPreference { - id: string; + id: number; user: { relationTo: 'users'; - value: string | User; + value: number | User; }; key?: string | null; value?: @@ -955,7 +988,7 @@ export interface PayloadPreference { * via the `definition` "payload-migrations". */ export interface PayloadMigration { - id: string; + id: number; name?: string | null; batch?: number | null; updatedAt: string; @@ -1286,6 +1319,16 @@ export interface ArrayFieldsSelect { updatedAt?: T; createdAt?: T; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "onDemand_select". + */ +export interface OnDemandSelect { + ui?: T; + ui2?: T; + updatedAt?: T; + createdAt?: T; +} /** * This interface was referenced by `Config`'s JSON-Schema * via the `definition` "users_select". @@ -1345,7 +1388,7 @@ export interface PayloadMigrationsSelect { * via the `definition` "tabsWithRichText". */ export interface TabsWithRichText { - id: string; + id: number; tab1?: { rt1?: { root: { @@ -1419,7 +1462,7 @@ export interface LexicalBlocksRadioButtonsBlock { export interface AvatarGroupBlock { avatars?: | { - image?: (string | null) | Upload; + image?: (number | null) | Upload; id?: string | null; }[] | null;