mirror of
https://github.com/zhigang1992/react-navigation.git
synced 2026-01-12 22:51:18 +08:00
feat: show stack trace in the flipper plugin
This commit is contained in:
@@ -218,7 +218,10 @@ const BaseNavigationContainer = React.forwardRef(
|
||||
|
||||
const onDispatchAction = React.useCallback(
|
||||
(action: NavigationAction, noop: boolean) => {
|
||||
emitter.emit({ type: '__unsafe_action__', data: { action, noop } });
|
||||
emitter.emit({
|
||||
type: '__unsafe_action__',
|
||||
data: { action, noop, stack: stackRef.current },
|
||||
});
|
||||
},
|
||||
[emitter]
|
||||
);
|
||||
@@ -241,12 +244,15 @@ const BaseNavigationContainer = React.forwardRef(
|
||||
[emitter]
|
||||
);
|
||||
|
||||
const stackRef = React.useRef<string | undefined>();
|
||||
|
||||
const builderContext = React.useMemo(
|
||||
() => ({
|
||||
addListener,
|
||||
addKeyedListener,
|
||||
onDispatchAction,
|
||||
onOptionsChange,
|
||||
stackRef,
|
||||
}),
|
||||
[addListener, addKeyedListener, onDispatchAction, onOptionsChange]
|
||||
);
|
||||
|
||||
@@ -61,6 +61,7 @@ const NavigationBuilderContext = React.createContext<{
|
||||
onRouteFocus?: (key: string) => void;
|
||||
onDispatchAction: (action: NavigationAction, noop: boolean) => void;
|
||||
onOptionsChange: (options: object) => void;
|
||||
stackRef?: React.MutableRefObject<string | undefined>;
|
||||
}>({
|
||||
onDispatchAction: () => undefined,
|
||||
onOptionsChange: () => undefined,
|
||||
|
||||
@@ -524,6 +524,10 @@ export type NavigationContainerEventMap = {
|
||||
* Whether the action was a no-op, i.e. resulted any state changes.
|
||||
*/
|
||||
noop: boolean;
|
||||
/**
|
||||
* Stack trace of the action, this will only be available during development.
|
||||
*/
|
||||
stack: string | undefined;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -60,10 +60,7 @@ type Options<
|
||||
navigation: any;
|
||||
options: ScreenOptions;
|
||||
}) => ScreenOptions);
|
||||
onAction: (
|
||||
action: NavigationAction,
|
||||
visitedNavigators?: Set<string>
|
||||
) => boolean;
|
||||
onAction: (action: NavigationAction) => boolean;
|
||||
getState: () => State;
|
||||
setState: (state: State) => void;
|
||||
addListener: AddListener;
|
||||
@@ -102,7 +99,7 @@ export default function useDescriptors<
|
||||
emitter,
|
||||
}: Options<State, ScreenOptions, EventMap>) {
|
||||
const [options, setOptions] = React.useState<Record<string, object>>({});
|
||||
const { onDispatchAction, onOptionsChange } = React.useContext(
|
||||
const { onDispatchAction, onOptionsChange, stackRef } = React.useContext(
|
||||
NavigationBuilderContext
|
||||
);
|
||||
|
||||
@@ -115,6 +112,7 @@ export default function useDescriptors<
|
||||
onRouteFocus,
|
||||
onDispatchAction,
|
||||
onOptionsChange,
|
||||
stackRef,
|
||||
}),
|
||||
[
|
||||
navigation,
|
||||
@@ -124,6 +122,7 @@ export default function useDescriptors<
|
||||
onRouteFocus,
|
||||
onDispatchAction,
|
||||
onOptionsChange,
|
||||
stackRef,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@react-navigation/routers';
|
||||
import * as React from 'react';
|
||||
|
||||
import NavigationBuilderContext from './NavigationBuilderContext';
|
||||
import type { NavigationHelpers, NavigationProp } from './types';
|
||||
import type { NavigationEventEmitter } from './useEventEmitter';
|
||||
|
||||
@@ -51,6 +52,8 @@ export default function useNavigationCache<
|
||||
router,
|
||||
emitter,
|
||||
}: Options<State, EventMap>) {
|
||||
const { stackRef } = React.useContext(NavigationBuilderContext);
|
||||
|
||||
// Cache object which holds navigation objects for each screen
|
||||
// We use `React.useMemo` instead of `React.useRef` coz we want to invalidate it when deps change
|
||||
// In reality, these deps will rarely change, if ever
|
||||
@@ -70,6 +73,10 @@ export default function useNavigationCache<
|
||||
>((acc, route) => {
|
||||
const previous = cache.current[route.key];
|
||||
|
||||
type Thunk =
|
||||
| NavigationAction
|
||||
| ((state: State) => NavigationAction | null | undefined);
|
||||
|
||||
if (previous) {
|
||||
// If a cached navigation object already exists, reuse it
|
||||
acc[route.key] = previous;
|
||||
@@ -77,11 +84,7 @@ export default function useNavigationCache<
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { emit, ...rest } = navigation;
|
||||
|
||||
const dispatch = (
|
||||
thunk:
|
||||
| NavigationAction
|
||||
| ((state: State) => NavigationAction | null | undefined)
|
||||
) => {
|
||||
const dispatch = (thunk: Thunk) => {
|
||||
const action = typeof thunk === 'function' ? thunk(getState()) : thunk;
|
||||
|
||||
if (action != null) {
|
||||
@@ -89,10 +92,36 @@ export default function useNavigationCache<
|
||||
}
|
||||
};
|
||||
|
||||
const withStack = (callback: () => void) => {
|
||||
let isStackSet = false;
|
||||
|
||||
try {
|
||||
if (
|
||||
process.env.NODE_ENV !== 'production' &&
|
||||
stackRef &&
|
||||
!stackRef.current
|
||||
) {
|
||||
// Capture the stack trace for devtools
|
||||
stackRef.current = new Error().stack;
|
||||
isStackSet = true;
|
||||
}
|
||||
|
||||
callback();
|
||||
} finally {
|
||||
if (isStackSet && stackRef) {
|
||||
stackRef.current = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const helpers = Object.keys(actions).reduce<Record<string, () => void>>(
|
||||
(acc, name) => {
|
||||
// @ts-expect-error: name is a valid key, but TypeScript is dumb
|
||||
acc[name] = (...args: any) => dispatch(actions[name](...args));
|
||||
acc[name] = (...args: any) =>
|
||||
withStack(() =>
|
||||
// @ts-expect-error: name is a valid key, but TypeScript is dumb
|
||||
dispatch(actions[name](...args))
|
||||
);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
@@ -103,7 +132,7 @@ export default function useNavigationCache<
|
||||
...helpers,
|
||||
// FIXME: too much work to fix the types for now
|
||||
...(emitter.create(route.key) as any),
|
||||
dispatch,
|
||||
dispatch: (thunk: Thunk) => withStack(() => dispatch(thunk)),
|
||||
setOptions: (options: object) =>
|
||||
setOptions((o) => ({
|
||||
...o,
|
||||
|
||||
@@ -17,10 +17,7 @@ import type { NavigationEventEmitter } from './useEventEmitter';
|
||||
PrivateValueStore;
|
||||
|
||||
type Options<State extends NavigationState, Action extends NavigationAction> = {
|
||||
onAction: (
|
||||
action: NavigationAction,
|
||||
visitedNavigators?: Set<string>
|
||||
) => boolean;
|
||||
onAction: (action: NavigationAction) => boolean;
|
||||
getState: () => State;
|
||||
emitter: NavigationEventEmitter<any>;
|
||||
router: Router<State, Action>;
|
||||
|
||||
@@ -6,20 +6,42 @@ import type {
|
||||
import deepEqual from 'deep-equal';
|
||||
import * as React from 'react';
|
||||
|
||||
type StackFrame = {
|
||||
lineNumber: number | null;
|
||||
column: number | null;
|
||||
file: string | null;
|
||||
methodName: string;
|
||||
};
|
||||
|
||||
type StackFrameResult = StackFrame & {
|
||||
collapse: boolean;
|
||||
};
|
||||
|
||||
type StackResult = {
|
||||
stack: StackFrameResult[];
|
||||
};
|
||||
|
||||
type InitData = {
|
||||
type: 'init';
|
||||
state: NavigationState | undefined;
|
||||
};
|
||||
|
||||
type ActionData = {
|
||||
type: 'action';
|
||||
action: NavigationAction;
|
||||
state: NavigationState | undefined;
|
||||
stack: string | undefined;
|
||||
};
|
||||
|
||||
export default function useDevToolsBase(
|
||||
ref: React.RefObject<NavigationContainerRef<any>>,
|
||||
callback: (
|
||||
...args:
|
||||
| [type: 'init', state: NavigationState | undefined]
|
||||
| [
|
||||
type: 'action',
|
||||
action: NavigationAction,
|
||||
state: NavigationState | undefined
|
||||
]
|
||||
) => void
|
||||
callback: (result: InitData | ActionData) => void
|
||||
) {
|
||||
const lastStateRef = React.useRef<NavigationState | undefined>();
|
||||
const lastActionRef = React.useRef<NavigationAction | undefined>();
|
||||
const lastActionRef =
|
||||
React.useRef<
|
||||
{ action: NavigationAction; stack: string | undefined } | undefined
|
||||
>();
|
||||
const callbackRef = React.useRef(callback);
|
||||
const lastResetRef = React.useRef<NavigationState | undefined>(undefined);
|
||||
|
||||
@@ -27,6 +49,71 @@ export default function useDevToolsBase(
|
||||
callbackRef.current = callback;
|
||||
});
|
||||
|
||||
const symbolicate = async (stack: string | undefined) => {
|
||||
if (stack == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const frames = stack
|
||||
.split('\n')
|
||||
.slice(2)
|
||||
.map((line): StackFrame | null => {
|
||||
const partMatch = line.match(/^((.+)@)?(.+):(\d+):(\d+)$/);
|
||||
|
||||
if (!partMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, , methodName, file, lineNumber, column] = partMatch;
|
||||
|
||||
return {
|
||||
methodName,
|
||||
file,
|
||||
lineNumber: Number(lineNumber),
|
||||
column: Number(column),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as StackFrame[];
|
||||
|
||||
const urlMatch = frames[0].file?.match(/^https?:\/\/.+(:\d+)?\//);
|
||||
|
||||
if (!urlMatch) {
|
||||
return stack;
|
||||
}
|
||||
|
||||
try {
|
||||
const result: StackResult = await fetch(`${urlMatch[0]}symbolicate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ stack: frames }),
|
||||
}).then((res) => res.json());
|
||||
|
||||
return result.stack
|
||||
.filter((it) => !it.collapse)
|
||||
.map(
|
||||
({ methodName, file, lineNumber, column }) =>
|
||||
`${methodName}@${file}:${lineNumber}:${column}`
|
||||
)
|
||||
.join('\n');
|
||||
} catch (err) {
|
||||
return stack;
|
||||
}
|
||||
};
|
||||
|
||||
const pendingPromiseRef = React.useRef<Promise<void>>(Promise.resolve());
|
||||
|
||||
const send = React.useCallback((data: ActionData) => {
|
||||
// We need to make sure that our callbacks executed in the same order
|
||||
pendingPromiseRef.current = pendingPromiseRef.current.then(async () => {
|
||||
if (data.stack) {
|
||||
const stack = await symbolicate(data.stack);
|
||||
|
||||
callbackRef.current({ ...data, stack });
|
||||
} else {
|
||||
callbackRef.current(data);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let timer: any;
|
||||
let unsubscribeAction: (() => void) | undefined;
|
||||
@@ -43,7 +130,7 @@ export default function useDevToolsBase(
|
||||
const state = ref.current.getRootState();
|
||||
|
||||
lastStateRef.current = state;
|
||||
callbackRef.current('init', state);
|
||||
callbackRef.current({ type: 'init', state });
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
@@ -56,9 +143,14 @@ export default function useDevToolsBase(
|
||||
|
||||
if (e.data.noop) {
|
||||
// Even if the state didn't change, it's useful to show the action
|
||||
callbackRef.current('action', action, lastStateRef.current);
|
||||
send({
|
||||
type: 'action',
|
||||
action,
|
||||
state: lastStateRef.current,
|
||||
stack: e.data.stack,
|
||||
});
|
||||
} else {
|
||||
lastActionRef.current = action;
|
||||
lastActionRef.current = e.data;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -74,17 +166,22 @@ export default function useDevToolsBase(
|
||||
|
||||
const state = navigation.getRootState();
|
||||
const lastState = lastStateRef.current;
|
||||
const action = lastActionRef.current;
|
||||
const lastChange = 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)) {
|
||||
if (lastChange === undefined && deepEqual(state, lastState)) {
|
||||
return;
|
||||
}
|
||||
|
||||
callbackRef.current('action', action ?? { type: '@@UNKNOWN' }, state);
|
||||
send({
|
||||
type: 'action',
|
||||
action: lastChange ? lastChange.action : { type: '@@UNKNOWN' },
|
||||
state,
|
||||
stack: lastChange?.stack,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -95,7 +192,7 @@ export default function useDevToolsBase(
|
||||
unsubscribeState?.();
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [ref]);
|
||||
}, [ref, send]);
|
||||
|
||||
const resetRoot = React.useCallback(
|
||||
(state: NavigationState) => {
|
||||
|
||||
@@ -26,25 +26,26 @@ export default function useFlipper(
|
||||
|
||||
const connectionRef = React.useRef<Flipper.FlipperConnection>();
|
||||
|
||||
const { resetRoot } = useDevToolsBase(ref, (...args) => {
|
||||
const { resetRoot } = useDevToolsBase(ref, (result) => {
|
||||
const connection = connectionRef.current;
|
||||
|
||||
if (!connection) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (args[0]) {
|
||||
switch (result.type) {
|
||||
case 'init':
|
||||
connection.send('init', {
|
||||
id: nanoid(),
|
||||
state: args[1],
|
||||
state: result.state,
|
||||
});
|
||||
break;
|
||||
case 'action':
|
||||
connection.send('action', {
|
||||
id: nanoid(),
|
||||
action: args[1],
|
||||
state: args[2],
|
||||
action: result.action,
|
||||
state: result.state,
|
||||
stack: result.stack,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -32,19 +32,19 @@ export default function useReduxDevToolsExtension(
|
||||
});
|
||||
}
|
||||
|
||||
const { resetRoot } = useDevToolsBase(ref, (...args) => {
|
||||
const { resetRoot } = useDevToolsBase(ref, (result) => {
|
||||
const devTools = devToolsRef.current;
|
||||
|
||||
if (!devTools) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (args[0]) {
|
||||
switch (result.type) {
|
||||
case 'init':
|
||||
devTools.init(args[1]);
|
||||
devTools.init(result.state);
|
||||
break;
|
||||
case 'action':
|
||||
devTools.send(args[1], args[2]);
|
||||
devTools.send(result.action, result.state);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,7 +45,11 @@ export function Logs({ active, logs, index, resetTo }: Props) {
|
||||
{active ? (
|
||||
<DetailSidebar>
|
||||
{selectedItem && (
|
||||
<Sidebar action={selectedItem.action} state={selectedItem.state} />
|
||||
<Sidebar
|
||||
action={selectedItem.action}
|
||||
state={selectedItem.state}
|
||||
stack={selectedItem.stack}
|
||||
/>
|
||||
)}
|
||||
</DetailSidebar>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Layout, ManagedDataInspector } from 'flipper';
|
||||
import { Layout, ManagedDataInspector, styled } from 'flipper';
|
||||
import { theme } from 'flipper-plugin';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Title4 } from './Typography';
|
||||
@@ -6,12 +7,65 @@ import { Title4 } from './Typography';
|
||||
export function Sidebar({
|
||||
action,
|
||||
state,
|
||||
stack,
|
||||
}: {
|
||||
action: object;
|
||||
state: object | undefined;
|
||||
stack: string | undefined;
|
||||
}) {
|
||||
return (
|
||||
<Layout.Container gap pad>
|
||||
{stack ? (
|
||||
<>
|
||||
<Title4>Stack</Title4>
|
||||
<Code>
|
||||
{stack.split('\n').map((line, index) => {
|
||||
const match = line.match(/^(.+)@(.+):(\d+):(\d+)$/);
|
||||
|
||||
if (match) {
|
||||
const [, methodName, file, lineNumber, column] = match;
|
||||
|
||||
if (file.includes('/node_modules/@react-navigation')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={index}>
|
||||
{methodName.split('.').map((part, i, self) => {
|
||||
if (i === self.length - 1 && i !== 0) {
|
||||
return <Method>{part}</Method>;
|
||||
}
|
||||
|
||||
if (self.length !== 1) {
|
||||
return (
|
||||
<>
|
||||
{part}
|
||||
<Separator>.</Separator>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return part;
|
||||
})}{' '}
|
||||
<Separator>(</Separator>
|
||||
<StringToken>{file.split('/').pop()}</StringToken>
|
||||
<Separator>:</Separator>
|
||||
<NumberToken>{lineNumber}</NumberToken>:
|
||||
<NumberToken>{column}</NumberToken>
|
||||
<Separator>)</Separator>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div key={index}>{line}</div>
|
||||
);
|
||||
})}
|
||||
</Code>
|
||||
</>
|
||||
) : null}
|
||||
<Title4>Action</Title4>
|
||||
<ManagedDataInspector data={action} expandRoot={false} />
|
||||
<Title4>State</Title4>
|
||||
@@ -19,3 +73,25 @@ export function Sidebar({
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
|
||||
const Code = styled.div({
|
||||
fontSize: 11,
|
||||
fontFamily: theme.monospace.fontFamily,
|
||||
margin: '7.5px 0px',
|
||||
});
|
||||
|
||||
const StringToken = styled.span({
|
||||
color: 'rgb(224, 76, 96)',
|
||||
});
|
||||
|
||||
const NumberToken = styled.span({
|
||||
color: 'rgb(77, 187, 166)',
|
||||
});
|
||||
|
||||
const Method = styled.span({
|
||||
color: 'rgb(123, 100, 192)',
|
||||
});
|
||||
|
||||
const Separator = styled.span({
|
||||
color: '#555',
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { styled } from 'flipper';
|
||||
import { theme } from 'flipper-plugin';
|
||||
|
||||
export const Title4 = styled.h4({
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
fontSize: theme.fontSize.default,
|
||||
lineHeight: 1.4,
|
||||
letterSpacing: -0.24,
|
||||
marginBottom: 0,
|
||||
|
||||
@@ -30,6 +30,7 @@ export type Log = {
|
||||
id: string;
|
||||
action: NavigationAction;
|
||||
state: NavigationState | undefined;
|
||||
stack: string | undefined;
|
||||
};
|
||||
|
||||
export type StoreType = {
|
||||
|
||||
Reference in New Issue
Block a user