1export interface ComposerNativeEventSnapshot {
2 readonly eventCount: number;
3 readonly value: string;
4 readonly selection: ComposerEditorSelection | null;
5}
6
7interface ComposerEditorSelection {
8 readonly start: number;
9 readonly end: number;
10}
11
12export function acknowledgeComposerNativeEvent(
13 mostRecentEventCount: number,
14 incomingEventCount: number,
15): number | null {
16 if (!Number.isSafeInteger(incomingEventCount) || incomingEventCount < mostRecentEventCount) {
17 return null;
18 }
19 return incomingEventCount;
20}
21
22export function resolveComposerControlledEventCount(
23 value: string,
24 selection: ComposerEditorSelection | null,
25 mostRecentEventCount: number,
26 snapshots: ReadonlyArray<ComposerNativeEventSnapshot>,
27): number {
28 let newestValueEventCount: number | null = null;
29 for (let index = snapshots.length - 1; index >= 0; index -= 1) {
30 const snapshot = snapshots[index];
31 if (snapshot?.value !== value) continue;
32
33 newestValueEventCount ??= snapshot.eventCount;
34 if (selection === null || snapshotSelectionMatches(snapshot, selection)) {
35 return snapshot.eventCount;
36 }
37 }
38
39 // A value emitted by native paired with a different selection is an
40 // intermediate React render. Keep it behind the native revision so it
41 // cannot move the caret while newer keystrokes are being processed.
42 if (newestValueEventCount !== null && mostRecentEventCount > 0) {
43 return Math.min(newestValueEventCount, mostRecentEventCount - 1);
44 }
45
46 return mostRecentEventCount;
47}
48
49// A snapshot without a selection describes a state the editor applied itself
50// (an assumed controlled document, where the native side may have bounded the
51// caret). Revision stamping treats it as matching any controlled selection so
52// a parent caret move on the assumed value stays at the assumed revision and
53// passes the editor's staleness guard. Echo detection must not reuse this
54// wildcard: an echo payload serializes `selection: null`, which would drop
55// that caret move instead of applying it.
56function snapshotSelectionMatches(
57 snapshot: ComposerNativeEventSnapshot,
58 selection: ComposerEditorSelection,
59): boolean {
60 if (snapshot.selection === null) return true;
61 return snapshot.selection.start === selection.start && snapshot.selection.end === selection.end;
62}
63
64export function isComposerNativeEcho(
65 value: string,
66 selection: ComposerEditorSelection | null,
67 eventCount: number,
68 snapshots: ReadonlyArray<ComposerNativeEventSnapshot>,
69): boolean {
70 for (let index = snapshots.length - 1; index >= 0; index -= 1) {
71 const snapshot = snapshots[index];
72 if (
73 snapshot !== undefined &&
74 snapshot.eventCount === eventCount &&
75 snapshot.value === value &&
76 (selection === null ||
77 (snapshot.selection !== null &&
78 snapshot.selection.start === selection.start &&
79 snapshot.selection.end === selection.end))
80 ) {
81 return true;
82 }
83 }
84 return false;
85}
86
87/**
88 * Records that a parent-driven controlled document was handed to the native
89 * editor. From that point the acknowledged snapshot history describes a
90 * superseded native state, so it is replaced with the assumed applied state;
91 * a later parent update back to a previously acknowledged value must classify
92 * as a fresh edit, not as a native echo the editor would drop. Native events
93 * that raced past the controlled revision stay authoritative and are kept.
94 */
95export function assumeComposerControlledState(
96 snapshots: ReadonlyArray<ComposerNativeEventSnapshot>,
97 eventCount: number,
98 value: string,
99): ComposerNativeEventSnapshot[] {
100 return [
101 { eventCount, value, selection: null },
102 ...snapshots.filter((snapshot) => snapshot.eventCount > eventCount),
103 ];
104}
105
106export function pruneAcknowledgedComposerNativeEvents(
107 snapshots: ReadonlyArray<ComposerNativeEventSnapshot>,
108 acknowledgedEventCount: number,
109): ComposerNativeEventSnapshot[] {
110 // The newest acknowledged snapshot must survive pruning: it is what lets a
111 // later, unrelated re-render classify the settled composer state as a native
112 // echo instead of a parent-driven edit that would re-control the caret (and
113 // reset the keyboard's autocorrect context on iOS).
114 let latestAcknowledgedIndex = -1;
115 for (let index = snapshots.length - 1; index >= 0; index -= 1) {
116 const snapshot = snapshots[index];
117 if (snapshot !== undefined && snapshot.eventCount <= acknowledgedEventCount) {
118 latestAcknowledgedIndex = index;
119 break;
120 }
121 }
122 return snapshots.filter(
123 (snapshot, index) =>
124 index === latestAcknowledgedIndex || snapshot.eventCount > acknowledgedEventCount,
125 );
126}