Page Builder Primitives For Themes And Widgets
Widgets are configured and arranged in the page builder. Everything a widget needs to participate — inline text editing on the canvas, correct form-path scoping in the settings drawer, safe reads of list settings — comes from one barrel:
import {
Editable,
WidgetContextProvider,
WidgetSettingsScope,
useWidgetUid,
useWidgetSettings,
useScopedFieldName,
useScopedFormContext,
useArraySetting,
asArray,
normalizeImageSrc
} from '@components/common/page-builder';
Every one of these is inert outside the page builder. A widget built with them renders as plain markup on the production storefront and behaves identically on the standalone widget editor — that is the point.
The rule that breaks widgets: two surfaces, two form shapes
A widget's setting component runs in two completely different form environments. Almost every widget bug that reaches production is a consequence of only testing one.
| Page-builder drawer | Legacy widget editor | |
|---|---|---|
| Where | The settings drawer inside /admin/page-builder/edit/:routeId | /admin/widgets/edit/:uuid |
| The form | One page-level useForm owned by the editor, shared by every widget on the route, with shouldUnregister: false | A standalone <Form> with shouldUnregister: true |
| Field paths | Auto-prefixed to block.<uid>.settings.<field> | Used verbatim: settings.<field> |
| List / object settings | Real arrays and objects | JSON strings, seeded through a hidden input |
| GraphQL props | None — the component is mounted without its query's data | Supplied from the component's query export |
| Saving | Per-widget debounced auto-save (one operation per widget per ~300 ms) | Explicit submit, validated against the widget's JSON schema |
The last three rows are where the traps live. Take them one at a time.
Field paths: WidgetSettingsScope
A widget setting component writes field names as if it owned the form:
<InputField name="settings.heading" label={_('Heading')} />
That is correct on the standalone page, where the form's values are shaped { name, status, settings: { … } }. In the drawer, several widgets share one form, so each needs its own namespace. The drawer supplies it by mounting the widget's setting component inside a scope:
<WidgetSettingsScope uid={widget.uid}>
<Area id="widget_setting_form" />
</WidgetSettingsScope>
WidgetSettingsScope publishes pathPrefix = 'block.<uid>.' through React context. Two hooks consume it:
useScopedFieldName(name)
Resolves one field name against the active scope. Returns the input unchanged when no scope is mounted, and is idempotent if the name already starts with the prefix.
const resolvedName = useScopedFieldName('settings.heading');
// drawer: 'block.9f3c….settings.heading'
// standalone: 'settings.heading'
Every field component in @components/common/form/ already calls this. If your setting form is composed purely of InputField, SelectField, ToggleField and friends, scoping is handled — you write bare settings.* names and both surfaces work.
useScopedFormContext()
A drop-in replacement for react-hook-form's useFormContext whose register, watch, setValue, unregister and getValues auto-prefix their path argument (including array-of-paths forms). Everything else on the context is passed through untouched.
import { useScopedFormContext } from '@components/common/page-builder';
const { register, watch, setValue, getValues } = useScopedFormContext();
If your setting component calls register / watch / setValue / getValues / useFieldArray with a literal settings.<x> path — instead of going through a Field component — swap useFormContext for useScopedFormContext. Outside the page builder it is a transparent passthrough; inside the drawer it is the difference between writing to the right widget and writing nowhere.
A third-party widget still importing vanilla useFormContext works on the standalone page and quietly does nothing in the drawer.
List settings: useArraySetting and asArray
The legacy widget editor is a plain form: it cannot carry an array through a submit, so <Form> seeds list and object settings as a JSON string in a hidden input:
<input type="hidden" {...register('settings.menus')}
defaultValue={JSON.stringify(initialMenus)} />
Which means this extremely natural line is a crash:
// ✗ Works in the drawer. Throws on /admin/widgets/edit/:uuid.
const items = watch('settings.items') ?? initialItems;
?? only guards null and undefined. A JSON string is neither, so it sails straight through into items.map(…) — items.map is not a function. And if it survived rendering, the string would fail the widget's array schema on save, because settings are never parsed anywhere in the save path.
Use the helpers instead:
import { useArraySetting, asArray } from '@components/common/page-builder';
export default function MyWidgetSetting({ myWidget }) {
const { getValues, setValue } = useScopedFormContext();
const widgetSettings = useWidgetSettings();
const initialItems = asArray(
myWidget?.items ?? widgetSettings.items,
[]
);
// Display: always an array, and normalizes form state to a real array on mount.
const items = useArraySetting('settings.items', initialItems);
// Mutation reads: never trust getValues to return an array either.
const readItems = () => asArray(getValues('settings.items'), initialItems);
const addItem = () => setValue('settings.items', [...readItems(), blank()], {
shouldDirty: true
});
return (
<RepeatableAccordion
items={items}
onRemove={(i) => setValue('settings.items', readItems().filter((_, x) => x !== i))}
/* … */
/>
);
}
| Helper | Signature | Behaviour |
|---|---|---|
asArray | asArray<T>(value, fallback) | Pure coercion. Arrays pass through; JSON strings are parsed (and returned only if they parse to an array); anything else returns fallback. Never throws. |
useArraySetting | useArraySetting<T>(name, fallback) | Reads the setting through useScopedFormContext().watch and coerces with asArray. Additionally, once on mount, if the stored value is a string it rewrites form state to the parsed array with shouldDirty: false — so the save path sees an array. No-ops in the drawer, where the value is already an array. |
Reads inside mutators must use asArray(getValues(name), fallback) rather than closing over the rendered items, so back-to-back edits do not clobber each other.
An alternative that is also correct: hold the array with useFieldArray and pass the fallback as watch's second (default) argument — core's SlideshowSetting predates the helpers and does this. RepeatableAccordion additionally carries a defensive Array.isArray(items) ? items : [] backstop, but do not rely on it: it protects the render, not the save.
No GraphQL props in the drawer
On the standalone widget editor, a setting component's query export is executed and its results arrive as props. In the drawer, the component is mounted with no props from that query at all — the editor injects a synthetic widget entry into the Area machinery, and there is no props mapping for it.
So every prop your setting component declares must be optional and optional-chained, with a fallback:
interface MyWidgetSettingProps {
// Optional: the page-builder drawer mounts this without GraphQL props.
myWidget?: { items?: Item[]; className?: string };
}
export default function MyWidgetSetting({ myWidget }: MyWidgetSettingProps) {
const widgetSettings = useWidgetSettings();
const initialItems = asArray(
myWidget?.items ?? (widgetSettings.items as Item[] | undefined),
[]
);
const initialClassName =
myWidget?.className ?? ((widgetSettings.className as string) ?? '');
// …
}
useWidgetSettings() is the reliable settings source in the drawer — it reads the widget's currently-applied settings (overlay already merged) from widget context. Core's BasicMenuSetting is the canonical implementation of this pattern.
Widget identity: WidgetContextProvider and useWidgetUid
<WidgetContextProvider uid={uuid} settings={settings}>
{children}
</WidgetContextProvider>
Wraps a rendered widget so nested components can identify which instance they belong to without prop drilling. The page-builder iframe's widget shell mounts one per widget render; the production storefront provides the context too, at no DOM cost.
| Export | Returns |
|---|---|
useWidgetUid() | The widget instance uid, or null outside a provider. |
useWidgetSettings() | The widget's currently-applied settings object, or {} outside a provider. |
Inline editing: Editable
<Editable> makes a piece of a widget's text editable directly on the canvas.
import { Editable } from '@components/common/page-builder';
<Editable as="h2" fieldPath="settings.heading" className="banner__heading">
{heading}
</Editable>
| Prop | Type | Default | Description |
|---|---|---|---|
fieldPath | string | — | Dot path under the widget's settings — "settings.heading", or nested: "settings.slides.0.heading". |
children | string | '' | The current text. Must be a string — plain text only. |
as | ElementType | 'span' | The tag to render. |
multiline | boolean | false | Allow line breaks. When false, Enter blurs instead of inserting a newline. |
className | string | — | Applied on both the production and the editable element, so styling is identical. |
focusOnMount | boolean | false | Grab focus and place the caret at the end on mount — for click-to-edit wrappers. |
onBlur | function | — | Called after blur, once the pending edit has flushed. |
How it behaves:
- On the production storefront it renders
<Tag className={className}>{children}</Tag>and nothing else. No contenteditable, no listeners, no injected stylesheet. - SSR-safe by construction. The first render is always the production path; page-builder mode is detected after mount, so there is no hydration mismatch.
- It needs a widget uid. With no
WidgetContextProviderabove it, it stays on the production path even inside the builder. - Edits flush on a 250 ms input debounce and again on blur. Escape reverts to the original text and blurs. Pasted content is stripped to plain text.
- It sends the full new settings object, patched at
fieldPath, so the editor's save path is the same one the settings drawer uses — one debounced operation per widget, whichever surface produced the change.
Related exports in the same barrel: EditableMarkdown (click-to-edit rich body text) and EditableImage / EditableImageOverlay (inline image replacement).
Image paths: normalizeImageSrc
Any value picked from the file manager must be routed through normalizeImageSrc before you store it:
import { normalizeImageSrc } from '@components/common/page-builder';
setValue('settings.image', normalizeImageSrc(picked), { shouldDirty: true });
It collapses accidental duplicate slashes in plain paths (/assets//file.jpg, emitted by older file-browser builds) while leaving absolute (https://bucket.s3.amazonaws.com/key) and protocol-relative (//cdn.example.com/key) URLs untouched.
The obvious raw.replace(/\/{2,}/g, '/') corrupts https:// into https:/. Cloud file storage returns full URLs, the storefront image proxy checks for a literal https:// prefix, and a single-slash URL is treated as a local path and 404s. Browsers silently repair https:/ in the address bar, so the value looks fine everywhere except through /images — which is exactly where your widget renders it.
Drawer UI primitives
The same barrel exports the building blocks core's own setting forms are made of, so a third-party widget's drawer looks native:
| Export | Purpose |
|---|---|
Section, Field | Collapsible group and labelled row layout. |
Segmented, Slider, Toggle | Compact drawer-sized controls. |
drawerInputClass, drawerTextareaClass | Class strings matching the drawer's input styling. |
RepeatableAccordion | Add / remove / reorder list editor. Requires a real array — see above. |
AnchorPicker, ANCHOR_CELLS | Nine-cell content-position picker. |
ImagePickerField, ColorSwatchField, MarkdownBodyField, CtaField | Composite fields for the common widget settings. |
CategoryPicker, ProductPicker, CollectionPicker, PagePicker, LandingPagePicker, LinkPicker | Entity pickers backed by admin search. |
Testing checklist for a widget setting component
- Open it in the page-builder drawer, change every field, and confirm the canvas updates and the change survives a reload.
- Open the same widget at
/admin/widgets/edit/:uuid, change every field, and save. Any list or object setting that was only tested in the drawer will surface here. - Confirm the component renders without crashing when its GraphQL prop is
undefined. - Confirm the widget renders correctly on the production storefront, where none of these primitives are active.
See also
- Page Builder — the editor, changesets, and publishing
- Widget Development — registering a widget type and its settings schema
- Theme Content — shipping widget instances and placements in
theme.json - The View System — Areas, the placement targets
Support us
EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.