Skip to content

Commit 3f97cdf

Browse files
authored
Merge branch 'main' into nick/parts-system-ui-mutations
2 parents d676a68 + bc6e5bf commit 3f97cdf

23 files changed

Lines changed: 987 additions & 105 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
@@ -137,6 +137,11 @@ function isDirectSingleCommentHighlightHit(target: EventTarget | null): boolean
137137
return getCommentHighlightThreadIds(target).length === 1;
138138
}
139139

140+
function isDirectTrackedChangeHit(target: EventTarget | null): boolean {
141+
if (!(target instanceof Element)) return false;
142+
return target.closest(TRACK_CHANGE_SELECTOR) != null;
143+
}
144+
140145
function resolveTrackChangeThreadId(target: EventTarget | null): string | null {
141146
if (!(target instanceof Element)) {
142147
return null;
@@ -258,11 +263,11 @@ function shouldIgnoreRepeatClickOnActiveComment(
258263
return false;
259264
}
260265

261-
// Direct clicks on commented text should place a caret at the clicked
262-
// position and let the comments plugin infer the active thread from the
263-
// resulting selection. Only preserve the pointerdown short-circuit for
264-
// nearby non-text surfaces, such as split-run gaps.
265-
if (isDirectSingleCommentHighlightHit(target)) {
266+
// Direct clicks on single-thread comment text or tracked-change text should
267+
// place a caret at the clicked position and let comment/thread activation be
268+
// inferred from the resulting selection. Only preserve the pointerdown
269+
// short-circuit for nearby non-text surfaces, such as split-run gaps.
270+
if (isDirectSingleCommentHighlightHit(target) || isDirectTrackedChangeHit(target)) {
266271
return false;
267272
}
268273

@@ -2604,7 +2609,9 @@ export class EditorInputManager {
26042609
}
26052610

26062611
#handleSingleCommentHighlightClick(event: PointerEvent, target: HTMLElement | null, editor: Editor): boolean {
2607-
if (isDirectSingleCommentHighlightHit(target)) {
2612+
// Direct hits on inline annotated text should not be intercepted here.
2613+
// Let generic click-to-position place the caret at the clicked pixel.
2614+
if (isDirectSingleCommentHighlightHit(target) || isDirectTrackedChangeHit(target)) {
26082615
return false;
26092616
}
26102617

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)