Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions apps/web/ct/harness/stubs/use-new-blocks-editor.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
}
13 changes: 9 additions & 4 deletions apps/web/docs/component-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions apps/web/src/components/sandbox/blocks/page-json-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -40,7 +40,8 @@ export function PageJsonPanel({
ref?: Ref<PageJsonPanelHandle>;
}) {
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();
Expand All @@ -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 =
Expand Down
67 changes: 51 additions & 16 deletions apps/web/src/components/sections-editor/add-section-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="relative flex aspect-[16/10] w-full items-center justify-center overflow-hidden bg-muted/40">
{image ? (
<img
src={image}
alt=""
referrerPolicy="no-referrer"
className="h-full w-full object-cover"
/>
) : (
<PlaceholderIcon className="h-8 w-8 text-muted-foreground/30" />
)}
</div>
);
}

function SectionGalleryCard({
entry,
previewUrl,
image,
scrollRootRef,
onSelect,
}: {
entry: ReturnType<typeof extractSectionCatalog>[number];
previewUrl: string;
/** `null` when nothing renders the section (the content protocol). */
previewUrl: string | null;
image: string | undefined;
scrollRootRef: RefObject<HTMLElement | null>;
onSelect: () => void;
}) {
Expand All @@ -191,12 +214,16 @@ function SectionGalleryCard({
"border-global-section/35 hover:bg-global-section/8",
)}
>
<LazySectionPreview
previewUrl={previewUrl}
title={entry.title}
scrollRootRef={scrollRootRef}
slotId={entry.resolveType}
/>
{previewUrl ? (
<LazySectionPreview
previewUrl={previewUrl}
title={entry.title}
scrollRootRef={scrollRootRef}
slotId={entry.resolveType}
/>
) : (
<StaticSectionPreview image={image} />
)}
<div className="flex items-center gap-2 border-t px-3 py-2.5">
<CardIcon
className="h-4 w-4 shrink-0"
Expand Down Expand Up @@ -231,7 +258,8 @@ export function AddSectionModal({
onOpenChange: (open: boolean) => void;
meta: LiveMeta | null | undefined;
decofile: Record<string, unknown> | null | undefined;
previewBaseUrl: string;
/** `null` when nothing renders sections (the content protocol). */
previewBaseUrl: string | null;
onSelect: (entry: ReturnType<typeof extractSectionCatalog>[number]) => void;
}) {
const t = useT();
Expand All @@ -250,7 +278,7 @@ export function AddSectionModal({
const siteTheme = decofile ? findSiteThemeBlock(decofile) : undefined;

const previewUrls = new Map<string, string>();
if (open && livePageResolveType) {
if (open && livePageResolveType && previewBaseUrl) {
for (const entry of sections) {
previewUrls.set(
entry.resolveType,
Expand Down Expand Up @@ -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)}
/>
Expand Down
117 changes: 117 additions & 0 deletions apps/web/src/components/sections-editor/async-render-controls.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<QueryClientProvider client={new QueryClient()}>{ui}</QueryClientProvider>,
);
}

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(
<SectionList
listKey="page"
rawSections={rawSections}
sections={parseSections(rawSections, {})}
meta={null}
decofile={{}}
selectedIndex={null}
onSelect={noop}
onDelete={noop}
onDuplicate={noop}
onMakeReusable={noop}
onToggleHidden={noop}
// As SectionsEditor wires it: unset on v8.
onToggleLazy={asyncRenderAvailable ? noop : undefined}
onAddVariant={noop}
onDetach={noop}
onAddSection={noop}
/>,
);
}

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(
<PageSeoForm
rawSeo={rawSeo}
innerSeo={{}}
defaultResolveType="website/sections/Seo/Seo.tsx"
seoSchema={null}
activeResolveType={null}
seoTypeOptions={undefined}
formResetKey={0}
onPersistRaw={noop}
onInnerChange={noop}
onClearForm={noop}
onBumpFormKey={noop}
asyncRenderAvailable={asyncRenderAvailable}
/>,
);
}

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();
});
});
7 changes: 6 additions & 1 deletion apps/web/src/components/sections-editor/page-seo-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -46,6 +48,7 @@ export function PageSeoForm({
onClearForm,
onBumpFormKey,
beforeFields,
asyncRenderAvailable = true,
}: PageSeoFormProps) {
const handleEnableChange = (enabled: boolean) => {
if (enabled) {
Expand Down Expand Up @@ -75,7 +78,9 @@ export function PageSeoForm({
<SeoFormChrome
rawSeo={rawSeo}
onEnableChange={handleEnableChange}
onAsyncRenderChange={handleAsyncRenderChange}
onAsyncRenderChange={
asyncRenderAvailable ? handleAsyncRenderChange : undefined
}
>
{beforeFields}
{isSeoEnabled(rawSeo) &&
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/components/sections-editor/schema-form.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -680,7 +683,7 @@ function SchemaFormBody({
? (next: Crumb[]) => onBreadcrumbChange([...consumedPrefix, ...next])
: onBreadcrumbChange;
return (
<div className="min-w-0 space-y-6">
<div className="min-w-0 space-y-6" data-blocks-editor={editor}>
{visibleKeys.map((key) => {
const propSchema = properties[key];
if (!propSchema) return null;
Expand Down
Loading
Loading