From 53bf679def3c7318db48776d4ad7266a555b8e3c Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:05:14 -0300 Subject: [PATCH] feat(web): v8 sites always get the new blocks editor; no async rendering on v8 v8 sites always get the new blocks editor, and the async-rendering controls show only on sites known to be v7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../ct/harness/stubs/use-new-blocks-editor.ts | 17 ++- apps/web/docs/component-architecture.md | 13 +- .../sandbox/blocks/page-json-panel.tsx | 10 +- .../sections-editor/add-section-modal.tsx | 67 +++++++--- .../async-render-controls.test.tsx | 117 ++++++++++++++++++ .../sections-editor/page-seo-form.tsx | 7 +- .../sections-editor/schema-form.tsx | 5 +- .../sections-editor/section-list.tsx | 19 +-- .../sections-editor/section-preview-image.ts | 16 +++ .../components/sections-editor/seo-editor.tsx | 7 ++ .../sections-editor/seo-form-chrome.tsx | 5 +- .../components/settings/review-settings.tsx | 4 +- apps/web/src/hooks/use-new-blocks-editor.ts | 51 +++++++- knip.jsonc | 3 +- 14 files changed, 297 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/components/sections-editor/async-render-controls.test.tsx diff --git a/apps/web/ct/harness/stubs/use-new-blocks-editor.ts b/apps/web/ct/harness/stubs/use-new-blocks-editor.ts index 724528754a..4a3e531b4e 100644 --- a/apps/web/ct/harness/stubs/use-new-blocks-editor.ts +++ b/apps/web/ct/harness/stubs/use-new-blocks-editor.ts @@ -1,13 +1,13 @@ /** * CT stub for `@/hooks/use-new-blocks-editor`. * - * The real hook reads the `new_blocks_editor` org flag through - * `useProjectContext()`, which needs the full app provider tree this harness - * doesn't mount. Specs pick the editor by writing {@link NEW_BLOCKS_EDITOR_KEY} + * The real hook gives v8 sites the new editor and v7 ones the + * `new_blocks_editor` org flag, reading the site's content backend and the org + * settings, which need the full app provider tree this harness doesn't mount. Specs pick the editor by writing {@link NEW_BLOCKS_EDITOR_KEY} * before mounting (absent reads as off, like an unset flag); harnesses flip it * mid-test with {@link setNewBlocksEditor}. */ -import { useSyncExternalStore } from "react"; +import { type ReactNode, useSyncExternalStore } from "react"; export const NEW_BLOCKS_EDITOR_KEY = "ct:new-blocks-editor"; @@ -30,3 +30,12 @@ export function setNewBlocksEditor(enabled: boolean): void { export function useNewBlocksEditor(): boolean { return useSyncExternalStore(subscribe, read); } + +export function useNewBlocksEditorState(): boolean | undefined { + return useNewBlocksEditor(); +} + +/** No site to detect here: the stored choice stands for every site. */ +export function NewBlocksEditorProvider({ children }: { children: ReactNode }) { + return children; +} diff --git a/apps/web/docs/component-architecture.md b/apps/web/docs/component-architecture.md index 85cfc120c2..1370381613 100644 --- a/apps/web/docs/component-architecture.md +++ b/apps/web/docs/component-architecture.md @@ -11,10 +11,15 @@ variables where callers need to override them through `className`. Only the redesigned blocks editor remains opt-in, per organization, through **Settings → General → New blocks editor** (the `new_blocks_editor` org flag, -off by default). Any member may switch it, through the basic-usage -`ORGANIZATION_BLOCKS_EDITOR_SET` tool; General shows plain members only that -switch. Components in the editor read -`useNewBlocksEditor()`. Switching it changes only the editor for every member; +off by default), and only on v7 sites: a v8 (content-protocol) site always +gets it, including on the org-less `/site-editor`. Any member may switch it, +through the basic-usage `ORGANIZATION_BLOCKS_EDITOR_SET` tool; General shows +plain members only that switch. Components in the editor read +`useNewBlocksEditor()`, which decides from the site's content backend +(`NewBlocksEditorProvider`, mounted by the Content and Preview tabs) and the +flag (`newBlocksEditorEnabled`); editor roots wait on +`useNewBlocksEditorState()` while the site's version is detected. Switching +it changes only the editor for every member; navigation, page headers, and other application screens keep the default UI. Editor browser tests opt in with `test.use({ newBlocksEditor: true })`, which sets the flag on the test's org. Component tests choose the editor through the diff --git a/apps/web/src/components/sandbox/blocks/page-json-panel.tsx b/apps/web/src/components/sandbox/blocks/page-json-panel.tsx index 0ca4bec4bb..6b53ff7e91 100644 --- a/apps/web/src/components/sandbox/blocks/page-json-panel.tsx +++ b/apps/web/src/components/sandbox/blocks/page-json-panel.tsx @@ -11,7 +11,7 @@ import { useDebouncedSaveBlock } from "@/components/sections-editor/use-save-blo import { usePackagePath } from "@/components/sections-editor/use-package-path"; import { decoBlockFilePath } from "@/components/sections-editor/deco-block-key"; import { decoRepoPath } from "@/components/sections-editor/deco-repo-path"; -import { useNewBlocksEditor } from "@/hooks/use-new-blocks-editor"; +import { useNewBlocksEditorState } from "@/hooks/use-new-blocks-editor"; import { useT } from "@/i18n/use-t.ts"; /** @@ -40,7 +40,8 @@ export function PageJsonPanel({ ref?: Ref; }) { const t = useT(); - const compact = useNewBlocksEditor(); + const editorState = useNewBlocksEditorState(); + const compact = editorState === true; const packagePath = usePackagePath(virtualMcpId); const blockFilePath = decoRepoPath(packagePath, decoBlockFilePath(pageKey)); const { org } = useProjectContext(); @@ -66,7 +67,10 @@ export function PageJsonPanel({ }); const pageData = decofile.data?.[pageKey]; - const loading = decofile.data === undefined && decofile.isLoading; + // Waits for which editor this site gets too, so its header doesn't swap. + const loading = + (decofile.data === undefined && decofile.isLoading) || + editorState === undefined; const missing = decofile.data !== undefined && pageData === undefined; const readOnly = !currentBranch || missing || loading; const initialJson = diff --git a/apps/web/src/components/sections-editor/add-section-modal.tsx b/apps/web/src/components/sections-editor/add-section-modal.tsx index ecfa06c8bc..a3e1f3eaf5 100644 --- a/apps/web/src/components/sections-editor/add-section-modal.tsx +++ b/apps/web/src/components/sections-editor/add-section-modal.tsx @@ -17,6 +17,7 @@ import { } from "./section-catalog"; import type { LiveMeta } from "./resolve-schema"; import { buildSectionPreviewUrl } from "./section-preview-url"; +import { resolveSectionGalleryImage } from "./section-preview-image"; import { onPreviewIframeSlotAvailable, releasePreviewIframeSlot, @@ -161,14 +162,36 @@ function LazySectionPreview({ ); } +/** A card thumbnail without a render: the schema's `@image`, or a placeholder. */ +function StaticSectionPreview({ image }: { image: string | undefined }) { + const PlaceholderIcon = useNewBlocksEditor() ? Cube01 : LayoutAlt01; + return ( +
+ {image ? ( + + ) : ( + + )} +
+ ); +} + function SectionGalleryCard({ entry, previewUrl, + image, scrollRootRef, onSelect, }: { entry: ReturnType[number]; - previewUrl: string; + /** `null` when nothing renders the section (the content protocol). */ + previewUrl: string | null; + image: string | undefined; scrollRootRef: RefObject; onSelect: () => void; }) { @@ -191,12 +214,16 @@ function SectionGalleryCard({ "border-global-section/35 hover:bg-global-section/8", )} > - + {previewUrl ? ( + + ) : ( + + )}
void; meta: LiveMeta | null | undefined; decofile: Record | null | undefined; - previewBaseUrl: string; + /** `null` when nothing renders sections (the content protocol). */ + previewBaseUrl: string | null; onSelect: (entry: ReturnType[number]) => void; }) { const t = useT(); @@ -250,7 +278,7 @@ export function AddSectionModal({ const siteTheme = decofile ? findSiteThemeBlock(decofile) : undefined; const previewUrls = new Map(); - if (open && livePageResolveType) { + if (open && livePageResolveType && previewBaseUrl) { for (const entry of sections) { previewUrls.set( entry.resolveType, @@ -312,13 +340,20 @@ export function AddSectionModal({ entry={entry} scrollRootRef={scrollRef} previewUrl={ - previewUrls.get(entry.resolveType) ?? - buildSectionPreviewUrl( - previewBaseUrl, - livePageResolveType, - entry.previewBlock, - siteTheme, - ) + previewBaseUrl + ? (previewUrls.get(entry.resolveType) ?? + buildSectionPreviewUrl( + previewBaseUrl, + livePageResolveType, + entry.previewBlock, + siteTheme, + )) + : null + } + image={ + previewBaseUrl || !meta + ? undefined + : resolveSectionGalleryImage(entry.resolveType, meta) } onSelect={() => onSelect(entry)} /> diff --git a/apps/web/src/components/sections-editor/async-render-controls.test.tsx b/apps/web/src/components/sections-editor/async-render-controls.test.tsx new file mode 100644 index 0000000000..cb92a06802 --- /dev/null +++ b/apps/web/src/components/sections-editor/async-render-controls.test.tsx @@ -0,0 +1,117 @@ +import { setupComponentTest } from "../../../test/setup"; +setupComponentTest(); +import { describe, expect, mock, test } from "bun:test"; +import { render, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; + +// The classic editor. The compact row menu gates on the same +// `enableAsyncRender`; its Radix portal doesn't mount reliably under the +// shared bun:test DOM, so it is checked in a browser instead. +mock.module("@/hooks/use-new-blocks-editor", () => ({ + useNewBlocksEditor: () => false, + useNewBlocksEditorState: () => false, +})); + +import { PageSeoForm } from "./page-seo-form"; +import { SectionList } from "./section-list"; +import { parseSections } from "./parse-sections"; +import { LAZY_RENDER_RESOLVE_TYPE } from "./seo-lazy-render"; +import type { RawSection } from "./section-types"; + +/** + * v8 has no async rendering (the v7→v8 migration strips Lazy wrappers), so no + * control is offered; v7 keeps both directions. + */ + +function renderUi(ui: ReactElement) { + return render( + {ui}, + ); +} + +const HERO = { __resolveType: "site/sections/Hero.tsx" } as RawSection; +const LAZY_HERO = { + __resolveType: LAZY_RENDER_RESOLVE_TYPE, + section: HERO, +} as RawSection; + +function renderList(rawSections: RawSection[], asyncRenderAvailable: boolean) { + const noop = () => {}; + renderUi( + , + ); +} + +describe("section row async rendering", () => { + test("classic: v7 offers enable and disable", () => { + renderList([HERO, LAZY_HERO], true); + expect( + within(document.body).getAllByLabelText("Enable async rendering"), + ).toHaveLength(1); + expect( + within(document.body).getAllByLabelText("Disable async rendering"), + ).toHaveLength(1); + }); + + test("classic: v8 offers no control", () => { + renderList([HERO, LAZY_HERO], false); + expect( + within(document.body).queryByLabelText("Enable async rendering"), + ).toBeNull(); + expect( + within(document.body).queryByLabelText("Disable async rendering"), + ).toBeNull(); + }); +}); + +describe("page SEO async rendering", () => { + function renderSeo(rawSeo: unknown, asyncRenderAvailable: boolean) { + const noop = () => {}; + renderUi( + , + ); + } + + const SEO = { __resolveType: "website/sections/Seo/Seo.tsx", title: "T" }; + + test("v7 offers the switch", () => { + renderSeo(SEO, true); + expect(document.querySelector("#seo-async-render")).not.toBeNull(); + }); + + test("v8 offers no switch", () => { + renderSeo(SEO, false); + expect(document.querySelector("#seo-async-render")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/sections-editor/page-seo-form.tsx b/apps/web/src/components/sections-editor/page-seo-form.tsx index 07f90665e7..54c08df5ca 100644 --- a/apps/web/src/components/sections-editor/page-seo-form.tsx +++ b/apps/web/src/components/sections-editor/page-seo-form.tsx @@ -28,6 +28,8 @@ interface PageSeoFormProps { /** Remounts schema widgets (type change). */ onBumpFormKey: () => void; beforeFields?: ReactNode; + /** False on v8, which has no async rendering: no switch at all. */ + asyncRenderAvailable?: boolean; } /** Page SEO: Enable + type + fields + Async render (admin EditSEO layout). */ @@ -46,6 +48,7 @@ export function PageSeoForm({ onClearForm, onBumpFormKey, beforeFields, + asyncRenderAvailable = true, }: PageSeoFormProps) { const handleEnableChange = (enabled: boolean) => { if (enabled) { @@ -75,7 +78,9 @@ export function PageSeoForm({ {beforeFields} {isSeoEnabled(rawSeo) && diff --git a/apps/web/src/components/sections-editor/schema-form.tsx b/apps/web/src/components/sections-editor/schema-form.tsx index 86103365f8..384d63a554 100644 --- a/apps/web/src/components/sections-editor/schema-form.tsx +++ b/apps/web/src/components/sections-editor/schema-form.tsx @@ -1,4 +1,5 @@ import { type ReactNode, useEffect } from "react"; +import { useNewBlocksEditor } from "@/hooks/use-new-blocks-editor"; import { useT } from "@/i18n/use-t.ts"; import { resolveSchema } from "./resolve-schema"; import { applySchemaDefaults } from "./schema-defaults"; @@ -528,6 +529,8 @@ function SchemaFormBody({ onVariantMatcherOp, }: SchemaFormProps) { const t = useT(); + // Which blocks editor renders this form, for browser tests to tell apart. + const editor = useNewBlocksEditor() ? "new" : "classic"; const properties = schema.properties; // The resolved root can itself be a single union field — a discriminated // block config whose props are a plain `A | B | C` union (e.g. the VTEX @@ -680,7 +683,7 @@ function SchemaFormBody({ ? (next: Crumb[]) => onBreadcrumbChange([...consumedPrefix, ...next]) : onBreadcrumbChange; return ( -
+
{visibleKeys.map((key) => { const propSchema = properties[key]; if (!propSchema) return null; diff --git a/apps/web/src/components/sections-editor/section-list.tsx b/apps/web/src/components/sections-editor/section-list.tsx index 938b3f5e3f..4e0720e61c 100644 --- a/apps/web/src/components/sections-editor/section-list.tsx +++ b/apps/web/src/components/sections-editor/section-list.tsx @@ -307,7 +307,8 @@ function SortableSectionItem({ onDuplicate: () => void; onMakeReusable: () => void; onToggleHidden: () => void; - onToggleLazy: () => void; + /** Unset where async rendering doesn't exist (v8): no control is offered. */ + onToggleLazy?: () => void; onAddVariant: () => void; onDetach: () => void; }) { @@ -319,6 +320,7 @@ function SortableSectionItem({ const isHidden = section.isHidden === true; const reserveActionButtonSpace = isAsyncRender || isHidden; const enableAddVariant = canAddSectionVariant(section); + const enableAsyncRender = !!onToggleLazy && !section.isMultivariate; const { attributes, listeners, setNodeRef, transform, isDragging } = useSortable({ id: sortableId, @@ -362,7 +364,7 @@ function SortableSectionItem({ decofile={decofile} /> - {!compact && !section.isMultivariate && ( + {!compact && enableAsyncRender && (
{enabled && - (compact ? ( + (compact || !onAsyncRenderChange ? ( children ) : ( <> diff --git a/apps/web/src/components/settings/review-settings.tsx b/apps/web/src/components/settings/review-settings.tsx index bd5d56251a..1e21045ad3 100644 --- a/apps/web/src/components/settings/review-settings.tsx +++ b/apps/web/src/components/settings/review-settings.tsx @@ -31,7 +31,6 @@ import { useSetNewBlocksEditor, useSetOrgFlag, } from "@/hooks/use-organization-settings"; -import { useNewBlocksEditor } from "@/hooks/use-new-blocks-editor"; import { track } from "@/lib/posthog-client"; import { useConnections, useProjectContext, WellKnownOrgMCPId } from "@/sdk"; import { Skeleton } from "@decocms/ui/components/skeleton.tsx"; @@ -447,7 +446,8 @@ export function BlogBlocksSettings() { */ export function BlocksEditorSettings() { const t = useT(); - const enabled = useNewBlocksEditor(); + // The org's own choice, not one site's editor (v8 sites always get it). + const enabled = useOrgFlag("new_blocks_editor"); const setEnabled = useSetNewBlocksEditor(); return ( diff --git a/apps/web/src/hooks/use-new-blocks-editor.ts b/apps/web/src/hooks/use-new-blocks-editor.ts index 81d3462f16..981e12cb5a 100644 --- a/apps/web/src/hooks/use-new-blocks-editor.ts +++ b/apps/web/src/hooks/use-new-blocks-editor.ts @@ -1,6 +1,51 @@ -import { useOrgFlag } from "@/hooks/use-organization-settings"; +import { + createContext, + createElement, + type ReactNode, + useContext, +} from "react"; +import { useOptionalChatTask } from "@/components/chat/context"; +import { + type ContentBackend, + newBlocksEditorEnabled, +} from "@/components/sections-editor/content-backend"; +import { useContentBackend } from "@/components/sections-editor/use-content-backend"; +import { useOrgFlagState } from "@/hooks/use-organization-settings"; +import { useProjectContext } from "@/sdk"; -/** Whether the org opted into the redesigned blocks editor. */ +/** The site's content backend, for the editors below it; `null`: no site. */ +const SiteBackendContext = createContext(null); + +/** + * Tells the editors below it which site they edit (the chat task's project + * and branch, the same ones the Content and Blocks panels read), so + * {@link useNewBlocksEditor} can give v8 sites the new editor. Mounted once + * per surface (the Content and Preview tabs) rather than probed per field. + */ +export function NewBlocksEditorProvider({ children }: { children: ReactNode }) { + const task = useOptionalChatTask(); + const backend = useContentBackend(task?.virtualMcpId, task?.currentBranch); + return createElement( + SiteBackendContext.Provider, + { value: backend.kind }, + children, + ); +} + +/** + * Whether to show the redesigned blocks editor (see `newBlocksEditorEnabled`): + * always on v8 sites, the org's `new_blocks_editor` flag on v7 ones and + * outside a site. `undefined` while that is still being detected — editor + * roots wait on it rather than flash one editor and swap to the other. + */ +export function useNewBlocksEditorState(): boolean | undefined { + const backend = useContext(SiteBackendContext); + const { org } = useProjectContext(); + const flag = useOrgFlagState("new_blocks_editor"); + return newBlocksEditorEnabled({ backend, hasOrg: !!org.id, orgFlag: flag }); +} + +/** {@link useNewBlocksEditorState}, the old editor until it is known. */ export function useNewBlocksEditor(): boolean { - return useOrgFlag("new_blocks_editor"); + return useNewBlocksEditorState() ?? false; } diff --git a/knip.jsonc b/knip.jsonc index 0af3ce85c7..0cd921491c 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -85,6 +85,7 @@ "apps/web/src/hooks/use-deco-serve-connection.ts": ["exports"], "apps/web/src/components/sections-editor/content-protocol-api.ts": [ "exports" - ] + ], + "apps/web/src/hooks/use-new-blocks-editor.ts": ["exports"] } }