From 78e009cd516680d31bf0c296f3e579859a54cc8f Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Thu, 5 Feb 2026 16:34:23 -0500 Subject: [PATCH 01/13] some initial implementations - to be refined --- .../src/components/DateOptimized.test.tsx | 155 +++++++++++ .../react/src/components/DateOptimized.tsx | 66 +++++ ...ltEmptyFieldEditingComponentsOptimized.tsx | 53 ++++ .../DesignLibrary/DesignLibraryOptimized.tsx | 179 +++++++++++++ .../src/components/FileOptimized.test.tsx | 102 ++++++++ .../react/src/components/FileOptimized.tsx | 62 +++++ .../src/components/FormOptimized.test.tsx | 220 ++++++++++++++++ .../react/src/components/FormOptimized.tsx | 116 +++++++++ .../src/components/LinkOptimized.test.tsx | 240 +++++++++++++++++ .../react/src/components/LinkOptimized.tsx | 111 ++++++++ .../src/components/RichTextOptimized.test.tsx | 168 ++++++++++++ .../src/components/RichTextOptimized.tsx | 69 +++++ .../SitecoreProviderOptimized.test.tsx | 117 +++++++++ .../components/SitecoreProviderOptimized.tsx | 101 ++++++++ .../src/components/TextOptimized.test.tsx | 243 ++++++++++++++++++ .../react/src/components/TextOptimized.tsx | 108 ++++++++ packages/react/src/hooks/index.ts | 1 + .../react/src/hooks/useComponentMap.test.tsx | 121 +++++++++ packages/react/src/hooks/useComponentMap.ts | 25 ++ packages/react/src/index.ts | 17 +- 20 files changed, 2273 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/components/DateOptimized.test.tsx create mode 100644 packages/react/src/components/DateOptimized.tsx create mode 100644 packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx create mode 100644 packages/react/src/components/DesignLibrary/DesignLibraryOptimized.tsx create mode 100644 packages/react/src/components/FileOptimized.test.tsx create mode 100644 packages/react/src/components/FileOptimized.tsx create mode 100644 packages/react/src/components/FormOptimized.test.tsx create mode 100644 packages/react/src/components/FormOptimized.tsx create mode 100644 packages/react/src/components/LinkOptimized.test.tsx create mode 100644 packages/react/src/components/LinkOptimized.tsx create mode 100644 packages/react/src/components/RichTextOptimized.test.tsx create mode 100644 packages/react/src/components/RichTextOptimized.tsx create mode 100644 packages/react/src/components/SitecoreProviderOptimized.test.tsx create mode 100644 packages/react/src/components/SitecoreProviderOptimized.tsx create mode 100644 packages/react/src/components/TextOptimized.test.tsx create mode 100644 packages/react/src/components/TextOptimized.tsx create mode 100644 packages/react/src/hooks/index.ts create mode 100644 packages/react/src/hooks/useComponentMap.test.tsx create mode 100644 packages/react/src/hooks/useComponentMap.ts diff --git a/packages/react/src/components/DateOptimized.test.tsx b/packages/react/src/components/DateOptimized.test.tsx new file mode 100644 index 0000000000..cce6433b1a --- /dev/null +++ b/packages/react/src/components/DateOptimized.test.tsx @@ -0,0 +1,155 @@ +/* eslint-disable no-unused-expressions */ +import React from 'react'; +import { expect } from 'chai'; +import { render } from '@testing-library/react'; + +import { DateFieldOptimized } from './DateOptimized'; +import { DateFieldProps } from './Date'; + +describe('', () => { + it('should render nothing with missing field', () => { + const field = (null as unknown) as DateFieldProps['field']; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render nothing with empty value', () => { + const field = { + value: '', + }; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render nothing with missing value', () => { + const field = {}; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render date value', () => { + const field = { + value: '2023-01-15T10:30:00Z', + }; + const rendered = render(); + expect(rendered.container.innerHTML).to.contain('2023-01-15T10:30:00Z'); + }); + + it('should render with tag provided', () => { + const field = { + value: '2023-01-15T10:30:00Z', + }; + const rendered = render().container.querySelector( + 'div' + ); + expect(rendered?.innerHTML).to.contain('2023-01-15T10:30:00Z'); + }); + + it('should render without tag', () => { + const field = { + value: '2023-01-15T10:30:00Z', + }; + const rendered = render(); + expect(rendered.container.innerHTML).to.contain('2023-01-15T10:30:00Z'); + }); + + it('should render with custom render function', () => { + const field = { + value: '2023-01-15T10:30:00Z', + }; + const customRender = (date: Date | null) => { + if (!date) return null; + return Custom: {date.toISOString()}; + }; + const rendered = render(); + expect(rendered.container.innerHTML).to.contain('Custom: 2023-01-15T10:30:00.000Z'); + }); + + it('should render with custom render function and tag', () => { + const field = { + value: '2023-01-15T10:30:00Z', + }; + const customRender = (date: Date | null) => { + if (!date) return null; + return date.toLocaleDateString(); + }; + const rendered = render( + + ).container.querySelector('div'); + expect(rendered).to.not.be.null; + }); + + it('should render other attributes', () => { + const field = { + value: '2023-01-15T10:30:00Z', + }; + const rendered = render( + + ).container.querySelector('span'); + expect(rendered?.outerHTML).to.contain('class="date-field"'); + expect(rendered?.outerHTML).to.contain('id="test-date"'); + }); + + describe('edit mode', () => { + const testMetadata = { + contextItem: { + id: '{09A07660-6834-476C-B93B-584248D3003B}', + language: 'en', + revision: 'a0b36ce0a7db49418edf90eb9621e145', + version: 1, + }, + fieldId: '{414061F4-FBB1-4591-BC37-BFFA67F745EB}', + fieldType: 'date', + rawValue: '2023-01-15T10:30:00Z', + }; + + it('should render field metadata component when metadata property is present', () => { + const field = { + value: '2023-01-15T10:30:00Z', + metadata: testMetadata, + }; + + const rendered = render(); + + expect(rendered.container.innerHTML).to.contain( + `${JSON.stringify( + testMetadata + )}` + ); + expect(rendered.container.innerHTML).to.contain('2023-01-15T10:30:00Z'); + expect(rendered.container.innerHTML).to.contain( + '' + ); + }); + + it('should render default empty field component when field value is empty in edit mode metadata', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const rendered = render(); + + expect(rendered.container.innerHTML).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + + it('should render nothing when field value is empty, when editing is explicitly disabled in edit mode metadata', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const rendered = render(); + + expect(rendered.container.innerHTML).to.equal(''); + }); + }); +}); diff --git a/packages/react/src/components/DateOptimized.tsx b/packages/react/src/components/DateOptimized.tsx new file mode 100644 index 0000000000..5d399d9ed9 --- /dev/null +++ b/packages/react/src/components/DateOptimized.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { withFieldMetadata } from '../enhancers/withFieldMetadata'; +import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; +import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; +import { EditableFieldProps } from './sharedTypes'; +import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; + +/** + * The props for the DateField component. + * @public + */ +export interface DateFieldProps extends EditableFieldProps { + /** The date field data. */ + [htmlAttributes: string]: unknown; + field: FieldMetadata & { + value?: string; + }; + /** + * The HTML element that will wrap the contents of the field. + */ + tag?: string; + + render?: (date: Date | null) => React.ReactNode; +} + +/** + * The DateField component - Optimized version using JSX instead of React.createElement. + * + * This is a modernized version that uses JSX syntax for better readability and + * maintainability, replacing React.createElement calls with native JSX. + * + * @public + */ +export const DateFieldOptimized = withFieldMetadata( + withEmptyFieldEditingComponent( + // eslint-disable-next-line no-unused-vars + ({ field, tag, editable = true, render, ...otherProps }) => { + if (isFieldValueEmpty(field)) { + return null; + } + + let children: React.ReactNode; + + const htmlProps: { + [htmlAttr: string]: unknown; + children?: React.ReactNode; + } = { + ...otherProps, + }; + + if (render) { + children = render(field.value ? new Date(field.value) : null); + } else { + children = field.value; + } + + if (tag) { + const Tag = (tag || 'span') as React.ElementType; + return {children}; + } else { + return {children}; + } + }, + { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText } + ) +); diff --git a/packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx b/packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx new file mode 100644 index 0000000000..671a54a6b8 --- /dev/null +++ b/packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +interface EmptyFieldComponentProps { + [key: string]: unknown; + tag?: React.ElementType; + className?: string; +} + +/** + * The DefaultEmptyFieldEditingComponentText component - Optimized version using JSX instead of React.createElement. + * + * This is a modernized version that uses JSX syntax for better readability and + * maintainability, replacing React.createElement calls with native JSX. + * + * @param {object} props - The props for the component. + * @public + */ +export function DefaultEmptyFieldEditingComponentTextOptimized(props: EmptyFieldComponentProps) { + const Tag = (props.tag || 'span') as React.ElementType; + return ( + + [No text in field] + + ); +} + +/** + * The DefaultEmptyFieldEditingComponentImage component (unchanged - already uses JSX). + * Re-exported here for completeness in the Optimized module. + * + * @param {object} props - The props for the component. + * @public + */ +export function DefaultEmptyFieldEditingComponentImageOptimized(props: EmptyFieldComponentProps) { + const inlineStyles = { + minWidth: '48px', + minHeight: '48px', + maxWidth: '400px', + maxHeight: '400px', + cursor: 'pointer', + }; + + return ( + + ); +} diff --git a/packages/react/src/components/DesignLibrary/DesignLibraryOptimized.tsx b/packages/react/src/components/DesignLibrary/DesignLibraryOptimized.tsx new file mode 100644 index 0000000000..493a2abdc6 --- /dev/null +++ b/packages/react/src/components/DesignLibrary/DesignLibraryOptimized.tsx @@ -0,0 +1,179 @@ +'use client'; +/* eslint-disable jsdoc/require-param */ +/* eslint-disable prefer-const */ +import React, { useEffect, useState } from 'react'; +import { + EDITING_COMPONENT_ID, + EDITING_COMPONENT_PLACEHOLDER, +} from '@sitecore-content-sdk/content/layout'; +import { + DesignLibraryStatus, + getDesignLibraryStatusEvent, + addComponentUpdateHandler, +} from '@sitecore-content-sdk/content/editing'; +import * as codegen from '@sitecore-content-sdk/content/codegen'; +import * as editing from '@sitecore-content-sdk/content/editing'; +import { useSitecore } from '../../enhancers/withSitecore'; +import { Placeholder, PlaceholderMetadata } from '../Placeholder'; +import { DesignLibraryErrorBoundary } from './DesignLibraryErrorBoundary'; +import { DynamicComponent } from './models'; +import { useLoadImportMap } from '../../enhancers/withLoadImportMap'; +import { ErrorComponent } from '../ErrorBoundary'; + +let { + getDesignLibraryImportMapEvent, + getDesignLibraryComponentPropsEvent, + addComponentPreviewHandler, + sendErrorEvent, +} = codegen; +let { postToDesignLibrary } = editing; + +export const __mockDependencies = (mocks: any) => { + addComponentPreviewHandler = mocks.addComponentPreviewHandler; + if (mocks.postToDesignLibrary) { + postToDesignLibrary = mocks.postToDesignLibrary; + } + if (mocks.sendErrorEvent) { + sendErrorEvent = mocks.sendErrorEvent; + } +}; + +/** + * Design Library component. + * + * Renders the **real** Sitecore component for `library` / `library-metadata` modes and, + * when generation is enabled (`page.mode.designLibrary.isVariantGeneration === true`), + * wires the **variant generation** handshake so the parent (DL Studio) can send + * generated code to preview and iterate on. + * @param {DesignLibraryProps} props + * @param {() => Promise} [props.loadImportMap] Optional async loader that resolves to the import-map used to resolve the generated component's imports. Required when `isVariantGeneration` is true. + * @returns {JSX.Element} The preview surface, or `null` when not in Design Library mode. + * @public + */ +export const DesignLibraryOptimized = () => { + const { page } = useSitecore(); + const route = page.layout.sitecore.route; + const rendering = route?.placeholders[EDITING_COMPONENT_PLACEHOLDER]?.[0]; + const uid = rendering?.uid; + + const { isDesignLibrary } = page.mode; + const isVariantGeneration = page.mode.designLibrary?.isVariantGeneration; + + const [propsState, setPropsState] = useState({ + fields: rendering?.fields, + params: rendering?.params, + }); + const [renderKey, setRenderKey] = useState(0); + const [Component, setComponent] = useState(null); + const isGeneratedComponentActive = !!Component; + + if (!isDesignLibrary) return null; + + if (!uid) return ; + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + postToDesignLibrary(getDesignLibraryStatusEvent(DesignLibraryStatus.READY, uid)); + + if (!isVariantGeneration) { + requestAnimationFrame(() => { + setRenderKey((k) => (k === 0 ? k + 1 : k)); + }); + } + + const unsubUpdate = addComponentUpdateHandler(rendering, (updated) => { + setPropsState({ fields: updated.fields, params: updated.params }); + setRenderKey((k) => k + 1); + }); + + // useEffect will cleanup event handler on re-render + return () => unsubUpdate && unsubUpdate(); + }, [isVariantGeneration, rendering, uid]); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + // Send a rendered event only as effect of a component update command + if (renderKey === 0) return; + + postToDesignLibrary(getDesignLibraryStatusEvent(DesignLibraryStatus.RENDERED, uid)); + }, [renderKey, uid]); + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (!isDesignLibrary || !isVariantGeneration) return; + + let cancelled = false; + // since import map is loaded lazily, we only need to add preview event handler once the import map is loaded + // unsubscribe function for useEffect cleanup will also be returned once importMap promise has been resolved or rejected + let unsubscribe: (() => void) | undefined; + const loadImportMap = useLoadImportMap(); + (async () => { + if (!loadImportMap) { + sendErrorEvent( + uid, + 'No loadImportMap provided', + codegen.DesignLibraryPreviewError.RenderInit + ); + return; + } + + let importMap: codegen.ImportEntry[]; + try { + const mod = await loadImportMap(); + importMap = mod.default; + } catch (e) { + sendErrorEvent( + uid, + `Error loading import map: ${e}`, + codegen.DesignLibraryPreviewError.RenderInit + ); + return; + } + // account for component being unmounted while resolving async import map + if (cancelled) return; + + unsubscribe = addComponentPreviewHandler(importMap, (error, Component) => { + // Error event is already sent in the addComponentPreviewHandler + if (error) return; + setComponent(() => Component as DynamicComponent); + setRenderKey((k) => k + 1); + }); + + const importMapEvent = getDesignLibraryImportMapEvent(uid, importMap); + postToDesignLibrary(importMapEvent); + + const propsEvent = getDesignLibraryComponentPropsEvent( + uid, + propsState.fields, + propsState.params + ); + postToDesignLibrary(propsEvent); + })(); + + // return function that calls unsubscribe - if the component was mounted correctly + return () => { + cancelled = true; + unsubscribe && unsubscribe(); + }; + }, [uid, propsState, isDesignLibrary, isVariantGeneration]); + + return ( +
+ {isGeneratedComponentActive ? ( + + + + + + ) : ( +
+ {route && ( + + )} +
+ )} +
+ ); +}; + +DesignLibraryOptimized.displayName = 'DesignLibraryOptimized'; diff --git a/packages/react/src/components/FileOptimized.test.tsx b/packages/react/src/components/FileOptimized.test.tsx new file mode 100644 index 0000000000..f8b9abbec2 --- /dev/null +++ b/packages/react/src/components/FileOptimized.test.tsx @@ -0,0 +1,102 @@ +/* eslint-disable no-unused-expressions */ +import React from 'react'; +import { expect } from 'chai'; +import { render } from '@testing-library/react'; + +import { FileOptimized } from './FileOptimized'; +import { FileField, FileFieldValue } from './File'; + +describe('', () => { + it('should render nothing with missing field', () => { + const field = (null as unknown) as FileField; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render nothing with missing value', () => { + const field = ({} as unknown) as FileField; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render nothing with empty value', () => { + const field = { value: {} } as FileField; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render with href and title', () => { + const field: FileField = { + value: { + src: '/lorem.pdf', + title: 'ipsum', + }, + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('ipsum'); + }); + + it('should render with href and displayName', () => { + const field: FileField = { + value: { + src: '/lorem.pdf', + displayName: 'ipsum', + }, + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('ipsum'); + }); + + it('should prefer title over displayName', () => { + const field: FileField = { + value: { + src: '/lorem.pdf', + title: 'title-value', + displayName: 'displayName-value', + }, + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('title-value'); + }); + + it('should render with children', () => { + const field: FileField = { + value: { + src: '/lorem.pdf', + title: 'ipsum', + }, + }; + const rendered = render( + + dolor + + ).container.querySelector('a'); + + expect(rendered?.innerHTML).to.contain('dolor'); + expect(rendered?.innerHTML).to.not.contain('ipsum'); + }); + + it('should render other attributes', () => { + const field: FileField = { + value: { + src: '/lorem.pdf', + title: 'ipsum', + }, + }; + const rendered = render( + + ).container.querySelector('a'); + expect(rendered?.outerHTML).to.contain( + 'ipsum' + ); + }); + + it('should render when file data provided directly', () => { + const field: FileFieldValue = { + src: '/lorem.pdf', + title: 'ipsum', + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('ipsum'); + }); +}); diff --git a/packages/react/src/components/FileOptimized.tsx b/packages/react/src/components/FileOptimized.tsx new file mode 100644 index 0000000000..41fabaea8f --- /dev/null +++ b/packages/react/src/components/FileOptimized.tsx @@ -0,0 +1,62 @@ +import { isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; +import React from 'react'; + +export interface FileFieldValue { + [propName: string]: unknown; + src?: string; + title?: string; + displayName?: string; +} + +/** + * The interface for the File field. + * @public + */ +export interface FileField { + value: FileFieldValue; +} + +export interface FileProps { + [attributeName: string]: unknown; + /** The file field data. */ + field: FileFieldValue | FileField; + /** HTML attributes that will be appended to the rendered tag. */ + children?: React.ReactNode; +} + +/** + * The File component - Optimized version using JSX instead of React.createElement. + * + * This is a modernized version that uses JSX syntax for better readability and + * maintainability, replacing React.createElement calls with native JSX. + * + * @param {FileProps} props component props + * @public + */ +export function FileOptimized({ field, children, ...otherProps }: FileProps) { + const dynamicField: FileField | FileFieldValue = field; + + if (isFieldValueEmpty(dynamicField)) { + return null; + } + + // handle link directly on field for forgetful devs + const file = ( + (dynamicField as FileFieldValue).src ? field : dynamicField.value + ) as FileFieldValue; + if (!file) { + return null; + } + + const linkText = !children ? file.title || file.displayName : null; + const anchorAttrs = { + href: file.src, + }; + + return ( + + {linkText} + {children} + + ); +} diff --git a/packages/react/src/components/FormOptimized.test.tsx b/packages/react/src/components/FormOptimized.test.tsx new file mode 100644 index 0000000000..e07562b88c --- /dev/null +++ b/packages/react/src/components/FormOptimized.test.tsx @@ -0,0 +1,220 @@ +/* eslint-disable no-unused-vars */ +/* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { expect } from 'chai'; +import { SitecoreProvider } from './SitecoreProvider'; +import { FormOptimized, mockFormModuleOptimized } from './FormOptimized'; +import sinon from 'sinon'; +import { PageMode } from '@sitecore-content-sdk/content/client'; +import { LayoutServicePageState } from '@sitecore-content-sdk/content/layout'; + +describe('FormOptimized', () => { + const ctx = { + api: { + edge: { + contextId: 'server-id', + clientContextId: 'client-id', + edgeUrl: 'edge-url', + }, + }, + page: { + normal: { + locale: 'en', + layout: { + sitecore: { + context: { pageEditing: false }, + route: null, + }, + }, + mode: { + name: LayoutServicePageState.Normal, + isNormal: true, + isPreview: false, + isEditing: false, + isDesignLibrary: false, + designLibrary: { + isVariantGeneration: false, + }, + }, + }, + editing: { + locale: 'en', + layout: { + sitecore: { + context: { pageEditing: true }, + route: null, + }, + }, + mode: { + name: LayoutServicePageState.Edit, + isEditing: true, + isNormal: false, + isPreview: false, + isDesignLibrary: false, + designLibrary: { + isVariantGeneration: false, + }, + }, + }, + }, + }; + + const rendering = { + uid: '123', + params: { + FormId: '456', + styles: 'form-class', + RenderingIdentifier: 'form-id', + }, + }; + + it('renders form using clientContextId when both IDs are present', async () => { + const loadFormSpy = sinon.spy((edgeId: string, formId: string, edgeUrl?: string) => { + expect(edgeId).to.equal('client-id'); + expect(formId).to.equal('456'); + expect(edgeUrl).to.equal('edge-url'); + + return Promise.resolve('
'); + }); + + const subscribeSpy = sinon.spy(); + const execSpy = sinon.spy(); + + mockFormModuleOptimized({ + loadForm: loadFormSpy, + subscribeToFormSubmitEvent: subscribeSpy, + executeScriptElements: execSpy, + }); + + const rendered = await render( + + + , + { container: document.body } + ); + + await waitFor(() => { + expect(loadFormSpy.calledOnce).to.be.true; + expect(subscribeSpy.calledOnce).to.be.true; + expect(execSpy.calledOnce).to.be.true; + expect(rendered.container.innerHTML).to.equal( + '
' + + '
' + ); + }); + }); + + it('does not load form when clientContextId is missing', async () => { + const apiNoClientId = { + edge: { + contextId: 'server-only', + edgeUrl: 'edge-url', + }, + }; + + const loadFormSpy = sinon.spy(() => { + return Promise.resolve('
'); + }); + + mockFormModuleOptimized({ + loadForm: loadFormSpy, + subscribeToFormSubmitEvent: sinon.spy(), + executeScriptElements: sinon.spy(), + }); + + const rendered = await render( + + + + ); + + await waitFor(() => { + expect(loadFormSpy.notCalled).to.be.true; + expect(rendered.container.innerHTML).to.equal('
'); + }); + }); + + it('renders form in edit mode (scripts executed, no submit subscription)', async () => { + const loadFormSpy = sinon + .stub() + .resolves('
'); + const subscribeSpy = sinon.spy(); + const execSpy = sinon.spy(); + + const mode: PageMode = { + name: LayoutServicePageState.Edit, + isEditing: true, + }; + + mockFormModuleOptimized({ + loadForm: loadFormSpy, + subscribeToFormSubmitEvent: subscribeSpy, + executeScriptElements: execSpy, + }); + + const rendered = await render( + + + + ); + + await waitFor(() => { + expect(loadFormSpy.calledOnce).to.be.true; + expect(subscribeSpy.notCalled).to.be.true; + expect(execSpy.calledOnce).to.be.true; + + expect(rendered.container.innerHTML).to.contain('
'); + }); + }); + + it('renders empty component on load failure (non-edit mode)', async () => { + mockFormModuleOptimized({ + loadForm: sinon.stub().rejects(), + subscribeToFormSubmitEvent: sinon.spy(), + executeScriptElements: sinon.spy(), + }); + + const rendered = await render( + + + + ); + + await waitFor(() => { + expect(rendered.container.innerHTML).to.equal('
'); + }); + }); + + it('renders edit-mode error placeholder on load failure', async () => { + mockFormModuleOptimized({ + loadForm: sinon.stub().rejects(), + subscribeToFormSubmitEvent: sinon.spy(), + executeScriptElements: sinon.spy(), + }); + + const mode: PageMode = { + name: LayoutServicePageState.Edit, + isEditing: true, + }; + + const rendered = await render( + + + , + { container: document.body } + ); + + await waitFor(() => { + rendered.rerender( + + + + ); + + expect(rendered.baseElement.innerHTML).to.equal( + '
There was a problem loading this section
' + ); + }); + }); +}); diff --git a/packages/react/src/components/FormOptimized.tsx b/packages/react/src/components/FormOptimized.tsx new file mode 100644 index 0000000000..04c821c21f --- /dev/null +++ b/packages/react/src/components/FormOptimized.tsx @@ -0,0 +1,116 @@ +'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; +import { form } from '@sitecore-content-sdk/content'; +import { useSitecore } from '../enhancers/withSitecore'; +import { ErrorComponent } from './ErrorBoundary'; + +/** + * Shape of the Form component rendering data. + * FormId is the rendering parameter that specifies the ID of the Sitecore Form to render. + */ +export type FormProps = { + rendering: ComponentRendering; + params: { + /** + * The ID of the Sitecore Form to render. + */ + FormId: string; + /** + * CSS class to apply to the form + */ + styles?: string; + /** + * The unique identifier of the rendering. + */ + RenderingIdentifier?: string; + }; +}; + +let { executeScriptElements, loadForm, subscribeToFormSubmitEvent } = form; + +/** + * Mock function to replace the form module functions for `testing` purposes. + * @param {any} formModule - The form module to mock + */ +export const mockFormModuleOptimized = (formModule: any) => { + executeScriptElements = formModule.executeScriptElements; + loadForm = formModule.loadForm; + subscribeToFormSubmitEvent = formModule.subscribeToFormSubmitEvent; +}; + +/** + * The Form component - Optimized version with fixed useEffect dependencies. + * + * This is a modernized version that fixes the exhaustive-deps warning by including + * all necessary dependencies (isEditing, params.FormId, and rendering.uid) in the + * useEffect dependency array. + * + * @param {FormProps} props incoming props + * @public + */ +export const FormOptimized = ({ params, rendering }: FormProps) => { + const id = params?.RenderingIdentifier; + const [error, setError] = useState(false); + const [content, setContent] = useState(''); + const context = useSitecore(); + const formRef = useRef(null); + + const isEditing = context.page.mode.isEditing; + + useEffect(() => { + if (!content) { + // Forms must use clientContextId since they are rendered client-side + const edgeId = context.api?.edge?.clientContextId; + + if (!edgeId) { + /* eslint-disable no-console */ + console.warn( + 'Warning: clientContextId is missing – form cannot be loaded properly on the client' + ); + return; + } + + loadForm(edgeId, params.FormId, context.api?.edge?.edgeUrl) + .then(setContent) + .catch(() => { + if (isEditing) { + console.error( + `Failed to load form with id ${params.FormId}. Check debug logs for content-sdk:form for more details.` + ); + } + setError(true); + }); + } else { + if (!formRef.current) return; + + // If we are in editing mode, we don't want to send any events + if (!isEditing) { + subscribeToFormSubmitEvent(formRef.current, rendering.uid); + } + + executeScriptElements(formRef.current); + } + // Fixed: Include all dependencies as recommended by React exhaustive-deps rule + }, [ + content, + isEditing, + params.FormId, + rendering.uid, + context.api?.edge?.clientContextId, + context.api?.edge?.edgeUrl, + ]); + + if (isEditing && error) { + return ; + } + + return ( +
+ ); +}; diff --git a/packages/react/src/components/LinkOptimized.test.tsx b/packages/react/src/components/LinkOptimized.test.tsx new file mode 100644 index 0000000000..f54ea8c7ee --- /dev/null +++ b/packages/react/src/components/LinkOptimized.test.tsx @@ -0,0 +1,240 @@ +/* eslint-disable no-unused-expressions */ +import React from 'react'; +import { expect } from 'chai'; +import { render } from '@testing-library/react'; + +import { LinkOptimized } from './LinkOptimized'; +import { LinkField, LinkFieldValue } from './Link'; + +describe('', () => { + it('should render nothing with missing field', () => { + const field = (null as unknown) as LinkField; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render nothing with missing value', () => { + const field = ({} as unknown) as LinkField; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render nothing with empty value', () => { + const field = { value: {} } as LinkField; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render with a value', () => { + const field: LinkField = { + value: { + href: '/lorem', + text: 'ipsum', + }, + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('ipsum'); + }); + + it('should render with children', () => { + const field: LinkField = { + value: { + href: '/lorem', + text: 'ipsum', + }, + }; + const rendered = render( + + dolor + + ).container.querySelector('a'); + + expect(rendered?.innerHTML).to.contain('dolor'); + }); + + it('should render with link text and children when showLinkTextWithChildrenPresent=true', () => { + const field: LinkField = { + value: { + href: '/lorem', + text: 'ipsum', + }, + }; + const rendered = render( + + dolor + + ).container.querySelector('a'); + + expect(rendered?.innerHTML).to.contain('ipsum'); + expect(rendered?.innerHTML).to.contain('dolor'); + }); + + it('should render with link text when children are empty string', () => { + const field: LinkField = { + value: { + href: '/lorem', + text: 'ipsum', + }, + }; + const rendered = render( + + {''} + + ).container.querySelector('a'); + + expect(rendered?.innerHTML).to.contain('ipsum'); + }); + + it('should render with href when text is not present', () => { + const field: LinkField = { + value: { + href: '/lorem', + }, + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('/lorem'); + }); + + it('should render all attributes with all provided', () => { + const field: LinkField = { + value: { + href: '/lorem', + text: 'ipsum', + title: 'Foo', + target: '_blank', + class: 'bar', + }, + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain( + 'ipsum' + ); + }); + + it('should render with an anchor', () => { + const field: LinkField = { + value: { + href: '/lorem', + anchor: 'ipsum', + text: 'lorem', + }, + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('lorem'); + }); + + it('should render anchor link value with href when anchor link type is provided', () => { + const field: LinkField = { + value: { + href: '/lorem', + anchor: 'ipsum', + text: 'lorem', + linktype: 'anchor', + }, + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('lorem'); + }); + + it('should render other attributes', () => { + const field: LinkField = { + value: { + href: '/lorem', + text: 'ipsum', + }, + }; + const rendered = render( + + ).container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('ipsum'); + }); + + it('should render when link data provided directly', () => { + const field: LinkFieldValue = { + href: '/lorem', + text: 'ipsum', + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('ipsum'); + }); + + it('should render with a querystring', () => { + const field: LinkField = { + value: { + href: '/lorem', + querystring: 'foo=bar', + text: 'lorem', + }, + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('lorem'); + }); + + it('should render with a querystring and an anchor', () => { + const field: LinkField = { + value: { + href: '/lorem', + querystring: 'foo=bar', + anchor: 'ipsum', + text: 'lorem', + }, + }; + const rendered = render().container.querySelector('a'); + expect(rendered?.outerHTML).to.contain('lorem'); + }); + + describe('edit mode metadata', () => { + const testMetadata = { + contextItem: { + id: '{09A07660-6834-476C-B93B-584248D3003B}', + language: 'en', + revision: 'a0b36ce0a7db49418edf90eb9621e145', + version: 1, + }, + fieldId: '{414061F4-FBB1-4591-BC37-BFFA67F745EB}', + fieldType: 'link', + rawValue: '/lorem', + }; + + it('should render field metadata component when metadata property is present', () => { + const field: LinkField = { + value: { + href: '/lorem', + text: 'ipsum', + }, + metadata: testMetadata, + }; + + const rendered = render(); + + expect(rendered.container.innerHTML).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'ipsum', + '', + ].join('') + ); + }); + + it('should render default empty field component when field value is empty in edit mode metadata', () => { + const field = { + value: { href: '' }, + metadata: testMetadata, + }; + + const rendered = render(); + + expect(rendered.container.innerHTML).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + }); +}); diff --git a/packages/react/src/components/LinkOptimized.tsx b/packages/react/src/components/LinkOptimized.tsx new file mode 100644 index 0000000000..ad8dd821d8 --- /dev/null +++ b/packages/react/src/components/LinkOptimized.tsx @@ -0,0 +1,111 @@ +'use client'; +import React, { RefAttributes, forwardRef } from 'react'; +import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; +import { withFieldMetadata } from '../enhancers/withFieldMetadata'; +import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; +import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; +import { EditableFieldProps } from './sharedTypes'; + +/** + * The interface for the Link field value. + * @public + */ +export interface LinkFieldValue { + [attributeName: string]: unknown; + href?: string; + className?: string; + class?: string; + title?: string; + target?: string; + text?: string; + anchor?: string; + querystring?: string; + linktype?: string; +} + +/** + * The interface for the Link field. + * @public + */ +export interface LinkField { + value: LinkFieldValue; +} + +/** + * The interface for the Link component props. + * @public + */ +export type LinkProps = EditableFieldProps & + React.AnchorHTMLAttributes & + RefAttributes & { + /** The link field data. */ + field: (LinkField | LinkFieldValue) & FieldMetadata; + + /** + * Displays a link text ('description' in Sitecore) even when children exist + */ + showLinkTextWithChildrenPresent?: boolean; + }; + +/** + * The Link component - Optimized version using JSX instead of React.createElement. + * + * This is a modernized version that uses JSX syntax for better readability and + * maintainability, replacing React.createElement calls with native JSX. + * + * @param {LinkProps} props component props + * @public + */ +export const LinkOptimized = withFieldMetadata( + withEmptyFieldEditingComponent( + forwardRef( + // eslint-disable-next-line no-unused-vars + ({ field, editable = true, showLinkTextWithChildrenPresent, ...otherProps }, ref) => { + const children = otherProps.children as React.ReactNode; + const dynamicField: LinkField | LinkFieldValue = field; + + if (isFieldValueEmpty(dynamicField)) { + return null; + } + + // handle link directly on field for forgetful devs + const link = (dynamicField as LinkFieldValue).href + ? (field as LinkFieldValue) + : (dynamicField as LinkField).value; + + if (!link) { + return null; + } + + const anchor = link.linktype !== 'anchor' && link.anchor ? `#${link.anchor}` : ''; + const querystring = link.querystring ? `?${link.querystring}` : ''; + + const anchorAttrs: { [attr: string]: unknown } = { + href: `${link.href}${querystring}${anchor}`, + className: link.class, + title: link.title, + target: link.target, + }; + + if (anchorAttrs.target === '_blank' && !anchorAttrs.rel) { + // information disclosure attack prevention keeps target blank site from getting ref to window.opener + anchorAttrs.rel = 'noopener noreferrer'; + } + + const linkText = + showLinkTextWithChildrenPresent || !children ? link.text || link.href : null; + + return ( + + + {linkText} + {children} + + + ); + } + ), + { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, isForwardRef: true } + ), + true +); diff --git a/packages/react/src/components/RichTextOptimized.test.tsx b/packages/react/src/components/RichTextOptimized.test.tsx new file mode 100644 index 0000000000..b49b1603dd --- /dev/null +++ b/packages/react/src/components/RichTextOptimized.test.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { expect } from 'chai'; +import { render } from '@testing-library/react'; + +import { RichTextOptimized } from './RichTextOptimized'; +import { RichTextField } from './RichText'; + +describe('', () => { + it('should render nothing with missing field', () => { + const field: RichTextField = null; + const rendered = render().container.querySelectorAll('div'); + expect(rendered).to.have.length(0); + }); + + it('should render nothing with empty value', () => { + const field = { + value: '', + }; + const rendered = render().container.querySelectorAll('div'); + expect(rendered).to.have.length(0); + }); + + it('should render nothing with missing value', () => { + const field = {}; + const rendered = render().container.querySelectorAll('div'); + expect(rendered).to.have.length(0); + }); + + it('should render value with editing explicitly disabled', () => { + const field = { + value: 'value', + }; + const rendered = render( + + ).container.querySelectorAll('div'); + expect(rendered).to.have.length(1); + expect(rendered[0].innerHTML).to.contain('value'); + }); + + it('should render value with with just a value', () => { + const field = { + value: 'value', + }; + const rendered = render().container.querySelectorAll('div'); + expect(rendered).to.have.length(1); + expect(rendered[0].innerHTML).to.contain('value'); + }); + + it('should render embedded html as-is', () => { + const field = { + value: 'some crazy stuff', + }; + const rendered = render().container.querySelectorAll('div'); + expect(rendered).to.have.length(1); + expect(rendered[0].innerHTML).to.contain(field.value); + }); + + it('should render tag with a tag provided', () => { + const field = { + value: 'value', + }; + const rendered = render( + + ).container.querySelectorAll('p'); + expect(rendered).to.have.length(1); + expect(rendered[0].innerHTML).to.contain('value'); + }); + + it('should render other attributes with other props provided', () => { + const field = { + value: 'value', + }; + const rendered = render( + + ).container.querySelectorAll('h1'); + expect(rendered).to.have.length(1); + expect(rendered[0].outerHTML).to.contain('

'); + expect(rendered[0].outerHTML).to.contain('value'); + }); + + describe('edit mode', () => { + const testMetadata = { + contextItem: { + id: '{09A07660-6834-476C-B93B-584248D3003B}', + language: 'en', + revision: 'a0b36ce0a7db49418edf90eb9621e145', + version: 1, + }, + fieldId: '{414061F4-FBB1-4591-BC37-BFFA67F745EB}', + fieldType: 'single-line', + rawValue: 'Test1', + }; + + it('should render field metadata component when metadata property is present', () => { + const field = { + value: 'value', + metadata: testMetadata, + }; + + const rendered = render(); + + expect(rendered.container.innerHTML).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '
value
', + '', + ].join('') + ); + }); + + it('should render default empty field component when field value is empty in edit mode metadata', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const rendered = render(); + + expect(rendered.container.innerHTML).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field value is empty in edit mode metadata', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = render( + + ); + + expect(rendered.container.innerHTML).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render nothing when field value is empty, when editing is explicitly disabled in edit mode metadata', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const rendered = render(); + + expect(rendered.container.innerHTML).to.equal(''); + }); + }); +}); diff --git a/packages/react/src/components/RichTextOptimized.tsx b/packages/react/src/components/RichTextOptimized.tsx new file mode 100644 index 0000000000..3d72087e68 --- /dev/null +++ b/packages/react/src/components/RichTextOptimized.tsx @@ -0,0 +1,69 @@ +'use client'; +import React, { ForwardedRef, forwardRef } from 'react'; +import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; +import { withFieldMetadata } from '../enhancers/withFieldMetadata'; +import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; +import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; +import { EditableFieldProps } from './sharedTypes'; + +/** + * The interface for the RichText field. + * @public + */ +export interface RichTextField extends FieldMetadata { + value?: string; +} + +/** + * The interface for the RichText component props. + * @public + */ +export interface RichTextProps extends EditableFieldProps { + [htmlAttributes: string]: unknown; + /** The rich text field data. */ + field?: RichTextField; + /** + * The HTML element that will wrap the contents of the field. + * @default
+ */ + tag?: string; +} + +/** + * The RichText component - Optimized version using JSX instead of React.createElement. + * + * This is a modernized version that uses JSX syntax for better readability and + * maintainability, replacing React.createElement calls with native JSX. + * + * @param {RichTextProps} props component props + * @public + */ +export const RichTextOptimized = withFieldMetadata( + withEmptyFieldEditingComponent( + forwardRef( + ( + // eslint-disable-next-line no-unused-vars + { field, tag = 'div', editable = true, ...otherProps }: RichTextProps, + ref: ForwardedRef + ) => { + if (isFieldValueEmpty(field)) { + return null; + } + + const htmlProps = { + dangerouslySetInnerHTML: { + __html: field.value, + }, + ref, + suppressHydrationWarning: field.metadata ? true : undefined, + ...otherProps, + }; + + const Tag = (tag || 'div') as React.ElementType; + return ; + } + ), + { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, isForwardRef: true } + ), + true +); diff --git a/packages/react/src/components/SitecoreProviderOptimized.test.tsx b/packages/react/src/components/SitecoreProviderOptimized.test.tsx new file mode 100644 index 0000000000..9de99f99db --- /dev/null +++ b/packages/react/src/components/SitecoreProviderOptimized.test.tsx @@ -0,0 +1,117 @@ +/* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ +import React, { FC } from 'react'; +import { expect } from 'chai'; +import { Page } from '@sitecore-content-sdk/content/client'; +import { SitecoreProviderOptimized } from './SitecoreProviderOptimized'; +import { useSitecore } from '../enhancers/withSitecore'; +import { LayoutServiceData, LayoutServicePageState } from '../index'; +import { render } from '@testing-library/react'; + +describe('SitecoreProviderOptimized', () => { + let nestedContext = {}; + + const NestedComponent: FC = () => { + const { page } = useSitecore(); + nestedContext = page; + return Page mode is {page.mode.name}; + }; + + const components = new Map(); + + // minimal API stub – details don't matter for these tests + const apiStub = {} as any; + + const mockLayoutData: LayoutServiceData = { + sitecore: { + context: { + pageEditing: false, + site: { name: 'ContentSdkTestWeb' }, + language: 'en', + }, + route: { + name: 'styleguide', + placeholders: { 'ContentSdkTestWeb-main': [] }, + itemId: 'testitemid', + }, + }, + }; + + const mockPage: Page = { + layout: mockLayoutData, + locale: 'en', + mode: { + name: LayoutServicePageState.Normal, + isNormal: true, + isPreview: false, + isEditing: false, + isDesignLibrary: false, + designLibrary: { + isVariantGeneration: false, + }, + }, + }; + + it('renders the component with the context', () => { + const rendered = render( + + + + ); + + expect(nestedContext).to.deep.equal(mockPage); + expect(rendered.getByText('Page mode is normal')).to.exist; + }); + + it('updates state when new page is received via props', () => { + const rendered = render( + + + + ); + + expect(nestedContext).to.deep.equal(mockPage); + + const newMockPage: Page = { + ...mockPage, + locale: 'gr', + }; + + rendered.rerender( + + + + ); + + expect(nestedContext).to.deep.equal({ + ...mockPage, + locale: 'gr', + }); + }); + + it('applies default edgeUrl when Edge IDs are present', () => { + let capturedApi: any; + + const ApiConsumer: FC = () => { + const { api } = useSitecore(); + capturedApi = api; + return
API Consumer
; + }; + + const apiWithEdgeId = { + edge: { + contextId: 'test-context-id', + clientContextId: 'test-client-id', + // no edgeUrl provided + }, + }; + + render( + + + + ); + + expect(capturedApi.edge.edgeUrl).to.exist; + expect(capturedApi.edge.edgeUrl).to.be.a('string'); + }); +}); diff --git a/packages/react/src/components/SitecoreProviderOptimized.tsx b/packages/react/src/components/SitecoreProviderOptimized.tsx new file mode 100644 index 0000000000..138ff709e2 --- /dev/null +++ b/packages/react/src/components/SitecoreProviderOptimized.tsx @@ -0,0 +1,101 @@ +'use client'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import fastDeepEqual from 'fast-deep-equal/es6/react'; +import { Page } from '@sitecore-content-sdk/content/client'; +import { SitecoreConfig } from '@sitecore-content-sdk/content/config'; +import { constants } from '@sitecore-content-sdk/core'; +import { ComponentMap } from './sharedTypes'; +import { ImportMapImport } from './DesignLibrary/models'; +import { + SitecoreProviderReactContext, + ComponentMapReactContext, + ImportMapReactContext, + SitecoreProviderState, +} from './SitecoreProvider'; + +export interface SitecoreProviderProps { + /** + * The API configuration defined in the `SitecoreConfig`. + */ + api: SitecoreConfig['api']; + /** + * The component map to use for rendering components. + */ + componentMap: ComponentMap; + /** + * The page data. + */ + page: Page; + /** + * The dynamic import for import map to be used in variant generation mode. + */ + loadImportMap: () => Promise; + + children: React.ReactNode; +} + +/** + * The SitecoreProvider component - Optimized functional component version. + * + * This is a modernized version of the SitecoreProvider that uses React hooks + * instead of class component lifecycle methods. It provides the same functionality + * with better React 19+ optimization support. + * + * @public + */ +export function SitecoreProviderOptimized(props: SitecoreProviderProps) { + const { api: propsApi, page: propsPage, componentMap, loadImportMap, children } = props; + + // Apply default edgeUrl if any Edge ID is present but no edgeUrl + const api = useMemo(() => { + if ( + (propsApi?.edge?.contextId || propsApi?.edge?.clientContextId) && + !propsApi?.edge?.edgeUrl + ) { + return { + ...propsApi, + edge: { + ...propsApi.edge, + edgeUrl: constants.SITECORE_EDGE_URL_DEFAULT, + }, + }; + } + return propsApi; + }, [propsApi]); + + const [page, setPageInternal] = useState(propsPage); + + // Memoize setPage callback + const setPage = useCallback((value: Page) => { + setPageInternal(value); + }, []); + + // Handle page prop changes using useEffect instead of componentDidUpdate + useEffect(() => { + if (!fastDeepEqual(propsPage, page)) { + setPage(propsPage); + } + }, [propsPage, page, setPage]); + + // Memoize the context value to prevent unnecessary re-renders + const contextValue = useMemo( + () => ({ + page, + setPage, + api, + }), + [page, setPage, api] + ); + + return ( + + + + {children} + + + + ); +} + +SitecoreProviderOptimized.displayName = 'SitecoreProviderOptimized'; diff --git a/packages/react/src/components/TextOptimized.test.tsx b/packages/react/src/components/TextOptimized.test.tsx new file mode 100644 index 0000000000..d7d403177e --- /dev/null +++ b/packages/react/src/components/TextOptimized.test.tsx @@ -0,0 +1,243 @@ +/* eslint-disable no-unused-expressions */ +import React from 'react'; +import { expect } from 'chai'; +import { render } from '@testing-library/react'; + +import { TextOptimized } from './TextOptimized'; +import { TextField } from './Text'; + +describe('', () => { + it('should render nothing with missing field', () => { + const field: TextField = null; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render nothing with empty string value', () => { + const field = { + value: '', + }; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render nothing with missing value', () => { + const field = {}; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should render value with editing explicitly disabled', () => { + const field = { + value: 'value', + }; + const rendered = render( + + ).container.querySelector('span'); + expect(rendered?.innerHTML).to.contain('value'); + }); + + it('should encode values with editing explicitly disabled', () => { + const field = { + value: 'value < >', + }; + const rendered = render( + + ).container.querySelector('span'); + expect(rendered.innerHTML).to.contain('< >'); + }); + + it('should render value with just a value', () => { + const field = { + value: 'value', + }; + const rendered = render().container.querySelector( + 'span' + ); + expect(rendered.innerHTML).to.contain('value'); + }); + + it('should render value without tag', () => { + const field = { + value: 'value', + }; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal('value'); + }); + + it('should render number value', () => { + const field = { + value: 1.23, + }; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal('1.23'); + }); + + it('should render zero number value', () => { + const field = { + value: 0, + }; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal('0'); + }); + + it('should render negative number value', () => { + const field = { + value: -1.23, + }; + const rendered = render(); + expect(rendered.container.innerHTML).to.equal('-1.23'); + }); + + it('should render value with just a value that contains line breaks', () => { + const field = { + value: 'xxx\n\naa\nbbb\ndd', + }; + const rendered = render().container.querySelector( + 'span' + ); + expect(rendered?.innerHTML).to.contain('xxx

aa
bbb
dd'); + }); + + it('should render value with just a value that contains only one line break', () => { + const field = { + value: '\n', + }; + const rendered = render().container.querySelector( + 'span' + ); + expect(rendered?.outerHTML).to.contain('
'); + }); + + it('should render embedded html as-is when encoding is disabled', () => { + const field = { + value: 'some crazy stuff', + }; + const rendered = render().container.querySelector( + 'span' + ); + expect(rendered?.innerHTML).to.contain(field.value); + }); + + it('should render tag with a tag provided', () => { + const field = { + value: 'value', + }; + const rendered = render().container.querySelector('h1'); + expect(rendered?.innerHTML).to.contain('value'); + }); + + it('should render other attributes with other props provided', () => { + const field = { + value: 'value', + }; + const rendered = render( + + ).container.querySelector('h1'); + expect(rendered?.outerHTML).to.contain('

'); + expect(rendered?.outerHTML).to.contain('value'); + }); + + describe('edit mode', () => { + const testMetadata = { + contextItem: { + id: '{09A07660-6834-476C-B93B-584248D3003B}', + language: 'en', + revision: 'a0b36ce0a7db49418edf90eb9621e145', + version: 1, + }, + fieldId: '{414061F4-FBB1-4591-BC37-BFFA67F745EB}', + fieldType: 'single-line', + rawValue: 'Test1', + }; + + it('should render field metadata component when metadata property is present', () => { + const field = { + value: 'value', + metadata: testMetadata, + }; + + const rendered = render(); + + expect(rendered.container.innerHTML).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'value', + '', + ].join('') + ); + }); + + it('should render default empty field component when field value is empty in edit mode metadata', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const rendered = render(); + + expect(rendered.container.innerHTML).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + '[No text in field]', + '', + ].join('') + ); + }); + + it('should render custom empty field component when provided, when field value is empty in edit mode metadata', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const EmptyFieldEditingComponent: React.FC = () => ( + Custom Empty field value + ); + + const rendered = render( + + ); + + expect(rendered.container.innerHTML).to.equal( + [ + `${JSON.stringify( + testMetadata + )}`, + 'Custom Empty field value', + '', + ].join('') + ); + }); + + it('should render nothing when field value is empty, when editing is explicitly disabled in edit mode metadata ', () => { + const field = { + value: '', + metadata: testMetadata, + }; + + const rendered = render(); + + expect(rendered.container.innerHTML).to.equal(''); + }); + + it('should apply suppressHydrationWarning prop when in editing mode', () => { + const field = { + value: 'value', + metadata: testMetadata, + }; + + const rendered = render(); + + // Check that the rendered output contains the value + // The suppressHydrationWarning is applied via JSX now + const span = rendered.container.querySelector('span'); + expect(span).to.not.be.null; + expect(span?.innerHTML).to.contain('value'); + }); + }); +}); diff --git a/packages/react/src/components/TextOptimized.tsx b/packages/react/src/components/TextOptimized.tsx new file mode 100644 index 0000000000..028bb81125 --- /dev/null +++ b/packages/react/src/components/TextOptimized.tsx @@ -0,0 +1,108 @@ +'use client'; +import React, { ReactElement } from 'react'; +import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; +import { withFieldMetadata } from '../enhancers/withFieldMetadata'; +import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; +import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; +import { EditableFieldProps } from './sharedTypes'; + +/** + * The interface for the Text field. + * @public + */ +export interface TextField extends FieldMetadata { + value?: string | number; +} + +export interface TextProps extends EditableFieldProps { + [htmlAttributes: string]: unknown; + /** The text field data. */ + field?: TextField; + /** + * The HTML element that will wrap the contents of the field. + */ + tag?: string; + /** + * If false, HTML-encoding of the field value is disabled and the value is rendered as-is. + */ + encode?: boolean; +} + +/** + * The Text component - Optimized version using JSX instead of React.createElement. + * + * This is a modernized version that uses JSX syntax for better readability and + * maintainability, replacing all React.createElement calls with native JSX. + * + * @public + */ +export const TextOptimized = withFieldMetadata( + withEmptyFieldEditingComponent( + ({ field, tag, editable = true, encode = true, ...otherProps }) => { + if (isFieldValueEmpty(field)) { + return null; + } + + // can't use editable value if we want to output unencoded + if (!encode) { + // eslint-disable-next-line no-param-reassign, no-unused-vars + editable = false; + } + + let output: string | number | (ReactElement | string)[] = + field.value === undefined ? '' : field.value; + + // when string value isn't formatted, we should format line breaks + const splitted = String(output).split('\n'); + + if (splitted.length) { + const formatted: (ReactElement | string)[] = []; + + splitted.forEach((str, i) => { + const isLast = i === splitted.length - 1; + + formatted.push(str); + + if (!isLast) { + formatted.push(
); + } + }); + + output = formatted; + } + + let children = null; + const htmlProps: { + [htmlAttributes: string]: unknown; + children?: React.ReactNode; + } = { + ...otherProps, + }; + + if (!encode) { + htmlProps.dangerouslySetInnerHTML = { + __html: output, + }; + } else { + children = output; + } + + const Tag = (tag || 'span') as React.ElementType; + + if (field.metadata) { + return ( + + {children} + + ); + } else if (tag || !encode) { + return {children}; + } else { + return {children}; + } + }, + { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText } + ) +); + +TextOptimized.displayName = 'TextOptimized'; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts new file mode 100644 index 0000000000..7374a42913 --- /dev/null +++ b/packages/react/src/hooks/index.ts @@ -0,0 +1 @@ +export { useComponentMap } from './useComponentMap'; diff --git a/packages/react/src/hooks/useComponentMap.test.tsx b/packages/react/src/hooks/useComponentMap.test.tsx new file mode 100644 index 0000000000..5d052e5e54 --- /dev/null +++ b/packages/react/src/hooks/useComponentMap.test.tsx @@ -0,0 +1,121 @@ +/* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ +import React, { FC } from 'react'; +import { expect } from 'chai'; +import { render } from '@testing-library/react'; +import { useComponentMap } from './useComponentMap'; +import { SitecoreProvider } from '../components/SitecoreProvider'; +import { Page } from '@sitecore-content-sdk/content/client'; +import { LayoutServicePageState } from '@sitecore-content-sdk/content/layout'; + +describe('useComponentMap', () => { + const TestComponent: FC = () =>
Test Component
; + const AnotherComponent: FC = () =>
Another Component
; + + const componentMap = new Map(); + componentMap.set('TestComponent', TestComponent); + componentMap.set('AnotherComponent', AnotherComponent); + + const mockPage: Page = { + layout: { + sitecore: { + context: { + pageEditing: false, + site: { name: 'TestSite' }, + language: 'en', + }, + route: { + name: 'test', + placeholders: {}, + itemId: 'testid', + }, + }, + }, + locale: 'en', + mode: { + name: LayoutServicePageState.Normal, + isNormal: true, + isPreview: false, + isEditing: false, + isDesignLibrary: false, + designLibrary: { + isVariantGeneration: false, + }, + }, + }; + + const apiStub = {} as any; + + it('should return the component map from context', () => { + let capturedComponentMap: any; + + const Consumer: FC = () => { + capturedComponentMap = useComponentMap(); + return
Consumer
; + }; + + render( + + + + ); + + expect(capturedComponentMap).to.equal(componentMap); + }); + + it('should allow retrieving components from the map', () => { + let retrievedComponent: any; + + const Consumer: FC = () => { + const map = useComponentMap(); + retrievedComponent = map.get('TestComponent'); + return
Consumer
; + }; + + render( + + + + ); + + expect(retrievedComponent).to.equal(TestComponent); + }); + + it('should return undefined for non-existent components', () => { + let retrievedComponent: any; + + const Consumer: FC = () => { + const map = useComponentMap(); + retrievedComponent = map.get('NonExistentComponent'); + return
Consumer
; + }; + + render( + + + + ); + + expect(retrievedComponent).to.be.undefined; + }); + + it('should work with multiple components in the map', () => { + let testComp: any; + let anotherComp: any; + + const Consumer: FC = () => { + const map = useComponentMap(); + testComp = map.get('TestComponent'); + anotherComp = map.get('AnotherComponent'); + return
Consumer
; + }; + + render( + + + + ); + + expect(testComp).to.equal(TestComponent); + expect(anotherComp).to.equal(AnotherComponent); + }); +}); diff --git a/packages/react/src/hooks/useComponentMap.ts b/packages/react/src/hooks/useComponentMap.ts new file mode 100644 index 0000000000..feaa3a6aef --- /dev/null +++ b/packages/react/src/hooks/useComponentMap.ts @@ -0,0 +1,25 @@ +'use client'; +import { useContext } from 'react'; +import { ComponentMapReactContext } from '../components/SitecoreProvider'; +import { ComponentMap } from '../components/sharedTypes'; + +/** + * Hook to access the component map from the SitecoreProvider context. + * This is a modern alternative to the withComponentMap HOC. + * + * @returns {ComponentMap} The component map from the nearest SitecoreProvider + * @public + * + * @example + * ```tsx + * function MyComponent() { + * const componentMap = useComponentMap(); + * const Component = componentMap.get('MyComponentName'); + * return Component ? : null; + * } + * ``` + */ +export function useComponentMap(): ComponentMap { + const componentMap = useContext(ComponentMapReactContext); + return componentMap; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 916070b820..693fb53324 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,4 @@ -export { +export { constants, enableDebug, ClientError, @@ -106,3 +106,18 @@ export { } from './components/DefaultEmptyFieldEditingComponents'; export { ClientEditingChromesUpdate } from './components/ClientEditingChromesUpdate'; export { SitePathService, SitePathServiceConfig } from '@sitecore-content-sdk/content/site'; + +// Optimized components - Modern React patterns with hooks and functional components +export { useComponentMap } from './hooks'; +export { SitecoreProviderOptimized } from './components/SitecoreProviderOptimized'; +export { FormOptimized, mockFormModuleOptimized } from './components/FormOptimized'; +export { DesignLibraryOptimized, __mockDependencies as __mockDependenciesOptimized } from './components/DesignLibrary/DesignLibraryOptimized'; +export { TextOptimized } from './components/TextOptimized'; +export { LinkOptimized } from './components/LinkOptimized'; +export { DateFieldOptimized } from './components/DateOptimized'; +export { FileOptimized } from './components/FileOptimized'; +export { RichTextOptimized } from './components/RichTextOptimized'; +export { + DefaultEmptyFieldEditingComponentTextOptimized, + DefaultEmptyFieldEditingComponentImageOptimized, +} from './components/DefaultEmptyFieldEditingComponentsOptimized'; From ecbd016c09bcb4c3c57e3e02f2fc405e28f76d85 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Fri, 6 Feb 2026 15:07:05 -0500 Subject: [PATCH 02/13] cleanup, rewrite Placeholder - initial implementation --- ...ltEmptyFieldEditingComponentsOptimized.tsx | 10 +- .../components/Placeholder/Placeholder.tsx | 168 ++++-------------- .../Placeholder/placeholder-utils.tsx | 80 +++++++++ .../useComponentMap.test.tsx | 0 .../{hooks => enhancers}/useComponentMap.ts | 0 .../react/src/enhancers/withPlaceholder.tsx | 99 +++++------ packages/react/src/hooks/index.ts | 1 - packages/react/src/index.ts | 10 +- 8 files changed, 166 insertions(+), 202 deletions(-) rename packages/react/src/{hooks => enhancers}/useComponentMap.test.tsx (100%) rename packages/react/src/{hooks => enhancers}/useComponentMap.ts (100%) delete mode 100644 packages/react/src/hooks/index.ts diff --git a/packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx b/packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx index 671a54a6b8..ba948c0ef5 100644 --- a/packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx +++ b/packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx @@ -15,14 +15,14 @@ interface EmptyFieldComponentProps { * @param {object} props - The props for the component. * @public */ -export function DefaultEmptyFieldEditingComponentTextOptimized(props: EmptyFieldComponentProps) { +export const DefaultEmptyFieldEditingComponentTextOptimized = (props: EmptyFieldComponentProps) => { const Tag = (props.tag || 'span') as React.ElementType; return ( [No text in field] ); -} +}; /** * The DefaultEmptyFieldEditingComponentImage component (unchanged - already uses JSX). @@ -31,7 +31,9 @@ export function DefaultEmptyFieldEditingComponentTextOptimized(props: EmptyField * @param {object} props - The props for the component. * @public */ -export function DefaultEmptyFieldEditingComponentImageOptimized(props: EmptyFieldComponentProps) { +export const DefaultEmptyFieldEditingComponentImageOptimized = ( + props: EmptyFieldComponentProps +) => { const inlineStyles = { minWidth: '48px', minHeight: '48px', @@ -50,4 +52,4 @@ export function DefaultEmptyFieldEditingComponentImageOptimized(props: EmptyFiel style={inlineStyles} /> ); -} +}; diff --git a/packages/react/src/components/Placeholder/Placeholder.tsx b/packages/react/src/components/Placeholder/Placeholder.tsx index 5578e80605..62d1ba7157 100644 --- a/packages/react/src/components/Placeholder/Placeholder.tsx +++ b/packages/react/src/components/Placeholder/Placeholder.tsx @@ -1,154 +1,47 @@ 'use client'; -import React from 'react'; +import React, { useEffect } from 'react'; import { PlaceholderProps } from './models'; import { withComponentMap } from '../../enhancers/withComponentMap'; import { PagesEditor } from '@sitecore-content-sdk/content/editing'; import { withSitecore } from '../../enhancers/withSitecore'; import { - getComponentForRendering, getPlaceholderRenderings, - getRenderedComponentProps, + getRenderedComponents, renderEmptyPlaceholder, } from './placeholder-utils'; -import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; -import { PlaceholderMetadata } from './PlaceholderMetadata'; -import ErrorBoundary, { ErrorComponent } from '../ErrorBoundary'; - -export class PlaceholderComponent extends React.Component { - isEmpty = false; - state: Readonly<{ error?: Error }>; - - constructor(props: PlaceholderProps) { - super(props); - this.state = {}; - } - - /** - * Renders the components for the placeholder based on the provided rendering data. - * @param {PlaceholderProps} props placeholder component props - * @param {ComponentRendering[]} placeholderRenderings renderings within placeholder - * @returns {React.ReactNode | React.ReactElement[]} rendered components - */ - static getRenderedComponents = ( - props: PlaceholderProps, - placeholderRenderings: ComponentRendering[] - ) => { - const { name, missingComponentComponent, hiddenRenderingComponent } = props; - - const transformedComponents = placeholderRenderings - .map((componentRendering: ComponentRendering, index: number) => { - const key = componentRendering.uid || `component-${index}`; - - const renderedProps = getRenderedComponentProps(props, componentRendering, key); - - const component = getComponentForRendering( - componentRendering, - name, - props.componentMap, - hiddenRenderingComponent, - missingComponentComponent - ); - - let rendered = React.createElement<{ [attr: string]: unknown }>( - component.component as React.ComponentType, - props.modifyComponentProps ? props.modifyComponentProps(renderedProps) : renderedProps - ); - - if (!component.isEmpty) { - const errorBoundaryKey = rendered.type + '-' + index; - - const disableSuspense = props.disableSuspense || false; - rendered = ( - - {rendered} - - ); - } - - // if in edit mode then emit shallow chromes for hydration in Pages - if (props.page.mode.isEditing) { - return ( - - {rendered} - - ); - } - - return rendered; - }) - .filter((element) => element); // remove nulls - - if (props.page.mode.isEditing) { - return [ - - {transformedComponents} - , - ]; - } - - return transformedComponents; - }; - - componentDidMount() { - if (this.isEmpty && PagesEditor.isActive()) { +import ErrorBoundary from '../ErrorBoundary'; + +const PlaceholderComponent = (props: PlaceholderProps) => { + const renderingData = props.rendering; + const placeholderRenderings = getPlaceholderRenderings( + renderingData, + props.name, + props.page.mode.isEditing + ); + const isEmpty = !placeholderRenderings.length; + + // componentDidMount equivalent: Reset chromes when placeholder is empty + useEffect(() => { + if (isEmpty && PagesEditor.isActive()) { PagesEditor.resetChromes(); } - } + }, [isEmpty]); // Empty array = runs once on mount - componentDidCatch(error: Error) { - this.setState({ error }); - } - - render() { - const childProps: PlaceholderProps = { ...this.props }; + const renderChildren = () => { + const childProps: PlaceholderProps = { ...props }; delete childProps.componentMap; - if (this.state.error) { - if (childProps.errorComponent) { - return ; - } - - return ( - - ); - } + const components = getRenderedComponents(props, placeholderRenderings); - const renderingData = childProps.rendering; + if (isEmpty) { + const rendered = props.renderEmpty ? props.renderEmpty(components) : components; - const placeholderRenderings = getPlaceholderRenderings( - renderingData, - this.props.name, - this.props.page.mode.isEditing - ); - - this.isEmpty = !placeholderRenderings.length; - - const components = PlaceholderComponent.getRenderedComponents( - this.props, - placeholderRenderings - ); - - if (this.isEmpty) { - const rendered = this.props.renderEmpty ? this.props.renderEmpty(components) : components; - - return this.props.page.mode.isEditing ? renderEmptyPlaceholder(rendered) : rendered; - } else if (this.props.render) { - return this.props.render(components, placeholderRenderings, childProps); - } else if (this.props.renderEach) { - const renderEach = this.props.renderEach; + return props.page.mode.isEditing ? renderEmptyPlaceholder(rendered) : rendered; + } else if (props.render) { + return props.render(components, placeholderRenderings, childProps); + } else if (props.renderEach) { + const renderEach = props.renderEach; return components.map((component, index) => { if (component && component.props && component.props.type === 'text/sitecore') { @@ -160,8 +53,11 @@ export class PlaceholderComponent extends React.Component { } else { return components; } - } -} + }; + + // Using error boundary for errors that may happen within Placeholder itself + return {renderChildren()}; +}; const PlaceholderWithComponentMap = withComponentMap(PlaceholderComponent); diff --git a/packages/react/src/components/Placeholder/placeholder-utils.tsx b/packages/react/src/components/Placeholder/placeholder-utils.tsx index 9c454a9dc7..16df624ae6 100644 --- a/packages/react/src/components/Placeholder/placeholder-utils.tsx +++ b/packages/react/src/components/Placeholder/placeholder-utils.tsx @@ -26,6 +26,8 @@ import { PlaceholderProps, RenderedProps, } from './models'; +import { PlaceholderMetadata } from './PlaceholderMetadata'; +import ErrorBoundary from '../ErrorBoundary'; /** * Get the renderings for the specified placeholder from the rendering data. @@ -282,3 +284,81 @@ export const getComponentForRendering = ( isEmpty: false, }; }; + +/** + * Renders the components for the placeholder based on the provided rendering data. + * @param {PlaceholderProps} props placeholder component props + * @param {ComponentRendering[]} placeholderRenderings renderings within placeholder + * @returns {React.ReactNode | React.ReactElement[]} rendered components + */ +export const getRenderedComponents = ( + props: PlaceholderProps, + placeholderRenderings: ComponentRendering[] +) => { + const { name, missingComponentComponent, hiddenRenderingComponent } = props; + + const transformedComponents = placeholderRenderings + .map((componentRendering: ComponentRendering, index: number) => { + const key = componentRendering.uid || `component-${index}`; + + const renderedProps = getRenderedComponentProps(props, componentRendering, key); + + const component = getComponentForRendering( + componentRendering, + name, + props.componentMap, + hiddenRenderingComponent, + missingComponentComponent + ); + + let rendered = React.createElement<{ [attr: string]: unknown }>( + component.component as React.ComponentType, + props.modifyComponentProps ? props.modifyComponentProps(renderedProps) : renderedProps + ); + + if (!component.isEmpty) { + const errorBoundaryKey = rendered.type + '-' + index; + + const disableSuspense = props.disableSuspense || false; + rendered = ( + + {rendered} + + ); + } + + // if in edit mode then emit shallow chromes for hydration in Pages + if (props.page.mode.isEditing) { + return ( + + {rendered} + + ); + } + + return rendered; + }) + .filter((element) => element); // remove nulls + + if (props.page.mode.isEditing) { + return [ + + {transformedComponents} + , + ]; + } + + return transformedComponents; +}; diff --git a/packages/react/src/hooks/useComponentMap.test.tsx b/packages/react/src/enhancers/useComponentMap.test.tsx similarity index 100% rename from packages/react/src/hooks/useComponentMap.test.tsx rename to packages/react/src/enhancers/useComponentMap.test.tsx diff --git a/packages/react/src/hooks/useComponentMap.ts b/packages/react/src/enhancers/useComponentMap.ts similarity index 100% rename from packages/react/src/hooks/useComponentMap.ts rename to packages/react/src/enhancers/useComponentMap.ts diff --git a/packages/react/src/enhancers/withPlaceholder.tsx b/packages/react/src/enhancers/withPlaceholder.tsx index 1dc4e8ff6d..73af2f7865 100644 --- a/packages/react/src/enhancers/withPlaceholder.tsx +++ b/packages/react/src/enhancers/withPlaceholder.tsx @@ -3,11 +3,11 @@ import { ComponentRendering, RouteData } from '@sitecore-content-sdk/content/lay import { withComponentMap } from './withComponentMap'; import { withSitecore } from './withSitecore'; import { - PlaceholderComponent, PlaceholderProps, getPlaceholderRenderings, + getRenderedComponents, } from '../components/Placeholder'; -import { ErrorComponent } from '../components/ErrorBoundary'; +import ErrorBoundary from '../components/ErrorBoundary'; export interface WithPlaceholderOptions { /** @@ -36,6 +36,7 @@ export interface PlaceholderToPropMapping { prop: string; } +// TODO: this HOC and Placeholder are kinda doing the same thing. Could the be combined? export type WithPlaceholderSpec = | (string | PlaceholderToPropMapping) | (string | PlaceholderToPropMapping)[]; @@ -55,68 +56,56 @@ export function withPlaceholder( | React.ComponentClass | React.FunctionComponent ) => { - class WithPlaceholder extends PlaceholderComponent { - constructor(props: PlaceholderProps) { - super(props); + const WithPlaceholder = (props: PlaceholderProps) => { + let childProps: PlaceholderProps = { ...props }; + + delete childProps.componentMap; + + if (options && options.propsTransformer) { + childProps = options.propsTransformer(childProps); } - render() { - let childProps: PlaceholderProps = { ...this.props }; + const renderingData = + options && options.resolvePlaceholderDataFromProps + ? options.resolvePlaceholderDataFromProps(childProps) + : childProps.rendering; - delete childProps.componentMap; + const definitelyArrayPlacholders = !Array.isArray(placeholders) + ? [placeholders] + : placeholders; - if (options && options.propsTransformer) { - childProps = options.propsTransformer(childProps); - } + definitelyArrayPlacholders.forEach((placeholder: string | PlaceholderToPropMapping) => { + let placeholderData: ComponentRendering[]; - if (this.state.error) { - if (childProps.errorComponent) { - return ; + if (typeof placeholder !== 'string' && placeholder.placeholder && placeholder.prop) { + placeholderData = getPlaceholderRenderings( + renderingData, + placeholder.placeholder, + childProps.page.mode.isEditing + ); + if (placeholderData) { + (childProps as PlaceholderProps & Record)[placeholder.prop] = + getRenderedComponents(props, placeholderData); } - - return ( - + } else { + placeholderData = getPlaceholderRenderings( + renderingData, + placeholder as string, + childProps.page.mode.isEditing ); - } - - const renderingData = - options && options.resolvePlaceholderDataFromProps - ? options.resolvePlaceholderDataFromProps(childProps) - : childProps.rendering; - - const definitelyArrayPlacholders = !Array.isArray(placeholders) - ? [placeholders] - : placeholders; - - definitelyArrayPlacholders.forEach((placeholder: string | PlaceholderToPropMapping) => { - let placeholderData: ComponentRendering[]; - - if (typeof placeholder !== 'string' && placeholder.placeholder && placeholder.prop) { - placeholderData = getPlaceholderRenderings( - renderingData, - placeholder.placeholder, - childProps.page.mode.isEditing - ); - if (placeholderData) { - (childProps as PlaceholderProps & Record)[placeholder.prop] = - PlaceholderComponent.getRenderedComponents(this.props, placeholderData); - } - } else { - placeholderData = getPlaceholderRenderings( - renderingData, - placeholder as string, - childProps.page.mode.isEditing - ); - if (placeholderData) { - (childProps as PlaceholderProps & Record)[placeholder as string] = - PlaceholderComponent.getRenderedComponents(this.props, placeholderData); - } + if (placeholderData) { + (childProps as PlaceholderProps & Record)[placeholder as string] = + getRenderedComponents(props, placeholderData); } - }); + } + }); - return ; - } - } + return ( + + + + ); + }; return withSitecore()(withComponentMap(WithPlaceholder)); }; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts deleted file mode 100644 index 7374a42913..0000000000 --- a/packages/react/src/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { useComponentMap } from './useComponentMap'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 693fb53324..ba94c97f73 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,4 @@ -export { +export { constants, enableDebug, ClientError, @@ -99,6 +99,7 @@ export { withPlaceholder } from './enhancers/withPlaceholder'; export { withDatasourceCheck } from './enhancers/withDatasourceCheck'; export { withFieldMetadata } from './enhancers/withFieldMetadata'; export { withEmptyFieldEditingComponent } from './enhancers/withEmptyFieldEditingComponent'; +export { useComponentMap } from './enhancers/useComponentMap'; export { EditingScripts } from './components/EditingScripts'; export { DefaultEmptyFieldEditingComponentText, @@ -106,12 +107,9 @@ export { } from './components/DefaultEmptyFieldEditingComponents'; export { ClientEditingChromesUpdate } from './components/ClientEditingChromesUpdate'; export { SitePathService, SitePathServiceConfig } from '@sitecore-content-sdk/content/site'; - -// Optimized components - Modern React patterns with hooks and functional components -export { useComponentMap } from './hooks'; export { SitecoreProviderOptimized } from './components/SitecoreProviderOptimized'; -export { FormOptimized, mockFormModuleOptimized } from './components/FormOptimized'; -export { DesignLibraryOptimized, __mockDependencies as __mockDependenciesOptimized } from './components/DesignLibrary/DesignLibraryOptimized'; +export { FormOptimized } from './components/FormOptimized'; +export { DesignLibraryOptimized } from './components/DesignLibrary/DesignLibraryOptimized'; export { TextOptimized } from './components/TextOptimized'; export { LinkOptimized } from './components/LinkOptimized'; export { DateFieldOptimized } from './components/DateOptimized'; From 1222c4fc8f2d7790c88f8bf1e8edd31b270570fa Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Fri, 6 Feb 2026 15:09:55 -0500 Subject: [PATCH 03/13] withSitecore rewrite --- packages/react/src/enhancers/withSitecore.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/react/src/enhancers/withSitecore.tsx b/packages/react/src/enhancers/withSitecore.tsx index 99a5981176..117769b657 100644 --- a/packages/react/src/enhancers/withSitecore.tsx +++ b/packages/react/src/enhancers/withSitecore.tsx @@ -1,5 +1,5 @@ 'use client'; -import React from 'react'; +import React, { useContext } from 'react'; import { EnhancedOmit } from '@sitecore-content-sdk/core/tools'; import { SitecoreProviderReactContext, @@ -57,17 +57,14 @@ export function withSitecore(options?: WithSitecoreOptions) { Component: React.ComponentType ) { return function WithSitecoreProvider(props: WithSitecoreHocProps) { + const scContext = useContext(SitecoreProviderReactContext); return ( - - {(value) => ( - - )} - + ); }; }; From 6ad8b5e79a2b70087eb2ad9695be3513a3f5ee5e Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Fri, 6 Feb 2026 15:15:01 -0500 Subject: [PATCH 04/13] withComponentMap rewrite --- .../src/enhancers/useComponentMap.test.tsx | 121 ------------------ .../react/src/enhancers/useComponentMap.ts | 25 ---- .../react/src/enhancers/withComponentMap.tsx | 15 ++- packages/react/src/index.ts | 2 +- 4 files changed, 14 insertions(+), 149 deletions(-) delete mode 100644 packages/react/src/enhancers/useComponentMap.test.tsx delete mode 100644 packages/react/src/enhancers/useComponentMap.ts diff --git a/packages/react/src/enhancers/useComponentMap.test.tsx b/packages/react/src/enhancers/useComponentMap.test.tsx deleted file mode 100644 index 5d052e5e54..0000000000 --- a/packages/react/src/enhancers/useComponentMap.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ -import React, { FC } from 'react'; -import { expect } from 'chai'; -import { render } from '@testing-library/react'; -import { useComponentMap } from './useComponentMap'; -import { SitecoreProvider } from '../components/SitecoreProvider'; -import { Page } from '@sitecore-content-sdk/content/client'; -import { LayoutServicePageState } from '@sitecore-content-sdk/content/layout'; - -describe('useComponentMap', () => { - const TestComponent: FC = () =>
Test Component
; - const AnotherComponent: FC = () =>
Another Component
; - - const componentMap = new Map(); - componentMap.set('TestComponent', TestComponent); - componentMap.set('AnotherComponent', AnotherComponent); - - const mockPage: Page = { - layout: { - sitecore: { - context: { - pageEditing: false, - site: { name: 'TestSite' }, - language: 'en', - }, - route: { - name: 'test', - placeholders: {}, - itemId: 'testid', - }, - }, - }, - locale: 'en', - mode: { - name: LayoutServicePageState.Normal, - isNormal: true, - isPreview: false, - isEditing: false, - isDesignLibrary: false, - designLibrary: { - isVariantGeneration: false, - }, - }, - }; - - const apiStub = {} as any; - - it('should return the component map from context', () => { - let capturedComponentMap: any; - - const Consumer: FC = () => { - capturedComponentMap = useComponentMap(); - return
Consumer
; - }; - - render( - - - - ); - - expect(capturedComponentMap).to.equal(componentMap); - }); - - it('should allow retrieving components from the map', () => { - let retrievedComponent: any; - - const Consumer: FC = () => { - const map = useComponentMap(); - retrievedComponent = map.get('TestComponent'); - return
Consumer
; - }; - - render( - - - - ); - - expect(retrievedComponent).to.equal(TestComponent); - }); - - it('should return undefined for non-existent components', () => { - let retrievedComponent: any; - - const Consumer: FC = () => { - const map = useComponentMap(); - retrievedComponent = map.get('NonExistentComponent'); - return
Consumer
; - }; - - render( - - - - ); - - expect(retrievedComponent).to.be.undefined; - }); - - it('should work with multiple components in the map', () => { - let testComp: any; - let anotherComp: any; - - const Consumer: FC = () => { - const map = useComponentMap(); - testComp = map.get('TestComponent'); - anotherComp = map.get('AnotherComponent'); - return
Consumer
; - }; - - render( - - - - ); - - expect(testComp).to.equal(TestComponent); - expect(anotherComp).to.equal(AnotherComponent); - }); -}); diff --git a/packages/react/src/enhancers/useComponentMap.ts b/packages/react/src/enhancers/useComponentMap.ts deleted file mode 100644 index feaa3a6aef..0000000000 --- a/packages/react/src/enhancers/useComponentMap.ts +++ /dev/null @@ -1,25 +0,0 @@ -'use client'; -import { useContext } from 'react'; -import { ComponentMapReactContext } from '../components/SitecoreProvider'; -import { ComponentMap } from '../components/sharedTypes'; - -/** - * Hook to access the component map from the SitecoreProvider context. - * This is a modern alternative to the withComponentMap HOC. - * - * @returns {ComponentMap} The component map from the nearest SitecoreProvider - * @public - * - * @example - * ```tsx - * function MyComponent() { - * const componentMap = useComponentMap(); - * const Component = componentMap.get('MyComponentName'); - * return Component ? : null; - * } - * ``` - */ -export function useComponentMap(): ComponentMap { - const componentMap = useContext(ComponentMapReactContext); - return componentMap; -} diff --git a/packages/react/src/enhancers/withComponentMap.tsx b/packages/react/src/enhancers/withComponentMap.tsx index e466ca1330..5f299326fa 100644 --- a/packages/react/src/enhancers/withComponentMap.tsx +++ b/packages/react/src/enhancers/withComponentMap.tsx @@ -1,3 +1,4 @@ +'use client'; import React, { JSX } from 'react'; import { ComponentMapReactContext } from '../components/SitecoreProvider'; import { useContext } from 'react'; @@ -18,9 +19,9 @@ export function withComponentMap( * @returns {JSX.Element} - the rendered component */ function WithComponentMap(props: T): JSX.Element { - const context = useContext(ComponentMapReactContext); + const contextComponentMap = useComponentMap(); - return ; + return ; } WithComponentMap.displayName = `withComponentMap(${ @@ -29,3 +30,13 @@ export function withComponentMap( return WithComponentMap; } + +/** + * Hook to access the component map in client context. + * @returns {ComponentMap} The component map from the SitecoreProvider + * @public + */ +export function useComponentMap(): ComponentMap { + const componentMap = useContext(ComponentMapReactContext); + return componentMap; +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index ba94c97f73..be1002eb13 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -99,7 +99,7 @@ export { withPlaceholder } from './enhancers/withPlaceholder'; export { withDatasourceCheck } from './enhancers/withDatasourceCheck'; export { withFieldMetadata } from './enhancers/withFieldMetadata'; export { withEmptyFieldEditingComponent } from './enhancers/withEmptyFieldEditingComponent'; -export { useComponentMap } from './enhancers/useComponentMap'; +export { withComponentMap, useComponentMap } from './enhancers/withComponentMap'; export { EditingScripts } from './components/EditingScripts'; export { DefaultEmptyFieldEditingComponentText, From aa74befaa211546981e9cf849fbfe0879c3a8307 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Mon, 9 Feb 2026 14:28:37 -0500 Subject: [PATCH 05/13] small refactors in components --- packages/react/src/components/FileOptimized.tsx | 8 ++++---- .../src/components/SitecoreProviderOptimized.test.tsx | 4 ++-- .../react/src/components/SitecoreProviderOptimized.tsx | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/react/src/components/FileOptimized.tsx b/packages/react/src/components/FileOptimized.tsx index 41fabaea8f..0455f40b07 100644 --- a/packages/react/src/components/FileOptimized.tsx +++ b/packages/react/src/components/FileOptimized.tsx @@ -26,14 +26,14 @@ export interface FileProps { /** * The File component - Optimized version using JSX instead of React.createElement. - * + * * This is a modernized version that uses JSX syntax for better readability and * maintainability, replacing React.createElement calls with native JSX. - * + * * @param {FileProps} props component props * @public */ -export function FileOptimized({ field, children, ...otherProps }: FileProps) { +export const FileOptimized = ({ field, children, ...otherProps }: FileProps) => { const dynamicField: FileField | FileFieldValue = field; if (isFieldValueEmpty(dynamicField)) { @@ -59,4 +59,4 @@ export function FileOptimized({ field, children, ...otherProps }: FileProps) { {children} ); -} +}; diff --git a/packages/react/src/components/SitecoreProviderOptimized.test.tsx b/packages/react/src/components/SitecoreProviderOptimized.test.tsx index 9de99f99db..dad4a7a60d 100644 --- a/packages/react/src/components/SitecoreProviderOptimized.test.tsx +++ b/packages/react/src/components/SitecoreProviderOptimized.test.tsx @@ -1,5 +1,5 @@ /* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ -import React, { FC } from 'react'; +import React from 'react'; import { expect } from 'chai'; import { Page } from '@sitecore-content-sdk/content/client'; import { SitecoreProviderOptimized } from './SitecoreProviderOptimized'; @@ -10,7 +10,7 @@ import { render } from '@testing-library/react'; describe('SitecoreProviderOptimized', () => { let nestedContext = {}; - const NestedComponent: FC = () => { + const NestedComponent = () => { const { page } = useSitecore(); nestedContext = page; return Page mode is {page.mode.name}; diff --git a/packages/react/src/components/SitecoreProviderOptimized.tsx b/packages/react/src/components/SitecoreProviderOptimized.tsx index 138ff709e2..d7a52459b6 100644 --- a/packages/react/src/components/SitecoreProviderOptimized.tsx +++ b/packages/react/src/components/SitecoreProviderOptimized.tsx @@ -36,14 +36,14 @@ export interface SitecoreProviderProps { /** * The SitecoreProvider component - Optimized functional component version. - * + * * This is a modernized version of the SitecoreProvider that uses React hooks * instead of class component lifecycle methods. It provides the same functionality * with better React 19+ optimization support. - * + * * @public */ -export function SitecoreProviderOptimized(props: SitecoreProviderProps) { +export const SitecoreProviderOptimized = (props: SitecoreProviderProps) => { const { api: propsApi, page: propsPage, componentMap, loadImportMap, children } = props; // Apply default edgeUrl if any Edge ID is present but no edgeUrl @@ -96,6 +96,6 @@ export function SitecoreProviderOptimized(props: SitecoreProviderProps) { ); -} +}; SitecoreProviderOptimized.displayName = 'SitecoreProviderOptimized'; From 3ba98ba9ea801ef6275c26382c6befee64ef6a1e Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Wed, 11 Feb 2026 14:23:48 -0500 Subject: [PATCH 06/13] rewrite for withPlaceholder --- .../components/Placeholder/Placeholder.tsx | 95 +++++++++++++++++-- .../react/src/components/Placeholder/index.ts | 2 +- .../Placeholder/placeholder-utils.tsx | 82 +--------------- .../src/enhancers/withPlaceholder.test.tsx | 26 ++--- .../react/src/enhancers/withPlaceholder.tsx | 38 +++----- 5 files changed, 116 insertions(+), 127 deletions(-) diff --git a/packages/react/src/components/Placeholder/Placeholder.tsx b/packages/react/src/components/Placeholder/Placeholder.tsx index 62d1ba7157..4ffe574468 100644 --- a/packages/react/src/components/Placeholder/Placeholder.tsx +++ b/packages/react/src/components/Placeholder/Placeholder.tsx @@ -5,11 +5,14 @@ import { withComponentMap } from '../../enhancers/withComponentMap'; import { PagesEditor } from '@sitecore-content-sdk/content/editing'; import { withSitecore } from '../../enhancers/withSitecore'; import { + getComponentForRendering, getPlaceholderRenderings, - getRenderedComponents, + getRenderedComponentProps, renderEmptyPlaceholder, } from './placeholder-utils'; import ErrorBoundary from '../ErrorBoundary'; +import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; +import { PlaceholderMetadata } from './PlaceholderMetadata'; const PlaceholderComponent = (props: PlaceholderProps) => { const renderingData = props.rendering; @@ -27,9 +30,9 @@ const PlaceholderComponent = (props: PlaceholderProps) => { } }, [isEmpty]); // Empty array = runs once on mount - const renderChildren = () => { - const childProps: PlaceholderProps = { ...props }; - + const renderPlhChildren = () => { + const childProps = { ...props }; + // TODO: cleanup more props delete childProps.componentMap; const components = getRenderedComponents(props, placeholderRenderings); @@ -56,13 +59,91 @@ const PlaceholderComponent = (props: PlaceholderProps) => { }; // Using error boundary for errors that may happen within Placeholder itself - return {renderChildren()}; + return {renderPlhChildren()}; }; -const PlaceholderWithComponentMap = withComponentMap(PlaceholderComponent); +/** + * Renders the components for the placeholder based on the provided rendering data. + * @param {PlaceholderProps} props placeholder component props + * @param {ComponentRendering[]} placeholderRenderings renderings within placeholder + * @returns {React.ReactNode | React.ReactElement[]} rendered components + */ +export const getRenderedComponents = ( + props: PlaceholderProps, + placeholderRenderings: ComponentRendering[] +) => { + const { name, missingComponentComponent, hiddenRenderingComponent } = props; + + const transformedComponents = placeholderRenderings + .map((componentRendering: ComponentRendering, index: number) => { + const component = getComponentForRendering( + componentRendering, + name, + props.componentMap, + hiddenRenderingComponent, + missingComponentComponent + ); + const key = componentRendering.uid || `component-${index}`; + + const renderedProps = getRenderedComponentProps(props, componentRendering, key); + const finalRenderedProps = props.modifyComponentProps + ? props.modifyComponentProps(renderedProps) + : renderedProps; + + let rendered = React.createElement<{ [attr: string]: unknown }>( + component.component as React.ComponentType, + finalRenderedProps + ); + + if (!component.isEmpty) { + const errorBoundaryKey = rendered.type + '-' + index; + + const disableSuspense = props.disableSuspense || false; + rendered = ( + + {rendered} + + ); + } + + // if in edit mode then emit shallow chromes for hydration in Pages + if (props.page.mode.isEditing) { + return ( + + {rendered} + + ); + } + + return rendered; + }) + .filter((element) => element); // remove nulls + + if (props.page.mode.isEditing) { + return [ + + {transformedComponents} + , + ]; + } + + return transformedComponents; +}; /** * The Placeholder component. * @public */ -export const Placeholder = withSitecore()(PlaceholderWithComponentMap); +export const Placeholder = withSitecore()(withComponentMap(PlaceholderComponent)); diff --git a/packages/react/src/components/Placeholder/index.ts b/packages/react/src/components/Placeholder/index.ts index be2656c94a..8558b4c197 100644 --- a/packages/react/src/components/Placeholder/index.ts +++ b/packages/react/src/components/Placeholder/index.ts @@ -1,4 +1,4 @@ -export { Placeholder, PlaceholderComponent } from './Placeholder'; +export { Placeholder, getRenderedComponents } from './Placeholder'; export { PlaceholderMetadata } from './PlaceholderMetadata'; export { PlaceholderProps, AppPlaceholderProps } from './models'; export { AppPlaceholder } from './AppPlaceholder'; diff --git a/packages/react/src/components/Placeholder/placeholder-utils.tsx b/packages/react/src/components/Placeholder/placeholder-utils.tsx index 16df624ae6..50730c78b1 100644 --- a/packages/react/src/components/Placeholder/placeholder-utils.tsx +++ b/packages/react/src/components/Placeholder/placeholder-utils.tsx @@ -26,8 +26,6 @@ import { PlaceholderProps, RenderedProps, } from './models'; -import { PlaceholderMetadata } from './PlaceholderMetadata'; -import ErrorBoundary from '../ErrorBoundary'; /** * Get the renderings for the specified placeholder from the rendering data. @@ -140,7 +138,7 @@ export const getRenderedComponentProps = ( }; /** - * Merge placeholder and component field and params content props. + * Merge specific placeholder props with component field and params content props. * @param {BasePlaceholderProps} placeholderProps placeholder props * @param {ComponentRendering} componentRendering component rendering * @returns {ComponentProps} merged props @@ -284,81 +282,3 @@ export const getComponentForRendering = ( isEmpty: false, }; }; - -/** - * Renders the components for the placeholder based on the provided rendering data. - * @param {PlaceholderProps} props placeholder component props - * @param {ComponentRendering[]} placeholderRenderings renderings within placeholder - * @returns {React.ReactNode | React.ReactElement[]} rendered components - */ -export const getRenderedComponents = ( - props: PlaceholderProps, - placeholderRenderings: ComponentRendering[] -) => { - const { name, missingComponentComponent, hiddenRenderingComponent } = props; - - const transformedComponents = placeholderRenderings - .map((componentRendering: ComponentRendering, index: number) => { - const key = componentRendering.uid || `component-${index}`; - - const renderedProps = getRenderedComponentProps(props, componentRendering, key); - - const component = getComponentForRendering( - componentRendering, - name, - props.componentMap, - hiddenRenderingComponent, - missingComponentComponent - ); - - let rendered = React.createElement<{ [attr: string]: unknown }>( - component.component as React.ComponentType, - props.modifyComponentProps ? props.modifyComponentProps(renderedProps) : renderedProps - ); - - if (!component.isEmpty) { - const errorBoundaryKey = rendered.type + '-' + index; - - const disableSuspense = props.disableSuspense || false; - rendered = ( - - {rendered} - - ); - } - - // if in edit mode then emit shallow chromes for hydration in Pages - if (props.page.mode.isEditing) { - return ( - - {rendered} - - ); - } - - return rendered; - }) - .filter((element) => element); // remove nulls - - if (props.page.mode.isEditing) { - return [ - - {transformedComponents} - , - ]; - } - - return transformedComponents; -}; diff --git a/packages/react/src/enhancers/withPlaceholder.test.tsx b/packages/react/src/enhancers/withPlaceholder.test.tsx index 9473b5bf5d..57f3e1fa95 100644 --- a/packages/react/src/enhancers/withPlaceholder.test.tsx +++ b/packages/react/src/enhancers/withPlaceholder.test.tsx @@ -114,7 +114,7 @@ describe('withPlaceholder HOC', () => { name: phKey, rendering: null as unknown as ComponentRendering, }; - const Element = withPlaceholder(phKey)(ErrorComponent); + const Element = withPlaceholder({ phKey })(ErrorComponent); const renderedComponent = render( @@ -132,7 +132,7 @@ describe('withPlaceholder HOC', () => { rendering: null as unknown as ComponentRendering, errorComponent: ErrorMessageComponent, }; - const Element = withPlaceholder(phKey)(ErrorComponent); + const Element = withPlaceholder({ phKey })(ErrorComponent); const renderedComponent = render( @@ -150,7 +150,7 @@ describe('withPlaceholder HOC', () => { name: phKey, rendering: component, }; - const Element = withPlaceholder(phKey)(Home); + const Element = withPlaceholder({ phKey })(Home); const renderedComponent = render( @@ -180,7 +180,7 @@ describe('withPlaceholder HOC', () => { errorComponent: ErrorMessageComponent, componentLoadingMessage: 'Custom loading message...', }; - const Element = withPlaceholder(phKey)(Home); + const Element = withPlaceholder({ phKey })(Home); const renderedComponent = render( @@ -209,7 +209,7 @@ describe('withPlaceholder HOC', () => { name: phKey, rendering: component, }; - const Element = withPlaceholder(phKey)(Home); + const Element = withPlaceholder({ phKey })(Home); const renderedComponent = render( @@ -225,7 +225,7 @@ describe('withPlaceholder HOC', () => { dataSet.data.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; const phKeyAndProp = { - placeholder: 'page-header', + phKey: 'page-header', prop: 'subProp', }; const props: EnhancedOmit = { @@ -249,7 +249,7 @@ describe('withPlaceholder HOC', () => { dataSet.data.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; const phKeyAndProp = { - placeholder: 'page-header', + phKey: 'page-header', prop: 'subProp', }; const phOptions = { @@ -312,7 +312,7 @@ describe('withPlaceholder HOC', () => { name: phKey, rendering: component, }; - const Element = withPlaceholder(phKey)(Home); + const Element = withPlaceholder({ phKey })(Home); const renderedComponent = render( @@ -341,7 +341,7 @@ describe('withPlaceholder HOC', () => { const component = layoutData.sitecore.route; const phKey = 'main'; const phKeyAndProp = { - placeholder: phKey, + phKey, prop: 'subProp', }; const props: EnhancedOmit = { @@ -381,7 +381,7 @@ describe('withPlaceholder HOC', () => { name: phKey, rendering: component, }; - const Element = withPlaceholder(phKey)(Home); + const Element = withPlaceholder({ phKey })(Home); const renderedComponent = render( @@ -405,7 +405,7 @@ describe('withPlaceholder HOC', () => { name: phKey, rendering: component, }; - const Element = withPlaceholder(phKey)(Home); + const Element = withPlaceholder({ phKey })(Home); const renderedComponent = render( @@ -433,7 +433,7 @@ describe('withPlaceholder HOC', () => { name: phKey, rendering: component, }; - const Element = withPlaceholder(phKey)(Home); + const Element = withPlaceholder({ phKey })(Home); const renderedComponent = render( @@ -466,7 +466,7 @@ describe('withPlaceholder HOC', () => { name: phKey, rendering: component, }; - const Element = withPlaceholder(phKey)(Home); + const Element = withPlaceholder({ phKey })(Home); const renderedComponent = render( diff --git a/packages/react/src/enhancers/withPlaceholder.tsx b/packages/react/src/enhancers/withPlaceholder.tsx index 73af2f7865..645d033652 100644 --- a/packages/react/src/enhancers/withPlaceholder.tsx +++ b/packages/react/src/enhancers/withPlaceholder.tsx @@ -29,17 +29,15 @@ export interface PlaceholderToPropMapping { /** * The name of the placeholder this component will expose */ - placeholder: string; + phKey: string; /** * The name of the prop on your wrapped component that you would like the placeholder data injected on */ - prop: string; + prop?: string; } // TODO: this HOC and Placeholder are kinda doing the same thing. Could the be combined? -export type WithPlaceholderSpec = - | (string | PlaceholderToPropMapping) - | (string | PlaceholderToPropMapping)[]; +export type WithPlaceholderSpec = PlaceholderToPropMapping | PlaceholderToPropMapping[]; /** * HOC to provide client-side placeholder functionality to a component. @@ -74,29 +72,19 @@ export function withPlaceholder( ? [placeholders] : placeholders; - definitelyArrayPlacholders.forEach((placeholder: string | PlaceholderToPropMapping) => { + definitelyArrayPlacholders.forEach((placeholder) => { let placeholderData: ComponentRendering[]; - if (typeof placeholder !== 'string' && placeholder.placeholder && placeholder.prop) { - placeholderData = getPlaceholderRenderings( - renderingData, - placeholder.placeholder, - childProps.page.mode.isEditing + placeholderData = getPlaceholderRenderings( + renderingData, + placeholder.phKey, + childProps.page.mode.isEditing + ); + if (placeholderData) { + childProps[placeholder.prop || placeholder.phKey] = getRenderedComponents( + props, + placeholderData ); - if (placeholderData) { - (childProps as PlaceholderProps & Record)[placeholder.prop] = - getRenderedComponents(props, placeholderData); - } - } else { - placeholderData = getPlaceholderRenderings( - renderingData, - placeholder as string, - childProps.page.mode.isEditing - ); - if (placeholderData) { - (childProps as PlaceholderProps & Record)[placeholder as string] = - getRenderedComponents(props, placeholderData); - } } }); From a8e2410ed7a1b887a2aaa8184e70d6412ed2f341 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Wed, 11 Feb 2026 16:02:45 -0500 Subject: [PATCH 07/13] Apply draft changes to components --- packages/react/src/components/Date.tsx | 5 +- .../src/components/DateOptimized.test.tsx | 155 ------ .../react/src/components/DateOptimized.tsx | 66 --- .../DefaultEmptyFieldEditingComponents.tsx | 9 +- ...ltEmptyFieldEditingComponentsOptimized.tsx | 55 -- .../DesignLibrary/DesignLibraryOptimized.tsx | 179 ------- packages/react/src/components/File.tsx | 10 +- .../src/components/FileOptimized.test.tsx | 102 ---- .../react/src/components/FileOptimized.tsx | 62 --- packages/react/src/components/Form.tsx | 9 +- .../src/components/FormOptimized.test.tsx | 220 -------- .../react/src/components/FormOptimized.tsx | 116 ---- packages/react/src/components/Link.tsx | 14 +- .../src/components/LinkOptimized.test.tsx | 240 --------- .../react/src/components/LinkOptimized.tsx | 111 ---- packages/react/src/components/RichText.tsx | 5 +- .../src/components/RichTextOptimized.test.tsx | 168 ------ .../src/components/RichTextOptimized.tsx | 69 --- .../react/src/components/SitecoreProvider.tsx | 93 ++-- .../SitecoreProviderOptimized.test.tsx | 117 ----- .../components/SitecoreProviderOptimized.tsx | 101 ---- packages/react/src/components/Text.tsx | 14 +- .../src/components/TextOptimized.test.tsx | 243 --------- .../react/src/components/TextOptimized.tsx | 108 ---- .../src/enhancers/withAppPlaceholder.test.tsx | 3 +- .../src/enhancers/withAppPlaceholder.tsx | 11 +- .../src/enhancers/withClientPlaceholder.tsx | 7 + .../src/enhancers/withDatasourceCheck.tsx | 5 +- .../react/src/enhancers/withLoadImportMap.tsx | 3 +- .../src/enhancers/withPlaceholder.test.tsx | 494 ------------------ .../react/src/enhancers/withPlaceholder.tsx | 100 ---- packages/react/src/index.ts | 13 - 32 files changed, 107 insertions(+), 2800 deletions(-) delete mode 100644 packages/react/src/components/DateOptimized.test.tsx delete mode 100644 packages/react/src/components/DateOptimized.tsx delete mode 100644 packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx delete mode 100644 packages/react/src/components/DesignLibrary/DesignLibraryOptimized.tsx delete mode 100644 packages/react/src/components/FileOptimized.test.tsx delete mode 100644 packages/react/src/components/FileOptimized.tsx delete mode 100644 packages/react/src/components/FormOptimized.test.tsx delete mode 100644 packages/react/src/components/FormOptimized.tsx delete mode 100644 packages/react/src/components/LinkOptimized.test.tsx delete mode 100644 packages/react/src/components/LinkOptimized.tsx delete mode 100644 packages/react/src/components/RichTextOptimized.test.tsx delete mode 100644 packages/react/src/components/RichTextOptimized.tsx delete mode 100644 packages/react/src/components/SitecoreProviderOptimized.test.tsx delete mode 100644 packages/react/src/components/SitecoreProviderOptimized.tsx delete mode 100644 packages/react/src/components/TextOptimized.test.tsx delete mode 100644 packages/react/src/components/TextOptimized.tsx create mode 100644 packages/react/src/enhancers/withClientPlaceholder.tsx delete mode 100644 packages/react/src/enhancers/withPlaceholder.test.tsx delete mode 100644 packages/react/src/enhancers/withPlaceholder.tsx diff --git a/packages/react/src/components/Date.tsx b/packages/react/src/components/Date.tsx index 588800cf76..b86307c39a 100644 --- a/packages/react/src/components/Date.tsx +++ b/packages/react/src/components/Date.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from 'react'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; @@ -51,7 +51,8 @@ export const DateField: React.FC = withFieldMetadata{children}; } else { return {children}; } diff --git a/packages/react/src/components/DateOptimized.test.tsx b/packages/react/src/components/DateOptimized.test.tsx deleted file mode 100644 index cce6433b1a..0000000000 --- a/packages/react/src/components/DateOptimized.test.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/* eslint-disable no-unused-expressions */ -import React from 'react'; -import { expect } from 'chai'; -import { render } from '@testing-library/react'; - -import { DateFieldOptimized } from './DateOptimized'; -import { DateFieldProps } from './Date'; - -describe('', () => { - it('should render nothing with missing field', () => { - const field = (null as unknown) as DateFieldProps['field']; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render nothing with empty value', () => { - const field = { - value: '', - }; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render nothing with missing value', () => { - const field = {}; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render date value', () => { - const field = { - value: '2023-01-15T10:30:00Z', - }; - const rendered = render(); - expect(rendered.container.innerHTML).to.contain('2023-01-15T10:30:00Z'); - }); - - it('should render with tag provided', () => { - const field = { - value: '2023-01-15T10:30:00Z', - }; - const rendered = render().container.querySelector( - 'div' - ); - expect(rendered?.innerHTML).to.contain('2023-01-15T10:30:00Z'); - }); - - it('should render without tag', () => { - const field = { - value: '2023-01-15T10:30:00Z', - }; - const rendered = render(); - expect(rendered.container.innerHTML).to.contain('2023-01-15T10:30:00Z'); - }); - - it('should render with custom render function', () => { - const field = { - value: '2023-01-15T10:30:00Z', - }; - const customRender = (date: Date | null) => { - if (!date) return null; - return Custom: {date.toISOString()}; - }; - const rendered = render(); - expect(rendered.container.innerHTML).to.contain('Custom: 2023-01-15T10:30:00.000Z'); - }); - - it('should render with custom render function and tag', () => { - const field = { - value: '2023-01-15T10:30:00Z', - }; - const customRender = (date: Date | null) => { - if (!date) return null; - return date.toLocaleDateString(); - }; - const rendered = render( - - ).container.querySelector('div'); - expect(rendered).to.not.be.null; - }); - - it('should render other attributes', () => { - const field = { - value: '2023-01-15T10:30:00Z', - }; - const rendered = render( - - ).container.querySelector('span'); - expect(rendered?.outerHTML).to.contain('class="date-field"'); - expect(rendered?.outerHTML).to.contain('id="test-date"'); - }); - - describe('edit mode', () => { - const testMetadata = { - contextItem: { - id: '{09A07660-6834-476C-B93B-584248D3003B}', - language: 'en', - revision: 'a0b36ce0a7db49418edf90eb9621e145', - version: 1, - }, - fieldId: '{414061F4-FBB1-4591-BC37-BFFA67F745EB}', - fieldType: 'date', - rawValue: '2023-01-15T10:30:00Z', - }; - - it('should render field metadata component when metadata property is present', () => { - const field = { - value: '2023-01-15T10:30:00Z', - metadata: testMetadata, - }; - - const rendered = render(); - - expect(rendered.container.innerHTML).to.contain( - `${JSON.stringify( - testMetadata - )}` - ); - expect(rendered.container.innerHTML).to.contain('2023-01-15T10:30:00Z'); - expect(rendered.container.innerHTML).to.contain( - '' - ); - }); - - it('should render default empty field component when field value is empty in edit mode metadata', () => { - const field = { - value: '', - metadata: testMetadata, - }; - - const rendered = render(); - - expect(rendered.container.innerHTML).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - '[No text in field]', - '', - ].join('') - ); - }); - - it('should render nothing when field value is empty, when editing is explicitly disabled in edit mode metadata', () => { - const field = { - value: '', - metadata: testMetadata, - }; - - const rendered = render(); - - expect(rendered.container.innerHTML).to.equal(''); - }); - }); -}); diff --git a/packages/react/src/components/DateOptimized.tsx b/packages/react/src/components/DateOptimized.tsx deleted file mode 100644 index 5d399d9ed9..0000000000 --- a/packages/react/src/components/DateOptimized.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React from 'react'; -import { withFieldMetadata } from '../enhancers/withFieldMetadata'; -import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; -import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; -import { EditableFieldProps } from './sharedTypes'; -import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; - -/** - * The props for the DateField component. - * @public - */ -export interface DateFieldProps extends EditableFieldProps { - /** The date field data. */ - [htmlAttributes: string]: unknown; - field: FieldMetadata & { - value?: string; - }; - /** - * The HTML element that will wrap the contents of the field. - */ - tag?: string; - - render?: (date: Date | null) => React.ReactNode; -} - -/** - * The DateField component - Optimized version using JSX instead of React.createElement. - * - * This is a modernized version that uses JSX syntax for better readability and - * maintainability, replacing React.createElement calls with native JSX. - * - * @public - */ -export const DateFieldOptimized = withFieldMetadata( - withEmptyFieldEditingComponent( - // eslint-disable-next-line no-unused-vars - ({ field, tag, editable = true, render, ...otherProps }) => { - if (isFieldValueEmpty(field)) { - return null; - } - - let children: React.ReactNode; - - const htmlProps: { - [htmlAttr: string]: unknown; - children?: React.ReactNode; - } = { - ...otherProps, - }; - - if (render) { - children = render(field.value ? new Date(field.value) : null); - } else { - children = field.value; - } - - if (tag) { - const Tag = (tag || 'span') as React.ElementType; - return {children}; - } else { - return {children}; - } - }, - { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText } - ) -); diff --git a/packages/react/src/components/DefaultEmptyFieldEditingComponents.tsx b/packages/react/src/components/DefaultEmptyFieldEditingComponents.tsx index dd63315cea..72b9557c2c 100644 --- a/packages/react/src/components/DefaultEmptyFieldEditingComponents.tsx +++ b/packages/react/src/components/DefaultEmptyFieldEditingComponents.tsx @@ -9,10 +9,11 @@ export const DefaultEmptyFieldEditingComponentText: React.FC<{ [key: string]: unknown; tag?: string; }> = (props) => { - return React.createElement( - props.tag || 'span', - { ...props, suppressHydrationWarning: true }, - '[No text in field]' + const Tag = (props.tag || 'span') as React.ElementType; + return ( + + [No text in field] + ); }; diff --git a/packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx b/packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx deleted file mode 100644 index ba948c0ef5..0000000000 --- a/packages/react/src/components/DefaultEmptyFieldEditingComponentsOptimized.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; - -interface EmptyFieldComponentProps { - [key: string]: unknown; - tag?: React.ElementType; - className?: string; -} - -/** - * The DefaultEmptyFieldEditingComponentText component - Optimized version using JSX instead of React.createElement. - * - * This is a modernized version that uses JSX syntax for better readability and - * maintainability, replacing React.createElement calls with native JSX. - * - * @param {object} props - The props for the component. - * @public - */ -export const DefaultEmptyFieldEditingComponentTextOptimized = (props: EmptyFieldComponentProps) => { - const Tag = (props.tag || 'span') as React.ElementType; - return ( - - [No text in field] - - ); -}; - -/** - * The DefaultEmptyFieldEditingComponentImage component (unchanged - already uses JSX). - * Re-exported here for completeness in the Optimized module. - * - * @param {object} props - The props for the component. - * @public - */ -export const DefaultEmptyFieldEditingComponentImageOptimized = ( - props: EmptyFieldComponentProps -) => { - const inlineStyles = { - minWidth: '48px', - minHeight: '48px', - maxWidth: '400px', - maxHeight: '400px', - cursor: 'pointer', - }; - - return ( - - ); -}; diff --git a/packages/react/src/components/DesignLibrary/DesignLibraryOptimized.tsx b/packages/react/src/components/DesignLibrary/DesignLibraryOptimized.tsx deleted file mode 100644 index 493a2abdc6..0000000000 --- a/packages/react/src/components/DesignLibrary/DesignLibraryOptimized.tsx +++ /dev/null @@ -1,179 +0,0 @@ -'use client'; -/* eslint-disable jsdoc/require-param */ -/* eslint-disable prefer-const */ -import React, { useEffect, useState } from 'react'; -import { - EDITING_COMPONENT_ID, - EDITING_COMPONENT_PLACEHOLDER, -} from '@sitecore-content-sdk/content/layout'; -import { - DesignLibraryStatus, - getDesignLibraryStatusEvent, - addComponentUpdateHandler, -} from '@sitecore-content-sdk/content/editing'; -import * as codegen from '@sitecore-content-sdk/content/codegen'; -import * as editing from '@sitecore-content-sdk/content/editing'; -import { useSitecore } from '../../enhancers/withSitecore'; -import { Placeholder, PlaceholderMetadata } from '../Placeholder'; -import { DesignLibraryErrorBoundary } from './DesignLibraryErrorBoundary'; -import { DynamicComponent } from './models'; -import { useLoadImportMap } from '../../enhancers/withLoadImportMap'; -import { ErrorComponent } from '../ErrorBoundary'; - -let { - getDesignLibraryImportMapEvent, - getDesignLibraryComponentPropsEvent, - addComponentPreviewHandler, - sendErrorEvent, -} = codegen; -let { postToDesignLibrary } = editing; - -export const __mockDependencies = (mocks: any) => { - addComponentPreviewHandler = mocks.addComponentPreviewHandler; - if (mocks.postToDesignLibrary) { - postToDesignLibrary = mocks.postToDesignLibrary; - } - if (mocks.sendErrorEvent) { - sendErrorEvent = mocks.sendErrorEvent; - } -}; - -/** - * Design Library component. - * - * Renders the **real** Sitecore component for `library` / `library-metadata` modes and, - * when generation is enabled (`page.mode.designLibrary.isVariantGeneration === true`), - * wires the **variant generation** handshake so the parent (DL Studio) can send - * generated code to preview and iterate on. - * @param {DesignLibraryProps} props - * @param {() => Promise} [props.loadImportMap] Optional async loader that resolves to the import-map used to resolve the generated component's imports. Required when `isVariantGeneration` is true. - * @returns {JSX.Element} The preview surface, or `null` when not in Design Library mode. - * @public - */ -export const DesignLibraryOptimized = () => { - const { page } = useSitecore(); - const route = page.layout.sitecore.route; - const rendering = route?.placeholders[EDITING_COMPONENT_PLACEHOLDER]?.[0]; - const uid = rendering?.uid; - - const { isDesignLibrary } = page.mode; - const isVariantGeneration = page.mode.designLibrary?.isVariantGeneration; - - const [propsState, setPropsState] = useState({ - fields: rendering?.fields, - params: rendering?.params, - }); - const [renderKey, setRenderKey] = useState(0); - const [Component, setComponent] = useState(null); - const isGeneratedComponentActive = !!Component; - - if (!isDesignLibrary) return null; - - if (!uid) return ; - - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - postToDesignLibrary(getDesignLibraryStatusEvent(DesignLibraryStatus.READY, uid)); - - if (!isVariantGeneration) { - requestAnimationFrame(() => { - setRenderKey((k) => (k === 0 ? k + 1 : k)); - }); - } - - const unsubUpdate = addComponentUpdateHandler(rendering, (updated) => { - setPropsState({ fields: updated.fields, params: updated.params }); - setRenderKey((k) => k + 1); - }); - - // useEffect will cleanup event handler on re-render - return () => unsubUpdate && unsubUpdate(); - }, [isVariantGeneration, rendering, uid]); - - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - // Send a rendered event only as effect of a component update command - if (renderKey === 0) return; - - postToDesignLibrary(getDesignLibraryStatusEvent(DesignLibraryStatus.RENDERED, uid)); - }, [renderKey, uid]); - - // eslint-disable-next-line react-hooks/rules-of-hooks - useEffect(() => { - if (!isDesignLibrary || !isVariantGeneration) return; - - let cancelled = false; - // since import map is loaded lazily, we only need to add preview event handler once the import map is loaded - // unsubscribe function for useEffect cleanup will also be returned once importMap promise has been resolved or rejected - let unsubscribe: (() => void) | undefined; - const loadImportMap = useLoadImportMap(); - (async () => { - if (!loadImportMap) { - sendErrorEvent( - uid, - 'No loadImportMap provided', - codegen.DesignLibraryPreviewError.RenderInit - ); - return; - } - - let importMap: codegen.ImportEntry[]; - try { - const mod = await loadImportMap(); - importMap = mod.default; - } catch (e) { - sendErrorEvent( - uid, - `Error loading import map: ${e}`, - codegen.DesignLibraryPreviewError.RenderInit - ); - return; - } - // account for component being unmounted while resolving async import map - if (cancelled) return; - - unsubscribe = addComponentPreviewHandler(importMap, (error, Component) => { - // Error event is already sent in the addComponentPreviewHandler - if (error) return; - setComponent(() => Component as DynamicComponent); - setRenderKey((k) => k + 1); - }); - - const importMapEvent = getDesignLibraryImportMapEvent(uid, importMap); - postToDesignLibrary(importMapEvent); - - const propsEvent = getDesignLibraryComponentPropsEvent( - uid, - propsState.fields, - propsState.params - ); - postToDesignLibrary(propsEvent); - })(); - - // return function that calls unsubscribe - if the component was mounted correctly - return () => { - cancelled = true; - unsubscribe && unsubscribe(); - }; - }, [uid, propsState, isDesignLibrary, isVariantGeneration]); - - return ( -
- {isGeneratedComponentActive ? ( - - - - - - ) : ( -
- {route && ( - - )} -
- )} -
- ); -}; - -DesignLibraryOptimized.displayName = 'DesignLibraryOptimized'; diff --git a/packages/react/src/components/File.tsx b/packages/react/src/components/File.tsx index fd5491b138..dcb892d994 100644 --- a/packages/react/src/components/File.tsx +++ b/packages/react/src/components/File.tsx @@ -1,4 +1,4 @@ -import { isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; +import { isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; import React from 'react'; export interface FileFieldValue { @@ -48,7 +48,13 @@ export const File: React.FC = ({ field, children, ...otherProps }) => const anchorAttrs = { href: file.src, }; - return React.createElement('a', { ...anchorAttrs, ...otherProps }, linkText, children); + + return ( + + {linkText} + {children} + + ); }; File.displayName = 'File'; diff --git a/packages/react/src/components/FileOptimized.test.tsx b/packages/react/src/components/FileOptimized.test.tsx deleted file mode 100644 index f8b9abbec2..0000000000 --- a/packages/react/src/components/FileOptimized.test.tsx +++ /dev/null @@ -1,102 +0,0 @@ -/* eslint-disable no-unused-expressions */ -import React from 'react'; -import { expect } from 'chai'; -import { render } from '@testing-library/react'; - -import { FileOptimized } from './FileOptimized'; -import { FileField, FileFieldValue } from './File'; - -describe('', () => { - it('should render nothing with missing field', () => { - const field = (null as unknown) as FileField; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render nothing with missing value', () => { - const field = ({} as unknown) as FileField; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render nothing with empty value', () => { - const field = { value: {} } as FileField; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render with href and title', () => { - const field: FileField = { - value: { - src: '/lorem.pdf', - title: 'ipsum', - }, - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('ipsum'); - }); - - it('should render with href and displayName', () => { - const field: FileField = { - value: { - src: '/lorem.pdf', - displayName: 'ipsum', - }, - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('ipsum'); - }); - - it('should prefer title over displayName', () => { - const field: FileField = { - value: { - src: '/lorem.pdf', - title: 'title-value', - displayName: 'displayName-value', - }, - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('title-value'); - }); - - it('should render with children', () => { - const field: FileField = { - value: { - src: '/lorem.pdf', - title: 'ipsum', - }, - }; - const rendered = render( - - dolor - - ).container.querySelector('a'); - - expect(rendered?.innerHTML).to.contain('dolor'); - expect(rendered?.innerHTML).to.not.contain('ipsum'); - }); - - it('should render other attributes', () => { - const field: FileField = { - value: { - src: '/lorem.pdf', - title: 'ipsum', - }, - }; - const rendered = render( - - ).container.querySelector('a'); - expect(rendered?.outerHTML).to.contain( - 'ipsum' - ); - }); - - it('should render when file data provided directly', () => { - const field: FileFieldValue = { - src: '/lorem.pdf', - title: 'ipsum', - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('ipsum'); - }); -}); diff --git a/packages/react/src/components/FileOptimized.tsx b/packages/react/src/components/FileOptimized.tsx deleted file mode 100644 index 0455f40b07..0000000000 --- a/packages/react/src/components/FileOptimized.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; -import React from 'react'; - -export interface FileFieldValue { - [propName: string]: unknown; - src?: string; - title?: string; - displayName?: string; -} - -/** - * The interface for the File field. - * @public - */ -export interface FileField { - value: FileFieldValue; -} - -export interface FileProps { - [attributeName: string]: unknown; - /** The file field data. */ - field: FileFieldValue | FileField; - /** HTML attributes that will be appended to the rendered tag. */ - children?: React.ReactNode; -} - -/** - * The File component - Optimized version using JSX instead of React.createElement. - * - * This is a modernized version that uses JSX syntax for better readability and - * maintainability, replacing React.createElement calls with native JSX. - * - * @param {FileProps} props component props - * @public - */ -export const FileOptimized = ({ field, children, ...otherProps }: FileProps) => { - const dynamicField: FileField | FileFieldValue = field; - - if (isFieldValueEmpty(dynamicField)) { - return null; - } - - // handle link directly on field for forgetful devs - const file = ( - (dynamicField as FileFieldValue).src ? field : dynamicField.value - ) as FileFieldValue; - if (!file) { - return null; - } - - const linkText = !children ? file.title || file.displayName : null; - const anchorAttrs = { - href: file.src, - }; - - return ( - - {linkText} - {children} - - ); -}; diff --git a/packages/react/src/components/Form.tsx b/packages/react/src/components/Form.tsx index dd028e7062..d6b56c18c3 100644 --- a/packages/react/src/components/Form.tsx +++ b/packages/react/src/components/Form.tsx @@ -86,7 +86,14 @@ export const Form = ({ params, rendering }: FormProps) => { executeScriptElements(formRef.current); } - }, [content]); + }, [ + content, + isEditing, + params.FormId, + rendering.uid, + context.api?.edge?.clientContextId, + context.api?.edge?.edgeUrl, + ]); if (isEditing && error) { return ; diff --git a/packages/react/src/components/FormOptimized.test.tsx b/packages/react/src/components/FormOptimized.test.tsx deleted file mode 100644 index e07562b88c..0000000000 --- a/packages/react/src/components/FormOptimized.test.tsx +++ /dev/null @@ -1,220 +0,0 @@ -/* eslint-disable no-unused-vars */ -/* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ -import { render, waitFor } from '@testing-library/react'; -import React from 'react'; -import { expect } from 'chai'; -import { SitecoreProvider } from './SitecoreProvider'; -import { FormOptimized, mockFormModuleOptimized } from './FormOptimized'; -import sinon from 'sinon'; -import { PageMode } from '@sitecore-content-sdk/content/client'; -import { LayoutServicePageState } from '@sitecore-content-sdk/content/layout'; - -describe('FormOptimized', () => { - const ctx = { - api: { - edge: { - contextId: 'server-id', - clientContextId: 'client-id', - edgeUrl: 'edge-url', - }, - }, - page: { - normal: { - locale: 'en', - layout: { - sitecore: { - context: { pageEditing: false }, - route: null, - }, - }, - mode: { - name: LayoutServicePageState.Normal, - isNormal: true, - isPreview: false, - isEditing: false, - isDesignLibrary: false, - designLibrary: { - isVariantGeneration: false, - }, - }, - }, - editing: { - locale: 'en', - layout: { - sitecore: { - context: { pageEditing: true }, - route: null, - }, - }, - mode: { - name: LayoutServicePageState.Edit, - isEditing: true, - isNormal: false, - isPreview: false, - isDesignLibrary: false, - designLibrary: { - isVariantGeneration: false, - }, - }, - }, - }, - }; - - const rendering = { - uid: '123', - params: { - FormId: '456', - styles: 'form-class', - RenderingIdentifier: 'form-id', - }, - }; - - it('renders form using clientContextId when both IDs are present', async () => { - const loadFormSpy = sinon.spy((edgeId: string, formId: string, edgeUrl?: string) => { - expect(edgeId).to.equal('client-id'); - expect(formId).to.equal('456'); - expect(edgeUrl).to.equal('edge-url'); - - return Promise.resolve(''); - }); - - const subscribeSpy = sinon.spy(); - const execSpy = sinon.spy(); - - mockFormModuleOptimized({ - loadForm: loadFormSpy, - subscribeToFormSubmitEvent: subscribeSpy, - executeScriptElements: execSpy, - }); - - const rendered = await render( - - - , - { container: document.body } - ); - - await waitFor(() => { - expect(loadFormSpy.calledOnce).to.be.true; - expect(subscribeSpy.calledOnce).to.be.true; - expect(execSpy.calledOnce).to.be.true; - expect(rendered.container.innerHTML).to.equal( - '
' + - '
' - ); - }); - }); - - it('does not load form when clientContextId is missing', async () => { - const apiNoClientId = { - edge: { - contextId: 'server-only', - edgeUrl: 'edge-url', - }, - }; - - const loadFormSpy = sinon.spy(() => { - return Promise.resolve('
'); - }); - - mockFormModuleOptimized({ - loadForm: loadFormSpy, - subscribeToFormSubmitEvent: sinon.spy(), - executeScriptElements: sinon.spy(), - }); - - const rendered = await render( - - - - ); - - await waitFor(() => { - expect(loadFormSpy.notCalled).to.be.true; - expect(rendered.container.innerHTML).to.equal('
'); - }); - }); - - it('renders form in edit mode (scripts executed, no submit subscription)', async () => { - const loadFormSpy = sinon - .stub() - .resolves('
'); - const subscribeSpy = sinon.spy(); - const execSpy = sinon.spy(); - - const mode: PageMode = { - name: LayoutServicePageState.Edit, - isEditing: true, - }; - - mockFormModuleOptimized({ - loadForm: loadFormSpy, - subscribeToFormSubmitEvent: subscribeSpy, - executeScriptElements: execSpy, - }); - - const rendered = await render( - - - - ); - - await waitFor(() => { - expect(loadFormSpy.calledOnce).to.be.true; - expect(subscribeSpy.notCalled).to.be.true; - expect(execSpy.calledOnce).to.be.true; - - expect(rendered.container.innerHTML).to.contain('
'); - }); - }); - - it('renders empty component on load failure (non-edit mode)', async () => { - mockFormModuleOptimized({ - loadForm: sinon.stub().rejects(), - subscribeToFormSubmitEvent: sinon.spy(), - executeScriptElements: sinon.spy(), - }); - - const rendered = await render( - - - - ); - - await waitFor(() => { - expect(rendered.container.innerHTML).to.equal('
'); - }); - }); - - it('renders edit-mode error placeholder on load failure', async () => { - mockFormModuleOptimized({ - loadForm: sinon.stub().rejects(), - subscribeToFormSubmitEvent: sinon.spy(), - executeScriptElements: sinon.spy(), - }); - - const mode: PageMode = { - name: LayoutServicePageState.Edit, - isEditing: true, - }; - - const rendered = await render( - - - , - { container: document.body } - ); - - await waitFor(() => { - rendered.rerender( - - - - ); - - expect(rendered.baseElement.innerHTML).to.equal( - '
There was a problem loading this section
' - ); - }); - }); -}); diff --git a/packages/react/src/components/FormOptimized.tsx b/packages/react/src/components/FormOptimized.tsx deleted file mode 100644 index 04c821c21f..0000000000 --- a/packages/react/src/components/FormOptimized.tsx +++ /dev/null @@ -1,116 +0,0 @@ -'use client'; -import React, { useEffect, useRef, useState } from 'react'; -import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; -import { form } from '@sitecore-content-sdk/content'; -import { useSitecore } from '../enhancers/withSitecore'; -import { ErrorComponent } from './ErrorBoundary'; - -/** - * Shape of the Form component rendering data. - * FormId is the rendering parameter that specifies the ID of the Sitecore Form to render. - */ -export type FormProps = { - rendering: ComponentRendering; - params: { - /** - * The ID of the Sitecore Form to render. - */ - FormId: string; - /** - * CSS class to apply to the form - */ - styles?: string; - /** - * The unique identifier of the rendering. - */ - RenderingIdentifier?: string; - }; -}; - -let { executeScriptElements, loadForm, subscribeToFormSubmitEvent } = form; - -/** - * Mock function to replace the form module functions for `testing` purposes. - * @param {any} formModule - The form module to mock - */ -export const mockFormModuleOptimized = (formModule: any) => { - executeScriptElements = formModule.executeScriptElements; - loadForm = formModule.loadForm; - subscribeToFormSubmitEvent = formModule.subscribeToFormSubmitEvent; -}; - -/** - * The Form component - Optimized version with fixed useEffect dependencies. - * - * This is a modernized version that fixes the exhaustive-deps warning by including - * all necessary dependencies (isEditing, params.FormId, and rendering.uid) in the - * useEffect dependency array. - * - * @param {FormProps} props incoming props - * @public - */ -export const FormOptimized = ({ params, rendering }: FormProps) => { - const id = params?.RenderingIdentifier; - const [error, setError] = useState(false); - const [content, setContent] = useState(''); - const context = useSitecore(); - const formRef = useRef(null); - - const isEditing = context.page.mode.isEditing; - - useEffect(() => { - if (!content) { - // Forms must use clientContextId since they are rendered client-side - const edgeId = context.api?.edge?.clientContextId; - - if (!edgeId) { - /* eslint-disable no-console */ - console.warn( - 'Warning: clientContextId is missing – form cannot be loaded properly on the client' - ); - return; - } - - loadForm(edgeId, params.FormId, context.api?.edge?.edgeUrl) - .then(setContent) - .catch(() => { - if (isEditing) { - console.error( - `Failed to load form with id ${params.FormId}. Check debug logs for content-sdk:form for more details.` - ); - } - setError(true); - }); - } else { - if (!formRef.current) return; - - // If we are in editing mode, we don't want to send any events - if (!isEditing) { - subscribeToFormSubmitEvent(formRef.current, rendering.uid); - } - - executeScriptElements(formRef.current); - } - // Fixed: Include all dependencies as recommended by React exhaustive-deps rule - }, [ - content, - isEditing, - params.FormId, - rendering.uid, - context.api?.edge?.clientContextId, - context.api?.edge?.edgeUrl, - ]); - - if (isEditing && error) { - return ; - } - - return ( -
- ); -}; diff --git a/packages/react/src/components/Link.tsx b/packages/react/src/components/Link.tsx index 2171644787..695cc0f770 100644 --- a/packages/react/src/components/Link.tsx +++ b/packages/react/src/components/Link.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import React, { RefAttributes, forwardRef } from 'react'; import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; @@ -91,14 +91,12 @@ export const Link: React.FC = withFieldMetadata + {linkText} + {children} + ); - - return {element}; } ), { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, isForwardRef: true } diff --git a/packages/react/src/components/LinkOptimized.test.tsx b/packages/react/src/components/LinkOptimized.test.tsx deleted file mode 100644 index f54ea8c7ee..0000000000 --- a/packages/react/src/components/LinkOptimized.test.tsx +++ /dev/null @@ -1,240 +0,0 @@ -/* eslint-disable no-unused-expressions */ -import React from 'react'; -import { expect } from 'chai'; -import { render } from '@testing-library/react'; - -import { LinkOptimized } from './LinkOptimized'; -import { LinkField, LinkFieldValue } from './Link'; - -describe('', () => { - it('should render nothing with missing field', () => { - const field = (null as unknown) as LinkField; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render nothing with missing value', () => { - const field = ({} as unknown) as LinkField; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render nothing with empty value', () => { - const field = { value: {} } as LinkField; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render with a value', () => { - const field: LinkField = { - value: { - href: '/lorem', - text: 'ipsum', - }, - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('ipsum'); - }); - - it('should render with children', () => { - const field: LinkField = { - value: { - href: '/lorem', - text: 'ipsum', - }, - }; - const rendered = render( - - dolor - - ).container.querySelector('a'); - - expect(rendered?.innerHTML).to.contain('dolor'); - }); - - it('should render with link text and children when showLinkTextWithChildrenPresent=true', () => { - const field: LinkField = { - value: { - href: '/lorem', - text: 'ipsum', - }, - }; - const rendered = render( - - dolor - - ).container.querySelector('a'); - - expect(rendered?.innerHTML).to.contain('ipsum'); - expect(rendered?.innerHTML).to.contain('dolor'); - }); - - it('should render with link text when children are empty string', () => { - const field: LinkField = { - value: { - href: '/lorem', - text: 'ipsum', - }, - }; - const rendered = render( - - {''} - - ).container.querySelector('a'); - - expect(rendered?.innerHTML).to.contain('ipsum'); - }); - - it('should render with href when text is not present', () => { - const field: LinkField = { - value: { - href: '/lorem', - }, - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('/lorem'); - }); - - it('should render all attributes with all provided', () => { - const field: LinkField = { - value: { - href: '/lorem', - text: 'ipsum', - title: 'Foo', - target: '_blank', - class: 'bar', - }, - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain( - 'ipsum' - ); - }); - - it('should render with an anchor', () => { - const field: LinkField = { - value: { - href: '/lorem', - anchor: 'ipsum', - text: 'lorem', - }, - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('lorem'); - }); - - it('should render anchor link value with href when anchor link type is provided', () => { - const field: LinkField = { - value: { - href: '/lorem', - anchor: 'ipsum', - text: 'lorem', - linktype: 'anchor', - }, - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('lorem'); - }); - - it('should render other attributes', () => { - const field: LinkField = { - value: { - href: '/lorem', - text: 'ipsum', - }, - }; - const rendered = render( - - ).container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('ipsum'); - }); - - it('should render when link data provided directly', () => { - const field: LinkFieldValue = { - href: '/lorem', - text: 'ipsum', - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('ipsum'); - }); - - it('should render with a querystring', () => { - const field: LinkField = { - value: { - href: '/lorem', - querystring: 'foo=bar', - text: 'lorem', - }, - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('lorem'); - }); - - it('should render with a querystring and an anchor', () => { - const field: LinkField = { - value: { - href: '/lorem', - querystring: 'foo=bar', - anchor: 'ipsum', - text: 'lorem', - }, - }; - const rendered = render().container.querySelector('a'); - expect(rendered?.outerHTML).to.contain('lorem'); - }); - - describe('edit mode metadata', () => { - const testMetadata = { - contextItem: { - id: '{09A07660-6834-476C-B93B-584248D3003B}', - language: 'en', - revision: 'a0b36ce0a7db49418edf90eb9621e145', - version: 1, - }, - fieldId: '{414061F4-FBB1-4591-BC37-BFFA67F745EB}', - fieldType: 'link', - rawValue: '/lorem', - }; - - it('should render field metadata component when metadata property is present', () => { - const field: LinkField = { - value: { - href: '/lorem', - text: 'ipsum', - }, - metadata: testMetadata, - }; - - const rendered = render(); - - expect(rendered.container.innerHTML).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - 'ipsum', - '', - ].join('') - ); - }); - - it('should render default empty field component when field value is empty in edit mode metadata', () => { - const field = { - value: { href: '' }, - metadata: testMetadata, - }; - - const rendered = render(); - - expect(rendered.container.innerHTML).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - '[No text in field]', - '', - ].join('') - ); - }); - }); -}); diff --git a/packages/react/src/components/LinkOptimized.tsx b/packages/react/src/components/LinkOptimized.tsx deleted file mode 100644 index ad8dd821d8..0000000000 --- a/packages/react/src/components/LinkOptimized.tsx +++ /dev/null @@ -1,111 +0,0 @@ -'use client'; -import React, { RefAttributes, forwardRef } from 'react'; -import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; -import { withFieldMetadata } from '../enhancers/withFieldMetadata'; -import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; -import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; -import { EditableFieldProps } from './sharedTypes'; - -/** - * The interface for the Link field value. - * @public - */ -export interface LinkFieldValue { - [attributeName: string]: unknown; - href?: string; - className?: string; - class?: string; - title?: string; - target?: string; - text?: string; - anchor?: string; - querystring?: string; - linktype?: string; -} - -/** - * The interface for the Link field. - * @public - */ -export interface LinkField { - value: LinkFieldValue; -} - -/** - * The interface for the Link component props. - * @public - */ -export type LinkProps = EditableFieldProps & - React.AnchorHTMLAttributes & - RefAttributes & { - /** The link field data. */ - field: (LinkField | LinkFieldValue) & FieldMetadata; - - /** - * Displays a link text ('description' in Sitecore) even when children exist - */ - showLinkTextWithChildrenPresent?: boolean; - }; - -/** - * The Link component - Optimized version using JSX instead of React.createElement. - * - * This is a modernized version that uses JSX syntax for better readability and - * maintainability, replacing React.createElement calls with native JSX. - * - * @param {LinkProps} props component props - * @public - */ -export const LinkOptimized = withFieldMetadata( - withEmptyFieldEditingComponent( - forwardRef( - // eslint-disable-next-line no-unused-vars - ({ field, editable = true, showLinkTextWithChildrenPresent, ...otherProps }, ref) => { - const children = otherProps.children as React.ReactNode; - const dynamicField: LinkField | LinkFieldValue = field; - - if (isFieldValueEmpty(dynamicField)) { - return null; - } - - // handle link directly on field for forgetful devs - const link = (dynamicField as LinkFieldValue).href - ? (field as LinkFieldValue) - : (dynamicField as LinkField).value; - - if (!link) { - return null; - } - - const anchor = link.linktype !== 'anchor' && link.anchor ? `#${link.anchor}` : ''; - const querystring = link.querystring ? `?${link.querystring}` : ''; - - const anchorAttrs: { [attr: string]: unknown } = { - href: `${link.href}${querystring}${anchor}`, - className: link.class, - title: link.title, - target: link.target, - }; - - if (anchorAttrs.target === '_blank' && !anchorAttrs.rel) { - // information disclosure attack prevention keeps target blank site from getting ref to window.opener - anchorAttrs.rel = 'noopener noreferrer'; - } - - const linkText = - showLinkTextWithChildrenPresent || !children ? link.text || link.href : null; - - return ( - - - {linkText} - {children} - - - ); - } - ), - { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, isForwardRef: true } - ), - true -); diff --git a/packages/react/src/components/RichText.tsx b/packages/react/src/components/RichText.tsx index 787d19fc1b..3e4390cfdd 100644 --- a/packages/react/src/components/RichText.tsx +++ b/packages/react/src/components/RichText.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import React, { ForwardedRef, forwardRef } from 'react'; import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; @@ -55,7 +55,8 @@ export const RichText: React.FC = withFieldMetadata; } ), { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, isForwardRef: true } diff --git a/packages/react/src/components/RichTextOptimized.test.tsx b/packages/react/src/components/RichTextOptimized.test.tsx deleted file mode 100644 index b49b1603dd..0000000000 --- a/packages/react/src/components/RichTextOptimized.test.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react'; -import { expect } from 'chai'; -import { render } from '@testing-library/react'; - -import { RichTextOptimized } from './RichTextOptimized'; -import { RichTextField } from './RichText'; - -describe('', () => { - it('should render nothing with missing field', () => { - const field: RichTextField = null; - const rendered = render().container.querySelectorAll('div'); - expect(rendered).to.have.length(0); - }); - - it('should render nothing with empty value', () => { - const field = { - value: '', - }; - const rendered = render().container.querySelectorAll('div'); - expect(rendered).to.have.length(0); - }); - - it('should render nothing with missing value', () => { - const field = {}; - const rendered = render().container.querySelectorAll('div'); - expect(rendered).to.have.length(0); - }); - - it('should render value with editing explicitly disabled', () => { - const field = { - value: 'value', - }; - const rendered = render( - - ).container.querySelectorAll('div'); - expect(rendered).to.have.length(1); - expect(rendered[0].innerHTML).to.contain('value'); - }); - - it('should render value with with just a value', () => { - const field = { - value: 'value', - }; - const rendered = render().container.querySelectorAll('div'); - expect(rendered).to.have.length(1); - expect(rendered[0].innerHTML).to.contain('value'); - }); - - it('should render embedded html as-is', () => { - const field = { - value: 'some crazy stuff', - }; - const rendered = render().container.querySelectorAll('div'); - expect(rendered).to.have.length(1); - expect(rendered[0].innerHTML).to.contain(field.value); - }); - - it('should render tag with a tag provided', () => { - const field = { - value: 'value', - }; - const rendered = render( - - ).container.querySelectorAll('p'); - expect(rendered).to.have.length(1); - expect(rendered[0].innerHTML).to.contain('value'); - }); - - it('should render other attributes with other props provided', () => { - const field = { - value: 'value', - }; - const rendered = render( - - ).container.querySelectorAll('h1'); - expect(rendered).to.have.length(1); - expect(rendered[0].outerHTML).to.contain('

'); - expect(rendered[0].outerHTML).to.contain('value'); - }); - - describe('edit mode', () => { - const testMetadata = { - contextItem: { - id: '{09A07660-6834-476C-B93B-584248D3003B}', - language: 'en', - revision: 'a0b36ce0a7db49418edf90eb9621e145', - version: 1, - }, - fieldId: '{414061F4-FBB1-4591-BC37-BFFA67F745EB}', - fieldType: 'single-line', - rawValue: 'Test1', - }; - - it('should render field metadata component when metadata property is present', () => { - const field = { - value: 'value', - metadata: testMetadata, - }; - - const rendered = render(); - - expect(rendered.container.innerHTML).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - '
value
', - '', - ].join('') - ); - }); - - it('should render default empty field component when field value is empty in edit mode metadata', () => { - const field = { - value: '', - metadata: testMetadata, - }; - - const rendered = render(); - - expect(rendered.container.innerHTML).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - '[No text in field]', - '', - ].join('') - ); - }); - - it('should render custom empty field component when provided, when field value is empty in edit mode metadata', () => { - const field = { - value: '', - metadata: testMetadata, - }; - - const EmptyFieldEditingComponent: React.FC = () => ( - Custom Empty field value - ); - - const rendered = render( - - ); - - expect(rendered.container.innerHTML).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - 'Custom Empty field value', - '', - ].join('') - ); - }); - - it('should render nothing when field value is empty, when editing is explicitly disabled in edit mode metadata', () => { - const field = { - value: '', - metadata: testMetadata, - }; - - const rendered = render(); - - expect(rendered.container.innerHTML).to.equal(''); - }); - }); -}); diff --git a/packages/react/src/components/RichTextOptimized.tsx b/packages/react/src/components/RichTextOptimized.tsx deleted file mode 100644 index 3d72087e68..0000000000 --- a/packages/react/src/components/RichTextOptimized.tsx +++ /dev/null @@ -1,69 +0,0 @@ -'use client'; -import React, { ForwardedRef, forwardRef } from 'react'; -import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; -import { withFieldMetadata } from '../enhancers/withFieldMetadata'; -import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; -import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; -import { EditableFieldProps } from './sharedTypes'; - -/** - * The interface for the RichText field. - * @public - */ -export interface RichTextField extends FieldMetadata { - value?: string; -} - -/** - * The interface for the RichText component props. - * @public - */ -export interface RichTextProps extends EditableFieldProps { - [htmlAttributes: string]: unknown; - /** The rich text field data. */ - field?: RichTextField; - /** - * The HTML element that will wrap the contents of the field. - * @default
- */ - tag?: string; -} - -/** - * The RichText component - Optimized version using JSX instead of React.createElement. - * - * This is a modernized version that uses JSX syntax for better readability and - * maintainability, replacing React.createElement calls with native JSX. - * - * @param {RichTextProps} props component props - * @public - */ -export const RichTextOptimized = withFieldMetadata( - withEmptyFieldEditingComponent( - forwardRef( - ( - // eslint-disable-next-line no-unused-vars - { field, tag = 'div', editable = true, ...otherProps }: RichTextProps, - ref: ForwardedRef - ) => { - if (isFieldValueEmpty(field)) { - return null; - } - - const htmlProps = { - dangerouslySetInnerHTML: { - __html: field.value, - }, - ref, - suppressHydrationWarning: field.metadata ? true : undefined, - ...otherProps, - }; - - const Tag = (tag || 'div') as React.ElementType; - return ; - } - ), - { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText, isForwardRef: true } - ), - true -); diff --git a/packages/react/src/components/SitecoreProvider.tsx b/packages/react/src/components/SitecoreProvider.tsx index 83008a0c05..7a1c802d77 100644 --- a/packages/react/src/components/SitecoreProvider.tsx +++ b/packages/react/src/components/SitecoreProvider.tsx @@ -1,5 +1,5 @@ 'use client'; -import React from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import fastDeepEqual from 'fast-deep-equal/es6/react'; import { Page } from '@sitecore-content-sdk/content/client'; import { SitecoreConfig } from '@sitecore-content-sdk/content/config'; @@ -67,66 +67,59 @@ export const ImportMapReactContext = React.createContext< * The SitecoreProvider component. * @public */ -export class SitecoreProvider extends React.Component< - SitecoreProviderProps, - SitecoreProviderState -> { - static displayName = 'SitecoreProvider'; +export const SitecoreProvider = (props: SitecoreProviderProps) => { + const { api: propsApi, page: propsPage, componentMap, loadImportMap, children } = props; - constructor(props: SitecoreProviderProps) { - super(props); - - // If any Edge ID is present but no edgeUrl, apply the default - let api = props.api; + // Apply default edgeUrl if any Edge ID is present but no edgeUrl + const api = useMemo(() => { if ( - (props.api?.edge?.contextId || props.api?.edge?.clientContextId) && - !props.api?.edge?.edgeUrl + (propsApi?.edge?.contextId || propsApi?.edge?.clientContextId) && + !propsApi?.edge?.edgeUrl ) { - api = { - ...props.api, + return { + ...propsApi, edge: { - ...props.api.edge, + ...propsApi.edge, edgeUrl: constants.SITECORE_EDGE_URL_DEFAULT, }, }; } + return propsApi; + }, [propsApi]); - this.state = { - page: props.page, - setPage: this.setPage, - api, - }; - } + const [page, setPageInternal] = useState(propsPage); - componentDidUpdate(prevProps: SitecoreProviderProps) { - // In case if somebody will manage SitecoreProvider state by passing fresh `page` prop - // instead of using `updateContext` - if (!fastDeepEqual(prevProps.page, this.props.page)) { - this.setPage(this.props.page); + // Memoize setPage callback + const setPage = useCallback((value: Page) => { + setPageInternal(value); + }, []); - return; + // Handle page prop changes using useEffect instead of componentDidUpdate + useEffect(() => { + if (!fastDeepEqual(propsPage, page)) { + setPage(propsPage); } - } + }, [propsPage, page, setPage]); - /** - * Update page state. - * @param {Page} value New page value - */ - setPage = (value: Page) => { - this.setState({ - page: value, - }); - }; + // Memoize the context value to prevent unnecessary re-renders + const contextValue = useMemo( + () => ({ + page, + setPage, + api, + }), + [page, setPage, api] + ); - render() { - return ( - - - - {this.props.children} - - - - ); - } -} + return ( + + + + {children} + + + + ); +}; + +SitecoreProvider.displayName = 'SitecoreProvider'; diff --git a/packages/react/src/components/SitecoreProviderOptimized.test.tsx b/packages/react/src/components/SitecoreProviderOptimized.test.tsx deleted file mode 100644 index dad4a7a60d..0000000000 --- a/packages/react/src/components/SitecoreProviderOptimized.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -/* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ -import React from 'react'; -import { expect } from 'chai'; -import { Page } from '@sitecore-content-sdk/content/client'; -import { SitecoreProviderOptimized } from './SitecoreProviderOptimized'; -import { useSitecore } from '../enhancers/withSitecore'; -import { LayoutServiceData, LayoutServicePageState } from '../index'; -import { render } from '@testing-library/react'; - -describe('SitecoreProviderOptimized', () => { - let nestedContext = {}; - - const NestedComponent = () => { - const { page } = useSitecore(); - nestedContext = page; - return Page mode is {page.mode.name}; - }; - - const components = new Map(); - - // minimal API stub – details don't matter for these tests - const apiStub = {} as any; - - const mockLayoutData: LayoutServiceData = { - sitecore: { - context: { - pageEditing: false, - site: { name: 'ContentSdkTestWeb' }, - language: 'en', - }, - route: { - name: 'styleguide', - placeholders: { 'ContentSdkTestWeb-main': [] }, - itemId: 'testitemid', - }, - }, - }; - - const mockPage: Page = { - layout: mockLayoutData, - locale: 'en', - mode: { - name: LayoutServicePageState.Normal, - isNormal: true, - isPreview: false, - isEditing: false, - isDesignLibrary: false, - designLibrary: { - isVariantGeneration: false, - }, - }, - }; - - it('renders the component with the context', () => { - const rendered = render( - - - - ); - - expect(nestedContext).to.deep.equal(mockPage); - expect(rendered.getByText('Page mode is normal')).to.exist; - }); - - it('updates state when new page is received via props', () => { - const rendered = render( - - - - ); - - expect(nestedContext).to.deep.equal(mockPage); - - const newMockPage: Page = { - ...mockPage, - locale: 'gr', - }; - - rendered.rerender( - - - - ); - - expect(nestedContext).to.deep.equal({ - ...mockPage, - locale: 'gr', - }); - }); - - it('applies default edgeUrl when Edge IDs are present', () => { - let capturedApi: any; - - const ApiConsumer: FC = () => { - const { api } = useSitecore(); - capturedApi = api; - return
API Consumer
; - }; - - const apiWithEdgeId = { - edge: { - contextId: 'test-context-id', - clientContextId: 'test-client-id', - // no edgeUrl provided - }, - }; - - render( - - - - ); - - expect(capturedApi.edge.edgeUrl).to.exist; - expect(capturedApi.edge.edgeUrl).to.be.a('string'); - }); -}); diff --git a/packages/react/src/components/SitecoreProviderOptimized.tsx b/packages/react/src/components/SitecoreProviderOptimized.tsx deleted file mode 100644 index d7a52459b6..0000000000 --- a/packages/react/src/components/SitecoreProviderOptimized.tsx +++ /dev/null @@ -1,101 +0,0 @@ -'use client'; -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import fastDeepEqual from 'fast-deep-equal/es6/react'; -import { Page } from '@sitecore-content-sdk/content/client'; -import { SitecoreConfig } from '@sitecore-content-sdk/content/config'; -import { constants } from '@sitecore-content-sdk/core'; -import { ComponentMap } from './sharedTypes'; -import { ImportMapImport } from './DesignLibrary/models'; -import { - SitecoreProviderReactContext, - ComponentMapReactContext, - ImportMapReactContext, - SitecoreProviderState, -} from './SitecoreProvider'; - -export interface SitecoreProviderProps { - /** - * The API configuration defined in the `SitecoreConfig`. - */ - api: SitecoreConfig['api']; - /** - * The component map to use for rendering components. - */ - componentMap: ComponentMap; - /** - * The page data. - */ - page: Page; - /** - * The dynamic import for import map to be used in variant generation mode. - */ - loadImportMap: () => Promise; - - children: React.ReactNode; -} - -/** - * The SitecoreProvider component - Optimized functional component version. - * - * This is a modernized version of the SitecoreProvider that uses React hooks - * instead of class component lifecycle methods. It provides the same functionality - * with better React 19+ optimization support. - * - * @public - */ -export const SitecoreProviderOptimized = (props: SitecoreProviderProps) => { - const { api: propsApi, page: propsPage, componentMap, loadImportMap, children } = props; - - // Apply default edgeUrl if any Edge ID is present but no edgeUrl - const api = useMemo(() => { - if ( - (propsApi?.edge?.contextId || propsApi?.edge?.clientContextId) && - !propsApi?.edge?.edgeUrl - ) { - return { - ...propsApi, - edge: { - ...propsApi.edge, - edgeUrl: constants.SITECORE_EDGE_URL_DEFAULT, - }, - }; - } - return propsApi; - }, [propsApi]); - - const [page, setPageInternal] = useState(propsPage); - - // Memoize setPage callback - const setPage = useCallback((value: Page) => { - setPageInternal(value); - }, []); - - // Handle page prop changes using useEffect instead of componentDidUpdate - useEffect(() => { - if (!fastDeepEqual(propsPage, page)) { - setPage(propsPage); - } - }, [propsPage, page, setPage]); - - // Memoize the context value to prevent unnecessary re-renders - const contextValue = useMemo( - () => ({ - page, - setPage, - api, - }), - [page, setPage, api] - ); - - return ( - - - - {children} - - - - ); -}; - -SitecoreProviderOptimized.displayName = 'SitecoreProviderOptimized'; diff --git a/packages/react/src/components/Text.tsx b/packages/react/src/components/Text.tsx index 8afffebbde..dadc0c58ba 100644 --- a/packages/react/src/components/Text.tsx +++ b/packages/react/src/components/Text.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import React, { ReactElement } from 'react'; import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; import { withFieldMetadata } from '../enhancers/withFieldMetadata'; @@ -83,14 +83,16 @@ export const Text: React.FC = withFieldMetadata( children = output; } + const Tag = (tag || 'span') as React.ElementType; + if (field.metadata) { - return React.createElement( - tag || 'span', - { ...htmlProps, suppressHydrationWarning: true }, - children + return ( + + {children} + ); } else if (tag || !encode) { - return React.createElement(tag || 'span', htmlProps, children); + return {children}; } else { return {children}; } diff --git a/packages/react/src/components/TextOptimized.test.tsx b/packages/react/src/components/TextOptimized.test.tsx deleted file mode 100644 index d7d403177e..0000000000 --- a/packages/react/src/components/TextOptimized.test.tsx +++ /dev/null @@ -1,243 +0,0 @@ -/* eslint-disable no-unused-expressions */ -import React from 'react'; -import { expect } from 'chai'; -import { render } from '@testing-library/react'; - -import { TextOptimized } from './TextOptimized'; -import { TextField } from './Text'; - -describe('', () => { - it('should render nothing with missing field', () => { - const field: TextField = null; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render nothing with empty string value', () => { - const field = { - value: '', - }; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render nothing with missing value', () => { - const field = {}; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should render value with editing explicitly disabled', () => { - const field = { - value: 'value', - }; - const rendered = render( - - ).container.querySelector('span'); - expect(rendered?.innerHTML).to.contain('value'); - }); - - it('should encode values with editing explicitly disabled', () => { - const field = { - value: 'value < >', - }; - const rendered = render( - - ).container.querySelector('span'); - expect(rendered.innerHTML).to.contain('< >'); - }); - - it('should render value with just a value', () => { - const field = { - value: 'value', - }; - const rendered = render().container.querySelector( - 'span' - ); - expect(rendered.innerHTML).to.contain('value'); - }); - - it('should render value without tag', () => { - const field = { - value: 'value', - }; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal('value'); - }); - - it('should render number value', () => { - const field = { - value: 1.23, - }; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal('1.23'); - }); - - it('should render zero number value', () => { - const field = { - value: 0, - }; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal('0'); - }); - - it('should render negative number value', () => { - const field = { - value: -1.23, - }; - const rendered = render(); - expect(rendered.container.innerHTML).to.equal('-1.23'); - }); - - it('should render value with just a value that contains line breaks', () => { - const field = { - value: 'xxx\n\naa\nbbb\ndd', - }; - const rendered = render().container.querySelector( - 'span' - ); - expect(rendered?.innerHTML).to.contain('xxx

aa
bbb
dd'); - }); - - it('should render value with just a value that contains only one line break', () => { - const field = { - value: '\n', - }; - const rendered = render().container.querySelector( - 'span' - ); - expect(rendered?.outerHTML).to.contain('
'); - }); - - it('should render embedded html as-is when encoding is disabled', () => { - const field = { - value: 'some crazy stuff', - }; - const rendered = render().container.querySelector( - 'span' - ); - expect(rendered?.innerHTML).to.contain(field.value); - }); - - it('should render tag with a tag provided', () => { - const field = { - value: 'value', - }; - const rendered = render().container.querySelector('h1'); - expect(rendered?.innerHTML).to.contain('value'); - }); - - it('should render other attributes with other props provided', () => { - const field = { - value: 'value', - }; - const rendered = render( - - ).container.querySelector('h1'); - expect(rendered?.outerHTML).to.contain('

'); - expect(rendered?.outerHTML).to.contain('value'); - }); - - describe('edit mode', () => { - const testMetadata = { - contextItem: { - id: '{09A07660-6834-476C-B93B-584248D3003B}', - language: 'en', - revision: 'a0b36ce0a7db49418edf90eb9621e145', - version: 1, - }, - fieldId: '{414061F4-FBB1-4591-BC37-BFFA67F745EB}', - fieldType: 'single-line', - rawValue: 'Test1', - }; - - it('should render field metadata component when metadata property is present', () => { - const field = { - value: 'value', - metadata: testMetadata, - }; - - const rendered = render(); - - expect(rendered.container.innerHTML).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - 'value', - '', - ].join('') - ); - }); - - it('should render default empty field component when field value is empty in edit mode metadata', () => { - const field = { - value: '', - metadata: testMetadata, - }; - - const rendered = render(); - - expect(rendered.container.innerHTML).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - '[No text in field]', - '', - ].join('') - ); - }); - - it('should render custom empty field component when provided, when field value is empty in edit mode metadata', () => { - const field = { - value: '', - metadata: testMetadata, - }; - - const EmptyFieldEditingComponent: React.FC = () => ( - Custom Empty field value - ); - - const rendered = render( - - ); - - expect(rendered.container.innerHTML).to.equal( - [ - `${JSON.stringify( - testMetadata - )}`, - 'Custom Empty field value', - '', - ].join('') - ); - }); - - it('should render nothing when field value is empty, when editing is explicitly disabled in edit mode metadata ', () => { - const field = { - value: '', - metadata: testMetadata, - }; - - const rendered = render(); - - expect(rendered.container.innerHTML).to.equal(''); - }); - - it('should apply suppressHydrationWarning prop when in editing mode', () => { - const field = { - value: 'value', - metadata: testMetadata, - }; - - const rendered = render(); - - // Check that the rendered output contains the value - // The suppressHydrationWarning is applied via JSX now - const span = rendered.container.querySelector('span'); - expect(span).to.not.be.null; - expect(span?.innerHTML).to.contain('value'); - }); - }); -}); diff --git a/packages/react/src/components/TextOptimized.tsx b/packages/react/src/components/TextOptimized.tsx deleted file mode 100644 index 028bb81125..0000000000 --- a/packages/react/src/components/TextOptimized.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client'; -import React, { ReactElement } from 'react'; -import { FieldMetadata, isFieldValueEmpty } from '@sitecore-content-sdk/content/layout'; -import { withFieldMetadata } from '../enhancers/withFieldMetadata'; -import { withEmptyFieldEditingComponent } from '../enhancers/withEmptyFieldEditingComponent'; -import { DefaultEmptyFieldEditingComponentText } from './DefaultEmptyFieldEditingComponents'; -import { EditableFieldProps } from './sharedTypes'; - -/** - * The interface for the Text field. - * @public - */ -export interface TextField extends FieldMetadata { - value?: string | number; -} - -export interface TextProps extends EditableFieldProps { - [htmlAttributes: string]: unknown; - /** The text field data. */ - field?: TextField; - /** - * The HTML element that will wrap the contents of the field. - */ - tag?: string; - /** - * If false, HTML-encoding of the field value is disabled and the value is rendered as-is. - */ - encode?: boolean; -} - -/** - * The Text component - Optimized version using JSX instead of React.createElement. - * - * This is a modernized version that uses JSX syntax for better readability and - * maintainability, replacing all React.createElement calls with native JSX. - * - * @public - */ -export const TextOptimized = withFieldMetadata( - withEmptyFieldEditingComponent( - ({ field, tag, editable = true, encode = true, ...otherProps }) => { - if (isFieldValueEmpty(field)) { - return null; - } - - // can't use editable value if we want to output unencoded - if (!encode) { - // eslint-disable-next-line no-param-reassign, no-unused-vars - editable = false; - } - - let output: string | number | (ReactElement | string)[] = - field.value === undefined ? '' : field.value; - - // when string value isn't formatted, we should format line breaks - const splitted = String(output).split('\n'); - - if (splitted.length) { - const formatted: (ReactElement | string)[] = []; - - splitted.forEach((str, i) => { - const isLast = i === splitted.length - 1; - - formatted.push(str); - - if (!isLast) { - formatted.push(
); - } - }); - - output = formatted; - } - - let children = null; - const htmlProps: { - [htmlAttributes: string]: unknown; - children?: React.ReactNode; - } = { - ...otherProps, - }; - - if (!encode) { - htmlProps.dangerouslySetInnerHTML = { - __html: output, - }; - } else { - children = output; - } - - const Tag = (tag || 'span') as React.ElementType; - - if (field.metadata) { - return ( - - {children} - - ); - } else if (tag || !encode) { - return {children}; - } else { - return {children}; - } - }, - { defaultEmptyFieldEditingComponent: DefaultEmptyFieldEditingComponentText } - ) -); - -TextOptimized.displayName = 'TextOptimized'; diff --git a/packages/react/src/enhancers/withAppPlaceholder.test.tsx b/packages/react/src/enhancers/withAppPlaceholder.test.tsx index 0a4212f4f8..1e5c64b109 100644 --- a/packages/react/src/enhancers/withAppPlaceholder.test.tsx +++ b/packages/react/src/enhancers/withAppPlaceholder.test.tsx @@ -188,13 +188,12 @@ describe('withAppPlaceholder HOC', () => { ); }; - const phKeys = ['page-header', 'page-content']; const props: WrapperProps = { rendering: cleanComponent, page: getPage(), componentMap, }; - const Element = withAppPlaceholder(MultiKeyTestComponent, phKeys); + const Element = withAppPlaceholder(MultiKeyTestComponent); const renderedComponent = render( diff --git a/packages/react/src/enhancers/withAppPlaceholder.tsx b/packages/react/src/enhancers/withAppPlaceholder.tsx index 4fcefe81bb..9b27ed3afb 100644 --- a/packages/react/src/enhancers/withAppPlaceholder.tsx +++ b/packages/react/src/enhancers/withAppPlaceholder.tsx @@ -4,6 +4,8 @@ import { AppPlaceholder } from '../components/Placeholder/AppPlaceholder'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; import { Page } from '@sitecore-content-sdk/content/client'; import { ComponentMap } from '../components/sharedTypes'; +import { rsc as isServerRuntime } from '../rsc-utils/rsc'; +import { Placeholder } from '../components/Placeholder'; export type ComponentProps = { rendering: ComponentRendering; @@ -16,6 +18,11 @@ export type WrapperProps = { componentMap: ComponentMap; }; +/** + * Provides a slot-like functionality by wrapping a component and rendering placeholders defined in the layout data. + * @param {ComponentType} Component - The component to be wrapped around placeholders. + * @returns {React.ReactNode} A new component that renders the original component with placeholders. + */ export const withAppPlaceholder = ( Component: ComponentType ) => { @@ -24,13 +31,15 @@ export const withAppPlaceholder = = {}; for (const placeholder of Object.keys(placeholders)) { - phProps[placeholder] = ( + phProps[placeholder] = isServerRuntime ? ( + ) : ( + ); } diff --git a/packages/react/src/enhancers/withClientPlaceholder.tsx b/packages/react/src/enhancers/withClientPlaceholder.tsx new file mode 100644 index 0000000000..7dddb3a744 --- /dev/null +++ b/packages/react/src/enhancers/withClientPlaceholder.tsx @@ -0,0 +1,7 @@ +'use client'; +import { withAppPlaceholder } from './withAppPlaceholder'; + +/** + * withAppPlaceholder import for client-side context and client components + */ +export const withClientPlaceholder = withAppPlaceholder; diff --git a/packages/react/src/enhancers/withDatasourceCheck.tsx b/packages/react/src/enhancers/withDatasourceCheck.tsx index 68c34c2f78..8b977cb4c1 100644 --- a/packages/react/src/enhancers/withDatasourceCheck.tsx +++ b/packages/react/src/enhancers/withDatasourceCheck.tsx @@ -1,4 +1,5 @@ -import React, { JSX } from 'react'; +'use client'; +import React, { JSX } from 'react'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; import { useSitecore } from './withSitecore'; @@ -32,7 +33,7 @@ export function withDatasourceCheck(options?: WithDatasourceCheckOptions) { return function withDatasourceCheckHoc( Component: React.ComponentType ) { - return function WithDatasourceCheck(props: ComponentProps) { + return (props: ComponentProps) => { const { page } = useSitecore(); const EditingError = options?.editingErrorComponent ?? DefaultEditingError; diff --git a/packages/react/src/enhancers/withLoadImportMap.tsx b/packages/react/src/enhancers/withLoadImportMap.tsx index 910d15f181..1a185595f1 100644 --- a/packages/react/src/enhancers/withLoadImportMap.tsx +++ b/packages/react/src/enhancers/withLoadImportMap.tsx @@ -1,4 +1,5 @@ -import React, { useContext, JSX } from 'react'; +'use client'; +import React, { useContext, JSX } from 'react'; import { ImportMapReactContext } from '../components/SitecoreProvider'; import { ImportMapImport } from '../components/DesignLibrary/models'; diff --git a/packages/react/src/enhancers/withPlaceholder.test.tsx b/packages/react/src/enhancers/withPlaceholder.test.tsx deleted file mode 100644 index 57f3e1fa95..0000000000 --- a/packages/react/src/enhancers/withPlaceholder.test.tsx +++ /dev/null @@ -1,494 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable no-unused-expressions */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import React, { ReactElement, ReactNode } from 'react'; -import { expect } from 'chai'; -import { render } from '@testing-library/react'; -import { Page } from '@sitecore-content-sdk/content/client'; -import { LayoutServicePageState } from '@sitecore-content-sdk/content/layout'; -import { convertedDevData as normalModeDevData } from '../test-data/normal-mode-data'; -import * as metadataData from '../test-data/metadata-data'; -import { withPlaceholder } from '../enhancers/withPlaceholder'; -import { SitecoreProvider } from '../components/SitecoreProvider'; -import { PlaceholderProps } from '../components/PlaceholderCommon'; -import { ComponentRendering, RouteData } from '@sitecore-content-sdk/content/layout'; -import { Placeholder } from '../components/Placeholder'; -import { EnhancedOmit } from '@sitecore-content-sdk/core/tools'; - -type CalloutProps = PlaceholderProps & { - [prop: string]: unknown; - fields: { message: { value?: string } }; - subProp?: ReactElement; -}; - -type HomeProps = PlaceholderProps & { - [prop: string]: unknown; - rendering?: RouteData | ComponentRendering; - subProp?: ReactElement; -}; - -const DownloadCallout: React.FC = (props) => ( -
- {props.fields?.message ? props.fields.message.value : ''} -
-); - -const Home: React.FC = ({ name, subProp, ...otherProps }: HomeProps) => { - if (subProp && !otherProps.reset) { - return
{subProp}
; - } else { - return
{otherProps[name] as ReactNode}
; - } -}; - -const ErrorComponent: React.FC = () => { - throw 'Error!'; -}; - -const ErrorMessageComponent: React.FC = () => ( -
Your error has been... dealt with.
-); - -const delay = (timeout, promise?) => { - return new Promise((resolve) => { - setTimeout(resolve, timeout); - }).then(() => promise); -}; - -const componentMap = new Map>(); - -componentMap.set('DownloadCallout', DownloadCallout); -componentMap.set('Jumbotron', () =>
); -componentMap.set('BrokenComponent', () => { - throw new Error('BrokenComponent error'); -}); -componentMap.set( - 'DynamicComponent', - React.lazy(() => - delay(500, () => { - throw new Error('DynamicComponent error'); - }) - ) -); - -const testData = [{ label: 'Dev data', data: normalModeDevData }]; - -describe('withPlaceholder HOC', () => { - const api = { - edge: { - contextId: 'id', - edgeUrl: 'url', - clientContextId: 'clientId', - }, - local: { - apiKey: 'apiKey', - apiHost: 'apiHost', - path: 'path', - }, - }; - - const getPage = (): Page => ({ - layout: normalModeDevData, - locale: 'en', - mode: { - name: LayoutServicePageState.Normal, - isNormal: true, - isPreview: false, - isEditing: false, - isDesignLibrary: false, - designLibrary: { - isVariantGeneration: false, - }, - }, - }); - - describe('Error handling', () => { - before(() => { - // Set to development mode to show error details - process.env.NODE_ENV = 'development'; - }); - - it('should render default error component on wrapped component error', () => { - const phKey = 'page-content'; - const props: EnhancedOmit = { - name: phKey, - rendering: null as unknown as ComponentRendering, - }; - const Element = withPlaceholder({ phKey })(ErrorComponent); - const renderedComponent = render( - - - - ); - expect( - renderedComponent.container.querySelectorAll('.sc-content-sdk-placeholder-error').length - ).to.equal(1); - }); - - it('should render custom component error on wrapped component error, when provided', () => { - const phKey = 'page-content'; - const props: EnhancedOmit = { - name: phKey, - rendering: null as unknown as ComponentRendering, - errorComponent: ErrorMessageComponent, - }; - const Element = withPlaceholder({ phKey })(ErrorComponent); - const renderedComponent = render( - - - - ); - expect(renderedComponent.container.querySelectorAll('.error-handled').length).to.equal(1); - }); - - it('should render nested broken component', () => { - const component = ( - normalModeDevData.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] - ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; - const phKey = 'page-content'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder({ phKey })(Home); - const renderedComponent = render( - - - - ); - - expect( - renderedComponent.container.querySelectorAll('.download-callout-mock').length - ).to.equal(1); - expect( - renderedComponent.container.querySelectorAll('.sc-content-sdk-placeholder-error').length - ).to.equal(1); - expect(renderedComponent.container.querySelectorAll('h4').length).to.equal(1); - expect(renderedComponent.container.querySelector('h4')?.outerHTML).to.equal( - '

Loading component...

' - ); - }); - - it('should render nested components using custom error component', () => { - const component = ( - normalModeDevData.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] - ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; - const phKey = 'page-content'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - errorComponent: ErrorMessageComponent, - componentLoadingMessage: 'Custom loading message...', - }; - const Element = withPlaceholder({ phKey })(Home); - const renderedComponent = render( - - - - ); - - expect( - renderedComponent.container.querySelectorAll('.download-callout-mock').length - ).to.equal(1); - expect(renderedComponent.container.querySelectorAll('.error-handled').length).to.equal(1); - expect(renderedComponent.container.querySelectorAll('h4').length).to.equal(1); - expect(renderedComponent.container.querySelector('h4')?.outerHTML).to.equal( - '

Custom loading message...

' - ); - }); - }); - - testData.forEach((dataSet) => { - describe(`with ${dataSet.label}`, () => { - it('should render a placeholder with given key', () => { - const component = ( - dataSet.data.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] - ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; - const phKey = 'page-content'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder({ phKey })(Home); - const renderedComponent = render( - - - - ); - expect( - renderedComponent.container.querySelectorAll('.download-callout-mock').length - ).to.equal(1); - }); - - it('should render a placeholder with given key and prop', () => { - const component = ( - dataSet.data.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] - ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; - const phKeyAndProp = { - phKey: 'page-header', - prop: 'subProp', - }; - const props: EnhancedOmit = { - name: 'page-header', - rendering: component, - }; - const Element = withPlaceholder(phKeyAndProp)(Home); - const renderedComponent = render( - - - - ); - expect( - renderedComponent.container.querySelectorAll('.home-mock-with-prop').length - ).to.not.equal(0); - expect(renderedComponent.container.querySelectorAll('.jumbotron-mock').length).to.equal(1); - }); - - it('should use propsTransformer method when provided', () => { - const component = ( - dataSet.data.sitecore.route?.placeholders.main as (ComponentRendering | RouteData)[] - ).find((c) => (c as ComponentRendering).componentName) as ComponentRendering; - const phKeyAndProp = { - phKey: 'page-header', - prop: 'subProp', - }; - const phOptions = { - propsTransformer: (props) => { - return { ...props, reset: true }; - }, - }; - const props: EnhancedOmit = { - name: 'page-header', - rendering: component, - }; - const Element = withPlaceholder(phKeyAndProp, phOptions)(Home); - const renderedComponent = render( - - - - ); - expect( - renderedComponent.container.querySelectorAll('.home-mock-with-prop').length - ).to.equal(0); - expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.not.equal(0); - }); - }); - }); - - describe('Metadata Mode', () => { - const defaultPage = getPage(); - const editModePage: Page = { - ...defaultPage, - mode: { - ...defaultPage.mode, - name: LayoutServicePageState.Edit, - isEditing: true, - }, - }; - - const { - layoutData, - layoutDataWithEmptyPlaceholder, - layoutDataForNestedDynamicPlaceholder, - layoutDataWithUnknownComponent, - } = metadataData; - - const componentMap = new Map(); - - componentMap.set('Header', () => ( -
- -
- )); - componentMap.set('Logo', () =>
); - - it('should render a placeholder with given key', () => { - const component = layoutData.sitecore.route; - const phKey = 'main'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder({ phKey })(Home); - const renderedComponent = render( - - - - ); - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '
', - '', - '', - '
', - '', - '', - '
', - '', - '', - '
', - ].join('') - ); - }); - - it('should render a placeholder with given key and prop', () => { - const component = layoutData.sitecore.route; - const phKey = 'main'; - const phKeyAndProp = { - phKey, - prop: 'subProp', - }; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder(phKeyAndProp)(Home); - const renderedComponent = render( - - - - ); - - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '
', - '', - '', - '
', - '', - '', - '
', - '', - '', - '
', - ].join('') - ); - }); - - it('should render code blocks even if placeholder is empty', () => { - const component = layoutDataWithEmptyPlaceholder.sitecore.route; - const phKey = 'main'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder({ phKey })(Home); - const renderedComponent = render( - - - - ); - - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '
', - ].join('') - ); - }); - - it('should render missing component with code blocks if component is not registered', () => { - const component = layoutDataWithUnknownComponent.sitecore.route; - const phKey = 'main'; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder({ phKey })(Home); - const renderedComponent = render( - - - - ); - - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '

Unknown

Content SDK component is missing React implementation. See the developer console for more information.

', - '', - '', - '
', - ].join('') - ); - }); - - it('should render dynamic placeholder', () => { - const phKey = 'container-1'; - const layoutData = layoutDataForNestedDynamicPlaceholder('container-{*}'); - const component = layoutData.sitecore.route; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder({ phKey })(Home); - const renderedComponent = render( - - - - ); - - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '
', - '', - '', - '
', - '', - '
', - '', - '', - '
', - ].join('') - ); - }); - - it('should render double digit dynamic placeholder', () => { - const phKey = 'container-1-2'; - const layoutData = layoutDataForNestedDynamicPlaceholder('container-1-{*}'); - const component = layoutData.sitecore.route; - const props: EnhancedOmit = { - name: phKey, - rendering: component, - }; - const Element = withPlaceholder({ phKey })(Home); - const renderedComponent = render( - - - - ); - - expect(renderedComponent?.container.innerHTML).to.equal( - [ - '
', - '', - '', - '
', - '', - '', - '
', - '', - '
', - '', - '', - '
', - ].join('') - ); - }); - }); -}); diff --git a/packages/react/src/enhancers/withPlaceholder.tsx b/packages/react/src/enhancers/withPlaceholder.tsx deleted file mode 100644 index 645d033652..0000000000 --- a/packages/react/src/enhancers/withPlaceholder.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import React from 'react'; -import { ComponentRendering, RouteData } from '@sitecore-content-sdk/content/layout'; -import { withComponentMap } from './withComponentMap'; -import { withSitecore } from './withSitecore'; -import { - PlaceholderProps, - getPlaceholderRenderings, - getRenderedComponents, -} from '../components/Placeholder'; -import ErrorBoundary from '../components/ErrorBoundary'; - -export interface WithPlaceholderOptions { - /** - * Function to map incoming placeholder props into rendering data to use for the placeholder data. - * Normally in a Content SDK component, props.rendering is passed the component data, and that is the default. - * However, if your component data is in a different prop, like say 'route' in a sample app, - * this lets you map that. - */ - resolvePlaceholderDataFromProps?: (props: unknown) => ComponentRendering | RouteData; - /** - * Function to alter the placeholder props from within the HOC. Enables the props to be - * transformed before being used by the placeholder/HOC, for example to customize the - * error or missing component display - */ - propsTransformer?: (props: PlaceholderProps) => PlaceholderProps; -} - -export interface PlaceholderToPropMapping { - /** - * The name of the placeholder this component will expose - */ - phKey: string; - /** - * The name of the prop on your wrapped component that you would like the placeholder data injected on - */ - prop?: string; -} - -// TODO: this HOC and Placeholder are kinda doing the same thing. Could the be combined? -export type WithPlaceholderSpec = PlaceholderToPropMapping | PlaceholderToPropMapping[]; - -/** - * HOC to provide client-side placeholder functionality to a component. - * @param {WithPlaceholderSpec} placeholders - * @param {WithPlaceholderOptions} [options] - * @public - */ -export function withPlaceholder( - placeholders: WithPlaceholderSpec, - options?: WithPlaceholderOptions -) { - return ( - WrappedComponent: - | React.ComponentClass - | React.FunctionComponent - ) => { - const WithPlaceholder = (props: PlaceholderProps) => { - let childProps: PlaceholderProps = { ...props }; - - delete childProps.componentMap; - - if (options && options.propsTransformer) { - childProps = options.propsTransformer(childProps); - } - - const renderingData = - options && options.resolvePlaceholderDataFromProps - ? options.resolvePlaceholderDataFromProps(childProps) - : childProps.rendering; - - const definitelyArrayPlacholders = !Array.isArray(placeholders) - ? [placeholders] - : placeholders; - - definitelyArrayPlacholders.forEach((placeholder) => { - let placeholderData: ComponentRendering[]; - - placeholderData = getPlaceholderRenderings( - renderingData, - placeholder.phKey, - childProps.page.mode.isEditing - ); - if (placeholderData) { - childProps[placeholder.prop || placeholder.phKey] = getRenderedComponents( - props, - placeholderData - ); - } - }); - - return ( - - - - ); - }; - - return withSitecore()(withComponentMap(WithPlaceholder)); - }; -} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index be1002eb13..c70778a33b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -95,7 +95,6 @@ export { WithSitecoreHocProps, } from './enhancers/withSitecore'; export { withEditorChromes } from './enhancers/withEditorChromes'; -export { withPlaceholder } from './enhancers/withPlaceholder'; export { withDatasourceCheck } from './enhancers/withDatasourceCheck'; export { withFieldMetadata } from './enhancers/withFieldMetadata'; export { withEmptyFieldEditingComponent } from './enhancers/withEmptyFieldEditingComponent'; @@ -107,15 +106,3 @@ export { } from './components/DefaultEmptyFieldEditingComponents'; export { ClientEditingChromesUpdate } from './components/ClientEditingChromesUpdate'; export { SitePathService, SitePathServiceConfig } from '@sitecore-content-sdk/content/site'; -export { SitecoreProviderOptimized } from './components/SitecoreProviderOptimized'; -export { FormOptimized } from './components/FormOptimized'; -export { DesignLibraryOptimized } from './components/DesignLibrary/DesignLibraryOptimized'; -export { TextOptimized } from './components/TextOptimized'; -export { LinkOptimized } from './components/LinkOptimized'; -export { DateFieldOptimized } from './components/DateOptimized'; -export { FileOptimized } from './components/FileOptimized'; -export { RichTextOptimized } from './components/RichTextOptimized'; -export { - DefaultEmptyFieldEditingComponentTextOptimized, - DefaultEmptyFieldEditingComponentImageOptimized, -} from './components/DefaultEmptyFieldEditingComponentsOptimized'; From df45523e0dbd600e2fe79bef1958891b115ff313 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Thu, 12 Feb 2026 20:39:39 -0500 Subject: [PATCH 08/13] small refactor for not-placeholders --- .../react/src/components/DesignLibrary/DesignLibrary.tsx | 2 +- packages/react/src/components/EditingScripts.tsx | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/DesignLibrary/DesignLibrary.tsx b/packages/react/src/components/DesignLibrary/DesignLibrary.tsx index 526f2db9f7..1160f2cc90 100644 --- a/packages/react/src/components/DesignLibrary/DesignLibrary.tsx +++ b/packages/react/src/components/DesignLibrary/DesignLibrary.tsx @@ -156,7 +156,7 @@ export const DesignLibrary = () => { cancelled = true; unsubscribe && unsubscribe(); }; - }, [isVariantGeneration, uid]); + }, [isDesignLibrary, isVariantGeneration, uid, loadImportMap, propsState]); return (
diff --git a/packages/react/src/components/EditingScripts.tsx b/packages/react/src/components/EditingScripts.tsx index 5d2c38cab1..f5db06464a 100644 --- a/packages/react/src/components/EditingScripts.tsx +++ b/packages/react/src/components/EditingScripts.tsx @@ -1,7 +1,10 @@ 'use client'; -import React, { JSX } from 'react'; +import React from 'react'; import { useSitecore } from '../enhancers/withSitecore'; -import { getContentSdkPagesClientData, getDesignLibraryScriptLink } from '@sitecore-content-sdk/content/editing'; +import { + getContentSdkPagesClientData, + getDesignLibraryScriptLink, +} from '@sitecore-content-sdk/content/editing'; /** * Renders client scripts and data for editing/preview mode for Pages. @@ -9,7 +12,7 @@ import { getContentSdkPagesClientData, getDesignLibraryScriptLink } from '@sitec * @returns A JSX element containing the editing scripts or an empty fragment if not in editing/preview mode. * @public */ -export const EditingScripts = (): JSX.Element => { +export const EditingScripts = () => { const { page: { mode, layout }, api, From 0d17b9898b8e3ebb2108c62057319f0b8307e4f5 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Thu, 12 Feb 2026 20:39:59 -0500 Subject: [PATCH 09/13] major placeholder logic refactor --- .../components/Placeholder/AppPlaceholder.tsx | 178 +++++++----------- .../Placeholder/Placeholder.test.tsx | 49 +++-- .../components/Placeholder/Placeholder.tsx | 135 ++++--------- .../src/components/Placeholder/models.ts | 106 +++++------ .../Placeholder/placeholder-utils.test.tsx | 32 ++-- .../Placeholder/placeholder-utils.tsx | 126 +++++++++---- 6 files changed, 280 insertions(+), 346 deletions(-) diff --git a/packages/react/src/components/Placeholder/AppPlaceholder.tsx b/packages/react/src/components/Placeholder/AppPlaceholder.tsx index 079dde57a0..3c5055e94c 100644 --- a/packages/react/src/components/Placeholder/AppPlaceholder.tsx +++ b/packages/react/src/components/Placeholder/AppPlaceholder.tsx @@ -1,13 +1,10 @@ -import { AppPlaceholderProps } from './models'; +import { AppPlaceholderProps, ChildComponentProps, ComponentForRendering } from './models'; import { - getAppComponentProps, - getComponentForRendering, + drawPlaceholderComponents, getPlaceholderRenderings, renderEmptyPlaceholder, } from './placeholder-utils'; import React from 'react'; -import { PlaceholderMetadata } from './PlaceholderMetadata'; -import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; import ErrorBoundary from '../ErrorBoundary'; import { ClientComponentWrapper } from './ClientComponentWrapper'; import { rsc } from '#rsc-env'; @@ -21,124 +18,77 @@ import { rsc } from '#rsc-env'; * @public */ export const AppPlaceholder = (props: AppPlaceholderProps) => { - const { rendering: parentRendering, componentMap, page } = props; + const renderingData = props.rendering; const placeholderRenderings = getPlaceholderRenderings( - parentRendering, + renderingData, props.name, - page.mode.isEditing + props.page.mode.isEditing ); - const components = placeholderRenderings - .map((rendering, index) => { - const { - component: Component, - isEmpty, - componentType, - dynamic, - } = getComponentForRendering( - rendering, - props.name, - componentMap, - props.hiddenRenderingComponent, - props.missingComponentComponent - ); - const isClient = componentType === 'client'; - const key = rendering.uid || `component-${index}`; + const drawAppPlaceholderChildComponent = ( + componentForRendering: ComponentForRendering, + renderedProps: ChildComponentProps, + key?: string + ) => { + // Client wrapper is required only when component crosses boundary from server to client. + // It happens when component is marker as client and rendered in RSC context. + // Also, it is not required when component is hidden or empty, as it will be rendered whthout boundary crossing. + const useClientWrapper = + componentForRendering.componentType === 'client' && rsc && !componentForRendering.isEmpty; + return useClientWrapper ? ( + + ) : ( + + ); + }; - // Use rsc context to determine the current runtime - const componentRuntime = rsc ? 'server' : 'client'; + const applyConditionalTransform = (renderedComponents: React.JSX.Element[]) => { + const isEmpty = !placeholderRenderings.length; - const renderedProps = getAppComponentProps(props, rendering); + if (isEmpty) { + const rendered = props.renderEmpty + ? props.renderEmpty(renderedComponents) + : renderedComponents; - const finalRenderedProps = props.modifyComponentProps - ? props.modifyComponentProps(renderedProps) - : renderedProps; + return props.page.mode.isEditing ? renderEmptyPlaceholder(rendered) : rendered; + } else if (props.render) { + return props.render(renderedComponents, placeholderRenderings, props); + } else if (props.renderEach) { + const renderEach = props.renderEach; - // Client wrapper is required only when component crosses boundary from server to client. - // It happens when component is marker as client and rendered in RSC context. - // Also, it is not required when component is hidden or empty, as it will be rendered whthout boundary crossing. - const useClientWrapper = isClient && rsc && !isEmpty; - let rendered = useClientWrapper ? ( - - ) : ( - - ); + return renderedComponents.map((component, index) => { + if (component && component.props && component.props.type === 'text/sitecore') { + return component; + } - if (!isEmpty) { - const errorBoundaryKey = rendered.type + '-' + index; - const disableSuspense = props.disableSuspense || false; - rendered = ( - - {rendered} - - ); - } + return renderEach(component, index); + }); + } else { + return renderedComponents; + } + }; - // if in edit mode then emit shallow chromes for hydration in Pages - if (page.mode.isEditing) { - const key = (rendering.uid as string) || `component-${index}`; - return ( - - {rendered} - - ); - } - return rendered; - }) - .filter((element) => element); - - const finalRendering = page.mode.isEditing - ? [ - - {components} - , - ] - : components; - - const placeholderEmpty = !placeholderRenderings.length; - - if (placeholderEmpty) { - const rendered = props.renderEmpty ? props.renderEmpty(finalRendering) : finalRendering; - - return page.mode.isEditing ? renderEmptyPlaceholder(rendered) : rendered; - } - - if (props.render) { - return props.render(components, placeholderRenderings, props); - } else if (props.renderEach) { - const renderEach = props.renderEach; - - return finalRendering.map((component, index) => { - if (component && component.props && component.props.type === 'text/sitecore') { - return component; - } + const componentRuntime = rsc ? 'server' : 'client'; + const components = drawPlaceholderComponents( + props, + placeholderRenderings, + drawAppPlaceholderChildComponent, + componentRuntime + ); - return renderEach(component, index); - }); - } else { - return finalRendering; - } + const finalOutput = applyConditionalTransform(components); + // Using error boundary for errors that may happen within Placeholder itself + return {finalOutput}; }; + diff --git a/packages/react/src/components/Placeholder/Placeholder.test.tsx b/packages/react/src/components/Placeholder/Placeholder.test.tsx index fe8167bd8c..1d1af1072a 100644 --- a/packages/react/src/components/Placeholder/Placeholder.test.tsx +++ b/packages/react/src/components/Placeholder/Placeholder.test.tsx @@ -1,4 +1,4 @@ -/* eslint-disable no-unused-vars */ +/* eslint-disable no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable no-unused-expressions */ /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -34,7 +34,7 @@ import * as HiddenRendering from '../HiddenRendering'; import * as ErrorBoundary from '../ErrorBoundary'; import { MissingComponent, MissingComponentProps } from '../MissingComponent'; import { Placeholder } from './Placeholder'; -import { ComponentProps } from './models'; +import { ChildComponentProps } from './models'; import { SitecoreProvider } from '../SitecoreProvider'; import { Page, PageMode } from '@sitecore-content-sdk/content/client'; @@ -86,7 +86,7 @@ const DownloadCallout: React.FC<{ extraDiv?: boolean; }> = (props) => (
- {props.fields.message ? props.fields.message.value : ''} + {props.fields?.message ? props.fields.message.value : ''} {props.extraDiv ?
extra!
: null}
); @@ -261,40 +261,35 @@ describe('', () => { ).to.be.true; }); - it('should apply modifyComponentProps to the final props', () => { + it('should pass passThroughComponentProps to rendered components', () => { const page = getPage(); - page.layout = dataSet.data; - const component = dataSet.data.sitecore.route as any; - const phKey = 'main'; - const expectedMessage = (component.placeholders.main as any[]).find((c) => c.componentName) - .fields.message; - - const modifyComponentProps = (props: ComponentProps) => { - if (props.rendering?.componentName === 'DownloadCallout') { - return { - ...props, - extraDiv: true, - }; - } - - return props; + // Create a simple test component directly without nesting + const testRendering: RouteData = { + placeholders: { + 'test-placeholder': [ + { + componentName: 'DownloadCallout', + uid: 'download-uid', + fields: { + message: { value: 'Test message' }, + }, + }, + ], + }, }; + page.layout = { sitecore: { context: {}, route: testRendering } }; const renderedComponent = render( ); - expect( - renderedComponent.container - .querySelector('.download-callout-mock') - ?.innerHTML.indexOf(expectedMessage.value) !== -1 - ).to.be.true; + expect(renderedComponent.container.querySelector('.download-callout-mock')).to.not.be.null; expect(renderedComponent.container.querySelectorAll('div.extra').length).to.equal(1); }); }); diff --git a/packages/react/src/components/Placeholder/Placeholder.tsx b/packages/react/src/components/Placeholder/Placeholder.tsx index 4ffe574468..f5e61abf5f 100644 --- a/packages/react/src/components/Placeholder/Placeholder.tsx +++ b/packages/react/src/components/Placeholder/Placeholder.tsx @@ -1,18 +1,15 @@ -'use client'; +'use client'; import React, { useEffect } from 'react'; -import { PlaceholderProps } from './models'; +import { ChildComponentProps, ComponentForRendering, PlaceholderProps } from './models'; import { withComponentMap } from '../../enhancers/withComponentMap'; import { PagesEditor } from '@sitecore-content-sdk/content/editing'; import { withSitecore } from '../../enhancers/withSitecore'; import { - getComponentForRendering, getPlaceholderRenderings, - getRenderedComponentProps, + drawPlaceholderComponents, renderEmptyPlaceholder, } from './placeholder-utils'; import ErrorBoundary from '../ErrorBoundary'; -import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; -import { PlaceholderMetadata } from './PlaceholderMetadata'; const PlaceholderComponent = (props: PlaceholderProps) => { const renderingData = props.rendering; @@ -23,30 +20,46 @@ const PlaceholderComponent = (props: PlaceholderProps) => { ); const isEmpty = !placeholderRenderings.length; - // componentDidMount equivalent: Reset chromes when placeholder is empty useEffect(() => { if (isEmpty && PagesEditor.isActive()) { PagesEditor.resetChromes(); } - }, [isEmpty]); // Empty array = runs once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // Empty array so it runs only once on mount + + const drawPlaceholderChildComponent = ( + componentForRendering: ComponentForRendering, + renderedProps: ChildComponentProps, + key?: string + ) => { + return ( + + ); + }; - const renderPlhChildren = () => { + const applyConditionalTransform = (renderedComponents: React.JSX.Element[]) => { const childProps = { ...props }; - // TODO: cleanup more props delete childProps.componentMap; - - const components = getRenderedComponents(props, placeholderRenderings); + const isEmpty = !placeholderRenderings.length; if (isEmpty) { - const rendered = props.renderEmpty ? props.renderEmpty(components) : components; + const rendered = props.renderEmpty + ? props.renderEmpty(renderedComponents) + : renderedComponents; return props.page.mode.isEditing ? renderEmptyPlaceholder(rendered) : rendered; } else if (props.render) { - return props.render(components, placeholderRenderings, childProps); + return props.render(renderedComponents, placeholderRenderings, childProps); } else if (props.renderEach) { const renderEach = props.renderEach; - return components.map((component, index) => { + return renderedComponents.map((component, index) => { if (component && component.props && component.props.type === 'text/sitecore') { return component; } @@ -54,92 +67,19 @@ const PlaceholderComponent = (props: PlaceholderProps) => { return renderEach(component, index); }); } else { - return components; + return renderedComponents; } }; - // Using error boundary for errors that may happen within Placeholder itself - return {renderPlhChildren()}; -}; - -/** - * Renders the components for the placeholder based on the provided rendering data. - * @param {PlaceholderProps} props placeholder component props - * @param {ComponentRendering[]} placeholderRenderings renderings within placeholder - * @returns {React.ReactNode | React.ReactElement[]} rendered components - */ -export const getRenderedComponents = ( - props: PlaceholderProps, - placeholderRenderings: ComponentRendering[] -) => { - const { name, missingComponentComponent, hiddenRenderingComponent } = props; - - const transformedComponents = placeholderRenderings - .map((componentRendering: ComponentRendering, index: number) => { - const component = getComponentForRendering( - componentRendering, - name, - props.componentMap, - hiddenRenderingComponent, - missingComponentComponent - ); - const key = componentRendering.uid || `component-${index}`; - - const renderedProps = getRenderedComponentProps(props, componentRendering, key); - const finalRenderedProps = props.modifyComponentProps - ? props.modifyComponentProps(renderedProps) - : renderedProps; - - let rendered = React.createElement<{ [attr: string]: unknown }>( - component.component as React.ComponentType, - finalRenderedProps - ); - - if (!component.isEmpty) { - const errorBoundaryKey = rendered.type + '-' + index; - - const disableSuspense = props.disableSuspense || false; - rendered = ( - - {rendered} - - ); - } - - // if in edit mode then emit shallow chromes for hydration in Pages - if (props.page.mode.isEditing) { - return ( - - {rendered} - - ); - } - - return rendered; - }) - .filter((element) => element); // remove nulls - - if (props.page.mode.isEditing) { - return [ - - {transformedComponents} - , - ]; - } + const components = drawPlaceholderComponents( + props, + placeholderRenderings, + drawPlaceholderChildComponent + ); - return transformedComponents; + const finalOutput = applyConditionalTransform(components); + // Using error boundary for errors that may happen within Placeholder itself + return {finalOutput}; }; /** @@ -147,3 +87,4 @@ export const getRenderedComponents = ( * @public */ export const Placeholder = withSitecore()(withComponentMap(PlaceholderComponent)); + diff --git a/packages/react/src/components/Placeholder/models.ts b/packages/react/src/components/Placeholder/models.ts index 4f26188689..5aa045db2e 100644 --- a/packages/react/src/components/Placeholder/models.ts +++ b/packages/react/src/components/Placeholder/models.ts @@ -10,22 +10,6 @@ type ErrorComponentProps = { [prop: string]: unknown; }; -/** Provided for the component which represents rendering data */ -export type ComponentProps = { - [key: string]: unknown; - rendering: ComponentRendering; -}; - -export interface AppComponentProps { - fields: { - [name: string]: Field | Item | Item[]; - }; - params: { - [name: string]: string; - }; - rendering: ComponentRendering; -} - export interface BasePlaceholderProps { /** Name of the placeholder to render. */ name: string; @@ -86,59 +70,37 @@ export interface BasePlaceholderProps { * Mutually exclusive with `render`. */ renderEach?: (component: React.ReactNode, index: number) => React.ReactNode; -} - -/** - * The interface for the Placeholder component props. - * @public - */ -export interface PlaceholderProps extends BasePlaceholderProps { - [key: string]: unknown; - /** - * Component Map will be used to map Sitecore component names to app implementation - * When rendered within a component, defaults to the context componentMap. - * When rendered as a server placeholder, this prop must be provided. This prop is not used in AppPlaceholder. - */ - componentMap?: ComponentMap; /** * Modify final props of component (before render) provided by rendering data. * Can be used in case when you need to insert additional data into the component. - * @param {ComponentProps} componentProps component props to be modified - * @returns {ComponentProps} modified or initial props + * @param {ChildComponentProps} componentProps component props to be modified + * @returns {ChildComponentProps} modified or initial props */ - modifyComponentProps?: (componentProps: ComponentProps) => ComponentProps; + modifyComponentProps?: (componentProps: ChildComponentProps) => ChildComponentProps; /** - * Render props function that enables control over the rendering of the components in the placeholder. - * Useful for techniques like wrapping each child in a wrapper component. + * An alternative to `modifyComponentProps` that allows passing additional props to the component without modifying the CSDK Placeholder props from Sitecore. + * These props will be merged into the result of modifyComponentProps if you use both + * Make sure to not include non-serializable props here in RSC server context https://react.dev/reference/rsc/use-server#serializable-parameters-and-return-values */ - render?: ( - components: React.ReactNode[], - data: ComponentRendering[], - props: PlaceholderProps - ) => React.ReactNode; -} + passThroughComponentProps?: { + [key: string]: unknown; + }; -/** - * The interface for the AppPlaceholder component props. - * @public - */ -export interface AppPlaceholderProps extends BasePlaceholderProps { /** * Component Map will be used to map Sitecore component names to app implementation * When rendered within a component, defaults to the context componentMap. * When rendered as a server placeholder, this prop must be provided. This prop is not used in AppPlaceholder. */ - componentMap: ComponentMap; - /** - * Modify final props of component (before render) provided by rendering data. - * Can be used in case when you need to insert additional data into the component. - * @param {AppComponentProps} componentProps component props to be modified - * @returns {AppComponentProps} modified or initial props - */ - modifyComponentProps?: (componentProps: AppComponentProps) => AppComponentProps; + componentMap?: ComponentMap; +} +/** + * The interface for the client Placeholder component props. + * @public + */ +export type PlaceholderProps = BasePlaceholderProps & { /** * Render props function that enables control over the rendering of the components in the placeholder. * Useful for techniques like wrapping each child in a wrapper component. @@ -146,17 +108,41 @@ export interface AppPlaceholderProps extends BasePlaceholderProps { render?: ( components: React.ReactNode[], data: ComponentRendering[], - props: AppPlaceholderProps + props: PlaceholderProps ) => React.ReactNode; -} +}; -export type RenderedProps = Omit & { +/** + * The interface for the AppPlaceholder component props. + * @public + */ +export type AppPlaceholderProps = Omit & + Required> & { + /** + * Render props function that enables control over the rendering of the components in the placeholder. + * Useful for techniques like wrapping each child in a wrapper component. + */ + render?: ( + components: React.ReactNode[], + data: ComponentRendering[], + props: AppPlaceholderProps + ) => React.ReactNode; + }; + +export type RenderedProps = ChildComponentProps & { key: string; - fields: { [field: string]: unknown }; - params: { [param: string]: unknown }; - rendering: ComponentRendering; }; +export interface ChildComponentProps { + fields: { + [name: string]: Field | Item | Item[]; + }; + params: { + [name: string]: string; + }; + rendering: ComponentRendering; +} + export interface ComponentForRendering { component: React.ComponentType; isEmpty: boolean; diff --git a/packages/react/src/components/Placeholder/placeholder-utils.test.tsx b/packages/react/src/components/Placeholder/placeholder-utils.test.tsx index ab8dec3abe..0b44218f18 100644 --- a/packages/react/src/components/Placeholder/placeholder-utils.test.tsx +++ b/packages/react/src/components/Placeholder/placeholder-utils.test.tsx @@ -7,7 +7,7 @@ import { createSandbox } from 'sinon'; import { getPlaceholderRenderings, getSXAParams, - getRenderedComponentProps, + getChildComponentProps, getComponentForRendering, } from './placeholder-utils'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; @@ -183,7 +183,7 @@ describe('placeholder-utils', () => { }); }); - describe('getRenderedComponentProps', () => { + describe('getChildComponentProps', () => { it('should merge placeholder and rendering fields', () => { const placeholderProps: PlaceholderProps = { name: 'test-placeholder', @@ -215,13 +215,14 @@ describe('placeholder-utils', () => { }, }; - const result = getRenderedComponentProps(placeholderProps, componentRendering, 'test-key'); + const result = getChildComponentProps(placeholderProps, componentRendering); expect(result.fields).to.deep.equal({ placeholderField: { value: 'placeholder-value' }, renderingField: { value: 'rendering-value' }, sharedField: { value: 'rendering-shared-value' }, // rendering should override placeholder }); + expect(result.rendering).to.equal(componentRendering); }); it('should merge placeholder and rendering params', () => { @@ -257,7 +258,7 @@ describe('placeholder-utils', () => { }, }; - const result = getRenderedComponentProps(placeholderProps, componentRendering, 'test-key'); + const result = getChildComponentProps(placeholderProps, componentRendering); expect(result.params).to.deep.equal({ placeholderParam: 'placeholder-param-value', @@ -267,9 +268,10 @@ describe('placeholder-utils', () => { Styles: 'custom-class', styles: 'col-lg-6 custom-class', // SXA styles should be added }); + expect(result.rendering).to.equal(componentRendering); }); - it('should return composite props object', () => { + it('should return minimal child component props object', () => { const placeholderProps: PlaceholderProps = { name: 'test-placeholder', rendering: { componentName: 'Test', uid: 'test-uid' }, @@ -302,24 +304,24 @@ describe('placeholder-utils', () => { }, }; - const result = getRenderedComponentProps(placeholderProps, componentRendering, 'test-key'); + const result = getChildComponentProps(placeholderProps, componentRendering); - expect(result.key).to.equal('test-key'); + // getChildComponentProps returns only fields, params, and rendering expect(result.rendering).to.equal(componentRendering); - expect(result.customProp).to.equal('custom-value'); - expect(result.componentMap).to.equal(placeholderProps.componentMap); - - // These props should be removed from the result - expect(result.missingComponentComponent).to.be.undefined; - expect(result.hiddenRenderingComponent).to.be.undefined; - expect(result.name).to.be.undefined; - expect(result.fields).to.deep.equal({ testField: { value: 'test-value' }, }); expect(result.params).to.deep.equal({ testParam: 'test-param', }); + + // getChildComponentProps does not include these props + expect((result as any).key).to.be.undefined; + expect((result as any).customProp).to.be.undefined; + expect((result as any).componentMap).to.be.undefined; + expect((result as any).missingComponentComponent).to.be.undefined; + expect((result as any).hiddenRenderingComponent).to.be.undefined; + expect((result as any).name).to.be.undefined; }); }); diff --git a/packages/react/src/components/Placeholder/placeholder-utils.tsx b/packages/react/src/components/Placeholder/placeholder-utils.tsx index 50730c78b1..ac3989c012 100644 --- a/packages/react/src/components/Placeholder/placeholder-utils.tsx +++ b/packages/react/src/components/Placeholder/placeholder-utils.tsx @@ -20,15 +20,17 @@ import { FEAAS_WRAPPER_RENDERING_NAME, } from '../FEaaS'; import { - AppComponentProps, + ChildComponentProps, BasePlaceholderProps, ComponentForRendering, PlaceholderProps, - RenderedProps, + AppPlaceholderProps, } from './models'; +import { PlaceholderMetadata } from './PlaceholderMetadata'; +import ErrorBoundary from '../ErrorBoundary'; /** - * Get the renderings for the specified placeholder from the rendering data. + * Get the renderings for the specified placeholder from the rendering layout data. * @param {ComponentRendering | RouteData } rendering rendering data * @param {string} name placeholder name * @param {boolean} isEditing whether components should be rendered in editing mode @@ -109,44 +111,16 @@ export const renderEmptyPlaceholder = (node: React.ReactNode | React.ReactElemen return
{node}
; }; -/** - * Get component props to be passed to the rendered component. - * @param {PlaceholderProps} placeholderProps current placeholder props - * @param {ComponentRendering} componentRendering rendering to be rendered - * @param {string} renderingKey unique key to pass over to rendering props - * @returns {RenderedProps} props to be passed to the rendered component - */ -export const getRenderedComponentProps = ( - placeholderProps: PlaceholderProps, - componentRendering: ComponentRendering, - renderingKey: string -): RenderedProps => { - // eslint-disable-next-line no-unused-vars - const { fields, params: placeholderParams, ...passThroughProps } = placeholderProps; - delete passThroughProps.missingComponentComponent; - delete passThroughProps.hiddenRenderingComponent; - delete (passThroughProps as { name?: string }).name; - - const mergedContentProps = getAppComponentProps(placeholderProps, componentRendering); - - return { - key: renderingKey, - ...passThroughProps, - ...mergedContentProps, - rendering: componentRendering, - }; -}; - /** * Merge specific placeholder props with component field and params content props. * @param {BasePlaceholderProps} placeholderProps placeholder props * @param {ComponentRendering} componentRendering component rendering * @returns {ComponentProps} merged props */ -export function getAppComponentProps( +export function getChildComponentProps( placeholderProps: T, componentRendering: ComponentRendering -): AppComponentProps { +): ChildComponentProps { const fields = { ...(placeholderProps.fields || {}), ...(componentRendering.fields || {}) }; const params = { ...(placeholderProps.params || {}), ...(componentRendering.params || {}) }; return { @@ -282,3 +256,89 @@ export const getComponentForRendering = ( isEmpty: false, }; }; + +export const drawPlaceholderComponents = ( + props: PlaceholderProps | AppPlaceholderProps, + placeholderRenderings: ComponentRendering[], + drawPlaceholderChildComponent: ( + componentForRendering: ComponentForRendering, + renderedProps: ChildComponentProps, + key?: string + ) => React.JSX.Element, + componentRuntime?: 'server' | 'client' | undefined +) => { + const { name, missingComponentComponent, hiddenRenderingComponent } = props; + const isEditing = props.page.mode.isEditing; + + const transformedComponents = placeholderRenderings + .map((componentRendering: ComponentRendering, index: number) => { + const component = getComponentForRendering( + componentRendering, + name, + props.componentMap, + hiddenRenderingComponent, + missingComponentComponent + ); + const key = componentRendering.uid || `component-${index}`; + + const renderedProps = props.modifyComponentProps + ? props.modifyComponentProps(getChildComponentProps(props, componentRendering)) + : getChildComponentProps(props, componentRendering); + + let rendered = drawPlaceholderChildComponent( + component, + { + ...renderedProps, + ...props.passThroughComponentProps, + }, + key + ); + + if (!component.isEmpty) { + const errorBoundaryKey = rendered.type + '-' + index; + + const disableSuspense = props.disableSuspense || false; + rendered = ( + + {rendered} + + ); + } + // if in edit mode then emit shallow chromes for hydration in Pages + return isEditing ? ( + + {rendered} + + ) : ( + rendered + ); + }) + .filter((element) => element); // remove nulls + + if (!props.page.mode.isEditing) { + return transformedComponents; + } + + return [ + + {transformedComponents} + , + ]; +}; + From 311f7a6655a4d458a25c04fbe6a570620bec96cb Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Fri, 13 Feb 2026 13:35:37 -0500 Subject: [PATCH 10/13] apply renderEach fix, fix build --- packages/nextjs/src/index.ts | 3 +- .../Placeholder/AppPlaceholder.test.tsx | 46 ++++++++++++++++++ .../components/Placeholder/AppPlaceholder.tsx | 10 ---- .../Placeholder/ClientComponentWrapper.tsx | 4 +- .../Placeholder/Placeholder.test.tsx | 47 ++++++++++++++++++- .../components/Placeholder/Placeholder.tsx | 10 ---- .../react/src/components/Placeholder/index.ts | 2 +- .../Placeholder/placeholder-utils.tsx | 8 +++- .../src/enhancers/withAppPlaceholder.tsx | 2 +- packages/react/src/index.ts | 2 + 10 files changed, 107 insertions(+), 27 deletions(-) diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index db32155c11..33a6089a8d 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -152,7 +152,8 @@ export { withSitecore, useSitecore, withEditorChromes, - withPlaceholder, + withAppPlaceholder, + withClientPlaceholder, withDatasourceCheck, ImageSizeParameters, WithSitecoreOptions, diff --git a/packages/react/src/components/Placeholder/AppPlaceholder.test.tsx b/packages/react/src/components/Placeholder/AppPlaceholder.test.tsx index 33822f5281..e32bc4472f 100644 --- a/packages/react/src/components/Placeholder/AppPlaceholder.test.tsx +++ b/packages/react/src/components/Placeholder/AppPlaceholder.test.tsx @@ -1308,6 +1308,52 @@ describe('App Placeholder logic', () => { // 4 placeholders in total, 8 code blocks expect(wrapper?.container.querySelectorAll('.scpm').length).to.equal(8); }); + + it('should use renderEach for each child in the placeholder when page editing is enabled', () => { + const page = getPage(); + const components = new Map(); + + page.mode.isEditing = true; + + components.set('Child', () => 'Child'); + + const route = { + name: 'Render Each Test', + placeholders: { + main: [ + { + componentName: 'Child', + }, + { + componentName: 'Child', + }, + ], + }, + }; + page.layout = { + sitecore: { + context: {}, + route, + }, + }; + const phKey = 'main'; + + const renderedComponent = render( + +
{children}
} + renderEach={(child) =>
{child}
} + /> +
+ ); + + expect(renderedComponent.container.querySelectorAll('.parentWrapper').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.wrapper').length).to.equal(2); + }); }); }); diff --git a/packages/react/src/components/Placeholder/AppPlaceholder.tsx b/packages/react/src/components/Placeholder/AppPlaceholder.tsx index 3c5055e94c..3cfe78a2cd 100644 --- a/packages/react/src/components/Placeholder/AppPlaceholder.tsx +++ b/packages/react/src/components/Placeholder/AppPlaceholder.tsx @@ -64,16 +64,6 @@ export const AppPlaceholder = (props: AppPlaceholderProps) => { return props.page.mode.isEditing ? renderEmptyPlaceholder(rendered) : rendered; } else if (props.render) { return props.render(renderedComponents, placeholderRenderings, props); - } else if (props.renderEach) { - const renderEach = props.renderEach; - - return renderedComponents.map((component, index) => { - if (component && component.props && component.props.type === 'text/sitecore') { - return component; - } - - return renderEach(component, index); - }); } else { return renderedComponents; } diff --git a/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx b/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx index 2879d77724..801d335e7b 100644 --- a/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx +++ b/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx @@ -4,12 +4,12 @@ import { ComponentMapReactContext } from '../SitecoreProvider'; import { useContext } from 'react'; import React from 'react'; import { useSitecore } from '../../enhancers/withSitecore'; -import { AppComponentProps } from './models'; +import { ChildComponentProps } from './models'; import { getComponentForRendering } from './placeholder-utils'; export interface ClientComponentWrapperProps { rendering: ComponentRendering; - componentProps: AppComponentProps; + componentProps: ChildComponentProps; placeholderName: string; } diff --git a/packages/react/src/components/Placeholder/Placeholder.test.tsx b/packages/react/src/components/Placeholder/Placeholder.test.tsx index 1d1af1072a..286e90c268 100644 --- a/packages/react/src/components/Placeholder/Placeholder.test.tsx +++ b/packages/react/src/components/Placeholder/Placeholder.test.tsx @@ -151,7 +151,7 @@ describe('', () => { ).to.equal(1); }); - it('should render components based on the rendereach function', () => { + it('should render components based on the renderEach function', () => { const page = getPage(); page.layout = dataSet.data; const component = dataSet.data.sitecore.route as RouteData; @@ -170,6 +170,51 @@ describe('', () => { expect(renderedComponent.container.querySelectorAll('.wrapper').length).to.equal(1); }); + it('should use renderEach for each child in the placeholder when page editing is enabled', () => { + const page = getPage(); + const components = new Map(); + + page.mode.isEditing = true; + + components.set('Child', () => 'Child'); + + const route = { + name: 'Render Each Test', + placeholders: { + main: [ + { + componentName: 'Child', + }, + { + componentName: 'Child', + }, + ], + }, + }; + page.layout = { + sitecore: { + context: {}, + route, + }, + }; + const phKey = 'main'; + + const renderedComponent = render( + +
{children}
} + renderEach={(child) =>
{child}
} + /> +
+ ); + + expect(renderedComponent.container.querySelectorAll('.parentWrapper').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.wrapper').length).to.equal(2); + }); + it('should render components based on the render function', () => { const page = getPage(); page.layout = dataSet.data; diff --git a/packages/react/src/components/Placeholder/Placeholder.tsx b/packages/react/src/components/Placeholder/Placeholder.tsx index f5e61abf5f..c6c6aec3a0 100644 --- a/packages/react/src/components/Placeholder/Placeholder.tsx +++ b/packages/react/src/components/Placeholder/Placeholder.tsx @@ -56,16 +56,6 @@ const PlaceholderComponent = (props: PlaceholderProps) => { return props.page.mode.isEditing ? renderEmptyPlaceholder(rendered) : rendered; } else if (props.render) { return props.render(renderedComponents, placeholderRenderings, childProps); - } else if (props.renderEach) { - const renderEach = props.renderEach; - - return renderedComponents.map((component, index) => { - if (component && component.props && component.props.type === 'text/sitecore') { - return component; - } - - return renderEach(component, index); - }); } else { return renderedComponents; } diff --git a/packages/react/src/components/Placeholder/index.ts b/packages/react/src/components/Placeholder/index.ts index 8558b4c197..29db9d6f36 100644 --- a/packages/react/src/components/Placeholder/index.ts +++ b/packages/react/src/components/Placeholder/index.ts @@ -1,4 +1,4 @@ -export { Placeholder, getRenderedComponents } from './Placeholder'; +export { Placeholder } from './Placeholder'; export { PlaceholderMetadata } from './PlaceholderMetadata'; export { PlaceholderProps, AppPlaceholderProps } from './models'; export { AppPlaceholder } from './AppPlaceholder'; diff --git a/packages/react/src/components/Placeholder/placeholder-utils.tsx b/packages/react/src/components/Placeholder/placeholder-utils.tsx index ac3989c012..6b9a6f4fe1 100644 --- a/packages/react/src/components/Placeholder/placeholder-utils.tsx +++ b/packages/react/src/components/Placeholder/placeholder-utils.tsx @@ -204,7 +204,7 @@ export const getComponentForRendering = ( componentType: 'universal', }; } else if (renderingDefinition.componentName === BYOC_WRAPPER_RENDERING_NAME) { - // wrapping with error boundary could cause problems in case where parent component uses withPlaceholder HOC and tries to access its children props + // wrapping with error boundary could cause problems in case where parent component uses withPlaceholder HOCs and tries to access its children props // that's why we need to mark BYOC wrapper dynamic return { component: BYOCWrapper, @@ -294,6 +294,12 @@ export const drawPlaceholderComponents = ( key ); + if (props.renderEach) { + rendered = props.renderEach(rendered, index) as React.ReactElement<{ + [attr: string]: unknown; + }>; + } + if (!component.isEmpty) { const errorBoundaryKey = rendered.type + '-' + index; diff --git a/packages/react/src/enhancers/withAppPlaceholder.tsx b/packages/react/src/enhancers/withAppPlaceholder.tsx index 9b27ed3afb..1ef7add9c0 100644 --- a/packages/react/src/enhancers/withAppPlaceholder.tsx +++ b/packages/react/src/enhancers/withAppPlaceholder.tsx @@ -39,7 +39,7 @@ export const withAppPlaceholder = ) : ( - + ); } diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index c70778a33b..a2d1acd438 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -99,6 +99,8 @@ export { withDatasourceCheck } from './enhancers/withDatasourceCheck'; export { withFieldMetadata } from './enhancers/withFieldMetadata'; export { withEmptyFieldEditingComponent } from './enhancers/withEmptyFieldEditingComponent'; export { withComponentMap, useComponentMap } from './enhancers/withComponentMap'; +export { withAppPlaceholder } from './enhancers/withAppPlaceholder'; +export { withClientPlaceholder } from './enhancers/withClientPlaceholder'; export { EditingScripts } from './components/EditingScripts'; export { DefaultEmptyFieldEditingComponentText, From 0db73c9d4a2cddcc92bb0c0382502030e527470e Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Fri, 13 Feb 2026 15:49:18 -0500 Subject: [PATCH 11/13] re-do withPlaceholder HOCs --- .../src/enhancers/withAppPlaceholder.tsx | 5 +- .../enhancers/withClientPlaceholder.test.tsx | 424 ++++++++++++++++++ .../src/enhancers/withClientPlaceholder.tsx | 40 +- 3 files changed, 462 insertions(+), 7 deletions(-) create mode 100644 packages/react/src/enhancers/withClientPlaceholder.test.tsx diff --git a/packages/react/src/enhancers/withAppPlaceholder.tsx b/packages/react/src/enhancers/withAppPlaceholder.tsx index 1ef7add9c0..a7e635c729 100644 --- a/packages/react/src/enhancers/withAppPlaceholder.tsx +++ b/packages/react/src/enhancers/withAppPlaceholder.tsx @@ -4,7 +4,6 @@ import { AppPlaceholder } from '../components/Placeholder/AppPlaceholder'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; import { Page } from '@sitecore-content-sdk/content/client'; import { ComponentMap } from '../components/sharedTypes'; -import { rsc as isServerRuntime } from '../rsc-utils/rsc'; import { Placeholder } from '../components/Placeholder'; export type ComponentProps = { @@ -31,15 +30,13 @@ export const withAppPlaceholder = = {}; for (const placeholder of Object.keys(placeholders)) { - phProps[placeholder] = isServerRuntime ? ( + phProps[placeholder] = ( - ) : ( - ); } diff --git a/packages/react/src/enhancers/withClientPlaceholder.test.tsx b/packages/react/src/enhancers/withClientPlaceholder.test.tsx new file mode 100644 index 0000000000..36b899a808 --- /dev/null +++ b/packages/react/src/enhancers/withClientPlaceholder.test.tsx @@ -0,0 +1,424 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable no-unused-expressions */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { ReactElement, ReactNode } from 'react'; +import { expect } from 'chai'; +import { render } from '@testing-library/react'; +import { Page } from '@sitecore-content-sdk/content/client'; +import { LayoutServicePageState } from '@sitecore-content-sdk/content/layout'; +import { convertedDevData as normalModeDevData } from '../test-data/normal-mode-data'; +import * as metadataData from '../test-data/metadata-data'; +import { withClientPlaceholder, ComponentProps, WrapperProps } from './withClientPlaceholder'; +import { SitecoreProvider } from '../components/SitecoreProvider'; +import { ComponentRendering, RouteData } from '@sitecore-content-sdk/content/layout'; +import { Placeholder } from '../components/Placeholder'; + +type CalloutProps = ComponentProps & { + [prop: string]: unknown; + fields: { message: { value?: string } }; + subProp?: ReactElement; +}; + +type HomeProps = ComponentProps & { + [prop: string]: unknown; + rendering?: RouteData | ComponentRendering; + subProp?: ReactElement; +}; + +const DownloadCallout: React.FC = (props) => ( +
+ {props.fields?.message ? props.fields.message.value : ''} +
+); + +const Home: React.FC = ({ placeholders, subProp, ...otherProps }: HomeProps) => { + if (subProp && !otherProps.reset) { + return
{subProp}
; + } else { + // For withClientPlaceholder, placeholders are provided as props, so we access them + const placeholderContent = + Object.keys(placeholders).length > 0 + ? placeholders['page-content'] || placeholders.main || placeholders['page-header'] + : null; + return
{placeholderContent as ReactNode}
; + } +}; + +const delay = (timeout: number, promise?: any) => { + return new Promise((resolve) => { + setTimeout(resolve, timeout); + }).then(() => promise); +}; + +const componentMap = new Map>(); + +componentMap.set('DownloadCallout', DownloadCallout); +componentMap.set('Jumbotron', () =>
); +componentMap.set('BrokenComponent', () => { + throw new Error('BrokenComponent error'); +}); +componentMap.set( + 'DynamicComponent', + React.lazy(() => + delay(500, () => { + throw new Error('DynamicComponent error'); + }) + ) +); + +describe('withClientPlaceholder HOC', () => { + const api = { + edge: { + contextId: 'id', + edgeUrl: 'url', + clientContextId: 'clientId', + }, + local: { + apiKey: 'apiKey', + apiHost: 'apiHost', + path: 'path', + }, + }; + + const getPage = (): Page => ({ + layout: normalModeDevData, + locale: 'en', + mode: { + name: LayoutServicePageState.Normal, + isNormal: true, + isPreview: false, + isEditing: false, + isDesignLibrary: false, + designLibrary: { + isVariantGeneration: false, + }, + }, + }); + + // Basic functionality tests + it('should render without placeholders', () => { + const cleanComponent: ComponentRendering = { + componentName: 'TestComponent', + uid: 'clean-test-123', + fields: { + title: { value: 'Test Title' }, + }, + }; + + const props: WrapperProps = { + rendering: cleanComponent, + page: getPage(), + componentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + expect(renderedComponent.container.querySelector('.home-mock')?.children.length).to.equal(0); + }); + + it('should render a single placeholder correctly', () => { + const cleanComponent: ComponentRendering = { + componentName: 'TestComponent', + uid: 'clean-test-123', + placeholders: { + 'page-content': [ + { + componentName: 'DownloadCallout', + uid: 'download-123', + fields: { linkText: { value: 'Download' } }, + }, + ], + }, + }; + + const props: WrapperProps = { + rendering: cleanComponent, + page: getPage(), + componentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.download-callout-mock').length).to.equal( + 1 + ); + }); + + it('should render multiple placeholders correctly', () => { + // Create a simple component with clean data (no broken components) + const cleanComponent: ComponentRendering = { + componentName: 'TestComponent', + uid: 'clean-test-123', + placeholders: { + 'page-header': [ + { + componentName: 'Jumbotron', + uid: 'jumbotron-123', + params: { shade: 'dark', titleSize: '1' }, + fields: { titleText: { value: 'Test Title' } }, + }, + ], + 'page-content': [ + { + componentName: 'DownloadCallout', + uid: 'download-123', + fields: { linkText: { value: 'Download' } }, + }, + ], + }, + }; + + // Test component that uses both placeholders + const MultiKeyTestComponent: React.FC = ({ placeholders }) => { + return ( +
+
{placeholders['page-header']}
+
{placeholders['page-content']}
+
+ ); + }; + + const props: WrapperProps = { + rendering: cleanComponent, + page: getPage(), + componentMap, + }; + const Element = withClientPlaceholder(MultiKeyTestComponent); + const renderedComponent = render( + + + + ); + + // Should render the test component + expect(renderedComponent.container.querySelectorAll('.multi-key-test').length).to.equal(1); + // Should render jumbotron from page-header placeholder + expect(renderedComponent.container.querySelectorAll('.jumbotron-mock').length).to.equal(1); + // Should render download callout from page-content placeholder + expect(renderedComponent.container.querySelectorAll('.download-callout-mock').length).to.equal( + 1 + ); + }); + + it('should pass correct props to Placeholder components', () => { + const cleanComponent: ComponentRendering = { + componentName: 'TestComponent', + uid: 'clean-test-123', + placeholders: { + 'page-content': [ + { + componentName: 'DownloadCallout', + uid: 'download-123', + fields: { linkText: { value: 'Download' } }, + }, + ], + }, + }; + const phKey = 'page-content'; + const page = getPage(); + + // Test component that captures placeholder props + let capturedPlaceholderProps: any = null; + const TestComponent: React.FC = ({ placeholders }) => { + const placeholder = placeholders[phKey]; + if (React.isValidElement(placeholder)) { + capturedPlaceholderProps = placeholder.props; + } + return
{placeholder}
; + }; + + const props: WrapperProps = { + rendering: cleanComponent, + page, + componentMap, + }; + + const Element = withClientPlaceholder(TestComponent); + render( + + + + ); + + // Verify Placeholder received correct props + expect(capturedPlaceholderProps).to.not.be.null; + expect(capturedPlaceholderProps.name).to.equal(phKey); + expect(capturedPlaceholderProps.rendering).to.equal(cleanComponent); + // Note: page and componentMap come from SitecoreProvider context in client mode + }); + + describe('Metadata Mode', () => { + const defaultPage = getPage(); + const editModePage: Page = { + ...defaultPage, + mode: { + ...defaultPage.mode, + name: LayoutServicePageState.Edit, + isEditing: true, + }, + }; + + const { + layoutData, + layoutDataWithEmptyPlaceholder, + layoutDataForNestedDynamicPlaceholder, + layoutDataWithUnknownComponent, + } = metadataData; + + const metadataComponentMap = new Map(); + + metadataComponentMap.set('Header', () => ( +
+ +
+ )); + metadataComponentMap.set('Logo', () =>
); + + it('should render a placeholder with given key', () => { + const component = layoutData.sitecore.route; + const phKey = 'main'; + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + // Check that it renders the basic structure + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.header-wrapper').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.Logo-mock').length).to.equal(1); + }); + + it('should render a placeholder with given key and multiple placeholders', () => { + const component = layoutData.sitecore.route; + const phKeys = ['main', 'secondary']; + + const MultiPlaceholderMetadataComponent: React.FC = ({ placeholders }) => { + return ( +
+
{placeholders.main}
+
+ {placeholders.secondary || Empty secondary} +
+
+ ); + }; + + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(MultiPlaceholderMetadataComponent); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.metadata-multi-mock').length).to.equal( + 1 + ); + expect(renderedComponent.container.querySelectorAll('.header-wrapper').length).to.equal(1); + expect(renderedComponent.container.querySelectorAll('.Logo-mock').length).to.equal(1); + }); + + it('should render code blocks even if placeholder is empty', () => { + const component = layoutDataWithEmptyPlaceholder.sitecore.route; + const phKey = 'main'; + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + // Placeholder should handle empty placeholders in edit mode + }); + + it('should render missing component with code blocks if component is not registered', () => { + const component = layoutDataWithUnknownComponent.sitecore.route; + const phKey = 'main'; + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + // Should render unknown component placeholder + expect(renderedComponent.container.innerHTML).to.include('Unknown'); + }); + + it('should render dynamic placeholder', () => { + const phKey = 'container-1'; + const layoutData = layoutDataForNestedDynamicPlaceholder('container-{*}'); + const component = layoutData.sitecore.route; + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + // Placeholder should handle dynamic placeholders + }); + + it('should render double digit dynamic placeholder', () => { + const phKey = 'container-1-2'; + const layoutData = layoutDataForNestedDynamicPlaceholder('container-1-{*}'); + const component = layoutData.sitecore.route; + const props: WrapperProps = { + rendering: component as ComponentRendering, + page: editModePage, + componentMap: metadataComponentMap, + }; + const Element = withClientPlaceholder(Home); + const renderedComponent = render( + + + + ); + + expect(renderedComponent.container.querySelectorAll('.home-mock').length).to.equal(1); + // Placeholder should handle double digit dynamic placeholders + }); + }); +}); diff --git a/packages/react/src/enhancers/withClientPlaceholder.tsx b/packages/react/src/enhancers/withClientPlaceholder.tsx index 7dddb3a744..ad9d4fb5c6 100644 --- a/packages/react/src/enhancers/withClientPlaceholder.tsx +++ b/packages/react/src/enhancers/withClientPlaceholder.tsx @@ -1,7 +1,41 @@ 'use client'; -import { withAppPlaceholder } from './withAppPlaceholder'; +import React from 'react'; +import { ComponentType } from 'react'; +import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; +import { Page } from '@sitecore-content-sdk/content/client'; +import { ComponentMap } from '../components/sharedTypes'; +import { Placeholder } from '../components/Placeholder'; + +export type ComponentProps = { + rendering: ComponentRendering; + placeholders: Record; +}; + +export type WrapperProps = { + rendering: ComponentRendering; + page: Page; + componentMap: ComponentMap; +}; /** - * withAppPlaceholder import for client-side context and client components + * Provides a slot-like functionality by wrapping a component and rendering placeholders defined in the layout data. + * @param {ComponentType} Component - The component to be wrapped around placeholders. + * @returns {React.ReactNode} A new component that renders the original component with placeholders. */ -export const withClientPlaceholder = withAppPlaceholder; +export const withClientPlaceholder = ( + Component: ComponentType +) => { + return (props: W) => { + const placeholders = props.rendering.placeholders || {}; + const phProps: Record = {}; + + for (const placeholder of Object.keys(placeholders)) { + phProps[placeholder] = ; + } + + const displayName = Component.displayName || Component.name || 'Component'; + const propsCopy: T = { ...props, displayName }; + + return ; + }; +}; From 609805412728bbb28a1a97fbaca1ec821101f22b Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Tue, 17 Feb 2026 08:37:44 -0500 Subject: [PATCH 12/13] lint --- .../src/components/DesignLibrary/DesignLibrary.tsx | 12 ++++++++++-- packages/react/src/components/SitecoreProvider.tsx | 7 +++++++ packages/react/src/enhancers/withAppPlaceholder.tsx | 1 - .../src/enhancers/withClientPlaceholder.test.tsx | 6 ------ 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/react/src/components/DesignLibrary/DesignLibrary.tsx b/packages/react/src/components/DesignLibrary/DesignLibrary.tsx index 1160f2cc90..a1ebd7b337 100644 --- a/packages/react/src/components/DesignLibrary/DesignLibrary.tsx +++ b/packages/react/src/components/DesignLibrary/DesignLibrary.tsx @@ -45,8 +45,16 @@ export const __mockDependencies = (mocks: any) => { * when generation is enabled (`page.mode.designLibrary.isVariantGeneration === true`), * wires the **variant generation** handshake so the parent (DL Studio) can send * generated code to preview and iterate on. - * @param {DesignLibraryProps} props - * @param {() => Promise} [props.loadImportMap] Optional async loader that resolves to the import-map used to resolve the generated component’s imports. Required when `isVariantGeneration` is true. + * @param {DesignLibraryProps} props - The props for the DesignLibrary component. + * @param {Page} props.page - The page data. + * @param {() => Promise} [props.loadImportMap] Optional async loader that resolves to the import-map used to resolve the generated component’s imports. Required when `isVariantGeneration` is true. + * @param {DynamicComponent} [props.Component] The component to render. + * @param {number} [props.renderKey] The key to use for the render. + * @param {boolean} [props.isGeneratedComponentActive] Whether the generated component is active. + * @param {boolean} [props.isDesignLibrary] Whether the component is a design library. + * @param {boolean} [props.isVariantGeneration] Whether the variant generation is enabled. + * @param {ComponentUpdateModel} [props.propsState] The props state. + * @param {() => void} [props.setPropsState] The function to set the props state. * @returns {JSX.Element} The preview surface, or `null` when not in Design Library mode. * @public */ diff --git a/packages/react/src/components/SitecoreProvider.tsx b/packages/react/src/components/SitecoreProvider.tsx index 7a1c802d77..d3919812b6 100644 --- a/packages/react/src/components/SitecoreProvider.tsx +++ b/packages/react/src/components/SitecoreProvider.tsx @@ -65,6 +65,13 @@ export const ImportMapReactContext = React.createContext< /** * The SitecoreProvider component. + * @param {SitecoreProviderProps} props - The props for the SitecoreProvider component. + * @param {SitecoreProviderProps['api']} props.api - The API configuration. + * @param {SitecoreProviderProps['page']} props.page - The page data. + * @param {SitecoreProviderProps['componentMap']} props.componentMap - The component map. + * @param {SitecoreProviderProps['loadImportMap']} props.loadImportMap - The function to load the import map. + * @param {React.ReactNode} props.children - The children to render. + * @returns {React.ReactNode} The SitecoreProvider component. * @public */ export const SitecoreProvider = (props: SitecoreProviderProps) => { diff --git a/packages/react/src/enhancers/withAppPlaceholder.tsx b/packages/react/src/enhancers/withAppPlaceholder.tsx index a7e635c729..036b64d1bc 100644 --- a/packages/react/src/enhancers/withAppPlaceholder.tsx +++ b/packages/react/src/enhancers/withAppPlaceholder.tsx @@ -4,7 +4,6 @@ import { AppPlaceholder } from '../components/Placeholder/AppPlaceholder'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; import { Page } from '@sitecore-content-sdk/content/client'; import { ComponentMap } from '../components/sharedTypes'; -import { Placeholder } from '../components/Placeholder'; export type ComponentProps = { rendering: ComponentRendering; diff --git a/packages/react/src/enhancers/withClientPlaceholder.test.tsx b/packages/react/src/enhancers/withClientPlaceholder.test.tsx index 36b899a808..ba757c9964 100644 --- a/packages/react/src/enhancers/withClientPlaceholder.test.tsx +++ b/packages/react/src/enhancers/withClientPlaceholder.test.tsx @@ -289,7 +289,6 @@ describe('withClientPlaceholder HOC', () => { it('should render a placeholder with given key', () => { const component = layoutData.sitecore.route; - const phKey = 'main'; const props: WrapperProps = { rendering: component as ComponentRendering, page: editModePage, @@ -310,7 +309,6 @@ describe('withClientPlaceholder HOC', () => { it('should render a placeholder with given key and multiple placeholders', () => { const component = layoutData.sitecore.route; - const phKeys = ['main', 'secondary']; const MultiPlaceholderMetadataComponent: React.FC = ({ placeholders }) => { return ( @@ -344,7 +342,6 @@ describe('withClientPlaceholder HOC', () => { it('should render code blocks even if placeholder is empty', () => { const component = layoutDataWithEmptyPlaceholder.sitecore.route; - const phKey = 'main'; const props: WrapperProps = { rendering: component as ComponentRendering, page: editModePage, @@ -363,7 +360,6 @@ describe('withClientPlaceholder HOC', () => { it('should render missing component with code blocks if component is not registered', () => { const component = layoutDataWithUnknownComponent.sitecore.route; - const phKey = 'main'; const props: WrapperProps = { rendering: component as ComponentRendering, page: editModePage, @@ -382,7 +378,6 @@ describe('withClientPlaceholder HOC', () => { }); it('should render dynamic placeholder', () => { - const phKey = 'container-1'; const layoutData = layoutDataForNestedDynamicPlaceholder('container-{*}'); const component = layoutData.sitecore.route; const props: WrapperProps = { @@ -402,7 +397,6 @@ describe('withClientPlaceholder HOC', () => { }); it('should render double digit dynamic placeholder', () => { - const phKey = 'container-1-2'; const layoutData = layoutDataForNestedDynamicPlaceholder('container-1-{*}'); const component = layoutData.sitecore.route; const props: WrapperProps = { From 9ce4c8ea7cf4b828c66b63c08c096fd24cd15f70 Mon Sep 17 00:00:00 2001 From: Artem Alexeyenko Date: Tue, 17 Feb 2026 17:51:57 -0500 Subject: [PATCH 13/13] Remove more HOCs, refactor affected logic, adjust API coverage --- packages/react/api/content-sdk-react.api.md | 84 ++++----- .../DesignLibrary/DesignLibrary.tsx | 13 +- .../react/src/components/EditingScripts.tsx | 2 +- .../react/src/components/ErrorBoundary.tsx | 2 +- packages/react/src/components/Form.tsx | 2 +- .../components/Placeholder/AppPlaceholder.tsx | 10 +- .../Placeholder/ClientComponentWrapper.tsx | 3 +- .../components/Placeholder/Placeholder.tsx | 29 ++-- .../src/components/Placeholder/models.ts | 7 +- .../Placeholder/placeholder-utils.tsx | 6 +- .../src/components/SitecoreProvider.test.tsx | 4 +- .../react/src/components/SitecoreProvider.tsx | 55 +++++- .../src/enhancers/withAppPlaceholder.tsx | 1 + .../src/enhancers/withClientPlaceholder.tsx | 1 + .../src/enhancers/withComponentMap.test.tsx | 44 ----- .../react/src/enhancers/withComponentMap.tsx | 42 ----- .../src/enhancers/withDatasourceCheck.tsx | 2 +- .../react/src/enhancers/withEditorChromes.tsx | 30 ++-- .../src/enhancers/withLoadImportMap.test.tsx | 160 ------------------ .../react/src/enhancers/withLoadImportMap.tsx | 45 ----- .../react/src/enhancers/withSitecore.test.tsx | 10 +- packages/react/src/enhancers/withSitecore.tsx | 54 +----- packages/react/src/index.ts | 9 +- packages/react/src/search/utils.test.tsx | 2 +- packages/react/src/search/utils.ts | 2 +- 25 files changed, 154 insertions(+), 465 deletions(-) delete mode 100644 packages/react/src/enhancers/withComponentMap.test.tsx delete mode 100644 packages/react/src/enhancers/withComponentMap.tsx delete mode 100644 packages/react/src/enhancers/withLoadImportMap.test.tsx delete mode 100644 packages/react/src/enhancers/withLoadImportMap.tsx diff --git a/packages/react/api/content-sdk-react.api.md b/packages/react/api/content-sdk-react.api.md index a37b4d7f49..7785127c95 100644 --- a/packages/react/api/content-sdk-react.api.md +++ b/packages/react/api/content-sdk-react.api.md @@ -59,17 +59,14 @@ import { SitePathService } from '@sitecore-content-sdk/content/site'; import { SitePathServiceConfig } from '@sitecore-content-sdk/content/site'; // @public -export const AppPlaceholder: (props: AppPlaceholderProps) => string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | React_2.JSX.Element | (string | number | bigint | boolean | Iterable | Promise> | Iterable | null | undefined> | React_2.JSX.Element | null | undefined)[] | null | undefined; +export const AppPlaceholder: (props: AppPlaceholderProps) => React_2.JSX.Element; // Warning: (ae-forgotten-export) The symbol "BasePlaceholderProps" needs to be exported by the entry point api-surface.d.ts // // @public -export interface AppPlaceholderProps extends BasePlaceholderProps { - componentMap: ComponentMap; - // Warning: (ae-forgotten-export) The symbol "AppComponentProps" needs to be exported by the entry point api-surface.d.ts - modifyComponentProps?: (componentProps: AppComponentProps) => AppComponentProps; +export type AppPlaceholderProps = Omit & Required> & { render?: (components: React.ReactNode[], data: ComponentRendering[], props: AppPlaceholderProps) => React.ReactNode; -} +}; // @public export class BYOCComponent extends React_2.Component { @@ -179,7 +176,7 @@ export { DictionaryPhrases } export { DictionaryService } // @public -export const EditingScripts: () => JSX_2.Element; +export const EditingScripts: () => React_2.JSX.Element; export { EditMode } @@ -375,17 +372,12 @@ export { Page } export { PageMode } // @public -export const Placeholder: (props: EnhancedOmit) => React_2.JSX.Element; +export const Placeholder: (props: PlaceholderProps) => React_2.JSX.Element; // @public -interface PlaceholderProps extends BasePlaceholderProps { - // (undocumented) - [key: string]: unknown; - componentMap?: ComponentMap; - // Warning: (ae-forgotten-export) The symbol "ComponentProps" needs to be exported by the entry point api-surface.d.ts - modifyComponentProps?: (componentProps: ComponentProps) => ComponentProps; +type PlaceholderProps = BasePlaceholderProps & { render?: (components: React.ReactNode[], data: ComponentRendering[], props: PlaceholderProps) => React.ReactNode; -} +}; export { PlaceholderProps as PlaceholderComponentProps } export { PlaceholderProps } @@ -430,19 +422,11 @@ export { RouteData } // @public export type SearchStatus = 'idle' | 'loading' | 'success' | 'error'; -// Warning: (ae-forgotten-export) The symbol "SitecoreProviderProps" needs to be exported by the entry point api-surface.d.ts -// // @public -export class SitecoreProvider extends React_2.Component { - constructor(props: SitecoreProviderProps); - // (undocumented) - componentDidUpdate(prevProps: SitecoreProviderProps): void; - // (undocumented) - static displayName: string; - // (undocumented) - render(): React_2.JSX.Element; - setPage: (value: Page) => void; -} +export const SitecoreProvider: { + (props: SitecoreProviderProps): React_2.JSX.Element; + displayName: string; +}; // @public export const SitecoreProviderReactContext: React_2.Context; @@ -451,7 +435,7 @@ export const SitecoreProviderReactContext: React_2.Context void; + setPage?: (value: Page) => void; } export { SitePathService } @@ -470,6 +454,9 @@ export interface TextField extends FieldMetadata { value?: string | number; } +// @public +export function useComponentMap(): ComponentMap; + // @public export const useInfiniteSearch: (options: UseInfiniteSearchOptions) => UseInfiniteSearchState; @@ -519,8 +506,17 @@ export type UseSearchState = Omit(Component: ComponentType) => (props: W) => React_2.JSX.Element; + +// Warning: (ae-forgotten-export) The symbol "ComponentProps_2" needs to be exported by the entry point api-surface.d.ts +// Warning: (ae-forgotten-export) The symbol "WrapperProps_2" needs to be exported by the entry point api-surface.d.ts +// +// @public +export const withClientPlaceholder: (Component: ComponentType) => (props: W) => React_2.JSX.Element; // Warning: (ae-forgotten-export) The symbol "WithDatasourceCheckOptions" needs to be exported by the entry point api-surface.d.ts // Warning: (ae-forgotten-export) The symbol "WithDatasourceCheckProps" needs to be exported by the entry point api-surface.d.ts @@ -529,7 +525,10 @@ export function useSitecore(options?: WithSitecoreOptions): WithSitecoreProps; export function withDatasourceCheck(options?: WithDatasourceCheckOptions): (Component: React_2.ComponentType) => (props: ComponentProps) => JSX_2.Element | null; // @public -export const withEditorChromes: (WrappedComponent: React_2.ComponentClass | React_2.FC) => React_2.ComponentClass; +export const withEditorChromes: (WrappedComponent: React_2.ComponentClass | React_2.FC) => { + (props: Record): React_2.JSX.Element; + displayName: string; +}; // Warning: (ae-forgotten-export) The symbol "WithEmptyFieldEditingComponentProps" needs to be exported by the entry point api-surface.d.ts // Warning: (ae-forgotten-export) The symbol "WithEmptyFieldEditingComponentOptions" needs to be exported by the entry point api-surface.d.ts @@ -542,33 +541,10 @@ export function withEmptyFieldEditingComponent(FieldComponent: ComponentType, isForwardRef?: boolean): React_2.ForwardRefExoticComponent & React_2.RefAttributes> | ((props: FieldComponentProps) => React_2.JSX.Element); -// Warning: (ae-forgotten-export) The symbol "WithPlaceholderSpec" needs to be exported by the entry point api-surface.d.ts -// Warning: (ae-forgotten-export) The symbol "WithPlaceholderOptions" needs to be exported by the entry point api-surface.d.ts -// -// @public -export function withPlaceholder(placeholders: WithPlaceholderSpec, options?: WithPlaceholderOptions): (WrappedComponent: React_2.ComponentClass | React_2.FunctionComponent) => (props: EnhancedOmit) => React_2.JSX.Element; - -// @public (undocumented) -export function withSitecore(options?: WithSitecoreOptions): (Component: React_2.ComponentType) => (props: WithSitecoreHocProps) => React_2.JSX.Element; - -// @public -export type WithSitecoreHocProps = EnhancedOmit; - -// @public -export interface WithSitecoreOptions { - updatable?: boolean; -} - -// @public -export interface WithSitecoreProps { - api?: SitecoreProviderState['api']; - page: Page; - updatePage?: ((value: Page) => void) | false; -} - // Warnings were encountered during analysis: // // src/components/FEaaS/models.ts:96:3 - (ae-forgotten-export) The symbol "RevisionType" needs to be exported by the entry point api-surface.d.ts +// src/components/SitecoreProvider.tsx:88:30 - (ae-forgotten-export) The symbol "SitecoreProviderProps" needs to be exported by the entry point api-surface.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/react/src/components/DesignLibrary/DesignLibrary.tsx b/packages/react/src/components/DesignLibrary/DesignLibrary.tsx index a1ebd7b337..9f42986368 100644 --- a/packages/react/src/components/DesignLibrary/DesignLibrary.tsx +++ b/packages/react/src/components/DesignLibrary/DesignLibrary.tsx @@ -13,11 +13,10 @@ import { } from '@sitecore-content-sdk/content/editing'; import * as codegen from '@sitecore-content-sdk/content/codegen'; import * as editing from '@sitecore-content-sdk/content/editing'; -import { useSitecore } from '../../enhancers/withSitecore'; +import { useSitecore, useLoadImportMap } from '../../components/SitecoreProvider'; import { Placeholder, PlaceholderMetadata } from '../Placeholder'; import { DesignLibraryErrorBoundary } from './DesignLibraryErrorBoundary'; import { DynamicComponent } from './models'; -import { useLoadImportMap } from '../../enhancers/withLoadImportMap'; import { ErrorComponent } from '../ErrorBoundary'; let { @@ -45,16 +44,6 @@ export const __mockDependencies = (mocks: any) => { * when generation is enabled (`page.mode.designLibrary.isVariantGeneration === true`), * wires the **variant generation** handshake so the parent (DL Studio) can send * generated code to preview and iterate on. - * @param {DesignLibraryProps} props - The props for the DesignLibrary component. - * @param {Page} props.page - The page data. - * @param {() => Promise} [props.loadImportMap] Optional async loader that resolves to the import-map used to resolve the generated component’s imports. Required when `isVariantGeneration` is true. - * @param {DynamicComponent} [props.Component] The component to render. - * @param {number} [props.renderKey] The key to use for the render. - * @param {boolean} [props.isGeneratedComponentActive] Whether the generated component is active. - * @param {boolean} [props.isDesignLibrary] Whether the component is a design library. - * @param {boolean} [props.isVariantGeneration] Whether the variant generation is enabled. - * @param {ComponentUpdateModel} [props.propsState] The props state. - * @param {() => void} [props.setPropsState] The function to set the props state. * @returns {JSX.Element} The preview surface, or `null` when not in Design Library mode. * @public */ diff --git a/packages/react/src/components/EditingScripts.tsx b/packages/react/src/components/EditingScripts.tsx index f5db06464a..a13a075bf2 100644 --- a/packages/react/src/components/EditingScripts.tsx +++ b/packages/react/src/components/EditingScripts.tsx @@ -1,6 +1,6 @@ 'use client'; import React from 'react'; -import { useSitecore } from '../enhancers/withSitecore'; +import { useSitecore } from './SitecoreProvider'; import { getContentSdkPagesClientData, getDesignLibraryScriptLink, diff --git a/packages/react/src/components/ErrorBoundary.tsx b/packages/react/src/components/ErrorBoundary.tsx index 730e130e76..19ed522c8d 100644 --- a/packages/react/src/components/ErrorBoundary.tsx +++ b/packages/react/src/components/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -'use client'; +'use client'; import React, { ReactNode, Suspense } from 'react'; import { Page } from '@sitecore-content-sdk/content/client'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; diff --git a/packages/react/src/components/Form.tsx b/packages/react/src/components/Form.tsx index d6b56c18c3..63a705963d 100644 --- a/packages/react/src/components/Form.tsx +++ b/packages/react/src/components/Form.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; import { form } from '@sitecore-content-sdk/content'; -import { useSitecore } from '../enhancers/withSitecore'; +import { useSitecore } from './SitecoreProvider'; import { ErrorComponent } from './ErrorBoundary'; let { executeScriptElements, loadForm, subscribeToFormSubmitEvent } = form; diff --git a/packages/react/src/components/Placeholder/AppPlaceholder.tsx b/packages/react/src/components/Placeholder/AppPlaceholder.tsx index 3cfe78a2cd..f1d539d018 100644 --- a/packages/react/src/components/Placeholder/AppPlaceholder.tsx +++ b/packages/react/src/components/Placeholder/AppPlaceholder.tsx @@ -19,11 +19,8 @@ import { rsc } from '#rsc-env'; */ export const AppPlaceholder = (props: AppPlaceholderProps) => { const renderingData = props.rendering; - const placeholderRenderings = getPlaceholderRenderings( - renderingData, - props.name, - props.page.mode.isEditing - ); + const isEditing = props.page.mode.isEditing; + const placeholderRenderings = getPlaceholderRenderings(renderingData, props.name, isEditing); const drawAppPlaceholderChildComponent = ( componentForRendering: ComponentForRendering, @@ -74,7 +71,8 @@ export const AppPlaceholder = (props: AppPlaceholderProps) => { props, placeholderRenderings, drawAppPlaceholderChildComponent, - componentRuntime + componentRuntime, + isEditing ); const finalOutput = applyConditionalTransform(components); diff --git a/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx b/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx index 801d335e7b..05c3786005 100644 --- a/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx +++ b/packages/react/src/components/Placeholder/ClientComponentWrapper.tsx @@ -1,9 +1,8 @@ 'use client'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; -import { ComponentMapReactContext } from '../SitecoreProvider'; +import { ComponentMapReactContext, useSitecore } from '../SitecoreProvider'; import { useContext } from 'react'; import React from 'react'; -import { useSitecore } from '../../enhancers/withSitecore'; import { ChildComponentProps } from './models'; import { getComponentForRendering } from './placeholder-utils'; diff --git a/packages/react/src/components/Placeholder/Placeholder.tsx b/packages/react/src/components/Placeholder/Placeholder.tsx index c6c6aec3a0..37b087a2f0 100644 --- a/packages/react/src/components/Placeholder/Placeholder.tsx +++ b/packages/react/src/components/Placeholder/Placeholder.tsx @@ -1,23 +1,24 @@ 'use client'; import React, { useEffect } from 'react'; import { ChildComponentProps, ComponentForRendering, PlaceholderProps } from './models'; -import { withComponentMap } from '../../enhancers/withComponentMap'; import { PagesEditor } from '@sitecore-content-sdk/content/editing'; -import { withSitecore } from '../../enhancers/withSitecore'; import { getPlaceholderRenderings, drawPlaceholderComponents, renderEmptyPlaceholder, } from './placeholder-utils'; import ErrorBoundary from '../ErrorBoundary'; +import { useComponentMap, useSitecore } from '../SitecoreProvider'; const PlaceholderComponent = (props: PlaceholderProps) => { const renderingData = props.rendering; - const placeholderRenderings = getPlaceholderRenderings( - renderingData, - props.name, - props.page.mode.isEditing - ); + let { page } = useSitecore(); + let componentMap = useComponentMap(); + page = props.page ?? page; + componentMap = props.componentMap || componentMap || undefined; + const modProps = { ...props, page, componentMap }; + const isEditing = page.mode.isEditing; + const placeholderRenderings = getPlaceholderRenderings(renderingData, modProps.name, isEditing); const isEmpty = !placeholderRenderings.length; useEffect(() => { @@ -37,8 +38,8 @@ const PlaceholderComponent = (props: PlaceholderProps) => { key={key} {...renderedProps} {...props.passThroughComponentProps} - page={props.page} - componentMap={props.componentMap} + page={page} + componentMap={componentMap} /> ); }; @@ -53,7 +54,7 @@ const PlaceholderComponent = (props: PlaceholderProps) => { ? props.renderEmpty(renderedComponents) : renderedComponents; - return props.page.mode.isEditing ? renderEmptyPlaceholder(rendered) : rendered; + return isEditing ? renderEmptyPlaceholder(rendered) : rendered; } else if (props.render) { return props.render(renderedComponents, placeholderRenderings, childProps); } else { @@ -62,9 +63,11 @@ const PlaceholderComponent = (props: PlaceholderProps) => { }; const components = drawPlaceholderComponents( - props, + modProps, placeholderRenderings, - drawPlaceholderChildComponent + drawPlaceholderChildComponent, + undefined, + isEditing ); const finalOutput = applyConditionalTransform(components); @@ -76,5 +79,5 @@ const PlaceholderComponent = (props: PlaceholderProps) => { * The Placeholder component. * @public */ -export const Placeholder = withSitecore()(withComponentMap(PlaceholderComponent)); +export const Placeholder = PlaceholderComponent; diff --git a/packages/react/src/components/Placeholder/models.ts b/packages/react/src/components/Placeholder/models.ts index 5aa045db2e..cbc855933d 100644 --- a/packages/react/src/components/Placeholder/models.ts +++ b/packages/react/src/components/Placeholder/models.ts @@ -50,7 +50,7 @@ export interface BasePlaceholderProps { * Page data. * This data is passed by the SitecoreProvider. */ - page: Page; + page?: Page; /** * The message that gets displayed while component is loading */ @@ -116,8 +116,8 @@ export type PlaceholderProps = BasePlaceholderProps & { * The interface for the AppPlaceholder component props. * @public */ -export type AppPlaceholderProps = Omit & - Required> & { +export type AppPlaceholderProps = Omit & + Required> & { /** * Render props function that enables control over the rendering of the components in the placeholder. * Useful for techniques like wrapping each child in a wrapper component. @@ -165,3 +165,4 @@ export const nonSerializedPlaceholderProps = [ 'missingComponentComponent', 'hiddenRenderingComponent', ] as const satisfies (keyof PlaceholderProps)[]; + diff --git a/packages/react/src/components/Placeholder/placeholder-utils.tsx b/packages/react/src/components/Placeholder/placeholder-utils.tsx index 6b9a6f4fe1..b223ac711d 100644 --- a/packages/react/src/components/Placeholder/placeholder-utils.tsx +++ b/packages/react/src/components/Placeholder/placeholder-utils.tsx @@ -265,10 +265,10 @@ export const drawPlaceholderComponents = ( renderedProps: ChildComponentProps, key?: string ) => React.JSX.Element, - componentRuntime?: 'server' | 'client' | undefined + componentRuntime?: 'server' | 'client' | undefined, + isEditing?: boolean ) => { const { name, missingComponentComponent, hiddenRenderingComponent } = props; - const isEditing = props.page.mode.isEditing; const transformedComponents = placeholderRenderings .map((componentRendering: ComponentRendering, index: number) => { @@ -333,7 +333,7 @@ export const drawPlaceholderComponents = ( }) .filter((element) => element); // remove nulls - if (!props.page.mode.isEditing) { + if (!isEditing) { return transformedComponents; } diff --git a/packages/react/src/components/SitecoreProvider.test.tsx b/packages/react/src/components/SitecoreProvider.test.tsx index 3a7f2822d5..1527ccaea6 100644 --- a/packages/react/src/components/SitecoreProvider.test.tsx +++ b/packages/react/src/components/SitecoreProvider.test.tsx @@ -2,8 +2,8 @@ import React, { FC } from 'react'; import { expect } from 'chai'; import { Page } from '@sitecore-content-sdk/content/client'; -import { SitecoreProvider } from './SitecoreProvider'; -import { WithSitecoreProps, withSitecore, useSitecore } from '../enhancers/withSitecore'; +import { SitecoreProvider, useSitecore } from './SitecoreProvider'; +import { WithSitecoreProps, withSitecore } from '../enhancers/withSitecore'; import { LayoutServiceData, LayoutServicePageState } from '../index'; import { render } from '@testing-library/react'; diff --git a/packages/react/src/components/SitecoreProvider.tsx b/packages/react/src/components/SitecoreProvider.tsx index d3919812b6..46a9f87f1b 100644 --- a/packages/react/src/components/SitecoreProvider.tsx +++ b/packages/react/src/components/SitecoreProvider.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react'; import fastDeepEqual from 'fast-deep-equal/es6/react'; import { Page } from '@sitecore-content-sdk/content/client'; import { SitecoreConfig } from '@sitecore-content-sdk/content/config'; @@ -38,7 +38,7 @@ export interface SitecoreProviderState { * @param {Page} value New page value. * @returns {void} */ - setPage: (value: Page) => void; + setPage?: (value: Page) => void; /** * The current page. */ @@ -49,6 +49,17 @@ export interface SitecoreProviderState { api?: SitecoreProviderProps['api']; } +/** + * The options for the useSitecore hook. + * @public + */ +export interface UseSitecoreOptions { + /** + * If set to true, the `updateContext` method will be injected into the component props. + */ + updatable?: boolean; +} + /** * The context for the SitecoreProvider component. * @public @@ -130,3 +141,43 @@ export const SitecoreProvider = (props: SitecoreProviderProps) => { }; SitecoreProvider.displayName = 'SitecoreProvider'; + +/** + * This hook grants acсess to the current Sitecore page and api. + * @param {UseSitecoreOptions} [options] hook options + * @example + * const EditMode = () => { + * const { page } = useSitecore(); + * return Edit Mode is {page.mode.isEditing ? 'active' : 'inactive'} + * } + * @returns {SitecoreProviderState} The current Sitecore context, including the page and api. + * @public + */ +export function useSitecore(options?: UseSitecoreOptions): SitecoreProviderState { + const scContext = useContext(SitecoreProviderReactContext); + const updatable = options?.updatable; + + return { + ...scContext, + setPage: updatable ? scContext.setPage : undefined, + }; +} + +/** + * Hook that retrieves the loadImportMap function from context. + * @returns {() => Promise | undefined} The loadImportMap function from context, or undefined if not available. + * @public + */ +export function useLoadImportMap(): (() => Promise) | undefined { + return useContext(ImportMapReactContext); +} + +/** + * Hook to access the component map in client context. + * @returns {ComponentMap} The component map from the SitecoreProvider + * @public + */ +export function useComponentMap(): ComponentMap { + const componentMap = useContext(ComponentMapReactContext); + return componentMap; +} diff --git a/packages/react/src/enhancers/withAppPlaceholder.tsx b/packages/react/src/enhancers/withAppPlaceholder.tsx index 036b64d1bc..ce69402283 100644 --- a/packages/react/src/enhancers/withAppPlaceholder.tsx +++ b/packages/react/src/enhancers/withAppPlaceholder.tsx @@ -20,6 +20,7 @@ export type WrapperProps = { * Provides a slot-like functionality by wrapping a component and rendering placeholders defined in the layout data. * @param {ComponentType} Component - The component to be wrapped around placeholders. * @returns {React.ReactNode} A new component that renders the original component with placeholders. + * @public */ export const withAppPlaceholder = ( Component: ComponentType diff --git a/packages/react/src/enhancers/withClientPlaceholder.tsx b/packages/react/src/enhancers/withClientPlaceholder.tsx index ad9d4fb5c6..ca32d9a79f 100644 --- a/packages/react/src/enhancers/withClientPlaceholder.tsx +++ b/packages/react/src/enhancers/withClientPlaceholder.tsx @@ -21,6 +21,7 @@ export type WrapperProps = { * Provides a slot-like functionality by wrapping a component and rendering placeholders defined in the layout data. * @param {ComponentType} Component - The component to be wrapped around placeholders. * @returns {React.ReactNode} A new component that renders the original component with placeholders. + * @public */ export const withClientPlaceholder = ( Component: ComponentType diff --git a/packages/react/src/enhancers/withComponentMap.test.tsx b/packages/react/src/enhancers/withComponentMap.test.tsx deleted file mode 100644 index 9f82a3deef..0000000000 --- a/packages/react/src/enhancers/withComponentMap.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ -import React from 'react'; -import { expect, use } from 'chai'; -import { spy } from 'sinon'; -import sinonChai from 'sinon-chai'; -import { render } from '@testing-library/react'; -import { withComponentMap } from './withComponentMap'; -import { ComponentMapReactContext } from '../components/SitecoreProvider'; - -use(sinonChai); - -describe('withComponentMap', () => { - it('should render component and pass componentMap from HOC props', () => { - const TestComponent = spy(({ componentMap }: { componentMap: any }) => ( -
{JSON.stringify(componentMap)}
- )); - const WrappedComponent = withComponentMap(TestComponent); - - const componentMapProp = { key: 'value' }; - const { getByText } = render(); - - expect(TestComponent).to.have.been.calledWithMatch({ componentMap: componentMapProp }); - expect(getByText(JSON.stringify(componentMapProp))).to.exist; - }); - - it('should render component and use componentMap from context as fallback', () => { - const TestComponent = spy(({ componentMap }: { componentMap: any }) => ( -
{JSON.stringify(componentMap)}
- )); - const WrappedComponent = withComponentMap(TestComponent); - - const contextValue = { fallbackKey: 'fallbackValue' }; - const { getByText } = render( - - - - ); - - expect(TestComponent).to.have.been.calledWithMatch({ componentMap: contextValue }); - expect(getByText(JSON.stringify(contextValue))).to.exist; - }); -}); diff --git a/packages/react/src/enhancers/withComponentMap.tsx b/packages/react/src/enhancers/withComponentMap.tsx deleted file mode 100644 index 5f299326fa..0000000000 --- a/packages/react/src/enhancers/withComponentMap.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client'; -import React, { JSX } from 'react'; -import { ComponentMapReactContext } from '../components/SitecoreProvider'; -import { useContext } from 'react'; -import { ComponentMap } from '../components/sharedTypes'; - -export interface WithComponentMapProps { - componentMap?: ComponentMap; -} - -/** - * @param {React.ComponentClass | React.FC} Component - */ -export function withComponentMap( - Component: React.ComponentClass | React.FC -) { - /** - * @param {T} props - props to pass to the wrapped component - * @returns {JSX.Element} - the rendered component - */ - function WithComponentMap(props: T): JSX.Element { - const contextComponentMap = useComponentMap(); - - return ; - } - - WithComponentMap.displayName = `withComponentMap(${ - Component.displayName || Component.name || 'Anonymous' - })`; - - return WithComponentMap; -} - -/** - * Hook to access the component map in client context. - * @returns {ComponentMap} The component map from the SitecoreProvider - * @public - */ -export function useComponentMap(): ComponentMap { - const componentMap = useContext(ComponentMapReactContext); - return componentMap; -} diff --git a/packages/react/src/enhancers/withDatasourceCheck.tsx b/packages/react/src/enhancers/withDatasourceCheck.tsx index 8b977cb4c1..aa10469e07 100644 --- a/packages/react/src/enhancers/withDatasourceCheck.tsx +++ b/packages/react/src/enhancers/withDatasourceCheck.tsx @@ -1,7 +1,7 @@ 'use client'; import React, { JSX } from 'react'; import { ComponentRendering } from '@sitecore-content-sdk/content/layout'; -import { useSitecore } from './withSitecore'; +import { useSitecore } from './../components/SitecoreProvider'; export const DefaultEditingError = (): JSX.Element => (
diff --git a/packages/react/src/enhancers/withEditorChromes.tsx b/packages/react/src/enhancers/withEditorChromes.tsx index 31eb107224..af30951b14 100644 --- a/packages/react/src/enhancers/withEditorChromes.tsx +++ b/packages/react/src/enhancers/withEditorChromes.tsx @@ -1,4 +1,4 @@ -import React, { ComponentType } from 'react'; +import React, { ComponentType, useEffect, useRef } from 'react'; import { resetEditorChromes } from '..'; /** @@ -9,18 +9,24 @@ import { resetEditorChromes } from '..'; export const withEditorChromes = ( WrappedComponent: React.ComponentClass | React.FC ) => { - class Enhancer extends React.Component { - displayName: string = - (WrappedComponent as ComponentType).displayName || WrappedComponent.name || 'Component'; - - componentDidUpdate() { + const Enhancer = (props: Record) => { + const isFirstRender = useRef(true); + useEffect(() => { + if (isFirstRender.current) { + isFirstRender.current = false; + return; + } + // only reset chromes on subsequent re-renders resetEditorChromes(); - } + }); + + return ; + }; - render() { - return ; - } - } + Enhancer.displayName = + (WrappedComponent as ComponentType).displayName || + (WrappedComponent as ComponentType).name || + 'Component'; - return Enhancer as React.ComponentClass; + return Enhancer; }; diff --git a/packages/react/src/enhancers/withLoadImportMap.test.tsx b/packages/react/src/enhancers/withLoadImportMap.test.tsx deleted file mode 100644 index c3d205db10..0000000000 --- a/packages/react/src/enhancers/withLoadImportMap.test.tsx +++ /dev/null @@ -1,160 +0,0 @@ -/* eslint-disable react-hooks/rules-of-hooks */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable no-unused-expressions, @typescript-eslint/no-unused-expressions */ -import React from 'react'; -import { expect, use } from 'chai'; -import { spy } from 'sinon'; -import sinonChai from 'sinon-chai'; -import { render } from '@testing-library/react'; -import { withLoadImportMap, WithLoadImportMapProps, useLoadImportMap } from './withLoadImportMap'; -import { ImportMapReactContext } from '../components/SitecoreProvider'; -import { ImportMapImport } from '../components/DesignLibrary/models'; - -use(sinonChai); - -describe('withLoadImportMap', () => { - const mockImportMapData: ImportMapImport = { - default: [ - { - module: 'react', - exports: [ - { name: 'default', value: {} }, - { name: 'useState', value: {} }, - ], - }, - ], - }; - - const TestComponent = spy(({ loadImportMap }: WithLoadImportMapProps) => ( -
- {loadImportMap ? 'loadImportMap provided' : 'no loadImportMap'} -
- )); - - it('should render component and pass loadImportMap from HOC props', async () => { - const loadImportMapProp = spy(() => Promise.resolve(mockImportMapData)); - - const WrappedComponent = withLoadImportMap(TestComponent); - - const { getByTestId } = render(); - - expect(TestComponent).to.have.been.calledWithMatch({ loadImportMap: loadImportMapProp }); - expect(getByTestId('test-component')).to.exist; - expect(getByTestId('test-component').textContent).to.equal('loadImportMap provided'); - }); - - it('should render component and use loadImportMap from context as fallback', async () => { - const contextLoadImportMap = spy(() => Promise.resolve(mockImportMapData)); - - const WrappedComponent = withLoadImportMap(TestComponent); - - const { getByTestId } = render( - - - - ); - - expect(TestComponent).to.have.been.calledWithMatch({ loadImportMap: contextLoadImportMap }); - expect(getByTestId('test-component')).to.exist; - expect(getByTestId('test-component').textContent).to.equal('loadImportMap provided'); - }); - - it('should prioritize prop loadImportMap over context loadImportMap', async () => { - const propLoadImportMap = spy(() => Promise.resolve(mockImportMapData)); - const contextLoadImportMap = spy(() => - Promise.resolve({ default: [{ module: 'context-module', exports: [] }] }) - ); - - const WrappedComponent = withLoadImportMap(TestComponent); - - render( - - - - ); - - expect(TestComponent).to.have.been.calledWithMatch({ loadImportMap: propLoadImportMap }); - expect(TestComponent).to.not.have.been.calledWithMatch({ - loadImportMap: contextLoadImportMap, - }); - }); - - it('should render without loadImportMap when neither prop nor context provides it', () => { - const WrappedComponent = withLoadImportMap(TestComponent); - - const { getByTestId } = render(); - - expect(TestComponent).to.have.been.calledWithMatch({ loadImportMap: undefined }); - expect(getByTestId('test-component').textContent).to.equal('no loadImportMap'); - }); - - it('should handle loadImportMap function being called', async () => { - const loadImportMapProp = spy(() => Promise.resolve(mockImportMapData)); - - const TestComponent = ({ loadImportMap }: WithLoadImportMapProps) => { - const [data, setData] = React.useState('loading'); - - React.useEffect(() => { - if (loadImportMap) { - loadImportMap().then(() => setData('loaded')); - } - }, [loadImportMap]); - - return
{data}
; - }; - - const WrappedComponent = withLoadImportMap(TestComponent); - - const { getByTestId, findByText } = render( - - ); - - expect(getByTestId('test-component').textContent).to.equal('loading'); - - await findByText('loaded'); - - expect(loadImportMapProp).to.have.been.calledOnce; - expect(getByTestId('test-component').textContent).to.equal('loaded'); - }); -}); - -describe('useLoadImportMap', () => { - const mockImportMapData: ImportMapImport = { - default: [ - { - module: 'react', - exports: [ - { name: 'default', value: {} }, - { name: 'useState', value: {} }, - ], - }, - ], - }; - - const TestComponent = () => { - const loadImportMap = useLoadImportMap(); - return ( -
- {loadImportMap ? 'loadImportMap available' : 'no loadImportMap'} -
- ); - }; - - it('should return loadImportMap function from context', () => { - const contextLoadImportMap = spy(() => Promise.resolve(mockImportMapData)); - - const { getByTestId } = render( - - - - ); - - expect(getByTestId('test-hook').textContent).to.equal('loadImportMap available'); - }); - - it('should return undefined when context is not provided', () => { - const { getByTestId } = render(); - - expect(getByTestId('test-hook').textContent).to.equal('no loadImportMap'); - }); -}); diff --git a/packages/react/src/enhancers/withLoadImportMap.tsx b/packages/react/src/enhancers/withLoadImportMap.tsx deleted file mode 100644 index 1a185595f1..0000000000 --- a/packages/react/src/enhancers/withLoadImportMap.tsx +++ /dev/null @@ -1,45 +0,0 @@ -'use client'; -import React, { useContext, JSX } from 'react'; -import { ImportMapReactContext } from '../components/SitecoreProvider'; -import { ImportMapImport } from '../components/DesignLibrary/models'; - -/** - * Props that include the loadImportMap function from context. - */ -export interface WithLoadImportMapProps { - /** - * The dynamic import for import map to be used in variant generation mode. - */ - loadImportMap?: () => Promise; -} - -/** - * Hook that retrieves the loadImportMap function from context. - * @returns {() => Promise | undefined} The loadImportMap function from context, or undefined if not available. - * @public - */ -export function useLoadImportMap(): (() => Promise) | undefined { - return useContext(ImportMapReactContext); -} - -/** - * Higher-order component that injects the loadImportMap function from context into component props. - * If the component already receives loadImportMap via props, the prop value takes precedence. - * @param {React.ComponentClass | React.FC} Component - The component to enhance. - * @returns {React.ComponentClass | React.FC} The enhanced component with loadImportMap injected. - */ -export function withLoadImportMap( - Component: React.ComponentClass | React.FC -) { - const WithLoadImportMap = (props: T): JSX.Element => { - const loadImportMapContext = useLoadImportMap(); - const loadClientImportMap = props.loadImportMap || loadImportMapContext; - return ; - }; - - WithLoadImportMap.displayName = `withLoadImportMap(${ - Component.displayName || Component.name || 'Component' - })`; - - return WithLoadImportMap; -} diff --git a/packages/react/src/enhancers/withSitecore.test.tsx b/packages/react/src/enhancers/withSitecore.test.tsx index 1a979b7a86..dc79354299 100644 --- a/packages/react/src/enhancers/withSitecore.test.tsx +++ b/packages/react/src/enhancers/withSitecore.test.tsx @@ -5,11 +5,11 @@ import { expect, use } from 'chai'; import { fireEvent, render } from '@testing-library/react'; import { spy } from 'sinon'; import sinonChai from 'sinon-chai'; - -import { useSitecore, withSitecore, WithSitecoreProps } from '../enhancers/withSitecore'; +import { withSitecore, WithSitecoreProps } from '../enhancers/withSitecore'; import { SitecoreProviderReactContext, SitecoreProviderState, + useSitecore, } from '../components/SitecoreProvider'; import { LayoutServicePageState } from '@sitecore-content-sdk/content/layout'; @@ -60,7 +60,7 @@ describe('withSitecore', () => { it('withSitecore()', () => { const TestComponent: React.FC = (props: WithSitecoreProps & { customProp: string }) => ( <> -
+
{props.page.locale} {props.customProp}
@@ -109,7 +109,7 @@ describe('withSitecore', () => { return ( <> -
+
{page.locale} {props.customProp}
@@ -142,7 +142,7 @@ describe('withSitecore', () => { const context = reactContext.page; return ( -
+
{context.locale} {props.customProp}
diff --git a/packages/react/src/enhancers/withSitecore.tsx b/packages/react/src/enhancers/withSitecore.tsx index 117769b657..4772148d06 100644 --- a/packages/react/src/enhancers/withSitecore.tsx +++ b/packages/react/src/enhancers/withSitecore.tsx @@ -1,23 +1,13 @@ -'use client'; -import React, { useContext } from 'react'; +'use client'; +import React from 'react'; import { EnhancedOmit } from '@sitecore-content-sdk/core/tools'; import { - SitecoreProviderReactContext, SitecoreProviderState, + useSitecore, + UseSitecoreOptions, } from '../components/SitecoreProvider'; import { Page } from '@sitecore-content-sdk/content/client'; -/** - * The options for the withSitecore HOC. - * @public - */ -export interface WithSitecoreOptions { - /** - * If set to true, the `updateContext` method will be injected into the component props. - */ - updatable?: boolean; -} - /** * The props that HOC will inject. * @public @@ -36,7 +26,7 @@ export interface WithSitecoreProps { * @param {Page} value New page value. * @returns {void} */ - updatePage?: ((value: Page) => void) | false; + setPage?: ((value: Page) => void) | false; } /** @@ -52,48 +42,20 @@ export type WithSitecoreHocProps = EnhancedOmit< * @param {WithSitecoreProviderOptions} [options] * @public */ -export function withSitecore(options?: WithSitecoreOptions) { +export function withSitecore(options?: UseSitecoreOptions) { return function withSitecoreProviderHoc( Component: React.ComponentType ) { return function WithSitecoreProvider(props: WithSitecoreHocProps) { - const scContext = useContext(SitecoreProviderReactContext); + const scContext = useSitecore(options); return ( ); }; }; } - -/** - * This hook grants acсess to the current Sitecore page and api. - * @param {WithSitecoreOptions} [options] hook options - * @example - * const EditMode = () => { - * const { page } = useSitecore(); - * return Edit Mode is {page.mode.isEditing ? 'active' : 'inactive'} - * } - * @example - * const EditMode = () => { - * const { page, updatePage } = useSitecore({ updatable: true }); - * const onClick = () => updatePage({ itemId: '123' }); - * return Item id is {page.itemId} - * } - * @returns {object} { api, page, updatePage } - * @public - */ -export function useSitecore(options?: WithSitecoreOptions): WithSitecoreProps { - const reactContext = React.useContext(SitecoreProviderReactContext); - const updatable = options?.updatable; - - return { - api: reactContext.api, - page: reactContext.page, - updatePage: updatable ? reactContext.setPage : undefined, - }; -} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a2d1acd438..0948ea0538 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -87,18 +87,11 @@ export { SitecoreProviderState, SitecoreProviderReactContext, } from './components/SitecoreProvider'; -export { - withSitecore, - useSitecore, - WithSitecoreOptions, - WithSitecoreProps, - WithSitecoreHocProps, -} from './enhancers/withSitecore'; export { withEditorChromes } from './enhancers/withEditorChromes'; export { withDatasourceCheck } from './enhancers/withDatasourceCheck'; export { withFieldMetadata } from './enhancers/withFieldMetadata'; export { withEmptyFieldEditingComponent } from './enhancers/withEmptyFieldEditingComponent'; -export { withComponentMap, useComponentMap } from './enhancers/withComponentMap'; +export { useComponentMap } from './components/SitecoreProvider'; export { withAppPlaceholder } from './enhancers/withAppPlaceholder'; export { withClientPlaceholder } from './enhancers/withClientPlaceholder'; export { EditingScripts } from './components/EditingScripts'; diff --git a/packages/react/src/search/utils.test.tsx b/packages/react/src/search/utils.test.tsx index b46fa09575..9647732b4a 100644 --- a/packages/react/src/search/utils.test.tsx +++ b/packages/react/src/search/utils.test.tsx @@ -7,8 +7,8 @@ import { render, waitFor } from '@testing-library/react'; import { SitecoreProviderReactContext, SitecoreProviderState, + useSitecore, } from '../components/SitecoreProvider'; -import { useSitecore } from '../enhancers/withSitecore'; import { getOffset, useSearchService } from './utils'; describe('search utils', () => { diff --git a/packages/react/src/search/utils.ts b/packages/react/src/search/utils.ts index be47470b36..28c81864f1 100644 --- a/packages/react/src/search/utils.ts +++ b/packages/react/src/search/utils.ts @@ -1,6 +1,6 @@ import { SearchService } from '@sitecore-content-sdk/search'; import { useMemo } from 'react'; -import { useSitecore } from '../enhancers/withSitecore'; +import { useSitecore } from '../components/SitecoreProvider'; export const DEFAULT_PAGE_SIZE = 10;