Ahead of the release
npm install @evanion/react-widget gives you 0.3.0. These pages document main, which has changes that release does not.
API reference
Every export of @evanion/react-widget.
The item model — AnyWidgetItem, WidgetRegistry, WidgetProblem,
WidgetMeta, KnownWidgetTypes, validateItems, defineWidgets,
ERROR_MESSAGES, VALIDATION_MESSAGES — comes from @evanion/widget and is
re-exported here. The specifier does not change and neither does the install:
this package pins the core exactly and depends on it.
The widget core’s API reference is where each of those is
written down, and the entries below link to it rather than restating it.
createWidgets(config)
function createWidgets<
const C extends WidgetRegistry<AnyWidgetComponent>,
M = WidgetMeta,
>(
config: WidgetsConfig<C, M>,
): {
// memo()-wrapped
Widgets: ComponentType<WidgetsProps<C, M>>;
defineItems: (items: WidgetItem<C, M>[]) => WidgetItem<C, M>[];
validateItems: (
items: unknown,
required?: Record<string, string[]>,
) => WidgetProblem[];
};Call once, at module scope.
config
| Field | Meaning |
|---|---|
components | widget type → component. Drives inference for the whole set |
chrome.wrapper | rendered around the whole set. Defaults to <section> |
chrome.item | rendered around each widget. Defaults to a <div> with the data-widget-* attributes |
chrome.suspense | 'per-item' (default) or 'none' |
chrome.suspenseFallback | rendered while a widget suspends. Defaults to nothing |
The two type parameters
C is the component map, inferred from config.components. M is the item
meta vocabulary, inferred from config.chrome.item.
Neither is meant to be passed by hand. Naming M explicitly costs the inference
of C, and chrome.item is the only thing that reads meta, so a set with no
such chrome has nothing to check meta against — there, and for an unannotated
chrome, meta stays any object.
<Widgets>
| Prop | Type | Meaning |
|---|---|---|
items | WidgetItem<C, M>[] | the items to render |
components | Partial<C> | per-instance overrides, merged over the factory’s map |
chrome | WidgetsChrome<M> | per-instance chrome overrides |
ctx | Record<string, unknown> | page-level data passed to every widget as a prop |
Wrapped in memo(). An items prop that is not an array is skipped with a
development warning and renders null.
Chrome is resolved per field, not per object: an instance chrome that sets
only wrapper keeps the factory’s item, suspense and suspenseFallback.
ctx follows the props spread, so the renderer’s value wins over anything an
item carries under that name — including when the renderer has none and the
widget is handed undefined. Items are untrusted input, and WidgetDataProps
omits ctx, so a typed item cannot express one at all.
validateItems(items, known, required?)
Re-exported unchanged from @evanion/widget.
The core’s entry carries the signature and the
arguments, and What it reports carries every message it
returns and the payload that produces each one.
Two entry points reach it here. The standalone export takes a plain list of type
names, so a webhook handler or a CI script validates a payload without importing
React components it will never render. The bound form off createWidgets closes
over the factory’s component map, so it takes items and required and no
known.
props is not checked against the component. That is WidgetItem<C>’s job and
it does it at compile time, which is the half of the check this package adds;
validateItems covers structure, which is all it can see given a list of type
names.
DefaultWrapper
function DefaultWrapper(
props: HTMLProps<HTMLDivElement> & {
items?: readonly RenderableWidgetItem[];
},
): ReactElement;A <section> carrying whatever props it is handed, less items, which it drops
rather than spreading onto the element. <section> rather than
<div> because a named section maps to the region landmark role, so a caller
who passes aria-label gets a region reachable by landmark navigation and one
who does not is no worse off.
DefaultItem
function DefaultItem(
props: HTMLProps<HTMLDivElement> & { meta?: Record<string, unknown> },
): ReactElement;A <div> carrying data-widget-id and data-widget-type, which CMS
click-to-edit overlays, analytics and E2E selectors key off.
meta is dropped rather than forwarded. It is arbitrary consumer data with no
meaning to the DOM, and React warns about an unknown attribute on every key of
it that reaches an element. Reading meta is what a custom chrome.item is for.
Neither default carries an error boundary or a Suspense boundary. The Suspense boundary lives in the renderer; error boundaries are not in this package at all.
ERROR_MESSAGES
Re-exported from @evanion/widget, and what this renderer passes to its
warn-once logger when it skips an item.
The core’s entry lists every key and its shape.
Each message names the offending item, which is what makes the message text a usable key: every bad item is reported once per process, and a second bad item is still reported separately.
Types
WidgetItem<C, M>
The checked item union, distributed over the keys of the component map. type
narrows props to that component’s props minus children and ctx, meta is
typed M, and children is gated on whether the component accepts it.
M does not distribute over keyof C: one chrome reads every item’s meta, so
there is one vocabulary per set rather than one per widget type.
WidgetDataProps<C>
Omit<ComponentProps<C>, 'children' | 'ctx'>, except that a component with no
remaining props resolves to Record<string, never> rather than {}.
WidgetChildren<C, K, M>
WidgetItem<C, M>[] when C[K] accepts children, and never when it does
not. The never is what makes nesting under such a widget a compile error
instead of a dropped subtree.
WidgetMeta
Record<string, unknown>, from @evanion/widget. The meta vocabulary of a
set whose chrome declares none, and the default for M throughout this page.
WidgetsChrome<M>
interface WidgetsChrome<M = WidgetMeta> {
wrapper?: WidgetsWrapperComponent;
item?: WidgetItemComponent<M>;
suspense?: 'per-item' | 'none';
suspenseFallback?: ReactNode;
}WidgetItemComponent<M>
type WidgetItemComponent<M = WidgetMeta> = ComponentType<{
children?: ReactNode;
'data-widget-id': string;
'data-widget-type': string;
meta?: M;
}>;Annotating one of these is what types the whole set’s meta.
WidgetsWrapperComponent
type WidgetsWrapperComponent = ComponentType<{
children?: ReactNode;
items?: readonly RenderableWidgetItem[];
}>;The region wrapper receives the rendered children and the items that produced
them, positionally aligned: an item the renderer skipped is dropped from both at
once, so index i of one is index i of the other. That is what lets a wrapper
group, size, key or window a region by meta — Children.toArray walks the
<Suspense> elements the renderer created, not the items, so a wrapper cannot
recover an item from its child without this prop.
items is optional, so a wrapper that ignores it declares nothing. One that
spreads its props onto a DOM element has to drop it, the way DefaultWrapper
does, or React warns about an unknown attribute.
AnyWidgetItem<Type, Props>
The loose item shape, from @evanion/widget, for data built before a component
map exists — a CMS payload, a fixture, a network response. It checks nothing
against a component, so prefer WidgetItem, which widens to it.
Getting started is where
the four fields are taught and the core’s entry
carries the interface.
RenderableWidgetItem
The type-erased view the renderer uses internally. The checked union is widened to this exactly once, at the boundary between the public props and the render loop: spreading a discriminated union of this size directly onto a component makes TypeScript give up with “union type that is too complex to represent”.
AnyWidgetComponent
ComponentType<any>, for use in a generic constraint. ComponentType<unknown>
would reject a component with concrete props, because component props are
contravariant. Inference is unaffected — ComponentProps<C[K]> resolves against
the concrete component that was passed.
Others
WidgetsConfig<C, M>, WidgetsProps<C, M> and WidgetSuspenseMode are this
package’s own. WidgetRegistry,
WidgetProblem,
KnownWidgetTypes and
defineWidgets come from @evanion/widget.
Migrating from 0.2.x
The item shape, the props and the data are unchanged. Three type names moved to
@evanion/widget and are re-exported from here under the names the whole family
uses.
| Was | Now |
|---|---|
WidgetComponentMap | WidgetRegistry<AnyWidgetComponent> |
WidgetItemProblem | WidgetProblem |
WidgetProps | AnyWidgetItem |
WidgetProps and WidgetsProps differed by one character and meant different
things — the loose item, and the component’s props. AnyWidgetItem is the
untyped counterpart to WidgetItem, which is what it always was.
chrome.wrapper now receives an items prop beside its children. It is
optional, so an existing wrapper keeps compiling; a wrapper that spreads its
props onto a DOM element has to drop it.
Migrating from 0.1.x
0.2.0 removed everything that could not exist under React’s react-server
export condition.
| Removed | Replacement |
|---|---|
'use client' | none needed; the package is importable from an RSC |
WidgetsProvider, useWidgets, WidgetsConfig.context | call createWidgets once at module scope |
the injected Output prop and <Output/> | read children |
WidgetOutputProps | none needed |
the default WidgetErrorBoundary | your own boundary in a custom chrome.item, in your own 'use client' file |
DEFAULT_STYLES.LOADING | chrome.suspenseFallback |
DEFAULT_STYLES.ERROR, ERROR_MESSAGES.WIDGET_ERROR, ERROR_MESSAGES.WIDGET_FAILED, ERROR_MESSAGES.LOADING | gone with the boundary that used them |
renderWidget and NestedWidgetsContext are no longer exported: the nesting
mechanism is internal, so changing it is not a breaking release.
Added since: meta and its typing through chrome.item, chrome.suspense, and
validateItems.