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
2 changes: 1 addition & 1 deletion .sync-ref
Original file line number Diff line number Diff line change
@@ -1 +1 @@
chaibuilder/pro@103c78ca9ad4b79792205a357c0f16748aa2ae17
chaibuilder/pro@3e72f9b8f19c8efd5574a52bc268c197150d5635
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { closestBlockProp, registerChaiBlock, registerChaiBlockProps } from "~/registry";
import { getBlockRuntimeProps } from "./new-blocks-render-helpers";

// `getBlockRuntimeProps` resolves the block schema through `getBlockSchema`
// (`props.schema` for blocks registered with `props: registerChaiBlockProps(...)`)
// and keeps only the `runtime: true` entries — the `closestBlockProp` bindings
// that Dropdown, Modal, ExpandableContent and RepeaterItem rely on. Lookups
// are memoized per block type, so each case uses a unique type.
const noop = () => null;
const withProps = (type: string, properties: Record<string, any>) =>
registerChaiBlock(noop as any, {
type,
label: type,
group: "basic",
props: registerChaiBlockProps({ properties }),
} as any);

describe("getBlockRuntimeProps", () => {
it("extracts runtime props from the `props.schema` of a registered block", () => {
withProps("TestRuntimeProps", {
show: closestBlockProp("TestParent", "flag"),
title: { type: "string", title: "Title", default: "x" },
});

const runtimeProps = getBlockRuntimeProps("TestRuntimeProps");

// `show` (from closestBlockProp) carries `runtime: true`; `title` does not.
expect(Object.keys(runtimeProps)).toEqual(["show"]);
expect(runtimeProps.show).toMatchObject({ runtime: true, block: "TestParent", prop: "flag" });
});

it("returns an empty object when a block has no runtime props", () => {
withProps("TestRuntimeNone", { title: { type: "string", title: "Title" } });
expect(getBlockRuntimeProps("TestRuntimeNone")).toEqual({});
});

it("returns an empty object for an unregistered block type (getBlockSchema is not null-safe)", () => {
expect(getBlockRuntimeProps("TestRuntimeUnregistered")).toEqual({});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { twMerge } from "cnfast";
import { getSplitChaiClasses } from "~/builder/hooks/get-split-classes";
import { CHAI_BUILT_IN_DESIGN_TOKENS } from "~/constants/BUILTIN_TOKENS";
import { DESIGN_TOKEN_PREFIX, STYLES_KEY } from "~/constants/STRINGS";
import { getRegisteredChaiBlock } from "~/registry";
import { getBlockSchema, getRegisteredChaiBlock } from "~/registry";
import { ChaiBlockConfig } from "~/types/blocks";
import { ChaiBlock } from "~/types/common";
import { ChaiDesignTokens } from "~/types/types";
Expand Down Expand Up @@ -88,12 +88,18 @@ const resolveTokenValue = (value: string | undefined, designTokens: ChaiDesignTo
.join(" ");
};

const getMergedDesignTokens = memoize(
(designTokens: ChaiDesignTokens): ChaiDesignTokens => ({
...CHAI_BUILT_IN_DESIGN_TOKENS,
...designTokens,
}),
);
// WeakMap, not lodash memoize: render paths pass a fresh designTokens object per
// request (and `getBlockTagAttributes` defaults to a fresh `{}` per call), so a
// strong identity-keyed cache never hits and grows unbounded on warm instances.
const mergedDesignTokensCache = new WeakMap<ChaiDesignTokens, ChaiDesignTokens>();
const getMergedDesignTokens = (designTokens: ChaiDesignTokens): ChaiDesignTokens => {
let merged = mergedDesignTokensCache.get(designTokens);
if (!merged) {
merged = { ...CHAI_BUILT_IN_DESIGN_TOKENS, ...designTokens };
mergedDesignTokensCache.set(designTokens, merged);
}
return merged;
};

const classNamesCache = new WeakMap<ChaiDesignTokens, Map<string, string>>();
const blockTagAttributesCache = new WeakMap<
Expand Down Expand Up @@ -199,8 +205,11 @@ export function getBlockTagAttributes(

export const getBlockRuntimeProps: (blockType: string) => Record<string, unknown> = memoize((blockType: string) => {
const chaiBlock = getRegisteredChaiBlock(blockType) as any;
const schema = chaiBlock?.props?.schema ?? {};
const props = get(schema, "properties", {});
// Route the block-schema lookup through the shared `getBlockSchema` helper
// (`~/registry`, here `props?.schema`) so it lives in one place. Guard with
// `?? {}` because `getBlockSchema` is not null-safe for an unregistered block
// type.
const props = get(getBlockSchema(chaiBlock ?? {}), "properties", {});
// return key value with value has runtime: true
return Object.fromEntries(Object.entries(props).filter(([, value]) => get(value, "runtime", false)));
});
Expand Down
30 changes: 5 additions & 25 deletions src/builder/core/components/chaibuilder-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,24 +13,18 @@ import { ChaiFeatureFlagsWidget } from "~/builder/core/flags/flags-widget";
import { setDebugLogs } from "~/builder/core/functions/logging";
import i18n from "~/builder/core/locales/load";
import { ScreenTooSmall } from "~/builder/core/screen-too-small";
import { useBlocksStore } from "~/builder/hooks/history/use-blocks-store-undoable-actions";
import { useBlockSelectionQuerySync } from "~/builder/hooks/use-block-selection-query-sync";
import { useBroadcastChannel, useUnmountBroadcastChannel } from "~/builder/hooks/use-broadcast-channel";
import { useBuilderReset } from "~/builder/hooks/use-builder-reset";
import { useCheckStructure } from "~/builder/hooks/use-check-structure";
import { useUnmountBroadcastChannel } from "~/builder/hooks/use-broadcast-channel";
import { useExpandTree } from "~/builder/hooks/use-expand-tree";
import { isPageLoadedAtom } from "~/builder/hooks/use-is-page-loaded";
import { useKeyEventWatcher } from "~/builder/hooks/use-key-event-watcher";
import { useWatchPartialBlocks } from "~/builder/hooks/use-partial-blocks-store";
import { builderSaveStateAtom } from "~/builder/hooks/use-save-page";
import { useWatchPageBlocks } from "~/builder/hooks/use-watch-page-blocks";
import { CHAI_SLOT_IDS, ChaiSlot } from "~/builder/register-apis";
import { syncBlocksWithDefaultProps } from "~/registry";
import { ChaiBuilderEditorProps } from "~/types";
import { ProRootLayout } from "./layout/pro-root-layout";

const ChaiWatchers = (props: ChaiBuilderEditorProps) => {
const [, setAllBlocks] = useBlocksStore();
const reset = useBuilderReset();
const [saveState] = useAtom(builderSaveStateAtom);
useAtom(selectedLibraryAtom);
useKeyEventWatcher();
Expand All @@ -39,9 +33,6 @@ const ChaiWatchers = (props: ChaiBuilderEditorProps) => {
useAutoSave();
useWatchPartialBlocks();
useUnmountBroadcastChannel();
const { postMessage } = useBroadcastChannel();
const [, setIsPageLoaded] = useAtom(isPageLoadedAtom);
const runValidation = useCheckStructure();

useEffect(() => {
builderStore.set(chaiBuilderPropsAtom, omit(props, ["blocks", "translations", "pageExternalData", "globalStyles"]));
Expand All @@ -55,20 +46,9 @@ const ChaiWatchers = (props: ChaiBuilderEditorProps) => {
builderStore.set(chaiDesignTokensAtom, props.designTokens || {});
}, [props.designTokens]);

useEffect(() => {
setIsPageLoaded(false);
// Added delay to allow the pageId to be set
setTimeout(() => {
const withDefaults = syncBlocksWithDefaultProps(props.blocks || []);
setAllBlocks(withDefaults);
if (withDefaults && withDefaults.length > 0) {
postMessage({ type: "blocks-updated", blocks: withDefaults });
}
reset();
setIsPageLoaded(true);
runValidation();
}, 400);
}, [props.blocks]);
// Registered after the props-atom effect above so the pageId is already in the
// store when the blocks apply (effects run in declaration order per commit).
useWatchPageBlocks(props.blocks);

useEffect(() => {
i18n.changeLanguage(props.locale || "en");
Expand Down
1 change: 1 addition & 0 deletions src/builder/core/components/settings/json-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const useJsonFormElements = () => {
slider: SliderField,
sources: SourcesField,
images: MultiImagesField,
galleryImages: MultiImagesField,
repeaterFilters: RepeaterFiltersField,
repeaterSort: RepeaterSortField,
hiddenField: HiddenField,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const StylingPropSelect = ({ value, options, onValueChange }: StylingProp
<ChevronDownIcon className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start">
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput placeholder={t("Search element...")} className="h-7 border-0 shadow-none" />
<CommandList>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { getBlocksFromHTML } from "~/utils/import-html/html-to-json";
// Mock the runtime module
vi.mock("~/registry", () => ({
syncBlocksWithDefaultProps: vi.fn((blocks) => blocks),
// getBlocksFromHTML now also calls restoreBlockPropTypes -> getRegisteredChaiBlock;
// returning undefined makes restore a no-op, leaving these assertions intact.
getRegisteredChaiBlock: vi.fn(() => undefined),
}));

describe("ImportHTML - syncBlocksWithDefaultProps integration", () => {
Expand Down
1 change: 1 addition & 0 deletions src/builder/core/modals/domToJsx.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ function convertAttributeName(attrName: string): string {
const specialCases: Record<string, string> = {
class: "className",
for: "htmlFor",
fetchpriority: "fetchPriority",
tabindex: "tabIndex",
readonly: "readOnly",
maxlength: "maxLength",
Expand Down
47 changes: 47 additions & 0 deletions src/builder/hooks/__tests__/use-get-page-data.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* @vitest-environment happy-dom
*/
import { act, renderHook } from "@testing-library/react";
import { presentBlocksAtom } from "~/builder/atoms/blocks";
import { builderStore } from "~/builder/atoms/store";
import { useGetPageData } from "~/builder/hooks/use-get-page-data";
import { ChaiBlock } from "~/types/common";

describe("useGetPageData", () => {
beforeEach(() => {
builderStore.set(presentBlocksAtom, []);
});

it("reads the latest blocks at call time, not a render-time snapshot", () => {
const { result } = renderHook(() => useGetPageData());
const getPageData = result.current;

expect(getPageData().blocks).toEqual([]);

const blocks = [{ _id: "1", _type: "Heading", content: "Hello" }] as unknown as ChaiBlock[];
act(() => {
builderStore.set(presentBlocksAtom, blocks);
});

// Same callback instance (captured before the store update) must see the new blocks
expect(getPageData().blocks).toEqual(blocks);
});

it("does not subscribe to the blocks store (no re-render on block commits)", () => {
let renders = 0;
const { result } = renderHook(() => {
renders++;
return useGetPageData();
});
const rendersAfterMount = renders;
const callbackAfterMount = result.current;

act(() => {
builderStore.set(presentBlocksAtom, [{ _id: "1", _type: "Heading" }] as unknown as ChaiBlock[]);
builderStore.set(presentBlocksAtom, [{ _id: "1", _type: "Heading", content: "typed" }] as unknown as ChaiBlock[]);
});

expect(renders).toBe(rendersAfterMount);
expect(result.current).toBe(callbackAfterMount);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @vitest-environment happy-dom
*
* Integration guard for the 0ms boot hop: proves that applying page blocks flips
* `isPageLoaded` false->true across a real React commit, so `useBlockSelectionQuerySync`
* resets its one-shot restore guard and re-preselects `?bid=` on an SPA page switch
* (no remount). Uses real timers + the real jotai default store so the actual
* scheduling order (state-render before the setTimeout(0) apply) is exercised.
*/
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { presentBlocksAtom } from "~/builder/atoms/blocks";
import { builderStore } from "~/builder/atoms/store";
import { isPageLoadedAtom } from "~/builder/hooks/use-is-page-loaded";
import { selectedBlockIdsAtom } from "~/builder/hooks/use-selected-blockIds";
import { ChaiBlock } from "~/types/common";

// setAllBlocks writes the real presentBlocksAtom so selection-sync observes it.
vi.mock("~/builder/hooks/history/use-blocks-store-undoable-actions", async () => {
const { builderStore } = await import("~/builder/atoms/store");
const { presentBlocksAtom } = await import("~/builder/atoms/blocks");
return {
useBlocksStore: () => [
builderStore.get(presentBlocksAtom),
(b: ChaiBlock[]) => builderStore.set(presentBlocksAtom, b),
],
};
});
vi.mock("~/builder/hooks/use-broadcast-channel", () => ({
useBroadcastChannel: () => ({ postMessage: vi.fn() }),
}));
vi.mock("~/builder/hooks/use-builder-reset", () => ({
useBuilderReset: () => () => {},
}));
vi.mock("~/builder/hooks/use-check-structure", () => ({
useCheckStructure: () => () => {},
}));
vi.mock("~/registry", () => ({
syncBlocksWithDefaultProps: (blocks: ChaiBlock[]) => blocks,
}));

import { useBlockSelectionQuerySync } from "~/builder/hooks/use-block-selection-query-sync";
import { useWatchPageBlocks } from "~/builder/hooks/use-watch-page-blocks";

const setUrlBid = (bid: string) =>
window.history.replaceState({}, "", `/?bid=${bid}`);

const useBoot = ({ blocks }: { blocks: ChaiBlock[] }) => {
useWatchPageBlocks(blocks);
useBlockSelectionQuerySync();
};

const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 10)); });

describe("useWatchPageBlocks + useBlockSelectionQuerySync (boot hop)", () => {
beforeEach(() => {
builderStore.set(isPageLoadedAtom, false);
builderStore.set(presentBlocksAtom, []);
builderStore.set(selectedBlockIdsAtom, []);
});
afterEach(() => vi.clearAllMocks());

it("restores ?bid selection on initial boot", async () => {
setUrlBid("B1");
renderHook(useBoot, { initialProps: { blocks: [{ _id: "B1", _type: "Box" }] as ChaiBlock[] } });
await settle();
expect(builderStore.get(selectedBlockIdsAtom)).toEqual(["B1"]);
});

it("re-restores ?bid after an SPA page switch (guard reset on the false->true edge)", async () => {
setUrlBid("B1");
const { rerender } = renderHook(useBoot, {
initialProps: { blocks: [{ _id: "B1", _type: "Box" }] as ChaiBlock[] },
});
await settle();
expect(builderStore.get(selectedBlockIdsAtom)).toEqual(["B1"]);

// SPA switch: new page (new blocks identity), new bid in URL, no remount.
setUrlBid("B2");
rerender({ blocks: [{ _id: "B2", _type: "Box" }] as ChaiBlock[] });
await settle();
expect(builderStore.get(selectedBlockIdsAtom)).toEqual(["B2"]);
});
});
Loading
Loading