@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.
@@ -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
+ });