Skip to content

Commit d4b260c

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(studio): designer canvas no longer freezes on drawer/modal form blocks (#1840)
The page designer's live block preview (PageBlockCanvas → BlockLivePreview) renders each block with the real runtime renderer behind a `pointer-events-none` click-trap. For an `object-form` block whose `formType` is `drawer` (or `modal`), the rendered DrawerForm/ModalForm mounts a Radix Sheet/Dialog through a portal at <body> with `open` defaulting to true. The portalled overlay escapes the click-trap, and Radix's modal mode sets `pointer-events:none` on <body> plus a focus trap — so the entire designer becomes unresponsive ("the UI freezes") the moment you pick "Drawer" for a form block. Coerce overlay form types to an inline `simple` form before handing the schema to SchemaRenderer (new `toCanvasSchema`), mirroring the existing ViewPreview INLINE_FORM_TYPES guard. The canvas shows the form's field layout in place; the saved metadata keeps its real formType. Adds unit tests for the coercion (drawer/modal → simple, inline types untouched, top-level + properties shapes, no input mutation, non-form blocks unchanged). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d237e6f commit d4b260c

2 files changed

Lines changed: 82 additions & 4 deletions

File tree

packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.test.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { describe, it, expect, afterEach } from 'vitest';
44
import { render, screen, cleanup } from '@testing-library/react';
5-
import { PageBlockCanvas } from './PageBlockCanvas';
5+
import { PageBlockCanvas, toCanvasSchema } from './PageBlockCanvas';
66

77
afterEach(cleanup);
88

@@ -20,3 +20,52 @@ describe('PageBlockCanvas — empty state', () => {
2020
expect(screen.queryByText(/configured in Properties/i)).not.toBeInTheDocument();
2121
});
2222
});
23+
24+
25+
/**
26+
* Overlay form-type guard. A live `object-form` block with `formType: 'drawer'`
27+
* (or `'modal'`) would mount a portalled, focus-trapping modal over the design
28+
* canvas and lock the whole editor ("the UI freezes"). toCanvasSchema coerces
29+
* those overlay types to an inline `simple` form so the canvas shows the field
30+
* layout in place — mirroring ViewPreview. Regression guard for that freeze.
31+
*/
32+
describe('toCanvasSchema — overlay form coercion', () => {
33+
it('coerces a drawer object-form to an inline simple form (no modal on the canvas)', () => {
34+
const schema = toCanvasSchema({
35+
type: 'object-form',
36+
properties: { objectName: 'showcase_project', mode: 'create', formType: 'drawer' },
37+
}) as any;
38+
expect(schema.type).toBe('object-form');
39+
expect(schema.properties.formType).toBe('simple');
40+
expect(schema.properties.objectName).toBe('showcase_project'); // siblings preserved
41+
});
42+
43+
it('coerces a modal form type too', () => {
44+
const schema = toCanvasSchema({ type: 'object-form', properties: { formType: 'modal' } }) as any;
45+
expect(schema.properties.formType).toBe('simple');
46+
});
47+
48+
it('also neutralises a top-level (hoisted) overlay formType', () => {
49+
const schema = toCanvasSchema({ type: 'object-form', formType: 'drawer' } as any) as any;
50+
expect(schema.formType).toBe('simple');
51+
});
52+
53+
it('leaves inline form types untouched', () => {
54+
for (const ft of ['simple', 'tabbed', 'wizard', 'split']) {
55+
const schema = toCanvasSchema({ type: 'object-form', properties: { formType: ft } }) as any;
56+
expect(schema.properties.formType).toBe(ft);
57+
}
58+
});
59+
60+
it('does not mutate the input block', () => {
61+
const block: any = { type: 'object-form', properties: { formType: 'drawer' } };
62+
toCanvasSchema(block);
63+
expect(block.properties.formType).toBe('drawer');
64+
});
65+
66+
it('leaves non-form blocks unchanged', () => {
67+
const schema = toCanvasSchema({ type: 'element:text', properties: { content: 'hi' } }) as any;
68+
expect(schema.type).toBe('element:text');
69+
expect(schema.properties.content).toBe('hi');
70+
});
71+
});

packages/app-shell/src/views/metadata-admin/previews/PageBlockCanvas.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,45 @@ import { parsePath, hopsToPath, getByPath, setByPath } from '../inspectors/PageB
3838
import { SchemaRenderer } from '@object-ui/react';
3939
import { PreviewErrorBoundary } from './PreviewShell';
4040

41+
/** Overlay form types (`drawer` / `modal`) render their body through a portal
42+
* as a modal Sheet/Dialog. On the canvas that portal escapes the
43+
* `pointer-events-none` click-trap below and — because Radix sets
44+
* `pointer-events:none` on <body> and traps focus while open — locks the whole
45+
* designer, which reads as "frozen". We coerce them to an inline `simple` form
46+
* so the field layout shows in place, mirroring ViewPreview. The saved metadata
47+
* keeps its real formType. */
48+
const OVERLAY_FORM_TYPES = new Set(['drawer', 'modal']);
49+
50+
/** Build the schema handed to SchemaRenderer, neutralising overlay form types so
51+
* a live form block never mounts a modal over the design canvas. SchemaRenderer
52+
* needs a `type` discriminator; the block always carries one. */
53+
export function toCanvasSchema(block: Block): Record<string, unknown> {
54+
const schema: Record<string, unknown> = {
55+
...(block as Record<string, unknown>),
56+
type: String(block?.type ?? ''),
57+
};
58+
// formType lives under `properties` (canonical) but may also be hoisted to the
59+
// top level; neutralise whichever carries an overlay type.
60+
const props = schema.properties && typeof schema.properties === 'object'
61+
? (schema.properties as Record<string, unknown>)
62+
: undefined;
63+
const formType = (props?.formType ?? schema.formType) as unknown;
64+
if (typeof formType === 'string' && OVERLAY_FORM_TYPES.has(formType)) {
65+
if (props) schema.properties = { ...props, formType: 'simple' };
66+
if ('formType' in schema) schema.formType = 'simple';
67+
}
68+
return schema;
69+
}
70+
4171
/** Render a single block via the real runtime renderer, behind a click-trap,
42-
* so the design canvas mirrors the live preview. SchemaRenderer needs a
43-
* `type` discriminator; the block always carries one. Capped height keeps a
72+
* so the design canvas mirrors the live preview. Capped height keeps a
4473
* tall widget (e.g. a data table) from dominating the canvas. */
4574
function BlockLivePreview({ block, maxHeightClass = 'max-h-72' }: { block: Block; maxHeightClass?: string }) {
4675
const typeStr = String(block?.type ?? '');
4776
return (
4877
<div className={cn('pointer-events-none select-none overflow-hidden p-3', maxHeightClass)}>
4978
<PreviewErrorBoundary fallbackHint={`"${typeStr}" can't render with its current configuration — check its Properties.`}>
50-
<SchemaRenderer schema={{ ...(block as Record<string, unknown>), type: typeStr } as never} />
79+
<SchemaRenderer schema={toCanvasSchema(block) as never} />
5180
</PreviewErrorBoundary>
5281
</div>
5382
);

0 commit comments

Comments
 (0)