mono/packages/ui/docs/widgets-api.md
2026-03-21 20:18:25 +01:00

1884 lines
66 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Widget & Container API — Complete Interface Proposal
> This document proposes a unified, type-safe API for the widget/container system.
> It consolidates current patterns from the codebase and introduces missing lifecycle hooks,
> nested layout management, and a proper context contract.
---
## 1. Widget Definition (Registry)
**Current**: [src/lib/widgetRegistry.ts](../src/lib/widgetRegistry.ts) · [src/lib/registerWidgets.ts](../src/lib/registerWidgets.ts)
```ts
interface WidgetDefinition<P extends BaseWidgetProps = BaseWidgetProps> {
/** React component — receives merged props + context */
component: React.ComponentType<P>;
/** Edit-mode-only component (optional, lazy-loaded) */
editComponent?: React.LazyExoticComponent<React.ComponentType<P>>;
/** Static metadata for palette, search, AI layout generation */
metadata: WidgetMetadata<P>;
/** Preview thumbnail component for drag preview */
previewComponent?: React.ComponentType<P>;
// ─── Nested Layout Support ───
/** Declare sub-layouts this widget manages (tabs, accordion panels, etc.) */
getNestedLayouts?: (props: P) => NestedLayoutRef[];
/** How nested layout data is embedded — determines save strategy */
nestedLayoutStrategy?: 'embedded' | 'linked';
// ─── Lifecycle Hooks ───
/** Called once when widget is first added to a container */
onInit?: (context: WidgetLifecycleContext) => Partial<P> | void;
/** Called before the parent layout saves — return final props */
onBeforeSave?: (props: P, context: WidgetLifecycleContext) => P;
/** Called when widget is removed from a container */
onDestroy?: (props: P, context: WidgetLifecycleContext) => void;
/** Validate props — return error messages or null */
validate?: (props: P) => Record<string, string> | null;
// ─── Translation ───
/** List prop keys that contain translatable content */
translatableProps?: (keyof P)[];
/** Custom extraction for complex nested translatable content */
extractTranslatableContent?: (props: P) => TranslatableEntry[];
// ─── Export ───
/** Render to static HTML for email/PDF export */
renderStatic?: (props: P, context: ExportContext) => string;
}
```
### Widget Metadata
```ts
interface WidgetMetadata<P = Record<string, any>> {
id: string; // Unique registry key, e.g. 'tabs-widget'
name: string; // Display name (translatable)
category: WidgetCategory; // Palette grouping
description: string; // Tooltip/search description
icon?: React.ComponentType; // Lucide icon component
thumbnail?: string; // Image URL for visual palette
defaultProps?: Partial<P>; // Initial values on widget creation
configSchema?: ConfigSchema<P>; // Property editor definition
minSize?: { width: number; height: number };
resizable?: boolean;
tags?: string[]; // Search/AI discovery tags
// ─── New ───
/** Widget capabilities for container-level feature gating */
capabilities?: WidgetCapability[];
/** Maximum instances per page (e.g. 1 for 'home' widget) */
maxInstances?: number;
/** Restrict to specific container types */
allowedContainers?: ContainerType[];
}
type WidgetCategory = 'control' | 'display' | 'layout' | 'chart' | 'system' | 'custom';
type WidgetCapability = 'nested-layout' | 'translatable' | 'data-bound' | 'interactive' | 'exportable';
```
---
## 2. Widget Props Contract
**Current**: Props are spread as `{...(widget.props || {})}` in [src/modules/layout/LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) (line 776)
```ts
/** Every widget component receives these props — non-negotiable */
interface BaseWidgetProps {
// ─── Identity ───
widgetInstanceId: string; // Unique instance ID within layout
widgetDefId: string; // Registry ID (e.g. 'tabs-widget')
// ─── Mode ───
isEditMode: boolean; // View vs Edit mode
enabled?: boolean; // Soft-disable (grayed out in edit, hidden in view)
// ─── Mutations ───
onPropsChange: (partial: Record<string, any>) => Promise<void>;
// ─── Selection (edit mode) ───
selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string, pageId?: string) => void;
editingWidgetId?: string | null;
onEditWidget?: (widgetId: string | null) => void;
selectedContainerId?: string | null;
onSelectContainer?: (containerId: string, pageId?: string) => void;
// ─── Data ───
contextVariables?: Record<string, any>; // Page-level template variables
pageContext?: Record<string, any>; // Page metadata (slug, locale, etc.)
// ─── Styling ───
customClassName?: string; // User-defined Tailwind classes
}
```
### Specialized Prop Interfaces (examples)
```ts
interface TabsWidgetProps extends BaseWidgetProps {
tabs: TabDef[];
activeTabId?: string;
orientation?: 'horizontal' | 'vertical';
tabBarPosition?: 'top' | 'bottom' | 'left' | 'right';
tabBarClassName?: string;
contentClassName?: string;
}
interface TabDef {
id: string;
label: string;
icon?: string;
layoutId: string;
layoutData?: PageLayout; // Embedded layout (source of truth for save)
}
```
---
## 3. Config Schema (Property Editor)
**Current**: `configSchema` in each widget registration in [src/lib/registerWidgets.ts](../src/lib/registerWidgets.ts)
```ts
type ConfigSchema<P> = {
[K in keyof Partial<P>]: ConfigField;
} & {
/** Virtual fields that map to multiple props (e.g. mountAndPath → mount + path) */
[virtualKey: string]: ConfigField;
};
interface ConfigField {
type: ConfigFieldType;
label: string;
description?: string;
default?: any;
// ─── Conditional visibility ───
/** Only show this field when condition is met */
showWhen?: { field: string; equals: any } | ((props: any) => boolean);
/** Disable editing when condition is met */
disableWhen?: { field: string; equals: any };
// ─── Type-specific options ───
options?: { value: string; label: string }[]; // for 'select'
min?: number; max?: number; step?: number; // for 'number'/'range'
multiSelect?: boolean; // for 'imagePicker'
mountKey?: string; pathKey?: string; // for 'vfsPicker'
// ─── Validation ───
required?: boolean;
pattern?: RegExp;
customValidate?: (value: any, allProps: any) => string | null;
// ─── Groups ───
group?: string; // Visual grouping in property panel
order?: number; // Sort order within group
}
type ConfigFieldType =
| 'text' | 'number' | 'boolean' | 'select' | 'selectWithText'
| 'color' | 'range' | 'classname' | 'markdown'
| 'imagePicker' | 'pagePicker' | 'vfsPicker'
| 'tabs-editor' | 'json'
| 'component'; // Custom React component as editor
```
---
## 4. Widget Instance (Stored in Layout)
**Current**: [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) (line 5)
```ts
interface WidgetInstance {
id: string; // Unique per layout
widgetId: string; // References WidgetDefinition.metadata.id
props?: Record<string, any>; // Serialized props (JSON-safe)
order?: number; // Sort order within container
// ─── Flex Container placement ───
rowId?: string;
column?: number;
// ─── Provenance ───
snippetId?: string; // Tracks which snippet spawned this widget
createdAt?: number; // For undo history / conflict resolution
}
```
---
## 5. Container Types
**Current**: [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) (lines 17-61)
```ts
type ContainerType = 'container' | 'flex-container';
/** Traditional grid container */
interface LayoutContainer {
id: string;
type: 'container';
columns: number;
gap: number;
widgets: WidgetInstance[];
children: LayoutContainer[]; // Recursive nesting
order?: number;
settings?: ContainerSettings;
}
/** Row-based flex container with adjustable columns */
interface FlexibleContainer {
id: string;
type: 'flex-container';
rows: RowDef[];
widgets: WidgetInstance[]; // Widgets reference rowId + column
gap: number;
order?: number;
settings?: ContainerSettings;
}
type AnyContainer = LayoutContainer | FlexibleContainer;
interface ContainerSettings {
collapsible?: boolean;
collapsed?: boolean;
title?: string;
showTitle?: boolean;
customClassName?: string;
enabled?: boolean;
// ─── New ───
/** Background color/gradient */
background?: string;
/** Padding override (Tailwind class) */
padding?: string;
/** Max width constraint */
maxWidth?: string;
/** Visibility conditions */
visibleWhen?: VisibilityCondition;
}
interface VisibilityCondition {
/** 'always' | 'authenticated' | 'role:admin' | custom expression */
rule: string;
/** Invert the condition */
negate?: boolean;
}
```
### Row Definition (FlexContainer)
```ts
interface RowDef {
id: string;
columns: ColumnDef[];
gap?: number;
sizing?: 'constrained' | 'unconstrained';
cellAlignments?: ('stretch' | 'start' | 'center' | 'end')[];
padding?: string;
}
interface ColumnDef {
width: number;
unit: 'fr' | 'px' | 'rem' | '%';
minWidth?: number;
}
```
---
## 6. Page Layout (Top-Level)
**Current**: [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) (line 63)
```ts
interface PageLayout {
id: string;
name: string;
containers: AnyContainer[];
createdAt: number;
updatedAt: number;
loadedBundles?: string[];
rootTemplate?: string;
// ─── New ───
/** Schema version for migration */
version: string;
/** Layout-level metadata */
meta?: {
description?: string;
thumbnail?: string;
/** Lock layout from editing */
locked?: boolean;
};
}
interface RootLayoutData {
pages: Record<string, PageLayout>;
version: string;
lastUpdated: number;
}
```
---
## 7. Nested Layout Management
**Current**: `getNestedLayouts` + SYNC-BACK pattern in [src/components/widgets/TabsWidget.tsx](../src/components/widgets/TabsWidget.tsx)
### Problem Space
Widgets like Tabs, Accordions, and LayoutContainerWidget manage **sub-layouts** — independent `PageLayout` trees embedded as props. The current system uses a SYNC-BACK effect to bridge `loadedPages` (live editing state) back to widget props (persistence), which causes [race conditions](./nested-ex.md).
### Proposed: NestedLayoutManager
```ts
interface NestedLayoutRef {
id: string; // Semantic ID (e.g. 'tab-1')
label: string; // Display label
layoutId: string; // Key in loadedPages
}
/** Provided by LayoutContext to widgets that declare nested layouts */
interface NestedLayoutManager {
/** Register a sub-layout — hydrates from embedded data, returns cleanup */
register(layoutId: string, initialData?: PageLayout): () => void;
/** Check if a layout has been authoritatively hydrated */
isHydrated(layoutId: string): boolean;
/** Get current layout data for serialization (save path) */
getLayoutData(layoutId: string): PageLayout | undefined;
/** Subscribe to layout changes (replaces SYNC-BACK polling) */
onLayoutChange(layoutId: string, callback: (layout: PageLayout) => void): () => void;
}
```
### Usage in TabsWidget (proposed)
```tsx
const TabsWidget: React.FC<TabsWidgetProps> = ({ tabs, isEditMode, onPropsChange }) => {
const { nestedLayouts } = useLayout();
// Register all tab layouts on mount
useEffect(() => {
if (!isEditMode) return;
const cleanups = tabs.map(t =>
nestedLayouts.register(t.layoutId, t.layoutData)
);
return () => cleanups.forEach(fn => fn());
}, [tabs.length]); // Only re-register when tabs are added/removed
// Subscribe to changes — replaces SYNC-BACK
useEffect(() => {
if (!isEditMode) return;
const unsubs = tabs.map(t =>
nestedLayouts.onLayoutChange(t.layoutId, (layout) => {
onPropsChange({
tabs: tabs.map(tab =>
tab.layoutId === t.layoutId ? { ...tab, layoutData: layout } : tab
)
});
})
);
return () => unsubs.forEach(fn => fn());
}, [tabs]);
return (
<GenericCanvas
pageId={currentTab.layoutId}
initialLayout={currentTab.layoutData}
isEditMode={isEditMode}
/>
);
};
```
**Key difference**: No polling. No timestamp comparison. The `onLayoutChange` callback fires only on **real mutations** (widget add/remove/move/prop-change), not on hydration.
---
## 8. Widget Lifecycle Context
```ts
interface WidgetLifecycleContext {
/** Current page ID */
pageId: string;
/** Current locale */
locale: string;
/** Access to the layout manager */
layout: {
addWidget: (containerId: string, widgetId: string, props?: any) => Promise<void>;
removeWidget: (containerId: string, widgetInstanceId: string) => Promise<void>;
getContainer: (containerId: string) => AnyContainer | undefined;
};
/** Access to nested layout manager (if widget has nested layouts) */
nestedLayouts?: NestedLayoutManager;
/** User info */
user?: { id: string; role: string };
}
```
---
## 9. Container Rendering Contract
**Current**: [src/modules/layout/LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) · [src/modules/layout/LayoutContainerView.tsx](../src/modules/layout/LayoutContainerView.tsx)
```ts
/** Props passed to container renderers */
interface ContainerRendererProps {
container: AnyContainer;
pageId: string;
isEditMode: boolean;
depth: number; // Nesting depth (for styling/limits)
// ─── Edit mode only ───
onAddWidget?: (widgetId: string, snippetId?: string) => void;
onRemoveWidget?: (widgetInstanceId: string) => void;
onMoveWidget?: (widgetInstanceId: string, direction: Direction) => void;
onReorderWidget?: (widgetInstanceId: string, newIndex: number) => void;
// ─── Selection ───
selectedWidgetId?: string | null;
onSelectWidget?: (widgetId: string) => void;
editingWidgetId?: string | null;
onEditWidget?: (widgetId: string | null) => void;
selectedContainerId?: string | null;
onSelectContainer?: (containerId: string) => void;
// ─── Context ───
contextVariables?: Record<string, any>;
pageContext?: Record<string, any>;
}
type Direction = 'up' | 'down' | 'left' | 'right';
```
---
## 10. Layout Context API
**Current**: [src/modules/layout/LayoutContext.tsx](../src/modules/layout/LayoutContext.tsx)
```ts
interface LayoutContextValue {
// ─── State ───
loadedPages: Map<string, PageLayout>;
isLoading: boolean;
/** Tracks which layouts have been authoritatively hydrated */
hydratedIds: ReadonlySet<string>;
// ─── Page Operations ───
loadPageLayout: (pageId: string, pageName?: string) => Promise<void>;
hydratePageLayout: (pageId: string, layout: PageLayout) => void;
savePageLayout: (pageId: string) => Promise<void>;
// ─── Container Operations ───
addPageContainer: (pageId: string, type?: ContainerType) => Promise<void>;
removePageContainer: (pageId: string, containerId: string) => Promise<void>;
updateContainerSettings: (pageId: string, containerId: string, settings: Partial<ContainerSettings>) => Promise<void>;
// ─── Widget Operations ───
addWidget: (pageId: string, containerId: string, widgetId: string, props?: any, snippetId?: string) => Promise<string>;
removeWidget: (pageId: string, containerId: string, widgetInstanceId: string) => Promise<void>;
moveWidget: (pageId: string, widgetInstanceId: string, direction: Direction) => Promise<void>;
updateWidgetProps: (pageId: string, widgetInstanceId: string, props: Record<string, any>) => Promise<void>;
renameWidget: (pageId: string, widgetInstanceId: string, name: string) => Promise<void>;
// ─── Nested Layout Management ───
nestedLayouts: NestedLayoutManager;
}
```
---
## 11. Export Context
```ts
interface ExportContext {
format: 'html' | 'email' | 'pdf' | 'amp';
locale: string;
baseUrl: string;
/** Resolve asset URLs (images, etc.) */
resolveAsset: (path: string) => string;
/** Current page context */
page: { slug: string; title: string; locale: string };
}
```
---
## File Map
| Interface | Current Location | Status |
|-----------|-----------------|--------|
| `WidgetDefinition` | [src/lib/widgetRegistry.ts](../src/lib/widgetRegistry.ts) | Extend |
| `WidgetMetadata` | [src/lib/widgetRegistry.ts](../src/lib/widgetRegistry.ts) | Extend |
| `WidgetInstance` | [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) | Minor additions |
| `LayoutContainer` | [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) | Extend settings |
| `FlexibleContainer` | [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) | Stable |
| `PageLayout` | [src/modules/layout/LayoutManager.ts](../src/modules/layout/LayoutManager.ts) | Add version/meta |
| `ConfigSchema` | inline in [src/lib/registerWidgets.ts](../src/lib/registerWidgets.ts) | Extract to types |
| `NestedLayoutManager` | — | **New** |
| `WidgetLifecycleContext` | — | **New** |
| `BaseWidgetProps` | implicit in [src/modules/layout/LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) | **Extract** |
| Widget registrations | [src/lib/registerWidgets.ts](../src/lib/registerWidgets.ts) | Adopt new types |
| Container rendering | [src/modules/layout/LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) | Formalize contract |
| Layout context | [src/modules/layout/LayoutContext.tsx](../src/modules/layout/LayoutContext.tsx) | Add `nestedLayouts` |
---
## 12. State Management — Escaping Callback Hell
### The Problem
The current system passes **13+ props** through every widget instance ([LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) lines 775-788):
```tsx
<WidgetComponent
{...(widget.props || {})}
widgetInstanceId={widget.id} // identity
widgetDefId={widget.widgetId} // identity
isEditMode={isEditMode} // mode
onPropsChange={handlePropsChange} // callback
selectedWidgetId={selectedWidgetId} // state (global)
onSelectWidget={onSelectWidget} // callback
editingWidgetId={editingWidgetId} // state (global)
onEditWidget={onEditWidget} // callback
contextVariables={contextVariables} // data
pageContext={pageContext} // data
selectedContainerId={...} // state (global)
onSelectContainer={...} // callback
/>
```
Every nesting level (Container → Widget → TabsWidget → GenericCanvas → Container → Widget) re-spreads all callbacks. This causes:
- Re-render cascades (any selection change re-renders every widget)
- Stale closures in deeply nested effects
- Race conditions (the SYNC-BACK bug)
- Unmaintainable component signatures
### Option A: Preact Signals
Signals are **reactive primitives** that bypass React's render cycle. A signal write only re-renders components that directly read it.
```ts
// layoutSignals.ts
import { signal, computed, effect } from '@preact/signals-react';
export const selectedWidgetId = signal<string | null>(null);
export const editingWidgetId = signal<string | null>(null);
export const loadedPages = signal(new Map<string, PageLayout>());
export const hydratedIds = signal(new Set<string>());
// Derived — no useEffect needed
export const currentLayout = computed(() =>
loadedPages.value.get(currentPageId.value)
);
// Replace SYNC-BACK with a simple effect
effect(() => {
const pages = loadedPages.value;
// Fires only when loadedPages actually changes — no polling
});
```
Widget component — zero callback props:
```tsx
const TabsWidget = ({ tabs, widgetInstanceId }) => {
const isSelected = selectedWidgetId.value === widgetInstanceId;
const handleClick = () => { selectedWidgetId.value = widgetInstanceId; };
// No onSelectWidget, onPropsChange, etc.
};
```
| Pros | Cons |
|------|------|
| Zero callbacks, zero prop drilling | New paradigm — team adoption overhead |
| Surgical re-renders (only readers update) | Mutable state — harder to debug than React DevTools |
| `effect()` replaces SYNC-BACK entirely | Suspense/concurrent mode not fully compatible |
| Tiny bundle (~1KB) | Signal chains harder to trace than component tree |
**Reference projects using Signals:**
- [Preact Signals for React](https://github.com/preactjs/signals) — Official adapter
- [Signia](https://github.com/tldraw/signia) — tldraw's signal library (powers their canvas)
- [Solid.js](https://www.solidjs.com/) — Framework built entirely on signals (inspiration, not direct use)
---
### Option B: Zustand Store ⭐ recommended
A thin external store replaces all callback+state props. Widgets subscribe to **slices** — only re-render when their specific data changes.
```ts
// useLayoutStore.ts
import { create } from 'zustand';
import { subscribeWithSelector } from 'zustand/middleware';
interface LayoutStore {
// ─── State ───
loadedPages: Map<string, PageLayout>;
hydratedIds: Set<string>;
selectedWidgetId: string | null;
editingWidgetId: string | null;
selectedContainerId: string | null;
isEditMode: boolean;
// ─── Actions (replace callbacks) ───
selectWidget: (id: string | null) => void;
editWidget: (id: string | null) => void;
selectContainer: (id: string | null) => void;
updateWidgetProps: (pageId: string, widgetId: string, props: Record<string, any>) => Promise<void>;
hydrateLayout: (pageId: string, layout: PageLayout) => void;
saveLayout: (pageId: string) => Promise<void>;
}
export const useLayoutStore = create<LayoutStore>()(
subscribeWithSelector((set, get) => ({
loadedPages: new Map(),
hydratedIds: new Set(),
selectedWidgetId: null,
editingWidgetId: null,
selectedContainerId: null,
isEditMode: false,
selectWidget: (id) => set({ selectedWidgetId: id }),
editWidget: (id) => set({ editingWidgetId: id }),
selectContainer: (id) => set({ selectedContainerId: id }),
hydrateLayout: (pageId, layout) => {
const pages = new Map(get().loadedPages);
pages.set(pageId, layout);
const hydrated = new Set(get().hydratedIds);
hydrated.add(pageId);
set({ loadedPages: pages, hydratedIds: hydrated });
},
updateWidgetProps: async (pageId, widgetId, props) => {
const pages = new Map(get().loadedPages);
const layout = pages.get(pageId);
if (!layout) return;
// ... mutate widget props in layout ...
layout.updatedAt = Date.now();
pages.set(pageId, { ...layout });
set({ loadedPages: pages });
},
saveLayout: async (pageId) => { /* persist to backend */ },
}))
);
```
Widget component — **4 props instead of 18**:
```tsx
const TabsWidget: React.FC<{ tabs: TabDef[]; widgetInstanceId: string }> = ({ tabs, widgetInstanceId }) => {
// Selector-based — only re-renders when THIS widget's selection state changes
const isSelected = useLayoutStore(s => s.selectedWidgetId === widgetInstanceId);
const isEditMode = useLayoutStore(s => s.isEditMode);
const selectWidget = useLayoutStore(s => s.selectWidget);
const updateProps = useLayoutStore(s => s.updateWidgetProps);
// Replace SYNC-BACK with subscribe (outside React render cycle)
useEffect(() => {
if (!isEditMode) return;
return useLayoutStore.subscribe(
s => s.loadedPages,
(pages) => {
// Only fires when loadedPages reference changes
tabs.forEach(t => {
const layout = pages.get(t.layoutId);
if (layout && isRealMutation(layout, t.layoutData)) {
updateProps(parentPageId, widgetInstanceId, {
tabs: tabs.map(tab =>
tab.layoutId === t.layoutId ? { ...tab, layoutData: layout } : tab
)
});
}
});
}
);
}, [tabs, isEditMode]);
};
```
Container passes only identity props:
```tsx
// Before: 18 props
<WidgetComponent {...widget.props} widgetInstanceId={...} widgetDefId={...}
isEditMode={...} onPropsChange={...} selectedWidgetId={...}
onSelectWidget={...} editingWidgetId={...} onEditWidget={...}
contextVariables={...} pageContext={...} selectedContainerId={...}
onSelectContainer={...} />
// After: 4 props — everything else comes from the store
<WidgetComponent {...widget.props} widgetInstanceId={widget.id}
widgetDefId={widget.widgetId} isEditMode={isEditMode} />
```
| Pros | Cons |
|------|------|
| Familiar React patterns, tiny API surface | Still React re-renders (selector-gated) |
| `subscribeWithSelector` replaces SYNC-BACK | Map/Set equality needs careful handling |
| Works with Suspense + concurrent mode | Zustand middleware stack can grow complex |
| Incremental migration — replace consumers one at a time | Extra dependency (~1.5KB) |
| Time-travel debugging with devtools middleware | — |
**Reference projects using Zustand:**
- [Zustand](https://github.com/pmndrs/zustand) — Docs, recipes, middleware patterns
- [tldraw](https://github.com/tldraw/tldraw) — Canvas editor with Zustand + Signia for state
- [Excalidraw](https://github.com/excalidraw/excalidraw) — Whiteboard with similar widget/element model
- [React Flow](https://github.com/xyflow/xyflow) — Node graph editor, Zustand-based state for nodes/edges
- [Plate.js](https://github.com/udecode/plate) — Plugin-based editor with Zustand stores per plugin
- [BuilderIO](https://github.com/BuilderIO/builder) — Visual page builder with widget registry patterns
---
### Option C: Context + useReducer (zero dependencies)
Keep React context but replace callbacks with actions:
```ts
type LayoutAction =
| { type: 'SELECT_WIDGET'; widgetId: string | null; pageId?: string }
| { type: 'EDIT_WIDGET'; widgetId: string | null }
| { type: 'SELECT_CONTAINER'; containerId: string | null }
| { type: 'UPDATE_PROPS'; pageId: string; widgetId: string; props: Record<string, any> }
| { type: 'HYDRATE_LAYOUT'; pageId: string; layout: PageLayout };
// Split contexts to avoid re-render cascade
const LayoutStateContext = React.createContext<LayoutState>(null!);
const LayoutDispatchContext = React.createContext<React.Dispatch<LayoutAction>>(null!);
// Widget uses dispatch — stable reference, never changes
const dispatch = useLayoutDispatch();
dispatch({ type: 'SELECT_WIDGET', widgetId: id });
```
| Pros | Cons |
|------|------|
| Zero dependencies | Context still re-renders all consumers on state change |
| Standard React pattern | Must split into ~4 contexts to prevent cascade |
| Easy to type | No selector-based subscriptions (need `use-context-selector` polyfill) |
| — | More boilerplate than Zustand |
**Reference projects using Context+Reducer:**
- [use-context-selector](https://github.com/dai-shi/use-context-selector) — Selector API for React Context (by Zustand's author)
- [Lexical](https://github.com/facebook/lexical) — Facebook's editor, plugin-based with context commands
- [Gutenberg](https://github.com/WordPress/gutenberg) — WordPress block editor, registry + Redux stores
---
### Comparison
| | Signals | Zustand ⭐ | Context+Reducer |
|---|---------|---------|-----------------|
| **Migration effort** | High (paradigm shift) | Medium (incremental) | Low (refactor) |
| **Re-renders** | Only signal readers | Only selector matches | All context consumers |
| **SYNC-BACK fix** | `effect()` — built in | `subscribe()` — external | Still needs guard |
| **Suspense compat** | ⚠️ Partial | ✅ Full | ✅ Full |
| **Debugging** | Signal graph | Redux DevTools | React DevTools |
| **Bundle** | ~1KB | ~1.5KB | 0KB |
| **Callback props removed** | All | All | All (via dispatch) |
| **Deep nesting** | ✅ Flat access | ✅ Flat access | ✅ Flat access |
---
## 13. Plugin System — Modify, Inject, Extend
Plugins allow internal teams and third-party packages to modify existing widgets, inject new ones, extend container behavior, and hook into the layout lifecycle — without touching core code.
### Plugin Definition
```ts
interface WidgetPlugin {
/** Unique plugin identifier (e.g. 'polymech:analytics', 'acme:custom-charts') */
id: string;
/** Human-readable name */
name: string;
/** Semver version */
version: string;
/** Plugin priority — higher priority plugins run first (default: 0) */
priority?: number;
/** Required capabilities — plugin won't load if missing */
requires?: string[];
// ─── Registration ───
/** Called once during app bootstrap — register widgets, hooks, slots */
setup: (api: PluginAPI) => void | Promise<void>;
/** Called on hot reload / plugin unload — clean up */
teardown?: () => void;
}
```
### Plugin API
The `PluginAPI` is the only interface plugins interact with. It gates all access.
```ts
interface PluginAPI {
// ─── Widget Registry ───
/** Register a new widget */
registerWidget: <P>(definition: WidgetDefinition<P>) => void;
/** Remove a widget from the registry */
unregisterWidget: (widgetId: string) => void;
/** Modify an existing widget's definition (non-destructive merge) */
modifyWidget: (widgetId: string, patch: Partial<WidgetDefinition>) => void;
/** Wrap an existing widget's component with a HOC */
wrapWidget: (widgetId: string, wrapper: WidgetWrapper) => void;
/** Extend a widget's configSchema with additional fields */
extendConfig: (widgetId: string, fields: Record<string, ConfigField>) => void;
// ─── Hooks Pipeline ───
/** Register a hook that runs at a specific lifecycle point */
addHook: <T extends HookName>(name: T, handler: HookHandler<T>) => void;
/** Remove a previously registered hook */
removeHook: <T extends HookName>(name: T, handler: HookHandler<T>) => void;
// ─── Slot Injection ───
/** Inject React content into named slots in the editor UI */
injectSlot: (slotId: SlotId, component: React.ComponentType<SlotProps>) => void;
// ─── Container Extensions ───
/** Register a custom container type */
registerContainerType: (type: string, renderer: ContainerRenderer) => void;
/** Add settings fields to existing container types */
extendContainerSettings: (type: ContainerType, fields: Record<string, ConfigField>) => void;
// ─── Store Access ───
/** Read-only access to layout store (Zustand) */
getStore: () => Readonly<LayoutStore>;
/** Subscribe to store changes */
subscribe: <T>(selector: (state: LayoutStore) => T, callback: (value: T) => void) => () => void;
}
```
---
### Hook Pipeline
Hooks are the primary extension mechanism. They form a **pipeline** — each hook handler can inspect, modify, or short-circuit data flowing through the system.
```ts
type HookName =
// Widget lifecycle
| 'widget:beforeRender' // Modify props before widget renders
| 'widget:afterRender' // Wrap rendered output (e.g. add analytics wrapper)
| 'widget:beforeSave' // Transform props before persisting
| 'widget:afterSave' // Side effects after save (analytics, sync, etc.)
| 'widget:beforeAdd' // Intercept widget addition (validation, defaults)
| 'widget:afterAdd' // Side effects after widget added
| 'widget:beforeRemove' // Confirmation, cleanup
| 'widget:afterRemove' // Side effects after removal
// Container lifecycle
| 'container:beforeRender' // Modify container before rendering children
| 'container:afterRender' // Wrap container output
| 'container:beforeSave' // Transform container data before persist
// Layout lifecycle
| 'layout:beforeHydrate' // Modify layout data during hydration
| 'layout:afterHydrate' // Side effects after hydration
| 'layout:beforeSave' // Final transform before full layout save
| 'layout:afterSave' // Side effects after save
// Editor
| 'editor:beforeDrop' // Validate/modify drag-drop operations
| 'editor:widgetPalette' // Filter/reorder palette widget list
| 'editor:contextMenu'; // Add items to right-click context menus
interface HookContext {
pluginId: string;
pageId: string;
isEditMode: boolean;
store: Readonly<LayoutStore>;
}
// Type-safe handler signatures per hook
type HookHandler<T extends HookName> =
T extends 'widget:beforeRender'
? (props: Record<string, any>, widget: WidgetInstance, ctx: HookContext) => Record<string, any>
: T extends 'widget:afterRender'
? (element: React.ReactElement, widget: WidgetInstance, ctx: HookContext) => React.ReactElement
: T extends 'widget:beforeAdd'
? (widgetId: string, props: Record<string, any>, ctx: HookContext) => Record<string, any> | false
: T extends 'editor:widgetPalette'
? (widgets: WidgetDefinition[], ctx: HookContext) => WidgetDefinition[]
: T extends 'editor:contextMenu'
? (items: ContextMenuItem[], widget: WidgetInstance, ctx: HookContext) => ContextMenuItem[]
: (data: any, ctx: HookContext) => any;
```
#### Hook Execution Order
Hooks run in **priority order** (higher first), then registration order within same priority:
```
Plugin A (priority: 10) → Plugin B (priority: 5) → Plugin C (priority: 0)
```
A hook handler can **short-circuit** by returning `false` (for `before*` hooks) or by not calling `next()` in an async pipeline.
---
### Widget Modification
Modify an existing widget without replacing it:
```ts
// Plugin: Add "analytics" toggle to every ImageWidget
const analyticsPlugin: WidgetPlugin = {
id: 'polymech:analytics',
name: 'Analytics Tracking',
version: '1.0.0',
setup(api) {
// 1. Add a config field to ImageWidget
api.extendConfig('image-widget', {
trackClicks: {
type: 'boolean',
label: 'Track Clicks',
default: false,
group: 'Analytics',
},
trackingLabel: {
type: 'text',
label: 'Event Label',
showWhen: { field: 'trackClicks', equals: true },
group: 'Analytics',
},
});
// 2. Wrap the widget to inject click tracking
api.wrapWidget('image-widget', (WrappedComponent) => {
return function TrackedImage(props) {
const handleClick = () => {
if (props.trackClicks) {
analytics.track('image_click', { label: props.trackingLabel });
}
};
return (
<div onClick={handleClick}>
<WrappedComponent {...props} />
</div>
);
};
});
}
};
```
---
### Widget Injection
Register entirely new widgets:
```ts
const chartPlugin: WidgetPlugin = {
id: 'acme:charts',
name: 'Advanced Charts',
version: '2.0.0',
setup(api) {
api.registerWidget({
component: React.lazy(() => import('./AcmeBarChart')),
metadata: {
id: 'acme-bar-chart',
name: 'Bar Chart (Acme)',
category: 'chart',
description: 'Interactive bar chart with drill-down',
tags: ['chart', 'analytics', 'acme'],
defaultProps: { dataSource: '', orientation: 'vertical' },
configSchema: {
dataSource: { type: 'text', label: 'Data Endpoint', required: true },
orientation: { type: 'select', label: 'Orientation', options: [
{ value: 'vertical', label: 'Vertical' },
{ value: 'horizontal', label: 'Horizontal' },
]},
},
},
});
}
};
```
---
### Widget Extension (Inheritance)
Extend an existing widget to create a variant:
```ts
setup(api) {
const baseImage = api.getStore()./* get widget def somehow */;
// Create a "Hero Image" that extends ImageWidget with extra defaults
api.registerWidget({
component: React.lazy(() => import('./HeroImage')),
metadata: {
id: 'hero-image',
name: 'Hero Image',
category: 'display',
description: 'Full-bleed hero image with overlay text',
tags: ['hero', 'image', 'banner'],
defaultProps: {
width: '100%',
height: '50vh',
objectFit: 'cover',
overlayText: '',
overlayPosition: 'center',
},
configSchema: {
// Inherit image fields + add overlay
...baseImageConfigSchema,
overlayText: { type: 'markdown', label: 'Overlay Text', group: 'Overlay' },
overlayPosition: { type: 'select', label: 'Position', group: 'Overlay', options: [
{ value: 'top-left', label: 'Top Left' },
{ value: 'center', label: 'Center' },
{ value: 'bottom-right', label: 'Bottom Right' },
]},
},
},
});
}
```
---
### Slot Injection
Plugins can inject UI into predefined **slots** in the editor chrome:
```ts
type SlotId =
| 'editor:toolbar:start' // Left side of top toolbar
| 'editor:toolbar:end' // Right side of top toolbar
| 'editor:sidebar:top' // Above widget palette
| 'editor:sidebar:bottom' // Below widget palette
| 'editor:panel:right:top' // Top of right properties panel
| 'editor:panel:right:bottom' // Bottom of right properties panel
| 'widget:toolbar' // Per-widget toolbar (green bar)
| 'container:toolbar' // Per-container toolbar
| 'container:empty' // Shown inside empty containers
| 'page:header' // Above page content
| 'page:footer'; // Below page content
interface SlotProps {
pageId: string;
isEditMode: boolean;
selectedWidgetId?: string | null;
selectedContainerId?: string | null;
}
```
Example: Inject a "Page Analytics" button into the toolbar:
```ts
setup(api) {
api.injectSlot('editor:toolbar:end', ({ pageId }) => (
<Button onClick={() => openAnalytics(pageId)} variant="ghost" size="sm">
<BarChart3 className="h-4 w-4 mr-1" /> Analytics
</Button>
));
}
```
---
### Container Plugins
Register custom container types:
```ts
setup(api) {
// Register a "carousel-container" that renders children in a swiper
api.registerContainerType('carousel-container', {
renderer: CarouselContainerRenderer,
editRenderer: CarouselContainerEditRenderer,
icon: GalleryHorizontal,
label: 'Carousel',
defaultSettings: {
autoplay: false,
interval: 5000,
showDots: true,
},
settingsSchema: {
autoplay: { type: 'boolean', label: 'Autoplay' },
interval: { type: 'number', label: 'Interval (ms)', showWhen: { field: 'autoplay', equals: true } },
showDots: { type: 'boolean', label: 'Show Dots' },
},
});
}
```
---
### Permission Gating
Plugins can restrict widget availability based on user roles, page context, or feature flags:
```ts
setup(api) {
// Filter palette based on user role
api.addHook('editor:widgetPalette', (widgets, ctx) => {
const user = ctx.store.user;
return widgets.filter(w => {
// Hide admin-only widgets from regular users
if (w.metadata.tags?.includes('admin-only') && user?.role !== 'admin') return false;
// Hide premium widgets from free-tier
if (w.metadata.tags?.includes('premium') && !user?.isPremium) return false;
return true;
});
});
// Prevent dropping restricted widgets
api.addHook('editor:beforeDrop', (dropData, ctx) => {
if (isRestricted(dropData.widgetId, ctx.store.user)) {
toast.error('Upgrade required');
return false; // Short-circuit — block the drop
}
return dropData;
});
}
```
---
### Plugin Lifecycle & Registration
```ts
// pluginManager.ts
class PluginManager {
private plugins = new Map<string, WidgetPlugin>();
private api: PluginAPI;
constructor(registry: WidgetRegistry, store: LayoutStore) {
this.api = createPluginAPI(registry, store);
}
async register(plugin: WidgetPlugin): Promise<void> {
// Validate requirements
if (plugin.requires?.some(dep => !this.plugins.has(dep))) {
throw new Error(`Plugin ${plugin.id} missing deps: ${plugin.requires}`);
}
// Run setup
await plugin.setup(this.api);
this.plugins.set(plugin.id, plugin);
}
async unregister(pluginId: string): Promise<void> {
const plugin = this.plugins.get(pluginId);
plugin?.teardown?.();
this.plugins.delete(pluginId);
}
getPlugins(): WidgetPlugin[] {
return [...this.plugins.values()]
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
}
}
// Bootstrap — app entry
const pluginManager = new PluginManager(widgetRegistry, layoutStore);
// Core widgets (always loaded)
await pluginManager.register(coreWidgetsPlugin);
// Optional plugins (lazy loaded, feature-flagged)
if (features.analytics) {
const { analyticsPlugin } = await import('./plugins/analytics');
await pluginManager.register(analyticsPlugin);
}
// User-installed plugins (dynamic import from config)
for (const pluginUrl of userPluginManifest) {
const mod = await import(/* @vite-ignore */ pluginUrl);
await pluginManager.register(mod.default);
}
```
---
### Plugin Isolation & Safety
| Concern | Strategy |
|---------|----------|
| **Performance** | Plugins that throw don't crash the editor — wrap `setup()` in try/catch |
| **Conflicts** | Two plugins modifying the same widget — resolved by priority order |
| **Memory** | `teardown()` must clean up subscriptions, timers, DOM listeners |
| **Security** | `PluginAPI` is a **capability-limited facade** — no direct store mutation |
| **Versioning** | `requires` field enables dependency resolution at registration time |
| **HMR** | `unregister` + `register` cycle on hot reload (Vite-compatible) |
---
### Current Registry → Plugin Bridge
The existing [widgetRegistry](../src/lib/widgetRegistry.ts) already supports `register()` with overwrite semantics (line 28-32). Wrapping the existing `registerWidgets()` call ([src/lib/registerWidgets.ts](../src/lib/registerWidgets.ts)) as a core plugin is a mechanical refactor:
```ts
// Before
import { registerAllWidgets } from '@/lib/registerWidgets';
registerAllWidgets();
// After
import { coreWidgetsPlugin } from '@/plugins/core-widgets';
await pluginManager.register(coreWidgetsPlugin);
```
---
## 14. Unified Node Model — Collapsing Widgets and Containers
### Why the distinction is problematic
The current system has **two separate type hierarchies**:
```
PageLayout
└── Container (grid | flex-container) ← structural
├── WidgetInstance (leaf) ← content
└── Container (nested, recursive) ← structural again
└── WidgetInstance ...
```
This works until a **widget needs to be a container** — TabsWidget, AccordionWidget, LayoutContainerWidget all manage sub-layouts internally. They're widgets pretending to be containers, which creates:
- The SYNC-BACK race condition ([nested-ex.md](./nested-ex.md))
- Separate code paths for adding widgets vs adding containers
- `addWidget()` + `addPageContainer()` + `removeWidget()` + `removePageContainer()` — doubled API surface
- Container rendering ([LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx)) vs widget rendering — two renderers for one recursive tree
- Embedded `layoutData` in widget props — a layout-within-a-layout, invisible to the parent tree
### Current data shape (two types)
```ts
// Container — knows about grid/flex, owns widgets
interface LayoutContainer {
id: string;
type: 'container' | 'flex-container';
columns: number;
gap: number;
widgets: WidgetInstance[]; // ← flat child array
children: LayoutContainer[]; // ← recursive containers only
settings?: ContainerSettings;
}
// Widget — leaf node, can't have children (officially)
interface WidgetInstance {
id: string;
widgetId: string; // registry key
props?: Record<string, any>; // may secretly contain layoutData!
}
```
### Proposed: Single Node type
Every element in the tree — containers, widgets, tab panes, rows — is just a `Node`:
```ts
interface Node {
id: string;
type: string; // 'flex' | 'grid' | 'image' | 'tabs' | 'tab-pane' | 'markdown'
props: Record<string, any>; // type-specific config
children: Node[]; // ← replaces BOTH Container.widgets AND Container.children
parentId: string | null;
// Layout hints (replaces Container-specific fields)
layout?: NodeLayout;
// Constraints (from registry, not stored — computed at runtime)
// constraints?: NodeConstraints;
}
interface NodeLayout {
display: 'flex' | 'grid' | 'block' | 'none';
columns?: number;
gap?: number;
rows?: RowDef[];
direction?: 'row' | 'column';
align?: 'stretch' | 'start' | 'center' | 'end';
}
interface NodeConstraints {
canHaveChildren: boolean;
allowedChildTypes?: string[];
maxChildren?: number;
draggable: boolean;
deletable: boolean;
}
```
### What collapses
| Before (two-type) | After (unified) |
|----|-----|
| `LayoutContainer` + `FlexibleContainer` + `WidgetInstance` | `Node` |
| `Container.widgets[]` | `Node.children[]` |
| `Container.children[]` (recursive containers) | `Node.children[]` (same array) |
| TabsWidget with embedded `layoutData` per tab | TabsNode with `children: [TabPaneNode, ...]` |
| SYNC-BACK effect | Gone — children are part of the tree |
| `addWidget()` + `addPageContainer()` | `addChild(parentId, node)` |
| Container renderer + Widget renderer | One recursive renderer |
| `NestedLayoutManager` (§7) | Not needed — nested layouts don't exist |
### TabsWidget: Before vs After
**Before** — widget with embedded layouts, SYNC-BACK, hydration races:
```ts
{
id: 'w1',
widgetId: 'tabs-widget',
props: {
tabs: [
{ id: 'tab-1', label: 'About', layoutId: 'tab-layout-1',
layoutData: { containers: [{ widgets: [...] }] } }, // layout-in-a-layout
{ id: 'tab-2', label: 'Contact', layoutId: 'tab-layout-2',
layoutData: { containers: [{ widgets: [...] }] } },
]
}
}
```
**After** — node with children, zero indirection:
```ts
{
id: 'w1',
type: 'tabs',
props: { activeTab: 'tab-1', orientation: 'horizontal' },
children: [
{
id: 'tp1', type: 'tab-pane',
props: { label: 'About' },
layout: { display: 'flex', direction: 'column', gap: 16 },
children: [
{ id: 'n1', type: 'markdown', props: { content: '...' }, children: [] },
{ id: 'n2', type: 'image', props: { src: '...' }, children: [] },
]
},
{
id: 'tp2', type: 'tab-pane',
props: { label: 'Contact' },
layout: { display: 'flex', direction: 'column', gap: 16 },
children: [
{ id: 'n3', type: 'contact-form', props: {}, children: [] },
]
},
]
}
```
No `layoutData`. No `loadedPages` for sub-layouts. No SYNC-BACK. The tree **is** the layout.
### FlexibleContainer + PhotoCards: Before vs After
A real-world page: a flex container with two rows — row 1 has a hero image spanning full width, row 2 has three PhotoCards in a 1fr 1fr 1fr grid.
**Before** — [FlexibleContainerRenderer.tsx](../src/modules/layout/FlexibleContainerRenderer.tsx) / [FlexContainerView.tsx](../src/modules/layout/FlexContainerView.tsx) with [PhotoCard.tsx](../src/components/PhotoCard.tsx):
```ts
// Page content stored in DB — container + widgetInstances, cells addressed by rowId:column
{
containers: [{
id: 'fc1',
type: 'flex-container',
gap: 16,
rows: [
{ id: 'r1', columns: [{ width: 1, unit: 'fr' }], sizing: 'unconstrained' },
{ id: 'r2', columns: [{ width: 1, unit: 'fr' }, { width: 1, unit: 'fr' }, { width: 1, unit: 'fr' }], gap: 12 },
],
widgets: [
// Hero image — row 1, column 0
{ id: 'w1', widgetId: 'photo-card', rowId: 'r1', column: 0, order: 0,
props: { pictureId: 'p100', image: '/pics/hero.jpg', title: 'Hero', variant: 'feed', imageFit: 'cover' } },
// Three gallery cards — row 2, columns 0/1/2
{ id: 'w2', widgetId: 'photo-card', rowId: 'r2', column: 0, order: 0,
props: { pictureId: 'p101', image: '/pics/a.jpg', title: 'Sunset', variant: 'grid' } },
{ id: 'w3', widgetId: 'photo-card', rowId: 'r2', column: 1, order: 0,
props: { pictureId: 'p102', image: '/pics/b.jpg', title: 'Mountains', variant: 'grid' } },
{ id: 'w4', widgetId: 'photo-card', rowId: 'r2', column: 2, order: 0,
props: { pictureId: 'p103', image: '/pics/c.jpg', title: 'Ocean', variant: 'grid' } },
],
settings: { title: 'Gallery Section', showTitle: true },
}]
}
```
The renderer ([FlexContainerView.tsx](../src/modules/layout/FlexContainerView.tsx) line 105124) groups widgets into a `Map<"rowId:colIdx", WidgetInstance[]>`, then renders per row with `columnsToGridTemplate()`.
**After** — Unified Node tree:
```ts
{
id: 'fc1',
type: 'flex',
props: { title: 'Gallery Section', showTitle: true },
layout: { display: 'flex', direction: 'column', gap: 16 },
children: [
// Row 1 — hero
{
id: 'r1', type: 'flex-row',
props: {},
layout: { display: 'grid', columns: 1, gap: 16 },
children: [
{ id: 'w1', type: 'photo-card', children: [],
props: { pictureId: 'p100', image: '/pics/hero.jpg', title: 'Hero',
variant: 'feed', imageFit: 'cover' } },
]
},
// Row 2 — three cards
{
id: 'r2', type: 'flex-row',
props: {},
layout: { display: 'grid', columns: 3, gap: 12 },
children: [
{ id: 'w2', type: 'photo-card', children: [],
props: { pictureId: 'p101', image: '/pics/a.jpg', title: 'Sunset', variant: 'grid' } },
{ id: 'w3', type: 'photo-card', children: [],
props: { pictureId: 'p102', image: '/pics/b.jpg', title: 'Mountains', variant: 'grid' } },
{ id: 'w4', type: 'photo-card', children: [],
props: { pictureId: 'p103', image: '/pics/c.jpg', title: 'Ocean', variant: 'grid' } },
]
},
]
}
```
Key differences:
| Aspect | Before | After |
|--------|--------|-------|
| Widget placement | `widget.rowId` + `widget.column` indices | Position = order in `parent.children[]` |
| Row definition | Separate `rows[]` array on container | `flex-row` nodes as children |
| Column spec | `row.columns[{width, unit}]` | `layout.columns` + `layout.gap` on the row node |
| Grouping logic | `widgetsByCell` Map computation (line 105124) | None — children are already grouped by parent |
| Adding a card | `addWidget(pageId, 'fc1', 'photo-card', { rowId: 'r2', column: 1 })` | `addChild('r2', photoCardNode, 1)` |
| Moving between rows | Update `widget.rowId` + `widget.column` + re-index | `moveNode('w2', 'r1', 0)` |
The `flex-row` type renders as a CSS grid row:
```tsx
// flex-row component — trivial
function FlexRowRenderer({ layout, children }: { layout: NodeLayout; children: React.ReactNode }) {
return (
<div
className="grid min-w-0 max-md:!grid-cols-1"
style={{
gridTemplateColumns: `repeat(${layout?.columns ?? 1}, 1fr)`,
gap: `${layout?.gap ?? 16}px`,
}}
>
{children}
</div>
);
}
```
The [PhotoCard](../src/components/PhotoCard.tsx) component itself doesn't change at all — it receives the same props either way. The only difference is **how it gets placed** in the tree.
### Registry: same interface, no type distinction
```ts
// "Container" types
widgetRegistry.register({
metadata: { id: 'flex', category: 'layout', name: 'Flex Container', ... },
component: FlexRenderer,
constraints: { canHaveChildren: true, draggable: true },
});
// "Widget" types — same registry, same interface
widgetRegistry.register({
metadata: { id: 'image', category: 'display', name: 'Image', ... },
component: ImageWidget,
constraints: { canHaveChildren: false, draggable: true },
});
// Nested container types
widgetRegistry.register({
metadata: { id: 'tabs', category: 'layout', name: 'Tabs', ... },
component: TabsWidget,
constraints: { canHaveChildren: true, allowedChildTypes: ['tab-pane'] },
});
widgetRegistry.register({
metadata: { id: 'tab-pane', category: 'layout', name: 'Tab Pane', ... },
component: TabPaneRenderer,
constraints: { canHaveChildren: true, draggable: false, deletable: false },
});
```
### Single recursive renderer
```tsx
function NodeRenderer({ node, depth = 0 }: { node: Node; depth?: number }) {
const def = widgetRegistry.get(node.type);
if (!def) return null;
const Component = def.component;
const canNest = def.constraints?.canHaveChildren;
return (
<Component {...node.props} nodeId={node.id} layout={node.layout}>
{canNest && node.children.map(child => (
<NodeRenderer key={child.id} node={child} depth={depth + 1} />
))}
</Component>
);
}
```
Replaces the current split between [LayoutContainerEdit.tsx](../src/modules/layout/LayoutContainerEdit.tsx) (~800 lines) and widget rendering.
### Store operations: unified
```ts
// Before: separate APIs
addWidget(pageId, containerId, widgetId, props);
addPageContainer(pageId, containerType);
removeWidget(pageId, containerId, widgetInstanceId);
removePageContainer(pageId, containerId);
moveWidget(pageId, widgetInstanceId, direction);
// After: one set of tree operations
addChild(parentId: string, node: Node, index?: number): void;
removeNode(nodeId: string): void;
moveNode(nodeId: string, newParentId: string, index?: number): void;
updateProps(nodeId: string, props: Partial<Record<string, any>>): void;
```
### PageLayout simplifies to
```ts
interface PageLayout {
id: string;
name: string;
root: Node; // ← single root node, replaces containers[]
version: string;
createdAt: number;
updatedAt: number;
}
```
### Migration path
| Step | Effort | What changes |
|------|--------|-------------|
| 1. Define `Node` type alongside existing types | Low | Types only, no runtime change |
| 2. Write `toNodeTree()` / `fromNodeTree()` converters | Medium | Convert at load/save boundary |
| 3. Build `NodeRenderer` (recursive) | Medium | Replaces container + widget rendering |
| 4. Migrate store operations to tree ops | Medium | `addChild` / `removeNode` / `moveNode` |
| 5. Migrate stored layouts (DB) | High | One-time script to flatten all pages |
| 6. Remove Container types, SYNC-BACK, NestedLayoutManager | Low | Delete code 🎉 |
### Who did this
| Project | Model | Notes |
|---------|-------|-------|
| [Gutenberg](https://github.com/WordPress/gutenberg) | Block → InnerBlocks | Flat blocks → recursive blocks. Same migration. |
| [Craft.js](https://github.com/prevwong/craft.js) | `Node` with `nodes` map | Flat node map + `linkedNodes` for nested canvases |
| [Puck](https://github.com/measuredco/puck) | `ComponentData` with `children` | Zones are named child slots in components |
| [tldraw](https://github.com/tldraw/tldraw) | `TLShape` with parent reference | Flat store, parent-child via `parentId` |
| [GrapesJS](https://github.com/GrapesJS/grapesjs) | `Component` with `components()` | Recursive component tree, no widget/container split |
| [Lexical](https://github.com/facebook/lexical) | `LexicalNode` with children | `ElementNode` can have children, `TextNode` can't |
---
## 15. Arbitrary React Components — The Four Levels
The unified Node model (§14) maps `node.type` → registry → React component.
If the registry can resolve to *any* component, the system becomes a generic React renderer.
### Level 1: Pre-registered (current)
Component is in the bundle, registered at boot:
```ts
widgetRegistry.register({
metadata: { id: 'photo-card', name: 'Photo Card', category: 'display', ... },
component: PhotoCard,
constraints: { canHaveChildren: false, draggable: true },
});
```
Type-safe, tree-shakeable, fast. Only components known at build time.
### Level 2: Lazy-imported (code-split)
Component is in the bundle but loaded on first use:
```ts
widgetRegistry.register({
metadata: { id: 'chart', name: 'Chart', category: 'data', ... },
component: React.lazy(() => import('@/components/Chart')),
constraints: { canHaveChildren: false },
});
```
Already used for [FlexContainerEdit](../src/modules/layout/FlexibleContainerRenderer.tsx) (line 8).
### Level 3: Plugin-registered (npm package, build-time)
A plugin brings its own components — installed via npm, part of the build:
```ts
// @polymech/maps-plugin
const mapsPlugin: WidgetPlugin = {
id: 'maps',
version: '1.0.0',
setup(api) {
api.registerWidget({
metadata: { id: 'google-map', name: 'Google Map', icon: MapPin, category: 'embed' },
component: React.lazy(() => import('./GoogleMapWidget')),
constraints: { canHaveChildren: false },
});
}
};
```
Safe — code-reviewed, bundled, no runtime loading.
### Level 4: Remote components (runtime, any URL)
Load a React component from a URL at runtime — not in the bundle, not known at build time.
Node shape in the tree:
```ts
{
id: 'rc1',
type: '__remote',
props: {
url: 'https://cdn.example.com/widgets/weather@1.0.0/index.mjs',
componentName: 'WeatherWidget',
// Pass-through props:
city: 'Berlin',
units: 'metric',
},
children: []
}
```
The `__remote` meta-widget loads and renders the ESM module:
```tsx
function RemoteComponent({ url, componentName, ...passThrough }: {
url: string;
componentName?: string;
[key: string]: any;
}) {
const [Component, setComponent] = useState<React.ComponentType | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let cancelled = false;
import(/* webpackIgnore: true */ url)
.then(mod => {
if (!cancelled) {
const Comp = componentName ? mod[componentName] : mod.default;
setComponent(() => Comp);
}
})
.catch(err => !cancelled && setError(err));
return () => { cancelled = true; };
}, [url, componentName]);
if (error) return <div className="text-destructive p-2 text-sm">Failed: {error.message}</div>;
if (!Component) return <div className="animate-pulse p-4 bg-muted rounded" />;
return (
<ErrorBoundary fallback={<div className="p-2 text-destructive">Widget crashed</div>}>
<Component {...passThrough} />
</ErrorBoundary>
);
}
```
Register once at boot:
```ts
widgetRegistry.register({
metadata: { id: '__remote', name: 'Remote Component', category: 'advanced', icon: Globe },
component: RemoteComponent,
constraints: { canHaveChildren: false },
});
```
### Loading strategies comparison
| Approach | How | Shared deps | Isolation | Build coordination |
|----------|-----|-------------|-----------|-------------------|
| **Dynamic `import(url)`** | Native ESM, `webpackIgnore` | No — remote bundles own React | None (same origin) | None |
| **Module Federation** (Webpack 5 / Vite) | `remoteEntry.js` manifest | Yes — shared React, shared stores | Partial | Remote must build with matching config |
| **Import Maps** | `<script type="importmap">` + bare specifiers | Yes — browser resolves to shared CDN copy | None | CDN versioning only |
| **Sandboxed iframe** | `<iframe src={url} sandbox>` + `postMessage` | No | Full | None |
**Recommendation**: Start with dynamic `import()` (simplest). Upgrade to Module Federation when you need shared React instances across remotes.
### Security model
Remote components execute arbitrary JS in your page. Mitigations:
| Threat | Mitigation |
|--------|-----------|
| XSS / DOM manipulation | CSP `script-src` allowlist, SRI hashes on known URLs |
| Props leaking auth tokens | Whitelist passable props per component, never forward `user` / `token` |
| Crashing the host | `ErrorBoundary` wrapping every `RemoteComponent` |
| Infinite loops / CPU | `requestIdleCallback` loading, Web Worker for heavy init |
| Access to store/context | Remote components get **only** `passThrough` props — no store, no context |
| Malicious URLs | Admin-only permission to add `__remote` nodes (see below), URL allowlist in settings |
### Permission gating
```ts
// Only admins can add remote widgets
pluginManager.addHook('editor:widgetPalette', (widgets, { user }) => {
if (!user.roles.includes('admin')) {
return widgets.filter(w => w.metadata.id !== '__remote');
}
return widgets;
});
// Allowlist for remote URLs
pluginManager.addHook('widget:beforeRender', (node, ctx) => {
if (node.type === '__remote') {
const allowedHosts = ctx.settings.remoteWidgetAllowlist || [];
const url = new URL(node.props.url);
if (!allowedHosts.includes(url.host)) {
return { ...node, props: { ...node.props, _blocked: true } };
}
}
return node;
});
```
### What this enables
| Use case | How |
|----------|-----|
| **Widget marketplace** | Publish component as ESM → add URL to page |
| **User-built components** | "Paste your widget URL" in advanced settings |
| **A/B test variants** | Swap component URL without redeploying |
| **Micro-frontends** | Each team publishes widgets independently |
| **Dev preview** | Point URL at `http://localhost:3001/MyWidget.js` during development |
| **Third-party embeds** | Wrap any React lib (chart, form, map) as a widget without forking |
### Authoring a remote widget
A remote widget is just a standard ESM module that exports a React component:
```tsx
// weather-widget/src/index.tsx
import React, { useState, useEffect } from 'react';
export function WeatherWidget({ city, units = 'metric' }: {
city: string;
units?: 'metric' | 'imperial';
}) {
const [weather, setWeather] = useState(null);
useEffect(() => {
fetch(`https://api.weather.example/${city}?units=${units}`)
.then(r => r.json())
.then(setWeather);
}, [city, units]);
if (!weather) return <div>Loading weather...</div>;
return (
<div className="p-4 rounded-lg bg-gradient-to-br from-blue-500 to-blue-700 text-white">
<h3 className="font-bold">{city}</h3>
<p className="text-3xl">{weather.temp}°</p>
<p className="text-sm opacity-80">{weather.condition}</p>
</div>
);
}
export default WeatherWidget;
```
Build with Vite as a library:
```ts
// weather-widget/vite.config.ts
export default defineConfig({
build: {
lib: {
entry: 'src/index.tsx',
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
external: ['react', 'react-dom'], // Use host's React
},
},
});
```
Deploy `dist/index.mjs` to CDN. Done.
### Summary: The four levels
| Level | When | Safety | Flexibility |
|-------|------|--------|-------------|
| 1. Pre-registered | Core widgets, always available | ★★★★★ | ★★ |
| 2. Lazy-imported | Heavy widgets, load-on-demand | ★★★★★ | ★★★ |
| 3. Plugin-registered | npm ecosystem, opt-in features | ★★★★ | ★★★★ |
| 4. Remote (runtime) | Marketplace, user-built, micro-frontend | ★★ | ★★★★★ |
All four levels coexist — the registry doesn't care how a component was loaded. A Node with `type: 'photo-card'` and a Node with `type: '__remote'` render through the same recursive `NodeRenderer` (§14).
---
## References
### Libraries
| Library | What it is | Relevance |
|---------|-----------|-----------|
| [Zustand](https://github.com/pmndrs/zustand) | Minimal state management for React | Recommended store replacement |
| [Preact Signals](https://github.com/preactjs/signals) | Fine-grained reactive primitives | Alternative reactive model |
| [Signia](https://github.com/tldraw/signia) | Signals library from tldraw team | Canvas-optimized signals |
| [Immer](https://github.com/immerjs/immer) | Immutable state via mutable API | Simplifies nested layout mutations |
| [use-context-selector](https://github.com/dai-shi/use-context-selector) | Selector API for React Context | Zero-dep context optimization |
| [dnd-kit](https://github.com/clauderic/dnd-kit) | Drag-and-drop toolkit | Already used — store integration patterns |
| [Tapable](https://github.com/webpack/tapable) | Webpack's hook/plugin system | Pipeline-based hook architecture |
| [Hookable](https://github.com/unjs/hookable) | Lightweight async hook system (UnJS) | Simpler alternative to Tapable |
### Reference Projects (widget/layout builder architectures)
| Project | Architecture | What to study |
|---------|-------------|---------------|
| [tldraw](https://github.com/tldraw/tldraw) | Signals (Signia) + Zustand | Canvas state, shape lifecycle, nested shape trees |
| [Excalidraw](https://github.com/excalidraw/excalidraw) | Zustand + custom store | Element model, collaborative state, undo/redo |
| [React Flow](https://github.com/xyflow/xyflow) | Zustand store per instance | Node/edge registry, nested sub-flows, store slices |
| [Plate.js](https://github.com/udecode/plate) | Zustand + plugin stores | **Plugin architecture** — per-plugin state, createPlatePlugin() |
| [Gutenberg](https://github.com/WordPress/gutenberg) | Redux + registry pattern | **Block extensions** — InnerBlocks, block filters, slot fills |
| [Builder.io](https://github.com/BuilderIO/builder) | Zustand + Mitosis | Visual widget builder, config schema, drag-drop |
| [GrapesJS](https://github.com/GrapesJS/grapesjs) | Backbone → events | **Plugin system** — component types, trait system, editor hooks |
| [Craft.js](https://github.com/prevwong/craft.js) | Context + custom store | **Resolver pattern** — plugin components → node tree |
| [Puck](https://github.com/measuredco/puck) | React + custom state | **Component config** — external component registration, drop zones |
| [Lexical](https://github.com/facebook/lexical) | Command bus + context | **Plugin architecture** — registerCommand, node transforms |
| [Strapi](https://github.com/strapi/strapi) | Plugin registries | **Admin panel plugins** — registerField, injectSlot pattern |
| [Sanity.io](https://github.com/sanity-io/sanity) | Plugin chains | **Schema extensions** — definePlugin(), document actions |
| [Payload CMS](https://github.com/payloadcms/payload) | Config-driven plugins | **Field plugins** — hooks.beforeChange, access control |
### Articles & Patterns
| Title | Topic |
|-------|-------|
| [Zustand: Bear Necessities of State Management](https://tkdodo.eu/blog/working-with-zustand) | Zustand best practices by TkDodo |
| [Signals vs React State](https://preactjs.com/blog/introducing-signals/) | Official Preact comparison |
| [The Case for Signals in JavaScript](https://dev.to/ryansolid/a-hands-on-introduction-to-fine-grained-reactivity-3ndf) | Ryan Carniato (Solid.js creator) |
| [React Context Performance](https://blog.isquaredsoftware.com/2021/01/context-redux-differences/) | Mark Erikson on context vs external stores |
| [Building a Page Builder](https://www.builder.io/blog/build-your-own-visual-builder) | Builder.io's architecture decisions |
| [Designing Plugin Systems](https://css-tricks.com/designing-a-javascript-plugin-system/) | General plugin architecture patterns |
| [Gutenberg Block Filters](https://developer.wordpress.org/block-editor/reference-guides/filters/) | WordPress block extension API |
| [GrapesJS Plugin API](https://grapesjs.com/docs/modules/Plugins.html) | Visual editor plugin registration |
| [Lexical Plugin Architecture](https://lexical.dev/docs/concepts/plugins) | Command-based plugin system |
| [Plate.js Plugin Guide](https://platejs.org/docs/plugin) | createPlatePlugin() pattern |