Skip to content

Commit 4508ad4

Browse files
authored
feat: new onContextLoad events and awaitable context updates (#532)
- awaitable dispatches to store set - adds onContextLoaded callback for plugins that require device context being loaded during configuration. (Requested in #524) - upgraded sovran to v0.2.7 (Adds awaitable dispatch) - fixes #527 where Application Installed/Opened events might had not contained up to date context data as the store set could not be awaited before firing the events. - chore: reconfigure commitlint - chore: upgrade husky to v8
1 parent aea3a76 commit 4508ad4

File tree

14 files changed

+379
-232
lines changed

14 files changed

+379
-232
lines changed

.husky/commit-msg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/usr/bin/env sh
2+
. "$(dirname -- "$0")/_/husky.sh"
3+
4+
yarn commitlint --edit $1

.husky/pre-commit

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#!/usr/bin/env sh
2+
. "$(dirname -- "$0")/_/husky.sh"
3+
4+
yarn lint && yarn typescript && yarn test

commitlint.config.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
module.exports = {
2+
extends: ['@commitlint/config-conventional'],
3+
rules: {
4+
'header-max-length': [2, 'always', 80],
5+
// Disable all the body and footer max length rules since CommitLint cannot handle multiline text in body and footer making these rules too much of a nuisance
6+
'body-max-length': [0, 'always'],
7+
'body-max-line-length': [0, 'always'],
8+
'footer-max-length': [0, 'always'],
9+
'footer-max-line-length': [0, 'always'],
10+
// Also disable this rule since it will always complain due to the multiline incompatibility
11+
'footer-leading-blank': [0, 'always'],
12+
},
13+
};

example/ios/Podfile.lock

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ PODS:
291291
- sovran-react-native
292292
- segment-analytics-react-native-plugin-idfa (0.2.1):
293293
- React-Core
294-
- sovran-react-native (0.2.6):
294+
- sovran-react-native (0.2.7):
295295
- React-Core
296296
- Yoga (1.14.0)
297297

@@ -461,9 +461,9 @@ SPEC CHECKSUMS:
461461
RNGestureHandler: 77d59828d40838c9fabb76a12d2d0a80c006906f
462462
segment-analytics-react-native: 5287504fa5aa60e64dbb497bee5c7eb6f94e5e49
463463
segment-analytics-react-native-plugin-idfa: 80e5d610f537156833eabea12a1804523355de95
464-
sovran-react-native: ef02f663b489ac5e63ea7b80cd8426bf82992263
464+
sovran-react-native: 8d549886ad24ab51f8d471a7db83d1a3ace36358
465465
Yoga: 90dcd029e45d8a7c1ff059e8b3c6612ff409061a
466466

467467
PODFILE CHECKSUM: 0c7eb82d495ca56953c50916b7b49e7512632eb6
468468

469-
COCOAPODS: 1.11.2
469+
COCOAPODS: 1.11.3

example/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
"@react-native-community/masked-view": "^0.1.11",
2525
"@react-navigation/native": "^6.0.2",
2626
"@react-navigation/stack": "^6.0.7",
27-
"@segment/sovran-react-native": "^0.2.6",
27+
"@segment/sovran-react-native": "^0.2.7",
2828
"react": "17.0.2",
2929
"react-native": "0.67.3",
3030
"react-native-bootsplash": "^3.2.4",

example/yarn.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1575,10 +1575,10 @@
15751575
color "^3.1.3"
15761576
warn-once "^0.1.0"
15771577

1578-
"@segment/sovran-react-native@^0.2.6":
1579-
version "0.2.6"
1580-
resolved "https://registry.yarnpkg.com/@segment/sovran-react-native/-/sovran-react-native-0.2.6.tgz#895ef37b71c299f56c89515cf8d200f13fce2251"
1581-
integrity sha512-SxqKvMvgu9PZo0jSkZ0yys08H9qJbI1uGbaeBvJFZfs92TIvQmcW0PYQZsspqt85RPzrL4J9KDio6xoLXmHurw==
1578+
"@segment/sovran-react-native@^0.2.7":
1579+
version "0.2.7"
1580+
resolved "https://registry.yarnpkg.com/@segment/sovran-react-native/-/sovran-react-native-0.2.7.tgz#5df47d00a862481ab1f3f07bcc4b8e0a737930ae"
1581+
integrity sha512-P4pv3yIbUMv1X54TGioZb+8m4DiDCyKBRuyheqbFEblZeZSckI+WuRp/pooQeyHoGBMQbOY/j0yKksY3Yydkvg==
15821582
dependencies:
15831583
"@react-native-async-storage/async-storage" "^1.15.15"
15841584
ansi-regex "5.0.1"

package.json

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"packages/plugins/*"
66
],
77
"scripts": {
8-
"bootstrap": "yarn install && yarn example install && yarn example pods",
8+
"bootstrap": "yarn install && yarn example install && yarn example pods && husky install",
99
"core": "yarn workspace @segment/analytics-react-native",
1010
"example": "yarn --cwd example",
1111
"build": "yarn workspaces run build",
@@ -20,27 +20,16 @@
2020
"<rootDir>/packages/*"
2121
]
2222
},
23-
"husky": {
24-
"hooks": {
25-
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS",
26-
"pre-commit": "yarn lint && yarn typescript && yarn test"
27-
}
28-
},
29-
"commitlint": {
30-
"extends": [
31-
"@commitlint/config-conventional"
32-
]
33-
},
3423
"devDependencies": {
3524
"@changesets/cli": "^2.16.0",
36-
"@commitlint/config-conventional": "^11.0.0",
25+
"@commitlint/config-conventional": "^16.2.4",
3726
"@react-native-community/eslint-config": "^2.0.0",
3827
"@release-it/conventional-changelog": "^2.0.0",
39-
"commitlint": "^11.0.0",
28+
"commitlint": "^16.2.4",
4029
"eslint": "^7.2.0",
4130
"eslint-config-prettier": "^7.0.0",
4231
"eslint-plugin-prettier": "^3.1.3",
43-
"husky": "^4.2.5",
32+
"husky": "^8.0.0",
4433
"jest": "^27.3.1",
4534
"prettier": "^2.3.2",
4635
"release-it": "14.12.4",

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"homepage": "https://github.com/segmentio/analytics-react-native#readme",
4747
"dependencies": {
4848
"@react-native-async-storage/async-storage": "^1.15.17",
49-
"@segment/sovran-react-native": "^0.2.6",
49+
"@segment/sovran-react-native": "^0.2.7",
5050
"deepmerge": "^4.2.2",
5151
"js-base64": "^3.7.2",
5252
"nanoid": "^3.1.25"

packages/core/src/__tests__/__helpers__/mockSegmentStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export class MockSegmentStore implements Storage {
9999
set: (value: DeepPartial<Context>) => {
100100
this.data.context = { ...value };
101101
this.callbacks.context.run(value);
102+
return this.data.context;
102103
},
103104
};
104105

@@ -110,6 +111,7 @@ export class MockSegmentStore implements Storage {
110111
set: (value: SegmentAPIIntegrations) => {
111112
this.data.settings = value;
112113
this.callbacks.settings.run(value);
114+
return this.data.settings;
113115
},
114116
add: (key: string, value: IntegrationSettings) => {
115117
this.data.settings[key] = value;
@@ -143,6 +145,7 @@ export class MockSegmentStore implements Storage {
143145
set: (value: UserInfoState) => {
144146
this.data.userInfo = value;
145147
this.callbacks.userInfo.run(value);
148+
return this.data.userInfo;
146149
},
147150
};
148151

packages/core/src/__tests__/internal/checkInstalledVersion.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,4 +205,27 @@ describe('internal #checkInstalledVersion', () => {
205205
deepmerge(newContext, injectedContextByPlugins)
206206
);
207207
});
208+
209+
it('executes callback when context is updated in store', async () => {
210+
client = new SegmentClient(clientArgs);
211+
const callback = jest.fn().mockImplementation(() => {
212+
expect(store.context.get()).toEqual(currentContext);
213+
});
214+
client.onContextLoaded(callback);
215+
jest.spyOn(context, 'getContext').mockResolvedValueOnce(currentContext);
216+
await client.init();
217+
expect(callback).toHaveBeenCalled();
218+
});
219+
220+
it('executes callback immediatley if registered after context was already loaded', async () => {
221+
client = new SegmentClient(clientArgs);
222+
jest.spyOn(context, 'getContext').mockResolvedValueOnce(currentContext);
223+
await client.init();
224+
// Register callback after context is loaded
225+
const callback = jest.fn().mockImplementation(() => {
226+
expect(store.context.get()).toEqual(currentContext);
227+
});
228+
client.onContextLoaded(callback);
229+
expect(callback).toHaveBeenCalled();
230+
});
208231
});

packages/core/src/analytics.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,15 @@ import {
2727
SegmentAPIIntegrations,
2828
SegmentAPISettings,
2929
SegmentEvent,
30+
UpdateType,
3031
UserInfoState,
3132
UserTraits,
3233
} from './types';
3334
import { getPluginsWithFlush, getPluginsWithReset } from './util';
3435
import { getUUID } from './uuid';
3536

37+
type OnContextLoadCallback = (type: UpdateType) => void | Promise<void>;
38+
3639
export class SegmentClient {
3740
// the config parameters for the client - a merge of user provided and default options
3841
private config: Config;
@@ -71,6 +74,10 @@ export class SegmentClient {
7174

7275
private isInitialized = false;
7376

77+
private isContextLoaded = false;
78+
79+
private onContextLoadedCallback: OnContextLoadCallback | undefined;
80+
7481
get platformPlugins() {
7582
const plugins: PlatformPlugin[] = [];
7683

@@ -568,8 +575,14 @@ export class SegmentClient {
568575
const previousContext = this.store.context.get();
569576

570577
// Only overwrite the previous context values to preserve any values that are added by enrichment plugins like IDFA
571-
this.store.context.set(deepmerge(previousContext ?? {}, context));
578+
await this.store.context.set(deepmerge(previousContext ?? {}, context));
572579

580+
// Only callback during the intial context load
581+
if (this.onContextLoadedCallback !== undefined && !this.isContextLoaded) {
582+
this.onContextLoadedCallback(UpdateType.initial);
583+
}
584+
585+
this.isContextLoaded = true;
573586
if (!this.config.trackAppLifecycleEvents) {
574587
return;
575588
}
@@ -669,4 +682,19 @@ export class SegmentClient {
669682

670683
this.logger.info('Client has been reset');
671684
}
685+
686+
/**
687+
* Registers a callback for when the client has loaded the device context. This happens at the startup of the app, but
688+
* it is handy for plugins that require context data during configure as it guarantees the context data is available.
689+
*
690+
* If the context is already loaded it will call the callback immediately.
691+
*
692+
* @param callback Function to call when context is ready.
693+
*/
694+
onContextLoaded(callback: OnContextLoadCallback) {
695+
this.onContextLoadedCallback = callback;
696+
if (this.isContextLoaded) {
697+
this.onContextLoadedCallback(UpdateType.initial);
698+
}
699+
}
672700
}

packages/core/src/storage/sovranStorage.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -160,21 +160,23 @@ export class SovranStorage implements Storage {
160160
get: () => this.contextStore.getState().context,
161161
onChange: (callback: (value?: DeepPartial<Context>) => void) =>
162162
this.contextStore.subscribe((store) => callback(store.context)),
163-
set: (value: DeepPartial<Context>) => {
164-
this.contextStore.dispatch((state) => {
163+
set: async (value: DeepPartial<Context>) => {
164+
const { context } = await this.contextStore.dispatch((state) => {
165165
return { context: { ...state.context, ...value } };
166166
});
167+
return context;
167168
},
168169
};
169170
readonly settings = {
170171
get: () => this.settingsStore.getState().settings,
171172
onChange: (
172173
callback: (value?: SegmentAPIIntegrations | undefined) => void
173174
) => this.settingsStore.subscribe((store) => callback(store.settings)),
174-
set: (value: SegmentAPIIntegrations) => {
175-
this.settingsStore.dispatch((state) => {
175+
set: async (value: SegmentAPIIntegrations) => {
176+
const { settings } = await this.settingsStore.dispatch((state) => {
176177
return { settings: { ...state.settings, ...value } };
177178
});
179+
return settings;
178180
},
179181
add: (key: string, value: IntegrationSettings) => {
180182
this.settingsStore.dispatch((state) => ({
@@ -209,10 +211,11 @@ export class SovranStorage implements Storage {
209211
get: () => this.userInfoStore.getState().userInfo,
210212
onChange: (callback: (value: UserInfoState) => void) =>
211213
this.userInfoStore.subscribe((store) => callback(store.userInfo)),
212-
set: (value: UserInfoState) => {
213-
this.userInfoStore.dispatch((state) => ({
214+
set: async (value: UserInfoState) => {
215+
const { userInfo } = await this.userInfoStore.dispatch((state) => ({
214216
userInfo: { ...state.userInfo, ...value },
215217
}));
218+
return userInfo;
216219
},
217220
};
218221

packages/core/src/storage/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export interface Watchable<T> {
2727
* Implements a value that can be set
2828
*/
2929
export interface Settable<T> {
30-
set: (value: T) => void;
30+
set: (value: T) => T | Promise<T>;
3131
}
3232

3333
/**

0 commit comments

Comments
 (0)