@octane-xplat/tiptap 0.10.0 → 0.11.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.
@@ -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/TiptapEditor.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 TiptapEditor = defineUniversalComponent("nativescript", function TiptapEdito
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 TiptapEditor = defineUniversalComponent("nativescript", function TiptapEdito
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 TiptapEditor = defineUniversalComponent("nativescript", function TiptapEdito
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": "3880e698-2b9a-436f-af5a-a7ec180643bf",
3
+ "buildId": "17348370-95cd-416a-8890-a13f83eea059",
4
4
  "mode": "production",
5
5
  "capabilities": {
6
6
  "independentHydration": false
@@ -1,5 +1,5 @@
1
1
  import { __s, bag3, clone, componentSlot, hookSlots, setStyle, template, useEffect, useImperativeHandle, useRef, withSlot } from "octane";
2
- import { updateFreshClassName } from "octane/internal/client";
2
+ import { compilerMemoRegion, hookMemoCreate, hookMemoEqual, hookMemoPublish2, updateFreshClassName } from "octane/internal/client";
3
3
  import { EditorContent, useEditor } from "@octanejs/tiptap";
4
4
  import StarterKit from "@tiptap/starter-kit";
5
5
  //#region src/TiptapEditor.web.tsrx
@@ -74,14 +74,24 @@ function activeFormats(editor) {
74
74
  for (const [name, kind, attrs] of ACTIVE_OF) if (editor.isActive(kind, attrs)) out.push(name);
75
75
  return out;
76
76
  }
77
+ function resolveExtensions(web) {
78
+ const extras = web?.extensions ?? [];
79
+ if (web?.starterKit === false || extras.some((ext) => ext?.name === "starterKit")) return extras;
80
+ return [web?.starterKit ? StarterKit.configure(web.starterKit) : StarterKit, ...extras];
81
+ }
77
82
  var TiptapEditor = /* @__PURE__ */ __s(function TiptapEditor(props, __s, __extra) {
83
+ const __memoRegion = compilerMemoRegion(__s, _hs$ + 0);
84
+ let __hk = __memoRegion.hooks;
85
+ if (__hk === void 0) __memoRegion.hooks = __hk = hookMemoCreate(4);
86
+ let __hkd0, __hkd1;
78
87
  const lastEmitted = useRef(void 0, _hs$ + 0);
79
88
  const lastJSON = useRef(void 0, _hs$ + 1);
80
89
  const lastHTMLInput = useRef(JSON.stringify([props.json !== void 0, props.value]), _hs$ + 2);
81
90
  const lastJSONInput = useRef(JSON.stringify(props.json), _hs$ + 3);
82
91
  const bound = useRef(void 0, _hs$ + 4);
92
+ const extensions = (__hkd0 = props.web?.extensions, __hkd1 = props.web?.starterKit, __hk[0] !== true || !hookMemoEqual(__hk[1], __hkd0) || !hookMemoEqual(__hk[2], __hkd1) ? hookMemoPublish2(__hk, 0, resolveExtensions(props.web), __hkd0, __hkd1) : __hk[3]);
83
93
  const editor = withSlot(_h$5, useEditor, {
84
- extensions: [StarterKit],
94
+ extensions,
85
95
  content: props.json ?? props.value ?? "",
86
96
  editable: props.editable !== false,
87
97
  autofocus: props.autofocus ?? false,
@@ -177,7 +187,7 @@ var TiptapEditor = /* @__PURE__ */ __s(function TiptapEditor(props, __s, __extra
177
187
  _b.c = _v;
178
188
  }
179
189
  }
180
- componentSlot(__s, 1, _b.b, EditorContent, { "editor": editor }, void 0, void 0, 2, void 0, void 0, "c:e9be3bbb");
190
+ componentSlot(__s, 1, _b.b, EditorContent, { "editor": editor }, void 0, void 0, 2, void 0, void 0, "c:95ead9de");
181
191
  });
182
192
  //#endregion
183
193
  export { TiptapEditor, supported };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "buildId": "e01adeb4-b2cd-4b21-9fdd-57831df35c5b",
3
+ "buildId": "1f4559d1-3518-4733-bde1-7ae4be559663",
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/tiptap",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Unified rich-text editor facade for Octane xplat — live tiptap on web/AppKit, Aztec + tiptap document model on Android",
5
5
  "keywords": [
6
6
  "cross-platform",
@@ -48,7 +48,7 @@
48
48
  "access": "public"
49
49
  },
50
50
  "dependencies": {
51
- "@octane-xplat/richtext": "0.10.0",
51
+ "@octane-xplat/richtext": "0.11.0",
52
52
  "@octanejs/tiptap": "0.0.51",
53
53
  "@tiptap/core": "3.28.0",
54
54
  "@tiptap/pm": "3.28.0",
@@ -59,11 +59,12 @@
59
59
  "devDependencies": {
60
60
  "@nativescript-community/octane": "0.2.4",
61
61
  "@nativescript/core": "9.1.2",
62
- "@octane-xplat/macos-renderer": "0.10.0",
62
+ "@octane-xplat/macos-renderer": "0.11.0",
63
63
  "@octanejs/vite-plugin": "0.1.61",
64
64
  "octane": "0.6.3",
65
65
  "typescript": "5.9.3",
66
- "vite": "8.3.0"
66
+ "vite": "8.3.0",
67
+ "vitest": "5.0.1"
67
68
  },
68
69
  "peerDependencies": {
69
70
  "@nativescript-community/octane": ">=0.2.1 <1",
@@ -88,6 +89,7 @@
88
89
  }
89
90
  },
90
91
  "scripts": {
91
- "build": "vite build && vite build --mode native && tsc -p tsconfig.types.json && node ../../scripts/check-native-dist.mjs && node ../richtext/build-browser.mjs"
92
+ "build": "vite build && vite build --mode native && tsc -p tsconfig.types.json && node ../../scripts/check-native-dist.mjs && node ../richtext/build-browser.mjs",
93
+ "test:native": "vitest run --config vitest.native.config.mts"
92
94
  }
93
95
  }
@@ -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 { TiptapJSON } 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 { TiptapEditor } from './TiptapEditor.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 TiptapJSON
39
+ const docB = { type: 'doc', html: '<p>bravo</p>' } as unknown as TiptapJSON
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(TiptapEditor, {
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('TiptapEditor 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(TiptapEditor, { json: docB } as any)
149
+ await settle()
150
+ expect(hosts[0].setCalls).toEqual(['<p>alpha</p>', '<p>bravo</p>'])
151
+
152
+ view.root.render(TiptapEditor, { 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(TiptapEditor, { 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 { TiptapEditorHandle, TiptapEditorProps } from './types'
5
+ import type { TiptapEditorHandle, TiptapEditorProps, TiptapJSON } from './types'
6
6
  import { ensureJSONBridge, htmlToJSON, jsonBridgeReady, jsonToHTML } from './json-bridge'
7
7
 
8
8
  export const supported = leafSupported
@@ -14,22 +14,45 @@ export function TiptapEditor(props: TiptapEditorProps) @{
14
14
  const inner = useRef<RichTextEditorHandle | null>(null)
15
15
  const [exposedHandle, setHandle] = useState<TiptapEditorHandle | null>(null)
16
16
  useImperativeHandle<TiptapEditorHandle | null>(props.ref, () => exposedHandle, [exposedHandle])
17
- const jsonReady = useRef(false)
18
17
  const lastJSON = useRef<string | undefined>(undefined)
18
+ const appliedHost = useRef<RichTextEditorHandle | null>(null)
19
+ // Latest doc requested for the host — by `json` prop or `setJSON`. Kept
20
+ // after a successful push so a rebound (recreated) host still gets it.
21
+ const desiredDoc = useRef<TiptapJSON | undefined>(undefined)
22
+
23
+ // Push the requested doc once both halves are up — the JSON bridge
24
+ // (async module load) and the bound host (ref/onReady timing). Skips the
25
+ // stamp the host already shows, so editor-emitted echoes and repeat props
26
+ // never reach setHTML; a different host always gets its own copy.
27
+ const applyDoc = () => {
28
+ const host = inner.current
29
+ const doc = desiredDoc.current
30
+ if (!host || doc === undefined || !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
+ }
19
40
 
20
41
  const bindHost = useCallback((h: RichTextEditorHandle | null) => {
21
42
  inner.current = h
43
+ if (h) {
44
+ // The ref binds inside the leaf's commit, before its own `value`
45
+ // seed — defer so `json` keeps precedence over `value`.
46
+ queueMicrotask(applyDoc)
47
+ }
22
48
 
23
49
  setHandle((previous) => previous?.native === h?.native ? previous : h ? ({
24
50
  getHTML: () => h.getHTML(),
25
51
  setHTML: (html: string) => h.setHTML(html),
26
52
  getJSON: () => (jsonBridgeReady() ? htmlToJSON(h.getHTML()) : null),
27
53
  setJSON: (doc) => {
28
- const html = jsonBridgeReady() ? jsonToHTML(doc) : null
29
- if (html != null) {
30
- lastJSON.current = JSON.stringify(doc)
31
- h.setHTML(html)
32
- }
54
+ desiredDoc.current = doc
55
+ applyDoc()
33
56
  },
34
57
  apply: (format) => h.apply(format),
35
58
  linkTo: (url, anchor) => h.linkTo(url, anchor),
@@ -45,25 +68,28 @@ export function TiptapEditor(props: TiptapEditorProps) @{
45
68
  }, [])
46
69
 
47
70
  // The bridge loads once per process — dynamic imports of the DOM-free
48
- // tiptap slices, plus the zeed-dom parse shim.
71
+ // tiptap slices, plus the zeed-dom parse shim. A doc that arrived before
72
+ // it settles is applied here, not dropped.
49
73
  useEffect(() => {
74
+ let alive = true
50
75
  ensureJSONBridge().then((ok) => {
51
- jsonReady.current = ok
76
+ if (!alive) return
52
77
  props.onJSONReady?.(ok)
78
+ applyDoc()
53
79
  })
80
+
81
+ return () => {
82
+ alive = false
83
+ }
54
84
  }, [])
55
85
 
56
- // `json` prop → editor content, when the bridge is up and the doc wasn't
57
- // emitted by the editor itself.
86
+ // `json` prop → editor content. It may land before the bridge settles or
87
+ // before the host binds — park it in desiredDoc and let the readiness
88
+ // flushes apply it.
58
89
  useEffect(() => {
59
- if (!props.json || !jsonBridgeReady() || !inner.current) return
60
- const stamp = JSON.stringify(props.json)
61
- if (stamp === lastJSON.current) return
62
- const html = jsonToHTML(props.json)
63
- if (html != null) {
64
- lastJSON.current = stamp
65
- inner.current.setHTML(html)
66
- }
90
+ if (!props.json) return
91
+ desiredDoc.current = props.json
92
+ applyDoc()
67
93
  }, [props.json])
68
94
 
69
95
  const emitJSON = () => {
@@ -71,6 +97,7 @@ export function TiptapEditor(props: TiptapEditorProps) @{
71
97
  const doc = htmlToJSON(inner.current.getHTML())
72
98
  if (doc) {
73
99
  lastJSON.current = JSON.stringify(doc)
100
+ appliedHost.current = inner.current
74
101
  props.onJSONChange?.(doc)
75
102
  }
76
103
  }
@@ -83,7 +110,12 @@ export function TiptapEditor(props: TiptapEditorProps) @{
83
110
  placeholder={props.placeholder}
84
111
  editable={props.editable}
85
112
  autofocus={props.autofocus}
86
- onReady={props.onReady}
113
+ onReady={() => {
114
+ // Host signalled ready after the leaf's own initial content seed —
115
+ // the last apply point that still outranks a `value` prop.
116
+ applyDoc()
117
+ props.onReady?.()
118
+ }}
87
119
  onChange={(html) => {
88
120
  props.onChange?.(html)
89
121
  emitJSON()
@@ -0,0 +1,129 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, expect, it, vi } from 'vitest'
3
+ import { act, createRoot } from 'octane'
4
+ import { Extension, Mark, Node } from '@tiptap/core'
5
+ import StarterKit from '@tiptap/starter-kit'
6
+ import { TiptapEditor } from './TiptapEditor.web.tsrx'
7
+ import type { TiptapEditorHandle } from './types'
8
+
9
+ const Spoiler = Mark.create({
10
+ name: 'spoiler',
11
+ parseHTML: () => [{ tag: 'span[data-spoiler]' }],
12
+ renderHTML: () => ['span', { 'data-spoiler': '' }, 0],
13
+ })
14
+
15
+ const Shout = Extension.create({
16
+ name: 'shout',
17
+ addCommands() {
18
+ return {
19
+ shout: () => ({ commands }: any) => commands.setContent('<p>SHOUT</p>'),
20
+ }
21
+ },
22
+ })
23
+
24
+ // Minimal schema for the starterKit:false path — callers dropping the
25
+ // built-in kit supply the base nodes themselves.
26
+ const Doc = Node.create({ name: 'doc', topNode: true, content: 'block+' })
27
+ const Paragraph = Node.create({
28
+ name: 'paragraph',
29
+ group: 'block',
30
+ content: 'inline*',
31
+ parseHTML: () => [{ tag: 'p' }],
32
+ renderHTML: () => ['p', 0],
33
+ })
34
+
35
+ const Text = Node.create({ name: 'text', group: 'inline' })
36
+
37
+ const roots: any[] = []
38
+
39
+ function mount(props: any = {}) {
40
+ const el = document.createElement('div')
41
+ document.body.append(el)
42
+ const root = createRoot(el)
43
+ roots.push(root)
44
+ let handle: TiptapEditorHandle | null = null
45
+ act(() =>
46
+ root.render(
47
+ <TiptapEditor
48
+ {...props}
49
+ ref={(next: TiptapEditorHandle | null) => {
50
+ handle = next
51
+ }}
52
+ />,
53
+ ),
54
+ )
55
+
56
+ return { el, root, get handle() { return handle } }
57
+ }
58
+
59
+ afterEach(() => {
60
+ for (const root of roots.splice(0)) act(() => root.unmount())
61
+ document.body.innerHTML = ''
62
+ vi.restoreAllMocks()
63
+ })
64
+
65
+ it('mounts StarterKit by default', () => {
66
+ const { handle } = mount({ value: '<p>Travel notes</p>' })
67
+ const editor = handle!.native
68
+ expect(editor.schema.marks.bold).toBeTruthy()
69
+ expect(editor.schema.nodes.heading).toBeTruthy()
70
+ expect(handle!.getHTML()).toContain('<p>Travel notes</p>')
71
+ })
72
+
73
+ it('registers caller mark extensions alongside StarterKit', () => {
74
+ const { handle } = mount({
75
+ value: '<p>shh <span data-spoiler>secret</span></p>',
76
+ web: { extensions: [Spoiler] },
77
+ })
78
+
79
+ const editor = handle!.native
80
+ expect(editor.schema.marks.spoiler).toBeTruthy()
81
+ expect(editor.schema.marks.bold).toBeTruthy()
82
+
83
+ const doc = handle!.getJSON()!
84
+ const marks = JSON.stringify(doc)
85
+ expect(marks).toContain('"spoiler"')
86
+ expect(handle!.getHTML()).toContain('data-spoiler')
87
+ })
88
+
89
+ it('exposes commands added by caller extensions', () => {
90
+ const { handle } = mount({ web: { extensions: [Shout] } })
91
+ handle!.native.commands.shout()
92
+ expect(handle!.getHTML()).toContain('SHOUT')
93
+ })
94
+
95
+ it('passes starterKit options to the built-in kit', () => {
96
+ const { handle } = mount({ web: { starterKit: { link: false } } })
97
+ const editor = handle!.native
98
+ expect(editor.schema.marks.link).toBeUndefined()
99
+ expect(editor.schema.marks.bold).toBeTruthy()
100
+ })
101
+
102
+ it('hands the schema to caller extensions when starterKit is false', () => {
103
+ const { handle } = mount({
104
+ web: { starterKit: false, extensions: [Doc, Paragraph, Text, Spoiler] },
105
+ })
106
+
107
+ const editor = handle!.native
108
+ expect(editor.schema.marks.spoiler).toBeTruthy()
109
+ expect(editor.schema.marks.bold).toBeUndefined()
110
+ expect(editor.schema.nodes.heading).toBeUndefined()
111
+ })
112
+
113
+ it('never registers StarterKit twice', () => {
114
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
115
+
116
+ const { handle } = mount({
117
+ web: { extensions: [StarterKit.configure({ link: false })] },
118
+ })
119
+
120
+ const kits = handle!.native.extensionManager.extensions.filter(
121
+ (extension: any) => extension.name === 'starterKit',
122
+ )
123
+
124
+ expect(kits).toHaveLength(1)
125
+ expect(handle!.native.schema.marks.link).toBeUndefined()
126
+ expect(warn).not.toHaveBeenCalledWith(
127
+ expect.stringContaining('Duplicate extension names'),
128
+ )
129
+ })