@bloklabs/react 1.2.1 → 1.2.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bloklabs/react",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "React bindings for the Blok editor",
5
5
  "license": "Apache-2.0",
6
6
  "author": "JackUait",
@@ -33,7 +33,7 @@
33
33
  "build": "yarn run -T vite build --mode production"
34
34
  },
35
35
  "peerDependencies": {
36
- "@bloklabs/core": "^1.2.1",
36
+ "@bloklabs/core": "^1.2.3",
37
37
  "react": "^18.0.0 || ^19.0.0",
38
38
  "react-dom": "^18.0.0 || ^19.0.0"
39
39
  }
@@ -0,0 +1,58 @@
1
+ import { Component, useSyncExternalStore, type ReactElement, type ReactNode } from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import type { BlockPortalRegistry } from './block-portal-registry';
4
+
5
+ interface BoundaryState {
6
+ failed: boolean;
7
+ }
8
+
9
+ /**
10
+ * Wraps a single block's render so a throw inside it can't tear down the whole
11
+ * editor's render tree (only the broken block goes blank).
12
+ */
13
+ class BlockErrorBoundary extends Component<{ children: ReactNode }, BoundaryState> {
14
+ public state: BoundaryState = { failed: false };
15
+
16
+ public static getDerivedStateFromError(): BoundaryState {
17
+ return { failed: true };
18
+ }
19
+
20
+ public componentDidCatch(): void {
21
+ // Swallowed: one broken block must not unmount its siblings.
22
+ }
23
+
24
+ public render(): ReactNode {
25
+ return this.state.failed ? null : this.props.children;
26
+ }
27
+ }
28
+
29
+ export interface BlockPortalHostProps {
30
+ registry: BlockPortalRegistry;
31
+ }
32
+
33
+ /**
34
+ * The single shared render tree that portals each registered React block into
35
+ * its Blok-owned host element. Mounted once per editor (by `BlokContent`), it
36
+ * subscribes to the registry and emits one `createPortal` per entry — so all
37
+ * blocks share one reconciler and the surrounding app's React context
38
+ * (`createPortal` preserves the component tree's context, not the DOM target's).
39
+ */
40
+ export function BlockPortalHost({ registry }: BlockPortalHostProps): ReactElement {
41
+ const entries = useSyncExternalStore(registry.subscribe, registry.getSnapshot, registry.getSnapshot);
42
+
43
+ return (
44
+ <>
45
+ {Array.from(entries.entries()).map(([id, entry]) => {
46
+ const BlockComponent = entry.component;
47
+
48
+ return createPortal(
49
+ <BlockErrorBoundary>
50
+ <BlockComponent {...entry.props} />
51
+ </BlockErrorBoundary>,
52
+ entry.hostEl,
53
+ id
54
+ );
55
+ })}
56
+ </>
57
+ );
58
+ }
@@ -1,5 +1,7 @@
1
1
  import { useEffect, useRef, forwardRef } from 'react';
2
2
  import { getHolder } from './holder-map';
3
+ import { getRegistry } from './registry-map';
4
+ import { BlockPortalHost } from './BlockPortalHost';
3
5
  import type { BlokContentProps } from './types';
4
6
 
5
7
  /**
@@ -41,6 +43,16 @@ export const BlokContent = forwardRef<HTMLDivElement, BlokContentProps>(
41
43
  };
42
44
  }, [editor]);
43
45
 
44
- return <div ref={setRefs} {...divProps} />;
46
+ // The portal host renders the editor's `createReactBlock` tools INSIDE this
47
+ // component tree (via createPortal into each Blok-owned host element), so
48
+ // app-level React context flows into block tools with no bridge. It emits
49
+ // no DOM at this position, leaving the imperatively-adopted holder alone.
50
+ const registry = editor === null ? undefined : getRegistry(editor);
51
+
52
+ return (
53
+ <div ref={setRefs} {...divProps}>
54
+ {registry === undefined ? null : <BlockPortalHost registry={registry} />}
55
+ </div>
56
+ );
45
57
  }
46
58
  );
@@ -43,6 +43,14 @@ export interface BlokEditorProps
43
43
  * When any value changes, the editor is destroyed and recreated. Keep each
44
44
  * value referentially stable (primitives or useMemo-stable objects) — a dep
45
45
  * whose identity changes every render recreates the editor each time.
46
+ *
47
+ * Tool configs do NOT belong here — reserve deps for true identity changes
48
+ * (e.g. the document id). FUNCTIONS inside tool configs are re-bound to the
49
+ * latest render's closure automatically (an inline `uploadByFile` with a
50
+ * fresh identity each render reaches the editor live), and non-function
51
+ * config VALUES of `createReactBlock` tools (permissions, URLs, locale…) are
52
+ * pushed to mounted blocks in place when they change. Neither requires
53
+ * recreation or `useState` identity-freezing.
46
54
  */
47
55
  deps?: DependencyList;
48
56
  /** Test id forwarded to the editor container element (via data-testid). */
@@ -0,0 +1,145 @@
1
+ import type { ComponentType } from 'react';
2
+
3
+ /**
4
+ * Tool-config key carrying the editor's portal registry into a
5
+ * `createReactBlock` tool. The tool is constructed by CORE (outside any React
6
+ * render), so it cannot read context — `useBlok` injects the editor-scoped
7
+ * registry through each react-block tool's `config`, and the tool reads it here.
8
+ */
9
+ export const BLOK_PORTAL_REGISTRY_CONFIG_KEY = '__blokPortalRegistry';
10
+
11
+ /**
12
+ * Tool-config key carrying the name the tool was registered under in the
13
+ * consumer's `tools` map (injected next to the registry). The factory needs it
14
+ * to associate portal entries with their tool so live config updates can be
15
+ * routed; it is stripped (with the registry key) before the sanitized config
16
+ * reaches the component.
17
+ */
18
+ export const BLOK_TOOL_NAME_CONFIG_KEY = '__blokToolName';
19
+
20
+ /**
21
+ * One mounted React block in the portal registry. `hostEl` is the Blok-owned
22
+ * `toolRenderedElement` (tagged `data-blok-mutation-free`) the component is
23
+ * portaled into; `props` is replaced immutably by `setProps` so the mounted
24
+ * component re-renders in place (no remount).
25
+ */
26
+ export interface BlockPortalEntry {
27
+ hostEl: HTMLElement;
28
+ component: ComponentType<Record<string, unknown>>;
29
+ props: Record<string, unknown>;
30
+ /** Name the tool is registered under — routes per-tool live config updates. */
31
+ toolName?: string;
32
+ }
33
+
34
+ /**
35
+ * Per-editor registry of React blocks. A single shared `BlockPortalHost`
36
+ * renders one `createPortal` per entry, so every block lives in ONE React tree
37
+ * (shared context, one reconciler) — the TipTap ReactNodeViewRenderer pattern,
38
+ * not a `createRoot` per block.
39
+ *
40
+ * Shaped as an external store (`subscribe`/`getSnapshot`) so the host can read
41
+ * it through `useSyncExternalStore`. The snapshot is immutable: every mutation
42
+ * replaces it, and `getSnapshot` is referentially stable between mutations.
43
+ */
44
+ export interface BlockPortalRegistry {
45
+ /**
46
+ * Register (or replace) the entry for `id`. Idempotent: a second register
47
+ * for the same id replaces the entry rather than mounting a duplicate.
48
+ */
49
+ register(id: string, entry: BlockPortalEntry): void;
50
+ /** Remove the entry for `id`. Safe (no-op) when absent. */
51
+ unregister(id: string): void;
52
+ /** Merge `props` into the entry's props (in-place update, no remount). */
53
+ setProps(id: string, props: Record<string, unknown>): void;
54
+ /**
55
+ * Replace the `config` prop of every entry registered under `toolName`, and
56
+ * remember it so entries registered LATER start from this latest config
57
+ * instead of their construction-time snapshot.
58
+ */
59
+ setToolConfig(toolName: string, config: Record<string, unknown>): void;
60
+ /** Subscribe to snapshot changes; returns the unsubscribe function. */
61
+ subscribe(listener: () => void): () => void;
62
+ /** Current immutable snapshot (stable identity until the next mutation). */
63
+ getSnapshot(): ReadonlyMap<string, BlockPortalEntry>;
64
+ }
65
+
66
+ /**
67
+ * Create a fresh portal registry. One per editor instance (associated via the
68
+ * registry map, mirroring the holder WeakMap), so multiple editors on a page
69
+ * never cross-render each other's blocks.
70
+ */
71
+ export const createBlockPortalRegistry = (): BlockPortalRegistry => {
72
+ const state = {
73
+ snapshot: new Map<string, BlockPortalEntry>() as ReadonlyMap<string, BlockPortalEntry>,
74
+ };
75
+ const listeners = new Set<() => void>();
76
+ /** Latest per-tool config, applied to mounted entries and future registers. */
77
+ const toolConfigs = new Map<string, Record<string, unknown>>();
78
+
79
+ const commit = (next: Map<string, BlockPortalEntry>): void => {
80
+ state.snapshot = next;
81
+ listeners.forEach(listener => listener());
82
+ };
83
+
84
+ return {
85
+ register(id: string, entry: BlockPortalEntry): void {
86
+ const next = new Map(state.snapshot);
87
+ const liveConfig = entry.toolName === undefined ? undefined : toolConfigs.get(entry.toolName);
88
+
89
+ next.set(
90
+ id,
91
+ liveConfig === undefined ? entry : { ...entry, props: { ...entry.props, config: liveConfig } }
92
+ );
93
+ commit(next);
94
+ },
95
+ unregister(id: string): void {
96
+ if (!state.snapshot.has(id)) {
97
+ return;
98
+ }
99
+
100
+ const next = new Map(state.snapshot);
101
+
102
+ next.delete(id);
103
+ commit(next);
104
+ },
105
+ setProps(id: string, props: Record<string, unknown>): void {
106
+ const current = state.snapshot.get(id);
107
+
108
+ if (current === undefined) {
109
+ return;
110
+ }
111
+
112
+ const next = new Map(state.snapshot);
113
+
114
+ next.set(id, { ...current, props: { ...current.props, ...props } });
115
+ commit(next);
116
+ },
117
+ setToolConfig(toolName: string, config: Record<string, unknown>): void {
118
+ toolConfigs.set(toolName, config);
119
+
120
+ const matching = [...state.snapshot].filter(([, entry]) => entry.toolName === toolName);
121
+
122
+ if (matching.length === 0) {
123
+ return;
124
+ }
125
+
126
+ const next = new Map(state.snapshot);
127
+
128
+ for (const [id, entry] of matching) {
129
+ next.set(id, { ...entry, props: { ...entry.props, config } });
130
+ }
131
+
132
+ commit(next);
133
+ },
134
+ subscribe(listener: () => void): () => void {
135
+ listeners.add(listener);
136
+
137
+ return (): void => {
138
+ listeners.delete(listener);
139
+ };
140
+ },
141
+ getSnapshot(): ReadonlyMap<string, BlockPortalEntry> {
142
+ return state.snapshot;
143
+ },
144
+ };
145
+ };
@@ -0,0 +1,346 @@
1
+ import { useEffect, useRef, type ComponentType, type ReactElement } from 'react';
2
+ import { flushSync } from 'react-dom';
3
+ import { DATA_ATTR, deepEqual, fillDefaults, mountChildBlocks, type PropSchema } from '@bloklabs/core/adapters';
4
+ import {
5
+ BLOK_PORTAL_REGISTRY_CONFIG_KEY,
6
+ BLOK_TOOL_NAME_CONFIG_KEY,
7
+ type BlockPortalRegistry,
8
+ } from './block-portal-registry';
9
+ import type { BlockAPI } from '@/types/api';
10
+ import type { BlockToolConstructorOptions, BlockToolData, ToolboxConfig } from '@/types/tools';
11
+
12
+ export type { PropSchema, PropSchemaEntry } from '@bloklabs/core/adapters';
13
+
14
+ /** Adapter-internal config keys, stripped before the config reaches the component. */
15
+ const INTERNAL_CONFIG_KEYS: readonly string[] = [
16
+ BLOK_PORTAL_REGISTRY_CONFIG_KEY,
17
+ BLOK_TOOL_NAME_CONFIG_KEY,
18
+ ];
19
+
20
+ /** Props handed to a React block's `component` (the only data write path is `commit`). */
21
+ export interface ReactBlockRenderProps<Data, Config = Record<string, unknown>> {
22
+ /** Frozen snapshot of the block data. Re-rendered with a new value on change; never mutate. */
23
+ data: Readonly<Data>;
24
+ /** The ONLY data write path: merge a partial patch and sync once. */
25
+ commit: (patch: Partial<Data>) => void;
26
+ /** This block's per-block API (id, connection methods, dispatchChange…). */
27
+ block: BlockAPI;
28
+ /**
29
+ * Read-only flag. Render disabled/inert UI when true (e.g. drop
30
+ * `contentEditable`, hide controls). Toggled IN PLACE by core's read-only
31
+ * switch — the component re-renders without remounting, so ephemeral state
32
+ * survives. Honor it: a block that ignores `readOnly` stays interactive when
33
+ * the editor is read-only (same contract as a vanilla tool's `setReadOnly`).
34
+ */
35
+ readOnly: boolean;
36
+ /**
37
+ * The tool's `config` from the consumer's `tools` map (adapter-internal keys
38
+ * stripped) — the first-class channel for host props (permissions, CDN/upload
39
+ * URLs, locale…). No context provider needed. Under `useBlok`/`BlokEditor`
40
+ * this prop is LIVE: functions always call the latest render's closure, and
41
+ * changed non-function values are pushed to mounted blocks in place — so
42
+ * config values never belong in `deps`.
43
+ */
44
+ config: Readonly<Partial<Config>>;
45
+ /** Engine-owned child slot — render `<BlockChildren />` for a container block. */
46
+ BlockChildren: ComponentType;
47
+ }
48
+
49
+ /** Spec for {@link createReactBlock}. */
50
+ export interface CreateReactBlockSpec<Data = BlockToolData, Config = Record<string, unknown>> {
51
+ /** Tool type name (registered key). */
52
+ type: string;
53
+ /** Optional toolbox entry. */
54
+ toolbox?: ToolboxConfig;
55
+ /** Declarative defaults that also define the exact `save()` key set. */
56
+ propSchema: PropSchema;
57
+ /** The component rendered for each block instance. */
58
+ component: ComponentType<ReactBlockRenderProps<Data, Config>>;
59
+ /** Optional lifecycle callbacks mapped from Blok's block hooks. */
60
+ onRendered?: (block: BlockAPI) => void;
61
+ onMoved?: (block: BlockAPI) => void;
62
+ onRemoved?: (block: BlockAPI) => void;
63
+ }
64
+
65
+ /**
66
+ * Author a first-party React block. Returns a `BlockToolConstructable`
67
+ * registered exactly like a vanilla tool (`tools: { type: createReactBlock(...) }`).
68
+ *
69
+ * The factory owns the host element (`data-blok-mutation-free`), a frozen
70
+ * defaults-filled data mirror, and the entry props the component reads. It
71
+ * portals the component into the host via the editor's shared portal registry
72
+ * (the TipTap `ReactNodeViewRenderer` pattern) — every block renders inside the
73
+ * SAME React tree that renders `BlokContent`, so app-level context (themes,
74
+ * design-system providers, stores) flows into blocks with no bridge. Blok's
75
+ * block lifecycle maps to React:
76
+ * - `render()` creates the host and registers the portal entry (returned sync).
77
+ * - `setData()` dedups, pushes the new snapshot, and flushes React's commit
78
+ * before resolving `true` — defeating core's default remount.
79
+ * - `save()` returns the complete frozen mirror (never the DOM, never partial).
80
+ * - `commit()` merges a patch and fires `dispatchChange` exactly once.
81
+ * - `removed()`/`destroy()` unregister the portal (deterministic unmount).
82
+ */
83
+ export function createReactBlock<Data = BlockToolData, Config = Record<string, unknown>>(
84
+ spec: CreateReactBlockSpec<Data, Config>
85
+ ): (new (options: BlockToolConstructorOptions) => {
86
+ render(): HTMLElement;
87
+ save(): BlockToolData;
88
+ setData(newData: BlockToolData): Promise<boolean>;
89
+ setReadOnly(state: boolean): void;
90
+ rendered(): void;
91
+ moved(): void;
92
+ removed(): void;
93
+ destroy(): void;
94
+ }) & {
95
+ readonly __isBlokReactBlock: true;
96
+ readonly toolbox: ToolboxConfig | undefined;
97
+ readonly isReadOnlySupported: boolean;
98
+ } {
99
+ return class ReactBlockTool {
100
+ /** Marker so `useBlok` can detect react-block tools and inject the registry. */
101
+ public static readonly __isBlokReactBlock = true as const;
102
+
103
+ public static get toolbox(): ToolboxConfig | undefined {
104
+ return spec.toolbox;
105
+ }
106
+
107
+ /**
108
+ * React blocks support read-only mode: `setReadOnly` pushes a new prop the
109
+ * component reads, so the block re-renders read-only IN PLACE. Without this
110
+ * flag core's ReadOnly module throws a critical error when read-only is
111
+ * enabled and a React block is present.
112
+ */
113
+ public static get isReadOnlySupported(): boolean {
114
+ return true;
115
+ }
116
+
117
+ private readonly blockApi: BlockAPI;
118
+ private readonly registry: BlockPortalRegistry | undefined;
119
+ /** Name this tool is registered under in the consumer's `tools` map. */
120
+ private readonly toolName: string | undefined;
121
+ /** The consumer's tool config with adapter-internal keys stripped. */
122
+ private readonly toolConfig: Readonly<Partial<Config>>;
123
+ private readonly pointerDrag: () => boolean;
124
+ private readonly childrenComponent: ComponentType;
125
+ private mirror: Readonly<Data>;
126
+ /** Dedup baseline: skip a redundant render of identical data. */
127
+ private lastRendered: Readonly<Data>;
128
+ /** Read-only flag mirrored into the entry props by setReadOnly. */
129
+ private readOnly: boolean;
130
+ private hostEl: HTMLElement | null = null;
131
+ /** True while a deferred dispatch is waiting for a pointer drag to end. */
132
+ private pendingDispatch = false;
133
+
134
+ public constructor(options: BlockToolConstructorOptions) {
135
+ this.blockApi = options.block;
136
+
137
+ const config = (options.config ?? {}) as Record<string, unknown>;
138
+
139
+ this.registry = config[BLOK_PORTAL_REGISTRY_CONFIG_KEY] as BlockPortalRegistry | undefined;
140
+ const toolName = config[BLOK_TOOL_NAME_CONFIG_KEY];
141
+
142
+ this.toolName = typeof toolName === 'string' ? toolName : undefined;
143
+
144
+ const publicConfig: Record<string, unknown> = {};
145
+
146
+ for (const [key, value] of Object.entries(config)) {
147
+ if (!INTERNAL_CONFIG_KEYS.includes(key)) {
148
+ publicConfig[key] = value;
149
+ }
150
+ }
151
+ this.toolConfig = Object.freeze(publicConfig) as Readonly<Partial<Config>>;
152
+
153
+ // Read the LIVE pointer-drag flag so a mid-drag commit can be deferred
154
+ // (core silently drops a dispatchChange while a drag is active).
155
+ const api = options.api as unknown as { blocks?: { isPointerDragActive?: boolean } } | undefined;
156
+
157
+ this.pointerDrag = (): boolean => api?.blocks?.isPointerDragActive === true;
158
+
159
+ this.mirror = fillDefaults<Data>(spec.propSchema, (options.data ?? {}) as Record<string, unknown>);
160
+ this.lastRendered = this.mirror;
161
+ this.readOnly = options.readOnly;
162
+
163
+ const blockApi = this.blockApi;
164
+
165
+ // Per-instance child slot: a childless ref'd div Blok owns; mountChildBlocks
166
+ // appends the real child holders imperatively (React never reconciles them).
167
+ this.childrenComponent = function BlockChildren(): ReactElement {
168
+ const slotRef = useRef<HTMLDivElement | null>(null);
169
+
170
+ // No dep array: re-adopt children after EVERY render (mirrors the Vue
171
+ // adapter's onMounted + onUpdated pair).
172
+ useEffect(() => {
173
+ if (slotRef.current !== null) {
174
+ mountChildBlocks(slotRef.current, blockApi.getChildren());
175
+ }
176
+ });
177
+
178
+ return <div ref={slotRef} {...{ [DATA_ATTR.nestedBlocks]: '' }} />;
179
+ };
180
+ }
181
+
182
+ public render(): HTMLElement {
183
+ const host = document.createElement('div');
184
+
185
+ // The Blok-owned host: React reconciles the chrome portaled INTO it, but
186
+ // this attribute makes core's MutationObserver ignore those mutations, so
187
+ // React's reconciliation never registers as a user edit.
188
+ host.setAttribute('data-blok-mutation-free', 'true');
189
+ this.hostEl = host;
190
+
191
+ this.registry?.register(this.blockApi.id, {
192
+ hostEl: host,
193
+ component: spec.component as unknown as ComponentType<Record<string, unknown>>,
194
+ props: this.buildProps(),
195
+ toolName: this.toolName,
196
+ });
197
+
198
+ // Returned synchronously; React flushes the portal on its next commit.
199
+ return host;
200
+ }
201
+
202
+ public rendered(): void {
203
+ spec.onRendered?.(this.blockApi);
204
+ }
205
+
206
+ public save(): BlockToolData {
207
+ // The complete, frozen mirror — never the DOM, never partial.
208
+ return this.mirror as BlockToolData;
209
+ }
210
+
211
+ public async setData(newData: BlockToolData): Promise<boolean> {
212
+ const next = fillDefaults<Data>(spec.propSchema, newData ?? {});
213
+
214
+ // Dedup: identical data → skip the render entirely, but still return true
215
+ // so core keeps the block in place (no remount).
216
+ if (deepEqual(next, this.lastRendered)) {
217
+ return true;
218
+ }
219
+
220
+ this.mirror = next;
221
+ this.lastRendered = next;
222
+
223
+ // Flush React's commit before resolving. Core drives setData inside its
224
+ // RAF-extended Yjs-suppression window, so the re-render must land before
225
+ // suppression lifts — without ever remounting.
226
+ this.pushProps({ data: next });
227
+
228
+ return true;
229
+ }
230
+
231
+ /**
232
+ * In-place read-only toggle. Pushes a new `readOnly` prop the component
233
+ * reads, so the block re-renders read-only WITHOUT a remount (ephemeral
234
+ * component state survives). A regular prototype method (not an arrow
235
+ * field) so core's `supportsInPlaceReadOnly` — which probes the
236
+ * constructable's PROTOTYPE for `setReadOnly` — selects the in-place path.
237
+ */
238
+ public setReadOnly(state: boolean): void {
239
+ this.readOnly = state;
240
+ this.pushProps({ readOnly: state });
241
+ }
242
+
243
+ public moved(): void {
244
+ // No remount: Blok relocates the host element; the portal follows it.
245
+ spec.onMoved?.(this.blockApi);
246
+ }
247
+
248
+ public removed(): void {
249
+ this.registry?.unregister(this.blockApi.id);
250
+ spec.onRemoved?.(this.blockApi);
251
+ }
252
+
253
+ public destroy(): void {
254
+ // Idempotent with removed(); unregister is safe when already absent.
255
+ this.registry?.unregister(this.blockApi.id);
256
+ }
257
+
258
+ /** The full entry props handed to the component on (re-)register. */
259
+ private buildProps(): Record<string, unknown> {
260
+ const props: ReactBlockRenderProps<Data, Config> = {
261
+ data: this.mirror,
262
+ commit: this.commit,
263
+ block: this.blockApi,
264
+ readOnly: this.readOnly,
265
+ config: this.toolConfig,
266
+ BlockChildren: this.childrenComponent,
267
+ };
268
+
269
+ return props as unknown as Record<string, unknown>;
270
+ }
271
+
272
+ /** Push a prop patch to the mounted component and flush React's commit. */
273
+ private pushProps(props: Record<string, unknown>): void {
274
+ const registry = this.registry;
275
+
276
+ if (registry === undefined) {
277
+ return;
278
+ }
279
+
280
+ // setData/setReadOnly arrive from core outside React's render phase, so a
281
+ // synchronous flush is safe and makes the DOM update deterministic.
282
+ flushSync(() => {
283
+ registry.setProps(this.blockApi.id, props);
284
+ });
285
+ }
286
+
287
+ /**
288
+ * The only data write path. Merges the patch into the frozen mirror, pushes
289
+ * the new snapshot, records the dedup baseline, and fires dispatchChange
290
+ * EXACTLY once — deferring it while a pointer drag is active (core would
291
+ * otherwise silently drop it).
292
+ */
293
+ private readonly commit = (patch: Partial<Data>): void => {
294
+ const next = fillDefaults<Data>(spec.propSchema, {
295
+ ...(this.mirror as Record<string, unknown>),
296
+ ...(patch as Record<string, unknown>),
297
+ });
298
+
299
+ // Idempotent: a patch that changes nothing is a full no-op — no props
300
+ // push, no dispatchChange. This is what makes an effect that echoes the
301
+ // current value back through commit safe without a consumer-side guard
302
+ // (an unguarded echo would otherwise loop commit → render → effect).
303
+ if (deepEqual(next, this.mirror)) {
304
+ return;
305
+ }
306
+
307
+ this.mirror = next;
308
+ this.lastRendered = next;
309
+ // No flushSync here: commit is called from React event handlers (inside a
310
+ // render batch), where a sync flush is illegal — the normal async commit
311
+ // is fine because the change originates from the component itself.
312
+ this.registry?.setProps(this.blockApi.id, { data: next });
313
+
314
+ this.flushDispatch();
315
+ };
316
+
317
+ /** Dispatch the change, or retry on the next frame if a drag is in progress. */
318
+ private flushDispatch(): void {
319
+ if (!this.pointerDrag()) {
320
+ this.pendingDispatch = false;
321
+ this.blockApi.dispatchChange();
322
+
323
+ return;
324
+ }
325
+
326
+ if (this.pendingDispatch) {
327
+ return;
328
+ }
329
+
330
+ this.pendingDispatch = true;
331
+
332
+ const retry = (): void => {
333
+ if (this.pointerDrag()) {
334
+ requestAnimationFrame(retry);
335
+
336
+ return;
337
+ }
338
+
339
+ this.pendingDispatch = false;
340
+ this.blockApi.dispatchChange();
341
+ };
342
+
343
+ requestAnimationFrame(retry);
344
+ }
345
+ };
346
+ }
package/src/index.ts CHANGED
@@ -5,6 +5,21 @@ export { BlokProvider, useBlokDefaults } from './provide-blok';
5
5
  export type { UseBlokConfig, BlokContentProps } from './types';
6
6
  export type { BlokEditorProps } from './BlokEditor';
7
7
  export { useBlocks } from './useBlocks';
8
+ export { createReactBlock } from './createReactBlock';
9
+ export type {
10
+ CreateReactBlockSpec,
11
+ ReactBlockRenderProps,
12
+ PropSchema,
13
+ PropSchemaEntry,
14
+ } from './createReactBlock';
15
+ export {
16
+ createBlockPortalRegistry,
17
+ BLOK_PORTAL_REGISTRY_CONFIG_KEY,
18
+ BLOK_TOOL_NAME_CONFIG_KEY,
19
+ } from './block-portal-registry';
20
+ export type { BlockPortalRegistry, BlockPortalEntry } from './block-portal-registry';
21
+ export { BlockPortalHost } from './BlockPortalHost';
22
+ export type { BlockPortalHostProps } from './BlockPortalHost';
8
23
  export type {
9
24
  BlockNode,
10
25
  CaretTarget,