authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-14 10:45:43+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-14 10:45:43+02:00
logb3b4b57794476c2e130d38f9243f3c5da1a0cbe7
treecce08c0a6870c56657217b73eaf1cf68b84aa126
parent6ae44b418a24dc021cf042cbb1e60ebeb47e160f
signature Signed by PGP key B5690EEEBB952194

fix(mobile): preserve keyboard suggestions while typing (#6323)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

6 files changed, 242 insertions(+), 53 deletions(-)

apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt+7
......@@ -252,6 +252,9 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
252252 val textLength = editor.text?.length ?: 0
253253 val safeStart = start.coerceIn(0, textLength)
254254 val safeEnd = end.coerceIn(0, textLength)
255 // Re-applying an unchanged selection resets the keyboard's suggestion
256 // state, so a no-op assignment must be skipped.
257 if (editor.selectionStart == safeStart && editor.selectionEnd == safeEnd) return
255258 editor.setSelection(safeStart, safeEnd)
256259 }
257260
......@@ -281,6 +284,10 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView(
281284 )
282285
283286 private fun emitSelectionChange(start: Int, end: Int) {
287 // Caret moves advance the revision counter like text edits do: a
288 // controlled payload computed before this move is stale and must fail the
289 // revision guard instead of yanking the caret back mid-typing.
290 nativeEventCount += 1
284291 onComposerSelectionChange(
285292 mapOf(
286293 "value" to editor.text.toString(),
apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift+17-1
......@@ -489,6 +489,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
489489 return
490490 }
491491 restoreBaseTypingAttributes()
492 // UIKit moves the selection before textViewDidChange runs. Emitting here
493 // would pair the post-edit text with a pre-edit revision counter, so let
494 // the change event that follows carry both; only pure caret moves emit.
495 guard self.textView.serializedText() == value else {
496 return
497 }
492498 emitSelection()
493499 }
494500
......@@ -774,8 +780,12 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
774780 }
775781
776782 private func emitSelection() {
783 // Caret moves advance the revision counter like text edits do: a
784 // controlled payload computed before this move is stale and must fail the
785 // revision guard instead of yanking the caret back mid-typing.
777786 let currentValue = textView.serializedText()
778787 let selection = sourceSelection()
788 nativeEventCount += 1
779789 onComposerSelectionChange([
780790 "value": currentValue,
781791 "selection": ["start": selection.start, "end": selection.end],
......@@ -817,10 +827,16 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro
817827 NSMaxRange(nextRange) <= textView.attributedText.length else {
818828 return
819829 }
830 self.requestedSelection = nil
831 // Programmatically assigning selectedRange resets the keyboard's
832 // autocorrect and predictive-text context even when the range is
833 // unchanged, so a no-op assignment must be skipped.
834 guard !NSEqualRanges(nextRange, textView.selectedRange) else {
835 return
836 }
820837 isApplyingControlledValue = true
821838 textView.selectedRange = nextRange
822839 isApplyingControlledValue = false
823 self.requestedSelection = nil
824840 }
825841
826842 private func updatePlaceholderVisibility() {
apps/mobile/src/native/T3ComposerEditor.ios.tsx+38-22
......@@ -19,6 +19,7 @@ import { useFontFamily } from "../lib/useFontFamily";
1919import { useScaledTextRole } from "../features/settings/appearance/useScaledTextRole";
2020import {
2121 acknowledgeComposerNativeEvent,
22 assumeComposerControlledState,
2223 isComposerNativeEcho,
2324 pruneAcknowledgedComposerNativeEvents,
2425 resolveComposerControlledEventCount,
......@@ -102,11 +103,11 @@ export function ComposerEditor({
102103 const nativeRef = useRef<NativeComposerEditorRef>(null);
103104 const mostRecentEventCountRef = useRef(0);
104105 const [mostRecentEventCount, setMostRecentEventCount] = useState(0);
105 const [nativeEventSequence, setNativeEventSequence] = useState(0);
106 const previousRenderedEventSequenceRef = useRef(0);
107 const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([
108 { eventCount: 0, value: props.value, selection: selection ?? null },
109 ]);
106 const [, forceNativeEventRender] = useState(0);
107 // The native editor mounts empty, so the snapshot history starts empty: the
108 // first controlled payload must be a non-echo so a restored draft (or a
109 // recycled native view) is applied rather than skipped.
110 const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([]);
110111 const confirmedTokensRef = useRef(collectComposerInlineTokens(props.value));
111112 const bodyText = useScaledTextRole("body");
112113 const textColor = useThemeColor("--color-foreground");
......@@ -154,15 +155,16 @@ export function ComposerEditor({
154155 })),
155156 );
156157 }, [props.value, skillLabels]);
157 const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current;
158 const controlledEventCount = includesNativeEvent
159 ? resolveComposerControlledEventCount(
160 props.value,
161 selection ?? null,
162 mostRecentEventCount,
163 nativeEventSnapshotsRef.current,
164 )
165 : mostRecentEventCount;
158 // Every render resolves against the snapshot history, so a render whose
159 // (value, selection) lags the acknowledged native state is stamped behind
160 // the native revision and rejected by the editor instead of re-applying a
161 // stale caret or stale text mid-typing.
162 const controlledEventCount = resolveComposerControlledEventCount(
163 props.value,
164 selection ?? null,
165 mostRecentEventCount,
166 nativeEventSnapshotsRef.current,
167 );
166168 const acknowledgesLatestNativeEvent = isComposerNativeEcho(
167169 props.value,
168170 selection ?? null,
......@@ -170,9 +172,7 @@ export function ComposerEditor({
170172 nativeEventSnapshotsRef.current,
171173 );
172174 const isNativeEcho =
173 includesNativeEvent &&
174 controlledEventCount === mostRecentEventCount &&
175 acknowledgesLatestNativeEvent;
175 controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent;
176176 const controlledDocumentJson = JSON.stringify({
177177 value: props.value,
178178 selection: isNativeEcho ? null : (selection ?? null),
......@@ -180,9 +180,6 @@ export function ComposerEditor({
180180 mostRecentEventCount: controlledEventCount,
181181 isNativeEcho,
182182 });
183 useEffect(() => {
184 previousRenderedEventSequenceRef.current = nativeEventSequence;
185 }, [nativeEventSequence]);
186183 useEffect(() => {
187184 if (!acknowledgesLatestNativeEvent) return;
188185 nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(
......@@ -190,6 +187,18 @@ export function ComposerEditor({
190187 mostRecentEventCount,
191188 );
192189 }, [acknowledgesLatestNativeEvent, mostRecentEventCount]);
190 const assumedValue = props.value;
191 useEffect(() => {
192 // A native event that arrived after this render was committed moves the
193 // acknowledged revision forward; the editor rejects this payload, so the
194 // snapshot history must not assume it applied.
195 if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return;
196 nativeEventSnapshotsRef.current = assumeComposerControlledState(
197 nativeEventSnapshotsRef.current,
198 controlledEventCount,
199 assumedValue,
200 );
201 }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]);
193202 const acceptNativeEvent = useCallback(
194203 (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => {
195204 const acknowledgedEventCount = acknowledgeComposerNativeEvent(
......@@ -257,7 +266,7 @@ export function ComposerEditor({
257266 onChangeText(event.nativeEvent.value);
258267 onSelectionChange?.(event.nativeEvent.selection);
259268 setMostRecentEventCount(acknowledgedEventCount);
260 setNativeEventSequence((sequence) => sequence + 1);
269 forceNativeEventRender((sequence) => sequence + 1);
261270 }}
262271 onComposerSelectionChange={(event) => {
263272 const acknowledgedEventCount = acceptNativeEvent(
......@@ -266,9 +275,16 @@ export function ComposerEditor({
266275 event.nativeEvent.selection,
267276 );
268277 if (acknowledgedEventCount === false) return;
278 // A selection change that raced a text mutation can carry post-edit
279 // text. It must reach the parent alongside the acknowledged revision,
280 // or the next render stamps the stale draft at that revision and can
281 // re-apply it over the newer native text.
282 if (event.nativeEvent.value !== props.value) {
283 onChangeText(event.nativeEvent.value);
284 }
269285 onSelectionChange?.(event.nativeEvent.selection);
270286 setMostRecentEventCount(acknowledgedEventCount);
271 setNativeEventSequence((sequence) => sequence + 1);
287 forceNativeEventRender((sequence) => sequence + 1);
272288 }}
273289 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
274290 onComposerFocus={onFocus}
apps/mobile/src/native/T3ComposerEditor.native.tsx+39-22
......@@ -21,6 +21,7 @@ import { useFontFamily } from "../lib/useFontFamily";
2121import { useThemeColor } from "../lib/useThemeColor";
2222import {
2323 acknowledgeComposerNativeEvent,
24 assumeComposerControlledState,
2425 isComposerNativeEcho,
2526 pruneAcknowledgedComposerNativeEvents,
2627 resolveComposerControlledEventCount,
......@@ -103,11 +104,11 @@ export function ComposerEditor({
103104 const nativeRef = useRef<NativeComposerEditorRef>(null);
104105 const mostRecentEventCountRef = useRef(0);
105106 const [mostRecentEventCount, setMostRecentEventCount] = useState(0);
106 const [nativeEventSequence, setNativeEventSequence] = useState(0);
107 const previousRenderedEventSequenceRef = useRef(0);
108 const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([
109 { eventCount: 0, value: props.value, selection: selection ?? null },
110 ]);
107 const [, forceNativeEventRender] = useState(0);
108 // The native editor mounts empty, so the snapshot history starts empty: the
109 // first controlled payload must be a non-echo so a restored draft (or a
110 // recycled native view) is applied rather than skipped.
111 const nativeEventSnapshotsRef = useRef<ComposerNativeEventSnapshot[]>([]);
111112 const [initialConfirmedTokens] = useState(() => collectComposerInlineTokens(props.value));
112113 const confirmedTokensRef = useRef(initialConfirmedTokens);
113114 const textColor = useThemeColor("--color-foreground");
......@@ -155,15 +156,16 @@ export function ComposerEditor({
155156 })),
156157 );
157158 }, [props.value, skillLabels]);
158 const includesNativeEvent = nativeEventSequence !== previousRenderedEventSequenceRef.current;
159 const controlledEventCount = includesNativeEvent
160 ? resolveComposerControlledEventCount(
161 props.value,
162 selection ?? null,
163 mostRecentEventCount,
164 nativeEventSnapshotsRef.current,
165 )
166 : mostRecentEventCount;
159 // Every render resolves against the snapshot history, so a render whose
160 // (value, selection) lags the acknowledged native state is stamped behind
161 // the native revision and rejected by the editor instead of re-applying a
162 // stale caret or stale text mid-typing.
163 const controlledEventCount = resolveComposerControlledEventCount(
164 props.value,
165 selection ?? null,
166 mostRecentEventCount,
167 nativeEventSnapshotsRef.current,
168 );
167169 const acknowledgesLatestNativeEvent = isComposerNativeEcho(
168170 props.value,
169171 selection ?? null,
......@@ -171,9 +173,7 @@ export function ComposerEditor({
171173 nativeEventSnapshotsRef.current,
172174 );
173175 const isNativeEcho =
174 includesNativeEvent &&
175 controlledEventCount === mostRecentEventCount &&
176 acknowledgesLatestNativeEvent;
176 controlledEventCount === mostRecentEventCount && acknowledgesLatestNativeEvent;
177177 const controlledDocumentJson = JSON.stringify({
178178 value: props.value,
179179 selection: isNativeEcho ? null : (selection ?? null),
......@@ -181,9 +181,6 @@ export function ComposerEditor({
181181 mostRecentEventCount: controlledEventCount,
182182 isNativeEcho,
183183 });
184 useEffect(() => {
185 previousRenderedEventSequenceRef.current = nativeEventSequence;
186 }, [nativeEventSequence]);
187184 useEffect(() => {
188185 if (!acknowledgesLatestNativeEvent) return;
189186 nativeEventSnapshotsRef.current = pruneAcknowledgedComposerNativeEvents(
......@@ -191,6 +188,18 @@ export function ComposerEditor({
191188 mostRecentEventCount,
192189 );
193190 }, [acknowledgesLatestNativeEvent, mostRecentEventCount]);
191 const assumedValue = props.value;
192 useEffect(() => {
193 // A native event that arrived after this render was committed moves the
194 // acknowledged revision forward; the editor rejects this payload, so the
195 // snapshot history must not assume it applied.
196 if (isNativeEcho || controlledEventCount !== mostRecentEventCountRef.current) return;
197 nativeEventSnapshotsRef.current = assumeComposerControlledState(
198 nativeEventSnapshotsRef.current,
199 controlledEventCount,
200 assumedValue,
201 );
202 }, [assumedValue, controlledEventCount, isNativeEcho, controlledDocumentJson]);
194203 const acceptNativeEvent = useCallback(
195204 (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => {
196205 const acknowledgedEventCount = acknowledgeComposerNativeEvent(
......@@ -263,7 +272,7 @@ export function ComposerEditor({
263272 onChangeText(event.nativeEvent.value);
264273 onSelectionChange?.(event.nativeEvent.selection);
265274 setMostRecentEventCount(acknowledgedEventCount);
266 setNativeEventSequence((sequence) => sequence + 1);
275 forceNativeEventRender((sequence) => sequence + 1);
267276 }}
268277 onComposerSelectionChange={(event) => {
269278 const acknowledgedEventCount = acceptNativeEvent(
......@@ -272,9 +281,17 @@ export function ComposerEditor({
272281 event.nativeEvent.selection,
273282 );
274283 if (acknowledgedEventCount === false) return;
284 // Android emits the selection change mid-mutation, before the change
285 // event, so the payload can carry post-edit text. It must reach the
286 // parent alongside the acknowledged revision, or the next render
287 // stamps the stale draft at that revision and can re-apply it over
288 // the newer native text.
289 if (event.nativeEvent.value !== props.value) {
290 onChangeText(event.nativeEvent.value);
291 }
275292 onSelectionChange?.(event.nativeEvent.selection);
276293 setMostRecentEventCount(acknowledgedEventCount);
277 setNativeEventSequence((sequence) => sequence + 1);
294 forceNativeEventRender((sequence) => sequence + 1);
278295 }}
279296 onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)}
280297 onComposerFocus={onFocus}
apps/mobile/src/native/composerEditorRevision.test.ts+87-2
......@@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest";
22
33import {
44 acknowledgeComposerNativeEvent,
5 assumeComposerControlledState,
56 isComposerNativeEcho,
67 pruneAcknowledgedComposerNativeEvents,
78 resolveComposerControlledEventCount,
......@@ -42,6 +43,19 @@ describe("isComposerNativeEcho", () => {
4243 it("matches value and revision when selection is uncontrolled", () => {
4344 expect(isComposerNativeEcho("native", null, 3, snapshots)).toBe(true);
4445 });
46
47 it("does not claim a controlled selection against an assumed state without one", () => {
48 // An echo payload serializes `selection: null`; classifying a controlled
49 // selection as an echo of an assumed state would drop a parent caret move.
50 const assumed = [{ eventCount: 3, value: "native", selection: null }];
51 expect(isComposerNativeEcho("native", { start: 0, end: 0 }, 3, assumed)).toBe(false);
52 expect(isComposerNativeEcho("other", { start: 0, end: 0 }, 3, assumed)).toBe(false);
53 });
54
55 it("matches an assumed state when selection is uncontrolled", () => {
56 const assumed = [{ eventCount: 3, value: "native", selection: null }];
57 expect(isComposerNativeEcho("native", null, 3, assumed)).toBe(true);
58 });
4559});
4660
4761describe("resolveComposerControlledEventCount", () => {
......@@ -95,7 +109,7 @@ describe("pruneAcknowledgedComposerNativeEvents", () => {
95109 selection: { start: eventCount, end: eventCount },
96110 }));
97111
98 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([]);
112 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 999)).toEqual([snapshots[999]]);
99113 });
100114
101115 it("retains native events that arrive after the acknowledged render", () => {
......@@ -104,6 +118,77 @@ describe("pruneAcknowledgedComposerNativeEvents", () => {
104118 { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } },
105119 ];
106120
107 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual([snapshots[1]]);
121 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 40)).toEqual(snapshots);
122 });
123
124 it("retains the newest acknowledged snapshot so settled re-renders stay echoes", () => {
125 const snapshots = [
126 { eventCount: 40, value: "a", selection: { start: 1, end: 1 } },
127 { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } },
128 { eventCount: 42, value: "abc", selection: { start: 3, end: 3 } },
129 ];
130
131 const pruned = pruneAcknowledgedComposerNativeEvents(snapshots, 42);
132 expect(pruned).toEqual([snapshots[2]]);
133 expect(isComposerNativeEcho("abc", { start: 3, end: 3 }, 42, pruned)).toBe(true);
134 });
135
136 it("keeps the newest of several snapshots sharing the acknowledged revision", () => {
137 const snapshots = [
138 { eventCount: 41, value: "ab", selection: { start: 2, end: 2 } },
139 { eventCount: 41, value: "ab", selection: { start: 1, end: 1 } },
140 ];
141
142 expect(pruneAcknowledgedComposerNativeEvents(snapshots, 41)).toEqual([snapshots[1]]);
143 });
144});
145
146describe("assumeComposerControlledState", () => {
147 it("replaces the acknowledged history with the applied controlled state", () => {
148 const snapshots = [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }];
149
150 expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([
151 { eventCount: 3, value: "", selection: null },
152 ]);
153 });
154
155 it("keeps native events that raced past the controlled revision", () => {
156 const snapshots = [
157 { eventCount: 3, value: "typed", selection: { start: 5, end: 5 } },
158 { eventCount: 4, value: "typed!", selection: { start: 6, end: 6 } },
159 ];
160
161 expect(assumeComposerControlledState(snapshots, 3, "")).toEqual([
162 { eventCount: 3, value: "", selection: null },
163 snapshots[1],
164 ]);
165 });
166
167 it("applies a parent caret move on the assumed value at the assumed revision", () => {
168 // Same value, new caret: not an echo (so the selection is serialized) but
169 // still stamped at the assumed revision so the editor accepts it.
170 const snapshots = assumeComposerControlledState([], 3, "typed");
171
172 expect(isComposerNativeEcho("typed", { start: 2, end: 2 }, 3, snapshots)).toBe(false);
173 expect(resolveComposerControlledEventCount("typed", { start: 2, end: 2 }, 3, snapshots)).toBe(
174 3,
175 );
176 });
177
178 it("re-applies a parent value that round-trips back to an acknowledged state", () => {
179 // Native acknowledged "typed", the parent then controlled the editor to ""
180 // (a send clearing the draft) and back to "typed" (the send failed and the
181 // draft was restored). The restore must be a fresh non-echo edit stamped at
182 // the current revision, not an echo the editor would drop.
183 const snapshots = assumeComposerControlledState(
184 [{ eventCount: 3, value: "typed", selection: { start: 5, end: 5 } }],
185 3,
186 "",
187 );
188
189 expect(isComposerNativeEcho("typed", { start: 5, end: 5 }, 3, snapshots)).toBe(false);
190 expect(resolveComposerControlledEventCount("typed", { start: 5, end: 5 }, 3, snapshots)).toBe(
191 3,
192 );
108193 });
109194});
apps/mobile/src/native/composerEditorRevision.ts+54-6
......@@ -31,10 +31,7 @@ export function resolveComposerControlledEventCount(
3131 if (snapshot?.value !== value) continue;
3232
3333 newestValueEventCount ??= snapshot.eventCount;
34 if (
35 selection === null ||
36 (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end)
37 ) {
34 if (selection === null || snapshotSelectionMatches(snapshot, selection)) {
3835 return snapshot.eventCount;
3936 }
4037 }
......@@ -49,6 +46,21 @@ export function resolveComposerControlledEventCount(
4946 return mostRecentEventCount;
5047}
5148
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
5264export function isComposerNativeEcho(
5365 value: string,
5466 selection: ComposerEditorSelection | null,
......@@ -62,7 +74,9 @@ export function isComposerNativeEcho(
6274 snapshot.eventCount === eventCount &&
6375 snapshot.value === value &&
6476 (selection === null ||
65 (snapshot.selection?.start === selection.start && snapshot.selection.end === selection.end))
77 (snapshot.selection !== null &&
78 snapshot.selection.start === selection.start &&
79 snapshot.selection.end === selection.end))
6680 ) {
6781 return true;
6882 }
......@@ -70,9 +84,43 @@ export function isComposerNativeEcho(
7084 return false;
7185}
7286
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
73106export function pruneAcknowledgedComposerNativeEvents(
74107 snapshots: ReadonlyArray<ComposerNativeEventSnapshot>,
75108 acknowledgedEventCount: number,
76109): ComposerNativeEventSnapshot[] {
77 return snapshots.filter((snapshot) => snapshot.eventCount > acknowledgedEventCount);
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 );
78126}