@bloklabs/react 1.2.6 → 1.4.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.
@@ -66,5 +66,5 @@ export function mergeBlokDefaults(
66
66
  merged.tools = { ...defaults.tools, ...config.tools };
67
67
  }
68
68
 
69
- return merged as UseBlokConfig;
69
+ return merged;
70
70
  }
package/src/types.ts CHANGED
@@ -6,7 +6,11 @@ import type { BlokConfig, Blok, EditorWidth } from '@/types';
6
6
  * React-only reactive `width` prop.
7
7
  *
8
8
  * Reactive props (sync after mount without recreation):
9
- * - `readOnly` — calls `editor.readOnly.set(value)`
9
+ * - `readOnly` — calls `editor.readOnly.set(value)`; the object form also
10
+ * syncs `hideControls` via `editor.readOnly.set(enabled, { hideControls })`
11
+ * - `hideToolbar` — calls `editor.toolbar.setHidden(value)`
12
+ * - `inlineToolbar` — calls `editor.tools.setInlineToolbar(value)` (arrays
13
+ * are compared by content, so a re-created literal is a no-op)
10
14
  * - `autofocus` — calls `editor.focus()` when changed to true
11
15
  * - `theme` — calls `editor.theme.set(value)`
12
16
  * - `width` — calls `editor.width.set(value)`
package/src/useBlok.ts CHANGED
@@ -11,7 +11,7 @@ import {
11
11
  } from './block-portal-registry';
12
12
  import { setRegistry, removeRegistry } from './registry-map';
13
13
  import { bindLiveToolConfigFunctions, buildLiveBlockConfig } from './live-tool-config';
14
- import { normalizeReadOnlyConfig } from '@bloklabs/core/adapters';
14
+ import { createEmittedEchoWindow, normalizeReadOnlyConfig } from '@bloklabs/core/adapters';
15
15
  import type { Blok } from '@/types';
16
16
  import type { UseBlokConfig } from './types';
17
17
 
@@ -70,7 +70,7 @@ const injectPortalRegistry = (tools: unknown, registry: BlockPortalRegistry): un
70
70
  typeof entry === 'function' ? { class: entry } : { ...(entry as Record<string, unknown>) };
71
71
 
72
72
  base.config = {
73
- ...((base.config as Record<string, unknown> | undefined) ?? {}),
73
+ ...((base.config) ?? {}),
74
74
  [BLOK_PORTAL_REGISTRY_CONFIG_KEY]: registry,
75
75
  [BLOK_TOOL_NAME_CONFIG_KEY]: name,
76
76
  };
@@ -124,6 +124,13 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
124
124
  // otherwise reset the caret). Declared here so the `onSave` wrapper below can
125
125
  // update it before the consumer's setState re-runs the data effect.
126
126
  const lastRenderedDataRef = useRef(config.data);
127
+ // Window of recently emitted `onSave` payloads. The baseline above only
128
+ // remembers the LAST one, but a host that persists on save and refetches can
129
+ // echo an EARLIER save after a newer one replaced the baseline (the user kept
130
+ // typing). Such a stale echo is still the editor's own output — re-rendering
131
+ // it would clobber the caret and the content typed since — so the data effect
132
+ // treats a match against ANY windowed payload as a no-op.
133
+ const echoWindowRef = useRef(createEmittedEchoWindow());
127
134
  const seededEditorRef = useRef<Blok | null>(null);
128
135
  // The `data` the live editor was actually constructed with. The seed gate in
129
136
  // the reactive-`data` effect compares the current prop against THIS (not mere
@@ -144,6 +151,9 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
144
151
  const liveRegistryRef = useRef<BlockPortalRegistry | null>(null);
145
152
  const wrappedToolsRef = useRef<unknown>(undefined);
146
153
  const pushedBlockConfigsRef = useRef<Map<string, Record<string, unknown>>>(new Map());
154
+ // Last-applied tool-level `toolbox` setting per tool name (dedupe baseline for
155
+ // the reactive toolbox effect below). Seeded from the construction-time tools.
156
+ const appliedToolboxRef = useRef<Map<string, unknown>>(new Map());
147
157
 
148
158
  // Main lifecycle effect
149
159
  useEffect(() => {
@@ -224,6 +234,12 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
224
234
  liveRegistryRef.current = registry;
225
235
  wrappedToolsRef.current = wrappedTools;
226
236
  pushedBlockConfigsRef.current = new Map();
237
+ appliedToolboxRef.current = new Map(
238
+ Object.entries((currentConfig.tools ?? {}) as Record<string, unknown>).map(([name, entry]) => [
239
+ name,
240
+ entry !== null && typeof entry === 'object' ? (entry as { toolbox?: unknown }).toolbox : undefined,
241
+ ])
242
+ );
227
243
 
228
244
  const blokConfig = {
229
245
  ...currentConfig,
@@ -249,6 +265,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
249
265
  // controlled consumer echoing it straight back into `data` is a no-op —
250
266
  // no redundant render(), no caret reset, no round-trip recursion.
251
267
  lastRenderedDataRef.current = args[0];
268
+ echoWindowRef.current.record(args[0]);
252
269
  configRef.current.onSave?.(...args);
253
270
  };
254
271
  }
@@ -269,7 +286,7 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
269
286
  };
270
287
  }
271
288
 
272
- const blok = new BlokRuntime(blokConfig) as unknown as Blok;
289
+ const blok = new BlokRuntime(blokConfig);
273
290
  state.editor = blok;
274
291
  // Remember what this editor was seeded with so the reactive-`data` effect can
275
292
  // tell a genuine post-construction change from the construction value itself.
@@ -358,14 +375,93 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
358
375
  }
359
376
  });
360
377
 
361
- // Reactive: readOnly (object form normalized so the effect dep is a stable boolean)
362
- const readOnlyEnabled = normalizeReadOnlyConfig(config.readOnly).enabled;
378
+ // Live tool-level `toolbox` setting. Toolbox membership was construction-only,
379
+ // which forced permission-style gating (`toolbox: false` for users who may not
380
+ // insert a tool) into `deps` — recreating the whole editor on a permission
381
+ // flip. Instead, diff each tool's `toolbox` setting against the last applied
382
+ // value on every render and push genuine changes through the runtime
383
+ // `tools.update` API (which reaches the live adapter and rebuilds the Toolbox
384
+ // UI). Applies only once the editor is ready — module APIs attach at isReady —
385
+ // and the ready transition re-renders, so a change made earlier is not lost.
386
+ useEffect(() => {
387
+ const state = stateRef.current;
388
+
389
+ if (state.editor === null || state.isDestroyed || !state.isEditorReady) {
390
+ return;
391
+ }
392
+
393
+ const latestTools = configRef.current.tools as Record<string, unknown> | undefined;
394
+
395
+ if (latestTools === null || typeof latestTools !== 'object') {
396
+ return;
397
+ }
398
+
399
+ for (const [name, entry] of Object.entries(latestTools)) {
400
+ // Only tools the editor was constructed with can be updated at runtime.
401
+ if (!appliedToolboxRef.current.has(name)) {
402
+ continue;
403
+ }
404
+
405
+ const latest =
406
+ entry !== null && typeof entry === 'object' ? (entry as { toolbox?: unknown }).toolbox : undefined;
407
+ const applied = appliedToolboxRef.current.get(name);
408
+
409
+ if (deepEqual(latest, applied)) {
410
+ continue;
411
+ }
412
+
413
+ appliedToolboxRef.current.set(name, latest);
414
+ (state.editor as unknown as {
415
+ tools?: { update: (toolName: string, config: Record<string, unknown>) => void };
416
+ }).tools?.update(name, { toolbox: latest });
417
+ }
418
+ });
419
+
420
+ // Reactive: readOnly (object form normalized so the effect deps are stable booleans).
421
+ // The object form additionally syncs `hideControls` through the options
422
+ // parameter of `readOnly.set` — a plain boolean prop keeps the historical
423
+ // boolean-only call so it never overwrites a construction-time object form.
424
+ const readOnlyIsObjectForm = typeof config.readOnly === 'object' && config.readOnly !== null;
425
+ const { enabled: readOnlyEnabled, hideControls: readOnlyHideControls } = normalizeReadOnlyConfig(config.readOnly);
363
426
  useEffect(() => {
364
427
  if (editor === null) {
365
428
  return;
366
429
  }
367
- void editor.readOnly.set(readOnlyEnabled);
368
- }, [editor, readOnlyEnabled]);
430
+
431
+ if (readOnlyIsObjectForm) {
432
+ void editor.readOnly.set(readOnlyEnabled, { hideControls: readOnlyHideControls });
433
+ } else {
434
+ void editor.readOnly.set(readOnlyEnabled);
435
+ }
436
+ }, [editor, readOnlyEnabled, readOnlyHideControls, readOnlyIsObjectForm]);
437
+
438
+ // Reactive: hideToolbar — `toolbar.setHidden` writes `config.hideToolbar`
439
+ // (read live by the toolbar's open guards) AND toggles the wrapper's
440
+ // toolbar-hidden attribute (the CSS hook that collapses the gutter), so a
441
+ // prop flip takes effect in place. Guarded on `undefined` so a consumer who
442
+ // never sets the prop gets no call at all (mirrors theme/width).
443
+ const { hideToolbar } = config;
444
+ useEffect(() => {
445
+ if (editor === null || hideToolbar === undefined) {
446
+ return;
447
+ }
448
+ editor.toolbar.setHidden(hideToolbar);
449
+ }, [editor, hideToolbar]);
450
+
451
+ // Reactive: inlineToolbar — `tools.setInlineToolbar` re-assigns every block
452
+ // tool's inline set and invalidates sanitize caches, so the change lands on
453
+ // the next selection without a re-render. The dep is a content serialization
454
+ // (boolean | string[]) so a re-created array literal with the same entries
455
+ // does not re-fire the setter.
456
+ const { inlineToolbar } = config;
457
+ const inlineToolbarKey = inlineToolbar === undefined ? undefined : JSON.stringify(inlineToolbar);
458
+ useEffect(() => {
459
+ if (editor === null || inlineToolbar === undefined) {
460
+ return;
461
+ }
462
+ editor.tools.setInlineToolbar(inlineToolbar);
463
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- inlineToolbar is deliberately depped by CONTENT (inlineToolbarKey) so a re-created array literal with the same entries does not thrash the setter
464
+ }, [editor, inlineToolbarKey]);
369
465
 
370
466
  // Reactive: autofocus
371
467
  const { autofocus } = config;
@@ -394,6 +490,72 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
394
490
  editor.width.set(width);
395
491
  }, [editor, width]);
396
492
 
493
+ // Reactive: style.tokens
494
+ //
495
+ // Theme tokens were construction-only, so a host with a live light/dark
496
+ // toggle had to bump `deps` (destroying the editor, losing caret/history) or
497
+ // hand-write the global stylesheet Blok already injects. Push genuine changes
498
+ // through the runtime `tokens` API instead, deep-equal–deduped because
499
+ // `tokens` is almost always a fresh object literal each render.
500
+ const styleTokens = config.style?.tokens;
501
+ /*
502
+ * Seeded with the mount-time value: construction already injected those
503
+ * tokens, so the first effect run is a no-op and only genuine changes push.
504
+ */
505
+ const appliedTokensRef = useRef<Record<string, string> | undefined>(config.style?.tokens);
506
+
507
+ useEffect(() => {
508
+ if (editor === null || styleTokens === undefined) {
509
+ return;
510
+ }
511
+
512
+ if (deepEqual(styleTokens, appliedTokensRef.current)) {
513
+ return;
514
+ }
515
+
516
+ appliedTokensRef.current = styleTokens;
517
+ editor.tokens.set(styleTokens);
518
+ }, [editor, styleTokens]);
519
+
520
+ // Reactive: i18n
521
+ //
522
+ // `config.i18n` was read once during the editor's boot and never again, so a
523
+ // host driving a language switcher from React state had no correct option:
524
+ // accept a permanently stale UI, or bump `deps` and destroy the editor
525
+ // mid-typing. Push genuine changes through the runtime `i18n.update` API
526
+ // instead, deep-equal–deduped because `i18n` is almost always a fresh object
527
+ // literal each render (a `useMemo`d message map re-created on locale change
528
+ // is the whole point of the prop).
529
+ //
530
+ // `defaultLocale` is deliberately not forwarded: it only decides the
531
+ // fallback while resolving the INITIAL locale, so it is inert after mount.
532
+ const { i18n } = config;
533
+ /*
534
+ * Seeded with the mount-time value: construction already applied it, so the
535
+ * first effect run is a no-op and only genuine changes push.
536
+ */
537
+ const appliedI18nRef = useRef<UseBlokConfig['i18n']>(config.i18n);
538
+
539
+ useEffect(() => {
540
+ if (editor === null || i18n === undefined) {
541
+ return;
542
+ }
543
+
544
+ if (deepEqual(i18n, appliedI18nRef.current)) {
545
+ return;
546
+ }
547
+
548
+ appliedI18nRef.current = i18n;
549
+
550
+ const { locale, messages, direction } = i18n;
551
+
552
+ void editor.i18n.update({
553
+ ...(locale === undefined ? {} : { locale }),
554
+ ...(messages === undefined ? {} : { messages }),
555
+ ...(direction === undefined ? {} : { direction }),
556
+ });
557
+ }, [editor, i18n]);
558
+
397
559
  // Reactive: placeholder
398
560
  const { placeholder } = config;
399
561
  useEffect(() => {
@@ -441,6 +603,8 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
441
603
  // A render still pending on a PRIOR editor is moot for this fresh instance;
442
604
  // clear it so its content can't dedupe a needed render on the new editor.
443
605
  pendingDataRef.current = undefined;
606
+ // Payloads emitted by a PRIOR editor are likewise moot for this one.
607
+ echoWindowRef.current.clear();
444
608
 
445
609
  if (deepEqual(data, constructedDataRef.current)) {
446
610
  lastRenderedDataRef.current = data;
@@ -456,6 +620,17 @@ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok
456
620
  return;
457
621
  }
458
622
 
623
+ // Skip stale echoes: content the editor itself emitted via `onSave` recently,
624
+ // arriving back reshaped (fresh envelope, stripped ids) and/or late — after a
625
+ // newer save already replaced the baseline above.
626
+ if (echoWindowRef.current.matches(data)) {
627
+ return;
628
+ }
629
+
630
+ // Genuinely external content takes over: earlier emitted payloads are moot,
631
+ // and keeping them could wrongly no-op a later deliberate revert to one.
632
+ echoWindowRef.current.clear();
633
+
459
634
  // Mark the content in-flight, but DON'T advance the success baseline until the
460
635
  // render actually resolves. A failed render leaves the editor on its last
461
636
  // successful content, so the baseline must keep pointing there — otherwise a
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState, type RefObject } from 'react';
2
+ import { Blok as BlokRuntime } from '@bloklabs/core';
3
+
4
+ /** Options accepted by {@link useBlokReady}. */
5
+ export interface UseBlokReadyOptions {
6
+ /**
7
+ * Restrict the wait to editors mounted inside this element. Accepts the ref
8
+ * you already hold on the container (read on every notification, so a ref
9
+ * that attaches after the first render is picked up).
10
+ *
11
+ * Omit it to observe every editor on the page. Passing it while it still
12
+ * resolves to null reports NOT ready — an unresolved scope must never fall
13
+ * back to the page-global one.
14
+ */
15
+ within?: RefObject<Element | null> | Element | null;
16
+ /**
17
+ * `'ready'` (default) settles when each editor has finished booting.
18
+ * `'rendered'` also waits for its content to be in the DOM and re-arms on
19
+ * every post-boot re-render.
20
+ */
21
+ settleOn?: 'ready' | 'rendered';
22
+ }
23
+
24
+ /**
25
+ * Live readiness of the Blok editors in a DOM scope.
26
+ *
27
+ * Returns `false` until every editor inside `within` is settled, and follows
28
+ * the scope afterwards: editors mounted later re-close the gate, and with
29
+ * `settleOn: 'rendered'` so does each re-render. A scope holding no editors is
30
+ * ready, so callers need no "nothing to wait for" special case.
31
+ *
32
+ * The first render always reports `false` — the scope element is not attached
33
+ * yet, and over-waiting is safe while under-waiting is a bug.
34
+ * @param options - scope and readiness depth
35
+ */
36
+ export function useBlokReady(options: UseBlokReadyOptions = {}): boolean {
37
+ const { within, settleOn } = options;
38
+ const [ready, setReady] = useState(false);
39
+
40
+ useEffect(() => {
41
+ const read = (): void => {
42
+ const scope = within instanceof Element ? within : (within?.current ?? null);
43
+
44
+ // A scope that was asked for but has not resolved yet is NOT the global
45
+ // scope — reporting ready here would gate on the wrong set of editors.
46
+ setReady(within !== undefined && scope === null
47
+ ? false
48
+ : BlokRuntime.readyState({
49
+ within: scope,
50
+ settleOn,
51
+ }).ready);
52
+ };
53
+
54
+ read();
55
+
56
+ return BlokRuntime.subscribeReady(read);
57
+ }, [within, settleOn]);
58
+
59
+ return ready;
60
+ }
@@ -0,0 +1,38 @@
1
+ import { createElement, Fragment, useMemo } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import { blocksToViewNodes } from '@bloklabs/core/view';
4
+ import type { BlocksToHtmlOptions } from '@bloklabs/core/view';
5
+ import type { LooseOutputData, OutputData } from '@bloklabs/core';
6
+
7
+ import { viewNodesToReact } from './view-nodes-to-react';
8
+
9
+ /**
10
+ * Render a saved Blok document to React elements with NO wrapper element —
11
+ * a Fragment of the block elements, for placement inside `<label>`s, table
12
+ * cells, and other slots where an extra `<div>` is invalid or unwanted.
13
+ *
14
+ * Synchronous and effect-free (SSR-safe); memoized on the data reference and
15
+ * the individual option values.
16
+ *
17
+ * @experimental Built on the `@experimental` ViewNode tree of
18
+ * `@bloklabs/core/view` — not frozen until a second framework adapter
19
+ * consumes it.
20
+ * @param data - saved document (strict or loose wire shape; nullish tolerated)
21
+ * @param options - schema / custom renderers / unknown-block policy
22
+ */
23
+ export const useBlokView = (
24
+ data: OutputData | LooseOutputData | null | undefined,
25
+ options?: BlocksToHtmlOptions
26
+ ): ReactNode => {
27
+ const schema = options?.schema;
28
+ const renderers = options?.renderers;
29
+ const onUnknownBlock = options?.onUnknownBlock;
30
+
31
+ return useMemo(() => {
32
+ return createElement(
33
+ Fragment,
34
+ null,
35
+ ...viewNodesToReact(blocksToViewNodes(data, { schema, renderers, onUnknownBlock }))
36
+ );
37
+ }, [data, schema, renderers, onUnknownBlock]);
38
+ };
@@ -0,0 +1,138 @@
1
+ /**
2
+ * ViewNode → React element mapping for the synchronous view bindings.
3
+ * Real elements via `createElement` recursion — never `dangerouslySetInnerHTML`.
4
+ */
5
+ import { createElement } from 'react';
6
+ import type { CSSProperties, ReactNode } from 'react';
7
+ import type { ViewNode } from '@bloklabs/core/view';
8
+
9
+ /** HTML void elements — must never receive children. */
10
+ const VOID_ELEMENTS = new Set([
11
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
12
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
13
+ ]);
14
+
15
+ /**
16
+ * Presence-valued HTML attributes mapped to boolean React props. `checked`
17
+ * is handled separately (→ `defaultChecked`, so React does not treat the
18
+ * input as controlled).
19
+ */
20
+ const BOOLEAN_ATTRIBUTES = new Set([
21
+ 'disabled', 'open', 'controls', 'autoplay', 'loop', 'muted', 'required',
22
+ 'multiple', 'playsinline', 'reversed', 'hidden', 'default', 'novalidate',
23
+ 'allowfullscreen', 'itemscope',
24
+ ]);
25
+
26
+ /** HTML attribute name → React prop name (beyond the identity mapping). */
27
+ const ATTRIBUTE_NAME_MAP: Record<string, string> = {
28
+ class: 'className',
29
+ for: 'htmlFor',
30
+ colspan: 'colSpan',
31
+ rowspan: 'rowSpan',
32
+ tabindex: 'tabIndex',
33
+ readonly: 'readOnly',
34
+ srcset: 'srcSet',
35
+ srclang: 'srcLang',
36
+ datetime: 'dateTime',
37
+ autoplay: 'autoPlay',
38
+ playsinline: 'playsInline',
39
+ novalidate: 'noValidate',
40
+ allowfullscreen: 'allowFullScreen',
41
+ crossorigin: 'crossOrigin',
42
+ referrerpolicy: 'referrerPolicy',
43
+ contenteditable: 'contentEditable',
44
+ spellcheck: 'spellCheck',
45
+ maxlength: 'maxLength',
46
+ minlength: 'minLength',
47
+ usemap: 'useMap',
48
+ itemscope: 'itemScope',
49
+ };
50
+
51
+ /**
52
+ * Convert a sanitized `style` attribute string to a React style object
53
+ * (React only accepts object styles). Custom properties keep their raw name;
54
+ * everything else is camelCased.
55
+ * @param css - style attribute text
56
+ */
57
+ const styleStringToObject = (css: string): CSSProperties => {
58
+ const style: Record<string, string> = {};
59
+
60
+ for (const declaration of css.split(';')) {
61
+ const separator = declaration.indexOf(':');
62
+
63
+ if (separator === -1) {
64
+ continue;
65
+ }
66
+
67
+ const property = declaration.slice(0, separator).trim();
68
+ const value = declaration.slice(separator + 1).trim();
69
+
70
+ if (property === '' || value === '') {
71
+ continue;
72
+ }
73
+
74
+ const key = property.startsWith('--')
75
+ ? property
76
+ : property.toLowerCase().replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase());
77
+
78
+ style[key] = value;
79
+ }
80
+
81
+ return style;
82
+ };
83
+
84
+ /**
85
+ * Map one ViewNode attribute record to React props.
86
+ * @param attrs - sanitized attributes from the view tree
87
+ * @param key - sibling index used as the React key
88
+ */
89
+ const attrsToProps = (attrs: Record<string, string>, key: number): Record<string, unknown> => {
90
+ const props: Record<string, unknown> = { key };
91
+
92
+ for (const [name, value] of Object.entries(attrs)) {
93
+ if (name === 'style') {
94
+ props.style = styleStringToObject(value);
95
+ continue;
96
+ }
97
+
98
+ if (name === 'checked') {
99
+ props.defaultChecked = true;
100
+ continue;
101
+ }
102
+
103
+ if (name === 'value') {
104
+ props.defaultValue = value;
105
+ continue;
106
+ }
107
+
108
+ if (BOOLEAN_ATTRIBUTES.has(name)) {
109
+ props[ATTRIBUTE_NAME_MAP[name] ?? name] = true;
110
+ continue;
111
+ }
112
+
113
+ props[ATTRIBUTE_NAME_MAP[name] ?? name] = value;
114
+ }
115
+
116
+ return props;
117
+ };
118
+
119
+ /**
120
+ * Map a list of ViewNodes to React nodes. Element keys come from the sibling
121
+ * index (the tree is static per render).
122
+ * @param nodes - view tree siblings
123
+ */
124
+ export const viewNodesToReact = (nodes: ViewNode[]): ReactNode[] => {
125
+ return nodes.map((node, index): ReactNode => {
126
+ if ('text' in node) {
127
+ return node.text;
128
+ }
129
+
130
+ const props = attrsToProps(node.attrs, index);
131
+
132
+ if (VOID_ELEMENTS.has(node.tag) || node.children.length === 0) {
133
+ return createElement(node.tag, props);
134
+ }
135
+
136
+ return createElement(node.tag, props, ...viewNodesToReact(node.children));
137
+ });
138
+ };