66 KiB
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/registerWidgets.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
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 (line 776)
/** 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)
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
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 (line 5)
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 (lines 17-61)
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)
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 (line 63)
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
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.
Proposed: NestedLayoutManager
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)
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
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/LayoutContainerView.tsx
/** 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
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
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 | Extend |
WidgetMetadata |
src/lib/widgetRegistry.ts | Extend |
WidgetInstance |
src/modules/layout/LayoutManager.ts | Minor additions |
LayoutContainer |
src/modules/layout/LayoutManager.ts | Extend settings |
FlexibleContainer |
src/modules/layout/LayoutManager.ts | Stable |
PageLayout |
src/modules/layout/LayoutManager.ts | Add version/meta |
ConfigSchema |
inline in src/lib/registerWidgets.ts | Extract to types |
NestedLayoutManager |
— | New |
WidgetLifecycleContext |
— | New |
BaseWidgetProps |
implicit in src/modules/layout/LayoutContainerEdit.tsx | Extract |
| Widget registrations | src/lib/registerWidgets.ts | Adopt new types |
| Container rendering | src/modules/layout/LayoutContainerEdit.tsx | Formalize contract |
| Layout context | 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 lines 775-788):
<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.
// 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:
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 — Official adapter
- Signia — tldraw's signal library (powers their canvas)
- Solid.js — 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.
// 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:
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:
// 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 — Docs, recipes, middleware patterns
- tldraw — Canvas editor with Zustand + Signia for state
- Excalidraw — Whiteboard with similar widget/element model
- React Flow — Node graph editor, Zustand-based state for nodes/edges
- Plate.js — Plugin-based editor with Zustand stores per plugin
- BuilderIO — Visual page builder with widget registry patterns
Option C: Context + useReducer (zero dependencies)
Keep React context but replace callbacks with actions:
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 — Selector API for React Context (by Zustand's author)
- Lexical — Facebook's editor, plugin-based with context commands
- 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
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.
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.
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:
// 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:
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:
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:
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:
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:
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:
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
// 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 already supports register() with overwrite semantics (line 28-32). Wrapping the existing registerWidgets() call (src/lib/registerWidgets.ts) as a core plugin is a mechanical refactor:
// 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)
- Separate code paths for adding widgets vs adding containers
addWidget()+addPageContainer()+removeWidget()+removePageContainer()— doubled API surface- Container rendering (LayoutContainerEdit.tsx) vs widget rendering — two renderers for one recursive tree
- Embedded
layoutDatain widget props — a layout-within-a-layout, invisible to the parent tree
Current data shape (two types)
// 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:
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:
{
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:
{
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 / FlexContainerView.tsx with PhotoCard.tsx:
// 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 line 105–124) groups widgets into a Map<"rowId:colIdx", WidgetInstance[]>, then renders per row with columnsToGridTemplate().
After — Unified Node tree:
{
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 105–124) |
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:
// 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 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
// "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
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 (~800 lines) and widget rendering.
Store operations: unified
// 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
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 | Block → InnerBlocks | Flat blocks → recursive blocks. Same migration. |
| Craft.js | Node with nodes map |
Flat node map + linkedNodes for nested canvases |
| Puck | ComponentData with children |
Zones are named child slots in components |
| tldraw | TLShape with parent reference |
Flat store, parent-child via parentId |
| GrapesJS | Component with components() |
Recursive component tree, no widget/container split |
| 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:
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:
widgetRegistry.register({
metadata: { id: 'chart', name: 'Chart', category: 'data', ... },
component: React.lazy(() => import('@/components/Chart')),
constraints: { canHaveChildren: false },
});
Already used for FlexContainerEdit (line 8).
Level 3: Plugin-registered (npm package, build-time)
A plugin brings its own components — installed via npm, part of the build:
// @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:
{
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:
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:
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
// 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:
// 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:
// 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 | Minimal state management for React | Recommended store replacement |
| Preact Signals | Fine-grained reactive primitives | Alternative reactive model |
| Signia | Signals library from tldraw team | Canvas-optimized signals |
| Immer | Immutable state via mutable API | Simplifies nested layout mutations |
| use-context-selector | Selector API for React Context | Zero-dep context optimization |
| dnd-kit | Drag-and-drop toolkit | Already used — store integration patterns |
| Tapable | Webpack's hook/plugin system | Pipeline-based hook architecture |
| Hookable | Lightweight async hook system (UnJS) | Simpler alternative to Tapable |
Reference Projects (widget/layout builder architectures)
| Project | Architecture | What to study |
|---|---|---|
| tldraw | Signals (Signia) + Zustand | Canvas state, shape lifecycle, nested shape trees |
| Excalidraw | Zustand + custom store | Element model, collaborative state, undo/redo |
| React Flow | Zustand store per instance | Node/edge registry, nested sub-flows, store slices |
| Plate.js | Zustand + plugin stores | Plugin architecture — per-plugin state, createPlatePlugin() |
| Gutenberg | Redux + registry pattern | Block extensions — InnerBlocks, block filters, slot fills |
| Builder.io | Zustand + Mitosis | Visual widget builder, config schema, drag-drop |
| GrapesJS | Backbone → events | Plugin system — component types, trait system, editor hooks |
| Craft.js | Context + custom store | Resolver pattern — plugin components → node tree |
| Puck | React + custom state | Component config — external component registration, drop zones |
| Lexical | Command bus + context | Plugin architecture — registerCommand, node transforms |
| Strapi | Plugin registries | Admin panel plugins — registerField, injectSlot pattern |
| Sanity.io | Plugin chains | Schema extensions — definePlugin(), document actions |
| Payload CMS | Config-driven plugins | Field plugins — hooks.beforeChange, access control |
Articles & Patterns
| Title | Topic |
|---|---|
| Zustand: Bear Necessities of State Management | Zustand best practices by TkDodo |
| Signals vs React State | Official Preact comparison |
| The Case for Signals in JavaScript | Ryan Carniato (Solid.js creator) |
| React Context Performance | Mark Erikson on context vs external stores |
| Building a Page Builder | Builder.io's architecture decisions |
| Designing Plugin Systems | General plugin architecture patterns |
| Gutenberg Block Filters | WordPress block extension API |
| GrapesJS Plugin API | Visual editor plugin registration |
| Lexical Plugin Architecture | Command-based plugin system |
| Plate.js Plugin Guide | createPlatePlugin() pattern |