[ui] Port cpu_freq tracks.

CpuFreqTrackController does some async stuff in it's onSetup function.
Previously this function was being called fire-n-forget, which meant
that onBoundsChange could be called before onSetup was complete. Thus,
this CL adds a more strict lifecycle to tracks where onCreate() is
guaranteed to complete before any of the other methods are called.

Change-Id: I5998fea769741c3218b28817f60cbfb279c3381c
diff --git a/ui/src/frontend/track_panel.ts b/ui/src/frontend/track_panel.ts
index 2c2d5e7..64cfd63 100644
--- a/ui/src/frontend/track_panel.ts
+++ b/ui/src/frontend/track_panel.ts
@@ -15,6 +15,7 @@
 import {hex} from 'color-convert';
 import m from 'mithril';
 
+import {Disposable} from '../base/disposable';
 import {currentTargetOffset} from '../base/dom_utils';
 import {Icons} from '../base/semantic_icons';
 import {duration, Span, time} from '../base/time';
@@ -101,7 +102,7 @@
 }
 
 interface TrackShellAttrs {
-  track: TrackLike;
+  track: TrackLifecycleContainer;
   trackState: TrackState;
 }
 
@@ -233,7 +234,7 @@
 }
 
 export interface TrackContentAttrs {
-  track: TrackLike;
+  track: TrackLifecycleContainer;
 }
 export class TrackContent implements m.ClassComponent<TrackContentAttrs> {
   private mouseDownX?: number;
@@ -292,7 +293,7 @@
 
 interface TrackComponentAttrs {
   trackState: TrackState;
-  track: TrackLike;
+  track: TrackLifecycleContainer;
 }
 class TrackComponent implements m.ClassComponent<TrackComponentAttrs> {
   view({attrs}: m.CVnode<TrackComponentAttrs>) {
@@ -352,12 +353,121 @@
   selectable: boolean;
 }
 
+enum TrackLifecycleState {
+  Initializing,
+  Initialized,
+  DestroyPending,
+  Destroying,
+  Destroyed,
+}
+
+export class TrackLifecycleContainer implements Disposable {
+  private state = TrackLifecycleState.Initializing;
+
+  constructor(private track: TrackLike) {
+    track.onCreate().then(() => {
+      if (this.state === TrackLifecycleState.DestroyPending) {
+        track.onDestroy();
+        this.state = TrackLifecycleState.Destroying;
+      } else {
+        this.state = TrackLifecycleState.Initialized;
+      }
+    });
+  }
+
+  onFullRedraw(): void {
+    if (this.state === TrackLifecycleState.Initialized) {
+      this.track.onFullRedraw();
+    }
+  }
+
+  getSliceRect(
+      visibleTimeScale: TimeScale, visibleWindow: Span<time, bigint>,
+      windowSpan: PxSpan, tStart: time, tEnd: time, depth: number): SliceRect
+      |undefined {
+    if (this.state === TrackLifecycleState.Initialized) {
+      return this.track.getSliceRect(
+          visibleTimeScale, visibleWindow, windowSpan, tStart, tEnd, depth);
+    } else {
+      return undefined;
+    }
+  }
+
+  getHeight(): number {
+    if (this.state === TrackLifecycleState.Initialized) {
+      return this.track.getHeight();
+    } else {
+      return 18;
+    }
+  }
+
+  getTrackShellButtons(): m.Vnode<TrackButtonAttrs, {}>[] {
+    if (this.state === TrackLifecycleState.Initialized) {
+      return this.track.getTrackShellButtons();
+    } else {
+      return [];
+    }
+  }
+
+  getContextMenu(): m.Vnode<any, {}>|null {
+    if (this.state === TrackLifecycleState.Initialized) {
+      return this.track.getContextMenu();
+    } else {
+      return null;
+    }
+  }
+
+  onMouseMove(position: {x: number; y: number;}): void {
+    if (this.state === TrackLifecycleState.Initialized) {
+      this.track.onMouseMove(position);
+    }
+  }
+
+  onMouseClick(position: {x: number; y: number;}): boolean {
+    if (this.state === TrackLifecycleState.Initialized) {
+      return this.track.onMouseClick(position);
+    } else {
+      return false;
+    }
+  }
+
+  onMouseOut(): void {
+    if (this.state === TrackLifecycleState.Initialized) {
+      this.track.onMouseOut();
+    }
+  }
+
+  render(ctx: CanvasRenderingContext2D) {
+    if (this.state === TrackLifecycleState.Initialized) {
+      this.track.render(ctx);
+    }
+  }
+
+  dispose() {
+    switch (this.state) {
+      case TrackLifecycleState.Initializing:
+        this.state = TrackLifecycleState.DestroyPending;
+        break;
+      case TrackLifecycleState.Initialized:
+        this.state = TrackLifecycleState.Destroying;
+        this.track.onDestroy().then(() => {
+          this.state = TrackLifecycleState.Destroyed;
+        });
+        break;
+      case TrackLifecycleState.DestroyPending:
+      case TrackLifecycleState.Destroying:
+      case TrackLifecycleState.Destroyed:
+        break;
+      default:
+        const x: never = this.state;
+        throw new Error(`Invalid state "${x}"`);
+    }
+  }
+}
+
 export class TrackPanel extends Panel<TrackPanelAttrs> {
-  // TODO(hjd): It would be nicer if these could not be undefined here.
-  // We should implement a NullTrack which can be used if the trackState
-  // has disappeared.
-  private track: TrackLike|undefined;
-  private trackState: TrackState|undefined;
+  private track?: TrackLifecycleContainer;
+  private trackState?: TrackState;
 
   private tryLoadTrack(vnode: m.CVnode<TrackPanelAttrs>) {
     const trackId = vnode.attrs.id;
@@ -379,10 +489,13 @@
       },
     };
 
-    this.track = uri ? pluginManager.createTrack(uri, trackCtx) :
-                       loadTrack(trackState, id);
-    this.track?.onCreate();
-    this.trackState = trackState;
+    const track = uri ? pluginManager.createTrack(uri, trackCtx) :
+                        loadTrack(trackState, id);
+
+    if (track) {
+      this.track = new TrackLifecycleContainer(track);
+      this.trackState = trackState;
+    }
   }
 
   view(vnode: m.CVnode<TrackPanelAttrs>) {
@@ -410,7 +523,7 @@
 
   onremove() {
     if (this.track !== undefined) {
-      this.track.onDestroy();
+      this.track.dispose();
       this.track = undefined;
     }
   }