@bloklabs/react 1.1.1

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/src/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ export { useBlok } from './useBlok';
2
+ export { BlokContent } from './BlokContent';
3
+ export { BlokEditor } from './BlokEditor';
4
+ export { BlokProvider, useBlokDefaults } from './provide-blok';
5
+ export type { UseBlokConfig, BlokContentProps } from './types';
6
+ export type { BlokEditorProps } from './BlokEditor';
7
+ export { useBlocks } from './useBlocks';
8
+ export type {
9
+ BlockNode,
10
+ CaretTarget,
11
+ InsertSpec,
12
+ TreeInsertSpec,
13
+ InsertPosition,
14
+ MoveTarget,
15
+ UseBlocksApi,
16
+ } from './blocks-snapshot';
@@ -0,0 +1,70 @@
1
+ import { createContext, useContext, type ReactNode } from 'react';
2
+ import type { UseBlokConfig } from './types';
3
+
4
+ /**
5
+ * Context holding app-wide Blok defaults (shared tools registry, default theme,
6
+ * i18n). Merged UNDER per-instance config by `useBlok` / `<BlokEditor>`. Mirrors
7
+ * Vue's and Angular's `provideBlok`.
8
+ */
9
+ export const BlokDefaultsContext = createContext<Partial<UseBlokConfig>>({});
10
+
11
+ /**
12
+ * Registers app-wide Blok defaults for every `useBlok` / `<BlokEditor>` rendered
13
+ * beneath it. Per-instance config overrides these, and the `tools` registry is
14
+ * merged (shared registry composes with per-instance additions) rather than
15
+ * replaced. Mirrors Vue's and Angular's `provideBlok`.
16
+ *
17
+ * @example
18
+ * ```tsx
19
+ * <BlokProvider defaults={{ theme: 'dark', tools: sharedTools }}>
20
+ * <App />
21
+ * </BlokProvider>
22
+ * ```
23
+ */
24
+ export function BlokProvider({
25
+ defaults,
26
+ children,
27
+ }: {
28
+ defaults: Partial<UseBlokConfig>;
29
+ children?: ReactNode;
30
+ }): React.ReactElement {
31
+ return <BlokDefaultsContext.Provider value={defaults}>{children}</BlokDefaultsContext.Provider>;
32
+ }
33
+
34
+ /** Reads the app-wide Blok defaults from the nearest `BlokProvider` (or `{}`). */
35
+ export function useBlokDefaults(): Partial<UseBlokConfig> {
36
+ return useContext(BlokDefaultsContext);
37
+ }
38
+
39
+ /**
40
+ * Merge app-wide `defaults` UNDER a per-instance `config`: a defined config value
41
+ * overrides the default, and the `tools` registry is merged across both layers.
42
+ * Returns the original `config` reference when there are no defaults, so the
43
+ * no-provider path stays identity-stable (no spurious editor recreation).
44
+ */
45
+ export function mergeBlokDefaults(
46
+ defaults: Partial<UseBlokConfig>,
47
+ config: UseBlokConfig
48
+ ): UseBlokConfig {
49
+ if (Object.keys(defaults).length === 0) {
50
+ return config;
51
+ }
52
+
53
+ const merged: Record<string, unknown> = { ...defaults };
54
+
55
+ for (const key of Object.keys(config)) {
56
+ const value = (config as Record<string, unknown>)[key];
57
+
58
+ if (value !== undefined) {
59
+ merged[key] = value;
60
+ }
61
+ }
62
+
63
+ // tools registries MERGE across layers (a shared registry composes with
64
+ // per-instance additions) rather than the instance replacing the default.
65
+ if (defaults.tools !== undefined || config.tools !== undefined) {
66
+ merged.tools = { ...defaults.tools, ...config.tools };
67
+ }
68
+
69
+ return merged as UseBlokConfig;
70
+ }
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { BlokConfig, Blok, EditorWidth } from '@/types';
2
+
3
+ /**
4
+ * Configuration for useBlok hook.
5
+ * Same as BlokConfig but without `holder` (managed by BlokContent), plus a
6
+ * React-only reactive `width` prop.
7
+ *
8
+ * Reactive props (sync after mount without recreation):
9
+ * - `readOnly` — calls `editor.readOnly.set(value)`
10
+ * - `autofocus` — calls `editor.focus()` when changed to true
11
+ * - `theme` — calls `editor.theme.set(value)`
12
+ * - `width` — calls `editor.width.set(value)`
13
+ * - `placeholder` — calls `editor.placeholder.set(value)`
14
+ * - `data` — re-renders via `editor.render(value)` when content changes
15
+ * (deep-equal–deduped and serialized; seeds the initial content at creation)
16
+ *
17
+ * The `onSave` config (inherited from `BlokConfig`) is the controlled output
18
+ * half: it fires with the full serialized `OutputData` on every content change,
19
+ * so `data` + `onSave` form a controlled component without manual `save()` calls.
20
+ */
21
+ export interface UseBlokConfig extends Omit<BlokConfig, 'holder'> {
22
+ /** Editor content width mode. Synced reactively after mount via `editor.width.set()`. */
23
+ width?: EditorWidth;
24
+ }
25
+
26
+ /**
27
+ * Props for the BlokContent component.
28
+ * Extends standard div HTML attributes so className, style, id, etc. pass through.
29
+ */
30
+ export interface BlokContentProps extends React.HTMLAttributes<HTMLDivElement> {
31
+ /** The Blok editor instance returned by useBlok. Pass null if editor is not ready yet. */
32
+ editor: Blok | null;
33
+ }
@@ -0,0 +1,91 @@
1
+ // packages/react/src/useBlocks.ts
2
+ import { useCallback, useMemo, useRef, useSyncExternalStore } from 'react';
3
+
4
+ import type { Blok } from '@/types';
5
+ import { createBlocksApiForEditor, EMPTY_API } from '@bloklabs/core/adapters';
6
+
7
+ import type { UseBlocksApi } from './blocks-snapshot';
8
+
9
+ const BLOCK_CHANGED_EVENT = 'block changed';
10
+
11
+ /**
12
+ * React hook exposing an id/parentId-relative, reactive view of the block tree.
13
+ * Re-renders whenever the editor emits 'block changed' — including programmatic
14
+ * `nest`/`unnest` reparents, which Blok surfaces as a structural mutation.
15
+ *
16
+ * The block-tree logic itself is framework-agnostic and lives in the shared
17
+ * {@link createBlocksApiForEditor} core (one implementation behind both the
18
+ * React and Vue `useBlocks`, so the two adapters cannot drift). This hook only
19
+ * supplies React's reactivity wrapper: `useSyncExternalStore` re-renders the
20
+ * component on 'block changed', and the shared API's reads run live during that
21
+ * render — so React needs no per-read dependency tracking (the default no-op
22
+ * `onRead`).
23
+ *
24
+ * Reactivity contract: reads refresh ONLY in response to the editor's
25
+ * 'block changed' event. A mutation that advances the editor state WITHOUT
26
+ * emitting it (a tool that mutates its own data without a sync, say) would leave
27
+ * these reads frozen on the previous snapshot until the next emission. The
28
+ * built-in mutators here all route through paths that emit, so this only bites
29
+ * out-of-band tool writes.
30
+ *
31
+ * Pre-ready contract: while `editor` is null (before `useBlok` resolves) the
32
+ * returned API is the stable {@link EMPTY_API} — every MUTATOR is a no-op,
33
+ * `insert`/`getById` return `null`, and `getChildren` returns `[]`. The one
34
+ * exception is `transact`, which still invokes its callback even pre-ready.
35
+ * Mutator calls made before the editor is ready are silently dropped, so guard
36
+ * on a non-null editor (or render-gate on it) when an insert must not be lost.
37
+ *
38
+ * Referential stability: the returned API object is stable across renders (it
39
+ * only changes when `editor` does), but each `getById`/`getChildren` call
40
+ * allocates fresh `BlockNode` objects/arrays from a live snapshot. Read them in
41
+ * render and re-read after a change — do NOT put a `getById`/`getChildren`
42
+ * result (or the api handle) in a `useMemo`/`useEffect` dependency array
43
+ * expecting it to change identity per mutation; depend on the node `id`s instead.
44
+ * @param editor - the Blok instance from useBlok, or null before it is ready
45
+ */
46
+ export function useBlocks(editor: Blok | null): UseBlocksApi {
47
+ // Monotonic version bumped on every 'block changed'; drives useSyncExternalStore.
48
+ const versionRef = useRef(0);
49
+
50
+ // Reset the monotonic version when the editor INSTANCE changes so a fresh
51
+ // editor doesn't inherit a stale version from a previous one. Done in render
52
+ // (idempotent: editorRef is updated in the same pass, so a re-render with the
53
+ // same editor won't reset again) — safe for useSyncExternalStore, which just
54
+ // re-reads getSnapshot and re-renders if the value differs.
55
+ const editorRef = useRef(editor);
56
+
57
+ if (editorRef.current !== editor) {
58
+ editorRef.current = editor;
59
+ versionRef.current = 0;
60
+ }
61
+
62
+ const subscribe = useCallback(
63
+ (onStoreChange: () => void): (() => void) => {
64
+ if (editor === null) {
65
+ return () => undefined;
66
+ }
67
+
68
+ const handler = (): void => {
69
+ versionRef.current += 1;
70
+ onStoreChange();
71
+ };
72
+
73
+ editor.on(BLOCK_CHANGED_EVENT, handler);
74
+
75
+ return () => editor.off(BLOCK_CHANGED_EVENT, handler);
76
+ },
77
+ [editor]
78
+ );
79
+
80
+ const getSnapshot = useCallback((): number => versionRef.current, []);
81
+
82
+ useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
83
+
84
+ // React re-renders the whole component on 'block changed' (above), and the
85
+ // shared API's reads run live during that render, so no per-read dependency
86
+ // tracking is needed — the default no-op `onRead` is correct here.
87
+ return useMemo<UseBlocksApi>(
88
+ () => (editor === null ? EMPTY_API : createBlocksApiForEditor(editor)),
89
+ [editor]
90
+ );
91
+ }
package/src/useBlok.ts ADDED
@@ -0,0 +1,365 @@
1
+ import { useState, useEffect, useRef, useMemo, useContext, type DependencyList } from 'react';
2
+ import { Blok as BlokRuntime } from '@bloklabs/core';
3
+ import { setHolder, removeHolder } from './holder-map';
4
+ import { deepEqual } from './deep-equal';
5
+ import { BlokDefaultsContext, mergeBlokDefaults } from './provide-blok';
6
+ import { normalizeReadOnlyConfig } from '@bloklabs/core/adapters';
7
+ import type { Blok } from '@/types';
8
+ import type { UseBlokConfig } from './types';
9
+
10
+ interface EditorInstanceState {
11
+ editor: Blok | null;
12
+ holder: HTMLDivElement | null;
13
+ destroyTimeout: ReturnType<typeof setTimeout> | null;
14
+ isDestroyed: boolean;
15
+ /** True once `editor.isReady` has resolved and its API surface is attached. */
16
+ isEditorReady: boolean;
17
+ /** Opaque token identifying which deps cycle created this editor */
18
+ depsToken: Record<string, unknown> | null;
19
+ }
20
+
21
+ /**
22
+ * React hook that manages a Blok editor instance lifecycle.
23
+ *
24
+ * Creates a detached holder div, instantiates Blok, and returns the instance
25
+ * once `isReady` resolves. Handles StrictMode double-mount by deferring
26
+ * destroy via `setTimeout(0)` and cancelling on remount.
27
+ *
28
+ * @param config - Blok configuration without `holder` (managed internally)
29
+ * @param deps - Optional dependency array; when values change, the editor is recreated
30
+ * @returns The Blok instance once ready, or null during initialization / SSR
31
+ */
32
+ export function useBlok(configInput: UseBlokConfig, deps?: DependencyList): Blok | null {
33
+ // App-wide defaults from the nearest <BlokProvider> are merged UNDER the
34
+ // per-instance config (instance wins; tools registries merge). When no
35
+ // provider is present this returns `configInput` unchanged.
36
+ const defaults = useContext(BlokDefaultsContext);
37
+ const config = mergeBlokDefaults(defaults, configInput);
38
+
39
+ const [editor, setEditor] = useState<Blok | null>(null);
40
+ const configRef = useRef(config);
41
+ configRef.current = config;
42
+
43
+ const stateRef = useRef<EditorInstanceState>({
44
+ editor: null,
45
+ holder: null,
46
+ destroyTimeout: null,
47
+ isDestroyed: false,
48
+ isEditorReady: false,
49
+ depsToken: null,
50
+ });
51
+
52
+ // A new token is created only when deps change (useMemo compares deps).
53
+ // StrictMode re-runs see the SAME token. Deps changes produce a NEW token.
54
+ // eslint-disable-next-line react-hooks/exhaustive-deps
55
+ const depsToken = useMemo(() => ({}), deps ?? []);
56
+
57
+ // Tracks the data the editor currently reflects — set when content is seeded
58
+ // or rendered (the reactive `data` effect below) AND when the editor emits its
59
+ // own serialized content via `onSave`. The latter is what makes a controlled
60
+ // `onSave -> setData -> data` round-trip a no-op: the echoed payload deep-equals
61
+ // this baseline, so the data effect skips the redundant render() (which would
62
+ // otherwise reset the caret). Declared here so the `onSave` wrapper below can
63
+ // update it before the consumer's setState re-runs the data effect.
64
+ const lastRenderedDataRef = useRef(config.data);
65
+ const seededEditorRef = useRef<Blok | null>(null);
66
+ // The `data` the live editor was actually constructed with. The seed gate in
67
+ // the reactive-`data` effect compares the current prop against THIS (not mere
68
+ // editor identity) so a prop that diverged from the construction value —
69
+ // undefined-at-mount then loaded, or changed before the editor finished
70
+ // initializing — still renders instead of being mistaken for the seed.
71
+ const constructedDataRef = useRef(config.data);
72
+ const renderChainRef = useRef<Promise<void>>(Promise.resolve());
73
+ // The content of the render currently queued/in-flight (distinct from
74
+ // `lastRenderedDataRef`, which tracks the last SUCCESSFULLY rendered content).
75
+ // Used to dedupe a re-queue of the same in-flight content without advancing the
76
+ // success baseline — so a failed render can't strand the baseline on content
77
+ // the editor never actually showed.
78
+ const pendingDataRef = useRef<UseBlokConfig['data']>(undefined);
79
+
80
+ // Main lifecycle effect
81
+ useEffect(() => {
82
+ if (typeof window === 'undefined') {
83
+ return;
84
+ }
85
+
86
+ const state = stateRef.current;
87
+
88
+ // Cancel pending deferred destroy (StrictMode remount)
89
+ if (state.destroyTimeout !== null) {
90
+ clearTimeout(state.destroyTimeout);
91
+ state.destroyTimeout = null;
92
+ }
93
+
94
+ // Reuse editor on StrictMode remount (same deps cycle). The construction
95
+ // effect below only calls setEditor(blok) once `blok.isReady` resolves and
96
+ // exportAPI() has attached the full instance API (readOnly, focus, etc.).
97
+ // StrictMode's synchronous cleanup+remount cycle happens well before that
98
+ // promise can settle, so publishing `state.editor` here unconditionally
99
+ // exposed a half-constructed instance to every reactive effect (e.g. the
100
+ // readOnly sync effect calling `.readOnly.set(...)` on an instance whose
101
+ // API surface wasn't attached yet, throwing). Only reuse it into state
102
+ // once it's actually known-ready — otherwise leave `editor` as-is and let
103
+ // the still-pending `isReady.then()` from the original construction (never
104
+ // torn down, since only its destroy was deferred) publish it for real.
105
+ if (state.editor !== null && !state.isDestroyed && state.depsToken === depsToken) {
106
+ if (state.isEditorReady) {
107
+ setEditor(state.editor);
108
+ }
109
+
110
+ return (): void => {
111
+ deferDestroy(state, setEditor);
112
+ };
113
+ }
114
+
115
+ // Destroy leftover editor from a previous deps cycle
116
+ if (state.editor !== null && !state.isDestroyed) {
117
+ removeHolder(state.editor);
118
+ try {
119
+ state.editor.destroy();
120
+ } catch {
121
+ // destroy may throw — still clean up state
122
+ }
123
+ state.editor = null;
124
+ state.holder = null;
125
+ state.isDestroyed = true;
126
+ state.isEditorReady = false;
127
+ setEditor(null);
128
+ }
129
+
130
+ // Create detached holder
131
+ const holder = document.createElement('div');
132
+ state.holder = holder;
133
+ state.isDestroyed = false;
134
+ state.isEditorReady = false;
135
+ state.depsToken = depsToken;
136
+
137
+ // Wrap callbacks via ref so they never go stale
138
+ const currentConfig = configRef.current;
139
+ const blokConfig = {
140
+ ...currentConfig,
141
+ holder,
142
+ onReady: (...args: Parameters<NonNullable<UseBlokConfig['onReady']>>): void => {
143
+ configRef.current.onReady?.(...args);
144
+ },
145
+ onChange: (...args: Parameters<NonNullable<UseBlokConfig['onChange']>>): void => {
146
+ configRef.current.onChange?.(...args);
147
+ },
148
+ };
149
+
150
+ // Only attach onSave when the consumer opted in: its mere presence makes the
151
+ // core serialize on every change batch, so an absent prop must stay absent.
152
+ // The wrapper reads through the ref so the latest callback is always used.
153
+ if (currentConfig.onSave) {
154
+ blokConfig.onSave = (...args: Parameters<NonNullable<UseBlokConfig['onSave']>>): void => {
155
+ // Record the editor's own serialized output as the rendered baseline so a
156
+ // controlled consumer echoing it straight back into `data` is a no-op —
157
+ // no redundant render(), no caret reset, no round-trip recursion.
158
+ lastRenderedDataRef.current = args[0];
159
+ configRef.current.onSave?.(...args);
160
+ };
161
+ }
162
+
163
+ // onBeforeRender / onAfterRender are opt-in (absent prop must stay absent so
164
+ // the core skips them). When present, route through the ref so the latest
165
+ // callback is always used without recreating the editor.
166
+ if (currentConfig.onBeforeRender) {
167
+ blokConfig.onBeforeRender = (
168
+ ...args: Parameters<NonNullable<UseBlokConfig['onBeforeRender']>>
169
+ ): ReturnType<NonNullable<UseBlokConfig['onBeforeRender']>> =>
170
+ configRef.current.onBeforeRender?.(...args) ?? args[0];
171
+ }
172
+
173
+ if (currentConfig.onAfterRender) {
174
+ blokConfig.onAfterRender = (...args: Parameters<NonNullable<UseBlokConfig['onAfterRender']>>): void => {
175
+ configRef.current.onAfterRender?.(...args);
176
+ };
177
+ }
178
+
179
+ const blok = new BlokRuntime(blokConfig) as unknown as Blok;
180
+ state.editor = blok;
181
+ // Remember what this editor was seeded with so the reactive-`data` effect can
182
+ // tell a genuine post-construction change from the construction value itself.
183
+ constructedDataRef.current = currentConfig.data;
184
+ setHolder(blok, holder);
185
+
186
+ void blok.isReady
187
+ .then(() => {
188
+ if (state.editor === blok && !state.isDestroyed) {
189
+ state.isEditorReady = true;
190
+ setEditor(blok);
191
+ }
192
+ })
193
+ .catch(() => {
194
+ if (state.editor === blok && !state.isDestroyed) {
195
+ removeHolder(blok);
196
+ try {
197
+ blok.destroy();
198
+ } catch {
199
+ // destroy may also throw — still clean up state
200
+ }
201
+ state.editor = null;
202
+ state.holder = null;
203
+ state.isDestroyed = true;
204
+ state.isEditorReady = false;
205
+ setEditor(null);
206
+ }
207
+ });
208
+
209
+ return (): void => {
210
+ deferDestroy(state, setEditor);
211
+ };
212
+ }, [depsToken]);
213
+
214
+ // Reactive: readOnly (object form normalized so the effect dep is a stable boolean)
215
+ const readOnlyEnabled = normalizeReadOnlyConfig(config.readOnly).enabled;
216
+ useEffect(() => {
217
+ if (editor === null) {
218
+ return;
219
+ }
220
+ void editor.readOnly.set(readOnlyEnabled);
221
+ }, [editor, readOnlyEnabled]);
222
+
223
+ // Reactive: autofocus
224
+ const { autofocus } = config;
225
+ useEffect(() => {
226
+ if (editor === null || !autofocus) {
227
+ return;
228
+ }
229
+ editor.focus();
230
+ }, [editor, autofocus]);
231
+
232
+ // Reactive: theme
233
+ const { theme } = config;
234
+ useEffect(() => {
235
+ if (editor === null || theme === undefined) {
236
+ return;
237
+ }
238
+ editor.theme.set(theme);
239
+ }, [editor, theme]);
240
+
241
+ // Reactive: width
242
+ const { width } = config;
243
+ useEffect(() => {
244
+ if (editor === null || width === undefined) {
245
+ return;
246
+ }
247
+ editor.width.set(width);
248
+ }, [editor, width]);
249
+
250
+ // Reactive: placeholder
251
+ const { placeholder } = config;
252
+ useEffect(() => {
253
+ if (editor === null || placeholder === undefined) {
254
+ return;
255
+ }
256
+ editor.placeholder.set(placeholder);
257
+ }, [editor, placeholder]);
258
+
259
+ // Reactive: data (controlled content)
260
+ //
261
+ // `data` seeds the editor at construction. Afterwards, changing the prop to
262
+ // new *content* re-renders the editor via the public render() API — no
263
+ // recreation. Updates are deep-equal–deduped against the editor's current
264
+ // content baseline (`lastRenderedDataRef`, declared above and also updated by
265
+ // the `onSave` wrapper) so a new reference with the same content — including
266
+ // the editor's own serialized output echoed back — is a no-op and won't
267
+ // clobber the caret. Renders are serialized so rapid changes can't overlap.
268
+ const { data } = config;
269
+ useEffect(() => {
270
+ if (editor === null || data === undefined) {
271
+ return;
272
+ }
273
+
274
+ // The lifecycle effect runs BEFORE this one, so a commit that changes `deps`
275
+ // AND `data` together has already swapped `state.editor` to the freshly
276
+ // constructed instance (and may have destroyed the old one) by the time this
277
+ // effect fires with the PREVIOUS render's `editor` closure. Rendering through
278
+ // that stale handle would target a destroyed editor — and the new instance is
279
+ // already seeded with the new `data` at construction. Skip unless this closure
280
+ // still owns the live editor, mirroring the isReady guard above.
281
+ const state = stateRef.current;
282
+
283
+ if (state.editor !== editor || state.isDestroyed) {
284
+ return;
285
+ }
286
+
287
+ // First time observing this editor: it was seeded at construction with
288
+ // `constructedDataRef`. When the current prop still matches that seed, just
289
+ // record the baseline and skip the redundant render. When it has diverged
290
+ // (undefined-at-mount then loaded, or changed before the editor finished
291
+ // initializing), fall through and render so the editor reflects the prop.
292
+ if (seededEditorRef.current !== editor) {
293
+ seededEditorRef.current = editor;
294
+ // A render still pending on a PRIOR editor is moot for this fresh instance;
295
+ // clear it so its content can't dedupe a needed render on the new editor.
296
+ pendingDataRef.current = undefined;
297
+
298
+ if (deepEqual(data, constructedDataRef.current)) {
299
+ lastRenderedDataRef.current = data;
300
+
301
+ return;
302
+ }
303
+ }
304
+
305
+ // Skip when the editor already reflects this content (last SUCCESSFUL render)
306
+ // OR a render of the same content is already queued/in-flight — re-rendering
307
+ // identical content would needlessly reset the caret.
308
+ if (deepEqual(data, lastRenderedDataRef.current) || deepEqual(data, pendingDataRef.current)) {
309
+ return;
310
+ }
311
+
312
+ // Mark the content in-flight, but DON'T advance the success baseline until the
313
+ // render actually resolves. A failed render leaves the editor on its last
314
+ // successful content, so the baseline must keep pointing there — otherwise a
315
+ // run of consecutive failures (e.g. successive malformed `data`) would strand
316
+ // the baseline on a never-rendered payload and wrongly dedupe a later retry.
317
+ pendingDataRef.current = data;
318
+ renderChainRef.current = renderChainRef.current
319
+ .catch(() => undefined)
320
+ .then(() => editor.render(data))
321
+ .then(
322
+ () => {
323
+ lastRenderedDataRef.current = data;
324
+ },
325
+ () => {
326
+ // render failed: keep the last successful baseline so a later identical
327
+ // or corrected `data` re-renders instead of being deduped. Owning the
328
+ // rejection here also keeps it off the unhandled-rejection path.
329
+ }
330
+ )
331
+ .then(() => {
332
+ // Clear the in-flight marker once settled, unless a newer render has since
333
+ // claimed it (then that newer render owns the clear).
334
+ if (pendingDataRef.current === data) {
335
+ pendingDataRef.current = undefined;
336
+ }
337
+ });
338
+ }, [editor, data]);
339
+
340
+ return editor;
341
+ }
342
+
343
+ function deferDestroy(
344
+ state: EditorInstanceState,
345
+ setEditorState: React.Dispatch<React.SetStateAction<Blok | null>>
346
+ ): void {
347
+ /* eslint-disable no-param-reassign -- intentional mutation of shared state ref */
348
+ state.destroyTimeout = setTimeout(() => {
349
+ if (state.editor !== null) {
350
+ removeHolder(state.editor);
351
+ try {
352
+ state.editor.destroy();
353
+ } catch {
354
+ // destroy may throw — still clean up state
355
+ }
356
+ state.editor = null;
357
+ state.holder = null;
358
+ state.isDestroyed = true;
359
+ state.isEditorReady = false;
360
+ state.destroyTimeout = null;
361
+ setEditorState(null);
362
+ }
363
+ }, 0);
364
+ /* eslint-enable no-param-reassign */
365
+ }