Skip to content

Commit 8e76cc6

Browse files
Merge pull request #665 from magiclabs/rominhalltari-sc-75722-react-native-sdk-add-support-for-general
Add `useInternetConnection` hook to track internet connectivity changes
2 parents 6b43f4f + 846912f commit 8e76cc6

File tree

18 files changed

+505
-16
lines changed

18 files changed

+505
-16
lines changed

packages/@magic-sdk/provider/src/core/sdk.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,6 @@ export class SDKBase {
196196
* has completed loading and is ready for requests.
197197
*/
198198
public async preload() {
199-
await this.overlay.ready;
199+
await this.overlay.checkIsReadyForRequest;
200200
}
201201
}

packages/@magic-sdk/provider/src/core/view-controller.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { createPromise } from '../util/promise-tools';
1010
import { getItem, setItem } from '../util/storage';
1111
import { createJwt } from '../util/web-crypto';
1212
import { SDKEnvironment } from './sdk-environment';
13+
import { createModalNotReadyError } from './sdk-exceptions';
1314

1415
interface RemoveEventListenerFunction {
1516
(): void;
@@ -88,7 +89,8 @@ async function persistMagicEventRefreshToken(event: MagicMessageEvent) {
8889
}
8990

9091
export abstract class ViewController {
91-
public ready: Promise<void>;
92+
private isReadyForRequest = false;
93+
public checkIsReadyForRequest: Promise<void>;
9294
protected readonly messageHandlers = new Set<(event: MagicMessageEvent) => any>();
9395

9496
/**
@@ -99,7 +101,7 @@ export abstract class ViewController {
99101
* relevant iframe context.
100102
*/
101103
constructor(protected readonly endpoint: string, protected readonly parameters: string) {
102-
this.ready = this.waitForReady();
104+
this.checkIsReadyForRequest = this.waitForReady();
103105
this.listen();
104106
}
105107

@@ -129,8 +131,17 @@ export abstract class ViewController {
129131
msgType: MagicOutgoingWindowMessage,
130132
payload: JsonRpcRequestPayload | JsonRpcRequestPayload[],
131133
): Promise<JsonRpcResponse<ResultType> | JsonRpcResponse<ResultType>[]> {
132-
return createPromise(async (resolve) => {
133-
await this.ready;
134+
return createPromise(async (resolve, reject) => {
135+
if (SDKEnvironment.platform !== 'react-native') {
136+
await this.checkIsReadyForRequest;
137+
} else if (!this.isReadyForRequest) {
138+
// On a mobile environment, `this.checkIsReadyForRequest` never resolves
139+
// if the app was initially opened without internet connection. That is
140+
// why we reject the promise without waiting and just let them call it
141+
// again when internet connection is re-established.
142+
const error = createModalNotReadyError();
143+
reject(error);
144+
}
134145

135146
const batchData: JsonRpcResponse[] = [];
136147
const batchIds = Array.isArray(payload) ? payload.map((p) => p.id) : [];
@@ -194,7 +205,10 @@ export abstract class ViewController {
194205

195206
private waitForReady() {
196207
return new Promise<void>((resolve) => {
197-
this.on(MagicIncomingWindowMessage.MAGIC_OVERLAY_READY, () => resolve());
208+
this.on(MagicIncomingWindowMessage.MAGIC_OVERLAY_READY, () => {
209+
resolve();
210+
this.isReadyForRequest = true;
211+
});
198212
});
199213
}
200214

packages/@magic-sdk/provider/test/spec/core/view-controller/post.spec.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33

44
import browserEnv from '@ikscodes/browser-env';
55
import { MagicIncomingWindowMessage, MagicOutgoingWindowMessage, JsonRpcRequestPayload } from '@magic-sdk/types';
6-
import _ from 'lodash';
76
import { createViewController } from '../../../factories';
87
import { JsonRpcResponse } from '../../../../src/core/json-rpc';
98
import * as storage from '../../../../src/util/storage';
109
import * as webCryptoUtils from '../../../../src/util/web-crypto';
1110
import { SDKEnvironment } from '../../../../src/core/sdk-environment';
11+
import { createModalNotReadyError } from '../../../../src/core/sdk-exceptions';
1212

1313
/**
1414
* Create a dummy request payload.
@@ -56,7 +56,7 @@ function stubViewController(viewController: any, events: [MagicIncomingWindowMes
5656
const postSpy = jest.fn();
5757

5858
viewController.on = onSpy;
59-
viewController.ready = Promise.resolve();
59+
viewController.checkIsReadyForRequest = Promise.resolve();
6060
viewController._post = postSpy;
6161

6262
return { handlerSpy, onSpy, postSpy };
@@ -201,6 +201,27 @@ test('Sends payload and stores rt if response event contains rt', async () => {
201201
expect(FAKE_STORE.rt).toEqual(FAKE_RT);
202202
});
203203

204+
test('does not wait for ready and throws error when platform is react-native', async () => {
205+
SDKEnvironment.platform = 'react-native';
206+
const eventWithRt = { data: { ...responseEvent().data } };
207+
const viewController = createViewController('asdf');
208+
const { handlerSpy, onSpy } = stubViewController(viewController, [
209+
[MagicIncomingWindowMessage.MAGIC_HANDLE_RESPONSE, eventWithRt],
210+
]);
211+
viewController.checkIsReadyForRequest = new Promise(() => null);
212+
213+
const payload = requestPayload();
214+
215+
try {
216+
await viewController.post(MagicOutgoingWindowMessage.MAGIC_HANDLE_REQUEST, payload);
217+
} catch (e) {
218+
expect(e).toEqual(createModalNotReadyError());
219+
}
220+
expect(createJwtStub).not.toHaveBeenCalledWith();
221+
expect(onSpy.mock.calls[0][0]).toEqual(MagicIncomingWindowMessage.MAGIC_HANDLE_RESPONSE);
222+
expect(handlerSpy).not.toHaveBeenCalled();
223+
});
224+
204225
test('does not call web crypto api if platform is not web', async () => {
205226
SDKEnvironment.platform = 'react-native';
206227
const eventWithRt = { data: { ...responseEvent().data } };
@@ -210,6 +231,9 @@ test('does not call web crypto api if platform is not web', async () => {
210231
]);
211232
const payload = requestPayload();
212233

234+
// @ts-ignore isReadyForRequest is private
235+
viewController.isReadyForRequest = true;
236+
213237
const response = await viewController.post(MagicOutgoingWindowMessage.MAGIC_HANDLE_REQUEST, payload);
214238

215239
expect(createJwtStub).not.toHaveBeenCalledWith();

packages/@magic-sdk/react-native-bare/README.md

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ npm install --save @magic-sdk/react-native-bare
2727
npm install --save react-native-device-info # Required Peer Dependency
2828
npm install --save @react-native-community/async-storage # Required Peer Dependency
2929
npm install --save react-native-safe-area-context # Required Peer Dependency
30+
npm install --save @react-native-community/netinfo # Required Peer Dependency
3031

3132
# Via Yarn:
3233
yarn add @magic-sdk/react-native-bare
33-
yarn add react-native-device-info # Required Peer Dependency
34+
yarn add react-native-device-info # Required Peer Dependency
3435
yarn add @react-native-community/async-storage # Required Peer Dependency
3536
yarn add react-native-safe-area-context # Required Peer Dependency
37+
yarn add @react-native-community/netinfo # Required Peer Dependency
3638
```
3739

3840
## ⚡️ Quick Start
@@ -69,4 +71,54 @@ Please note that as of **v14.0.0** our React Native package offerings wrap the `
6971
We have also added an optional `backgroundColor` prop to the `Relayer` to fix issues with `SafeAreaView` showing the background. By default, the background will be white. If you have changed the background color as part of your [custom branding setup](https://magic.link/docs/authentication/features/login-ui#configuration), make sure to pass your custom background color to `magic.Relayer`:
7072
```tsx
7173
<magic.Relayer backgroundColor="#0000FF"/>
74+
```
75+
76+
## 🙌🏾 Troubleshooting
77+
78+
### Symlinking in Monorepo w/ Metro
79+
80+
For React Native projects living within a **monorepo** that run into the following `TypeError: Undefined is not an object` error:
81+
82+
<img width="299" alt="Screenshot 2022-11-23 at 12 19 19 PM" src="https://user-images.githubusercontent.com/13407884/203641477-ec2e472e-86dc-4a22-b54a-eb694001617e.png">
83+
84+
When attempting to import `Magic`, take note that the React Native metro bundler doesn’t work well with symlinks, which tend to be utilized by most package managers.
85+
86+
For this issue consider using Microsoft's [rnx-kit](https://microsoft.github.io/rnx-kit/docs/guides/bundling) suite of tools that include a plugin for metro that fixes this symlink related error.
87+
88+
### Handling internet connection problems
89+
When an app is opened without internet connection, any request to the Magic SDK will result in a rejection with a `MagicSDKError`:
90+
91+
```json
92+
{
93+
"code": "MODAL_NOT_READY",
94+
"rawMessage": "Modal is not ready."
95+
}
96+
```
97+
98+
99+
It is good practice to use [@react-native-community/netinfo](https://www.npmjs.com/package/@react-native-community/netinfo) to track the internet connection state of the device. For your convenience, we've also added a hook that uses this library behind the scenes:
100+
101+
102+
```tsx
103+
import { useInternetConnection } from '@magic-sdk/react-native-expo';
104+
105+
const magic = new Magic('YOUR_API_KEY');
106+
107+
const connected = useInternetConnection()
108+
109+
useEffect(() => {
110+
if (!connected) {
111+
// Unomount this component and show your "You're offline" screen.
112+
}
113+
}, [connected])
114+
115+
export default function App() {
116+
return <>
117+
<SafeAreaProvider>
118+
{/* Render the Magic iframe! */}
119+
<magic.Relayer />
120+
{...}
121+
</SafeAreaProvider>
122+
</>
123+
}
72124
```

packages/@magic-sdk/react-native-bare/jest.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Config } from '@jest/types';
33

44
const config: Config.InitialOptions = {
55
...baseJestConfig,
6-
preset: 'react-native',
6+
preset: '@testing-library/react-native',
77
transform: {
88
'^.+\\.(js|jsx)$': 'babel-jest',
99
'\\.(ts|tsx)$': 'ts-jest',

packages/@magic-sdk/react-native-bare/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,19 @@
3737
"@babel/plugin-transform-flow-strip-types": "^7.14.5",
3838
"@babel/runtime": "~7.10.4",
3939
"@react-native-async-storage/async-storage": "^1.15.5",
40+
"@react-native-community/netinfo": ">11.0.0",
41+
"@testing-library/react-native": "^12.4.0",
4042
"metro-react-native-babel-preset": "^0.66.2",
4143
"react": "^16.13.1",
4244
"react-native": "^0.62.2",
4345
"react-native-device-info": "^10.3.0",
4446
"react-native-safe-area-context": "^4.4.1",
45-
"react-native-webview": "^12.4.0"
47+
"react-native-webview": "^12.4.0",
48+
"react-test-renderer": "^16.13.1"
4649
},
4750
"peerDependencies": {
4851
"@react-native-async-storage/async-storage": ">=1.15.5",
52+
"@react-native-community/netinfo": ">11.0.0",
4953
"react": ">=16",
5054
"react-native": ">=0.60",
5155
"react-native-device-info": ">=10.3.0",
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { useEffect, useState } from 'react';
2+
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';
3+
4+
export const useInternetConnection = () => {
5+
const [isConnected, setIsConnected] = useState(true);
6+
useEffect(() => {
7+
const handleConnectionChange = (connectionInfo: NetInfoState) => {
8+
setIsConnected(!!connectionInfo.isConnected);
9+
};
10+
11+
// Subscribe to connection changes and cleanup on unmount
12+
return NetInfo.addEventListener(handleConnectionChange);
13+
}, []);
14+
15+
return isConnected;
16+
};

packages/@magic-sdk/react-native-bare/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,5 @@ export type Magic<T extends MagicSDKExtensionsOption<any> = MagicSDKExtensionsOp
6969
SDKBaseReactNative,
7070
T
7171
>;
72+
73+
export { useInternetConnection } from './hooks';

packages/@magic-sdk/react-native-bare/test/mocks.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,41 @@
1+
// @react-native-community/netinfo mocks
2+
const defaultState = {
3+
type: 'cellular',
4+
isConnected: true,
5+
isInternetReachable: true,
6+
details: {
7+
isConnectionExpensive: true,
8+
cellularGeneration: '3g',
9+
},
10+
};
11+
12+
const NetInfoStateType = {
13+
unknown: 'unknown',
14+
none: 'none',
15+
cellular: 'cellular',
16+
wifi: 'wifi',
17+
bluetooth: 'bluetooth',
18+
ethernet: 'ethernet',
19+
wimax: 'wimax',
20+
vpn: 'vpn',
21+
other: 'other',
22+
};
23+
24+
const RNCNetInfoMock = {
25+
NetInfoStateType,
26+
configure: jest.fn(),
27+
fetch: jest.fn(),
28+
refresh: jest.fn(),
29+
addEventListener: jest.fn(),
30+
useNetInfo: jest.fn(),
31+
getCurrentState: jest.fn(),
32+
};
33+
34+
RNCNetInfoMock.fetch.mockResolvedValue(defaultState);
35+
RNCNetInfoMock.refresh.mockResolvedValue(defaultState);
36+
RNCNetInfoMock.useNetInfo.mockReturnValue(defaultState);
37+
RNCNetInfoMock.addEventListener.mockReturnValue(jest.fn());
38+
139
export function reactNativeStyleSheetStub() {
240
const { StyleSheet } = jest.requireActual('react-native');
341
return jest.spyOn(StyleSheet, 'create');
@@ -6,9 +44,9 @@ export function reactNativeStyleSheetStub() {
644
const noopModule = () => ({});
745

846
export function removeReactDependencies() {
9-
jest.mock('react', noopModule);
1047
jest.mock('react-native-webview', noopModule);
1148
jest.mock('react-native-safe-area-context', noopModule);
49+
jest.mock('@react-native-community/netinfo', () => RNCNetInfoMock);
1250

1351
// The `localforage` driver we use to enable React Native's `AsyncStorage`
1452
// currently uses an `import` statement at the top of it's index file, this is
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { act, renderHook } from '@testing-library/react-native';
2+
import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo';
3+
import { useInternetConnection } from '../../src/hooks';
4+
5+
beforeAll(() => {
6+
// @ts-ignore mock resolved value
7+
NetInfo.getCurrentState.mockResolvedValue({
8+
type: NetInfoStateType.cellular,
9+
isConnected: true,
10+
isInternetReachable: true,
11+
details: {
12+
isConnectionExpensive: true,
13+
cellularGeneration: '4g',
14+
},
15+
});
16+
});
17+
18+
describe('useInternetConnection', () => {
19+
it('should initialize with true when connected', async () => {
20+
const { result } = renderHook(() => useInternetConnection());
21+
22+
expect(result.current).toBe(true);
23+
});
24+
25+
it('should call the listener when the connection changes', async () => {
26+
NetInfo.addEventListener = jest.fn();
27+
28+
const { result } = renderHook(() => useInternetConnection());
29+
30+
// Initial render, assuming it's connected
31+
expect(result.current).toBe(true);
32+
33+
// Simulate a change in connection status
34+
act(() => {
35+
// @ts-ignore mock calls
36+
NetInfo.addEventListener.mock.calls[0][0]({
37+
type: 'cellular',
38+
isConnected: false,
39+
isInternetReachable: true,
40+
details: {
41+
isConnectionExpensive: true,
42+
},
43+
});
44+
});
45+
46+
// Wait for the next tick of the event loop to allow state update
47+
await act(async () => {
48+
await new Promise((resolve) => setTimeout(resolve, 0)); // or setImmediate
49+
});
50+
51+
// Check if the hook state has been updated
52+
expect(result.current).toBe(false);
53+
});
54+
});

0 commit comments

Comments
 (0)