Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2a27aa1
Turn -1s into 0s instead of letting them overflow and cause weird res…
justinmc Jul 15, 2020
9ab67dd
For invalid selection, set it to nil instead of zero
justinmc Jul 16, 2020
65a27d8
Avoid bugs where a range is expected by setting range to 0,0 instead …
justinmc Jul 17, 2020
c9baf78
Avoid race condition by debouncing editing calls to framework
justinmc Jul 17, 2020
145fa27
Test that calls are batched
justinmc Jul 21, 2020
5de9e67
Clean up logging and comments
justinmc Jul 21, 2020
a979911
WIP Trying to fix tests
justinmc Jul 22, 2020
f6b9ea8
Merge branch 'master' into ios-range-overflow-infinite-loop
justinmc Aug 7, 2020
58b0e55
Merge branch 'master' into ios-range-overflow-infinite-loop
justinmc Aug 7, 2020
2e1b0b5
Merge branch 'master' into ios-range-overflow-infinite-loop
justinmc Sep 10, 2020
87d2119
Merge branch 'master' into ios-range-overflow-infinite-loop
justinmc Sep 11, 2020
ea19ac2
fix testUITextInputCallsUpdateEditingStateOnce
justinmc Sep 11, 2020
da16787
Existing tests pass by waiting for debounced calls.
justinmc Sep 11, 2020
3928860
Test batching
justinmc Sep 14, 2020
44c766a
Clean up
justinmc Sep 14, 2020
9949623
release _latestState
justinmc Sep 18, 2020
43ae4eb
Merge branch 'master' into ios-range-overflow-infinite-loop
justinmc Sep 23, 2020
a87230b
Dont send an ack back to the framework for text changes. The reason w…
justinmc Sep 23, 2020
255efbb
Test that the client isn't updated with its own text changes
justinmc Sep 23, 2020
bca94b6
Update return type in test
justinmc Oct 1, 2020
c36382c
Also test selection and composing changes
justinmc Oct 1, 2020
5c01b17
Merge branch 'master' into ios-range-overflow-infinite-loop
justinmc Oct 2, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ @implementation FlutterTextInputView {
int _textInputClient;
const char* _selectionAffinity;
FlutterTextRange* _selectedTextRange;
NSDictionary* _latestState;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We reuse FlutterTextInputView so the cache needs to be erased every time the connection changes/resets. Also this needs to be properly deallocated I think.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright got the deallocation in 9949623. For erasing the state each time the connection changes, would that just be in resetAllClientIds?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FlutterTextInputView.setClientId I think?

}

@synthesize tokenizer = _tokenizer;
Expand Down Expand Up @@ -995,10 +996,36 @@ - (void)updateEditingState {
@"text" : [NSString stringWithString:self.text],
};

// Debounce calls to updateEditingClient on the leading edge. This makes iOS
// text editing behave more similarly to Android's, which has built-in event
// batching, and avoids race conditions. The delay value was chosen to be
// imperceptible by the user but still long enough to allow the framework to
// respond with formatting updates, thereby avoiding common race conditions.
if (_latestState == nil) {
_latestState = state;
[self updateEditingClient];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 10000000), dispatch_get_main_queue(), ^(void){
if (_latestState == state) {
_latestState = nil;
}
});
return;
}
_latestState = state;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 10000000), dispatch_get_main_queue(), ^(void){
if (state != _latestState) {
return;
}
[self updateEditingClient];
_latestState = nil;
});
}

- (void)updateEditingClient {
if (_textInputClient == 0 && _autofillId != nil) {
[_textInputDelegate updateEditingClient:_textInputClient withState:state withTag:_autofillId];
[_textInputDelegate updateEditingClient:_textInputClient withState:_latestState withTag:_autofillId];
} else {
[_textInputDelegate updateEditingClient:_textInputClient withState:state];
[_textInputDelegate updateEditingClient:_textInputClient withState:_latestState];
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,33 +174,85 @@ - (void)testUITextInputCallsUpdateEditingStateOnce {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;

__block XCTestExpectation* expectation;
__block int updateCount = 0;
OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
if (expectation != nil) {
[expectation fulfill];
}
});

[inputView insertText:@"text to insert"];
// Update the framework exactly once.
// The framework has been updated exactly once. It happened immediately,
// because calls to updateEditingState are debounced on the leading edge.
XCTAssertEqual(updateCount, 1);

expectation = [self expectationWithDescription:@"called updateEditingClient"];
[inputView deleteBackward];
// Due to the debouncing, this call will happen after a short delay.
XCTAssertEqual(updateCount, 1);
[self waitForExpectations:@[expectation] timeout:1];
expectation = nil;
XCTAssertEqual(updateCount, 2);

// Subsequent calls follow this pattern of leading edge debouncing. Now that
// enough time has passed to allow the debouncing to call through, the
// debouncing is reset. The next call will happen immediately on the leading
// edge, and subsequent calls are debounced.
inputView.selectedTextRange = [FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)];
XCTAssertEqual(updateCount, 3);

expectation = [self expectationWithDescription:@"called updateEditingClient"];
[inputView replaceRange:[FlutterTextRange rangeWithNSRange:NSMakeRange(0, 1)]
withText:@"replace text"];
XCTAssertEqual(updateCount, 3);
[self waitForExpectations:@[expectation] timeout:1];
expectation = nil;
XCTAssertEqual(updateCount, 4);

[inputView setMarkedText:@"marked text" selectedRange:NSMakeRange(0, 1)];
XCTAssertEqual(updateCount, 5);

expectation = [self expectationWithDescription:@"called updateEditingClient"];
[inputView unmarkText];
XCTAssertEqual(updateCount, 5);
[self waitForExpectations:@[expectation] timeout:1];
expectation = nil;
XCTAssertEqual(updateCount, 6);
}

- (void)testUITextInputCallsToUpdateEditingStateAreDebounced {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;

__block XCTestExpectation* expectation;
__block int updateCount = 0;
OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
if (expectation != nil) {
[expectation fulfill];
}
});

[inputView insertText:@"text to insert"];
// The framework has been updated exactly once. It happened immediately,
// because calls to updateEditingState are debounced on the leading edge.
XCTAssertEqual(updateCount, 1);

// These calls will be batched and come through after a short delay.
expectation = [self expectationWithDescription:@"called updateEditingClient twice"];
[inputView deleteBackward];
[inputView deleteBackward];
[inputView deleteBackward];
XCTAssertEqual(updateCount, 1);
[self waitForExpectations:@[expectation] timeout:1];
XCTAssertEqual(updateCount, 2);
XCTAssertTrue([inputView.text isEqualToString:@"text to ins"]);
}

- (void)testTextChangesTriggerUpdateEditingClient {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;
Expand Down Expand Up @@ -314,6 +366,17 @@ - (void)testUpdateEditingClientSelectionClamping {
FlutterTextInputView* inputView = [[FlutterTextInputView alloc] init];
inputView.textInputDelegate = engine;

// Debounced calls need to be waited for using this expectation.
__block XCTestExpectation* expectation;
__block int updateCount = 0;
OCMStub([engine updateEditingClient:0 withState:[OCMArg isNotNil]])
.andDo(^(NSInvocation* invocation) {
updateCount++;
if (expectation != nil) {
[expectation fulfill];
}
});

[inputView.text setString:@"SELECTION"];
inputView.markedTextRange = nil;
inputView.selectedTextRange = nil;
Expand All @@ -328,13 +391,16 @@ - (void)testUpdateEditingClientSelectionClamping {
}]]);

// Needs clamping.
expectation = [self expectationWithDescription:@"called updateEditingClient"];
[inputView setTextInputState:@{
@"text" : @"SELECTION",
@"selectionBase" : @0,
@"selectionExtent" : @9999
}];
[inputView updateEditingState];

[self waitForExpectations:@[expectation] timeout:1];
expectation = nil;
OCMVerify([engine updateEditingClient:0
withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([state[@"selectionBase"] intValue]) == 0 &&
Expand All @@ -352,12 +418,15 @@ - (void)testUpdateEditingClientSelectionClamping {
}]]);

// Both ends need clamping.
expectation = [self expectationWithDescription:@"called updateEditingClient"];
[inputView setTextInputState:@{
@"text" : @"SELECTION",
@"selectionBase" : @9999,
@"selectionExtent" : @9999
}];
[inputView updateEditingState];
[self waitForExpectations:@[expectation] timeout:1];
expectation = nil;
OCMVerify([engine updateEditingClient:0
withState:[OCMArg checkWithBlock:^BOOL(NSDictionary* state) {
return ([state[@"selectionBase"] intValue]) == 9 &&
Expand Down