These pages document @evanion/react-widget 0.3.3, the version on npm.
API reference
@evanion/react-widget exports one factory, two default chrome components and
their types. It also re-exports the validator, the message constants and the
loose item types from @evanion/widget, so one install covers both. The entries
from validateItems on are those re-exports.
createWidgets(config)
createWidgets takes a component map and optional chrome, and returns the
Widgets component that renders items against them.
function createWidgets<
const C extends WidgetRegistry<AnyWidgetComponent>,
M = WidgetMeta,
>(
config: WidgetsConfig<C, M>,
): {
Widgets: MemoExoticComponent<
(props: WidgetsProps<C, M>) => JSX.Element | null
>;
defineItems: (items: WidgetItem<C, M>[]) => WidgetItem<C, M>[];
validateItems: (
items: unknown,
required?: Record<string, string[]>,
) => WidgetProblem[];
};| Returned | What it does |
|---|---|
Widgets | renders an item array against the map; its props are WidgetsProps |
defineItems | returns the array it is handed, typed WidgetItem<C, M>[], for an array held in a variable |
validateItems | validateItems with the map’s keys as the known types |
C is inferred from config.components and M from config.chrome.item. Pass
neither by hand. WidgetRegistry,
WidgetMeta and WidgetProblem come from
@evanion/widget and have entries below. TypeScript infers no type argument
once one is named, so naming M means writing C out as well. Call
createWidgets once, at module scope, because each call returns a new Widgets
and a call inside a component remounts the region on every render.
const = (: { : string; : number }) => (
<>
{.}, {.} kr
</>
);
// 1. Create a typed widget set from the component map
const { , } = ({
: { : },
});
// 2. Define items with compile-time prop checking
const = ([
{
: 'brass',
: 'listing',
: { : 'Brass: Birmingham', : 649 },
},
]);
// 3. Render the region
const = (< ={} />); // -> '<section><div data-widget-id="brass" data-widget-type="listing"><h3>Brass: Birmingham, 649 kr</h3></div></section>'WidgetsConfig
WidgetsConfig<C, M> is the argument to createWidgets.
interface WidgetsConfig<
C extends WidgetRegistry<AnyWidgetComponent>,
M = WidgetMeta,
> {
components: C;
chrome?: WidgetsChrome<M>;
}components is the component map, and its shape drives inference for every
item and prop the returned Widgets accepts. chrome is the factory’s
WidgetsChrome.
WidgetsProps
WidgetsProps<C, M> is the props of the Widgets component createWidgets
returns.
| Prop | Type | Meaning |
|---|---|---|
items | WidgetItem<C, M>[] | the items to render |
components | Partial<C> | optional; per-instance components, merged over the factory’s map |
chrome | WidgetsChrome<M> | optional; per-instance chrome, resolved field by field |
ctx | Record<string, unknown> | optional; page-level data, passed to every widget as its ctx prop |
Widgets is wrapped in memo(). An items prop that is not an array renders
null and warns in development. Widgets passes ctx after the item’s props,
so its value reaches the widget over anything an item carries under that name,
undefined included. The compiler does not check ctx against the ctx prop a
widget declares. Overriding the map or the chrome for one
region
shows the per-instance props.
WidgetsChrome
WidgetsChrome<M> is the markup Widgets draws around its region and its
widgets, and the Suspense behaviour between them.
interface WidgetsChrome<M = WidgetMeta> {
wrapper?: WidgetsWrapperComponent;
item?: WidgetItemComponent<M>;
suspense?: WidgetSuspenseMode;
suspenseFallback?: ReactNode;
}| Field | Default | What it is |
|---|---|---|
wrapper | DefaultWrapper | rendered around the whole region |
item | DefaultItem | rendered around each widget; its annotation types meta |
suspense | 'per-item' | whether each widget gets its own <Suspense> |
suspenseFallback | nothing | rendered while a widget suspends |
WidgetSuspenseMode
WidgetSuspenseMode is 'per-item' | 'none', the values chrome.suspense
takes.
type WidgetSuspenseMode = 'per-item' | 'none';'per-item' gives each widget its own boundary, so one slow widget does not
hold up its siblings. 'none' gives the region no boundary, for widgets that
never suspend. React’s streaming renderer then writes every item into the shell,
the first HTML it sends, and leaves none to a script that moves it into place
later.
When to disable Suspense
carries the measurement.
No mode puts one boundary around the whole region. React would outline that one
boundary and send every item in it after the shell, at once. To get one boundary
anyway, wrap <Widgets> in <Suspense> and set 'none'.
WidgetItemComponent
WidgetItemComponent<M> is the type of chrome.item, the component rendered
around each widget.
type WidgetItemComponent<M = WidgetMeta> = ComponentType<{
children?: ReactNode;
'data-widget-id': string;
'data-widget-type': string;
meta?: M;
}>;Annotating chrome.item with it types the meta of every item one
createWidgets call accepts: createWidgets infers M from the annotation.
import type { } from '@evanion/react-widget';
type = { : number; ?: number };
const : <> = ({
,
,
...
}) => (
<
{...}
={{
: `${?. ?? 'auto'} / span ${?. ?? 1}`,
}}
>
{}
</>
);
const { , } = ({
: { : },
: { : },
});
const = ([
{
: 'root',
: 'listing',
: { : 'Root' },
: { : 2, : 2 },
},
// @ts-expect-error GridCell reads `column`, and `col` is not a key of GridMeta
{ : 'hive', : 'listing', : { : 'Hive' }, : { : 1 } },
]);
const = (< ={} />); // -> '<section><div data-widget-id="root" data-widget-type="listing" style="grid-column:2 / span 2"><h3>Root</h3></div><div data-widget-id="hive" data-widget-type="listing" style="grid-column:auto / span 1"><h3>Hive</h3></div></section>'WidgetsWrapperComponent
WidgetsWrapperComponent is the type of chrome.wrapper, the component
rendered around the whole region.
type WidgetsWrapperComponent = ComponentType<{
children?: ReactNode;
items?: readonly RenderableWidgetItem[];
}>;items and children are aligned by position. An item Widgets skipped is
dropped from both, so index i of one is index i of the other, and a wrapper
can group, size or window by item. Each child is the chrome.item element for
one item. Its own props are data-widget-id, data-widget-type and meta, and
React keys it by the item’s id. The item’s props sit on the widget element
nested inside it. items hands the wrapper each whole item as data, top-level
items only.
If your wrapper spreads its props onto a DOM element, remove items first, the
way DefaultWrapper does, or React warns about an unknown attribute.
DefaultWrapper
DefaultWrapper is the default chrome.wrapper: a <section> carrying every
prop it is handed except items.
function DefaultWrapper(
props: HTMLProps<HTMLDivElement> & {
items?: readonly RenderableWidgetItem[];
},
): JSX.Element;A <section> with an accessible name maps to the region landmark role, so an
aria-label makes the region reachable by landmark navigation.
const { , } = ({
: { : },
: {
: () => < {...} ="New in" />,
: () => < {...} ="slot" />,
},
});
const = ([
{ : 'azul', : 'listing', : { : 'Azul' } },
]);
const = (< ={} />); // -> '<section aria-label="New in"><div data-widget-id="azul" data-widget-type="listing" class="slot"><h3>Azul</h3></div></section>'DefaultItem
DefaultItem is the default chrome.item: a <div> carrying data-widget-id,
data-widget-type and every other prop it is handed except meta.
function DefaultItem(
props: HTMLProps<HTMLDivElement> & { meta?: Record<string, unknown> },
): JSX.Element;CMS click-to-edit overlays, analytics and end-to-end selectors key off the two
data-widget-* attributes. meta is dropped because React warns about an
unknown attribute on every key of it that reaches the DOM. Neither default
carries a Suspense boundary or an error boundary; the Suspense boundary lives in
Widgets.
WidgetItem
WidgetItem<C, M> is the checked item, a union with one member per key of the
component map.
type WidgetItem<
C extends WidgetRegistry<AnyWidgetComponent>,
M = WidgetMeta,
> = {
[K in keyof C & string]: {
id: string;
type: K;
props: WidgetDataProps<C[K]>;
meta?: M;
children?: WidgetChildren<C, K, M>;
};
}[keyof C & string];type narrows props to that component’s props. M is one type per
createWidgets call, because one chrome.item reads every item’s meta.
WidgetItem widens to AnyWidgetItem. satisfies WidgetItem<typeof components>[] checks an array the way defineItems does.
const = (: { : string; : number }) => (
<>
{.}, {.} kr
</>
);
const { , } = ({
: { : },
});
const = ([
{ : 'root', : 'listing', : { : 'Root', : 499 } },
]);
// @ts-expect-error 'raffle' is not a key of the component map
([{ : 'raffle', : 'raffle', : {} }]);
const = (< ={} />); // -> '<section><div data-widget-id="root" data-widget-type="listing"><h3>Root, 499 kr</h3></div></section>'WidgetDataProps
WidgetDataProps<C> is the props an item carries for component C: its
props less children and ctx, which Widgets supplies.
type WidgetDataProps<C extends AnyWidgetComponent> = [
keyof Omit<ComponentProps<C>, 'children' | 'ctx'>,
] extends [never]
? Record<string, never>
: Omit<ComponentProps<C>, 'children' | 'ctx'>;A component with no props left resolves to Record<string, never>, which admits
props: {} and rejects any key. Were the type {} instead, the same props
would admit every key, because TypeScript assigns any object to {} with no
excess-property check.
WidgetChildren
WidgetChildren<C, K, M> is the type of an item’s children:
WidgetItem<C, M>[] when component C[K] declares children, and never
when it does not.
type WidgetChildren<
C extends WidgetRegistry<AnyWidgetComponent>,
K extends keyof C,
M = WidgetMeta,
> = 'children' extends keyof ComponentProps<C[K]> ? WidgetItem<C, M>[] : never;The never makes nesting under a widget that renders no children a compile
error. Without it, Widgets drops that subtree and logs nothing.
([
{
: 'azul',
: 'listing',
: { : 'Azul' },
// @ts-expect-error ListingCard declares no children, so nothing nests under it
: [{ : 'hive', : 'listing', : { : 'Hive' } }],
},
]);RenderableWidgetItem
RenderableWidgetItem is the item with its types erased, which Widgets
renders from and chrome.wrapper receives as items.
interface RenderableWidgetItem {
id: string;
type: string;
props: Record<string, unknown>;
meta?: Record<string, unknown>;
children?: RenderableWidgetItem[];
}Widgets widens the checked union to this once, between its public props and
its render loop. Spreading the union onto a component directly makes
TypeScript fail with TS2590, “union type that is too complex to represent”.
AnyWidgetComponent
AnyWidgetComponent is ComponentType<any>, the constraint a component map’s
values meet.
type AnyWidgetComponent = ComponentType<any>;ComponentType<unknown> rejects a component with concrete props, because
component props are contravariant. Inference is unaffected:
ComponentProps<C[K]> resolves against the component that was passed.
validateItems
validateItems walks a payload and returns every problem it finds, re-exported
unchanged from @evanion/widget.
import { } from '@evanion/react-widget';
const : unknown = .(
'[{"id":"root","type":"listing","props":{"title":"Root"}},{"id":"raffle","type":"raffle","props":{}}]',
);
const = (, ['listing', 'booking']); // -> [{ index: 1, id: 'raffle', type: 'raffle', message: 'unknown widget type' }]The standalone export takes a list of type names or a component map, so a
webhook or a CI script checks a payload without importing a React component. The
bound form that createWidgets returns takes no names. Both take an optional
required map from a widget type to the props that must be present and
non-blank, where blank is undefined, null or a whitespace-only string.
Neither checks a prop’s type against a component; WidgetItem does that at
compile time. @evanion/widget’s entry carries the
signature, and What it reports every message.
defineWidgets
defineWidgets returns the component map it is handed, typed as the literal
object, re-exported from @evanion/widget.
import { , } from '@evanion/react-widget';
import type { } from '@evanion/react-widget';
const = (: { : string }) => .;
const = ({ : });
// @ts-expect-error 'raffle' is not a key of components
const : <typeof >['type'] = 'raffle';
const : unknown = [{ : 'raffle', : 'raffle', : {} }];
const = (, ); // -> [{ index: 0, id: 'raffle', type: 'raffle', message: 'unknown widget type' }]A map declared on its own serves as the type argument of WidgetItem and as the
known types of the standalone validateItems, so the raffle type fails the
compile and the runtime check alike. Annotating that map as WidgetRegistry
widens its keys to string, and defineWidgets keeps them literal. A map
declared with no annotation keeps them literal too, so the call is optional in
this package. @evanion/widget’s entry carries the
signature.
ERROR_MESSAGES
ERROR_MESSAGES is the text Widgets warns with in development, re-exported
from @evanion/widget.
MALFORMED_ITEMS reports an items prop that is not an array, and Widgets
renders nothing. MALFORMED_CHILDREN reports nested items that are not an
array, and Widgets renders the widget without them. UNKNOWN is the string
'unknown', which a message uses in place of an id or type the item lacks.
The rest report an item Widgets skipped. A message about one item names its
id, and Widgets logs each distinct message once per process, so a second bad
item still gets its own warning. @evanion/widget’s
entry lists every key.
VALIDATION_MESSAGES
VALIDATION_MESSAGES is the text validateItems reports, re-exported from
@evanion/widget, so you can group or translate problems without matching on
prose. @evanion/widget’s entry lists every
key.
AnyWidgetItem
AnyWidgetItem is the loose item, 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; WidgetItem widens to it.
The widget core’s Overview
teaches the item’s fields, and
@evanion/widget’s entry carries the interface.
WidgetRegistry
WidgetRegistry<T> is Record<string, T>, from @evanion/widget. This package
instantiates it as WidgetRegistry<AnyWidgetComponent>, its component map type.
@evanion/widget’s entry carries the rest.
WidgetMeta
WidgetMeta is Record<string, unknown>, from @evanion/widget: the meta
type when createWidgets gets a chrome.item that declares none, and the
default for M on this page. @evanion/widget’s
entry carries the rest.
WidgetProblem
WidgetProblem is one line of the list validateItems returns, from
@evanion/widget: an index, the item’s id and type, and a message.
@evanion/widget’s entry says what each field
holds.
KnownWidgetTypes
KnownWidgetTypes is WidgetRegistry | readonly string[], from
@evanion/widget: the known types validateItems takes, as a component map or
a plain list of names. @evanion/widget’s entry
carries the rest.
Migrating react-widget 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 here under the names the whole family
uses.
| Was | Now |
|---|---|
WidgetComponentMap | WidgetRegistry<AnyWidgetComponent> |
WidgetItemProblem | WidgetProblem |
WidgetProps | AnyWidgetItem |
chrome.wrapper now receives an items prop beside its children. It is
optional, so an existing wrapper keeps compiling. If your wrapper spreads its
props onto a DOM element, remove items first.
Migrating react-widget from 0.1.x
0.2.0 removed everything that cannot exist under React’s react-server export
condition.
| Removed | Replacement |
|---|---|
'use client' | none needed; the package is importable from a Server Component |
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 | removed with the boundary that used them |
renderWidget and NestedWidgetsContext are no longer exported. The nesting
mechanism is internal, so a change to it is not a breaking release.