Skip to content

Commit

Permalink
feat: show stack trace in the flipper plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
satya164 committed Jun 10, 2021
1 parent 67f6950 commit 97772af
Show file tree
Hide file tree
Showing 13 changed files with 263 additions and 47 deletions.
8 changes: 7 additions & 1 deletion packages/core/src/BaseNavigationContainer.tsx
Expand Up @@ -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]
);
Expand All @@ -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]
);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/NavigationBuilderContext.tsx
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/types.tsx
Expand Up @@ -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;
};
};
};
Expand Down
9 changes: 4 additions & 5 deletions packages/core/src/useDescriptors.tsx
Expand Up @@ -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;
Expand Down Expand Up @@ -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
);

Expand All @@ -115,6 +112,7 @@ export default function useDescriptors<
onRouteFocus,
onDispatchAction,
onOptionsChange,
stackRef,
}),
[
navigation,
Expand All @@ -124,6 +122,7 @@ export default function useDescriptors<
onRouteFocus,
onDispatchAction,
onOptionsChange,
stackRef,
]
);

Expand Down
45 changes: 37 additions & 8 deletions packages/core/src/useNavigationCache.tsx
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand All @@ -70,29 +73,55 @@ 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;
} else {
// 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) {
navigation.dispatch({ source: route.key, ...action });
}
};

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;
},
{}
Expand All @@ -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,
Expand Down
5 changes: 1 addition & 4 deletions packages/core/src/useNavigationHelpers.tsx
Expand Up @@ -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>;
Expand Down
131 changes: 114 additions & 17 deletions packages/devtools/src/useDevToolsBase.tsx
Expand Up @@ -6,27 +6,114 @@ 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);

React.useEffect(() => {
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;
Expand All @@ -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);
});
Expand All @@ -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;
}
});

Expand All @@ -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,
});
});
};

Expand All @@ -95,7 +192,7 @@ export default function useDevToolsBase(
unsubscribeState?.();
clearTimeout(timer);
};
}, [ref]);
}, [ref, send]);

const resetRoot = React.useCallback(
(state: NavigationState) => {
Expand Down

0 comments on commit 97772af

Please sign in to comment.