Skip to content

Commit 30c1e10

Browse files
authored
Merge pull request #2894 from superdoc-dev/merge/main-into-stable-2026-04-22
Merge main into stable
2 parents fb15999 + 1e50560 commit 30c1e10

24 files changed

Lines changed: 1015 additions & 116 deletions

packages/sdk/langs/python/superdoc/transport.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -435,24 +435,33 @@ async def connect(self) -> None:
435435
"""Ensure the host process is running and handshake is complete."""
436436
await self._ensure_connected()
437437

438+
async def _await_in_flight_cleanup(self) -> None:
439+
"""Wait for an already-running cleanup task, if any."""
440+
existing = self._cleanup_task
441+
if existing and not existing.done():
442+
try:
443+
await asyncio.shield(existing)
444+
except asyncio.CancelledError:
445+
raise
446+
except Exception:
447+
pass
448+
438449
async def dispose(self) -> None:
439450
"""Gracefully shut down the host process."""
440451
if self._state == _State.DISCONNECTED:
452+
# Reader-triggered cleanup flips state to DISCONNECTED before the
453+
# subprocess is fully reaped. If that cleanup is still in flight,
454+
# wait for it so dispose() doesn't return while the host process
455+
# is still being torn down.
456+
await self._await_in_flight_cleanup()
441457
return
442458
if self._state == _State.DISPOSING:
443459
# A reader-triggered cleanup is in flight (or an earlier teardown
444460
# left state in DISPOSING briefly). Wait for it so the caller
445461
# observes "host fully torn down" by the time dispose() returns.
446462
# shield() so a cancelled dispose() doesn't interrupt _cleanup
447463
# mid-flight and leak the host process.
448-
existing = self._cleanup_task
449-
if existing and not existing.done():
450-
try:
451-
await asyncio.shield(existing)
452-
except asyncio.CancelledError:
453-
raise
454-
except Exception:
455-
pass
464+
await self._await_in_flight_cleanup()
456465
return
457466

458467
self._stopping = True

packages/sdk/langs/python/tests/test_transport.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,54 @@ async def slow_cleanup(error):
764764
finally:
765765
_cleanup_wrapper(cli)
766766

767+
@pytest.mark.asyncio
768+
async def test_dispose_waits_for_cleanup_after_state_flips_disconnected(self):
769+
# `_cleanup()` flips state to DISCONNECTED before awaiting
770+
# `process.wait()`. dispose() must still wait for that cleanup task
771+
# instead of short-circuiting and returning while teardown is in
772+
# flight.
773+
cli = _mock_cli_bin({'handshake': 'ok'})
774+
try:
775+
transport = AsyncHostTransport(cli, startup_timeout_ms=5_000)
776+
await transport.connect()
777+
process = transport._process
778+
assert process is not None
779+
780+
wait_started = asyncio.Event()
781+
release = asyncio.Event()
782+
real_wait = process.wait
783+
784+
async def slow_wait():
785+
wait_started.set()
786+
await release.wait()
787+
return await real_wait()
788+
789+
process.wait = slow_wait # type: ignore[assignment]
790+
791+
transport._schedule_cleanup(
792+
SuperDocError('reader-overflow', code=HOST_PROTOCOL_ERROR),
793+
)
794+
cleanup_task = transport._cleanup_task
795+
assert cleanup_task is not None
796+
797+
await asyncio.wait_for(wait_started.wait(), timeout=2.0)
798+
assert transport.state == 'DISCONNECTED'
799+
assert transport._process is None
800+
assert not cleanup_task.done()
801+
802+
dispose_task = asyncio.create_task(transport.dispose())
803+
await asyncio.sleep(0.05)
804+
assert not dispose_task.done()
805+
806+
release.set()
807+
await dispose_task
808+
assert transport.state == 'DISCONNECTED'
809+
assert transport._cleanup_task is None
810+
await process.wait()
811+
assert process.returncode is not None
812+
finally:
813+
_cleanup_wrapper(cli)
814+
767815
@pytest.mark.asyncio
768816
async def test_ensure_connected_drains_in_flight_cleanup_before_spawn(self):
769817
# Round-3 regression: without this drain, `_start_host` reassigns

packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with-tabs.js';
2+
13
/**
24
* Insert a heading node at an absolute document position.
35
*
@@ -25,13 +27,13 @@ export const insertHeadingAt =
2527
},
2628
};
2729
const normalizedText = typeof text === 'string' ? text : '';
28-
const textNode = normalizedText.length > 0 ? state.schema.text(normalizedText) : null;
30+
// buildTextWithTabs splits '\t' into real tab nodes so exports emit <w:tab/>.
31+
const content = normalizedText.length > 0 ? buildTextWithTabs(state.schema, normalizedText, undefined) : null;
2932

3033
let paragraphNode;
3134
try {
3235
paragraphNode =
33-
paragraphType.createAndFill(attrs, textNode ?? undefined) ??
34-
paragraphType.create(attrs, textNode ? [textNode] : undefined);
36+
paragraphType.createAndFill(attrs, content ?? undefined) ?? paragraphType.create(attrs, content ?? undefined);
3537
} catch {
3638
return false;
3739
}

packages/super-editor/src/editors/v1/core/commands/insertHeadingAt.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ describe('insertHeadingAt', () => {
145145

146146
insertHeadingAt({ pos: 0, level: 1, text: 'Hello' })({ state, dispatch });
147147

148-
expect(state.schema.text).toHaveBeenCalledWith('Hello');
148+
expect(state.schema.text).toHaveBeenCalledWith('Hello', undefined);
149149
});
150150

151151
// --- tracked mode ---

packages/super-editor/src/editors/v1/core/commands/insertListItemAt.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getResolvedParagraphProperties } from '@extensions/paragraph/resolvedPropertiesCache.js';
2+
import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with-tabs.js';
23

34
/**
45
* Insert a list-item paragraph before/after a target list paragraph position.
@@ -42,12 +43,12 @@ export const insertListItemAt =
4243
};
4344

4445
const normalizedText = typeof text === 'string' ? text : '';
45-
const textNode = normalizedText.length > 0 ? state.schema.text(normalizedText) : undefined;
46+
// buildTextWithTabs splits '\t' into real tab nodes so exports emit <w:tab/>.
47+
const content = normalizedText.length > 0 ? buildTextWithTabs(state.schema, normalizedText, undefined) : undefined;
4648

4749
let paragraphNode;
4850
try {
49-
paragraphNode =
50-
paragraphType.createAndFill(attrs, textNode) ?? paragraphType.create(attrs, textNode ? [textNode] : undefined);
51+
paragraphNode = paragraphType.createAndFill(attrs, content) ?? paragraphType.create(attrs, content ?? undefined);
5152
} catch {
5253
return false;
5354
}

packages/super-editor/src/editors/v1/core/commands/insertListItemAt.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ describe('insertListItemAt', () => {
175175
dispatch,
176176
});
177177

178-
expect(state.schema.text).toHaveBeenCalledWith('New item');
178+
expect(state.schema.text).toHaveBeenCalledWith('New item', undefined);
179179
});
180180

181181
it('does not inherit sdBlockId from the target node when omitted', () => {

packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { buildTextWithTabs } from '../../document-api-adapters/helpers/text-with-tabs.js';
2+
13
/**
24
* Insert a paragraph node at an absolute document position.
35
*
@@ -16,13 +18,14 @@ export const insertParagraphAt =
1618
...(paraId ? { paraId } : undefined),
1719
};
1820
const normalizedText = typeof text === 'string' ? text : '';
19-
const textNode = normalizedText.length > 0 ? state.schema.text(normalizedText) : null;
21+
// buildTextWithTabs splits '\t' into real tab nodes so exports emit <w:tab/>
22+
// instead of a raw tab character inside <w:t>.
23+
const content = normalizedText.length > 0 ? buildTextWithTabs(state.schema, normalizedText, undefined) : null;
2024

2125
let paragraphNode;
2226
try {
2327
paragraphNode =
24-
paragraphType.createAndFill(attrs, textNode ?? undefined) ??
25-
paragraphType.create(attrs, textNode ? [textNode] : undefined);
28+
paragraphType.createAndFill(attrs, content ?? undefined) ?? paragraphType.create(attrs, content ?? undefined);
2629
} catch {
2730
return false;
2831
}

packages/super-editor/src/editors/v1/core/commands/insertParagraphAt.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ describe('insertParagraphAt', () => {
100100

101101
insertParagraphAt({ pos: 0, text: 'Hello' })({ state, dispatch });
102102

103-
expect(state.schema.text).toHaveBeenCalledWith('Hello');
103+
expect(state.schema.text).toHaveBeenCalledWith('Hello', undefined);
104104
});
105105

106106
it('sets forceTrackChanges meta when tracked is true', () => {

packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ function isDirectSingleCommentHighlightHit(target: EventTarget | null): boolean
101101
return getCommentHighlightThreadIds(target).length === 1;
102102
}
103103

104+
function isDirectTrackedChangeHit(target: EventTarget | null): boolean {
105+
if (!(target instanceof Element)) return false;
106+
return target.closest(TRACK_CHANGE_SELECTOR) != null;
107+
}
108+
104109
function resolveTrackChangeThreadId(target: EventTarget | null): string | null {
105110
if (!(target instanceof Element)) {
106111
return null;
@@ -220,11 +225,11 @@ function shouldIgnoreRepeatClickOnActiveComment(
220225
return false;
221226
}
222227

223-
// Direct clicks on commented text should place a caret at the clicked
224-
// position and let the comments plugin infer the active thread from the
225-
// resulting selection. Only preserve the pointerdown short-circuit for
226-
// nearby non-text surfaces, such as split-run gaps.
227-
if (isDirectSingleCommentHighlightHit(target)) {
228+
// Direct clicks on single-thread comment text or tracked-change text should
229+
// place a caret at the clicked position and let comment/thread activation be
230+
// inferred from the resulting selection. Only preserve the pointerdown
231+
// short-circuit for nearby non-text surfaces, such as split-run gaps.
232+
if (isDirectSingleCommentHighlightHit(target) || isDirectTrackedChangeHit(target)) {
228233
return false;
229234
}
230235

@@ -2292,7 +2297,9 @@ export class EditorInputManager {
22922297
}
22932298

22942299
#handleSingleCommentHighlightClick(event: PointerEvent, target: HTMLElement | null, editor: Editor): boolean {
2295-
if (isDirectSingleCommentHighlightHit(target)) {
2300+
// Direct hits on inline annotated text should not be intercepted here.
2301+
// Let generic click-to-position place the caret at the clicked pixel.
2302+
if (isDirectSingleCommentHighlightHit(target) || isDirectTrackedChangeHit(target)) {
22962303
return false;
22972304
}
22982305

packages/super-editor/src/editors/v1/core/presentation-editor/tests/EditorInputManager.activeCommentClick.test.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ describe('EditorInputManager - single-thread comment highlight clicks', () => {
238238
expect(viewportHost.setPointerCapture).toHaveBeenCalled();
239239
});
240240

241-
it('activates a tracked-change decoration when it owns the clicked visual surface', () => {
241+
it('lets direct clicks on a tracked-change decoration fall through to generic caret placement', () => {
242242
mockEditor.state.comments$.activeThreadId = 'comment-2';
243243

244244
const trackedChange = document.createElement('span');
@@ -248,12 +248,32 @@ describe('EditorInputManager - single-thread comment highlight clicks', () => {
248248

249249
dispatchPointerDown(trackedChange);
250250

251-
expect(mockEditor.commands.setCursorById).toHaveBeenCalledWith('change-1', {
252-
activeCommentId: 'change-1',
253-
});
254-
expect(resolvePointerPositionHit).not.toHaveBeenCalled();
255-
expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled();
256-
expect(viewportHost.setPointerCapture).not.toHaveBeenCalled();
251+
expect(mockEditor.commands.setCursorById).not.toHaveBeenCalled();
252+
expect(resolvePointerPositionHit).toHaveBeenCalled();
253+
expect(TextSelection.create as unknown as Mock).toHaveBeenCalled();
254+
expect(mockEditor.state.tr.setSelection).toHaveBeenCalled();
255+
expect(viewportHost.setPointerCapture).toHaveBeenCalled();
256+
});
257+
258+
it('lets repeat direct clicks on the active tracked-change decoration reposition caret', () => {
259+
mockEditor.state.comments$.activeThreadId = 'change-1';
260+
261+
const trackedChange = document.createElement('span');
262+
trackedChange.className = 'track-delete-dec highlighted';
263+
trackedChange.setAttribute('data-track-change-id', 'change-1');
264+
viewportHost.appendChild(trackedChange);
265+
266+
dispatchPointerDown(trackedChange);
267+
268+
expect(mockEditor.emit).not.toHaveBeenCalledWith(
269+
'commentsUpdate',
270+
expect.objectContaining({ activeCommentId: 'change-1' }),
271+
);
272+
expect(mockEditor.commands.setCursorById).not.toHaveBeenCalled();
273+
expect(resolvePointerPositionHit).toHaveBeenCalled();
274+
expect(TextSelection.create as unknown as Mock).toHaveBeenCalled();
275+
expect(mockEditor.state.tr.setSelection).toHaveBeenCalled();
276+
expect(viewportHost.setPointerCapture).toHaveBeenCalled();
257277
});
258278

259279
it('activates a nearby single-thread highlight when a split-run gap receives the pointer event', () => {

0 commit comments

Comments
 (0)