-
Notifications
You must be signed in to change notification settings - Fork 47.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Import React Concurrent Mode Profiler #19634
Import React Concurrent Mode Profiler #19634
Conversation
Implement parallel lanes
Disable parcel caching to fix tooltips
Enable Flow in VS Code by adding flow-bin, .flowconfig and VS code settings
Based on CRA's setup.
This reverts commit e027a56.
* Move constants to constants.js * Reorg, typo fixes * Move more helper funcs to canvasUtils * Move canvas code to canvas * Move helper functions into utils * Run prettier, fix CLI script command * remove unused imports in app.js, rename utils.js * Update renderCanvas.js * prettier CLI fix, move constants to canvas
Add types for canvas files
* Upgrade Prettier * Copy React's Prettier config * Customize prettier config to allow CSS/JSON prettifying and ignore folders * yarn prettier * Downgrade to Prettier 1.19.1 and run prettier
* Copy React's ESLint config * Add ESLint etc deps and lint script Copied from React, but we also upgrade all of them except eslint-config-fbjs. * Delete all irrelevant ESLint rules * Run yarn lint --fix * Fix lint * Replace single quotes in lint stript with double quotes * Add newline at end of .eslintignore
* Add GitHub Actions workflow Adds CI workflow to run `yarn lint`, `yarn test`, and `yarn build`. `yarn flow` excluded for now as we still have many typecheck failures. Based on: - Default Node.js workflow - https://github.com/actions/cache/blob/master/examples.md#node---yarn * Prettify node.js.yml * Configure test script to pass with no tests Tests are currently failing as there are no tests in the codebase. This commit adds `--passWithNoTests` to make Jest not error. This commit should be reverted once there are tests in our codebase. * Revert "Configure test script to pass with no tests" This reverts commit bd57b9ea82328fb3765b91b7847eb99f51b7aae4. * Fix lint in new files on master
* Unexport unnecessary exported vars in src/canvas * Remove unused App.css classes * Extract CanvasPage from App.js and clean up now-non-nullable types * Wrap App in React.StrictMode * Extract automatic ImportPage from App.js * Wrap App updates in batchedUpdates
`preprocessDataV2` previously took the `ts` value of the first timeline event, following `preprocessData`. However, this is problematic with Chrome performance data, as Chrome sticks a bunch of metadata events (i.e. `ph === 'M'`) with `ts === 0`. This caused the computed React events and measures to have enormous `timestamp` values, since they were now offset from 0 instead of being offset from the first event. This commit fixes this issue by taking the `ts` value of the first event with a non-zero and non-undefined `ts`. This fix was implemented with reference to the trace event doc: https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU/preview 1. V2 measures now appear correctly in WIP (not pushed) canvas with V2 data. 1. CI. Tests still passing.
Fix preprocessDataV2 to prevent enormous React timestamps
No worries! I had planned for that so the Babel upgrade commit could be cherry picked and reverted easily 😆 The PR is opened at #19647. I'll revert commit |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think I've reviewed everything but the View classes.
Trying to find time to do those too but have had a ton of meetings today.
onChange?: OnChangeFn, | ||
pageX: number, | ||
pageY: number, | ||
|}) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know I wrote this originally, but as I look at it again– I realize the "show" function is a little brittle. If a menu is already being shown, calling show again will leave that menu hanging.
Seems like we should check if currentHideFn !=== null
and if so– call hideMenu
before continuing to show the new one. Thoughts?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, nice catch! I'll do that.
Should I also make this change in react-devtools-shared
's implementation too? Also, would it be a good idea to merge the 2 context menu implementations after this PR? I think the only differences are that the scheduling profiler's implementation has an extra onChange
callback (from your prototype) and slightly more comprehensive Flow types (which I added), as well as some CSS changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah if the same bug exists in react-devtools-shared'
it would be nice to do a fix for that as well (maybe as a separate PR tho 😁) Flow typing improvements also welcome!
Let's not do any code-sharing yet.
/** | ||
* Prerequisite: rect1 must intersect with rect2. | ||
*/ | ||
export function rectIntersectionWithRect(rect1: Rect, rect2: Rect): Rect { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Can we name this something more semantically meaningful? The current name is too similar to rectIntersectsRect
.
Maybe something like intersectionOfRects
(to match the naming of unionOfRects
below)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense! When I wrote this I didn't want to call it intersectionOfRects
because I thought it'd be more confusing that unionOfRects
accepts any number of rects whereas this only accepts 2. But I agree that intersectionOfRects
will be easier to read, especially since it's often called close to rectIntersectsRect
. I'll rename it
) => Layout; | ||
|
||
function viewToLayoutInfo(view: View): LayoutInfo { | ||
return {view, frame: view.frame}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This conversion seems not very useful. Can we get rid of it?
Seems like passing the view around would be the same in terms of property access for the frame
bit, and would remove the unnecessary object wrapper and view
access for other properties.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This conversion seems not very useful. Can we get rid of it?
I don't think we can 😆
Because the layouters are composable (via createComposedLayout
), they need some way to pass the intermediate frames to each other. If we get rid of this LayoutInfo
wrapper and store the intermediate frames in the views, we have some problems because:
-
Invalidated views are redrawn the next time
CanvasPage
's layout effects are flushed. Because theView
class'ssetFrame
method invalidates the view's contents (by callingsetsNeedsDisplay
) if the frame is changed, we only want to callsetFrame
with the final frame, so that the view will only be redrawn if the frame actually changed.Without the
LayoutInfo
wrapper, the intermediate frames will trigger a lot of unnecessary invalidation and thus many views will be unnecessarily redrawn. -
View subclasses can override the
setFrame
method and do arbitrary things, including updating its state. For example,HorizontalPanAndZoomView
andVerticalScrollView
re-clamp their offsets and zoom levels when the frame changes, so that e.g. resizing the browser window don't cause black areas to appear. If this is done with intermediate frames, this clamping can cause bugs, e.g. if an intermediate frame had a much widerwidth
than the final one, resizing the browser window may cause you to suddenly scroll left as the offset got clamped with that intermediatewidth
.
This LayoutInfo
wrapper was the best way I could think of to solve this. Is there a better way to solve this?
} | ||
|
||
export function viewsToLayout(views: View[]): Layout { | ||
return views.map(viewToLayoutInfo); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ditto for this mapping.
x: interaction.payload.event.x, | ||
y: interaction.payload.event.y, | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should still pass a deps array to useCallback
. That array should just be empty.
Without the deps array, useCallback
will just always pass the new function every time it's called.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is coming along nicely.
This morning I looked through the View
classes. I have some initial high-level thoughts which I'll leave as comments.
<a href="https://github.com/MLH-Fellowship/scheduling-profiler-prototype"> | ||
<button className={style.ViewSourceButton}> | ||
<span>Source </span> | ||
</button> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did we intentionally remove the loading spinner state? Looks like the browser is back to looking frozen when importing large profiles again.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah! I mixed PRs up then.
Sounds good. Thanks @kartikcho
} | ||
|
||
_getResizeBar(): View { | ||
return this.subviews[1]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This approach makes me a little uneasy, because subviews
is managed by the superclass– but the subclass has hard-coded assumptions about the size and ordering.
I wonder if you considered making subviews
a getter method that the subclasses had to implement rather than calling addSubview
to register? Then this casting could be unnecessary, e.g.
export class ResizableSplitView extends View {
// No casting or Array access necessary:
_bottomSubview: View;
_resizeBar: View;
_topSubview: View;
// Memoized array:
_subviews: View[];
constructor(
surface: Surface,
frame: Rect,
topSubview: View,
bottomSubview: View
) {
super(surface, frame, noopLayout);
this._bottomSubview = bottomSubview;
this._resizeBar = new ResizeBar(surface, frame);
this._topSubview = topSubview;
this._subviews = [_topSubview, _resizeBar, _bottomSubview];
// ...
}
getSubviews(): View[] {
this._subviews;
}
}
(Same general idea goes for the other views too, but I won't repeat the comment.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, I actually implemented something similar initially. Unfortunately, I didn't think it was very clean as it forced all view subclasses to correctly handle and maintain subview/superview relationships. I figured it'll be better to centralize all of that in the base View
class. What do you think?
Regarding this particular issue, maybe it'll be better if we stored a reference to the views in the subclass? It's definitely still not great though. Seems like there are some deep seated issues in this architecture 😞
export class ResizableSplitView extends View {
// No casting or Array access necessary:
_bottomSubview: View;
_resizeBar: View;
_topSubview: View;
constructor(
surface: Surface,
frame: Rect,
topSubview: View,
bottomSubview: View
) {
super(surface, frame, noopLayout);
this._bottomSubview = bottomSubview;
this._resizeBar = new ResizeBar(surface, frame);
this._topSubview = topSubview;
this.addSubview(this._topSubview);
this.addSubview(this._resizeBar);
this.addSubview(this._bottomSubview);
// ...
}
// ...
}
this.subviews.forEach(subview => | ||
subview.handleInteractionAndPropagateToSubviews(interaction), | ||
); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The way handleInteraction
and handleInteractionAndPropagateToSubviews
work together seems like an unfortunate constraint that results from using inheritance.
I guess we could enforce that handleInteraction
was never called directly, e.g.
class Foo {
_handleInteractionLock: boolean = true;
handleInteraction(interaction: Interaction) {
if (this._handleInteractionLock) {
throw Error("Do not call handleInteraction() directly");
}
// ...
}
handleInteractionAndPropagateToSubviews(interaction: Interaction) {
this._handleInteractionLock = false;
try {
this.handleInteraction(interaction);
} finally {
this._handleInteractionLock = true;
}
}
}
But this feels like overkill.
I wonder if you considered an alternate design here that used composition instead, to better enforce the API between both sides? Then the separation might be a little clearer.
I haven't thought about this deeply, but maybe something like
class ViewManager {
_view: View;
constructor(view: View) {
this._view = view;
}
handleInteraction(interaction: Interaction) {
const view = this._view;
view.handleInteraction(interaction);
view
.getSubviews()
.forEach((subview) =>
subview.handleInteractionAndPropagateToSubviews(interaction)
);
}
// ...
}
class ExampleView implements View {
constructor() {
this._viewManager = new ViewManager(this);
}
getSubviews(): View[] {
// ...
}
handleInteraction(interaction: Interaction) {
// ...
}
// ...
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, maybe I'm not understanding your ViewManager
design correctly. If the ViewManager
class is an implementation detail of the View
class, won't View
still need to implement all its existing methods, including handleInteractionAndPropagateToSubviews
?
What about this alternative design:
- Turn
View
into an interface that only implements e.g.draw
,handleInteraction
, etc. - Define a new public
ViewManager
class that handles the invalidation and interaction propagation logic.
CanvasPage
will then instantiate ViewManager
s and inject concrete View
implementations into them.
This will mean that the manager will handle subViewManager/superViewManager relationships. Also, View
implementations that instantiate their own subviews (e.g. ResizableSplitView
, FlamechartView
) will need to know how to instantiate ViewManager
s, which doesn't feel right.
_drawSingleMark( | ||
context: CanvasRenderingContext2D, | ||
rect: Rect, | ||
mark: UserTimingMark, | ||
baseY: number, | ||
scaleFactor: number, | ||
showHoverHighlight: boolean, | ||
) { | ||
const {frame} = this; | ||
const {timestamp} = mark; | ||
|
||
const x = timestampToPosition(timestamp, scaleFactor, frame); | ||
const radius = EVENT_DIAMETER / 2; | ||
const markRect: Rect = { | ||
origin: { | ||
x: x - radius, | ||
y: baseY, | ||
}, | ||
size: {width: EVENT_DIAMETER, height: EVENT_DIAMETER}, | ||
}; | ||
if (!rectIntersectsRect(markRect, rect)) { | ||
return; // Not in view | ||
} | ||
|
||
const fillStyle = showHoverHighlight | ||
? COLORS.USER_TIMING_HOVER | ||
: COLORS.USER_TIMING; | ||
|
||
if (fillStyle !== null) { | ||
const y = markRect.origin.y + radius; | ||
|
||
context.beginPath(); | ||
context.fillStyle = fillStyle; | ||
context.arc(x, y, radius, 0, 2 * Math.PI); | ||
context.fill(); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we give these marks a different visual style, to set them apart from the e.g. state updates and suspends? Maybe something like:
_drawSingleMark( | |
context: CanvasRenderingContext2D, | |
rect: Rect, | |
mark: UserTimingMark, | |
baseY: number, | |
scaleFactor: number, | |
showHoverHighlight: boolean, | |
) { | |
const {frame} = this; | |
const {timestamp} = mark; | |
const x = timestampToPosition(timestamp, scaleFactor, frame); | |
const radius = EVENT_DIAMETER / 2; | |
const markRect: Rect = { | |
origin: { | |
x: x - radius, | |
y: baseY, | |
}, | |
size: {width: EVENT_DIAMETER, height: EVENT_DIAMETER}, | |
}; | |
if (!rectIntersectsRect(markRect, rect)) { | |
return; // Not in view | |
} | |
const fillStyle = showHoverHighlight | |
? COLORS.USER_TIMING_HOVER | |
: COLORS.USER_TIMING; | |
if (fillStyle !== null) { | |
const y = markRect.origin.y + radius; | |
context.beginPath(); | |
context.fillStyle = fillStyle; | |
context.arc(x, y, radius, 0, 2 * Math.PI); | |
context.fill(); | |
} | |
} | |
_drawSingleMark( | |
context: CanvasRenderingContext2D, | |
rect: Rect, | |
mark: UserTimingMark, | |
baseY: number, | |
scaleFactor: number, | |
showHoverHighlight: boolean, | |
) { | |
const {frame} = this; | |
const {timestamp} = mark; | |
const x = timestampToPosition(timestamp, scaleFactor, frame); | |
const size = USER_TIMING_MARK_SIZE; | |
const halfSize = size / 2; | |
const markRect: Rect = { | |
origin: { | |
x: x - halfSize, | |
y: baseY, | |
}, | |
size: {width: size, height: size}, | |
}; | |
if (!rectIntersectsRect(markRect, rect)) { | |
return; // Not in view | |
} | |
const fillStyle = showHoverHighlight | |
? COLORS.USER_TIMING_HOVER | |
: COLORS.USER_TIMING; | |
if (fillStyle !== null) { | |
const y = baseY + halfSize; | |
context.beginPath(); | |
context.fillStyle = fillStyle; | |
context.moveTo(x, y - halfSize); | |
context.lineTo(x + halfSize, y); | |
context.lineTo(x, y + halfSize); | |
context.lineTo(x - halfSize, y); | |
context.fill(); | |
} | |
} |
The above proposal also involves adding a new constant to content-views/constants.js
:
export const USER_TIMING_MARK_SIZE = 8;
I think this is okay to merge as-is. I have some small suggestions (mentioned above) that could be made in a follow up PR, either by myself or you folks. I want to land #19647 first though, on its own. Then we can land this one. |
Looks like the |
I don't have permissions to push to this repo, so I can't fix it. @kartikcho can you merge master and resolve the conflict? |
Exciting! 🥳
Thanks for the suggestions! I'll look at them in greater detail tomorrow as it's late here, but I really like the idea of having a |
Excellent work @taneliang and @kartikcho! I'm going to merge this now and we/I can follow up on some of the remaining items afterward. |
Co-authored-by: Brian Vaughn <[email protected]> Co-authored-by: Kartik Choudhary <[email protected]>
Summary
This PR imports the concurrent mode profiler project into
packages/react-devtools-scheduling-profiler
, with history 🎉Broadly follows the approach of #16381.
Resolves MLH-Fellowship/scheduling-profiler-prototype#134.
cc @bvaughn
Import Details
Notes
build
andstart
scripts were failing with this error: No "exports" main resolved in @babel/helper-compilation-targets/package.json babel/babel#11216 (comment). I've followed the maintainer's recommendation and updated all@babel/*
packages across the repository, excluding fixtures. We can easily revert this (i.e. babe03c) if there's a better solution.css-loader
config differences can be backported into the other devtools packages later.index.html
file into thedist
folder usingHtmlWebpackPlugin
. This means that thedist
folder can be served directly, and can likely be deployed to Vercel without a need for anow.json
file.Major differences with the version in the original repo (full diff):
title
has been renamed to "React Concurrent Mode Profiler".Process
Similar to #16381, the goal of this import is to preserve history from https://github.com/MLH-Fellowship/scheduling-profiler-prototype. Verified that
git log --follow
worked as expected on imported files.Prepare fork for merge
Import into React
cd ../react git remote add profiler-importable ../profiler-merge git fetch --all git checkout -b scheduling-profiler-import git merge --allow-unrelated-histories profiler-importable/importable git remote rm profiler-importable
Checklist
Test Plan
yarn build
cd dist
serve
(using https://www.npmjs.com/package/serve)yarn start
works