feat: initial implementation of a flipper plugin

This commit is contained in:
Satyajit Sahoo
2021-03-20 17:36:20 +01:00
parent a7bb7a4afa
commit d6f6f5f94d
32 changed files with 4449 additions and 221 deletions

View File

@@ -36,20 +36,23 @@
"clean": "del lib"
},
"dependencies": {
"@react-navigation/core": "^6.0.0-next.8",
"deep-equal": "^2.0.5"
"deep-equal": "^2.0.5",
"nanoid": "^3.1.22"
},
"devDependencies": {
"@react-navigation/core": "^6.0.0-next.8",
"@testing-library/react-native": "^7.2.0",
"@types/deep-equal": "^1.0.1",
"@types/react": "^16.9.53",
"del-cli": "^3.0.1",
"react": "~16.13.1",
"react-native-builder-bob": "^0.18.1",
"react-native-flipper": "^0.80.0",
"typescript": "^4.2.3"
},
"peerDependencies": {
"react": "*"
"react": "*",
"react-native-flipper": "*"
},
"react-native-builder-bob": {
"source": "src",

View File

@@ -1,9 +1,12 @@
const noop: any = () => {};
export let useReduxDevToolsExtension: typeof import('./useReduxDevToolsExtension').default;
export let useFlipper: typeof import('./useFlipper').default;
if (process.env.NODE_ENV !== 'production') {
useReduxDevToolsExtension = require('./useReduxDevToolsExtension').default;
useFlipper = require('./useFlipper').default;
} else {
useReduxDevToolsExtension = noop;
useFlipper = noop;
}

View File

@@ -0,0 +1,111 @@
import * as React from 'react';
import type {
NavigationContainerRef,
NavigationState,
NavigationAction,
} from '@react-navigation/core';
import deepEqual from 'deep-equal';
export default function useDevToolsBase(
ref: React.RefObject<NavigationContainerRef<any>>,
callback: (
...args:
| [type: 'init', state: NavigationState | undefined]
| [
type: 'action',
action: NavigationAction,
state: NavigationState | undefined
]
) => void
) {
const lastStateRef = React.useRef<NavigationState | undefined>();
const lastActionRef = React.useRef<NavigationAction | undefined>();
const callbackRef = React.useRef(callback);
const lastResetRef = React.useRef<NavigationState | undefined>(undefined);
React.useEffect(() => {
callbackRef.current = callback;
});
React.useEffect(() => {
let timer: number;
let unsubscribeAction: (() => void) | undefined;
let unsubscribeState: (() => void) | undefined;
const initialize = async () => {
if (!ref.current) {
// If the navigation object isn't ready yet, wait for it
await new Promise<void>((resolve) => {
timer = setInterval(() => {
if (ref.current) {
resolve();
clearTimeout(timer);
const state = ref.current.getRootState();
lastStateRef.current = state;
callbackRef.current('init', state);
}
}, 100);
});
}
const navigation = ref.current!;
unsubscribeAction = navigation.addListener('__unsafe_action__', (e) => {
const action = e.data.action;
if (e.data.noop) {
// Even if the state didn't change, it's useful to show the action
callbackRef.current('action', action, lastStateRef.current);
} else {
lastActionRef.current = action;
}
});
unsubscribeState = navigation.addListener('state', (e) => {
// Don't show the action in dev tools if the state is what we sent to reset earlier
if (
lastResetRef.current &&
deepEqual(lastResetRef.current, e.data.state)
) {
lastStateRef.current = undefined;
return;
}
const state = navigation.getRootState();
const lastState = lastStateRef.current;
const action = lastActionRef.current;
lastActionRef.current = undefined;
lastStateRef.current = state;
// If we don't have an action and the state didn't change, then it's probably extraneous
if (action === undefined && deepEqual(state, lastState)) {
return;
}
callbackRef.current('action', action ?? { type: '@@UNKNOWN' }, state);
});
};
initialize();
return () => {
unsubscribeAction?.();
unsubscribeState?.();
clearTimeout(timer);
};
}, [ref]);
const resetRoot = React.useCallback(
(state: NavigationState) => {
if (ref.current) {
lastResetRef.current = state;
ref.current.resetRoot(state);
}
},
[ref]
);
return { resetRoot };
}

View File

@@ -0,0 +1,95 @@
import * as React from 'react';
import { addPlugin, Flipper } from 'react-native-flipper';
import type { NavigationContainerRef } from '@react-navigation/core';
import { nanoid } from 'nanoid/non-secure';
import useDevToolsBase from './useDevToolsBase';
export default function useFlipper(
ref: React.RefObject<NavigationContainerRef<any>>
) {
const connectionRef = React.useRef<Flipper.FlipperConnection>();
const { resetRoot } = useDevToolsBase(ref, (...args) => {
const connection = connectionRef.current;
if (!connection) {
return;
}
switch (args[0]) {
case 'init':
connection.send('init', {
id: nanoid(),
state: args[1],
});
break;
case 'action':
connection.send('action', {
id: nanoid(),
action: args[1],
state: args[2],
});
break;
}
});
React.useEffect(() => {
addPlugin({
getId() {
return 'react-navigation';
},
async onConnect(connection) {
connectionRef.current = connection;
const on = (event: string, listener: (params: any) => Promise<any>) => {
connection.receive(event, (params, responder) => {
try {
const result = listener(params);
// Return null instead of undefined, otherwise flipper doesn't respond
responder.success(result ?? null);
} catch (e) {
responder.error(e);
}
});
};
on('navigation.invoke', ({ method, args = [] }) => {
switch (method) {
case 'resetRoot':
return resetRoot(args[0]);
default:
// @ts-expect-error: we want to call arbitrary methods here
return ref.current?.[method](...args);
}
});
on('linking.invoke', ({ method, args = [] }) => {
// @ts-expect-error: __linking isn't publicly exposed
const linking = ref.current?.__linking;
switch (method) {
case 'getStateFromPath':
case 'getPathFromState':
case 'getActionFromState':
return linking?.[method](
args[0],
args[1]?.trim()
? // eslint-disable-next-line no-eval
eval(`(function() { return ${args[1]}; }())`)
: linking.config
);
default:
return linking?.[method](...args);
}
});
},
onDisconnect() {
connectionRef.current = undefined;
},
runInBackground() {
return false;
},
});
}, [ref, resetRoot]);
}

View File

@@ -1,10 +1,6 @@
import * as React from 'react';
import type {
NavigationContainerRef,
NavigationState,
NavigationAction,
} from '@react-navigation/core';
import deepEqual from 'deep-equal';
import type { NavigationContainerRef } from '@react-navigation/core';
import useDevToolsBase from './useDevToolsBase';
type DevToolsConnection = {
init(value: any): void;
@@ -35,8 +31,22 @@ export default function useReduxDevToolsExtension(
});
}
const lastStateRef = React.useRef<NavigationState | undefined>();
const lastActionRef = React.useRef<NavigationAction | undefined>();
const { resetRoot } = useDevToolsBase(ref, (...args) => {
const devTools = devToolsRef.current;
if (!devTools) {
return;
}
switch (args[0]) {
case 'init':
devTools.init(args[1]);
break;
case 'action':
devTools.send(args[1], args[2]);
break;
}
});
React.useEffect(
() =>
@@ -44,66 +54,9 @@ export default function useReduxDevToolsExtension(
if (message.type === 'DISPATCH' && message.state) {
const state = JSON.parse(message.state);
lastStateRef.current = state;
ref.current?.resetRoot(state);
resetRoot(state);
}
}),
[ref]
[resetRoot]
);
React.useEffect(() => {
const devTools = devToolsRef.current;
const navigation = ref.current;
if (!navigation || !devTools) {
return;
}
if (lastStateRef.current === undefined) {
const state = navigation.getRootState();
devTools.init(state);
lastStateRef.current = state;
}
const unsubscribeAction = navigation.addListener(
'__unsafe_action__',
(e) => {
const action = e.data.action;
if (e.data.noop) {
// Even if the state didn't change, it's useful to show the action
devTools.send(action, lastStateRef.current);
} else {
lastActionRef.current = action;
}
}
);
const unsubscribeState = navigation.addListener('state', (e) => {
// Don't show the action in dev tools if the state is what we sent to reset earlier
if (lastStateRef.current === e.data.state) {
return;
}
const lastState = lastStateRef.current;
const state = navigation.getRootState();
const action = lastActionRef.current;
lastActionRef.current = undefined;
lastStateRef.current = state;
// If we don't have an action and the state didn't change, then it's probably extraneous
if (action === undefined && deepEqual(state, lastState)) {
return;
}
devTools.send(action ?? '@@UNKNOWN', state);
});
return () => {
unsubscribeAction();
unsubscribeState();
};
});
}