Skip to content

Commit

Permalink
feat: Implement better reanimated logger with clean stack traces (#6385)
Browse files Browse the repository at this point in the history
## Summary

This PR is a much cleaner approach than proposed in #6364. It includes
metro-config modification which is essential to collapse logs from
reanimated source code, which aren't helpful to the user while tracking
down the issue. The previous approach was trimming logs from reanimated
source code completely - this approach just collapses them, so that they
are still available to the user and can be revealed above the presented
stack trace part.

## General idea

To get better logs, I had to implement the following 2 changes:
1. **metro config** - the `symbolicator` must have been added to
properly collapse stack frames that aren't meaningful to the user,
2. **logger object** - the new logger object uses `LogBox.addLog`
method, thanks to which we can get pretty stack traces when we log a
warning from the UI thread (before such warnings didn't include
meaningful stack trace as error stack was created inside `LogBox` after
`runOnJS` was called, so we were getting a bit limited JS stack - see
[example
11](#6387 (comment))
in the follow up PR).

## Example improvement
(tested on a real project to see if it works there as well)

- current logs either point to the reanimated source code or aren't
readable at all (if warning is logged from the UI thread as in the
example below)
- new logger shows correct destination of the issue culprit in the code
frame, collapses stack frames in the call stack that aren't interesting
to the user (reanimated source code) and focuses on the file where the
user included problematic code

| Before | After |
|-|-|
| <video
src="https://github.com/user-attachments/assets/a5302586-f4d0-4636-8bd8-6c406c9d8c73"
/> | <video
src="https://github.com/user-attachments/assets/3121636f-69a2-4b6f-8f38-b1889d4c62e1"
/> |

## Test plan

See the example in the next PR (#6387).

---------

Co-authored-by: Tomasz Żelawski <[email protected]>
  • Loading branch information
MatiPl01 and tjzel authored Aug 26, 2024
1 parent a6b4ba1 commit 3b378fe
Show file tree
Hide file tree
Showing 17 changed files with 278 additions and 15 deletions.
7 changes: 6 additions & 1 deletion apps/fabric-example/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const {
wrapWithReanimatedMetroConfig,
} = require('react-native-reanimated/metro-config');

const path = require('path');

Expand All @@ -8,4 +11,6 @@ const config = {
watchFolders: [root],
};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);
module.exports = wrapWithReanimatedMetroConfig(
mergeConfig(getDefaultConfig(__dirname), config)
);
7 changes: 6 additions & 1 deletion apps/macos-example/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const {
wrapWithReanimatedMetroConfig,
} = require('react-native-reanimated/metro-config');

const path = require('path');
const exclusionList = require('metro-config/src/defaults/exclusionList');
Expand Down Expand Up @@ -30,4 +33,6 @@ const config = {
},
};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);
module.exports = wrapWithReanimatedMetroConfig(
mergeConfig(getDefaultConfig(__dirname), config)
);
7 changes: 6 additions & 1 deletion apps/paper-example/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const {
wrapWithReanimatedMetroConfig,
} = require('react-native-reanimated/metro-config');

const path = require('path');

Expand All @@ -8,4 +11,6 @@ const config = {
watchFolders: [root],
};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);
module.exports = wrapWithReanimatedMetroConfig(
mergeConfig(getDefaultConfig(__dirname), config)
);
7 changes: 6 additions & 1 deletion apps/tvos-example/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
const {
wrapWithReanimatedMetroConfig,
} = require('react-native-reanimated/metro-config');

const path = require('path');
const exclusionList = require('metro-config/src/defaults/exclusionList');
Expand Down Expand Up @@ -29,4 +32,6 @@ const config = {
},
};

module.exports = mergeConfig(getDefaultConfig(__dirname), config);
module.exports = wrapWithReanimatedMetroConfig(
mergeConfig(getDefaultConfig(__dirname), config)
);
5 changes: 4 additions & 1 deletion apps/web-example/metro.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const { getDefaultConfig } = require('expo/metro-config');
const {
wrapWithReanimatedMetroConfig,
} = require('react-native-reanimated/metro-config');

const path = require('path');
const exclusionList = require('metro-config/src/defaults/exclusionList');
Expand Down Expand Up @@ -27,4 +30,4 @@ config.resolver.blacklistRE = exclusionList(
)
);

module.exports = config;
module.exports = wrapWithReanimatedMetroConfig(config);
3 changes: 3 additions & 0 deletions packages/react-native-reanimated/metro-config/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { ConfigT } from 'metro-config';

export declare function wrapWithReanimatedMetroConfig(config: ConfigT): ConfigT;
35 changes: 35 additions & 0 deletions packages/react-native-reanimated/metro-config/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const COLLAPSED_STACK_REGEX = new RegExp(
[
// For internal usage in the example app
'/packages/react-native-reanimated/.+\\.(t|j)sx?$',
// When reanimated is installed as a dependency (node_modules)
'/node_modules/react-native-reanimated/.+\\.(t|j)sx?$',
]
// Make patterns work with both Windows and POSIX paths.
.map((pathPattern) => pathPattern.replaceAll('/', '[/\\\\]'))
.join('|')
);

function wrapWithReanimatedMetroConfig(config) {
return {
...config,
symbolicator: {
async customizeFrame(frame) {
const collapse = Boolean(
// Collapse the stack frame based on user's config symbolicator settings
(await config?.symbolicator?.customizeFrame?.(frame))?.collapse ||
// or, if not already collapsed, collapse the stack frame with path
// to react-native-reanimated source code
(frame.file && COLLAPSED_STACK_REGEX.test(frame.file))
);
return {
collapse,
};
},
},
};
}

module.exports = {
wrapWithReanimatedMetroConfig,
};
1 change: 1 addition & 0 deletions packages/react-native-reanimated/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"scripts/reanimated_utils.rb",
"mock.js",
"plugin/index.js",
"metro-config",
"!**/__tests__",
"!**/__fixtures__",
"!**/__mocks__",
Expand Down
4 changes: 2 additions & 2 deletions packages/react-native-reanimated/plugin/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/react-native-reanimated/plugin/src/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ const notCapturedIdentifiers = [

// Reanimated
'_WORKLET',
'ReanimatedError',
];

/**
Expand Down
34 changes: 34 additions & 0 deletions packages/react-native-reanimated/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,40 @@
'use strict';
import type { WorkletStackDetails } from './commonTypes';

type ReanimatedError = Error & 'ReanimatedError'; // signed type

interface ReanimatedErrorConstructor extends Error {
new (message?: string): ReanimatedError;
(message?: string): ReanimatedError;
readonly prototype: ReanimatedError;
}

const ReanimatedErrorConstructor: ReanimatedErrorConstructor =
function ReanimatedError(message?: string) {
'worklet';
const prefix = '[Reanimated]';
const errorInstance = new Error(message ? `${prefix} ${message}` : prefix);
errorInstance.name = 'ReanimatedError';
return errorInstance;
} as ReanimatedErrorConstructor;

export { ReanimatedErrorConstructor as ReanimatedError };

/**
* Registers `ReanimatedError` in global scope.
* Use it only for Worklet runtimes.
*/
export function registerReanimatedError() {
'worklet';
if (!_WORKLET) {
throw new Error(
'[Reanimated] registerReanimatedError() must be called on Worklet runtime'
);
}
(global as Record<string, unknown>).ReanimatedError =
ReanimatedErrorConstructor;
}

const _workletStackDetails = new Map<number, WorkletStackDetails>();

export function registerWorkletStackDetails(
Expand Down
56 changes: 56 additions & 0 deletions packages/react-native-reanimated/src/logger/LogBox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
'use strict';
/**
* Copied from:
* react-native/Libraries/LogBox/Data/LogBoxData.js
* react-native/Libraries/LogBox/Data/parseLogBoxLog.js
*/

import type { LogBoxStatic } from 'react-native';
import { LogBox as RNLogBox } from 'react-native';

export type LogLevel = 'warn' | 'error' | 'fatal' | 'syntax';

type Message = {
content: string;
substitutions: { length: number; offset: number }[];
};

type Category = string;

interface Location {
row: number;
column: number;
}

interface CodeFrame {
content: string;
location?: Location | null;
fileName: string;
collapse?: boolean;
}

type ComponentStack = CodeFrame[];

type ComponentStackType = 'legacy' | 'stack';

export type LogData = {
level: LogLevel;
message: Message;
category: Category;
componentStack: ComponentStack;
componentStackType: ComponentStackType | null;
stack?: string;
};

interface LogBoxExtended extends LogBoxStatic {
addLog(data: LogData): void;
}

const LogBox = RNLogBox as LogBoxExtended;

const noop = () => {
// do nothing
};

// Do nothing when addLogBoxLog is called if LogBox is not available
export const addLogBoxLog = LogBox?.addLog?.bind(LogBox) ?? noop;
3 changes: 3 additions & 0 deletions packages/react-native-reanimated/src/logger/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
'use strict';
export * from './logger';
export * from './LogBox';
81 changes: 81 additions & 0 deletions packages/react-native-reanimated/src/logger/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use strict';
import { addLogBoxLog } from './LogBox';
import type { LogLevel, LogData } from './LogBox';

function logToConsole(data: LogData) {
'worklet';
switch (data.level) {
case 'warn':
console.warn(data.message.content);
break;
case 'error':
case 'fatal':
case 'syntax':
console.error(data.message.content);
break;
}
}

function formatMessage(message: string) {
'worklet';
return `[Reanimated] ${message}`;
}

function createLog(level: LogLevel, message: string): LogData {
'worklet';
const formattedMessage = formatMessage(message);

return {
level,
message: {
content: formattedMessage,
substitutions: [],
},
category: formattedMessage,
componentStack: [],
componentStackType: null,
stack: new Error().stack,
};
}

const loggerImpl = {
logFunction: logToConsole,
};

/**
* Function that logs to LogBox and console.
* Used to replace the default console logging with logging to LogBox
* on the UI thread when runOnJS is available.
*
* @param data - The details of the log.
*/
export function logToLogBoxAndConsole(data: LogData) {
addLogBoxLog(data);
logToConsole(data);
}

/**
* Replaces the default log function with a custom implementation.
*
* @param logFunction - The custom log function.
*/
export function replaceLoggerImplementation(
logFunction: (data: LogData) => void
) {
loggerImpl.logFunction = logFunction;
}

export const logger = {
warn(message: string) {
'worklet';
loggerImpl.logFunction(createLog('warn', message));
},
error(message: string) {
'worklet';
loggerImpl.logFunction(createLog('error', message));
},
fatal(message: string) {
'worklet';
loggerImpl.logFunction(createLog('fatal', message));
},
};
2 changes: 2 additions & 0 deletions packages/react-native-reanimated/src/runtimes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';
import { isWorkletFunction } from './commonTypes';
import type { WorkletFunction } from './commonTypes';
import { registerReanimatedError } from './errors';
import { setupCallGuard, setupConsole } from './initializers';
import NativeReanimatedModule from './NativeReanimated';
import { shouldBeUseWeb } from './PlatformChecker';
Expand Down Expand Up @@ -38,6 +39,7 @@ export function createWorkletRuntime(
name,
makeShareableCloneRecursive(() => {
'worklet';
registerReanimatedError();
setupCallGuard();
setupConsole();
initializer?.();
Expand Down
Loading

0 comments on commit 3b378fe

Please sign in to comment.