Repository: jlfwong/speedscope
Pull request #305 (2020-08-04 07:17:53)

Fix accumulated errors in Chrome profile imports caused by zeroed negative timeDeltas
## Context

Hi! I'm working on an experimental React [concurrent mode profiler](https://react-scheduling-profiler.vercel.app) in partnership with the React core team, and we're using a [custom build of Speedscope](https://github.com/taneliang/speedscope/compare/master...taneliang:fork-for-scheduling-profiler) that exposes Speedscope's internals to support our custom flamechart rendering. Specifically, Speedscope is used to import and process Chrome profiles, which are then fed to our rendering code that draws everything to a canvas.

Here's a screenshot of our app for context. The stuff above the thick gray bar is React data (some React Fiber lanes, React events, and other user timing marks), and a flamechart is drawn below.

![image](https://user-images.githubusercontent.com/12784593/89261576-e2e3b600-d660-11ea-9b90-6c6991d061d6.png)

## Problem

Early on, we had [an issue](https://github.com/MLH-Fellowship/scheduling-profiler-prototype/issues/42) where our flamechart was not aligned with the React data. The discrepancy between the flamechart frames and our React data grew over the time of the profile.

We tracked down the cause to https://github.com/jlfwong/speedscope/pull/80, which resolves https://github.com/jlfwong/speedscope/issues/70. It seems like zeroing out those negative time deltas resulted in the accumulation of errors over the time of these profiles, which resulted in the very visible misalignment in our profiler.

I am confident that the React data's timestamps are correct because they are obtained from User Timing marks, which have absolute timestamps and are thus independent of any `timeDelta` stuff. This would mean that Speedscope is likely displaying incorrect timestamps for Chrome profiles.

## Solution

This PR takes a different approach to solving the negative `timeDelta` problem: we add a `lastElapsed` variable as a sort of backstop, preventing `elapsed` from traveling backwards in time, while still ensuring that `elapsed` is always accurate.

We've been using this patch in our custom build for about a month now and it seems to work well.

## Unknowns

1. I'm not too sure how this will affect Speedscope's functionality as I'm not familiar enough with it to do a thorough test.
1. I also updated the test snapshots, but I'm not sure if the change in weights will break anything

---

I know we're not exactly using Speedscope in a supported way, but we hope this fix will also be useful enough to be upstreamed.

Separately, we'd also like to explore the possibility of exporting Speedscope internals so that we don't have to maintain our fork (I think this was discussed in https://github.com/jlfwong/speedscope/issues/16); I'd be happy to make a separate PR to expose the components we need.

Thanks for all the work on this project!

coveralls: 
[![Coverage Status](https://coveralls.io/builds/32522319/badge)](https://coveralls.io/builds/32522319)

Coverage increased (+0.02%) to 46.355% when pulling **9d892fc0d24132fbbdcb83da50eec6690bdd16a7 on taneliang:retain-negative-timedeltas** into **b26cdb5be4e0adbfbd29be87634bfa881c0da324 on jlfwong:master**.


Patch:
diff --git a/src/import/chrome.ts b/src/import/chrome.ts
index bde1930..01175f6 100644
--- a/src/import/chrome.ts
+++ b/src/import/chrome.ts
@@ -224,6 +224,9 @@ export function importFromChromeCPUProfile(chromeProfile: CPUProfile): Profile {
   // Ref: https://github.com/v8/v8/blob/44bd8fd7/src/inspector/js_protocol.json#L1485
   let elapsed = chromeProfile.timeDeltas[0]
 
+  // Prevents negative time deltas from causing bad data.
+  let lastElapsed = elapsed
+
   let lastNodeId = NaN
 
   // The chrome CPU profile format doesn't collapse identical samples. We'll do that
@@ -232,21 +235,30 @@ export function importFromChromeCPUProfile(chromeProfile: CPUProfile): Profile {
     const nodeId = chromeProfile.samples[i]
     if (nodeId != lastNodeId) {
       samples.push(nodeId)
-      sampleTimes.push(elapsed)
+      if (elapsed < lastElapsed) {
+        sampleTimes.push(lastElapsed)
+      } else {
+        sampleTimes.push(elapsed)
+        lastElapsed = elapsed
+      }
     }
 
     if (i === chromeProfile.samples.length - 1) {
       if (!isNaN(lastNodeId)) {
         samples.push(lastNodeId)
-        sampleTimes.push(elapsed)
+        if (elapsed < lastElapsed) {
+          sampleTimes.push(lastElapsed)
+        } else {
+          sampleTimes.push(elapsed)
+          lastElapsed = elapsed
+        }
       }
     } else {
-      let timeDelta = chromeProfile.timeDeltas[i + 1]
-      if (timeDelta < 0) {
-        // This is super noisy, but can be helpful when debugging strange data
-        // console.warn('Substituting zero for unexpected time delta:', timeDelta, 'at index', i)
-        timeDelta = 0
-      }
+      const timeDelta = chromeProfile.timeDeltas[i + 1]
+      // This is super noisy, but can be helpful when debugging strange data
+      // if (timeDelta < 0) {
+      //   console.warn('Substituting zero for unexpected time delta:', timeDelta, 'at index', i)
+      // }
 
       elapsed += timeDelta
       lastNodeId = nodeId
diff --git a/src/lib/canvas-2d-batch-renderers.ts b/src/lib/canvas-2d-batch-renderers.ts
new file mode 100644
index 0000000..a217e4f
--- /dev/null
+++ b/src/lib/canvas-2d-batch-renderers.ts
@@ -0,0 +1,69 @@
+// This file contains a collection of classes which make it easier to perform
+// batch rendering of Canvas2D primitives. The advantage of this over just doing
+// ctx.beginPath() ... ctx.rect(...) ... ctx.endPath() is that you can construct
+// several different batch renderers are the same time, then decide on their
+// paint order at the end.
+//
+// See FlamechartPanZoomView.renderOverlays for an example of how this is used.
+
+export interface TextArgs {
+  text: string
+  x: number
+  y: number
+}
+
+export class BatchCanvasTextRenderer {
+  private argsBatch: TextArgs[] = []
+
+  text(args: TextArgs) {
+    this.argsBatch.push(args)
+  }
+
+  fill(ctx: CanvasRenderingContext2D, color: string) {
+    if (this.argsBatch.length === 0) return
+    ctx.fillStyle = color
+    for (let args of this.argsBatch) {
+      ctx.fillText(args.text, args.x, args.y)
+    }
+    this.argsBatch = []
+  }
+}
+
+export interface RectArgs {
+  x: number
+  y: number
+  w: number
+  h: number
+}
+
+export class BatchCanvasRectRenderer {
+  private argsBatch: RectArgs[] = []
+
+  rect(args: RectArgs) {
+    this.argsBatch.push(args)
+  }
+
+  private drawPath(ctx: CanvasRenderingContext2D) {
+    ctx.beginPath()
+    for (let args of this.argsBatch) {
+      ctx.rect(args.x, args.y, args.w, args.h)
+    }
+    ctx.closePath()
+    this.argsBatch = []
+  }
+
+  fill(ctx: CanvasRenderingContext2D, color: string) {
+    if (this.argsBatch.length === 0) return
+    ctx.fillStyle = color
+    this.drawPath(ctx)
+    ctx.fill()
+  }
+
+  stroke(ctx: CanvasRenderingContext2D, color: string, lineWidth: number) {
+    if (this.argsBatch.length === 0) return
+    ctx.strokeStyle = color
+    ctx.lineWidth = lineWidth
+    this.drawPath(ctx)
+    ctx.stroke()
+  }
+}
diff --git a/src/lib/flamechart.ts b/src/lib/flamechart.ts
index 313a11f..eeacdfb 100644
--- a/src/lib/flamechart.ts
+++ b/src/lib/flamechart.ts
@@ -1,7 +1,7 @@
 import {Frame, CallTreeNode} from './profile'
 
 import {lastOf} from './utils'
-import {clamp} from './math'
+import {clamp, Rect, Vec2} from './math'
 
 export interface FlamechartFrame {
   node: CallTreeNode
@@ -90,6 +90,27 @@ export class Flamechart {
     return clamp(viewportWidth, minWidth, maxWidth)
   }
 
+  // Given a desired config-space viewport rectangle, clamp the rectangle so
+  // that it fits within the given flamechart. This prevents the viewport from
+  // extending past the bounds of the flamechart or zooming in too far.
+  getClampedConfigSpaceViewportRect({
+    configSpaceViewportRect,
+    renderInverted,
+  }: {
+    configSpaceViewportRect: Rect
+    renderInverted?: boolean
+  }) {
+    const configSpaceSize = new Vec2(this.getTotalWeight(), this.getLayers().length)
+    const width = this.getClampedViewportWidth(configSpaceViewportRect.size.x)
+    const size = configSpaceViewportRect.size.withX(width)
+    const origin = Vec2.clamp(
+      configSpaceViewportRect.origin,
+      new Vec2(0, renderInverted ? 0 : -1),
+      Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))),
+    )
+    return new Rect(origin, configSpaceViewportRect.size.withX(width))
+  }
+
   constructor(private source: FlamechartDataSource) {
     const stack: FlamechartFrame[] = []
     const openFrame = (node: CallTreeNode, value: number) => {
diff --git a/src/lib/profile-search.ts b/src/lib/profile-search.ts
new file mode 100644
index 0000000..5286129
--- /dev/null
+++ b/src/lib/profile-search.ts
@@ -0,0 +1,90 @@
+import {Profile, Frame, CallTreeNode} from './profile'
+import {FuzzyMatch, fuzzyMatchStrings} from './fuzzy-find'
+import {Flamechart, FlamechartFrame} from './flamechart'
+import {Rect, Vec2} from './math'
+
+export enum FlamechartType {
+  CHRONO_FLAME_CHART,
+  LEFT_HEAVY_FLAME_GRAPH,
+}
+
+// A utility class for storing cached search results to avoid recomputation when
+// the search results & profile did not change.
+export class ProfileSearchResults {
+  constructor(readonly profile: Profile, readonly searchQuery: string) {}
+
+  private matches: Map<Frame, FuzzyMatch> | null = null
+  getMatchForFrame(frame: Frame): FuzzyMatch | null {
+    if (!this.matches) {
+      this.matches = new Map()
+      this.profile.forEachFrame(frame => {
+        const match = fuzzyMatchStrings(frame.name, this.searchQuery)
+        if (match == null) return
+        this.matches!.set(frame, match)
+      })
+    }
+    return this.matches.get(frame) || null
+  }
+}
+
+export interface FlamechartSearchMatch {
+  configSpaceBounds: Rect
+  node: CallTreeNode
+}
+
+interface CachedFlamechartResult {
+  matches: FlamechartSearchMatch[]
+  indexForNode: Map<CallTreeNode, number>
+}
+
+export class FlamechartSearchResults {
+  constructor(readonly flamechart: Flamechart, readonly profileResults: ProfileSearchResults) {}
+
+  private matches: CachedFlamechartResult | null = null
+  private getResults(): CachedFlamechartResult {
+    if (this.matches == null) {
+      const matches: FlamechartSearchMatch[] = []
+      const indexForNode = new Map<CallTreeNode, number>()
+      const visit = (frame: FlamechartFrame, depth: number) => {
+        const {node} = frame
+        if (this.profileResults.getMatchForFrame(node.frame)) {
+          const configSpaceBounds = new Rect(
+            new Vec2(frame.start, depth),
+            new Vec2(frame.end - frame.start, 1),
+          )
+          indexForNode.set(node, matches.length)
+          matches.push({configSpaceBounds, node})
+        }
+
+        frame.children.forEach(child => {
+          visit(child, depth + 1)
+        })
+      }
+
+      const layers = this.flamechart.getLayers()
+      if (layers.length > 0) {
+        layers[0].forEach(frame => visit(frame, 0))
+      }
+
+      this.matches = {matches, indexForNode}
+    }
+    return this.matches
+  }
+
+  count(): number {
+    return this.getResults().matches.length
+  }
+
+  indexOf(node: CallTreeNode): number | null {
+    const result = this.getResults().indexForNode.get(node)
+    return result === undefined ? null : result
+  }
+
+  at(index: number): FlamechartSearchMatch {
+    const matches = this.getResults().matches
+    if (index < 0 || index >= matches.length) {
+      throw new Error(`Index ${index} out of bounds in list of ${matches.length} matches.`)
+    }
+    return matches[index]
+  }
+}
diff --git a/src/lib/profile.ts b/src/lib/profile.ts
index a7a88fb..a37fc3e 100644
--- a/src/lib/profile.ts
+++ b/src/lib/profile.ts
@@ -116,6 +116,13 @@ export class Profile {
 
   protected frames = new KeyedSet<Frame>()
 
+  // Profiles store two call-trees.
+  //
+  // The "append order" call tree is the one in which nodes are ordered in
+  // whatever order they were appended to their parent.
+  //
+  // The "grouped" call tree is one in which each node has at most one child per
+  // frame. Nodes are ordered in decreasing order of weight
   protected appendOrderCalltreeRoot = new CallTreeNode(Frame.root, null)
   protected groupedCalltreeRoot = new CallTreeNode(Frame.root, null)
 
@@ -169,6 +176,17 @@ export class Profile {
     return this.totalNonIdleWeight
   }
 
+  // This is private because it should only be called in the ProfileBuilder
+  // classes. Once a Profile instance has been constructed, it should be treated
+  // as immutable.
+  protected sortGroupedCallTree() {
+    function visit(node: CallTreeNode) {
+      node.children.sort((a, b) => -(a.getTotalWeight() - b.getTotalWeight()))
+      node.children.forEach(visit)
+    }
+    visit(this.groupedCalltreeRoot)
+  }
+
   forEachCallGrouped(
     openFrame: (node: CallTreeNode, value: number) => void,
     closeFrame: (node: CallTreeNode, value: number) => void,
@@ -180,10 +198,7 @@ export class Profile {
 
       let childTime = 0
 
-      const children = [...node.children]
-      children.sort((a, b) => -(a.getTotalWeight() - b.getTotalWeight()))
-
-      children.forEach(function (child) {
+      node.children.forEach(function (child) {
         visit(child, start + childTime)
         childTime += child.getTotalWeight()
       })
@@ -250,12 +265,6 @@ export class Profile {
     this.frames.forEach(fn)
   }
 
-  forEachSample(fn: (sample: CallTreeNode, weight: number) => void) {
-    for (let i = 0; i < this.samples.length; i++) {
-      fn(this.samples[i], this.weights[i])
-    }
-  }
-
   getProfileWithRecursionFlattened(): Profile {
     const builder = new CallTreeProfileBuilder()
 
@@ -511,6 +520,7 @@ export class StackListProfileBuilder extends Profile {
       this.totalWeight,
       this.weights.reduce((a, b) => a + b, 0),
     )
+    this.sortGroupedCallTree()
     return this
   }
 }
@@ -651,6 +661,7 @@ export class CallTreeProfileBuilder extends Profile {
     if (this.appendOrderStack.length > 1 || this.groupedOrderStack.length > 1) {
       throw new Error('Tried to complete profile construction with a non-empty stack')
     }
+    this.sortGroupedCallTree()
     return this
   }
 }
diff --git a/src/store/index.ts b/src/store/index.ts
index aca59f4..12bb37b 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -11,6 +11,10 @@ import {HashParams, getHashParams} from '../lib/hash-params'
 import {ProfileGroupState, profileGroup} from './profiles-state'
 import {SortMethod, SortField, SortDirection} from '../views/profile-table-view'
 import {useSelector} from '../lib/preact-redux'
+import {Profile} from '../lib/profile'
+import {FlamechartViewState} from './flamechart-view-state'
+import {SandwichViewState} from './sandwich-view-state'
+import {getProfileToView} from './getters'
 
 export const enum ViewMode {
   CHRONO_FLAME_CHART,
@@ -101,3 +105,30 @@ export function useAppSelector<T>(selector: (t: ApplicationState) => T, cacheArg
   /* eslint-disable react-hooks/exhaustive-deps */
   return useSelector(selector, cacheArgs)
 }
+
+export interface ActiveProfileState {
+  profile: Profile
+  index: number
+  chronoViewState: FlamechartViewState
+  leftHeavyViewState: FlamechartViewState
+  sandwichViewState: SandwichViewState
+}
+
+export function useActiveProfileState(): ActiveProfileState | null {
+  return useAppSelector(state => {
+    const {profileGroup} = state
+    if (!profileGroup) return null
+    if (profileGroup.indexToView >= profileGroup.profiles.length) return null
+
+    const index = profileGroup.indexToView
+    const profileState = profileGroup.profiles[index]
+    return {
+      ...profileGroup.profiles[profileGroup.indexToView],
+      profile: getProfileToView({
+        profile: profileState.profile,
+        flattenRecursion: state.flattenRecursion,
+      }),
+      index: profileGroup.indexToView,
+    }
+  }, [])
+}
diff --git a/src/views/application-container.tsx b/src/views/application-container.tsx
index 20fc5c1..0236a38 100644
--- a/src/views/application-container.tsx
+++ b/src/views/application-container.tsx
@@ -1,10 +1,11 @@
 import {h} from 'preact'
-import {Application, ActiveProfileState} from './application'
-import {getProfileToView, getCanvasContext} from '../store/getters'
+import {Application} from './application'
+import {getCanvasContext} from '../store/getters'
 import {actions} from '../store/actions'
 import {useActionCreator} from '../lib/preact-redux'
 import {memo} from 'preact/compat'
-import {useAppSelector} from '../store'
+import {useAppSelector, useActiveProfileState} from '../store'
+import {ProfileSearchContextProvider} from './search-view'
 
 const {
   setLoading,
@@ -24,36 +25,21 @@ export const ApplicationContainer = memo(() => {
     [],
   )
 
-  const activeProfileState: ActiveProfileState | null = useAppSelector(state => {
-    const {profileGroup} = state
-    if (!profileGroup) return null
-    if (profileGroup.indexToView >= profileGroup.profiles.length) return null
-
-    const index = profileGroup.indexToView
-    const profileState = profileGroup.profiles[index]
-    return {
-      ...profileGroup.profiles[profileGroup.indexToView],
-      profile: getProfileToView({
-        profile: profileState.profile,
-        flattenRecursion: state.flattenRecursion,
-      }),
-      index: profileGroup.indexToView,
-    }
-  }, [])
-
   return (
-    <Application
-      activeProfileState={activeProfileState}
-      canvasContext={canvasContext}
-      setGLCanvas={useActionCreator(setGLCanvas, [])}
-      setLoading={useActionCreator(setLoading, [])}
-      setError={useActionCreator(setError, [])}
-      setProfileGroup={useActionCreator(setProfileGroup, [])}
-      setDragActive={useActionCreator(setDragActive, [])}
-      setViewMode={useActionCreator(setViewMode, [])}
-      setFlattenRecursion={useActionCreator(setFlattenRecursion, [])}
-      setProfileIndexToView={useActionCreator(setProfileIndexToView, [])}
-      {...appState}
-    />
+    <ProfileSearchContextProvider>
+      <Application
+        activeProfileState={useActiveProfileState()}
+        canvasContext={canvasContext}
+        setGLCanvas={useActionCreator(setGLCanvas, [])}
+        setLoading={useActionCreator(setLoading, [])}
+        setError={useActionCreator(setError, [])}
+        setProfileGroup={useActionCreator(setProfileGroup, [])}
+        setDragActive={useActionCreator(setDragActive, [])}
+        setViewMode={useActionCreator(setViewMode, [])}
+        setFlattenRecursion={useActionCreator(setFlattenRecursion, [])}
+        setProfileIndexToView={useActionCreator(setProfileIndexToView, [])}
+        {...appState}
+      />
+    </ProfileSearchContextProvider>
   )
 })
diff --git a/src/views/application.tsx b/src/views/application.tsx
index 31482fb..5a7aa84 100644
--- a/src/views/application.tsx
+++ b/src/views/application.tsx
@@ -2,16 +2,14 @@ import {h} from 'preact'
 import {StyleSheet, css} from 'aphrodite'
 import {FileSystemDirectoryEntry} from '../import/file-system-entry'
 
-import {Profile, ProfileGroup} from '../lib/profile'
+import {ProfileGroup} from '../lib/profile'
 import {FontFamily, FontSize, Colors, Duration} from './style'
 import {importEmscriptenSymbolMap} from '../lib/emscripten'
 import {SandwichViewContainer} from './sandwich-view'
 import {saveToFile} from '../lib/file-format'
-import {ApplicationState, ViewMode, canUseXHR} from '../store'
+import {ApplicationState, ViewMode, canUseXHR, ActiveProfileState} from '../store'
 import {StatelessComponent} from '../lib/typed-redux'
 import {LeftHeavyFlamechartView, ChronoFlamechartView} from './flamechart-view-container'
-import {SandwichViewState} from '../store/sandwich-view-state'
-import {FlamechartViewState} from '../store/flamechart-view-state'
 import {CanvasContext} from '../gl/canvas-context'
 import {Graphics} from '../gl/graphics'
 import {Toolbar} from './toolbar'
@@ -131,14 +129,6 @@ export class GLCanvas extends StatelessComponent<GLCanvasProps> {
   }
 }
 
-export interface ActiveProfileState {
-  profile: Profile
-  index: number
-  chronoViewState: FlamechartViewState
-  leftHeavyViewState: FlamechartViewState
-  sandwichViewState: SandwichViewState
-}
-
 export type ApplicationProps = ApplicationState & {
   setGLCanvas: (canvas: HTMLCanvasElement | null) => void
   setLoading: (loading: boolean) => void
diff --git a/src/views/callee-flamegraph-view.tsx b/src/views/callee-flamegraph-view.tsx
index 7af82b8..dfc1800 100644
--- a/src/views/callee-flamegraph-view.tsx
+++ b/src/views/callee-flamegraph-view.tsx
@@ -13,7 +13,7 @@ import {
   getFrameToColorBucket,
 } from '../store/getters'
 import {FlamechartID} from '../store/flamechart-view-state'
-import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper'
+import {FlamechartWrapper} from './flamechart-wrapper'
 import {useAppSelector} from '../store'
 import {h} from 'preact'
 import {memo} from 'preact/compat'
@@ -78,14 +78,9 @@ export const CalleeFlamegraphView = memo((ownProps: FlamechartViewContainerProps
       canvasContext={canvasContext}
       getCSSColorForFrame={getCSSColorForFrame}
       {...useFlamechartSetters(FlamechartID.SANDWICH_CALLEES, index)}
+      {...callerCallee.calleeFlamegraph}
       // This overrides the setSelectedNode specified in useFlamechartSettesr
       setSelectedNode={noop}
-      {...callerCallee.calleeFlamegraph}
-      /*
-       * TODO(jlfwong): When implementing search for the sandwich views,
-       * change these flags
-       * */
-      {...useDummySearchProps()}
     />
   )
 })
diff --git a/src/views/flamechart-pan-zoom-view.tsx b/src/views/flamechart-pan-zoom-view.tsx
index 288bfb9..3e30e41 100644
--- a/src/views/flamechart-pan-zoom-view.tsx
+++ b/src/views/flamechart-pan-zoom-view.tsx
@@ -1,5 +1,5 @@
 import {Rect, AffineTransform, Vec2, clamp} from '../lib/math'
-import {CallTreeNode, Frame} from '../lib/profile'
+import {CallTreeNode} from '../lib/profile'
 import {Flamechart, FlamechartFrame} from '../lib/flamechart'
 import {CanvasContext} from '../gl/canvas-context'
 import {FlamechartRenderer} from '../gl/flamechart-renderer'
@@ -13,8 +13,8 @@ import {
 import {style} from './flamechart-style'
 import {h, Component} from 'preact'
 import {css} from 'aphrodite'
-import {memoizeByReference} from '../lib/utils'
-import {FuzzyMatch, fuzzyMatchStrings} from '../lib/fuzzy-find'
+import {ProfileSearchResults} from '../lib/profile-search'
+import {BatchCanvasTextRenderer, BatchCanvasRectRenderer} from '../lib/canvas-2d-batch-renderers'
 
 interface FlamechartFrameLabel {
   configSpaceBounds: Rect
@@ -56,8 +56,7 @@ export interface FlamechartPanZoomViewProps {
   logicalSpaceViewportSize: Vec2
   setLogicalSpaceViewportSize: (size: Vec2) => void
 
-  searchIsActive: boolean
-  searchQuery: string
+  searchResults: ProfileSearchResults | null
 }
 
 export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps, {}> {
@@ -173,24 +172,6 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
 
     ctx.clearRect(0, 0, physicalViewSize.x, physicalViewSize.y)
 
-    if (this.hoveredLabel) {
-      let color = Colors.DARK_GRAY
-      if (this.props.selectedNode === this.hoveredLabel.node) {
-        color = Colors.DARK_BLUE
-      }
-
-      ctx.lineWidth = 2 * devicePixelRatio
-      ctx.strokeStyle = color
-
-      const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
-      ctx.strokeRect(
-        Math.round(physicalViewBounds.left()),
-        Math.round(physicalViewBounds.top()),
-        Math.round(Math.max(0, physicalViewBounds.width())),
-        Math.round(Math.max(0, physicalViewBounds.height())),
-      )
-    }
-
     ctx.font = `${physicalViewSpaceFontSize}px/${physicalViewSpaceFrameHeight}px ${FontFamily.MONOSPACE}`
     ctx.textBaseline = 'alphabetic'
 
@@ -201,14 +182,12 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
 
     const LABEL_PADDING_PX = 5 * window.devicePixelRatio
 
-    const memoizedFuzzyMatch = memoizeByReference((frame: Frame): FuzzyMatch | null => {
-      return fuzzyMatchStrings(frame.name, this.props.searchQuery)
-    })
-    const frameMatchesSearchQuery = (frame: Frame): FuzzyMatch | null => {
-      if (!this.props.searchIsActive) return null
-      if (this.props.searchQuery.length === 0) return null
-      return memoizedFuzzyMatch(frame)
-    }
+    const labelBatch = new BatchCanvasTextRenderer()
+    const fadedLabelBatch = new BatchCanvasTextRenderer()
+    const matchedTextHighlightBatch = new BatchCanvasRectRenderer()
+    const directlySelectedOutlineBatch = new BatchCanvasRectRenderer()
+    const indirectlySelectedOutlineBatch = new BatchCanvasRectRenderer()
+    const matchedFrameBatch = new BatchCanvasRectRenderer()
 
     const renderFrameLabelAndChildren = (frame: FlamechartFrame, depth = 0) => {
       const width = frame.end - frame.start
@@ -244,7 +223,7 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
         }
 
         if (physicalLabelBounds.width() > minWidthToRender) {
-          const match = frameMatchesSearchQuery(frame.node.frame)
+          const match = this.props.searchResults?.getMatchForFrame(frame.node.frame)
 
           const trimmedText = trimTextMid(
             ctx,
@@ -262,44 +241,40 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
             // actually do the highlighting.
             let lastEndIndex = 0
             let left = physicalLabelBounds.left() + LABEL_PADDING_PX
-            ctx.fillStyle = Colors.YELLOW
 
-            ctx.beginPath()
             const padding = (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2 - 2
             for (let [startIndex, endIndex] of rangesToHighlightInTrimmedText) {
-              left += ctx.measureText(trimmedText.trimmedString.substring(lastEndIndex, startIndex))
-                .width
-              const highlightWidth = ctx.measureText(
+              left += cachedMeasureTextWidth(
+                ctx,
+                trimmedText.trimmedString.substring(lastEndIndex, startIndex),
+              )
+              const highlightWidth = cachedMeasureTextWidth(
+                ctx,
                 trimmedText.trimmedString.substring(startIndex, endIndex),
-              ).width
-              ctx.rect(
-                left,
-                physicalLabelBounds.top() + padding,
-                highlightWidth,
-                physicalViewSpaceFrameHeight - 2 * padding,
               )
+              matchedTextHighlightBatch.rect({
+                x: left,
+                y: physicalLabelBounds.top() + padding,
+                w: highlightWidth,
+                h: physicalViewSpaceFrameHeight - 2 * padding,
+              })
 
               left += highlightWidth
               lastEndIndex = endIndex
             }
-            ctx.fill()
           }
 
-          // Note that this is specifying the position of the starting text
-          // baseline.
-          if (this.props.searchIsActive && this.props.searchQuery.length > 0 && !match) {
-            ctx.fillStyle = Colors.LIGHT_GRAY
-          } else {
-            ctx.fillStyle = Colors.DARK_GRAY
-          }
-          ctx.fillText(
-            trimmedText.trimmedString,
-            physicalLabelBounds.left() + LABEL_PADDING_PX,
-            Math.round(
+          const batch = this.props.searchResults != null && !match ? fadedLabelBatch : labelBatch
+          batch.text({
+            text: trimmedText.trimmedString,
+
+            // This is specifying the position of the starting text baseline.
+            x: physicalLabelBounds.left() + LABEL_PADDING_PX,
+            y: Math.round(
               physicalLabelBounds.bottom() -
                 (physicalViewSpaceFrameHeight - physicalViewSpaceFontSize) / 2,
             ),
-          )
+          })
         }
       }
       for (let child of frame.children) {
@@ -307,19 +282,14 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
       }
     }
 
-    for (let frame of this.props.flamechart.getLayers()[0] || []) {
-      renderFrameLabelAndChildren(frame)
-    }
-
     const frameOutlineWidth = 2 * window.devicePixelRatio
     ctx.strokeStyle = Colors.PALE_DARK_BLUE
-    ctx.lineWidth = frameOutlineWidth
     const minConfigSpaceWidthToRenderOutline = (
       configToPhysical.inverseTransformVector(new Vec2(1, 0)) || new Vec2(0, 0)
     ).x
 
     const renderSpecialFrameOutlines = (frame: FlamechartFrame, depth = 0) => {
-      if (!this.props.selectedNode && !this.props.searchIsActive) return
+      if (!this.props.selectedNode && this.props.searchResults == null) return
       const width = frame.end - frame.start
       const y = this.props.renderInverted ? this.configSpaceSize().y - 1 - depth : depth
       const configSpaceBounds = new Rect(new Vec2(frame.start, y), new Vec2(width, 1))
@@ -330,49 +300,68 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
       if (configSpaceBounds.top() > this.props.configSpaceViewportRect.bottom()) return
 
       if (configSpaceBounds.hasIntersectionWith(this.props.configSpaceViewportRect)) {
-        let outlineColor: string | null = null
+        if (this.props.searchResults?.getMatchForFrame(frame.node.frame)) {
+          const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
+          matchedFrameBatch.rect({
+            x: Math.round(physicalRectBounds.left() + frameOutlineWidth / 2),
+            y: Math.round(physicalRectBounds.top() + frameOutlineWidth / 2),
+            w: Math.round(Math.max(0, physicalRectBounds.width() - frameOutlineWidth)),
+            h: Math.round(Math.max(0, physicalRectBounds.height() - frameOutlineWidth)),
+          })
+        }
 
         if (this.props.selectedNode != null && frame.node.frame === this.props.selectedNode.frame) {
-          if (frame.node === this.props.selectedNode) {
-            outlineColor = Colors.DARK_BLUE
-          } else if (ctx.strokeStyle !== Colors.PALE_DARK_BLUE) {
-            outlineColor = Colors.PALE_DARK_BLUE
-          }
-        } else {
-          if (frameMatchesSearchQuery(frame.node.frame)) {
-            outlineColor = Colors.YELLOW
-          }
-        }
+          let batch =
+            frame.node === this.props.selectedNode
+              ? directlySelectedOutlineBatch
+              : indirectlySelectedOutlineBatch
 
-        if (outlineColor != null) {
-          if (ctx.strokeStyle !== outlineColor) {
-            // If the outline color changed, stroke the existing path
-            // constructed by previous ctx.rect calls, then update the stroke
-            // style before drawing the next one.
-            ctx.stroke()
-            ctx.beginPath()
-            ctx.strokeStyle = outlineColor
-          }
           const physicalRectBounds = configToPhysical.transformRect(configSpaceBounds)
-          ctx.rect(
-            Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
-            Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
-            Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
-            Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)),
-          )
+          batch.rect({
+            x: Math.round(physicalRectBounds.left() + 1 + frameOutlineWidth / 2),
+            y: Math.round(physicalRectBounds.top() + 1 + frameOutlineWidth / 2),
+            w: Math.round(Math.max(0, physicalRectBounds.width() - 2 - frameOutlineWidth)),
+            h: Math.round(Math.max(0, physicalRectBounds.height() - 2 - frameOutlineWidth)),
+          })
         }
       }
-
       for (let child of frame.children) {
         renderSpecialFrameOutlines(child, depth + 1)
       }
     }
 
-    ctx.beginPath()
     for (let frame of this.props.flamechart.getLayers()[0] || []) {
       renderSpecialFrameOutlines(frame)
     }
-    ctx.stroke()
+
+    for (let frame of this.props.flamechart.getLayers()[0] || []) {
+      renderFrameLabelAndChildren(frame)
+    }
+
+    matchedFrameBatch.fill(ctx, Colors.ORANGE)
+    matchedTextHighlightBatch.fill(ctx, Colors.YELLOW)
+    fadedLabelBatch.fill(ctx, Colors.LIGHT_GRAY)
+    labelBatch.fill(ctx, Colors.BLACK)
+    indirectlySelectedOutlineBatch.stroke(ctx, Colors.PALE_DARK_BLUE, frameOutlineWidth)
+    directlySelectedOutlineBatch.stroke(ctx, Colors.DARK_BLUE, frameOutlineWidth)
+
+    if (this.hoveredLabel) {
+      let color = Colors.DARK_GRAY
+      if (this.props.selectedNode === this.hoveredLabel.node) {
+        color = Colors.DARK_BLUE
+      }
+
+      ctx.lineWidth = 2 * devicePixelRatio
+      ctx.strokeStyle = color
+
+      const physicalViewBounds = configToPhysical.transformRect(this.hoveredLabel.configSpaceBounds)
+      ctx.strokeRect(
+        Math.round(physicalViewBounds.left()),
+        Math.round(physicalViewBounds.top()),
+        Math.round(Math.max(0, physicalViewBounds.width())),
+        Math.round(Math.max(0, physicalViewBounds.height())),
+      )
+    }
 
     this.renderTimeIndicators()
   }
@@ -754,10 +743,7 @@ export class FlamechartPanZoomView extends Component<FlamechartPanZoomViewProps,
     if (this.props.flamechart !== nextProps.flamechart) {
       this.hoveredLabel = null
       this.renderCanvas()
-    } else if (
-      this.props.searchQuery !== nextProps.searchQuery ||
-      this.props.searchIsActive !== nextProps.searchIsActive
-    ) {
+    } else if (this.props.searchResults !== nextProps.searchResults) {
       this.renderCanvas()
     } else if (this.props.selectedNode !== nextProps.selectedNode) {
       this.renderCanvas()
diff --git a/src/views/flamechart-search-view.tsx b/src/views/flamechart-search-view.tsx
new file mode 100644
index 0000000..1e1d9b2
--- /dev/null
+++ b/src/views/flamechart-search-view.tsx
@@ -0,0 +1,151 @@
+import {memo} from 'preact/compat'
+import {useContext, useMemo, useCallback} from 'preact/hooks'
+import {SearchView, ProfileSearchContext} from './search-view'
+import {
+  FlamechartSearchMatch,
+  FlamechartSearchResults,
+  ProfileSearchResults,
+} from '../lib/profile-search'
+import {Rect, Vec2} from '../lib/math'
+import {h, createContext, ComponentChildren} from 'preact'
+import {Flamechart} from '../lib/flamechart'
+import {CallTreeNode} from '../lib/profile'
+
+export const FlamechartSearchContext = createContext<FlamechartSearchData | null>(null)
+
+export interface FlamechartSearchProps {
+  flamechart: Flamechart
+  selectedNode: CallTreeNode | null
+  setSelectedNode: (node: CallTreeNode | null) => void
+  configSpaceViewportRect: Rect
+  setConfigSpaceViewportRect: (rect: Rect) => void
+  children: ComponentChildren
+}
+
+interface FlamechartSearchData {
+  results: FlamechartSearchResults | null
+  flamechart: Flamechart
+  selectedNode: CallTreeNode | null
+  setSelectedNode: (node: CallTreeNode | null) => void
+  configSpaceViewportRect: Rect
+  setConfigSpaceViewportRect: (rect: Rect) => void
+}
+
+export const FlamechartSearchContextProvider = ({
+  flamechart,
+  selectedNode,
+  setSelectedNode,
+  configSpaceViewportRect,
+  setConfigSpaceViewportRect,
+  children,
+}: FlamechartSearchProps) => {
+  const profileSearchResults: ProfileSearchResults | null = useContext(ProfileSearchContext)
+  const flamechartSearchResults: FlamechartSearchResults | null = useMemo(() => {
+    if (profileSearchResults == null) {
+      return null
+    }
+    return new FlamechartSearchResults(flamechart, profileSearchResults)
+  }, [flamechart, profileSearchResults])
+
+  return (
+    <FlamechartSearchContext.Provider
+      value={{
+        results: flamechartSearchResults,
+        flamechart,
+        selectedNode,
+        setSelectedNode,
+        configSpaceViewportRect,
+        setConfigSpaceViewportRect,
+      }}
+    >
+      {children}
+    </FlamechartSearchContext.Provider>
+  )
+}
+
+export const FlamechartSearchView = memo(() => {
+  const flamechartData = useContext(FlamechartSearchContext)
+
+  // TODO(jlfwong): This pattern is pretty gross, but I really don't want values
+  // that can be undefined or null.
+  const searchResults = flamechartData == null ? null : flamechartData.results
+  const selectedNode = flamechartData == null ? null : flamechartData.selectedNode
+  const setSelectedNode = flamechartData == null ? null : flamechartData.setSelectedNode
+  const configSpaceViewportRect =
+    flamechartData == null ? null : flamechartData.configSpaceViewportRect
+  const setConfigSpaceViewportRect =
+    flamechartData == null ? null : flamechartData.setConfigSpaceViewportRect
+  const flamechart = flamechartData == null ? null : flamechartData.flamechart
+
+  const numResults = searchResults == null ? null : searchResults.count()
+  const resultIndex: number | null = useMemo(() => {
+    if (searchResults == null) return null
+    if (selectedNode == null) return null
+    return searchResults.indexOf(selectedNode)
+  }, [searchResults, selectedNode])
+
+  const selectAndZoomToMatch = useCallback(
+    (match: FlamechartSearchMatch) => {
+      if (!setSelectedNode) return
+      if (!flamechart) return
+      if (!configSpaceViewportRect) return
+      if (!setConfigSpaceViewportRect) return
+
+      // After the node is selected, we want to set the viewport so that the new
+      // node can be seen clearly.
+      //
+      // TODO(jlfwong): The lack of animation here can be kind of jarring. It
+      // would be nice to have some easier way for people to orient themselves
+      // after the viewport shifted.
+      const configSpaceResultBounds = match.configSpaceBounds
+
+      const viewportRect = new Rect(
+        configSpaceResultBounds.origin.minus(new Vec2(0, 1)),
+        configSpaceResultBounds.size.withY(configSpaceViewportRect.height()),
+      )
+
+      setSelectedNode(match.node)
+      setConfigSpaceViewportRect(
+        flamechart.getClampedConfigSpaceViewportRect({configSpaceViewportRect: viewportRect}),
+      )
+    },
+    [configSpaceViewportRect, setConfigSpaceViewportRect, setSelectedNode, flamechart],
+  )
+
+  const {selectPrev, selectNext} = useMemo(() => {
+    if (numResults == null || numResults === 0 || searchResults == null) {
+      return {selectPrev: () => {}, selectNext: () => {}}
+    }
+
+    return {
+      selectPrev: () => {
+        if (!searchResults?.at) return
+        if (numResults == null || numResults === 0) return
+
+        let index = resultIndex == null ? numResults - 1 : resultIndex - 1
+        if (index < 0) index = numResults - 1
+        const result = searchResults.at(index)
+        selectAndZoomToMatch(result)
+      },
+
+      selectNext: () => {
+        if (!searchResults?.at) return
+        if (numResults == null || numResults === 0) return
+
+        let index = resultIndex == null ? 0 : resultIndex + 1
+        if (index >= numResults) index = 0
+        const result = searchResults.at(index)
+        selectAndZoomToMatch(result)
+      },
+    }
+  }, [numResults, resultIndex, searchResults, searchResults?.at, selectAndZoomToMatch])
+
+  return (
+    <SearchView
+      resultIndex={resultIndex}
+      numResults={numResults}
+      selectPrev={selectPrev}
+      selectNext={selectNext}
+    />
+  )
+})
diff --git a/src/views/flamechart-view-container.tsx b/src/views/flamechart-view-container.tsx
index 58acaa8..00fe768 100644
--- a/src/views/flamechart-view-container.tsx
+++ b/src/views/flamechart-view-container.tsx
@@ -14,12 +14,11 @@ import {
   createGetCSSColorForFrame,
   getFrameToColorBucket,
 } from '../store/getters'
-import {ActiveProfileState} from './application'
 import {Vec2, Rect} from '../lib/math'
 import {actions} from '../store/actions'
 import {memo} from 'preact/compat'
-import {useAppSelector} from '../store'
-import {SearchViewProps} from './search-view'
+import {ActiveProfileState} from '../store'
+import {FlamechartSearchContextProvider} from './flamechart-search-view'
 
 interface FlamechartSetters {
   setLogicalSpaceViewportSize: (logicalSpaceViewportSize: Vec2) => void
@@ -66,24 +65,9 @@ export type FlamechartViewProps = {
   flamechartRenderer: FlamechartRenderer
   renderInverted: boolean
   getCSSColorForFrame: (frame: Frame) => string
-  searchIsActive: boolean
-  searchQuery: string
-  setSearchQuery: (query: string) => void
-  setSearchIsActive: (active: boolean) => void
 } & FlamechartSetters &
   FlamechartViewState
 
-const {setSearchQuery, setSearchIsActive} = actions
-
-function useSearchViewProps(): SearchViewProps {
-  return {
-    searchIsActive: useAppSelector(state => state.searchIsActive, []),
-    setSearchQuery: useActionCreator(setSearchQuery, []),
-    searchQuery: useAppSelector(state => state.searchQuery, []),
-    setSearchIsActive: useActionCreator(setSearchIsActive, []),
-  }
-}
-
 export const getChronoViewFlamechart = memoizeByShallowEquality(
   ({
     profile,
@@ -143,17 +127,26 @@ export const ChronoFlamechartView = memo((props: FlamechartViewContainerProps) =
     flamechart,
   })
 
+  const setters = useFlamechartSetters(FlamechartID.CHRONO, index)
+
   return (
-    <FlamechartView
-      renderInverted={false}
+    <FlamechartSearchContextProvider
       flamechart={flamechart}
-      flamechartRenderer={flamechartRenderer}
-      canvasContext={canvasContext}
-      getCSSColorForFrame={getCSSColorForFrame}
-      {...useFlamechartSetters(FlamechartID.CHRONO, index)}
-      {...useSearchViewProps()}
-      {...chronoViewState}
-    />
+      selectedNode={chronoViewState.selectedNode}
+      setSelectedNode={setters.setSelectedNode}
+      configSpaceViewportRect={chronoViewState.configSpaceViewportRect}
+      setConfigSpaceViewportRect={setters.setConfigSpaceViewportRect}
+    >
+      <FlamechartView
+        renderInverted={false}
+        flamechart={flamechart}
+        flamechartRenderer={flamechartRenderer}
+        canvasContext={canvasContext}
+        getCSSColorForFrame={getCSSColorForFrame}
+        {...chronoViewState}
+        {...setters}
+      />
+    </FlamechartSearchContextProvider>
   )
 })
 
@@ -195,16 +188,25 @@ export const LeftHeavyFlamechartView = memo((ownProps: FlamechartViewContainerPr
     flamechart,
   })
 
+  const setters = useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)
+
   return (
-    <FlamechartView
-      renderInverted={false}
+    <FlamechartSearchContextProvider
       flamechart={flamechart}
-      flamechartRenderer={flamechartRenderer}
-      canvasContext={canvasContext}
-      getCSSColorForFrame={getCSSColorForFrame}
-      {...useFlamechartSetters(FlamechartID.LEFT_HEAVY, index)}
-      {...useSearchViewProps()}
-      {...leftHeavyViewState}
-    />
+      selectedNode={leftHeavyViewState.selectedNode}
+      setSelectedNode={setters.setSelectedNode}
+      configSpaceViewportRect={leftHeavyViewState.configSpaceViewportRect}
+      setConfigSpaceViewportRect={setters.setConfigSpaceViewportRect}
+    >
+      <FlamechartView
+        renderInverted={false}
+        flamechart={flamechart}
+        flamechartRenderer={flamechartRenderer}
+        canvasContext={canvasContext}
+        getCSSColorForFrame={getCSSColorForFrame}
+        {...leftHeavyViewState}
+        {...setters}
+      />
+    </FlamechartSearchContextProvider>
   )
 })
diff --git a/src/views/flamechart-view.tsx b/src/views/flamechart-view.tsx
index b14a5ea..5d6e7a1 100644
--- a/src/views/flamechart-view.tsx
+++ b/src/views/flamechart-view.tsx
@@ -1,4 +1,4 @@
-import {h} from 'preact'
+import {h, Fragment} from 'preact'
 import {css} from 'aphrodite'
 
 import {CallTreeNode} from '../lib/profile'
@@ -14,7 +14,8 @@ import {FlamechartPanZoomView} from './flamechart-pan-zoom-view'
 import {Hovertip} from './hovertip'
 import {FlamechartViewProps} from './flamechart-view-container'
 import {StatelessComponent} from '../lib/typed-redux'
-import {SearchView} from './search-view'
+import {ProfileSearchContext} from './search-view'
+import {FlamechartSearchView} from './flamechart-search-view'
 
 export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
   private configSpaceSize() {
@@ -102,28 +103,28 @@ export class FlamechartView extends StatelessComponent<FlamechartViewProps> {
           canvasContext={this.props.canvasContext}
           setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
         />
-        <FlamechartPanZoomView
-          canvasContext={this.props.canvasContext}
-          flamechart={this.props.flamechart}
-          flamechartRenderer={this.props.flamechartRenderer}
-          renderInverted={false}
-          onNodeHover={this.onNodeHover}
-          onNodeSelect={this.onNodeClick}
-          selectedNode={this.props.selectedNode}
-          transformViewport={this.transformViewport}
-          configSpaceViewportRect={this.props.configSpaceViewportRect}
-          setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
-          logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
-          setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
-          searchQuery={this.props.searchQuery}
-          searchIsActive={this.props.searchIsActive}
-        />
-        <SearchView
-          searchQuery={this.props.searchQuery}
-          searchIsActive={this.props.searchIsActive}
-          setSearchQuery={this.props.setSearchQuery}
-          setSearchIsActive={this.props.setSearchIsActive}
-        />
+        <ProfileSearchContext.Consumer>
+          {searchResults => (
+            <Fragment>
+              <FlamechartPanZoomView
+                canvasContext={this.props.canvasContext}
+                flamechart={this.props.flamechart}
+                flamechartRenderer={this.props.flamechartRenderer}
+                renderInverted={false}
+                onNodeHover={this.onNodeHover}
+                onNodeSelect={this.onNodeClick}
+                selectedNode={this.props.selectedNode}
+                transformViewport={this.transformViewport}
+                configSpaceViewportRect={this.props.configSpaceViewportRect}
+                setConfigSpaceViewportRect={this.setConfigSpaceViewportRect}
+                logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
+                setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
+                searchResults={searchResults}
+              />
+              <FlamechartSearchView />
+            </Fragment>
+          )}
+        </ProfileSearchContext.Consumer>
         {this.renderTooltip()}
         {this.props.selectedNode && (
           <FlamechartDetailView
diff --git a/src/views/flamechart-wrapper.tsx b/src/views/flamechart-wrapper.tsx
index d0f602c..74d96ac 100644
--- a/src/views/flamechart-wrapper.tsx
+++ b/src/views/flamechart-wrapper.tsx
@@ -8,30 +8,14 @@ import {noop, formatPercent} from '../lib/utils'
 import {Hovertip} from './hovertip'
 import {FlamechartViewProps} from './flamechart-view-container'
 import {StatelessComponent} from '../lib/typed-redux'
-import {useCallback} from 'preact/hooks'
-import {SearchViewProps} from './search-view'
-
-export function useDummySearchProps(): SearchViewProps {
-  return {
-    searchIsActive: false,
-    searchQuery: '',
-    setSearchQuery: useCallback((q: string) => {}, []),
-    setSearchIsActive: useCallback((v: boolean) => {}, []),
-  }
-}
 
 export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
   private clampViewportToFlamegraph(viewportRect: Rect) {
     const {flamechart, renderInverted} = this.props
-    const configSpaceSize = new Vec2(flamechart.getTotalWeight(), flamechart.getLayers().length)
-    const width = this.props.flamechart.getClampedViewportWidth(viewportRect.size.x)
-    const size = viewportRect.size.withX(width)
-    const origin = Vec2.clamp(
-      viewportRect.origin,
-      new Vec2(0, renderInverted ? 0 : -1),
-      Vec2.max(Vec2.zero, configSpaceSize.minus(size).plus(new Vec2(0, 1))),
-    )
-    return new Rect(origin, viewportRect.size.withX(width))
+    return flamechart.getClampedConfigSpaceViewportRect({
+      configSpaceViewportRect: viewportRect,
+      renderInverted,
+    })
   }
   private setConfigSpaceViewportRect = (configSpaceViewportRect: Rect) => {
     this.props.setConfigSpaceViewportRect(this.clampViewportToFlamegraph(configSpaceViewportRect))
@@ -95,8 +79,7 @@ export class FlamechartWrapper extends StatelessComponent<FlamechartViewProps> {
           renderInverted={this.props.renderInverted}
           logicalSpaceViewportSize={this.props.logicalSpaceViewportSize}
           setLogicalSpaceViewportSize={this.setLogicalSpaceViewportSize}
-          searchIsActive={this.props.searchIsActive}
-          searchQuery={this.props.searchQuery}
+          searchResults={null}
         />
         {this.renderTooltip()}
       </div>
diff --git a/src/views/inverted-caller-flamegraph-view.tsx b/src/views/inverted-caller-flamegraph-view.tsx
index d372bf3..9985ca8 100644
--- a/src/views/inverted-caller-flamegraph-view.tsx
+++ b/src/views/inverted-caller-flamegraph-view.tsx
@@ -14,7 +14,7 @@ import {
 } from '../store/getters'
 import {FlamechartID} from '../store/flamechart-view-state'
 import {useAppSelector} from '../store'
-import {FlamechartWrapper, useDummySearchProps} from './flamechart-wrapper'
+import {FlamechartWrapper} from './flamechart-wrapper'
 import {h} from 'preact'
 import {memo} from 'preact/compat'
 
@@ -87,14 +87,9 @@ export const InvertedCallerFlamegraphView = memo((ownProps: FlamechartViewContai
       canvasContext={canvasContext}
       getCSSColorForFrame={getCSSColorForFrame}
       {...useFlamechartSetters(FlamechartID.SANDWICH_INVERTED_CALLERS, index)}
+      {...callerCallee.invertedCallerFlamegraph}
       // This overrides the setSelectedNode specified in useFlamechartSettesr
       setSelectedNode={noop}
-      {...callerCallee.invertedCallerFlamegraph}
-      /*
-       * TODO(jlfwong): When implementing search for the sandwich views,
-       * change these flags
-       * */
-      {...useDummySearchProps()}
     />
   )
 })
diff --git a/src/views/profile-table-view.tsx b/src/views/profile-table-view.tsx
index 3ed78fb..7a57741 100644
--- a/src/views/profile-table-view.tsx
+++ b/src/views/profile-table-view.tsx
@@ -1,18 +1,17 @@
 import {h, Component, JSX, ComponentChild} from 'preact'
 import {StyleSheet, css} from 'aphrodite'
 import {Profile, Frame} from '../lib/profile'
-import {sortBy, formatPercent} from '../lib/utils'
+import {formatPercent} from '../lib/utils'
 import {FontSize, Colors, Sizes, commonStyle} from './style'
 import {ColorChit} from './color-chit'
 import {ListItem, ScrollableListView} from './scrollable-list-view'
 import {actions} from '../store/actions'
 import {createGetCSSColorForFrame, getFrameToColorBucket} from '../store/getters'
-import {ActiveProfileState} from './application'
 import {useActionCreator} from '../lib/preact-redux'
-import {useAppSelector} from '../store'
+import {useAppSelector, ActiveProfileState} from '../store'
 import {memo} from 'preact/compat'
-import {useCallback, useMemo} from 'preact/hooks'
-import {fuzzyMatchStrings} from '../lib/fuzzy-find'
+import {useCallback, useMemo, useContext} from 'preact/hooks'
+import {SandwichViewContext} from './sandwich-view'
 
 export enum SortField {
   SYMBOL_NAME,
@@ -68,13 +67,9 @@ class SortIcon extends Component<SortIconProps, {}> {
   }
 }
 
-interface ProfileTableRowInfo {
+interface ProfileTableRowViewProps {
   frame: Frame
   matchedRanges: [number, number][] | null
-}
-
-interface ProfileTableRowViewProps {
-  info: ProfileTableRowInfo
   index: number
   profile: Profile
   selectedFrame: Frame | null
@@ -100,15 +95,14 @@ function highlightRanges(
 }
 
 const ProfileTableRowView = ({
-  info,
+  frame,
+  matchedRanges,
   profile,
   index,
   selectedFrame,
   setSelectedFrame,
   getCSSColorForFrame,
 }: ProfileTableRowViewProps) => {
-  const {frame, matchedRanges} = info
-
   const totalWeight = frame.getTotalWeight()
   const selfWeight = frame.getSelfWeight()
   const totalPerc = (100.0 * totalWeight) / profile.getTotalNonIdleWeight()
@@ -206,48 +200,21 @@ export const ProfileTableView = memo(
       [sortMethod, setSortMethod],
     )
 
-    const rowList = useMemo((): {frame: Frame; matchedRanges: [number, number][] | null}[] => {
-      const rowList: ProfileTableRowInfo[] = []
-
-      profile.forEachFrame(frame => {
-        let matchedRanges: [number, number][] | null = null
-        if (searchIsActive) {
-          const match = fuzzyMatchStrings(frame.name, searchQuery)
-          if (match == null) return
-          matchedRanges = match.matchedRanges
-        }
-        rowList.push({frame, matchedRanges})
-      })
-
-      switch (sortMethod.field) {
-        case SortField.SYMBOL_NAME: {
-          sortBy(rowList, f => f.frame.name.toLowerCase())
-          break
-        }
-        case SortField.SELF: {
-          sortBy(rowList, f => f.frame.getSelfWeight())
-          break
-        }
-        case SortField.TOTAL: {
-          sortBy(rowList, f => f.frame.getTotalWeight())
-          break
-        }
-      }
-      if (sortMethod.direction === SortDirection.DESCENDING) {
-        rowList.reverse()
-      }
-
-      return rowList
-    }, [profile, sortMethod, searchQuery, searchIsActive])
+    const sandwichContext = useContext(SandwichViewContext)
 
     const renderItems = useCallback(
       (firstIndex: number, lastIndex: number) => {
+        if (!sandwichContext) return null
+
         const rows: JSX.Element[] = []
 
         for (let i = firstIndex; i <= lastIndex; i++) {
+          const frame = sandwichContext.rowList[i]
+          const match = sandwichContext.getSearchMatchForFrame(frame)
           rows.push(
             ProfileTableRowView({
-              info: rowList[i],
+              frame,
+              matchedRanges: match == null ? null : match.matchedRanges,
               index: i,
               profile: profile,
               selectedFrame: selectedFrame,
@@ -278,7 +245,7 @@ export const ProfileTableView = memo(
         return <table className={css(style.tableView)}>{rows}</table>
       },
       [
-        rowList,
+        sandwichContext,
         profile,
         selectedFrame,
         setSelectedFrame,
@@ -288,9 +255,13 @@ export const ProfileTableView = memo(
       ],
     )
 
-    const listItems: ListItem[] = useMemo(() => rowList.map(f => ({size: Sizes.FRAME_HEIGHT})), [
-      rowList,
-    ])
+    const listItems: ListItem[] = useMemo(
+      () =>
+        sandwichContext == null
+          ? []
+          : sandwichContext.rowList.map(f => ({size: Sizes.FRAME_HEIGHT})),
+      [sandwichContext],
+    )
 
     const onTotalClick = useCallback((ev: MouseEvent) => onSortClick(SortField.TOTAL, ev), [
       onSortClick,
@@ -341,7 +312,7 @@ export const ProfileTableView = memo(
           className={css(style.scrollView)}
           renderItems={renderItems}
           initialIndexInView={
-            selectedFrame == null ? null : rowList.findIndex(f => f.frame === selectedFrame)
+            selectedFrame == null ? null : sandwichContext?.getIndexForFrame(selectedFrame)
           }
         />
       </div>
diff --git a/src/views/sandwich-search-view.tsx b/src/views/sandwich-search-view.tsx
new file mode 100644
index 0000000..b36a771
--- /dev/null
+++ b/src/views/sandwich-search-view.tsx
@@ -0,0 +1,44 @@
+import {memo} from 'preact/compat'
+import {useContext, useMemo} from 'preact/hooks'
+import {SearchView} from './search-view'
+import {h} from 'preact'
+import {SandwichViewContext} from './sandwich-view'
+
+export const SandwichSearchView = memo(() => {
+  const sandwichViewContext = useContext(SandwichViewContext)
+
+  const rowList = sandwichViewContext != null ? sandwichViewContext.rowList : null
+  const resultIndex =
+    sandwichViewContext?.selectedFrame != null
+      ? sandwichViewContext.getIndexForFrame(sandwichViewContext.selectedFrame)
+      : null
+  const numResults = rowList != null ? rowList.length : null
+
+  const {selectPrev, selectNext} = useMemo(() => {
+    if (rowList == null || numResults == null || numResults === 0 || sandwichViewContext == null) {
+      return {selectPrev: () => {}, selectNext: () => {}}
+    }
+
+    return {
+      selectPrev: () => {
+        let index = resultIndex == null ? numResults - 1 : resultIndex - 1
+        if (index < 0) index = numResults - 1
+        sandwichViewContext.setSelectedFrame(rowList[index])
+      },
+      selectNext: () => {
+        let index = resultIndex == null ? 0 : resultIndex + 1
+        if (index >= numResults) index = 0
+        sandwichViewContext.setSelectedFrame(rowList[index])
+      },
+    }
+  }, [resultIndex, rowList, numResults, sandwichViewContext])
+
+  return (
+    <SearchView
+      resultIndex={resultIndex}
+      numResults={numResults}
+      selectPrev={selectPrev}
+      selectNext={selectNext}
+    />
+  )
+})
diff --git a/src/views/sandwich-view.tsx b/src/views/sandwich-view.tsx
index c0ca0c7..45a5ea3 100644
--- a/src/views/sandwich-view.tsx
+++ b/src/views/sandwich-view.tsx
@@ -1,18 +1,20 @@
 import {Frame} from '../lib/profile'
 import {StyleSheet, css} from 'aphrodite'
-import {ProfileTableViewContainer} from './profile-table-view'
-import {h, JSX} from 'preact'
+import {ProfileTableViewContainer, SortField, SortDirection} from './profile-table-view'
+import {h, JSX, createContext} from 'preact'
 import {memo} from 'preact/compat'
-import {useCallback} from 'preact/hooks'
+import {useCallback, useMemo, useContext} from 'preact/hooks'
 import {commonStyle, Sizes, Colors, FontSize} from './style'
 import {actions} from '../store/actions'
 import {StatelessComponent} from '../lib/typed-redux'
 import {InvertedCallerFlamegraphView} from './inverted-caller-flamegraph-view'
 import {CalleeFlamegraphView} from './callee-flamegraph-view'
-import {ActiveProfileState} from './application'
-import {useDispatch, useActionCreator} from '../lib/preact-redux'
-import {SearchView} from './search-view'
-import {useAppSelector} from '../store'
+import {useDispatch} from '../lib/preact-redux'
+import {SandwichSearchView} from './sandwich-search-view'
+import {useAppSelector, ActiveProfileState} from '../store'
+import {sortBy} from '../lib/utils'
+import {ProfileSearchContext} from './search-view'
+import {FuzzyMatch} from '../lib/fuzzy-find'
 
 interface SandwichViewProps {
   selectedFrame: Frame | null
@@ -20,10 +22,6 @@ interface SandwichViewProps {
   activeProfileState: ActiveProfileState
   setSelectedFrame: (selectedFrame: Frame | null) => void
   glCanvas: HTMLCanvasElement
-  searchQuery: string
-  searchIsActive: boolean
-  setSearchQuery: (query: string | null) => void
-  setSearchIsActive: (active: boolean) => void
 }
 
 class SandwichView extends StatelessComponent<SandwichViewProps> {
@@ -45,13 +43,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
   }
 
   render() {
-    const {
-      selectedFrame,
-      searchIsActive,
-      setSearchIsActive,
-      searchQuery,
-      setSearchQuery,
-    } = this.props
+    const {selectedFrame} = this.props
     let flamegraphViews: JSX.Element | null = null
 
     if (selectedFrame) {
@@ -84,12 +76,7 @@ class SandwichView extends StatelessComponent<SandwichViewProps> {
       <div className={css(commonStyle.hbox, commonStyle.fillY)}>
         <div className={css(style.tableView)}>
           <ProfileTableViewContainer activeProfileState={this.props.activeProfileState} />
-          <SearchView
-            searchQuery={searchQuery}
-            setSearchQuery={setSearchQuery}
-            searchIsActive={searchIsActive}
-            setSearchIsActive={setSearchIsActive}
-          />
+          <SandwichSearchView />
         </div>
         {flamegraphViews}
       </div>
@@ -143,7 +130,15 @@ interface SandwichViewContainerProps {
   glCanvas: HTMLCanvasElement
 }
 
-const {setSearchQuery, setSearchIsActive} = actions
+interface SandwichViewContextData {
+  rowList: Frame[]
+  selectedFrame: Frame | null
+  setSelectedFrame: (frame: Frame | null) => void
+  getIndexForFrame: (frame: Frame) => number | null
+  getSearchMatchForFrame: (frame: Frame) => FuzzyMatch | null
+}
+
+export const SandwichViewContext = createContext<SandwichViewContextData | null>(null)
 
 export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps) => {
   const {activeProfileState, glCanvas} = ownProps
@@ -163,17 +158,78 @@ export const SandwichViewContainer = memo((ownProps: SandwichViewContainerProps)
     [dispatch, index],
   )
 
+  const profile = activeProfileState.profile
+  const tableSortMethod = useAppSelector(state => state.tableSortMethod, [])
+  const profileSearchResults = useContext(ProfileSearchContext)
+
+  const selectedFrame = callerCallee ? callerCallee.selectedFrame : null
+
+  const rowList: Frame[] = useMemo(() => {
+    const rowList: Frame[] = []
+
+    profile.forEachFrame(frame => {
+      if (profileSearchResults && !profileSearchResults.getMatchForFrame(frame)) {
+        return
+      }
+      rowList.push(frame)
+    })
+
+    switch (tableSortMethod.field) {
+      case SortField.SYMBOL_NAME: {
+        sortBy(rowList, f => f.name.toLowerCase())
+        break
+      }
+      case SortField.SELF: {
+        sortBy(rowList, f => f.getSelfWeight())
+        break
+      }
+      case SortField.TOTAL: {
+        sortBy(rowList, f => f.getTotalWeight())
+        break
+      }
+    }
+    if (tableSortMethod.direction === SortDirection.DESCENDING) {
+      rowList.reverse()
+    }
+
+    return rowList
+  }, [profile, profileSearchResults, tableSortMethod])
+
+  const getIndexForFrame: (frame: Frame) => number | null = useMemo(() => {
+    const indexByFrame = new Map<Frame, number>()
+    for (let i = 0; i < rowList.length; i++) {
+      indexByFrame.set(rowList[i], i)
+    }
+    return (frame: Frame) => {
+      const index = indexByFrame.get(frame)
+      return index == null ? null : index
+    }
+  }, [rowList])
+
+  const getSearchMatchForFrame: (frame: Frame) => FuzzyMatch | null = useMemo(() => {
+    return (frame: Frame) => {
+      if (profileSearchResults == null) return null
+      return profileSearchResults.getMatchForFrame(frame)
+    }
+  }, [profileSearchResults])
+
+  const contextData: SandwichViewContextData = {
+    rowList,
+    selectedFrame,
+    setSelectedFrame,
+    getIndexForFrame,
+    getSearchMatchForFrame,
+  }
+
   return (
-    <SandwichView
-      activeProfileState={activeProfileState}
-      glCanvas={glCanvas}
-      setSelectedFrame={setSelectedFrame}
-      selectedFrame={callerCallee ? callerCallee.selectedFrame : null}
-      profileIndex={index}
-      searchQuery={useAppSelector(state => state.searchQuery, [])}
-      setSearchQuery={useActionCreator(setSearchQuery, [])}
-      searchIsActive={useAppSelector(state => state.searchIsActive, [])}
-      setSearchIsActive={useActionCreator(setSearchIsActive, [])}
-    />
+    <SandwichViewContext.Provider value={contextData}>
+      <SandwichView
+        activeProfileState={activeProfileState}
+        glCanvas={glCanvas}
+        setSelectedFrame={setSelectedFrame}
+        selectedFrame={selectedFrame}
+        profileIndex={index}
+      />
+    </SandwichViewContext.Provider>
   )
 })
diff --git a/src/views/scrollable-list-view.tsx b/src/views/scrollable-list-view.tsx
index 5cecec2..5832a78 100644
--- a/src/views/scrollable-list-view.tsx
+++ b/src/views/scrollable-list-view.tsx
@@ -17,7 +17,10 @@ interface RangeResult {
 interface ScrollableListViewProps {
   items: ListItem[]
   axis: 'x' | 'y'
-  renderItems: (firstVisibleIndex: number, lastVisibleIndex: number) => JSX.Element | JSX.Element[]
+  renderItems: (
+    firstVisibleIndex: number,
+    lastVisibleIndex: number,
+  ) => JSX.Element | JSX.Element[] | null
   className?: string
   initialIndexInView?: number | null
 }
diff --git a/src/views/search-view.tsx b/src/views/search-view.tsx
index e66776d..667e9ba 100644
--- a/src/views/search-view.tsx
+++ b/src/views/search-view.tsx
@@ -1,23 +1,54 @@
 import {StyleSheet, css} from 'aphrodite'
-import {h} from 'preact'
-import {useCallback, useRef, useEffect} from 'preact/hooks'
+import {h, createContext, ComponentChildren, Fragment} from 'preact'
+import {useCallback, useRef, useEffect, useMemo} from 'preact/hooks'
 import {memo} from 'preact/compat'
 import {Sizes, Colors, FontSize} from './style'
+import {ProfileSearchResults} from '../lib/profile-search'
+import {Profile} from '../lib/profile'
+import {useActiveProfileState, useAppSelector} from '../store'
+import {useActionCreator} from '../lib/preact-redux'
+import {actions} from '../store/actions'
 
 function stopPropagation(ev: Event) {
   ev.stopPropagation()
 }
 
-export interface SearchViewProps {
-  searchQuery: string
-  searchIsActive: boolean
+export const ProfileSearchContext = createContext<ProfileSearchResults | null>(null)
 
-  setSearchQuery: (query: string) => void
-  setSearchIsActive: (active: boolean) => void
+export const ProfileSearchContextProvider = ({children}: {children: ComponentChildren}) => {
+  const activeProfileState = useActiveProfileState()
+  const profile: Profile | null = activeProfileState ? activeProfileState.profile : null
+  const searchIsActive = useAppSelector(state => state.searchIsActive, [])
+  const searchQuery = useAppSelector(state => state.searchQuery, [])
+
+  const searchResults = useMemo(() => {
+    if (!profile || !searchIsActive || searchQuery.length === 0) {
+      return null
+    }
+    return new ProfileSearchResults(profile, searchQuery)
+  }, [searchIsActive, searchQuery, profile])
+
+  return (
+    <ProfileSearchContext.Provider value={searchResults}>{children}</ProfileSearchContext.Provider>
+  )
+}
+
+const {setSearchQuery: setSearchQueryAction, setSearchIsActive: setSearchIsActiveAction} = actions
+
+interface SearchViewProps {
+  resultIndex: number | null
+  numResults: number | null
+  selectNext: () => void
+  selectPrev: () => void
 }
 
 export const SearchView = memo(
-  ({searchQuery, setSearchQuery, searchIsActive, setSearchIsActive}: SearchViewProps) => {
+  ({numResults, resultIndex, selectNext, selectPrev}: SearchViewProps) => {
+    const searchQuery = useAppSelector(state => state.searchQuery, [])
+    const searchIsActive = useAppSelector(state => state.searchIsActive, [])
+    const setSearchQuery = useActionCreator(setSearchQueryAction, [])
+    const setSearchIsActive = useActionCreator(setSearchIsActiveAction, [])
+
     const onInput = useCallback(
       (ev: Event) => {
         const value = (ev.target as HTMLInputElement).value
@@ -28,6 +59,19 @@ export const SearchView = memo(
 
     const inputRef = useRef<HTMLInputElement | null>(null)
 
+    const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive])
+
+    const selectPrevOrNextResult = useCallback(
+      (ev: KeyboardEvent) => {
+        if (ev.shiftKey) {
+          selectPrev()
+        } else {
+          selectNext()
+        }
+      },
+      [selectPrev, selectNext],
+    )
+
     const onKeyDown = useCallback(
       (ev: KeyboardEvent) => {
         ev.stopPropagation()
@@ -37,6 +81,10 @@ export const SearchView = memo(
           setSearchIsActive(false)
         }
 
+        if (ev.key === 'Enter') {
+          selectPrevOrNextResult(ev)
+        }
+
         if (ev.key == 'f' && (ev.metaKey || ev.ctrlKey)) {
           if (inputRef.current) {
             // If the input is already focused, select all
@@ -49,7 +97,7 @@ export const SearchView = memo(
           ev.preventDefault()
         }
       },
-      [setSearchIsActive],
+      [setSearchIsActive, selectPrevOrNextResult],
     )
 
     useEffect(() => {
@@ -81,24 +129,37 @@ export const SearchView = memo(
       }
     }, [setSearchIsActive])
 
-    const close = useCallback(() => setSearchIsActive(false), [setSearchIsActive])
-
     if (!searchIsActive) return null
 
     return (
       <div className={css(style.searchView)}>
         <span className={css(style.icon)}>🔍</span>
-        <input
-          className={css(style.input)}
-          value={searchQuery}
-          onInput={onInput}
-          onKeyDown={onKeyDown}
-          onKeyUp={stopPropagation}
-          onKeyPress={stopPropagation}
-          ref={inputRef}
-        />
-
+        <span className={css(style.inputContainer)}>
+          <input
+            className={css(style.input)}
+            value={searchQuery}
+            onInput={onInput}
+            onKeyDown={onKeyDown}
+            onKeyUp={stopPropagation}
+            onKeyPress={stopPropagation}
+            ref={inputRef}
+          />
+        </span>
+        {numResults != null && (
+          <Fragment>
+            <span className={css(style.resultCount)}>
+              {resultIndex == null ? '?' : resultIndex + 1}/{numResults}
+            </span>
+            <button className={css(style.icon, style.button)} onClick={selectPrev}>
+              ⬅️
+            </button>
+            <button className={css(style.icon, style.button)} onClick={selectNext}>
+              ➡️
+            </button>
+          </Fragment>
+        )}
         <svg
+          className={css(style.icon)}
           onClick={close}
           width="16"
           height="16"
@@ -122,7 +183,7 @@ const style = StyleSheet.create({
     top: 0,
     right: 10,
     height: Sizes.TOOLBAR_HEIGHT,
-    width: 150,
+    width: 16 * 13,
     borderWidth: 2,
     borderColor: Colors.BLACK,
     borderStyle: 'solid',
@@ -131,12 +192,19 @@ const style = StyleSheet.create({
     background: Colors.DARK_GRAY,
     color: Colors.WHITE,
     display: 'flex',
+    alignItems: 'center',
+  },
+  inputContainer: {
+    flexShrink: 1,
+    flexGrow: 1,
+    display: 'flex',
   },
   input: {
+    width: '100%',
     border: 'none',
     background: 'none',
     fontSize: FontSize.LABEL,
-    flex: 1,
+    lineHeight: `${Sizes.TOOLBAR_HEIGHT}px`,
     color: Colors.WHITE,
     ':focus': {
       border: 'none',
@@ -147,10 +215,23 @@ const style = StyleSheet.create({
       background: Colors.DARK_BLUE,
     },
   },
+  resultCount: {
+    verticalAlign: 'middle',
+  },
   icon: {
-    display: 'inline-block',
+    flexShrink: 0,
     verticalAlign: 'middle',
-    paddingTop: '0px',
-    margin: '0 2px 0 4px',
+    height: '100%',
+    margin: '0px 2px 0px 2px',
+    fontSize: FontSize.LABEL,
+  },
+  button: {
+    display: 'inline',
+    background: 'none',
+    border: 'none',
+    padding: 0,
+    ':focus': {
+      outline: 'none',
+    },
   },
 })
diff --git a/src/views/style.ts b/src/views/style.ts
index 91ca7d6..b680683 100644
--- a/src/views/style.ts
+++ b/src/views/style.ts
@@ -23,6 +23,7 @@ export enum Colors {
   GREEN = '#6FCF97',
   TRANSPARENT_GREEN = 'rgba(111, 207, 151, 0.2)',
   YELLOW = '#FEDC62',
+  ORANGE = '#FFAC02',
 }
 
 export enum Sizes {


Test patch:
diff --git a/src/import/__snapshots__/chrome.test.ts.snap b/src/import/__snapshots__/chrome.test.ts.snap
index c9601db..1dd0be8 100644
--- a/src/import/__snapshots__/chrome.test.ts.snap
+++ b/src/import/__snapshots__/chrome.test.ts.snap
@@ -633,7 +633,7 @@ Object {
       "line": 0,
       "name": "(anonymous)",
       "selfWeight": 0,
-      "totalWeight": 2591,
+      "totalWeight": 2524,
     },
     Frame {
       "col": -1,
@@ -641,8 +641,8 @@ Object {
       "key": "Worker::-1:-1",
       "line": -1,
       "name": "Worker",
-      "selfWeight": 873,
-      "totalWeight": 2591,
+      "selfWeight": 806,
+      "totalWeight": 2524,
     },
     Frame {
       "col": 29,
@@ -731,7 +731,7 @@ Object {
     "(program) 11.20ms",
     " 296.00µs",
     "(program) 1.90ms",
-    "(anonymous);Worker 873.00µs",
+    "(anonymous);Worker 806.00µs",
     "(anonymous);Worker;(program) 1.72ms",
     " 670.00µs",
     "(program) 28.45ms",
@@ -1305,7 +1305,7 @@ Object {
       "line": 0,
       "name": "(anonymous)",
       "selfWeight": 542,
-      "totalWeight": 6218,
+      "totalWeight": 6115,
     },
     Frame {
       "col": 25,
@@ -1314,7 +1314,7 @@ Object {
       "line": 30,
       "name": "insertTextScript",
       "selfWeight": 392,
-      "totalWeight": 977,
+      "totalWeight": 874,
     },
     Frame {
       "col": 25,
@@ -1322,8 +1322,8 @@ Object {
       "key": "insertHeaderNode:chrome-extension://denbgaamihkadbghdceggmchnflmhpmk/contentScript.js:57:25",
       "line": 57,
       "name": "insertHeaderNode",
-      "selfWeight": 292,
-      "totalWeight": 585,
+      "selfWeight": 189,
+      "totalWeight": 482,
     },
     Frame {
       "col": undefined,
@@ -1558,7 +1558,7 @@ Object {
     "(anonymous);(anonymous);(anonymous);(program) 27.49ms",
     "(anonymous) 542.00µs",
     "(anonymous);insertTextScript 392.00µs",
-    "(anonymous);insertTextScript;insertHeaderNode 292.00µs",
+    "(anonymous);insertTextScript;insertHeaderNode 189.00µs",
     "(anonymous);insertTextScript;insertHeaderNode;appendChild;(anonymous);(anonymous) 148.00µs",
     "(anonymous);insertTextScript;insertHeaderNode;appendChild 145.00µs",
     "(anonymous);listenForMessage;get webstore 148.00µs",