@octane-xplat/lexical 0.10.0 → 0.11.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.
@@ -2,7 +2,7 @@ import { ensureJSONBridge, htmlToJSON, jsonBridgeReady, jsonToHTML } from "./ind
2
2
  import { defineUniversalComponent, hookSlots, universalComponent, universalPlan, universalProps, universalValue, useCallback, useEffect, useImperativeHandle, useRef, useState } from "@nativescript-community/octane";
3
3
  import { RichTextEditor, supported } from "@octane-xplat/richtext";
4
4
  //#region src/LexicalEditor.tsrx
5
- var _hs$ = /* @__PURE__ */ hookSlots(8);
5
+ var _hs$ = /* @__PURE__ */ hookSlots(9);
6
6
  var _h$0 = /* @__PURE__ */ Symbol(_hs$);
7
7
  var _h$1 = /* @__PURE__ */ Symbol(_hs$ + 1);
8
8
  var _h$2 = /* @__PURE__ */ Symbol(_hs$ + 2);
@@ -11,6 +11,7 @@ var _h$4 = /* @__PURE__ */ Symbol(_hs$ + 4);
11
11
  var _h$5 = /* @__PURE__ */ Symbol(_hs$ + 5);
12
12
  var _h$6 = /* @__PURE__ */ Symbol(_hs$ + 6);
13
13
  var _h$7 = /* @__PURE__ */ Symbol(_hs$ + 7);
14
+ var _h$8 = /* @__PURE__ */ Symbol(_hs$ + 8);
14
15
  var __octaneUniversalPlan0 = universalPlan("nativescript", {
15
16
  "kind": "slot",
16
17
  "slot": 0
@@ -20,20 +21,32 @@ var LexicalEditor = defineUniversalComponent("nativescript", function LexicalEdi
20
21
  const inner = useRef(null, _h$0);
21
22
  const [exposedHandle, setHandle] = useState(null, _h$1);
22
23
  useImperativeHandle(props.ref, () => exposedHandle, [exposedHandle], _h$2);
23
- const jsonReady = useRef(false, _h$3);
24
- const lastJSON = useRef(void 0, _h$4);
24
+ const lastJSON = useRef(void 0, _h$3);
25
+ const appliedHost = useRef(null, _h$4);
26
+ const desiredDoc = useRef(void 0, _h$5);
27
+ const applyDoc = () => {
28
+ const host = inner.current;
29
+ const doc = desiredDoc.current;
30
+ if (!host || doc === void 0 || !jsonBridgeReady()) return;
31
+ const stamp = JSON.stringify(doc);
32
+ if (stamp === lastJSON.current && appliedHost.current === host) return;
33
+ const html = jsonToHTML(doc);
34
+ if (html != null) {
35
+ lastJSON.current = stamp;
36
+ appliedHost.current = host;
37
+ host.setHTML(html);
38
+ }
39
+ };
25
40
  const bindHost = useCallback((h) => {
26
41
  inner.current = h;
42
+ if (h) queueMicrotask(applyDoc);
27
43
  setHandle((previous) => previous?.native === h?.native ? previous : h ? {
28
44
  getHTML: () => h.getHTML(),
29
45
  setHTML: (html) => h.setHTML(html),
30
46
  getJSON: () => jsonBridgeReady() ? htmlToJSON(h.getHTML()) : null,
31
47
  setJSON: (doc) => {
32
- const html = jsonBridgeReady() ? jsonToHTML(doc) : null;
33
- if (html != null) {
34
- lastJSON.current = JSON.stringify(doc);
35
- h.setHTML(html);
36
- }
48
+ desiredDoc.current = doc;
49
+ applyDoc();
37
50
  },
38
51
  apply: (format) => h.apply(format),
39
52
  linkTo: (url, anchor) => h.linkTo(url, anchor),
@@ -46,28 +59,29 @@ var LexicalEditor = defineUniversalComponent("nativescript", function LexicalEdi
46
59
  isFocused: () => h.isFocused(),
47
60
  native: h.native
48
61
  } : null);
49
- }, [], _h$5);
62
+ }, [], _h$6);
50
63
  useEffect(() => {
64
+ let alive = true;
51
65
  ensureJSONBridge().then((ok) => {
52
- jsonReady.current = ok;
66
+ if (!alive) return;
53
67
  props.onJSONReady?.(ok);
68
+ applyDoc();
54
69
  });
55
- }, [], _h$6);
70
+ return () => {
71
+ alive = false;
72
+ };
73
+ }, [], _h$7);
56
74
  useEffect(() => {
57
- if (!props.json || !jsonBridgeReady() || !inner.current) return;
58
- const stamp = JSON.stringify(props.json);
59
- if (stamp === lastJSON.current) return;
60
- const html = jsonToHTML(props.json);
61
- if (html != null) {
62
- lastJSON.current = stamp;
63
- inner.current.setHTML(html);
64
- }
65
- }, [props.json], _h$7);
75
+ if (!props.json) return;
76
+ desiredDoc.current = props.json;
77
+ applyDoc();
78
+ }, [props.json], _h$8);
66
79
  const emitJSON = () => {
67
80
  if (!inner.current || !jsonBridgeReady()) return;
68
81
  const doc = htmlToJSON(inner.current.getHTML());
69
82
  if (doc) {
70
83
  lastJSON.current = JSON.stringify(doc);
84
+ appliedHost.current = inner.current;
71
85
  props.onJSONChange?.(doc);
72
86
  }
73
87
  };
@@ -110,7 +124,10 @@ var LexicalEditor = defineUniversalComponent("nativescript", function LexicalEdi
110
124
  [
111
125
  "set",
112
126
  "onReady",
113
- props.onReady
127
+ () => {
128
+ applyDoc();
129
+ props.onReady?.();
130
+ }
114
131
  ],
115
132
  [
116
133
  "set",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "buildId": "5ca3a356-fe4b-4bf2-8331-5e237e22a4e8",
3
+ "buildId": "b62e9178-ae49-4f7a-9ff4-4837828c5c66",
4
4
  "mode": "production",
5
5
  "capabilities": {
6
6
  "independentHydration": false
@@ -1,4 +1,4 @@
1
- import { __s, bag10, bag3, child, clone, componentSlot, createScopedElement, createScopedValue, drainFrag, hookSlots, markChildrenBlock, setStyle, sibling, template, useEffect, useImperativeHandle, useRef, useState } from "octane";
1
+ import { Fragment, __s, bag10, bag3, child, clone, componentSlot, createScopedElement, createScopedValue, drainFrag, hookSlots, markChildrenBlock, positionalChildren, setStyle, sibling, template, useEffect, useImperativeHandle, useRef, useState } from "octane";
2
2
  import { bindSignalAttribute, bindSignalChild, createElementFromConfig, enableSignalBindings } from "octane/internal/client";
3
3
  import { AutoFocusPlugin, ContentEditable, EditorRefPlugin, HistoryPlugin, LexicalComposer, LexicalErrorBoundary, LinkPlugin, ListPlugin, OnChangePlugin, RichTextPlugin } from "@octanejs/lexical";
4
4
  import { $createParagraphNode, $createTextNode, $findMatchingParent, $getRoot, $getSelection, $isNodeSelection, $isRangeSelection, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, REDO_COMMAND, UNDO_COMMAND } from "lexical";
@@ -11,7 +11,7 @@ import { $setBlocksType } from "@lexical/selection";
11
11
  import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html";
12
12
  //#region src/LexicalEditor.web.tsrx
13
13
  enableSignalBindings(1, true);
14
- var _t$0 = /* @__PURE__ */ template("<!><!><!><!><!><!><!>", 3, 1);
14
+ var _t$0 = /* @__PURE__ */ template("<!><!><!><!><!>", 3, 1);
15
15
  var _t$1 = /* @__PURE__ */ template("<div></div>");
16
16
  var _hs$ = /* @__PURE__ */ hookSlots(11);
17
17
  var __childrenBody = {};
@@ -177,7 +177,7 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
177
177
  useImperativeHandle(props.ref, () => exposedHandle, [exposedHandle], _hs$ + 7);
178
178
  const initialConfig = {
179
179
  namespace: "octane-xplat/lexical",
180
- nodes: NODES,
180
+ nodes: props.web?.replaceNodes ? props.web.nodes ?? [] : [.../* @__PURE__ */ new Set([...NODES, ...props.web?.nodes ?? []])],
181
181
  editable: props.editable !== false,
182
182
  editorState: props.json != null ? JSON.stringify(props.json) : props.value != null ? (editor) => {
183
183
  const dom = new DOMParser().parseFromString(props.value, "text/html");
@@ -226,6 +226,7 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
226
226
  native: editor
227
227
  };
228
228
  setHandle(bound.current);
229
+ props.onJSONReady?.(true);
229
230
  props.onReady?.();
230
231
  };
231
232
  useEffect(() => {
@@ -270,11 +271,12 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
270
271
  }
271
272
  });
272
273
  };
274
+ if (__s.block.pending && !__s.block.crossRenderUpdate) return;
273
275
  function __children$1(__props, __s, __extra) {
274
276
  const __block = __s.block;
275
277
  let _b = __s.slots[0];
276
278
  if (_b === void 0) {
277
- let _m0, _m1, _m2, _m3, _m4, _m5, _m8, _m9;
279
+ let _m0, _m1, _m2, _m5, _m6, _m7;
278
280
  const _root = clone(_t$0);
279
281
  _m0 = __block.parentNode;
280
282
  const _el0 = child(_root);
@@ -282,24 +284,22 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
282
284
  const _el1 = sibling(_el0, 1);
283
285
  _m2 = _el1;
284
286
  const _el2 = sibling(_el1, 1);
285
- _m3 = _el2;
287
+ _m5 = _el2;
286
288
  const _el3 = sibling(_el2, 1);
287
- _m4 = _el3;
288
- const _el4 = sibling(_el3, 1);
289
- _m5 = _el4;
290
- const _el5 = sibling(_el4, 1);
291
- _m8 = _el5;
292
- _m9 = sibling(_el5, 1);
289
+ _m6 = _el3;
290
+ _m7 = sibling(_el3, 1);
293
291
  drainFrag(_root, __block.parentNode, __block.endMarker);
294
- _b = bag10(__s, null, _m0, _m1, _m2, _m3, _m4, _m5, null, void 0, _m8, _m9);
292
+ _b = bag10(__s, null, _m0, _m1, _m2, null, void 0, _m5, _m6, _m7, null, void 0);
295
293
  }
296
294
  componentSlot(__s, 1, _b.a, RichTextPlugin, {
297
- "contentEditable": createScopedValue(() => createElementFromConfig("c:7597daf3", ContentEditable, {
295
+ "contentEditable": createScopedValue(() => createElementFromConfig("c:8bd4dc5e", ContentEditable, {
298
296
  id: props.id,
299
297
  "aria-placeholder": props.placeholder ?? "",
300
298
  placeholder: null,
301
299
  className: "vx-lexical-input",
302
- style: { outline: "none" }
300
+ style: { outline: "none" },
301
+ onFocus: props.onFocus,
302
+ onBlur: props.onBlur
303
303
  })),
304
304
  "placeholder": props.placeholder ? createScopedValue(() => createScopedElement("div", {
305
305
  className: "vx-lexical-placeholder",
@@ -312,12 +312,15 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
312
312
  }, () => props.placeholder)) : null,
313
313
  "ErrorBoundary": LexicalErrorBoundary
314
314
  }, _b.b, void 0, 2, void 0, void 0, "c:1283dc87");
315
- componentSlot(__s, 2, _b.a, HistoryPlugin, {}, _b.c, void 0, 2, void 0, void 0, "c:1dd1c554");
316
- componentSlot(__s, 3, _b.a, ListPlugin, {}, _b.d, void 0, 2, void 0, void 0, "c:4bb68e0f");
317
- componentSlot(__s, 4, _b.a, LinkPlugin, {}, _b.e, void 0, 2, void 0, void 0, "c:5c76533c");
318
- _b.k = bindSignalChild(__s, _b.k, 5, _b.a, props.autofocus ? createElementFromConfig("c:980e387e", AutoFocusPlugin, {}) : null, "b:928b159c", _b.f, true, void 0, void 0, void 0);
319
- componentSlot(__s, 6, _b.a, OnChangePlugin, { "onChange": emitChange }, _b.i, void 0, 2, void 0, void 0, "c:079f6eaf");
320
- componentSlot(__s, 7, _b.a, EditorRefPlugin, { "editorRef": attach }, _b.j, void 0, 2, void 0, void 0, "c:230323aa");
315
+ _b.k = bindSignalChild(__s, _b.k, 2, _b.a, !props.web?.replacePlugins ? createScopedElement(Fragment, {}, () => positionalChildren([
316
+ createElementFromConfig("c:2ac25b31", HistoryPlugin, {}),
317
+ createElementFromConfig("c:8c987b0a", ListPlugin, {}),
318
+ createElementFromConfig("c:c85acd4c", LinkPlugin, {}),
319
+ props.autofocus ? createElementFromConfig("c:4e4f8e9d", AutoFocusPlugin, {}) : null
320
+ ])) : null, "b:d23e6e54", _b.c, true, void 0, void 0, void 0);
321
+ componentSlot(__s, 3, _b.a, OnChangePlugin, { "onChange": emitChange }, _b.f, void 0, 2, void 0, void 0, "c:fbe62b73");
322
+ componentSlot(__s, 4, _b.a, EditorRefPlugin, { "editorRef": attach }, _b.g, void 0, 2, void 0, void 0, "c:bcb623dd");
323
+ _b.l = bindSignalChild(__s, _b.l, 5, _b.a, props.web?.plugins, "b:6078ff9a", _b.h, true, void 0, void 0, void 0);
321
324
  }
322
325
  let _b = __s.slots[0];
323
326
  if (_b === void 0) {
@@ -326,7 +329,7 @@ var LexicalEditor = /* @__PURE__ */ __s(function LexicalEditor(props, __s, __ext
326
329
  _m1 = _root;
327
330
  _b = bag3(__s, _root, void 0, _m1, void 0);
328
331
  }
329
- _b.a = bindSignalAttribute(__s, _b.a, _b.b, "class", props.className, "b:db7e728b", "class");
332
+ _b.a = bindSignalAttribute(__s, _b.a, _b.b, "class", props.className, "b:748f4137", "class");
330
333
  {
331
334
  const _v = {
332
335
  position: "relative",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "buildId": "41f913ce-4dc3-4a50-9909-0d4d1b93f3ed",
3
+ "buildId": "8d1e9a01-c578-493a-8940-55ea47770771",
4
4
  "mode": "production",
5
5
  "capabilities": {
6
6
  "independentHydration": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octane-xplat/lexical",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Unified rich-text editor facade for Octane xplat — live lexical on web/AppKit, Aztec + lexical document model on Android",
5
5
  "keywords": [
6
6
  "cross-platform",
@@ -55,7 +55,7 @@
55
55
  "@lexical/rich-text": "0.51.0",
56
56
  "@lexical/selection": "0.51.0",
57
57
  "@lexical/utils": "0.51.0",
58
- "@octane-xplat/richtext": "0.10.0",
58
+ "@octane-xplat/richtext": "0.11.1",
59
59
  "@octanejs/lexical": "0.2.0",
60
60
  "lexical": "0.51.0",
61
61
  "zeed-dom": "0.18.0"
@@ -63,11 +63,12 @@
63
63
  "devDependencies": {
64
64
  "@nativescript-community/octane": "0.2.4",
65
65
  "@nativescript/core": "9.1.2",
66
- "@octane-xplat/macos-renderer": "0.10.0",
66
+ "@octane-xplat/macos-renderer": "0.11.1",
67
67
  "@octanejs/vite-plugin": "0.1.61",
68
68
  "octane": "0.6.3",
69
69
  "typescript": "5.9.3",
70
- "vite": "8.3.0"
70
+ "vite": "8.3.0",
71
+ "vitest": "5.0.1"
71
72
  },
72
73
  "peerDependencies": {
73
74
  "@nativescript-community/octane": ">=0.2.1 <1",
@@ -92,6 +93,7 @@
92
93
  }
93
94
  },
94
95
  "scripts": {
95
- "build": "vite build && vite build --mode native && tsc -p tsconfig.types.json && node ../../scripts/check-native-dist.mjs && node ../richtext/build-browser.mjs"
96
+ "build": "vite build && vite build --mode native && tsc -p tsconfig.types.json && node ../../scripts/check-native-dist.mjs && node ../richtext/build-browser.mjs",
97
+ "test:native": "vitest run --config vitest.native.config.mts"
96
98
  }
97
99
  }
@@ -0,0 +1,229 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ createObjectContainer,
4
+ createObjectDriver,
5
+ createUniversalRoot,
6
+ flushUniversalSync,
7
+ } from 'octane/universal/native'
8
+
9
+ import type { LexicalJSON } from './types'
10
+
11
+ // Controlled fake for the lazy JSON bridge — the real module's dynamic
12
+ // imports aren't needed to test facade timing. `resolved`/`ready` model the
13
+ // bridge settling; docs carry `html` so conversions stay deterministic.
14
+ const bridge = vi.hoisted(() => ({
15
+ ready: false,
16
+ resolved: undefined as boolean | undefined,
17
+ waiters: [] as Array<(ok: boolean) => void>,
18
+ }))
19
+
20
+ vi.mock('./json-bridge', () => ({
21
+ ensureJSONBridge: () =>
22
+ bridge.resolved !== undefined
23
+ ? Promise.resolve(bridge.resolved)
24
+ : new Promise<boolean>((resolve) => bridge.waiters.push(resolve)),
25
+ jsonBridgeReady: () => bridge.ready,
26
+ jsonToHTML: (doc: any) =>
27
+ doc && typeof doc === 'object' && typeof doc.html === 'string' ? doc.html : null,
28
+ htmlToJSON: (html: string) => ({ type: 'doc', html }),
29
+ }))
30
+
31
+ vi.mock('@octane-xplat/richtext', async () => {
32
+ return await import('../tests/fake-richtext.fixture.mobile.tsrx')
33
+ })
34
+
35
+ import { LexicalEditor } from './LexicalEditor.tsrx'
36
+ import { hosts, resetFakeRichtext, setDefer } from '../tests/fake-richtext.fixture.mobile.tsrx'
37
+
38
+ const docA = { type: 'doc', html: '<p>alpha</p>' } as unknown as LexicalJSON
39
+ const docB = { type: 'doc', html: '<p>bravo</p>' } as unknown as LexicalJSON
40
+
41
+ const roots: ReturnType<typeof createUniversalRoot>[] = []
42
+
43
+ function mount(props: any) {
44
+ const container = createObjectContainer('nativescript')
45
+ const root = createUniversalRoot(container, createObjectDriver('nativescript'))
46
+ roots.push(root)
47
+ let handle: any = null
48
+ root.render(LexicalEditor, {
49
+ ...props,
50
+ ref: (next: any) => {
51
+ handle = next
52
+ },
53
+ })
54
+
55
+ return {
56
+ root,
57
+ container,
58
+ get handle() {
59
+ return handle
60
+ },
61
+ }
62
+ }
63
+
64
+ function settleBridge(ok: boolean) {
65
+ bridge.ready = ok
66
+ bridge.resolved = ok
67
+ for (const resolve of bridge.waiters.splice(0)) {
68
+ resolve(ok)
69
+ }
70
+ }
71
+
72
+ async function settle() {
73
+ for (let i = 0; i < 12; i++) {
74
+ await Promise.resolve()
75
+ }
76
+
77
+ flushUniversalSync(() => {})
78
+ }
79
+
80
+ beforeEach(() => {
81
+ bridge.ready = false
82
+ bridge.resolved = undefined
83
+ bridge.waiters.length = 0
84
+ resetFakeRichtext()
85
+ })
86
+
87
+ afterEach(() => {
88
+ for (const root of roots.splice(0)) {
89
+ root.unmount()
90
+ }
91
+ })
92
+
93
+ describe('LexicalEditor native JSON readiness', () => {
94
+ it('applies an initial props.json once when the bridge settles after mount', async () => {
95
+ const onJSONReady = vi.fn()
96
+ mount({ json: docA, onJSONReady })
97
+ await settle()
98
+ expect(hosts[0].setCalls).toEqual([])
99
+
100
+ settleBridge(true)
101
+ await settle()
102
+ expect(onJSONReady).toHaveBeenCalledWith(true)
103
+ expect(hosts[0].html).toBe('<p>alpha</p>')
104
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
105
+ })
106
+
107
+ it('applies an initial props.json when the host readies after the bridge', async () => {
108
+ bridge.ready = true
109
+ bridge.resolved = true
110
+ setDefer(true)
111
+ mount({ json: docA })
112
+ await settle()
113
+ expect(hosts[0].setCalls).toEqual([])
114
+
115
+ hosts[0].ready()
116
+ await settle()
117
+ expect(hosts[0].html).toBe('<p>alpha</p>')
118
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
119
+ })
120
+
121
+ it('applies an initial props.json once when host and bridge are both late', async () => {
122
+ setDefer(true)
123
+ mount({ json: docA })
124
+ await settle()
125
+ hosts[0].ready()
126
+ await settle()
127
+ expect(hosts[0].setCalls).toEqual([])
128
+
129
+ settleBridge(true)
130
+ await settle()
131
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
132
+ })
133
+
134
+ it('keeps json precedence over value once readiness lands', async () => {
135
+ mount({ value: '<p>value</p>', json: docA })
136
+ await settle()
137
+ settleBridge(true)
138
+ await settle()
139
+ expect(hosts[0].html).toBe('<p>alpha</p>')
140
+ })
141
+
142
+ it('applies later props.json updates and skips identical docs', async () => {
143
+ const view = mount({ json: docA })
144
+ settleBridge(true)
145
+ await settle()
146
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
147
+
148
+ view.root.render(LexicalEditor, { json: docB } as any)
149
+ await settle()
150
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>', '<p>bravo</p>'])
151
+
152
+ view.root.render(LexicalEditor, { json: docB } as any)
153
+ await settle()
154
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>', '<p>bravo</p>'])
155
+ })
156
+
157
+ it('does not push a doc the editor itself emitted back at it', async () => {
158
+ const onJSONChange = vi.fn()
159
+ const view = mount({ onJSONChange })
160
+ settleBridge(true)
161
+ await settle()
162
+
163
+ const rig = hosts[0]
164
+ rig.html = '<p>typed</p>'
165
+ // The leaf calls props.onChange(html); the facade wraps it to emit JSON.
166
+ rig.props.onChange('<p>typed</p>')
167
+ await settle()
168
+ const emitted = onJSONChange.mock.calls[0][0]
169
+ expect(emitted).toEqual({ type: 'doc', html: '<p>typed</p>' })
170
+
171
+ view.root.render(LexicalEditor, { json: emitted, onJSONChange } as any)
172
+ await settle()
173
+ expect(rig.setCalls).toEqual([])
174
+ })
175
+
176
+ it('defers an imperative setJSON issued before the bridge settles', async () => {
177
+ const view = mount({})
178
+ await settle()
179
+ expect(view.handle).not.toBeNull()
180
+
181
+ view.handle.setJSON(docB)
182
+ expect(hosts[0].setCalls).toEqual([])
183
+ settleBridge(true)
184
+ await settle()
185
+ expect(hosts[0].setCalls).toEqual(['<p>bravo</p>'])
186
+ })
187
+
188
+ it('re-applies the current doc when the host rebinds', async () => {
189
+ mount({ json: docA })
190
+ settleBridge(true)
191
+ await settle()
192
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
193
+
194
+ hosts[0].rebind()
195
+ await settle()
196
+ expect(hosts[0].html).toBe('<p>alpha</p>')
197
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>'])
198
+ })
199
+
200
+ it('ignores a bridge that settles after unmount', async () => {
201
+ const onJSONReady = vi.fn()
202
+ const view = mount({ json: docA, onJSONReady })
203
+ await settle()
204
+ view.root.unmount()
205
+
206
+ settleBridge(true)
207
+ await settle()
208
+ expect(onJSONReady).not.toHaveBeenCalled()
209
+ expect(hosts[0].setCalls).toEqual([])
210
+ })
211
+
212
+ it('reports bridge failure through onJSONReady without touching the host', async () => {
213
+ const onJSONReady = vi.fn()
214
+ const view = mount({ json: docA, onJSONReady })
215
+ await settle()
216
+ settleBridge(false)
217
+ await settle()
218
+ expect(onJSONReady).toHaveBeenCalledWith(false)
219
+ expect(hosts[0].setCalls).toEqual([])
220
+ expect(view.handle.getJSON()).toBeNull()
221
+ })
222
+
223
+ it('drops a doc the converter rejects instead of writing null HTML', async () => {
224
+ mount({ json: { type: 'doc', content: [{ type: 'mystery' }] } as any })
225
+ settleBridge(true)
226
+ await settle()
227
+ expect(hosts[0].setCalls).toEqual([])
228
+ })
229
+ })
@@ -2,7 +2,7 @@
2
2
  import { useState, useImperativeHandle, useCallback, useEffect, useRef } from 'octane'
3
3
  import { RichTextEditor, supported as leafSupported } from '@octane-xplat/richtext'
4
4
  import type { RichTextEditorHandle } from '@octane-xplat/richtext'
5
- import type { LexicalEditorHandle, LexicalEditorProps } from './types'
5
+ import type { LexicalEditorHandle, LexicalEditorProps, LexicalJSON } from './types'
6
6
  import { ensureJSONBridge, htmlToJSON, jsonBridgeReady, jsonToHTML } from './json-bridge'
7
7
 
8
8
  export const supported = leafSupported
@@ -16,22 +16,45 @@ export function LexicalEditor(props: LexicalEditorProps) @{
16
16
  const inner = useRef<RichTextEditorHandle | null>(null)
17
17
  const [exposedHandle, setHandle] = useState<LexicalEditorHandle | null>(null)
18
18
  useImperativeHandle<LexicalEditorHandle | null>(props.ref, () => exposedHandle, [exposedHandle])
19
- const jsonReady = useRef(false)
20
19
  const lastJSON = useRef<string | undefined>(undefined)
20
+ const appliedHost = useRef<RichTextEditorHandle | null>(null)
21
+ // Latest doc requested for the host — by `json` prop or `setJSON`. Kept
22
+ // after a successful push so a rebound (recreated) host still gets it.
23
+ const desiredDoc = useRef<LexicalJSON | undefined>(undefined)
24
+
25
+ // Push the requested doc once both halves are up — the JSON bridge
26
+ // (async module load) and the bound host (ref/onReady timing). Skips the
27
+ // stamp the host already shows, so editor-emitted echoes and repeat props
28
+ // never reach setHTML; a different host always gets its own copy.
29
+ const applyDoc = () => {
30
+ const host = inner.current
31
+ const doc = desiredDoc.current
32
+ if (!host || doc === undefined || !jsonBridgeReady()) return
33
+ const stamp = JSON.stringify(doc)
34
+ if (stamp === lastJSON.current && appliedHost.current === host) return
35
+ const html = jsonToHTML(doc)
36
+ if (html != null) {
37
+ lastJSON.current = stamp
38
+ appliedHost.current = host
39
+ host.setHTML(html)
40
+ }
41
+ }
21
42
 
22
43
  const bindHost = useCallback((h: RichTextEditorHandle | null) => {
23
44
  inner.current = h
45
+ if (h) {
46
+ // The ref binds inside the leaf's commit, before its own `value`
47
+ // seed — defer so `json` keeps precedence over `value`.
48
+ queueMicrotask(applyDoc)
49
+ }
24
50
 
25
51
  setHandle((previous) => previous?.native === h?.native ? previous : h ? ({
26
52
  getHTML: () => h.getHTML(),
27
53
  setHTML: (html: string) => h.setHTML(html),
28
54
  getJSON: () => (jsonBridgeReady() ? htmlToJSON(h.getHTML()) : null),
29
55
  setJSON: (doc) => {
30
- const html = jsonBridgeReady() ? jsonToHTML(doc) : null
31
- if (html != null) {
32
- lastJSON.current = JSON.stringify(doc)
33
- h.setHTML(html)
34
- }
56
+ desiredDoc.current = doc
57
+ applyDoc()
35
58
  },
36
59
  apply: (format) => h.apply(format),
37
60
  linkTo: (url, anchor) => h.linkTo(url, anchor),
@@ -47,25 +70,28 @@ export function LexicalEditor(props: LexicalEditorProps) @{
47
70
  }, [])
48
71
 
49
72
  // The bridge loads once per process — dynamic imports of the DOM-free
50
- // lexical slices, plus the zeed-dom parse shim.
73
+ // lexical slices, plus the zeed-dom parse shim. A doc that arrived before
74
+ // it settles is applied here, not dropped.
51
75
  useEffect(() => {
76
+ let alive = true
52
77
  ensureJSONBridge().then((ok) => {
53
- jsonReady.current = ok
78
+ if (!alive) return
54
79
  props.onJSONReady?.(ok)
80
+ applyDoc()
55
81
  })
82
+
83
+ return () => {
84
+ alive = false
85
+ }
56
86
  }, [])
57
87
 
58
- // `json` prop → editor content, when the bridge is up and the doc wasn't
59
- // emitted by the editor itself.
88
+ // `json` prop → editor content. It may land before the bridge settles or
89
+ // before the host binds — park it in desiredDoc and let the readiness
90
+ // flushes apply it.
60
91
  useEffect(() => {
61
- if (!props.json || !jsonBridgeReady() || !inner.current) return
62
- const stamp = JSON.stringify(props.json)
63
- if (stamp === lastJSON.current) return
64
- const html = jsonToHTML(props.json)
65
- if (html != null) {
66
- lastJSON.current = stamp
67
- inner.current.setHTML(html)
68
- }
92
+ if (!props.json) return
93
+ desiredDoc.current = props.json
94
+ applyDoc()
69
95
  }, [props.json])
70
96
 
71
97
  const emitJSON = () => {
@@ -73,6 +99,7 @@ export function LexicalEditor(props: LexicalEditorProps) @{
73
99
  const doc = htmlToJSON(inner.current.getHTML())
74
100
  if (doc) {
75
101
  lastJSON.current = JSON.stringify(doc)
102
+ appliedHost.current = inner.current
76
103
  props.onJSONChange?.(doc)
77
104
  }
78
105
  }
@@ -85,7 +112,12 @@ export function LexicalEditor(props: LexicalEditorProps) @{
85
112
  placeholder={props.placeholder}
86
113
  editable={props.editable}
87
114
  autofocus={props.autofocus}
88
- onReady={props.onReady}
115
+ onReady={() => {
116
+ // Host signalled ready after the leaf's own initial content seed —
117
+ // the last apply point that still outranks a `value` prop.
118
+ applyDoc()
119
+ props.onReady?.()
120
+ }}
89
121
  onChange={(html) => {
90
122
  props.onChange?.(html)
91
123
  emitJSON()