From a347126db8b87117b3882d9632f640b863d1761f Mon Sep 17 00:00:00 2001 From: Thomas Cristina de Carvalho Date: Mon, 9 Mar 2026 17:27:24 -0400 Subject: [PATCH 1/3] Enable drag-and-drop reordering for visual editing sections Add data-sanity attributes and optimistic state to support drag-and-drop reordering of array-based sections in Sanity's Presentation tool. - Create SectionsRenderer component with useOptimistic for instant reorder feedback and createDataAttribute for drag-and-drop overlays - Add dataSanity prop to CmsSection/SectionWrapper for section-level data-sanity attributes - Update page, product, and collection routes to use SectionsRenderer --- app/components/cms-section.tsx | 4 ++ app/components/sections-renderer.tsx | 70 +++++++++++++++++++ app/routes/($locale).$.tsx | 20 +++--- ...$locale).collections.$collectionHandle.tsx | 20 +++--- .../($locale).products.$productHandle.tsx | 20 +++--- 5 files changed, 101 insertions(+), 33 deletions(-) create mode 100644 app/components/sections-renderer.tsx diff --git a/app/components/cms-section.tsx b/app/components/cms-section.tsx index aa37b41..6af2d23 100644 --- a/app/components/cms-section.tsx +++ b/app/components/cms-section.tsx @@ -19,6 +19,7 @@ type CmsSectionProps = FooterDataType | SectionDataType; export function CmsSection(props: { data: CmsSectionProps; + dataSanity?: string; encodeDataAttribute?: EncodeDataAttributeCallback; index?: number; type?: CmsSectionType; @@ -50,6 +51,7 @@ export function CmsSection(props: { return Section ? (
diff --git a/app/components/sections-renderer.tsx b/app/components/sections-renderer.tsx new file mode 100644 index 0000000..c97bef7 --- /dev/null +++ b/app/components/sections-renderer.tsx @@ -0,0 +1,70 @@ +import type {EncodeDataAttributeCallback} from '@sanity/react-loader'; +import type {SanityDocument} from '@sanity/client'; +import type {SectionDataType} from 'types'; + +import {createDataAttribute, useOptimistic} from '@sanity/visual-editing/react'; +import {useMemo} from 'react'; + +import {CmsSection} from './cms-section'; +import {useRootLoaderData} from '~/root'; +import {SANITY_STUDIO_PATH} from '~/sanity/constants'; + +type SectionsRendererProps = { + documentId: string; + documentType: string; + encodeDataAttribute?: EncodeDataAttributeCallback; + sections: SectionDataType[]; +}; + +export function SectionsRenderer(props: SectionsRendererProps) { + const {documentId, documentType, encodeDataAttribute, sections} = props; + const {env, sanityPreviewMode} = useRootLoaderData(); + + const optimisticSections = useOptimistic< + SectionDataType[], + SanityDocument<{sections: SectionDataType[]}> + >(sections, (currentSections, action) => { + if (action.type === 'mutate') { + return action.document?.sections ?? currentSections; + } + return currentSections; + }); + + const baseDataAttribute = useMemo( + () => + sanityPreviewMode + ? createDataAttribute({ + baseUrl: SANITY_STUDIO_PATH, + id: documentId, + type: documentType, + projectId: env.PUBLIC_SANITY_STUDIO_PROJECT_ID, + dataset: env.PUBLIC_SANITY_STUDIO_DATASET, + }) + : undefined, + [ + documentId, + documentType, + env.PUBLIC_SANITY_STUDIO_DATASET, + env.PUBLIC_SANITY_STUDIO_PROJECT_ID, + sanityPreviewMode, + ], + ); + + if (!optimisticSections || optimisticSections.length === 0) return null; + + return ( +
+ {optimisticSections.map((section, index) => ( + + ))} +
+ ); +} diff --git a/app/routes/($locale).$.tsx b/app/routes/($locale).$.tsx index 1d483d8..c1a8647 100644 --- a/app/routes/($locale).$.tsx +++ b/app/routes/($locale).$.tsx @@ -5,9 +5,9 @@ import type {Route} from './+types/($locale).$'; import {DEFAULT_LOCALE} from 'countries'; -import {CmsSection} from '~/components/cms-section'; import {PAGE_QUERY} from '~/data/sanity/queries'; import {useEncodeDataAttribute} from '~/hooks/use-encode-data-attribute'; +import {SectionsRenderer} from '~/components/sections-renderer'; import {resolveShopifyPromises} from '~/lib/resolve-shopify-promises'; import {getSeoMetaFromMatches} from '~/lib/seo'; @@ -73,16 +73,14 @@ export default function PageRoute({loaderData}: Route.ComponentProps) { const {data} = loaderData.page; const encodeDataAttribute = useEncodeDataAttribute(data ?? {}); - return data?.sections && data.sections.length > 0 - ? data.sections.map((section, index) => ( - - )) - : null; + return data?.sections && data.sections.length > 0 ? ( + + ) : null; } function getPageHandle(args: { diff --git a/app/routes/($locale).collections.$collectionHandle.tsx b/app/routes/($locale).collections.$collectionHandle.tsx index e7b24ee..c6a5c8e 100644 --- a/app/routes/($locale).collections.$collectionHandle.tsx +++ b/app/routes/($locale).collections.$collectionHandle.tsx @@ -7,9 +7,9 @@ import invariant from 'tiny-invariant'; import type {Route} from './+types/($locale).collections.$collectionHandle'; -import {CmsSection} from '~/components/cms-section'; import {COLLECTION_QUERY as CMS_COLLECTION_QUERY} from '~/data/sanity/queries'; import {useEncodeDataAttribute} from '~/hooks/use-encode-data-attribute'; +import {SectionsRenderer} from '~/components/sections-renderer'; import {COLLECTION_QUERY} from '~/data/shopify/queries'; import {mergeRouteModuleMeta} from '~/lib/meta'; import {resolveShopifyPromises} from '~/lib/resolve-shopify-promises'; @@ -88,16 +88,14 @@ export default function Collection({loaderData}: Route.ComponentProps) { return ( <> - {template?.sections && template.sections.length > 0 - ? template.sections.map((section, index) => ( - - )) - : null} + {template?.sections && template.sections.length > 0 ? ( + + ) : null} - {template?.sections && - template.sections.length > 0 && - template.sections.map((section, index) => ( - - ))} + {template?.sections && template.sections.length > 0 && ( + + )} Date: Mon, 9 Mar 2026 17:35:39 -0400 Subject: [PATCH 2/3] Fix optimistic reducer document filter and layout regression - Guard useOptimistic reducer with action.id === documentId to prevent cross-document mutation corruption - Add className="contents" to wrapper div to preserve gap-y spacing from parent main element's flex layout --- app/components/sections-renderer.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/components/sections-renderer.tsx b/app/components/sections-renderer.tsx index c97bef7..2da73a9 100644 --- a/app/components/sections-renderer.tsx +++ b/app/components/sections-renderer.tsx @@ -24,7 +24,7 @@ export function SectionsRenderer(props: SectionsRendererProps) { SectionDataType[], SanityDocument<{sections: SectionDataType[]}> >(sections, (currentSections, action) => { - if (action.type === 'mutate') { + if (action.type === 'mutate' && action.id === documentId) { return action.document?.sections ?? currentSections; } return currentSections; @@ -53,7 +53,10 @@ export function SectionsRenderer(props: SectionsRendererProps) { if (!optimisticSections || optimisticSections.length === 0) return null; return ( -
+
{optimisticSections.map((section, index) => ( Date: Mon, 9 Mar 2026 17:45:53 -0400 Subject: [PATCH 3/3] Fix useOptimistic reducer to reconcile references on reorder The raw document from useOptimistic actions contains unresolved portable text fields ({_key, _type, value} objects) that crash React when rendered. Use the reconciliation pattern from Sanity docs: apply the new order from the mutation but keep the GROQ-projected data from the current state by matching on _key. --- app/components/sections-renderer.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/components/sections-renderer.tsx b/app/components/sections-renderer.tsx index 2da73a9..1ef41ea 100644 --- a/app/components/sections-renderer.tsx +++ b/app/components/sections-renderer.tsx @@ -22,10 +22,16 @@ export function SectionsRenderer(props: SectionsRendererProps) { const optimisticSections = useOptimistic< SectionDataType[], - SanityDocument<{sections: SectionDataType[]}> + SanityDocument<{sections: Array<{_key: string}>}> >(sections, (currentSections, action) => { - if (action.type === 'mutate' && action.id === documentId) { - return action.document?.sections ?? currentSections; + if (action.id === documentId && action.document?.sections) { + // Reconcile: use the new order from the mutation but keep the + // GROQ-projected data from currentSections. The raw document + // has unresolved portable text/references that can't be rendered. + return action.document.sections.map( + (section) => + currentSections?.find((s) => s._key === section._key) || section, + ) as SectionDataType[]; } return currentSections; });