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.
Chrome, meta and Suspense
Chrome
A widget set has two wrappers. chrome.wrapper goes around the whole region;
chrome.item goes around each widget.
const { Widgets } = createWidgets({
components: { listing: ListingCard, booking: TableBooking },
chrome: {
wrapper: ({ children }) => (
<aside className="shelf">
<header>Latest updates</header>
<div className="widget-container">{children}</div>
</aside>
),
item: ({ children, ...rest }) => (
<div className="widget-item" {...rest}>
{children}
</div>
),
},
});The wrapper receives children and an optional items, the region’s items in
the order they render. The item chrome receives children, data-widget-id,
data-widget-type and meta.
Spread the data-widget-* attributes onto the element unless you have a reason
not to: CMS click-to-edit overlays, analytics and E2E selectors key off them,
and a custom chrome that drops them silently breaks all three.
The defaults are DefaultWrapper (a <section>) and DefaultItem (a <div>
with those attributes). Both are exported, so a custom chrome can wrap one
rather than reimplementing it.
Per-instance overrides
<Widgets> takes components and chrome of its own, merged over the
factory’s:
function StorefrontPage() {
return (
<Widgets
items={items}
components={{ listing: FeaturedListing }}
chrome={{ wrapper: HeroRegion }}
/>
);
}components is a shallow merge of the two maps; chrome is resolved field by
field, so overriding wrapper keeps the factory’s item, suspense and
suspenseFallback.
meta: placing a widget without telling it where it is
A dashboard grid, a masonry board or a CMS page with per-block spans needs placement data. That data belongs to the wrapper: a widget renders the same at column 1 and at column 7.
meta is handed to chrome.item and is never spread into the widget’s props.
import type { WidgetItemComponent } from '@evanion/react-widget';
type GridMeta = { column: number; columnSpan?: number };
const GridItem: WidgetItemComponent<GridMeta> = ({
children,
meta,
...rest
}) => (
<div
{...rest}
style={{
gridColumn: `${meta?.column ?? 'auto'} / span ${meta?.columnSpan ?? 1}`,
}}
>
{children}
</div>
);
const { Widgets } = createWidgets({
components: { chart: Chart },
chrome: { item: GridItem },
});
<Widgets
items={[
{
id: 'today',
type: 'chart',
props: { metric: 'revenue' },
meta: { column: 1, columnSpan: 4 },
// meta: { colunm: 1 } ← compile error: not a key GridItem reads
},
]}
/>;Annotating chrome.item with the vocabulary it reads is what types every item’s
meta, at the top level and inside children. Without that, a misspelled key
compiles and the item is placed by whatever fallback the chrome applies — a
layout that looks deliberate.
There is no type argument to pass. createWidgets infers the vocabulary from
the chrome.item it is given: an annotated component, a plain function with an
annotated parameter object, or a memo()-wrapped one all work.
Typing meta removes the typo, not the narrowing. meta is optional on every
item, so a chrome that needs a key still writes the fallback for the item that
omits it — the ?? 'auto' above.
DefaultItem drops meta rather than forwarding it. React warns about an
unknown attribute for every key of an arbitrary object that reaches a DOM
element.
ctx: page-level data for every widget
<Widgets ctx={{ locale, currency }} items={items} />Every widget receives ctx as a prop. This exists instead of a context
provider: React’s react-server condition has no createContext, and the
package has to be importable from a Server Component.
ctx is the renderer’s to supply, so it is omitted from an item’s props
alongside children. A widget may declare it required without every item
repeating a value <Widgets> is going to pass anyway, and an item cannot
override it — ctx follows the props spread, and items are untrusted input.
The counterpart in @evanion/astro-widget is the same prop by the same name.
Suspense
By default every widget gets its own <Suspense> boundary, so one suspending
widget does not block its siblings. The fallback comes from
chrome.suspenseFallback and defaults to nothing.
const { Widgets } = createWidgets({
components: { report: AsyncReport },
chrome: { suspenseFallback: <Skeleton /> },
});The boundary lives in the renderer rather than in the item chrome, so replacing
chrome.item cannot silently remove it.
There is no default skeleton. A region is a dashboard grid for one consumer and a table of rows for the next, and one generic placeholder would be wrong in both.
Synchronous regions: chrome.suspense
A boundary costs more than its markers when the region is large. React’s
streaming SSR outlines any boundary it has not finished by the time the shell
passes progressiveChunkSize — 12,800 bytes by default — whether or not
anything in it suspended. The content is written to a trailing <div hidden>
and an inline <script>$RC(…)</script> moves it into place.
Measured over 150 synchronous items of about 1,000 bytes each, streamed with the default chunk size:
chrome.suspense | bytes | deferred boundaries | rows in the shell |
|---|---|---|---|
per-item | 139,233 | 145 | 5 of 150 |
none | 122,069 | 0 | 150 of 150 |
So for a region whose widgets are all synchronous, say so:
const { Widgets } = createWidgets({
components: { row: LedgerRow },
chrome: { suspense: 'none' },
});A client that does not run the inline scripts never sees outlined content. It is
in the HTML, inside <div hidden>, and $RC is what moves it — which covers
scripts disabled and a Content-Security-Policy rejecting inline script without a
nonce. Anything reading the HTML in document order sees placeholders where the
content should be, and the content at the bottom in completion order: a text
extraction, a reader-mode pass, a diffing snapshot test, curl | sed.
There is no detection and no heuristic. An async function component and
React.lazy are recognisable at runtime; a component calling use(promise) is
not, memo() hides both, and an async function downlevelled below ES2017
becomes a plain function. Guessing wrong drops the boundary from a widget that
does suspend, which is worse than paying for one that does not.
Under none a widget that suspends anyway suspends whatever boundary is above
the region, up to the page. Put your own <Suspense> around <Widgets> if that
should be the region rather than the page — which is also how you get one
boundary for the whole set, since there is no region-wide setting. One boundary
around the whole set outlines the whole set: over those same 150 items,
per-item keeps 5 rows in the shell and a single region boundary keeps none.
The host has a knob too: progressiveChunkSize on renderToPipeableStream
takes the deferral to zero, and renderToString never defers. That is the
framework’s entry.server to set, not the library’s.
Error boundaries
The package ships none. A React error boundary requires a class component, which
React does not expose under the react-server condition, so a default boundary
would put a 'use client' directive on the whole package and drag every widget
into the client bundle with it.
Add your own in a custom chrome.item, in your own 'use client' file:
'use client';
import { ErrorBoundary } from 'react-error-boundary';
export function SafeItem({ children, ...rest }) {
return (
<div {...rest}>
<ErrorBoundary fallback={<p>This widget failed.</p>}>
{children}
</ErrorBoundary>
</div>
);
}const { Widgets } = createWidgets({
components: { listing: ListingCard },
chrome: { item: SafeItem },
});The boundary is then client code and the widgets inside it are not, which is the split the package is arranged to allow.
Large regions
The renderer is eager: every item in the list is rendered, and there is no windowing option. Two recipes cover the cases that made people ask for one.
For server-rendered, SEO-relevant content, do not virtualize — put
content-visibility: auto on the item chrome. It removes the layout and paint
cost of off-screen blocks while leaving every one of them in the HTML:
.widget-item {
content-visibility: auto;
contain-intrinsic-size: auto 240px;
}For a client-side dashboard where the list is long enough to matter, virtualize
in your own 'use client' chrome.wrapper with @tanstack/react-virtual. The
wrapper receives items beside children, positionally aligned, so it measures
and windows by item and indexes into React.Children.toArray(children) — one
entry per top-level item, already rendered as elements. That keeps the
virtualizer a dependency of the application
rather than of this package. Only the top-level list of a region is ever a
candidate; nested items are not.