@wordpress/widget-primitives 0.1.1-next.v.202606191442.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE.md +788 -0
  3. package/README.md +76 -0
  4. package/build/components/widget-render/index.cjs +31 -0
  5. package/build/components/widget-render/index.cjs.map +7 -0
  6. package/build/components/widget-render/widget-render.cjs +50 -0
  7. package/build/components/widget-render/widget-render.cjs.map +7 -0
  8. package/build/hooks/index.cjs +31 -0
  9. package/build/hooks/index.cjs.map +7 -0
  10. package/build/hooks/use-widget-types.cjs +84 -0
  11. package/build/hooks/use-widget-types.cjs.map +7 -0
  12. package/build/index.cjs +34 -0
  13. package/build/index.cjs.map +7 -0
  14. package/build/tools/get-lazy-widget-component/get-lazy-widget-component.cjs +50 -0
  15. package/build/tools/get-lazy-widget-component/get-lazy-widget-component.cjs.map +7 -0
  16. package/build/tools/get-lazy-widget-component/index.cjs +31 -0
  17. package/build/tools/get-lazy-widget-component/index.cjs.map +7 -0
  18. package/build/types.cjs +19 -0
  19. package/build/types.cjs.map +7 -0
  20. package/build-module/components/widget-render/index.mjs +6 -0
  21. package/build-module/components/widget-render/index.mjs.map +7 -0
  22. package/build-module/components/widget-render/widget-render.mjs +25 -0
  23. package/build-module/components/widget-render/widget-render.mjs.map +7 -0
  24. package/build-module/hooks/index.mjs +6 -0
  25. package/build-module/hooks/index.mjs.map +7 -0
  26. package/build-module/hooks/use-widget-types.mjs +59 -0
  27. package/build-module/hooks/use-widget-types.mjs.map +7 -0
  28. package/build-module/index.mjs +8 -0
  29. package/build-module/index.mjs.map +7 -0
  30. package/build-module/tools/get-lazy-widget-component/get-lazy-widget-component.mjs +25 -0
  31. package/build-module/tools/get-lazy-widget-component/get-lazy-widget-component.mjs.map +7 -0
  32. package/build-module/tools/get-lazy-widget-component/index.mjs +6 -0
  33. package/build-module/tools/get-lazy-widget-component/index.mjs.map +7 -0
  34. package/build-module/types.mjs +1 -0
  35. package/build-module/types.mjs.map +7 -0
  36. package/build-types/components/widget-render/index.d.ts +2 -0
  37. package/build-types/components/widget-render/index.d.ts.map +1 -0
  38. package/build-types/components/widget-render/stories/index.story.d.ts +19 -0
  39. package/build-types/components/widget-render/stories/index.story.d.ts.map +1 -0
  40. package/build-types/components/widget-render/widget-render.d.ts +13 -0
  41. package/build-types/components/widget-render/widget-render.d.ts.map +1 -0
  42. package/build-types/hooks/index.d.ts +2 -0
  43. package/build-types/hooks/index.d.ts.map +1 -0
  44. package/build-types/hooks/use-widget-types.d.ts +17 -0
  45. package/build-types/hooks/use-widget-types.d.ts.map +1 -0
  46. package/build-types/index.d.ts +13 -0
  47. package/build-types/index.d.ts.map +1 -0
  48. package/build-types/tools/get-lazy-widget-component/get-lazy-widget-component.d.ts +12 -0
  49. package/build-types/tools/get-lazy-widget-component/get-lazy-widget-component.d.ts.map +1 -0
  50. package/build-types/tools/get-lazy-widget-component/index.d.ts +2 -0
  51. package/build-types/tools/get-lazy-widget-component/index.d.ts.map +1 -0
  52. package/build-types/types.d.ts +169 -0
  53. package/build-types/types.d.ts.map +1 -0
  54. package/package.json +72 -0
  55. package/src/components/widget-render/index.ts +1 -0
  56. package/src/components/widget-render/stories/index.story.tsx +356 -0
  57. package/src/components/widget-render/widget-render.tsx +44 -0
  58. package/src/hooks/index.ts +1 -0
  59. package/src/hooks/use-widget-types.ts +90 -0
  60. package/src/index.ts +21 -0
  61. package/src/stories/introduction.mdx +14 -0
  62. package/src/tools/get-lazy-widget-component/get-lazy-widget-component.ts +62 -0
  63. package/src/tools/get-lazy-widget-component/index.ts +1 -0
  64. package/src/types.ts +196 -0
@@ -0,0 +1,356 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import type { Meta, StoryObj } from '@storybook/react-vite';
5
+ import type { ComponentType } from 'react';
6
+
7
+ /**
8
+ * WordPress dependencies
9
+ */
10
+ // Form controls read these stylesheets, normally enqueued by WordPress.
11
+ // eslint-disable-next-line @wordpress/no-non-module-stylesheet-imports
12
+ import '@wordpress/components/build-style/style.css';
13
+ // eslint-disable-next-line @wordpress/no-non-module-stylesheet-imports
14
+ import '@wordpress/dataviews/build-style/style.css';
15
+ import { DataForm, useFormValidity } from '@wordpress/dataviews';
16
+ import type { Field, Form } from '@wordpress/dataviews';
17
+ import { Suspense, useId, useMemo, useState } from '@wordpress/element';
18
+ import { wordpress } from '@wordpress/icons';
19
+ import { Card, Icon, Stack } from '@wordpress/ui';
20
+
21
+ /**
22
+ * Internal dependencies
23
+ */
24
+ import { WidgetRender } from '..';
25
+ import type { WidgetRenderProps, WidgetType } from '../../../types';
26
+
27
+ /*
28
+ * Stories run without WordPress, so both halves are declared inline: the
29
+ * type through the `widgetType` prop, the component through
30
+ * `resolveWidgetModule`.
31
+ */
32
+
33
+ interface DemoAttributes {
34
+ greeting?: string;
35
+ world?: 'earth' | 'moon' | 'mars' | 'saturn';
36
+ }
37
+
38
+ const WORLDS: {
39
+ value: NonNullable< DemoAttributes[ 'world' ] >;
40
+ label: string;
41
+ emoji: string;
42
+ background: string;
43
+ }[] = [
44
+ {
45
+ value: 'earth',
46
+ label: 'World',
47
+ emoji: '🌍',
48
+ background: 'var(--wpds-color-background-surface-info-weak)',
49
+ },
50
+ {
51
+ value: 'moon',
52
+ label: 'Moon',
53
+ emoji: '🌕',
54
+ background: 'var(--wpds-color-background-surface-neutral)',
55
+ },
56
+ {
57
+ value: 'mars',
58
+ label: 'Mars',
59
+ emoji: '🔴',
60
+ background: 'var(--wpds-color-background-surface-caution-weak)',
61
+ },
62
+ {
63
+ value: 'saturn',
64
+ label: 'Saturn',
65
+ emoji: '🪐',
66
+ background: 'var(--wpds-color-background-surface-warning-weak)',
67
+ },
68
+ ];
69
+
70
+ function DemoWidget( {
71
+ attributes,
72
+ setAttributes,
73
+ }: WidgetRenderProps< DemoAttributes > ) {
74
+ const { greeting = 'Hello', world = 'earth' } = attributes ?? {};
75
+ const index = Math.max(
76
+ 0,
77
+ WORLDS.findIndex( ( entry ) => entry.value === world )
78
+ );
79
+ const current = WORLDS[ index ];
80
+
81
+ return (
82
+ <div
83
+ style={ {
84
+ background: current.background,
85
+ border: '1px solid var(--wpds-color-stroke-surface-neutral)',
86
+ borderRadius: 'var(--wpds-border-radius-md)',
87
+ color: 'var(--wpds-color-foreground-content-neutral)',
88
+ display: 'grid',
89
+ gap: 'var(--wpds-dimension-gap-md)',
90
+ justifyItems: 'center',
91
+ padding: 'var(--wpds-dimension-padding-xl)',
92
+ } }
93
+ >
94
+ <strong style={ { fontSize: '1.5em' } }>
95
+ { `${ greeting }, ${ current.label }! ${ current.emoji }` }
96
+ </strong>
97
+
98
+ { setAttributes && (
99
+ <button
100
+ onClick={ () =>
101
+ setAttributes( {
102
+ world: WORLDS[ ( index + 1 ) % WORLDS.length ]
103
+ .value,
104
+ } )
105
+ }
106
+ >
107
+ Next world
108
+ </button>
109
+ ) }
110
+ </div>
111
+ );
112
+ }
113
+
114
+ const demoWidgetType: WidgetType< DemoAttributes > = {
115
+ apiVersion: 1,
116
+ name: 'demo/hello-world',
117
+ title: 'Hello World',
118
+ description: 'Minimal widget that greets worlds near and far.',
119
+ icon: wordpress,
120
+ renderModule: 'demo/widgets/hello-world/render',
121
+ attributes: [
122
+ {
123
+ id: 'greeting',
124
+ label: 'Greeting',
125
+ type: 'text',
126
+ isValid: { required: true },
127
+ },
128
+ {
129
+ id: 'world',
130
+ label: 'World',
131
+ type: 'text',
132
+ elements: WORLDS.map( ( { value, label } ) => ( {
133
+ value,
134
+ label,
135
+ } ) ),
136
+ },
137
+ ] as Field< DemoAttributes >[],
138
+ example: {
139
+ attributes: { greeting: 'Hello', world: 'mars' },
140
+ },
141
+ };
142
+
143
+ // What `import( widget.renderModule )` resolves to on a WordPress page.
144
+ const resolveDemoModule = async () => ( {
145
+ default: DemoWidget as ComponentType< WidgetRenderProps< unknown > >,
146
+ } );
147
+
148
+ const meta: Meta< typeof WidgetRender > = {
149
+ title: 'Widget Primitives/WidgetRender',
150
+ component: WidgetRender,
151
+ tags: [ 'status-experimental' ],
152
+ parameters: {
153
+ componentStatus: {
154
+ status: 'use-with-caution',
155
+ whereUsed: 'global',
156
+ notes: 'The `@wordpress/widget-primitives` package is under active development: APIs may change without notice. Recommended for development workflows only; not production-ready.',
157
+ },
158
+ docs: {
159
+ description: {
160
+ component: `
161
+ \`WidgetRender\` is the host-agnostic entry point that renders a widget type: it resolves the widget's render component and mounts it with the current attributes.
162
+
163
+ A host provides three things:
164
+
165
+ - \`widgetType\`: the widget's metadata, as declared by its author. On a WordPress page it arrives through \`useWidgetTypes()\`.
166
+ - \`resolveWidgetModule\`: how the render component is loaded. Dynamic \`import()\` against an import map, eagerly enqueued script modules, or a custom resolver are all valid strategies.
167
+ - \`setAttributes\` (optional): grants the widget write access to its own attributes. Omit it and the widget renders read-only.
168
+ `,
169
+ },
170
+ },
171
+ },
172
+ };
173
+
174
+ export default meta;
175
+
176
+ function DefaultStory() {
177
+ const [ attributes, setAttributes ] = useState< DemoAttributes >( {
178
+ ...demoWidgetType.example?.attributes,
179
+ } );
180
+
181
+ return (
182
+ <Suspense fallback={ null }>
183
+ <WidgetRender< DemoAttributes >
184
+ widgetType={ demoWidgetType }
185
+ attributes={ attributes }
186
+ setAttributes={ ( next ) =>
187
+ setAttributes( ( prev ) => ( { ...prev, ...next } ) )
188
+ }
189
+ resolveWidgetModule={ resolveDemoModule }
190
+ />
191
+ </Suspense>
192
+ );
193
+ }
194
+
195
+ export const Default: StoryObj = {
196
+ render: () => <DefaultStory />,
197
+ parameters: {
198
+ docs: {
199
+ description: {
200
+ story: `
201
+ The minimal contract between a host and a widget:
202
+
203
+ - \`attributes\` flow into the widget as plain data.
204
+ - The widget writes back through \`setAttributes\`. Here, the "Next world" button updates the \`world\` attribute from inside the widget.
205
+
206
+ The primitive resolves the render component with \`lazy()\`, so the surrounding \`Suspense\` boundary, and with it the loading UI, is a host decision.
207
+ `,
208
+ },
209
+ },
210
+ },
211
+ };
212
+
213
+ function WidgetWithSettings() {
214
+ const [ attributes, setAttributes ] = useState< DemoAttributes >( {
215
+ ...demoWidgetType.example?.attributes,
216
+ } );
217
+
218
+ const fields = demoWidgetType.attributes as Field< DemoAttributes >[];
219
+
220
+ const form = useMemo< Form >(
221
+ () => ( {
222
+ layout: { type: 'regular', labelPosition: 'top' },
223
+ fields: fields.map( ( field ) => field.id ),
224
+ } ),
225
+ [ fields ]
226
+ );
227
+
228
+ const applyEdits = ( edits: Partial< DemoAttributes > ) =>
229
+ setAttributes( ( prev ) => ( { ...prev, ...edits } ) );
230
+
231
+ const { validity } = useFormValidity( attributes, fields, form );
232
+
233
+ return (
234
+ <div
235
+ style={ {
236
+ alignItems: 'start',
237
+ display: 'grid',
238
+ gap: 'var(--wpds-dimension-gap-xl)',
239
+ gridTemplateColumns: '2fr 1fr',
240
+ } }
241
+ >
242
+ <Suspense fallback={ null }>
243
+ <WidgetRender< DemoAttributes >
244
+ widgetType={ demoWidgetType }
245
+ attributes={ attributes }
246
+ resolveWidgetModule={ resolveDemoModule }
247
+ />
248
+ </Suspense>
249
+ <aside
250
+ style={ {
251
+ border: '1px solid var(--wpds-color-stroke-surface-neutral)',
252
+ borderRadius: 'var(--wpds-border-radius-md)',
253
+ padding: 'var(--wpds-dimension-padding-lg)',
254
+ } }
255
+ >
256
+ <DataForm< DemoAttributes >
257
+ data={ attributes }
258
+ fields={ fields }
259
+ form={ form }
260
+ validity={ validity }
261
+ onChange={ applyEdits }
262
+ />
263
+ </aside>
264
+ </div>
265
+ );
266
+ }
267
+
268
+ export const WithSettings: StoryObj = {
269
+ render: () => <WidgetWithSettings />,
270
+ parameters: {
271
+ docs: {
272
+ description: {
273
+ story: `
274
+ A widget type declares its settings as a dataviews \`Field[]\` under \`attributes\`. That single declaration is enough for a host to build a settings UI:
275
+
276
+ - The \`DataForm\` on the right is mounted straight from the schema, with no per-widget form wiring.
277
+ - Validation comes from the same source: the \`greeting\` field is marked as required, and \`useFormValidity\` surfaces the result in the form.
278
+ - Edits flow into the rendered widget on the left through the shared attributes state.
279
+
280
+ Any host can derive its settings UI this way, whatever shape it takes.
281
+ `,
282
+ },
283
+ },
284
+ },
285
+ };
286
+
287
+ function WidgetInHostChrome() {
288
+ const [ attributes, setAttributes ] = useState< DemoAttributes >( {
289
+ ...demoWidgetType.example?.attributes,
290
+ } );
291
+
292
+ const titleId = useId();
293
+
294
+ return (
295
+ <Card.Root
296
+ render={ <section /> }
297
+ aria-labelledby={ titleId }
298
+ style={ {
299
+ // Striped background to tell the chrome apart from the render.
300
+ background: `repeating-linear-gradient(
301
+ 45deg,
302
+ var(--wpds-color-background-surface-neutral),
303
+ var(--wpds-color-background-surface-neutral) 8px,
304
+ var(--wpds-color-background-surface-neutral-weak) 8px,
305
+ var(--wpds-color-background-surface-neutral-weak) 16px
306
+ )`,
307
+ maxWidth: 480,
308
+ } }
309
+ >
310
+ <Card.Header>
311
+ <Stack direction="row" align="center" gap="sm">
312
+ { demoWidgetType.icon && (
313
+ <span aria-hidden="true">
314
+ <Icon icon={ demoWidgetType.icon } />
315
+ </span>
316
+ ) }
317
+ <Card.Title id={ titleId } render={ <h3 /> }>
318
+ { demoWidgetType.title }
319
+ </Card.Title>
320
+ </Stack>
321
+ </Card.Header>
322
+ <Card.Content>
323
+ <Suspense fallback={ null }>
324
+ <WidgetRender< DemoAttributes >
325
+ widgetType={ demoWidgetType }
326
+ attributes={ attributes }
327
+ setAttributes={ ( next ) =>
328
+ setAttributes( ( prev ) => ( {
329
+ ...prev,
330
+ ...next,
331
+ } ) )
332
+ }
333
+ resolveWidgetModule={ resolveDemoModule }
334
+ />
335
+ </Suspense>
336
+ </Card.Content>
337
+ </Card.Root>
338
+ );
339
+ }
340
+
341
+ export const WithHostChrome: StoryObj = {
342
+ render: () => <WidgetInHostChrome />,
343
+ parameters: {
344
+ docs: {
345
+ description: {
346
+ story: `
347
+ Chrome belongs to the host: the widget describes itself through metadata, and each host decides how (and whether) to frame it.
348
+
349
+ In this story the chrome is a \`Card\`: its header reads the type's metadata (\`icon\`, \`title\`) and the card body frames the widget render.
350
+
351
+ The diagonal stripes mark the chrome's area; the solid panel inside is the widget render. The widget renders no header of its own; another host could place the same metadata elsewhere, or skip it.
352
+ `,
353
+ },
354
+ },
355
+ },
356
+ };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+
5
+ /**
6
+ * Internal dependencies
7
+ */
8
+ import { getLazyWidgetComponent } from '../../tools/get-lazy-widget-component';
9
+ import type { ResolveWidgetModule, WidgetType } from '../../types';
10
+
11
+ interface WidgetRenderProps< Item = unknown > {
12
+ widgetType: WidgetType< Item >;
13
+ attributes?: Item;
14
+ setAttributes?: ( next: Partial< Item > ) => void;
15
+ resolveWidgetModule: ResolveWidgetModule;
16
+ }
17
+
18
+ /*
19
+ * Resolves a widget type's `renderModule` via `resolveWidgetModule` and
20
+ * mounts the resulting component with the `attributes`/`setAttributes`
21
+ * render contract.
22
+ */
23
+ export function WidgetRender< Item = unknown >( {
24
+ widgetType,
25
+ attributes,
26
+ setAttributes,
27
+ resolveWidgetModule,
28
+ }: WidgetRenderProps< Item > ) {
29
+ const WidgetComponent = getLazyWidgetComponent(
30
+ widgetType.renderModule,
31
+ resolveWidgetModule
32
+ );
33
+
34
+ return (
35
+ <>
36
+ { /* Cached `lazy()` keyed by renderModule; identity is stable across renders. */ }
37
+ { /* eslint-disable-next-line react-hooks/static-components */ }
38
+ <WidgetComponent
39
+ attributes={ attributes }
40
+ setAttributes={ setAttributes }
41
+ />
42
+ </>
43
+ );
44
+ }
@@ -0,0 +1 @@
1
+ export { useWidgetTypes } from './use-widget-types';
@@ -0,0 +1,90 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { useEffect, useState } from '@wordpress/element';
5
+
6
+ /**
7
+ * Internal dependencies
8
+ */
9
+ import type { WidgetModuleRecord, WidgetName, WidgetType } from '../types';
10
+
11
+ /* `true` while records or their metadata imports are still resolving; hosts
12
+ must not treat a widget instance as missing until it is `false`. */
13
+ type UseWidgetTypesResult = readonly [ WidgetType[], boolean ];
14
+
15
+ /**
16
+ * Resolves widget types from host-supplied records.
17
+ *
18
+ * For each record it dynamically imports `widget_module` and merges the
19
+ * module's default export with the runtime fields (`name`, `renderModule`).
20
+ * Pass `null`/`undefined` while records are still loading.
21
+ *
22
+ * @param records Host-supplied records, or `null`/`undefined` while loading.
23
+ */
24
+ export function useWidgetTypes(
25
+ records: WidgetModuleRecord[] | null | undefined
26
+ ): UseWidgetTypesResult {
27
+ const [ widgetTypes, setWidgetTypes ] = useState< WidgetType[] >( [] );
28
+ const [ isResolvingWidgetTypes, setIsResolvingWidgetTypes ] =
29
+ useState( true );
30
+
31
+ useEffect( () => {
32
+ if ( records === null || records === undefined ) {
33
+ setIsResolvingWidgetTypes( true );
34
+ return;
35
+ }
36
+
37
+ if ( records.length === 0 ) {
38
+ setWidgetTypes( [] );
39
+ setIsResolvingWidgetTypes( false );
40
+ return;
41
+ }
42
+
43
+ let cancelled = false;
44
+ setIsResolvingWidgetTypes( true );
45
+
46
+ Promise.all(
47
+ records.map( async ( record ) => {
48
+ if ( ! record.widget_module ) {
49
+ return null;
50
+ }
51
+
52
+ try {
53
+ const module = await import(
54
+ /* webpackIgnore: true */ record.widget_module
55
+ );
56
+
57
+ if ( ! module?.default ) {
58
+ return null;
59
+ }
60
+
61
+ return {
62
+ ...( module.default as Partial< WidgetType > ),
63
+ name: record.name as WidgetName,
64
+ renderModule: record.render_module ?? '',
65
+ ...( record.presentation
66
+ ? { presentation: record.presentation }
67
+ : {} ),
68
+ } as WidgetType;
69
+ } catch {
70
+ return null;
71
+ }
72
+ } )
73
+ ).then( ( results ) => {
74
+ if ( cancelled ) {
75
+ return;
76
+ }
77
+
78
+ setWidgetTypes(
79
+ results.filter( ( t ): t is WidgetType => t !== null )
80
+ );
81
+ setIsResolvingWidgetTypes( false );
82
+ } );
83
+
84
+ return () => {
85
+ cancelled = true;
86
+ };
87
+ }, [ records ] );
88
+
89
+ return [ widgetTypes, isResolvingWidgetTypes ];
90
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Components
3
+ */
4
+ export { WidgetRender } from './components/widget-render';
5
+
6
+ /**
7
+ * Hooks
8
+ */
9
+ export { useWidgetTypes } from './hooks';
10
+
11
+ /**
12
+ * Types
13
+ */
14
+ export type {
15
+ WidgetName,
16
+ WidgetIcon,
17
+ WidgetType,
18
+ WidgetRenderProps,
19
+ ResolveWidgetModule,
20
+ WidgetModuleRecord,
21
+ } from './types';
@@ -0,0 +1,14 @@
1
+ import { Meta, Markdown } from '@storybook/addon-docs/blocks';
2
+ import ArchitectureDoc from '../../../../docs/explanations/architecture/dashboard-widgets.md?raw';
3
+ import pipelineDiagram from '../../../../docs/explanations/architecture/assets/dashboard-widgets-pipeline.svg';
4
+
5
+ {/* Use the locally served diagram so Storybook renders it offline. */}
6
+
7
+ <Meta title="Widget Primitives/Introduction" />
8
+
9
+ <Markdown>
10
+ { ArchitectureDoc.replace(
11
+ 'https://raw.githubusercontent.com/WordPress/gutenberg/HEAD/docs/explanations/architecture/assets/dashboard-widgets-pipeline.svg',
12
+ pipelineDiagram
13
+ ) }
14
+ </Markdown>
@@ -0,0 +1,62 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import type { ComponentType } from 'react';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import { lazy } from '@wordpress/element';
10
+
11
+ /**
12
+ * Internal dependencies
13
+ */
14
+ import type {
15
+ ResolveWidgetModule,
16
+ WidgetModule,
17
+ WidgetRenderProps,
18
+ } from '../../types';
19
+
20
+ type LazyWidgetComponent = ComponentType< WidgetRenderProps< unknown > >;
21
+
22
+ function isValidWidgetModule( module: unknown ): module is WidgetModule {
23
+ return (
24
+ typeof module === 'object' &&
25
+ module !== null &&
26
+ 'default' in module &&
27
+ typeof ( module as { default: unknown } ).default === 'function'
28
+ );
29
+ }
30
+
31
+ /*
32
+ * Cache keyed by `renderModule`. The lazy component must keep a stable
33
+ * identity across renders; rebuilding it inline (e.g. via `useMemo`) resets
34
+ * the Suspense boundary and the resolved module.
35
+ */
36
+ const componentCache = new Map< string, LazyWidgetComponent >();
37
+
38
+ /*
39
+ * Resolve a widget render module to a `lazy()` React component, cached by
40
+ * `renderModule` id so repeated calls return the same instance.
41
+ */
42
+ export function getLazyWidgetComponent(
43
+ renderModule: string,
44
+ resolveWidgetModule: ResolveWidgetModule
45
+ ): LazyWidgetComponent {
46
+ const cached = componentCache.get( renderModule );
47
+ if ( cached ) {
48
+ return cached;
49
+ }
50
+
51
+ const lazyComponent = lazy< LazyWidgetComponent >( async () => {
52
+ const module: unknown = await resolveWidgetModule( renderModule );
53
+ if ( ! isValidWidgetModule( module ) ) {
54
+ throw new Error( `Invalid widget module: ${ renderModule }` );
55
+ }
56
+
57
+ return module;
58
+ } );
59
+
60
+ componentCache.set( renderModule, lazyComponent );
61
+ return lazyComponent;
62
+ }
@@ -0,0 +1 @@
1
+ export { getLazyWidgetComponent } from './get-lazy-widget-component';