Skip to content

Commit

Permalink
Merge pull request #665 from magiclabs/rominhalltari-sc-75722-react-n…
Browse files Browse the repository at this point in the history
…ative-sdk-add-support-for-general

Add `useInternetConnection` hook to track internet connectivity changes
  • Loading branch information
romin-halltari authored Nov 20, 2023
2 parents 6b43f4f + 846912f commit 8e76cc6
Show file tree
Hide file tree
Showing 18 changed files with 505 additions and 16 deletions.
2 changes: 1 addition & 1 deletion packages/@magic-sdk/provider/src/core/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,6 @@ export class SDKBase {
* has completed loading and is ready for requests.
*/
public async preload() {
await this.overlay.ready;
await this.overlay.checkIsReadyForRequest;
}
}
24 changes: 19 additions & 5 deletions packages/@magic-sdk/provider/src/core/view-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createPromise } from '../util/promise-tools';
import { getItem, setItem } from '../util/storage';
import { createJwt } from '../util/web-crypto';
import { SDKEnvironment } from './sdk-environment';
import { createModalNotReadyError } from './sdk-exceptions';

interface RemoveEventListenerFunction {
(): void;
Expand Down Expand Up @@ -88,7 +89,8 @@ async function persistMagicEventRefreshToken(event: MagicMessageEvent) {
}

export abstract class ViewController {
public ready: Promise<void>;
private isReadyForRequest = false;
public checkIsReadyForRequest: Promise<void>;
protected readonly messageHandlers = new Set<(event: MagicMessageEvent) => any>();

/**
Expand All @@ -99,7 +101,7 @@ export abstract class ViewController {
* relevant iframe context.
*/
constructor(protected readonly endpoint: string, protected readonly parameters: string) {
this.ready = this.waitForReady();
this.checkIsReadyForRequest = this.waitForReady();
this.listen();
}

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

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

private waitForReady() {
return new Promise<void>((resolve) => {
this.on(MagicIncomingWindowMessage.MAGIC_OVERLAY_READY, () => resolve());
this.on(MagicIncomingWindowMessage.MAGIC_OVERLAY_READY, () => {
resolve();
this.isReadyForRequest = true;
});
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@

import browserEnv from '@ikscodes/browser-env';
import { MagicIncomingWindowMessage, MagicOutgoingWindowMessage, JsonRpcRequestPayload } from '@magic-sdk/types';
import _ from 'lodash';
import { createViewController } from '../../../factories';
import { JsonRpcResponse } from '../../../../src/core/json-rpc';
import * as storage from '../../../../src/util/storage';
import * as webCryptoUtils from '../../../../src/util/web-crypto';
import { SDKEnvironment } from '../../../../src/core/sdk-environment';
import { createModalNotReadyError } from '../../../../src/core/sdk-exceptions';

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

viewController.on = onSpy;
viewController.ready = Promise.resolve();
viewController.checkIsReadyForRequest = Promise.resolve();
viewController._post = postSpy;

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

test('does not wait for ready and throws error when platform is react-native', async () => {
SDKEnvironment.platform = 'react-native';
const eventWithRt = { data: { ...responseEvent().data } };
const viewController = createViewController('asdf');
const { handlerSpy, onSpy } = stubViewController(viewController, [
[MagicIncomingWindowMessage.MAGIC_HANDLE_RESPONSE, eventWithRt],
]);
viewController.checkIsReadyForRequest = new Promise(() => null);

const payload = requestPayload();

try {
await viewController.post(MagicOutgoingWindowMessage.MAGIC_HANDLE_REQUEST, payload);
} catch (e) {
expect(e).toEqual(createModalNotReadyError());
}
expect(createJwtStub).not.toHaveBeenCalledWith();
expect(onSpy.mock.calls[0][0]).toEqual(MagicIncomingWindowMessage.MAGIC_HANDLE_RESPONSE);
expect(handlerSpy).not.toHaveBeenCalled();
});

test('does not call web crypto api if platform is not web', async () => {
SDKEnvironment.platform = 'react-native';
const eventWithRt = { data: { ...responseEvent().data } };
Expand All @@ -210,6 +231,9 @@ test('does not call web crypto api if platform is not web', async () => {
]);
const payload = requestPayload();

// @ts-ignore isReadyForRequest is private
viewController.isReadyForRequest = true;

const response = await viewController.post(MagicOutgoingWindowMessage.MAGIC_HANDLE_REQUEST, payload);

expect(createJwtStub).not.toHaveBeenCalledWith();
Expand Down
54 changes: 53 additions & 1 deletion packages/@magic-sdk/react-native-bare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ npm install --save @magic-sdk/react-native-bare
npm install --save react-native-device-info # Required Peer Dependency
npm install --save @react-native-community/async-storage # Required Peer Dependency
npm install --save react-native-safe-area-context # Required Peer Dependency
npm install --save @react-native-community/netinfo # Required Peer Dependency

# Via Yarn:
yarn add @magic-sdk/react-native-bare
yarn add react-native-device-info # Required Peer Dependency
yarn add react-native-device-info # Required Peer Dependency
yarn add @react-native-community/async-storage # Required Peer Dependency
yarn add react-native-safe-area-context # Required Peer Dependency
yarn add @react-native-community/netinfo # Required Peer Dependency
```

## ⚡️ Quick Start
Expand Down Expand Up @@ -69,4 +71,54 @@ Please note that as of **v14.0.0** our React Native package offerings wrap the `
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`:
```tsx
<magic.Relayer backgroundColor="#0000FF"/>
```

## 🙌🏾 Troubleshooting

### Symlinking in Monorepo w/ Metro

For React Native projects living within a **monorepo** that run into the following `TypeError: Undefined is not an object` error:

<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">

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.

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.

### Handling internet connection problems
When an app is opened without internet connection, any request to the Magic SDK will result in a rejection with a `MagicSDKError`:

```json
{
"code": "MODAL_NOT_READY",
"rawMessage": "Modal is not ready."
}
```


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:


```tsx
import { useInternetConnection } from '@magic-sdk/react-native-expo';

const magic = new Magic('YOUR_API_KEY');

const connected = useInternetConnection()

useEffect(() => {
if (!connected) {
// Unomount this component and show your "You're offline" screen.
}
}, [connected])

export default function App() {
return <>
<SafeAreaProvider>
{/* Render the Magic iframe! */}
<magic.Relayer />
{...}
</SafeAreaProvider>
</>
}
```
2 changes: 1 addition & 1 deletion packages/@magic-sdk/react-native-bare/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { Config } from '@jest/types';

const config: Config.InitialOptions = {
...baseJestConfig,
preset: 'react-native',
preset: '@testing-library/react-native',
transform: {
'^.+\\.(js|jsx)$': 'babel-jest',
'\\.(ts|tsx)$': 'ts-jest',
Expand Down
6 changes: 5 additions & 1 deletion packages/@magic-sdk/react-native-bare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,19 @@
"@babel/plugin-transform-flow-strip-types": "^7.14.5",
"@babel/runtime": "~7.10.4",
"@react-native-async-storage/async-storage": "^1.15.5",
"@react-native-community/netinfo": ">11.0.0",
"@testing-library/react-native": "^12.4.0",
"metro-react-native-babel-preset": "^0.66.2",
"react": "^16.13.1",
"react-native": "^0.62.2",
"react-native-device-info": "^10.3.0",
"react-native-safe-area-context": "^4.4.1",
"react-native-webview": "^12.4.0"
"react-native-webview": "^12.4.0",
"react-test-renderer": "^16.13.1"
},
"peerDependencies": {
"@react-native-async-storage/async-storage": ">=1.15.5",
"@react-native-community/netinfo": ">11.0.0",
"react": ">=16",
"react-native": ">=0.60",
"react-native-device-info": ">=10.3.0",
Expand Down
16 changes: 16 additions & 0 deletions packages/@magic-sdk/react-native-bare/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useEffect, useState } from 'react';
import NetInfo, { NetInfoState } from '@react-native-community/netinfo';

export const useInternetConnection = () => {
const [isConnected, setIsConnected] = useState(true);
useEffect(() => {
const handleConnectionChange = (connectionInfo: NetInfoState) => {
setIsConnected(!!connectionInfo.isConnected);
};

// Subscribe to connection changes and cleanup on unmount
return NetInfo.addEventListener(handleConnectionChange);
}, []);

return isConnected;
};
2 changes: 2 additions & 0 deletions packages/@magic-sdk/react-native-bare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,5 @@ export type Magic<T extends MagicSDKExtensionsOption<any> = MagicSDKExtensionsOp
SDKBaseReactNative,
T
>;

export { useInternetConnection } from './hooks';
40 changes: 39 additions & 1 deletion packages/@magic-sdk/react-native-bare/test/mocks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,41 @@
// @react-native-community/netinfo mocks
const defaultState = {
type: 'cellular',
isConnected: true,
isInternetReachable: true,
details: {
isConnectionExpensive: true,
cellularGeneration: '3g',
},
};

const NetInfoStateType = {
unknown: 'unknown',
none: 'none',
cellular: 'cellular',
wifi: 'wifi',
bluetooth: 'bluetooth',
ethernet: 'ethernet',
wimax: 'wimax',
vpn: 'vpn',
other: 'other',
};

const RNCNetInfoMock = {
NetInfoStateType,
configure: jest.fn(),
fetch: jest.fn(),
refresh: jest.fn(),
addEventListener: jest.fn(),
useNetInfo: jest.fn(),
getCurrentState: jest.fn(),
};

RNCNetInfoMock.fetch.mockResolvedValue(defaultState);
RNCNetInfoMock.refresh.mockResolvedValue(defaultState);
RNCNetInfoMock.useNetInfo.mockReturnValue(defaultState);
RNCNetInfoMock.addEventListener.mockReturnValue(jest.fn());

export function reactNativeStyleSheetStub() {
const { StyleSheet } = jest.requireActual('react-native');
return jest.spyOn(StyleSheet, 'create');
Expand All @@ -6,9 +44,9 @@ export function reactNativeStyleSheetStub() {
const noopModule = () => ({});

export function removeReactDependencies() {
jest.mock('react', noopModule);
jest.mock('react-native-webview', noopModule);
jest.mock('react-native-safe-area-context', noopModule);
jest.mock('@react-native-community/netinfo', () => RNCNetInfoMock);

// The `localforage` driver we use to enable React Native's `AsyncStorage`
// currently uses an `import` statement at the top of it's index file, this is
Expand Down
54 changes: 54 additions & 0 deletions packages/@magic-sdk/react-native-bare/test/spec/hooks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { act, renderHook } from '@testing-library/react-native';
import NetInfo, { NetInfoStateType } from '@react-native-community/netinfo';
import { useInternetConnection } from '../../src/hooks';

beforeAll(() => {
// @ts-ignore mock resolved value
NetInfo.getCurrentState.mockResolvedValue({
type: NetInfoStateType.cellular,
isConnected: true,
isInternetReachable: true,
details: {
isConnectionExpensive: true,
cellularGeneration: '4g',
},
});
});

describe('useInternetConnection', () => {
it('should initialize with true when connected', async () => {
const { result } = renderHook(() => useInternetConnection());

expect(result.current).toBe(true);
});

it('should call the listener when the connection changes', async () => {
NetInfo.addEventListener = jest.fn();

const { result } = renderHook(() => useInternetConnection());

// Initial render, assuming it's connected
expect(result.current).toBe(true);

// Simulate a change in connection status
act(() => {
// @ts-ignore mock calls
NetInfo.addEventListener.mock.calls[0][0]({
type: 'cellular',
isConnected: false,
isInternetReachable: true,
details: {
isConnectionExpensive: true,
},
});
});

// Wait for the next tick of the event loop to allow state update
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0)); // or setImmediate
});

// Check if the hook state has been updated
expect(result.current).toBe(false);
});
});
Loading

0 comments on commit 8e76cc6

Please sign in to comment.