Merge "Correctly handle partial writes."
diff --git a/include/perfetto/base/scoped_file.h b/include/perfetto/base/scoped_file.h
index 7e380f0..59ca75f 100644
--- a/include/perfetto/base/scoped_file.h
+++ b/include/perfetto/base/scoped_file.h
@@ -24,6 +24,7 @@
 
 #if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
 #include <corecrt_io.h>
+typedef int mode_t;
 #else
 #include <dirent.h>
 #include <unistd.h>
@@ -36,6 +37,8 @@
 namespace perfetto {
 namespace base {
 
+constexpr mode_t kInvalidMode = static_cast<mode_t>(-1);
+
 // RAII classes for auto-releasing fds and dirs.
 template <typename T,
           int (*CloseFunction)(T),
@@ -81,8 +84,8 @@
 using ScopedFile = ScopedResource<int, close, -1>;
 inline static ScopedFile OpenFile(const std::string& path,
                                   int flags,
-                                  mode_t mode = 0) {
-  PERFETTO_DCHECK((flags & O_CREAT) == 0 || mode != 0);
+                                  mode_t mode = kInvalidMode) {
+  PERFETTO_DCHECK((flags & O_CREAT) == 0 || mode != kInvalidMode);
 #if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
   ScopedFile fd(open(path.c_str(), flags, mode));
 #else
diff --git a/ui/package-lock.json b/ui/package-lock.json
index d5a0e39..67ee3c9 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -3256,6 +3256,11 @@
       "integrity": "sha512-oTZqweIP51xaGPI4uPa56/Pri/480R+mo7SeU+YETByQNhDG55ycFyNLIgta9vXhILrxXDmF7ZGhqZIcuN0gJQ==",
       "dev": true
     },
+    "immer": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-1.7.2.tgz",
+      "integrity": "sha512-4Urocwu9+XLDJw4Tc6ZCg7APVjjLInCFvO4TwGsAYV5zT6YYSor14dsZR0+0tHlDIN92cFUOq+i7fC00G5vTxA=="
+    },
     "immutable": {
       "version": "3.8.2",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz",
@@ -5974,20 +5979,12 @@
       "requires": {
         "@types/estree": "0.0.39",
         "@types/node": "*"
-      },
-      "dependencies": {
-        "@types/node": {
-          "version": "8.10.15",
-          "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.15.tgz",
-          "integrity": "sha512-qNb+m5Cuj6YUMK7YFcvuSgcHCKfVg1uXAUOP91SWvAakZlZTzbGmJaBi99CgDWEAyfZo51NlUhXkuP5WtXsgjg==",
-          "dev": true
-        }
       }
     },
     "rollup-plugin-commonjs": {
-      "version": "9.1.3",
-      "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-9.1.3.tgz",
-      "integrity": "sha512-g91ZZKZwTW7F7vL6jMee38I8coj/Q9GBdTmXXeFL7ldgC1Ky5WJvHgbKlAiXXTh762qvohhExwUgeQGFh9suGg==",
+      "version": "9.1.8",
+      "resolved": "https://registry.npmjs.org/rollup-plugin-commonjs/-/rollup-plugin-commonjs-9.1.8.tgz",
+      "integrity": "sha512-c3nAfVVyEwbq9OohIeQudfQQdGV9Cl1RE8MUc90fH9UdtCiWAYpI+au3HxGwNf1DdV51HfBjCDbT4fwjsZEUUg==",
       "dev": true,
       "requires": {
         "estree-walker": "^0.5.1",
@@ -5997,9 +5994,9 @@
       }
     },
     "rollup-plugin-node-resolve": {
-      "version": "3.3.0",
-      "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.3.0.tgz",
-      "integrity": "sha512-9zHGr3oUJq6G+X0oRMYlzid9fXicBdiydhwGChdyeNRGPcN/majtegApRKHLR5drboUvEWU+QeUmGTyEZQs3WA==",
+      "version": "3.4.0",
+      "resolved": "https://registry.npmjs.org/rollup-plugin-node-resolve/-/rollup-plugin-node-resolve-3.4.0.tgz",
+      "integrity": "sha512-PJcd85dxfSBWih84ozRtBkB731OjXk0KnzN0oGp7WOWcarAFkVa71cV5hTJg2qpVsV2U8EUwrzHP3tvy9vS3qg==",
       "dev": true,
       "requires": {
         "builtin-modules": "^2.0.0",
@@ -6015,6 +6012,17 @@
         }
       }
     },
+    "rollup-plugin-replace": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/rollup-plugin-replace/-/rollup-plugin-replace-2.0.0.tgz",
+      "integrity": "sha512-pK9mTd/FNrhtBxcTBXoh0YOwRIShV0gGhv9qvUtNcXHxIMRZMXqfiZKVBmCRGp8/2DJRy62z2JUE7/5tP6WxOQ==",
+      "dev": true,
+      "requires": {
+        "magic-string": "^0.22.4",
+        "minimatch": "^3.0.2",
+        "rollup-pluginutils": "^2.0.1"
+      }
+    },
     "rollup-pluginutils": {
       "version": "2.3.0",
       "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.3.0.tgz",
diff --git a/ui/package.json b/ui/package.json
index b28879f..148426d 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -13,6 +13,7 @@
   "dependencies": {
     "@types/mithril": "^1.1.12",
     "@types/uuid": "^3.4.3",
+    "immer": "^1.7.2",
     "mithril": "^1.1.6",
     "protobufjs": "^6.8.6",
     "uuid": "^3.3.2"
@@ -26,8 +27,9 @@
     "node-sass": "^4.9.2",
     "puppeteer": "^1.5.0",
     "rollup": "^0.59.4",
-    "rollup-plugin-commonjs": "^9.1.3",
-    "rollup-plugin-node-resolve": "^3.3.0",
+    "rollup-plugin-commonjs": "^9.1.8",
+    "rollup-plugin-node-resolve": "^3.4.0",
+    "rollup-plugin-replace": "^2.0.0",
     "sorcery": "^0.10.0",
     "tslib": "^1.9.3",
     "tslint": "^5.10.0",
diff --git a/ui/rollup.config.js b/ui/rollup.config.js
index 168f320..8c884f1 100644
--- a/ui/rollup.config.js
+++ b/ui/rollup.config.js
@@ -1,5 +1,6 @@
 import commonjs from 'rollup-plugin-commonjs';
 import nodeResolve from 'rollup-plugin-node-resolve';
+import replace from 'rollup-plugin-replace';
 
 export default {
   output: {name: 'perfetto'},
@@ -15,6 +16,11 @@
         'fs',
         'path',
       ]
+    }),
+
+    replace({
+      'immer_1.produce': 'immer_1',
     })
+
   ]
 }
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index bd7cada..fb108c0 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -12,125 +12,320 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-import {State} from './state';
+import {DraftObject} from 'immer';
+
+import {defaultTraceTime, State, Status, TraceTime} from './state';
 import {TimeSpan} from './time';
 
 export interface Action { type: string; }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function openTraceFromUrl(url: string) {
-  return {
-    type: 'OPEN_TRACE_FROM_URL',
+  return Actions.openTraceFromUrl({
     url,
-  };
+  });
 }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function openTraceFromFile(file: File) {
-  return {
-    type: 'OPEN_TRACE_FROM_FILE',
+  return Actions.openTraceFromFile({
     file,
-  };
+  });
 }
 
-// TODO(hjd): Remove CPU and add a generic way to handle track specific state.
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function addTrack(
     engineId: string, trackKind: string, name: string, config: {}) {
-  return {
-    type: 'ADD_TRACK',
+  return Actions.addTrack({
     engineId,
-    trackKind,
+    kind: trackKind,
     name,
     config,
-  };
+  });
 }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function requestTrackData(
     trackId: string, start: number, end: number, resolution: number) {
-  return {type: 'REQ_TRACK_DATA', trackId, start, end, resolution};
+  return Actions.reqTrackData({trackId, start, end, resolution});
 }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function clearTrackDataRequest(trackId: string) {
-  return {type: 'CLEAR_TRACK_DATA_REQ', trackId};
+  return Actions.clearTrackDataReq({trackId});
 }
 
-export function executeQuery(engineId: string, queryId: string, query: string) {
-  return {
-    type: 'EXECUTE_QUERY',
-    engineId,
-    queryId,
-    query,
-  };
-}
-
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function deleteQuery(queryId: string) {
-  return {
-    type: 'DELETE_QUERY',
+  return Actions.deleteQuery({
     queryId,
-  };
+  });
 }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function navigate(route: string) {
-  return {
-    type: 'NAVIGATE',
+  return Actions.navigate({
     route,
-  };
+  });
 }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function moveTrack(trackId: string, direction: 'up'|'down') {
-  return {
-    type: 'MOVE_TRACK',
+  return Actions.moveTrack({
     trackId,
     direction,
-  };
+  });
 }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function toggleTrackPinned(trackId: string) {
-  return {
-    type: 'TOGGLE_TRACK_PINNED',
+  return Actions.toggleTrackPinned({
     trackId,
-  };
+  });
 }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function setEngineReady(engineId: string, ready = true) {
-  return {type: 'SET_ENGINE_READY', engineId, ready};
+  return Actions.setEngineReady({engineId, ready});
 }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function createPermalink() {
-  return {type: 'CREATE_PERMALINK', requestId: new Date().toISOString()};
+  return Actions.createPermalink({requestId: new Date().toISOString()});
 }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function setPermalink(requestId: string, hash: string) {
-  return {type: 'SET_PERMALINK', requestId, hash};
+  return Actions.setPermalink({requestId, hash});
 }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function loadPermalink(hash: string) {
-  return {type: 'LOAD_PERMALINK', requestId: new Date().toISOString(), hash};
+  return Actions.loadPermalink({requestId: new Date().toISOString(), hash});
 }
 
+// TODO(hjd): Temporary until the reducer/action refactoring is done.
 export function setState(newState: State) {
-  return {
-    type: 'SET_STATE',
-    newState,
-  };
+  return Actions.setState({newState});
 }
 
 export function setTraceTime(ts: TimeSpan) {
-  return {
-    type: 'SET_TRACE_TIME',
+  return Actions.setTraceTime({
     startSec: ts.start,
     endSec: ts.end,
     lastUpdate: Date.now() / 1000,
-  };
+  });
 }
 
 export function setVisibleTraceTime(ts: TimeSpan) {
-  return {
-    type: 'SET_VISIBLE_TRACE_TIME',
+  return Actions.setVisibleTraceTime({
     startSec: ts.start,
     endSec: ts.end,
     lastUpdate: Date.now() / 1000,
-  };
+  });
 }
 
 export function updateStatus(msg: string) {
-  return {type: 'UPDATE_STATUS', msg, timestamp: Date.now() / 1000};
+  return Actions.updateStatus({msg, timestamp: Date.now() / 1000});
 }
+
+type StateDraft = DraftObject<State>;
+
+export const StateActions = {
+
+  navigate(state: StateDraft, args: {route: string}): void {
+    state.route = args.route;
+  },
+
+  openTraceFromFile(state: StateDraft, args: {file: File}): void {
+    state.traceTime = defaultTraceTime;
+    state.visibleTraceTime = defaultTraceTime;
+    const id = `${state.nextId++}`;
+    // Reset displayed tracks.
+    state.pinnedTracks = [];
+    state.scrollingTracks = [];
+    state.engines[id] = {
+      id,
+      ready: false,
+      source: args.file,
+    };
+    state.route = `/viewer`;
+  },
+
+  openTraceFromUrl(state: StateDraft, args: {url: string}): void {
+    state.traceTime = defaultTraceTime;
+    state.visibleTraceTime = defaultTraceTime;
+    const id = `${state.nextId++}`;
+    // Reset displayed tracks.
+    state.pinnedTracks = [];
+    state.scrollingTracks = [];
+    state.engines[id] = {
+      id,
+      ready: false,
+      source: args.url,
+    };
+    state.route = `/viewer`;
+  },
+
+  addTrack(
+      state: StateDraft,
+      args: {engineId: string; kind: string; name: string; config: {};}): void {
+    const id = `${state.nextId++}`;
+    state.tracks[id] = {
+      id,
+      engineId: args.engineId,
+      kind: args.kind,
+      name: args.name,
+      config: args.config,
+    };
+    state.scrollingTracks.push(id);
+  },
+
+  reqTrackData(state: StateDraft, args: {
+    trackId: string; start: number; end: number; resolution: number;
+  }): void {
+    const id = args.trackId;
+    state.tracks[id].dataReq = {
+      start: args.start,
+      end: args.end,
+      resolution: args.resolution
+    };
+  },
+
+  clearTrackDataReq(state: StateDraft, args: {trackId: string}): void {
+    const id = args.trackId;
+    state.tracks[id].dataReq = undefined;
+  },
+
+  executeQuery(
+      state: StateDraft,
+      args: {queryId: string; engineId: string; query: string}): void {
+    state.queries[args.queryId] = {
+      id: args.queryId,
+      engineId: args.engineId,
+      query: args.query,
+    };
+  },
+
+  deleteQuery(state: StateDraft, args: {queryId: string}): void {
+    delete state.queries[args.queryId];
+  },
+
+  moveTrack(
+      state: StateDraft, args: {trackId: string; direction: 'up' | 'down';}):
+      void {
+        const id = args.trackId;
+        const isPinned = state.pinnedTracks.includes(id);
+        const isScrolling = state.scrollingTracks.includes(id);
+        if (!isScrolling && !isPinned) {
+          throw new Error(`No track with id ${id}`);
+        }
+        const tracks = isPinned ? state.pinnedTracks : state.scrollingTracks;
+
+        const oldIndex: number = tracks.indexOf(id);
+        const newIndex = args.direction === 'up' ? oldIndex - 1 : oldIndex + 1;
+        const swappedTrackId = tracks[newIndex];
+        if (isPinned && newIndex === state.pinnedTracks.length) {
+          // Move from last element of pinned to first element of scrolling.
+          state.scrollingTracks.unshift(state.pinnedTracks.pop()!);
+        } else if (isScrolling && newIndex === -1) {
+          // Move first element of scrolling to last element of pinned.
+          state.pinnedTracks.push(state.scrollingTracks.shift()!);
+        } else if (swappedTrackId) {
+          tracks[newIndex] = id;
+          tracks[oldIndex] = swappedTrackId;
+        }
+      },
+
+  toggleTrackPinned(state: StateDraft, args: {trackId: string}): void {
+    const id = args.trackId;
+    const isPinned = state.pinnedTracks.includes(id);
+
+    if (isPinned) {
+      state.pinnedTracks.splice(state.pinnedTracks.indexOf(id), 1);
+      state.scrollingTracks.unshift(id);
+    } else {
+      state.scrollingTracks.splice(state.scrollingTracks.indexOf(id), 1);
+      state.pinnedTracks.push(id);
+    }
+  },
+
+  setEngineReady(state: StateDraft, args: {engineId: string; ready: boolean}):
+      void {
+        state.engines[args.engineId].ready = args.ready;
+      },
+
+  createPermalink(state: StateDraft, args: {requestId: string}): void {
+    state.permalink = {requestId: args.requestId, hash: undefined};
+  },
+
+  setPermalink(state: StateDraft, args: {requestId: string; hash: string}):
+      void {
+        // Drop any links for old requests.
+        if (state.permalink.requestId !== args.requestId) return;
+        state.permalink = args;
+      },
+
+  loadPermalink(state: StateDraft, args: {requestId: string; hash: string}):
+      void {
+        state.permalink = args;
+      },
+
+  setTraceTime(state: StateDraft, args: TraceTime): void {
+    state.traceTime = args;
+  },
+
+  setVisibleTraceTime(state: StateDraft, args: TraceTime): void {
+    state.visibleTraceTime = args;
+  },
+
+  updateStatus(state: StateDraft, args: Status): void {
+    state.status = args;
+  },
+
+  // TODO(hjd): Remove setState - it causes problems due to reuse of ids.
+  setState(_state: StateDraft, _args: {newState: State}): void {
+    // This has to be handled at a higher level since we can't
+    // replace the whole tree here however we still need a method here
+    // so it appears on the proxy Actions class.
+    throw new Error('Called setState on StateActions.');
+  },
+};
+
+// A DeferredAction is a bundle of Args and a method name.
+export interface DeferredAction<Args = {}> {
+  type: string;
+  args: Args;
+}
+
+// This type magic creates a type function DeferredActions<T> which takes a type
+// T and 'maps' its attributes. For each attribute on T matching the signature:
+// (state: StateDraft, args: Args) => void
+// DeferredActions<T> has an attribute:
+// (args: Args) => DeferredAction<Args>
+type ActionFunction<Args> = (state: StateDraft, args: Args) => void;
+type DeferredActionFunc<T> = T extends ActionFunction<infer Args>?
+    (args: Args) => DeferredAction<Args>:
+    never;
+type DeferredActions<C> = {
+  [P in keyof C]: DeferredActionFunc<C[P]>;
+};
+
+// Actions is an implementation of DeferredActions<typeof StateActions>.
+// (since StateActions is a variable not a type we have to do
+// 'typeof StateActions' to access the (unnamed) type of StateActions).
+// It's a Proxy such that any attribute access returns a function:
+// (args) => {return {type: ATTRIBUTE_NAME, args};}
+export const Actions =
+    // tslint:disable-next-line no-any
+    new Proxy<DeferredActions<typeof StateActions>>({} as any, {
+      // tslint:disable-next-line no-any
+      get(_: any, prop: string, _2: any) {
+        return (args: {}): DeferredAction<{}> => {
+          return {
+            type: prop,
+            args,
+          };
+        };
+      },
+    });
diff --git a/ui/src/common/actions_unittest.ts b/ui/src/common/actions_unittest.ts
new file mode 100644
index 0000000..7a3dc94
--- /dev/null
+++ b/ui/src/common/actions_unittest.ts
@@ -0,0 +1,238 @@
+// Copyright (C) 2018 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {produce} from 'immer';
+import {StateActions} from './actions';
+import {createEmptyState, State, TrackState} from './state';
+
+function fakeTrack(state: State, id: string): TrackState {
+  const track: TrackState = {
+    id,
+    engineId: '1',
+    kind: 'SOME_TRACK_KIND',
+    name: 'A track',
+    config: {},
+  };
+  state.tracks[id] = track;
+  return track;
+}
+
+test('navigate', () => {
+  const after = produce(createEmptyState(), draft => {
+    StateActions.navigate(draft, {route: '/foo'});
+  });
+  expect(after.route).toBe('/foo');
+});
+
+test('add tracks', () => {
+  const once = produce(createEmptyState(), draft => {
+    StateActions.addTrack(draft, {
+      engineId: '1',
+      kind: 'cpu',
+      name: 'Cpu 1',
+      config: {},
+    });
+  });
+  const twice = produce(once, draft => {
+    StateActions.addTrack(draft, {
+      engineId: '2',
+      kind: 'cpu',
+      name: 'Cpu 2',
+      config: {},
+    });
+  });
+
+  expect(Object.values(twice.tracks).length).toBe(2);
+  expect(twice.scrollingTracks.length).toBe(2);
+});
+
+test('reorder tracks', () => {
+  const once = produce(createEmptyState(), draft => {
+    StateActions.addTrack(draft, {
+      engineId: '1',
+      kind: 'cpu',
+      name: 'Cpu 1',
+      config: {},
+    });
+    StateActions.addTrack(draft, {
+      engineId: '2',
+      kind: 'cpu',
+      name: 'Cpu 2',
+      config: {},
+    });
+  });
+
+  const firstTrackId = once.scrollingTracks[0];
+  const secondTrackId = once.scrollingTracks[1];
+
+  const twice = produce(once, draft => {
+    StateActions.moveTrack(draft, {
+      trackId: `${firstTrackId}`,
+      direction: 'down',
+    });
+  });
+
+  expect(twice.scrollingTracks[0]).toBe(secondTrackId);
+  expect(twice.scrollingTracks[1]).toBe(firstTrackId);
+});
+
+test('reorder pinned to scrolling', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a', 'b'];
+  state.scrollingTracks = ['c'];
+
+  const after = produce(state, draft => {
+    StateActions.moveTrack(draft, {
+      trackId: 'b',
+      direction: 'down',
+    });
+  });
+
+  expect(after.pinnedTracks).toEqual(['a']);
+  expect(after.scrollingTracks).toEqual(['b', 'c']);
+});
+
+test('reorder scrolling to pinned', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a'];
+  state.scrollingTracks = ['b', 'c'];
+
+  const after = produce(state, draft => {
+    StateActions.moveTrack(draft, {
+      trackId: 'b',
+      direction: 'up',
+    });
+  });
+
+  expect(after.pinnedTracks).toEqual(['a', 'b']);
+  expect(after.scrollingTracks).toEqual(['c']);
+});
+
+test('reorder clamp bottom', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a', 'b'];
+  state.scrollingTracks = ['c'];
+
+  const after = produce(state, draft => {
+    StateActions.moveTrack(draft, {
+      trackId: 'a',
+      direction: 'up',
+    });
+  });
+  expect(after).toEqual(state);
+});
+
+test('reorder clamp top', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a'];
+  state.scrollingTracks = ['b', 'c'];
+
+  const after = produce(state, draft => {
+    StateActions.moveTrack(draft, {
+      trackId: 'c',
+      direction: 'down',
+    });
+  });
+  expect(after).toEqual(state);
+});
+
+test('pin', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a'];
+  state.scrollingTracks = ['b', 'c'];
+
+  const after = produce(state, draft => {
+    StateActions.toggleTrackPinned(draft, {
+      trackId: 'c',
+    });
+  });
+  expect(after.pinnedTracks).toEqual(['a', 'c']);
+  expect(after.scrollingTracks).toEqual(['b']);
+});
+
+test('unpin', () => {
+  const state = createEmptyState();
+  fakeTrack(state, 'a');
+  fakeTrack(state, 'b');
+  fakeTrack(state, 'c');
+  state.pinnedTracks = ['a', 'b'];
+  state.scrollingTracks = ['c'];
+
+  const after = produce(state, draft => {
+    StateActions.toggleTrackPinned(draft, {
+      trackId: 'a',
+    });
+  });
+  expect(after.pinnedTracks).toEqual(['b']);
+  expect(after.scrollingTracks).toEqual(['a', 'c']);
+});
+
+test('open trace', () => {
+  const after = produce(createEmptyState(), draft => {
+    StateActions.openTraceFromUrl(draft, {
+      url: 'https://example.com/bar',
+    });
+  });
+
+  const engineKeys = Object.keys(after.engines);
+  expect(engineKeys.length).toBe(1);
+  expect(after.engines[engineKeys[0]].source).toBe('https://example.com/bar');
+  expect(after.route).toBe('/viewer');
+});
+
+test('open second trace from file', () => {
+  const once = produce(createEmptyState(), draft => {
+    StateActions.openTraceFromUrl(draft, {
+      url: 'https://example.com/bar',
+    });
+  });
+
+  const twice = produce(once, draft => {
+    StateActions.addTrack(draft, {
+      engineId: '1',
+      kind: 'cpu',
+      name: 'Cpu 1',
+      config: {},
+    });
+  });
+
+  const thrice = produce(twice, draft => {
+    StateActions.openTraceFromUrl(draft, {
+      url: 'https://example.com/foo',
+    });
+  });
+
+  const engineKeys = Object.keys(thrice.engines);
+  expect(engineKeys.length).toBe(2);
+  expect(thrice.engines[engineKeys[0]].source).toBe('https://example.com/bar');
+  expect(thrice.engines[engineKeys[1]].source).toBe('https://example.com/foo');
+  expect(thrice.pinnedTracks.length).toBe(0);
+  expect(thrice.scrollingTracks.length).toBe(0);
+  expect(thrice.route).toBe('/viewer');
+});
diff --git a/ui/src/controller/globals.ts b/ui/src/controller/globals.ts
index 8a336fa..c46ffbf 100644
--- a/ui/src/controller/globals.ts
+++ b/ui/src/controller/globals.ts
@@ -12,13 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+import {produce} from 'immer';
+
 import {assertExists} from '../base/logging';
 import {Remote} from '../base/remote';
-import {Action} from '../common/actions';
+import {DeferredAction, StateActions} from '../common/actions';
 import {createEmptyState, State} from '../common/state';
+
 import {ControllerAny} from './controller';
 import {Engine} from './engine';
-import {rootReducer} from './reducer';
 import {WasmEngineProxy} from './wasm_engine_proxy';
 import {
   createWasmEngine,
@@ -33,19 +35,19 @@
   private _rootController?: ControllerAny;
   private _frontend?: Remote;
   private _runningControllers = false;
-  private _queuedActions = new Array<Action>();
+  private _queuedActions = new Array<DeferredAction>();
 
   initialize(rootController: ControllerAny, frontendProxy: Remote) {
-    this._state = createEmptyState();
     this._rootController = rootController;
     this._frontend = frontendProxy;
+    this._state = createEmptyState();
   }
 
-  dispatch(action: Action): void {
+  dispatch(action: DeferredAction): void {
     this.dispatchMultiple([action]);
   }
 
-  dispatchMultiple(actions: Action[]): void {
+  dispatchMultiple(actions: DeferredAction[]): void {
     this._queuedActions = this._queuedActions.concat(actions);
 
     // If we are in the middle of running the controllers, queue the actions
@@ -67,10 +69,10 @@
     for (let iter = 0; runAgain || this._queuedActions.length > 0; iter++) {
       if (iter > 100) throw new Error('Controllers are stuck in a livelock');
       const actions = this._queuedActions;
-      this._queuedActions = new Array<Action>();
+      this._queuedActions = new Array<DeferredAction>();
       for (const action of actions) {
         console.debug('Applying action', action);
-        this._state = rootReducer(this.state, action);
+        this.applyAction(action);
       }
       this._runningControllers = true;
       try {
@@ -102,8 +104,21 @@
     return assertExists(this._state);
   }
 
-  set state(state: State) {
-    this._state = state;
+  applyAction(action: DeferredAction): void {
+    assertExists(this._state);
+    // We need a special case for when we want to replace the whole tree.
+    if (action.type === 'setState') {
+      const args = (action as DeferredAction<{newState: State}>).args;
+      this._state = args.newState;
+      return;
+    }
+    // 'produce' creates a immer proxy which wraps the current state turning
+    // all imperative mutations of the state done in the callback into
+    // immutable changes to the returned state.
+    this._state = produce(this.state, draft => {
+      // tslint:disable-next-line no-any
+      (StateActions as any)[action.type](draft, action.args);
+    });
   }
 
   resetForTesting() {
diff --git a/ui/src/controller/reducer.ts b/ui/src/controller/reducer.ts
deleted file mode 100644
index 8e6cc9b..0000000
--- a/ui/src/controller/reducer.ts
+++ /dev/null
@@ -1,229 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {defaultTraceTime, State} from '../common/state';
-
-// TODO(hjd): Type check this better.
-// tslint:disable-next-line no-any
-export function rootReducer(state: State, action: any): State {
-  switch (action.type) {
-    case 'NAVIGATE': {
-      const nextState = {...state};
-      nextState.route = action.route;
-      return nextState;
-    }
-
-    case 'OPEN_TRACE_FROM_FILE': {
-      const nextState = {...state};
-      nextState.traceTime = {...defaultTraceTime};
-      nextState.visibleTraceTime = {...defaultTraceTime};
-      const id = `${nextState.nextId++}`;
-      // Reset displayed tracks.
-      nextState.pinnedTracks = [];
-      nextState.scrollingTracks = [];
-      nextState.engines[id] = {
-        id,
-        ready: false,
-        source: action.file,
-      };
-      nextState.route = `/viewer`;
-
-      return nextState;
-    }
-
-    case 'OPEN_TRACE_FROM_URL': {
-      const nextState = {...state};
-      nextState.traceTime = {...defaultTraceTime};
-      nextState.visibleTraceTime = {...defaultTraceTime};
-      const id = `${nextState.nextId++}`;
-      // Reset displayed tracks.
-      nextState.pinnedTracks = [];
-      nextState.scrollingTracks = [];
-      nextState.engines[id] = {
-        id,
-        ready: false,
-        source: action.url,
-      };
-      nextState.route = `/viewer`;
-      return nextState;
-    }
-
-    case 'ADD_TRACK': {
-      const nextState = {...state};
-      nextState.tracks = {...state.tracks};
-      nextState.scrollingTracks = [...state.scrollingTracks];
-      const id = `${nextState.nextId++}`;
-      nextState.tracks[id] = {
-        id,
-        engineId: action.engineId,
-        kind: action.trackKind,
-        name: action.name,
-        config: action.config,
-      };
-      nextState.scrollingTracks.push(id);
-      return nextState;
-    }
-
-    case 'REQ_TRACK_DATA': {
-      const id = action.trackId;
-      const nextState = {...state};
-      const nextTracks = nextState.tracks = {...state.tracks};
-      const nextTrack = nextTracks[id] = {...nextTracks[id]};
-      nextTrack.dataReq = {
-        start: action.start,
-        end: action.end,
-        resolution: action.resolution
-      };
-      return nextState;
-    }
-
-    case 'CLEAR_TRACK_DATA_REQ': {
-      const id = action.trackId;
-      const nextState = {...state};
-      const nextTracks = nextState.tracks = {...state.tracks};
-      const nextTrack = nextTracks[id] = {...nextTracks[id]};
-      nextTrack.dataReq = undefined;
-      return nextState;
-    }
-
-    case 'EXECUTE_QUERY': {
-      const nextState = {...state};
-      nextState.queries = {...state.queries};
-      nextState.queries[action.queryId] = {
-        id: action.queryId,
-        engineId: action.engineId,
-        query: action.query,
-      };
-      return nextState;
-    }
-
-    case 'DELETE_QUERY': {
-      const nextState = {...state};
-      nextState.queries = {...state.queries};
-      delete nextState.queries[action.queryId];
-      return nextState;
-    }
-
-    case 'MOVE_TRACK': {
-      if (!action.direction) {
-        throw new Error('No direction given');
-      }
-      const id = action.trackId;
-      const isPinned = state.pinnedTracks.includes(id);
-      const isScrolling = state.scrollingTracks.includes(id);
-      if (!isScrolling && !isPinned) {
-        throw new Error(`No track with id ${id}`);
-      }
-      const nextState = {...state};
-      const scrollingTracks = nextState.scrollingTracks =
-          state.scrollingTracks.slice();
-      const pinnedTracks = nextState.pinnedTracks = state.pinnedTracks.slice();
-
-      const tracks = isPinned ? pinnedTracks : scrollingTracks;
-
-      const oldIndex = tracks.indexOf(id);
-      const newIndex = action.direction === 'up' ? oldIndex - 1 : oldIndex + 1;
-      const swappedTrackId = tracks[newIndex];
-      if (isPinned && newIndex === pinnedTracks.length) {
-        // Move from last element of pinned to first element of scrolling.
-        scrollingTracks.unshift(pinnedTracks.pop()!);
-      } else if (isScrolling && newIndex === -1) {
-        // Move first element of scrolling to last element of pinned.
-        pinnedTracks.push(scrollingTracks.shift()!);
-      } else if (swappedTrackId) {
-        tracks[newIndex] = id;
-        tracks[oldIndex] = swappedTrackId;
-      } else {
-        return state;
-      }
-      return nextState;
-    }
-
-    case 'TOGGLE_TRACK_PINNED': {
-      const id = action.trackId;
-      const isPinned = state.pinnedTracks.includes(id);
-
-      const nextState = {...state};
-      const pinnedTracks = nextState.pinnedTracks = [...state.pinnedTracks];
-      const scrollingTracks = nextState.scrollingTracks =
-          [...state.scrollingTracks];
-      if (isPinned) {
-        pinnedTracks.splice(pinnedTracks.indexOf(id), 1);
-        scrollingTracks.unshift(id);
-      } else {
-        scrollingTracks.splice(scrollingTracks.indexOf(id), 1);
-        pinnedTracks.push(id);
-      }
-      return nextState;
-    }
-
-    case 'SET_ENGINE_READY': {
-      const nextState = {...state};  // Creates a shallow copy.
-      nextState.engines = {...state.engines};
-      nextState.engines[action.engineId].ready = action.ready;
-      return nextState;
-    }
-
-    case 'CREATE_PERMALINK': {
-      const nextState = {...state};
-      nextState.permalink = {requestId: action.requestId, hash: undefined};
-      return nextState;
-    }
-
-    case 'SET_PERMALINK': {
-      // Drop any links for old requests.
-      if (state.permalink.requestId !== action.requestId) return state;
-
-      const nextState = {...state};
-      nextState.permalink = {requestId: action.requestId, hash: action.hash};
-      return nextState;
-    }
-
-    case 'LOAD_PERMALINK': {
-      const nextState = {...state};
-      nextState.permalink = {requestId: action.requestId, hash: action.hash};
-      return nextState;
-    }
-
-    case 'SET_STATE': {
-      return action.newState;
-    }
-
-    case 'SET_TRACE_TIME': {
-      const nextState = {...state};
-      nextState.traceTime.startSec = action.startSec;
-      nextState.traceTime.endSec = action.endSec;
-      nextState.traceTime.lastUpdate = action.lastUpdate;
-      return nextState;
-    }
-
-    case 'SET_VISIBLE_TRACE_TIME': {
-      const nextState = {...state};
-      nextState.visibleTraceTime.startSec = action.startSec;
-      nextState.visibleTraceTime.endSec = action.endSec;
-      nextState.visibleTraceTime.lastUpdate = action.lastUpdate;
-      return nextState;
-    }
-
-    case 'UPDATE_STATUS': {
-      const nextState = {...state};
-      nextState.status = {msg: action.msg, timestamp: action.timestamp};
-      return nextState;
-    }
-
-    default:
-      break;
-  }
-  return state;
-}
diff --git a/ui/src/controller/reducer_unittest.ts b/ui/src/controller/reducer_unittest.ts
deleted file mode 100644
index fc0f246..0000000
--- a/ui/src/controller/reducer_unittest.ts
+++ /dev/null
@@ -1,217 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-//      http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {moveTrack, toggleTrackPinned} from '../common/actions';
-import {createEmptyState, State, TrackState} from '../common/state';
-
-import {rootReducer} from './reducer';
-
-function fakeTrack(state: State, id: string): TrackState {
-  const track: TrackState = {
-    id,
-    engineId: '1',
-    kind: 'SOME_TRACK_KIND',
-    name: 'A track',
-    config: {},
-  };
-  state.tracks[id] = track;
-  return track;
-}
-
-test('navigate', async () => {
-  const before = createEmptyState();
-  const after = rootReducer(before, {type: 'NAVIGATE', route: '/foo'});
-  expect(after.route).toBe('/foo');
-});
-
-test('add tracks', () => {
-  const empty = createEmptyState();
-  const step1 = rootReducer(empty, {
-    type: 'ADD_TRACK',
-    engineId: '1',
-    trackKind: 'cpu',
-    cpu: '1',
-  });
-  const state = rootReducer(step1, {
-    type: 'ADD_TRACK',
-    engineId: '2',
-    trackKind: 'cpu',
-    cpu: '2',
-  });
-  expect(Object.values(state.tracks).length).toBe(2);
-  expect(state.scrollingTracks.length).toBe(2);
-});
-
-test('reorder tracks', () => {
-  const empty = createEmptyState();
-  const step1 = rootReducer(empty, {
-    type: 'ADD_TRACK',
-    engineId: '1',
-    trackKind: 'cpu',
-    config: {},
-  });
-  const before = rootReducer(step1, {
-    type: 'ADD_TRACK',
-    engineId: '2',
-    trackKind: 'cpu',
-    config: {},
-  });
-
-  const firstTrackId = before.scrollingTracks[0];
-  const secondTrackId = before.scrollingTracks[1];
-
-  const after = rootReducer(before, {
-    type: 'MOVE_TRACK',
-    trackId: `${firstTrackId}`,
-    direction: 'down',
-  });
-
-  // Ensure the order is swapped. This test would fail to detect side effects
-  // if the before state was modified, so other tests are needed as well.
-  expect(after.scrollingTracks[0]).toBe(secondTrackId);
-  expect(after.scrollingTracks[1]).toBe(firstTrackId);
-
-  // Ensure the track state contents have actually swapped places in the new
-  // state, but not in the old one.
-  expect(before.tracks[before.scrollingTracks[0]].engineId).toBe('1');
-  expect(before.tracks[before.scrollingTracks[1]].engineId).toBe('2');
-  expect(after.tracks[after.scrollingTracks[0]].engineId).toBe('2');
-  expect(after.tracks[after.scrollingTracks[1]].engineId).toBe('1');
-});
-
-test('reorder pinned to scrolling', () => {
-  const before = createEmptyState();
-
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a', 'b'];
-  before.scrollingTracks = ['c'];
-
-  const after = rootReducer(before, moveTrack('b', 'down'));
-  expect(after.pinnedTracks).toEqual(['a']);
-  expect(after.scrollingTracks).toEqual(['b', 'c']);
-});
-
-test('reorder scrolling to pinned', () => {
-  const before = createEmptyState();
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a'];
-  before.scrollingTracks = ['b', 'c'];
-
-  const after = rootReducer(before, moveTrack('b', 'up'));
-  expect(after.pinnedTracks).toEqual(['a', 'b']);
-  expect(after.scrollingTracks).toEqual(['c']);
-});
-
-test('reorder clamp bottom', () => {
-  const before = createEmptyState();
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a', 'b'];
-  before.scrollingTracks = ['c'];
-
-  const after = rootReducer(before, moveTrack('a', 'up'));
-  expect(after).toEqual(before);
-});
-
-test('reorder clamp top', () => {
-  const before = createEmptyState();
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a'];
-  before.scrollingTracks = ['b', 'c'];
-
-  const after = rootReducer(before, moveTrack('c', 'down'));
-  expect(after).toEqual(before);
-});
-
-test('pin', () => {
-  const before = createEmptyState();
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a'];
-  before.scrollingTracks = ['b', 'c'];
-
-  const after = rootReducer(before, toggleTrackPinned('c'));
-  expect(after.pinnedTracks).toEqual(['a', 'c']);
-  expect(after.scrollingTracks).toEqual(['b']);
-});
-
-test('unpin', () => {
-  const before = createEmptyState();
-  fakeTrack(before, 'a');
-  fakeTrack(before, 'b');
-  fakeTrack(before, 'c');
-
-  before.pinnedTracks = ['a', 'b'];
-  before.scrollingTracks = ['c'];
-
-  const after = rootReducer(before, toggleTrackPinned('a'));
-  expect(after.pinnedTracks).toEqual(['b']);
-  expect(after.scrollingTracks).toEqual(['a', 'c']);
-});
-
-test('open trace', async () => {
-  const before = createEmptyState();
-  const after = rootReducer(before, {
-    type: 'OPEN_TRACE_FROM_URL',
-    url: 'https://example.com/bar',
-  });
-  const engineKeys = Object.keys(after.engines);
-  expect(engineKeys.length).toBe(1);
-  expect(after.engines[engineKeys[0]].source).toBe('https://example.com/bar');
-  expect(after.route).toBe('/viewer');
-});
-
-test('set state', async () => {
-  const newState = createEmptyState();
-  const before = createEmptyState();
-  const after = rootReducer(before, {
-    type: 'SET_STATE',
-    newState,
-  });
-  expect(after).toBe(newState);
-});
-
-test('open second trace from file', () => {
-  const before = createEmptyState();
-  const afterFirst = rootReducer(before, {
-    type: 'OPEN_TRACE_FROM_URL',
-    url: 'https://example.com/bar',
-  });
-  afterFirst.scrollingTracks = ['track1', 'track2'];
-  afterFirst.pinnedTracks = ['track3', 'track4'];
-  const after = rootReducer(afterFirst, {
-    type: 'OPEN_TRACE_FROM_URL',
-    url: 'https://example.com/foo',
-  });
-  const engineKeys = Object.keys(after.engines);
-  expect(engineKeys.length).toBe(2);
-  expect(after.engines[engineKeys[0]].source).toBe('https://example.com/bar');
-  expect(after.engines[engineKeys[1]].source).toBe('https://example.com/foo');
-  expect(after.pinnedTracks.length).toBe(0);
-  expect(after.scrollingTracks.length).toBe(0);
-  expect(after.route).toBe('/viewer');
-});
diff --git a/ui/src/controller/trace_controller.ts b/ui/src/controller/trace_controller.ts
index 06f570f..08b205f 100644
--- a/ui/src/controller/trace_controller.ts
+++ b/ui/src/controller/trace_controller.ts
@@ -16,8 +16,8 @@
 
 import {assertExists, assertTrue} from '../base/logging';
 import {
-  Action,
   addTrack,
+  DeferredAction,
   navigate,
   setEngineReady,
   setTraceTime,
@@ -180,7 +180,7 @@
   private async listTracks() {
     globals.dispatch(updateStatus('Loading tracks'));
     const engine = assertExists<Engine>(this.engine);
-    const addToTrackActions: Action[] = [];
+    const addToTrackActions: DeferredAction[] = [];
     const numCpus = await engine.getNumberOfCpus();
     for (let cpu = 0; cpu < numCpus; cpu++) {
       addToTrackActions.push(
diff --git a/ui/src/frontend/panel_container.ts b/ui/src/frontend/panel_container.ts
index 4fb18f5..15be904 100644
--- a/ui/src/frontend/panel_container.ts
+++ b/ui/src/frontend/panel_container.ts
@@ -32,10 +32,12 @@
  */
 const SCROLLING_CANVAS_OVERDRAW_FACTOR = 2;
 
+// We need any here so we can accept vnodes with arbitrary attrs.
+// tslint:disable-next-line:no-any
+export type AnyAttrsVnode = m.Vnode<any, {}>;
+
 interface Attrs {
-  // Panels with non-empty attrs does not work without any.
-  // tslint:disable-next-line:no-any
-  panels: Array<m.Vnode<any, {}>>;
+  panels: AnyAttrsVnode[];
   doesScroll: boolean;
 }
 
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index 6f8e5d2..073814c 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -15,8 +15,8 @@
 import * as m from 'mithril';
 
 import {
+  Actions,
   createPermalink,
-  executeQuery,
   navigate,
   openTraceFromFile,
   openTraceFromUrl
@@ -60,7 +60,11 @@
 function createCannedQuery(query: string): (_: Event) => void {
   return (e: Event) => {
     e.preventDefault();
-    globals.dispatch(executeQuery('0', 'command', query));
+    globals.dispatch(Actions.executeQuery({
+      engineId: '0',
+      queryId: 'command',
+      query,
+    }));
   };
 }
 
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index 03b142a..6e1acdb 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -14,7 +14,7 @@
 
 import * as m from 'mithril';
 
-import {deleteQuery, executeQuery} from '../common/actions';
+import {Actions, deleteQuery} from '../common/actions';
 import {QueryResponse} from '../common/queries';
 import {EngineConfig} from '../common/state';
 
@@ -81,10 +81,12 @@
   if (mode === 'search') {
     const name = txt.value.replace(/'/g, '\\\'').replace(/[*]/g, '%');
     const query = `select str from strings where str like '%${name}%' limit 10`;
-    globals.dispatch(executeQuery('0', QUERY_ID, query));
+    globals.dispatch(
+        Actions.executeQuery({engineId: '0', queryId: QUERY_ID, query}));
   }
   if (mode === 'command' && key === 'Enter') {
-    globals.dispatch(executeQuery('0', 'command', txt.value));
+    globals.dispatch(Actions.executeQuery(
+        {engineId: '0', queryId: 'command', query: txt.value}));
   }
 }
 
diff --git a/ui/src/frontend/viewer_page.ts b/ui/src/frontend/viewer_page.ts
index e801992..adca962 100644
--- a/ui/src/frontend/viewer_page.ts
+++ b/ui/src/frontend/viewer_page.ts
@@ -23,14 +23,15 @@
 import {OverviewTimelinePanel} from './overview_timeline_panel';
 import {createPage} from './pages';
 import {PanAndZoomHandler} from './pan_and_zoom_handler';
-import {PanelContainer} from './panel_container';
+import {Panel} from './panel';
+import {AnyAttrsVnode, PanelContainer} from './panel_container';
 import {TimeAxisPanel} from './time_axis_panel';
 import {TRACK_SHELL_WIDTH} from './track_panel';
 import {TrackPanel} from './track_panel';
 
 const MAX_ZOOM_SPAN_SEC = 1e-4;  // 0.1 ms.
 
-class QueryTable implements m.ClassComponent {
+class QueryTable extends Panel {
   view() {
     const resp = globals.queryResults.get('command') as QueryResponse;
     if (resp === undefined) {
@@ -59,6 +60,8 @@
             m('.query-error', `SQL error: ${resp.error}`) :
             m('table.query-table', m('thead', header), m('tbody', rows)));
   }
+
+  renderCanvas() {}
 }
 
 /**
@@ -130,7 +133,8 @@
   }
 
   view() {
-    const scrollingPanels = globals.state.scrollingTracks.length > 0 ?
+    const scrollingPanels: AnyAttrsVnode[] =
+        globals.state.scrollingTracks.length > 0 ?
         [
           m(HeaderPanel, {title: 'Tracks', key: 'tracksheader'}),
           ...globals.state.scrollingTracks.map(
@@ -138,10 +142,10 @@
           m(FlameGraphPanel, {key: 'flamegraph'}),
         ] :
         [];
+    scrollingPanels.unshift(m(QueryTable));
+
     return m(
         '.page',
-        m(QueryTable),
-        // TODO: Pan and zoom logic should be in its own mithril component.
         m('.pan-and-zoom-content',
           m('.pinned-panel-container', m(PanelContainer, {
               doesScroll: false,