Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Prevent excessive fetch calls #54

Merged
merged 8 commits into from
Dec 18, 2024
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 1.6.0

* Feat: save network calls when context fields don't change

## 1.5.3

* Fix: payload stringify in bootstrap
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ final unleash = UnleashClient(
url: Uri.parse('https://<your-unleash-instance>/api/frontend'),
clientKey: '<your-client-side-token>',
appName: 'my-app');
unleash.start();
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

added missing start method in README

```

#### Connection options
Expand Down
28 changes: 28 additions & 0 deletions lib/unleash_context.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,32 @@ class UnleashContext {

return params;
}

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! UnleashContext) return false;
return other.userId == userId &&
other.sessionId == sessionId &&
other.remoteAddress == remoteAddress &&
_mapEquals(other.properties, properties);
}

@override
int get hashCode {
return Object.hash(
userId,
sessionId,
remoteAddress,
properties,
);
}

static bool _mapEquals(Map<String, String> map1, Map<String, String> map2) {
if (map1.length != map2.length) return false;
for (final key in map1.keys) {
if (map1[key] != map2[key]) return false;
}
return true;
}
}
21 changes: 21 additions & 0 deletions lib/unleash_proxy_client_flutter.dart
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,27 @@ class UnleashClient extends EventEmitter {
}
}

/// Checks if any of the provided context fields are different from the current ones.
bool _anyFieldHasChanged(Map<String, String> fields) {
for (var entry in fields.entries) {
String key = entry.key;
String newValue = entry.value;

if (key == 'userId') {
if (context.userId != newValue) return true;
} else if (key == 'sessionId') {
if (context.sessionId != newValue) return true;
} else if (key == 'remoteAddress') {
if (context.remoteAddress != newValue) return true;
} else {
if (context.properties[key] != newValue) return true;
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm guessing this one also handles cases where the key didn't exist before? That is, if context.properties[key] is null/undefined/whatever it is in dart, then null != "some string" kicks in and we'll run the update, right?

I don't see a unit test for that, but it might be worth adding one (or modifying the current one)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Very good point. I will add a test and I think we're missing field removal in update context

}
}
return false;
}

Future<void> updateContext(UnleashContext unleashContext) async {
if (unleashContext == context) return;
if (started == false) {
await _waitForEvent('initialized');
_updateContextFields(unleashContext);
Expand Down Expand Up @@ -286,6 +306,7 @@ class UnleashClient extends EventEmitter {
}

Future<void> setContextFields(Map<String, String> fields) async {
if (!_anyFieldHasChanged(fields)) return;
if (clientState == ClientState.ready) {
fields.forEach((field, value) {
_updateContextField(field, value);
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ description: A Flutter/Dart client that can be used together with the unleash-pr
homepage: https://github.com/Unleash/unleash_proxy_client_flutter
repository: https://github.com/Unleash/unleash_proxy_client_flutter
issue_tracker: https://github.com/Unleash/unleash_proxy_client_flutter
version: 1.5.3
version: 1.6.0

environment:
sdk: ">=2.18.0 <4.0.0"
Expand Down
94 changes: 94 additions & 0 deletions test/unleash_proxy_client_flutter_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,100 @@ void main() {
]);
});

test(
'set and update context with the same value will not trigger new fetch call',
() async {
final getMock = GetMock();
final unleash = UnleashClient(
url: url,
clientKey: 'proxy-123',
appName: 'flutter-test',
sessionIdGenerator: generateSessionId,
storageProvider: InMemoryStorageProvider(),
fetcher: getMock);
await unleash.updateContext(UnleashContext(
userId: '123',
remoteAddress: 'address',
sessionId: 'session',
properties: {'customKey': 'customValue'}));
// update whole context before start but keep data identical
await unleash.updateContext(UnleashContext(
userId: '123',
remoteAddress: 'address',
sessionId: 'session',
properties: {'customKey': 'customValue'}));
// set standard property before start
unleash.setContextField('userId', '123');
// set standard an custom property before start
unleash.setContextFields({'customKey': 'customValue', 'userId': '123'});
await unleash.start();

// set standard properties after start
await unleash.setContextField('userId', '123');
await unleash.setContextField('remoteAddress', 'address');
await unleash.setContextField('sessionId', 'session');
// set custom property after start
await unleash.setContextField('customKey', 'customValue');
// set standard and custom property after start
await unleash
.setContextFields({'customKey': 'customValue', 'userId': '123'});
// update whole context after start
await unleash.updateContext(UnleashContext(
userId: '123',
remoteAddress: 'address',
sessionId: 'session',
properties: {'customKey': 'customValue'}));

expect(getMock.calledTimes, 1);
expect(getMock.calledWithUrls, [
Uri.parse(
'https://app.unleash-hosted.com/demo/api/proxy?userId=123&remoteAddress=address&sessionId=session&properties%5BcustomKey%5D=customValue&appName=flutter-test&environment=default')
]);
});

test('update context removing fields triggers new flag update', () async {
final getMock = GetMock();
final unleash = UnleashClient(
url: url,
clientKey: 'proxy-123',
appName: 'flutter-test',
sessionIdGenerator: generateSessionId,
storageProvider: InMemoryStorageProvider(),
fetcher: getMock);
// ignore this one
unleash.updateContext(UnleashContext(
userId: '123',
remoteAddress: 'address',
sessionId: 'session',
properties: {
'customKey': 'customValue',
'remove1': 'val1',
'remove2': 'val2'
}));
// first call
unleash.updateContext(UnleashContext(
userId: '123',
remoteAddress: 'address',
sessionId: 'session',
properties: {'customKey': 'customValue', 'remove1': 'val1'}));
await unleash.start();

// remove another field and second call
await unleash.updateContext(UnleashContext(
userId: '123',
remoteAddress: 'address',
sessionId: 'session',
properties: {'customKey': 'customValue'}));

expect(getMock.calledTimes, 2);
expect(getMock.calledWithUrls, [
Uri.parse(
'https://app.unleash-hosted.com/demo/api/proxy?userId=123&remoteAddress=address&sessionId=session&properties%5BcustomKey%5D=customValue&properties%5Bremove1%5D=val1&appName=flutter-test&environment=default'),
Uri.parse(
'https://app.unleash-hosted.com/demo/api/proxy?userId=123&remoteAddress=address&sessionId=session&properties%5BcustomKey%5D=customValue&appName=flutter-test&environment=default')
]);
});

test('update context without await', () async {
final getMock = GetMock();
final unleash = UnleashClient(
Expand Down
Loading