Skip to main content

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 drawerLegacy widget editor
WhereThe settings drawer inside /admin/page-builder/edit/:routeId/admin/widgets/edit/:uuid
The formOne page-level useForm owned by the editor, shared by every widget on the route, with shouldUnregister: falseA standalone <Form> with shouldUnregister: true
Field pathsAuto-prefixed to block.<uid>.settings.<field>Used verbatim: settings.<field>
List / object settingsReal arrays and objectsJSON strings, seeded through a hidden input
GraphQL propsNone — the component is mounted without its query's dataSupplied from the component's query export
SavingPer-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();
Use it whenever you touch the form directly

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))}
/* … */
/>
);
}
HelperSignatureBehaviour
asArrayasArray<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.
useArraySettinguseArraySetting<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.

ExportReturns
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>
PropTypeDefaultDescription
fieldPathstringDot path under the widget's settings — "settings.heading", or nested: "settings.slides.0.heading".
childrenstring''The current text. Must be a string — plain text only.
asElementType'span'The tag to render.
multilinebooleanfalseAllow line breaks. When false, Enter blurs instead of inserting a newline.
classNamestringApplied on both the production and the editable element, so styling is identical.
focusOnMountbooleanfalseGrab focus and place the caret at the end on mount — for click-to-edit wrappers.
onBlurfunctionCalled 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 WidgetContextProvider above 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.

Never hand-roll this normalization

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:

ExportPurpose
Section, FieldCollapsible group and labelled row layout.
Segmented, Slider, ToggleCompact drawer-sized controls.
drawerInputClass, drawerTextareaClassClass strings matching the drawer's input styling.
RepeatableAccordionAdd / remove / reorder list editor. Requires a real array — see above.
AnchorPicker, ANCHOR_CELLSNine-cell content-position picker.
ImagePickerField, ColorSwatchField, MarkdownBodyField, CtaFieldComposite fields for the common widget settings.
CategoryPicker, ProductPicker, CollectionPicker, PagePicker, LandingPagePicker, LinkPickerEntity pickers backed by admin search.

Testing checklist for a widget setting component

  1. Open it in the page-builder drawer, change every field, and confirm the canvas updates and the change survives a reload.
  2. 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.
  3. Confirm the component renders without crashing when its GraphQL prop is undefined.
  4. Confirm the widget renders correctly on the production storefront, where none of these primitives are active.

See also



Support us


EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.