@agent-native/toolkit 0.6.0 → 0.7.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/README.md +17 -1
- package/dist/chat-history/ChatHistoryList.d.ts +103 -0
- package/dist/chat-history/ChatHistoryList.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.js +109 -0
- package/dist/chat-history/ChatHistoryList.js.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts +2 -0
- package/dist/chat-history/ChatHistoryList.spec.d.ts.map +1 -0
- package/dist/chat-history/ChatHistoryList.spec.js +283 -0
- package/dist/chat-history/ChatHistoryList.spec.js.map +1 -0
- package/dist/chat-history/index.d.ts +2 -0
- package/dist/chat-history/index.d.ts.map +1 -0
- package/dist/chat-history/index.js +2 -0
- package/dist/chat-history/index.js.map +1 -0
- package/dist/chat-history.css +314 -0
- package/package.json +17 -1
- package/src/app-shell/header-actions.tsx +84 -0
- package/src/app-shell/index.ts +1 -0
- package/src/chat-history/ChatHistoryList.spec.tsx +453 -0
- package/src/chat-history/ChatHistoryList.tsx +469 -0
- package/src/chat-history/index.ts +7 -0
- package/src/chat-history.css +314 -0
- package/src/collab-ui/AgentPresenceChip.tsx +69 -0
- package/src/collab-ui/LiveCursorOverlay.tsx +241 -0
- package/src/collab-ui/PresenceBar.tsx +346 -0
- package/src/collab-ui/RecentEditHighlights.tsx +225 -0
- package/src/collab-ui/RemoteSelectionRings.tsx +227 -0
- package/src/collab-ui/agent-identity.ts +16 -0
- package/src/collab-ui/index.ts +39 -0
- package/src/collab-ui/lead-client.ts +32 -0
- package/src/collab-ui/types.spec.ts +18 -0
- package/src/collab-ui/types.ts +96 -0
- package/src/composer/AgentComposerFrame.tsx +59 -0
- package/src/composer/ComposerPlusMenu.tsx +983 -0
- package/src/composer/MentionPopover.tsx +412 -0
- package/src/composer/PastedTextChip.tsx +150 -0
- package/src/composer/PromptComposer.spec.ts +73 -0
- package/src/composer/PromptComposer.tsx +718 -0
- package/src/composer/RealtimeVoiceMode.spec.tsx +771 -0
- package/src/composer/RealtimeVoiceMode.tsx +856 -0
- package/src/composer/TiptapComposer.spec.ts +390 -0
- package/src/composer/TiptapComposer.tsx +2936 -0
- package/src/composer/VoiceButton.spec.ts +33 -0
- package/src/composer/VoiceButton.tsx +255 -0
- package/src/composer/asset-picker-url.spec.ts +56 -0
- package/src/composer/asset-picker-url.ts +80 -0
- package/src/composer/attachment-accept.spec.ts +22 -0
- package/src/composer/attachment-accept.ts +71 -0
- package/src/composer/draft-key.spec.ts +19 -0
- package/src/composer/draft-key.ts +7 -0
- package/src/composer/extensions/FileReference.tsx +55 -0
- package/src/composer/extensions/MentionReference.tsx +93 -0
- package/src/composer/extensions/SkillReference.tsx +56 -0
- package/src/composer/index.ts +79 -0
- package/src/composer/pasted-text.spec.ts +162 -0
- package/src/composer/pasted-text.ts +160 -0
- package/src/composer/prompt-attachments.spec.ts +80 -0
- package/src/composer/prompt-attachments.ts +93 -0
- package/src/composer/realtime-voice-audio-level.spec.ts +32 -0
- package/src/composer/realtime-voice-audio-level.ts +64 -0
- package/src/composer/realtime-voice-transcript.spec.ts +95 -0
- package/src/composer/realtime-voice-transcript.ts +134 -0
- package/src/composer/runtime-adapters.tsx +343 -0
- package/src/composer/types.ts +66 -0
- package/src/composer/use-file-search.ts +64 -0
- package/src/composer/use-mention-search.ts +90 -0
- package/src/composer/use-skills.ts +45 -0
- package/src/composer/useRealtimeVoiceMode.spec.ts +818 -0
- package/src/composer/useRealtimeVoiceMode.tsx +2117 -0
- package/src/composer/useVoiceDictation.spec.ts +45 -0
- package/src/composer/useVoiceDictation.ts +1191 -0
- package/src/context-ui/ContextMeter.tsx +101 -0
- package/src/context-ui/ContextSegmentRow.tsx +184 -0
- package/src/context-ui/ContextTreemap.tsx +125 -0
- package/src/context-ui/ContextXRayPanel.tsx +377 -0
- package/src/context-ui/format.ts +42 -0
- package/src/context-ui/index.ts +15 -0
- package/src/context-ui/types.ts +43 -0
- package/src/design-tweaks/index.ts +1 -0
- package/src/design-tweaks/visual-style-controls.tsx +1084 -0
- package/src/editor/BubbleToolbar.tsx +253 -0
- package/src/editor/CodeBlockNode.tsx +254 -0
- package/src/editor/DragHandle.spec.ts +588 -0
- package/src/editor/DragHandle.ts +1437 -0
- package/src/editor/ImageExtension.roundtrip.spec.ts +117 -0
- package/src/editor/ImageExtension.ts +306 -0
- package/src/editor/RegistryBlockContext.tsx +107 -0
- package/src/editor/RegistryBlockNode.spec.ts +279 -0
- package/src/editor/RegistryBlockNode.tsx +652 -0
- package/src/editor/RichMarkdownEditor.collab.spec.ts +79 -0
- package/src/editor/RichMarkdownEditor.roundtrip.spec.ts +226 -0
- package/src/editor/RichMarkdownEditor.tsx +81 -0
- package/src/editor/RunId.ts +61 -0
- package/src/editor/SharedRichEditor.tsx +321 -0
- package/src/editor/SlashCommandMenu.tsx +310 -0
- package/src/editor/disableHistory.spec.ts +85 -0
- package/src/editor/extensions.ts +402 -0
- package/src/editor/gfmDoc.spec.ts +230 -0
- package/src/editor/gfmDoc.ts +92 -0
- package/src/editor/index.ts +93 -0
- package/src/editor/registrySlashCommands.ts +131 -0
- package/src/editor/structuralReorderUndo.spec.ts +144 -0
- package/src/editor/surgical-apply.spec.ts +279 -0
- package/src/editor/surgical-apply.ts +192 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +422 -0
- package/src/editor/useCollabReconcile.idempotent.spec.ts +324 -0
- package/src/editor/useCollabReconcile.ts +589 -0
- package/src/editor.css +510 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/use-mobile.tsx +21 -0
- package/src/hooks/use-toast.ts +188 -0
- package/src/index.ts +8 -0
- package/src/onboarding/index.ts +1 -0
- package/src/provider.tsx +47 -0
- package/src/sharing/VisibilityBadge.spec.ts +96 -0
- package/src/sharing/VisibilityBadge.tsx +37 -0
- package/src/sharing/index.ts +4 -0
- package/src/styles.css +21 -0
- package/src/ui/accordion.tsx +56 -0
- package/src/ui/alert-dialog.tsx +141 -0
- package/src/ui/alert.tsx +59 -0
- package/src/ui/aspect-ratio.tsx +5 -0
- package/src/ui/avatar.tsx +48 -0
- package/src/ui/badge.tsx +37 -0
- package/src/ui/breadcrumb.tsx +115 -0
- package/src/ui/button.tsx +78 -0
- package/src/ui/calendar.tsx +214 -0
- package/src/ui/card.tsx +86 -0
- package/src/ui/carousel.tsx +260 -0
- package/src/ui/chart.tsx +375 -0
- package/src/ui/checkbox.tsx +28 -0
- package/src/ui/collapsible.tsx +9 -0
- package/src/ui/command.spec.tsx +51 -0
- package/src/ui/command.tsx +174 -0
- package/src/ui/context-menu.tsx +202 -0
- package/src/ui/date-picker.tsx +64 -0
- package/src/ui/dialog.spec.tsx +43 -0
- package/src/ui/dialog.tsx +146 -0
- package/src/ui/drawer.tsx +126 -0
- package/src/ui/dropdown-menu.tsx +218 -0
- package/src/ui/form.tsx +177 -0
- package/src/ui/hover-card.tsx +27 -0
- package/src/ui/index.ts +52 -0
- package/src/ui/input-otp.tsx +69 -0
- package/src/ui/input.tsx +22 -0
- package/src/ui/label.tsx +24 -0
- package/src/ui/logo.tsx +36 -0
- package/src/ui/menubar.tsx +235 -0
- package/src/ui/navigation-menu.tsx +128 -0
- package/src/ui/pagination.tsx +121 -0
- package/src/ui/popover.tsx +44 -0
- package/src/ui/progress.tsx +26 -0
- package/src/ui/radio-group.tsx +42 -0
- package/src/ui/resizable.tsx +43 -0
- package/src/ui/scroll-area.tsx +46 -0
- package/src/ui/select.tsx +158 -0
- package/src/ui/separator.tsx +29 -0
- package/src/ui/sheet.tsx +160 -0
- package/src/ui/sidebar.tsx +777 -0
- package/src/ui/skeleton.tsx +18 -0
- package/src/ui/slider.tsx +26 -0
- package/src/ui/sonner.tsx +58 -0
- package/src/ui/spinner.tsx +17 -0
- package/src/ui/switch.tsx +27 -0
- package/src/ui/table.tsx +117 -0
- package/src/ui/tabs.tsx +53 -0
- package/src/ui/textarea.tsx +23 -0
- package/src/ui/toast.tsx +127 -0
- package/src/ui/toaster.tsx +33 -0
- package/src/ui/toggle-group.tsx +59 -0
- package/src/ui/toggle.tsx +43 -0
- package/src/ui/tooltip.tsx +53 -0
- package/src/ui/use-toast.ts +3 -0
- package/src/utils.ts +8 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { useEditor, type Editor } from "@tiptap/react";
|
|
4
|
+
import React, { act } from "react";
|
|
5
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import { createRichMarkdownExtensions } from "./RichMarkdownEditor.js";
|
|
9
|
+
import { useCollabReconcile, getEditorMarkdown } from "./useCollabReconcile.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Regression for the collab-reconcile escalation loop.
|
|
13
|
+
*
|
|
14
|
+
* When a block's stored markdown is NOT idempotent through the editor's
|
|
15
|
+
* parse/serialize (e.g. raw HTML in a GFM block: `<h1>...</h1>` serializes to
|
|
16
|
+
* the escaped `<h1>...`), the reconcile used to re-`setContent` the value
|
|
17
|
+
* every poll because the raw `value` never equalled the editor's serialized
|
|
18
|
+
* output. Each re-apply re-parsed already-applied content, the autosave echo
|
|
19
|
+
* came back as a "new" value, and the doc kept mutating (`<p>` → `<p>` →
|
|
20
|
+
* `&lt;p&gt;` …) while also fighting active typing.
|
|
21
|
+
*
|
|
22
|
+
* The fix makes the reconcile compare by DOC EQUIVALENCE: it tracks the raw
|
|
23
|
+
* value it last applied AND the editor's serialized output after that apply, so
|
|
24
|
+
* a re-supplied raw value or its own serialized echo is recognized and skipped.
|
|
25
|
+
* These tests drive the REAL hook + a REAL editor across simulated polls and
|
|
26
|
+
* assert the reconcile applies a non-idempotent value AT MOST ONCE and the
|
|
27
|
+
* editor stabilizes (no repeated emit / mutation).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
let container: HTMLDivElement;
|
|
31
|
+
let root: Root;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
(
|
|
35
|
+
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
|
|
36
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
37
|
+
container = document.createElement("div");
|
|
38
|
+
document.body.appendChild(container);
|
|
39
|
+
root = createRoot(container);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
act(() => root.unmount());
|
|
44
|
+
container.remove();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
interface HarnessProps {
|
|
48
|
+
value: string;
|
|
49
|
+
contentUpdatedAt: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Captured {
|
|
53
|
+
editor: Editor | null;
|
|
54
|
+
/** Every markdown string the editor emitted via onUpdate (autosave echoes). */
|
|
55
|
+
emitted: string[];
|
|
56
|
+
/** How many times the reconcile/seed called setContent. */
|
|
57
|
+
setContentCalls: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Mirrors the real SharedRichEditor wiring: an `onUpdate` that defers to the
|
|
62
|
+
* hook's guards (so programmatic setContent is ignored and only genuine edits
|
|
63
|
+
* "autosave"), and a `setContent` override that counts applies. Non-collab path
|
|
64
|
+
* (no ydoc) — the escalation reproduces there and keeps the harness simple.
|
|
65
|
+
*/
|
|
66
|
+
function makeHarness() {
|
|
67
|
+
const captured: Captured = {
|
|
68
|
+
editor: null,
|
|
69
|
+
emitted: [],
|
|
70
|
+
setContentCalls: 0,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
function Harness({ value, contentUpdatedAt }: HarnessProps) {
|
|
74
|
+
const guardsRef = React.useRef<ReturnType<
|
|
75
|
+
typeof useCollabReconcile
|
|
76
|
+
> | null>(null);
|
|
77
|
+
|
|
78
|
+
const editor = useEditor({
|
|
79
|
+
extensions: createRichMarkdownExtensions({ dialect: "gfm" }),
|
|
80
|
+
content: value,
|
|
81
|
+
onUpdate: ({ editor, transaction }) => {
|
|
82
|
+
const guards = guardsRef.current;
|
|
83
|
+
if (!guards || guards.shouldIgnoreUpdate(transaction)) return;
|
|
84
|
+
const markdown = getEditorMarkdown(editor);
|
|
85
|
+
if (!guards.registerEmitted(markdown)) return;
|
|
86
|
+
captured.emitted.push(markdown);
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
captured.editor = editor;
|
|
90
|
+
|
|
91
|
+
const guards = useCollabReconcile({
|
|
92
|
+
editor,
|
|
93
|
+
value,
|
|
94
|
+
contentUpdatedAt,
|
|
95
|
+
editable: true,
|
|
96
|
+
getMarkdown: getEditorMarkdown,
|
|
97
|
+
// These specs pin the LEGACY whole-document setContent path (the
|
|
98
|
+
// fallback when no parsed doc is available); the surgical path has its
|
|
99
|
+
// own coverage in surgical-apply.spec.ts. Disable it so `setContent`
|
|
100
|
+
// remains the counted apply mechanism.
|
|
101
|
+
parseValue: false,
|
|
102
|
+
// Mirror the hook's (fixed) defaultSetContent: hand the markdown string to
|
|
103
|
+
// tiptap-markdown's setContent override WITHOUT
|
|
104
|
+
// `parseOptions.preserveWhitespace`, which would otherwise route through
|
|
105
|
+
// insertContentAt's double-parse and escape the markdown. Counts applies.
|
|
106
|
+
setContent: (ed, v, options) => {
|
|
107
|
+
captured.setContentCalls += 1;
|
|
108
|
+
if (options.addToHistory === false) {
|
|
109
|
+
ed.chain()
|
|
110
|
+
.command(({ tr }) => {
|
|
111
|
+
tr.setMeta("addToHistory", false);
|
|
112
|
+
return true;
|
|
113
|
+
})
|
|
114
|
+
.setContent(v, { emitUpdate: options.emitUpdate })
|
|
115
|
+
.run();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
ed.commands.setContent(v);
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
guardsRef.current = guards;
|
|
122
|
+
|
|
123
|
+
return React.createElement("div", null);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { captured, Harness };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Flush the reconcile's timer task + any chained promises. */
|
|
130
|
+
async function flush() {
|
|
131
|
+
await act(async () => {
|
|
132
|
+
await Promise.resolve();
|
|
133
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
134
|
+
await Promise.resolve();
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const NON_IDEMPOTENT =
|
|
139
|
+
'<h1>Title</h1><ul class="contains-task-list"><li>One</li></ul>';
|
|
140
|
+
|
|
141
|
+
describe("useCollabReconcile idempotent-safe reconcile", () => {
|
|
142
|
+
it("applies a NON-idempotent value at most once across many polls and stabilizes", async () => {
|
|
143
|
+
const { captured, Harness } = makeHarness();
|
|
144
|
+
|
|
145
|
+
// Initial mount: editor seeded empty, first poll delivers the raw HTML.
|
|
146
|
+
act(() => {
|
|
147
|
+
root.render(
|
|
148
|
+
React.createElement(Harness, {
|
|
149
|
+
value: "",
|
|
150
|
+
contentUpdatedAt: "2024-01-01T00:00:00.000Z",
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
await flush();
|
|
155
|
+
|
|
156
|
+
// First real value arrives.
|
|
157
|
+
act(() => {
|
|
158
|
+
root.render(
|
|
159
|
+
React.createElement(Harness, {
|
|
160
|
+
value: NON_IDEMPOTENT,
|
|
161
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
await flush();
|
|
166
|
+
|
|
167
|
+
const serializedAfterApply = getEditorMarkdown(captured.editor!);
|
|
168
|
+
// The non-idempotent value escapes on apply, so the serialized form differs
|
|
169
|
+
// from the raw input — that divergence is exactly what used to loop.
|
|
170
|
+
expect(serializedAfterApply).not.toBe(NON_IDEMPOTENT);
|
|
171
|
+
const applyCountAfterFirst = captured.setContentCalls;
|
|
172
|
+
expect(applyCountAfterFirst).toBeGreaterThanOrEqual(1);
|
|
173
|
+
|
|
174
|
+
// Simulate the poll loop RE-SUPPLYING the SAME raw value many times (a
|
|
175
|
+
// source-sync / lagging poll that keeps handing back the stored HTML).
|
|
176
|
+
for (let i = 0; i < 6; i++) {
|
|
177
|
+
act(() => {
|
|
178
|
+
root.render(
|
|
179
|
+
React.createElement(Harness, {
|
|
180
|
+
value: NON_IDEMPOTENT,
|
|
181
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
await flush();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// It must NOT keep re-applying — the doc-equivalence guard recognizes the
|
|
189
|
+
// re-supplied raw value as already-applied.
|
|
190
|
+
expect(captured.setContentCalls).toBe(applyCountAfterFirst);
|
|
191
|
+
// And the editor's serialized output is stable (did not escalate).
|
|
192
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(serializedAfterApply);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("recognizes its own serialized echo (bumped timestamp) and does not re-apply", async () => {
|
|
196
|
+
const { captured, Harness } = makeHarness();
|
|
197
|
+
|
|
198
|
+
act(() => {
|
|
199
|
+
root.render(
|
|
200
|
+
React.createElement(Harness, {
|
|
201
|
+
value: "",
|
|
202
|
+
contentUpdatedAt: "2024-01-01T00:00:00.000Z",
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
await flush();
|
|
207
|
+
|
|
208
|
+
act(() => {
|
|
209
|
+
root.render(
|
|
210
|
+
React.createElement(Harness, {
|
|
211
|
+
value: NON_IDEMPOTENT,
|
|
212
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
await flush();
|
|
217
|
+
|
|
218
|
+
const serialized = getEditorMarkdown(captured.editor!);
|
|
219
|
+
const applyCount = captured.setContentCalls;
|
|
220
|
+
|
|
221
|
+
// Autosave persists the editor's SERIALIZED output, bumping updatedAt; the
|
|
222
|
+
// next poll hands that serialized echo back as the new authoritative value.
|
|
223
|
+
for (let i = 0; i < 5; i++) {
|
|
224
|
+
act(() => {
|
|
225
|
+
root.render(
|
|
226
|
+
React.createElement(Harness, {
|
|
227
|
+
value: serialized,
|
|
228
|
+
contentUpdatedAt: `2024-01-01T00:00:0${2 + i}.000Z`,
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
await flush();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// The echo is doc-equivalent to what the editor already shows -> no re-apply
|
|
236
|
+
// and no escalation.
|
|
237
|
+
expect(captured.setContentCalls).toBe(applyCount);
|
|
238
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(serialized);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("still applies a genuinely-new external edit after a non-idempotent apply", async () => {
|
|
242
|
+
const { captured, Harness } = makeHarness();
|
|
243
|
+
|
|
244
|
+
act(() => {
|
|
245
|
+
root.render(
|
|
246
|
+
React.createElement(Harness, {
|
|
247
|
+
value: "",
|
|
248
|
+
contentUpdatedAt: "2024-01-01T00:00:00.000Z",
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
await flush();
|
|
253
|
+
|
|
254
|
+
act(() => {
|
|
255
|
+
root.render(
|
|
256
|
+
React.createElement(Harness, {
|
|
257
|
+
value: NON_IDEMPOTENT,
|
|
258
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
await flush();
|
|
263
|
+
const applyCount = captured.setContentCalls;
|
|
264
|
+
|
|
265
|
+
// A real, different agent edit (clean idempotent markdown) with a newer
|
|
266
|
+
// timestamp must still reconcile in.
|
|
267
|
+
act(() => {
|
|
268
|
+
root.render(
|
|
269
|
+
React.createElement(Harness, {
|
|
270
|
+
value: "# Brand New Heading\n\nFresh body.",
|
|
271
|
+
contentUpdatedAt: "2024-01-01T00:00:05.000Z",
|
|
272
|
+
}),
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
await flush();
|
|
276
|
+
|
|
277
|
+
expect(captured.setContentCalls).toBeGreaterThan(applyCount);
|
|
278
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
279
|
+
"# Brand New Heading\n\nFresh body.",
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("does not re-apply an idempotent value that is re-polled unchanged", async () => {
|
|
284
|
+
const { captured, Harness } = makeHarness();
|
|
285
|
+
const CLEAN = "# Heading\n\nA paragraph with **bold** text.";
|
|
286
|
+
|
|
287
|
+
act(() => {
|
|
288
|
+
root.render(
|
|
289
|
+
React.createElement(Harness, {
|
|
290
|
+
value: "",
|
|
291
|
+
contentUpdatedAt: "2024-01-01T00:00:00.000Z",
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
});
|
|
295
|
+
await flush();
|
|
296
|
+
|
|
297
|
+
act(() => {
|
|
298
|
+
root.render(
|
|
299
|
+
React.createElement(Harness, {
|
|
300
|
+
value: CLEAN,
|
|
301
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
await flush();
|
|
306
|
+
const applyCount = captured.setContentCalls;
|
|
307
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(CLEAN);
|
|
308
|
+
|
|
309
|
+
for (let i = 0; i < 4; i++) {
|
|
310
|
+
act(() => {
|
|
311
|
+
root.render(
|
|
312
|
+
React.createElement(Harness, {
|
|
313
|
+
value: CLEAN,
|
|
314
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
await flush();
|
|
319
|
+
}
|
|
320
|
+
// Idempotent + unchanged -> the existing equality guard already skips.
|
|
321
|
+
expect(captured.setContentCalls).toBe(applyCount);
|
|
322
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(CLEAN);
|
|
323
|
+
});
|
|
324
|
+
});
|