@tangle-network/ui 11.5.0 → 11.6.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.
- package/CHANGELOG.md +16 -0
- package/README.md +27 -2
- package/dist/{chunk-7O22XQ4D.js → chunk-PLWRAS5Z.js} +1 -1
- package/dist/{chunk-NZLLDHEG.js → chunk-QGTIBEPX.js} +608 -420
- package/dist/{document-editor-pane-MX3FIZSE.js → document-editor-pane-A3JURPW5.js} +1 -1
- package/dist/{document-editor-pane-C3cYEDWZ.d.ts → document-editor-pane-IAEQn_Dl.d.ts} +8 -4
- package/dist/editor.d.ts +5 -4
- package/dist/editor.js +1 -1
- package/dist/files.d.ts +1 -1
- package/dist/files.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/editor/document-editor-pane.test.tsx +58 -0
- package/src/editor/editor-lazy.test.tsx +102 -0
- package/src/editor/editor-lazy.ts +40 -0
- package/src/editor/editor-loading.tsx +25 -0
- package/src/editor/editor-peers.test.ts +250 -0
- package/src/editor/editor-peers.ts +272 -0
- package/src/editor/editor-provider.test.tsx +226 -0
- package/src/editor/editor-provider.tsx +301 -265
- package/src/editor/editor-toolbar.tsx +1 -1
- package/src/editor/markdown-document-editor.test.tsx +121 -0
- package/src/editor/markdown-document-editor.tsx +114 -82
- package/src/editor/tiptap-editor.test.tsx +228 -0
- package/src/editor/tiptap-editor.tsx +162 -130
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the optional peers behind the `./editor` entry.
|
|
3
|
+
*
|
|
4
|
+
* A bundler resolves an uninstalled optional peer to a stub module that
|
|
5
|
+
* carries a default export only and throws when it evaluates. One static
|
|
6
|
+
* `import { EditorContent } from "@tiptap/react"` therefore fails the build of
|
|
7
|
+
* every consumer that does not install tiptap, even when nothing renders an
|
|
8
|
+
* editor. Every value taken from an optional peer must arrive through the
|
|
9
|
+
* dynamic `import()` calls in this module, and each component is built from
|
|
10
|
+
* the loaded namespaces. Type-only imports are erased, so they stay allowed.
|
|
11
|
+
*
|
|
12
|
+
* A bundler still reads the literal specifier in a dynamic `import()`. Vite
|
|
13
|
+
* and Rollup leave an unresolved one to run time on their own; esbuild does so
|
|
14
|
+
* only when the call carries a `.catch()`. Every import below therefore
|
|
15
|
+
* attaches `rethrow`. Webpack has no such rule and needs consumer
|
|
16
|
+
* configuration, which `packages/ui/README.md` gives.
|
|
17
|
+
*
|
|
18
|
+
* Three loaders keep each surface independent of the peers it does not use. A
|
|
19
|
+
* consumer that installs only tiptap can edit markdown locally. A consumer
|
|
20
|
+
* that installs only yjs and Hocuspocus can drive its own editor from
|
|
21
|
+
* `EditorProvider`'s context. Only the collaborative editor needs all six.
|
|
22
|
+
*
|
|
23
|
+
* A missing peer and a transient chunk fetch fail differently, so they carry
|
|
24
|
+
* different types: a missing peer throws `MissingEditorPeersError`, which
|
|
25
|
+
* `editor-lazy.ts` treats as permanent, and any other rejection keeps its own
|
|
26
|
+
* error and stays retryable.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type * as Hocuspocus from "@hocuspocus/provider";
|
|
30
|
+
import type * as TiptapCollaboration from "@tiptap/extension-collaboration";
|
|
31
|
+
import type * as TiptapCollaborationCaret from "@tiptap/extension-collaboration-caret";
|
|
32
|
+
import type * as TiptapReact from "@tiptap/react";
|
|
33
|
+
import type * as TiptapStarterKit from "@tiptap/starter-kit";
|
|
34
|
+
import type * as Yjs from "yjs";
|
|
35
|
+
|
|
36
|
+
/** Namespaces the local markdown editor needs. */
|
|
37
|
+
export interface DocumentEditorPeers {
|
|
38
|
+
react: typeof TiptapReact;
|
|
39
|
+
starterKit: typeof TiptapStarterKit;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Namespaces the collaboration transport needs. `EditorProvider` builds the
|
|
44
|
+
* document and the socket from these two alone, so it must not wait on the
|
|
45
|
+
* tiptap stack: a consumer can drive its own editor from the provider's
|
|
46
|
+
* context with nothing else installed.
|
|
47
|
+
*/
|
|
48
|
+
export interface EditorProviderPeers {
|
|
49
|
+
hocuspocus: typeof Hocuspocus;
|
|
50
|
+
yjs: typeof Yjs;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Namespaces the collaborative editor needs, on top of the two sets above. */
|
|
54
|
+
export interface CollaborationPeers extends DocumentEditorPeers, EditorProviderPeers {
|
|
55
|
+
collaboration: typeof TiptapCollaboration;
|
|
56
|
+
collaborationCaret: typeof TiptapCollaborationCaret;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DOCUMENT_PEERS_MISSING =
|
|
60
|
+
"@tangle-network/ui/editor needs its optional editor peers. " +
|
|
61
|
+
"Install @tiptap/react and @tiptap/starter-kit.";
|
|
62
|
+
|
|
63
|
+
const PROVIDER_PEERS_MISSING =
|
|
64
|
+
"@tangle-network/ui/editor needs its optional collaboration transport peers. " +
|
|
65
|
+
"Install @hocuspocus/provider and yjs.";
|
|
66
|
+
|
|
67
|
+
const COLLABORATION_PEERS_MISSING =
|
|
68
|
+
"@tangle-network/ui/editor needs its optional collaboration peers. " +
|
|
69
|
+
"Install @tiptap/react, @tiptap/starter-kit, @tiptap/extension-collaboration, " +
|
|
70
|
+
"@tiptap/extension-collaboration-caret, @hocuspocus/provider and yjs.";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A peer the editor needs is absent, or resolved to a stub that carries none
|
|
74
|
+
* of the members the editor calls. The condition holds for the rest of the
|
|
75
|
+
* session, because a package does not install itself mid-run.
|
|
76
|
+
*/
|
|
77
|
+
export class MissingEditorPeersError extends Error {
|
|
78
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
79
|
+
super(message, options);
|
|
80
|
+
this.name = "MissingEditorPeersError";
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* True for the error above. The name carries the answer, so a duplicated copy
|
|
86
|
+
* of this module in a consumer's bundle still reports its own error correctly.
|
|
87
|
+
*/
|
|
88
|
+
export function isMissingEditorPeersError(error: unknown): boolean {
|
|
89
|
+
return error instanceof Error && error.name === "MissingEditorPeersError";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The messages a bundler or a runtime gives for a module it cannot resolve. */
|
|
93
|
+
const RESOLUTION_FAILURE =
|
|
94
|
+
/could not resolve|cannot find (?:module|package)|can't resolve|failed to resolve|module not found/i;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True when the rejection says the package is not installed. Only such an
|
|
98
|
+
* error gets the install list: a transient chunk-fetch failure that reads as
|
|
99
|
+
* "install the peers" sends the reader to the wrong fix. An error this
|
|
100
|
+
* predicate does not match keeps its own message, so it can only
|
|
101
|
+
* under-report.
|
|
102
|
+
*/
|
|
103
|
+
export function isMissingPeerError(error: unknown): boolean {
|
|
104
|
+
return error instanceof Error && RESOLUTION_FAILURE.test(error.message);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Hands an `import()` rejection on unchanged. esbuild reports an unresolvable
|
|
109
|
+
* literal `import()` as a build error and defers it to run time only when the
|
|
110
|
+
* call carries a `.catch()`, so every peer import attaches this handler. It
|
|
111
|
+
* changes nothing at run time.
|
|
112
|
+
*/
|
|
113
|
+
function rethrow(error: unknown): never {
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Every optional peer the `./editor` entry needs at run time. The loaders
|
|
119
|
+
* import all but `@tiptap/core`, which the tiptap packages need in turn: a
|
|
120
|
+
* consumer that resolves it to nothing breaks the same way, so a failure that
|
|
121
|
+
* names it is a missing peer too. `scripts/validate-dist.mjs` holds the same
|
|
122
|
+
* list and rejects a build where the two disagree.
|
|
123
|
+
*/
|
|
124
|
+
const DEFERRED_PEERS = [
|
|
125
|
+
"@tiptap/core",
|
|
126
|
+
"@tiptap/react",
|
|
127
|
+
"@tiptap/starter-kit",
|
|
128
|
+
"@tiptap/extension-collaboration",
|
|
129
|
+
"@tiptap/extension-collaboration-caret",
|
|
130
|
+
"@hocuspocus/provider",
|
|
131
|
+
"yjs",
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
/** Every deferred peer a resolution failure names. */
|
|
135
|
+
function unresolvedPeersFrom(error: unknown): string[] {
|
|
136
|
+
if (!(error instanceof Error)) return [];
|
|
137
|
+
const named = DEFERRED_PEERS.filter((name) => error.message.includes(name));
|
|
138
|
+
// "@tiptap/extension-collaboration" is a prefix of the caret package, so a
|
|
139
|
+
// message about the caret names both. Drop a name another match contains.
|
|
140
|
+
return named.filter(
|
|
141
|
+
(name) => !named.some((other) => other !== name && other.includes(name)),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Turns a rejection that names an unresolved peer into the install-list error,
|
|
147
|
+
* and keeps the original as its cause. Any other rejection passes through, so
|
|
148
|
+
* a transient chunk fetch keeps its own message and stays retryable.
|
|
149
|
+
*/
|
|
150
|
+
export function asMissingEditorPeersError(
|
|
151
|
+
error: unknown,
|
|
152
|
+
missingMessage: string,
|
|
153
|
+
): unknown {
|
|
154
|
+
if (!isMissingPeerError(error)) return error;
|
|
155
|
+
const unresolved = unresolvedPeersFrom(error);
|
|
156
|
+
// A resolution failure that names none of the peers comes from somewhere
|
|
157
|
+
// else: an application chunk, or a dependency of a peer that did load.
|
|
158
|
+
// Installing the list would not fix it, and a later attempt can still
|
|
159
|
+
// succeed, so it keeps its own error and stays retryable.
|
|
160
|
+
if (unresolved.length === 0) return error;
|
|
161
|
+
const detail =
|
|
162
|
+
unresolved.length === 1 ? ` ${unresolved[0]} did not resolve.` : "";
|
|
163
|
+
return new MissingEditorPeersError(missingMessage + detail, { cause: error });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function loadPeers<T>(
|
|
167
|
+
load: () => Promise<T>,
|
|
168
|
+
missingMessage: string,
|
|
169
|
+
): Promise<T> {
|
|
170
|
+
try {
|
|
171
|
+
return await load();
|
|
172
|
+
} catch (error) {
|
|
173
|
+
throw asMissingEditorPeersError(error, missingMessage);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* True for a tiptap extension the editor can configure. A bundler can stub a
|
|
179
|
+
* missing optional peer as a silent namespace whose default export is an empty
|
|
180
|
+
* object, which is defined but carries no `configure`. Reading the member the
|
|
181
|
+
* factories call separates that shape from a real extension.
|
|
182
|
+
*/
|
|
183
|
+
function isConfigurableExtension(value: unknown): boolean {
|
|
184
|
+
return typeof (value as { configure?: unknown } | undefined)?.configure === "function";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Reads the members the editor calls, so a stub namespace fails with the
|
|
189
|
+
* install list, and not as an undefined-property crash in the middle of a
|
|
190
|
+
* render.
|
|
191
|
+
*/
|
|
192
|
+
function assertDocumentEditorPeers(
|
|
193
|
+
peers: DocumentEditorPeers,
|
|
194
|
+
missingMessage: string,
|
|
195
|
+
): void {
|
|
196
|
+
if (
|
|
197
|
+
typeof peers.react.useEditor !== "function" ||
|
|
198
|
+
peers.react.EditorContent === undefined ||
|
|
199
|
+
!isConfigurableExtension(peers.starterKit.default)
|
|
200
|
+
) {
|
|
201
|
+
throw new MissingEditorPeersError(missingMessage);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function assertEditorProviderPeers(
|
|
206
|
+
peers: EditorProviderPeers,
|
|
207
|
+
missingMessage: string,
|
|
208
|
+
): void {
|
|
209
|
+
if (
|
|
210
|
+
typeof peers.hocuspocus.HocuspocusProvider !== "function" ||
|
|
211
|
+
typeof peers.yjs.Doc !== "function"
|
|
212
|
+
) {
|
|
213
|
+
throw new MissingEditorPeersError(missingMessage);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function assertCollaborationPeers(peers: CollaborationPeers): void {
|
|
218
|
+
assertDocumentEditorPeers(peers, COLLABORATION_PEERS_MISSING);
|
|
219
|
+
assertEditorProviderPeers(peers, COLLABORATION_PEERS_MISSING);
|
|
220
|
+
if (
|
|
221
|
+
!isConfigurableExtension(peers.collaboration.default) ||
|
|
222
|
+
!isConfigurableExtension(peers.collaborationCaret.default)
|
|
223
|
+
) {
|
|
224
|
+
throw new MissingEditorPeersError(COLLABORATION_PEERS_MISSING);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function importDocumentEditorPeers(): Promise<DocumentEditorPeers> {
|
|
229
|
+
const [react, starterKit] = await Promise.all([
|
|
230
|
+
import("@tiptap/react").catch(rethrow),
|
|
231
|
+
import("@tiptap/starter-kit").catch(rethrow),
|
|
232
|
+
]);
|
|
233
|
+
return { react, starterKit };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function importEditorProviderPeers(): Promise<EditorProviderPeers> {
|
|
237
|
+
const [hocuspocus, yjs] = await Promise.all([
|
|
238
|
+
import("@hocuspocus/provider").catch(rethrow),
|
|
239
|
+
import("yjs").catch(rethrow),
|
|
240
|
+
]);
|
|
241
|
+
return { hocuspocus, yjs };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Resolve the collaboration transport's peers, or throw and name them. */
|
|
245
|
+
export async function loadEditorProviderPeers(): Promise<EditorProviderPeers> {
|
|
246
|
+
const peers = await loadPeers(importEditorProviderPeers, PROVIDER_PEERS_MISSING);
|
|
247
|
+
assertEditorProviderPeers(peers, PROVIDER_PEERS_MISSING);
|
|
248
|
+
return peers;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Resolve the local markdown editor's peers, or throw and name them. */
|
|
252
|
+
export async function loadDocumentEditorPeers(): Promise<DocumentEditorPeers> {
|
|
253
|
+
const peers = await loadPeers(importDocumentEditorPeers, DOCUMENT_PEERS_MISSING);
|
|
254
|
+
assertDocumentEditorPeers(peers, DOCUMENT_PEERS_MISSING);
|
|
255
|
+
return peers;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Resolve the collaborative editor's peers, or throw and name them. */
|
|
259
|
+
export async function loadCollaborationPeers(): Promise<CollaborationPeers> {
|
|
260
|
+
const peers = await loadPeers(async () => {
|
|
261
|
+
const [documentPeers, providerPeers, collaboration, collaborationCaret] =
|
|
262
|
+
await Promise.all([
|
|
263
|
+
importDocumentEditorPeers(),
|
|
264
|
+
importEditorProviderPeers(),
|
|
265
|
+
import("@tiptap/extension-collaboration").catch(rethrow),
|
|
266
|
+
import("@tiptap/extension-collaboration-caret").catch(rethrow),
|
|
267
|
+
]);
|
|
268
|
+
return { ...documentPeers, ...providerPeers, collaboration, collaborationCaret };
|
|
269
|
+
}, COLLABORATION_PEERS_MISSING);
|
|
270
|
+
assertCollaborationPeers(peers);
|
|
271
|
+
return peers;
|
|
272
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { act, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { Doc } from "yjs";
|
|
4
|
+
import { createEditorProvider, useEditorContext } from "./editor-provider";
|
|
5
|
+
import type * as EditorPeers from "./editor-peers";
|
|
6
|
+
import type { EditorProviderPeers } from "./editor-peers";
|
|
7
|
+
import { useEditorConnection } from "./use-editor";
|
|
8
|
+
|
|
9
|
+
interface ProviderOptions {
|
|
10
|
+
url: string;
|
|
11
|
+
name: string;
|
|
12
|
+
document: Doc;
|
|
13
|
+
onConnect: () => void;
|
|
14
|
+
onDisconnect: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class StubHocuspocusProvider {
|
|
18
|
+
static instances: StubHocuspocusProvider[] = [];
|
|
19
|
+
|
|
20
|
+
awareness = {
|
|
21
|
+
clientID: 1,
|
|
22
|
+
getStates: () => new Map(),
|
|
23
|
+
getLocalState: () => ({}),
|
|
24
|
+
setLocalStateField: vi.fn(),
|
|
25
|
+
on: vi.fn(),
|
|
26
|
+
off: vi.fn(),
|
|
27
|
+
};
|
|
28
|
+
connect = vi.fn();
|
|
29
|
+
disconnect = vi.fn();
|
|
30
|
+
destroy = vi.fn();
|
|
31
|
+
|
|
32
|
+
constructor(readonly options: ProviderOptions) {
|
|
33
|
+
StubHocuspocusProvider.instances.push(this);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Only the members the provider reads; yjs is the real package. */
|
|
38
|
+
function stubPeers() {
|
|
39
|
+
return {
|
|
40
|
+
yjs: { Doc },
|
|
41
|
+
hocuspocus: { HocuspocusProvider: StubHocuspocusProvider },
|
|
42
|
+
} as unknown as EditorProviderPeers;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ConnectionProbe() {
|
|
46
|
+
// Reads the context through the public hook, not through the provider
|
|
47
|
+
// module, so a context created per factory call would leave it unresolved.
|
|
48
|
+
const { state, isConnected } = useEditorConnection();
|
|
49
|
+
const { doc } = useEditorContext();
|
|
50
|
+
return (
|
|
51
|
+
<span data-testid="probe" data-fragment={doc.getXmlFragment("prosemirror").length}>
|
|
52
|
+
{state}
|
|
53
|
+
{isConnected ? " (live)" : ""}
|
|
54
|
+
</span>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
StubHocuspocusProvider.instances = [];
|
|
60
|
+
vi.doUnmock("./editor-peers");
|
|
61
|
+
vi.resetModules();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("createEditorProvider", () => {
|
|
65
|
+
it("builds the document and the transport from the loaded peers", () => {
|
|
66
|
+
const EditorProvider = createEditorProvider(stubPeers());
|
|
67
|
+
|
|
68
|
+
render(
|
|
69
|
+
<EditorProvider
|
|
70
|
+
websocketUrl="wss://collab.example/ws"
|
|
71
|
+
documentName="doc:readme"
|
|
72
|
+
token="jwt"
|
|
73
|
+
user={{ name: "Ada" }}
|
|
74
|
+
>
|
|
75
|
+
<ConnectionProbe />
|
|
76
|
+
</EditorProvider>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
expect(StubHocuspocusProvider.instances).toHaveLength(1);
|
|
80
|
+
const [provider] = StubHocuspocusProvider.instances;
|
|
81
|
+
expect(provider.options.url).toBe("wss://collab.example/ws");
|
|
82
|
+
expect(provider.options.name).toBe("doc:readme");
|
|
83
|
+
// The Y.Doc came from `peers.yjs.Doc`, and the editor asks it for this
|
|
84
|
+
// fragment, so the transport and the editor must share the one instance.
|
|
85
|
+
expect(provider.options.document).toBeInstanceOf(Doc);
|
|
86
|
+
expect(provider.awareness.setLocalStateField).toHaveBeenCalledWith(
|
|
87
|
+
"user",
|
|
88
|
+
expect.objectContaining({ name: "Ada" }),
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("serves the connection state to hooks that read the shared context", async () => {
|
|
93
|
+
const EditorProvider = createEditorProvider(stubPeers());
|
|
94
|
+
const onConnectionChange = vi.fn();
|
|
95
|
+
|
|
96
|
+
render(
|
|
97
|
+
<EditorProvider
|
|
98
|
+
websocketUrl="wss://collab.example/ws"
|
|
99
|
+
documentName="doc:readme"
|
|
100
|
+
token="jwt"
|
|
101
|
+
user={{ name: "Ada" }}
|
|
102
|
+
onConnectionChange={onConnectionChange}
|
|
103
|
+
>
|
|
104
|
+
<ConnectionProbe />
|
|
105
|
+
</EditorProvider>,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
expect(screen.getByTestId("probe")).toHaveTextContent("connecting");
|
|
109
|
+
|
|
110
|
+
const [provider] = StubHocuspocusProvider.instances;
|
|
111
|
+
act(() => {
|
|
112
|
+
provider.options.onConnect();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await waitFor(() => {
|
|
116
|
+
expect(screen.getByTestId("probe")).toHaveTextContent("connected (live)");
|
|
117
|
+
});
|
|
118
|
+
expect(onConnectionChange).toHaveBeenCalledWith("connected");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("destroys the transport when it unmounts", () => {
|
|
122
|
+
const EditorProvider = createEditorProvider(stubPeers());
|
|
123
|
+
|
|
124
|
+
const { unmount } = render(
|
|
125
|
+
<EditorProvider
|
|
126
|
+
websocketUrl="wss://collab.example/ws"
|
|
127
|
+
documentName="doc:readme"
|
|
128
|
+
token="jwt"
|
|
129
|
+
user={{ name: "Ada" }}
|
|
130
|
+
>
|
|
131
|
+
<ConnectionProbe />
|
|
132
|
+
</EditorProvider>,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const [provider] = StubHocuspocusProvider.instances;
|
|
136
|
+
unmount();
|
|
137
|
+
|
|
138
|
+
expect(provider.destroy).toHaveBeenCalledTimes(1);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("EditorProvider", () => {
|
|
143
|
+
it("renders with no tiptap package installed", async () => {
|
|
144
|
+
// The provider builds the document and the socket from yjs and Hocuspocus
|
|
145
|
+
// alone. A consumer that drives its own editor from this context installs
|
|
146
|
+
// those two and nothing else, so reaching for a tiptap namespace here
|
|
147
|
+
// would break that consumer's build-clean install.
|
|
148
|
+
const tiptapSpecifiers = [
|
|
149
|
+
"@tiptap/react",
|
|
150
|
+
"@tiptap/starter-kit",
|
|
151
|
+
"@tiptap/extension-collaboration",
|
|
152
|
+
"@tiptap/extension-collaboration-caret",
|
|
153
|
+
];
|
|
154
|
+
vi.resetModules();
|
|
155
|
+
for (const specifier of tiptapSpecifiers) {
|
|
156
|
+
vi.doMock(specifier, () => {
|
|
157
|
+
throw new Error(`Could not resolve "${specifier}"`);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
vi.doMock("yjs", () => ({ Doc }));
|
|
161
|
+
vi.doMock("@hocuspocus/provider", () => ({
|
|
162
|
+
HocuspocusProvider: StubHocuspocusProvider,
|
|
163
|
+
}));
|
|
164
|
+
const { EditorProvider } = await import("./editor-provider");
|
|
165
|
+
|
|
166
|
+
render(
|
|
167
|
+
<EditorProvider
|
|
168
|
+
websocketUrl="wss://collab.example/ws"
|
|
169
|
+
documentName="doc:readme"
|
|
170
|
+
token="jwt"
|
|
171
|
+
user={{ name: "Ada" }}
|
|
172
|
+
>
|
|
173
|
+
<span data-testid="child">custom editor surface</span>
|
|
174
|
+
</EditorProvider>,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await waitFor(() => {
|
|
178
|
+
expect(screen.getByTestId("child")).toBeInTheDocument();
|
|
179
|
+
});
|
|
180
|
+
expect(StubHocuspocusProvider.instances).toHaveLength(1);
|
|
181
|
+
|
|
182
|
+
for (const specifier of [...tiptapSpecifiers, "yjs", "@hocuspocus/provider"]) {
|
|
183
|
+
vi.doUnmock(specifier);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("holds children back until the peers load, then renders them", async () => {
|
|
188
|
+
let releasePeers: (() => void) | null = null;
|
|
189
|
+
const gate = new Promise<void>((resolve) => {
|
|
190
|
+
releasePeers = resolve;
|
|
191
|
+
});
|
|
192
|
+
vi.resetModules();
|
|
193
|
+
vi.doMock("./editor-peers", async () => ({
|
|
194
|
+
...(await vi.importActual<typeof EditorPeers>("./editor-peers")),
|
|
195
|
+
loadEditorProviderPeers: async () => {
|
|
196
|
+
await gate;
|
|
197
|
+
return stubPeers();
|
|
198
|
+
},
|
|
199
|
+
}));
|
|
200
|
+
const { EditorProvider } = await import("./editor-provider");
|
|
201
|
+
|
|
202
|
+
render(
|
|
203
|
+
<EditorProvider
|
|
204
|
+
websocketUrl="wss://collab.example/ws"
|
|
205
|
+
documentName="doc:readme"
|
|
206
|
+
token="jwt"
|
|
207
|
+
user={{ name: "Ada" }}
|
|
208
|
+
>
|
|
209
|
+
<span data-testid="child">connected surface</span>
|
|
210
|
+
</EditorProvider>,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// A child that rendered before the provider would throw out of
|
|
214
|
+
// `useEditorContext`, so the wrapper must render nothing while the gate
|
|
215
|
+
// holds the loader.
|
|
216
|
+
expect(screen.queryByTestId("child")).toBeNull();
|
|
217
|
+
expect(StubHocuspocusProvider.instances).toHaveLength(0);
|
|
218
|
+
|
|
219
|
+
releasePeers?.();
|
|
220
|
+
|
|
221
|
+
await waitFor(() => {
|
|
222
|
+
expect(screen.getByTestId("child")).toBeInTheDocument();
|
|
223
|
+
});
|
|
224
|
+
expect(StubHocuspocusProvider.instances).toHaveLength(1);
|
|
225
|
+
});
|
|
226
|
+
});
|