Merge "Modify trace redactor target to be included in module apex" into main
diff --git a/python/tools/check_ratchet.py b/python/tools/check_ratchet.py
index d53b0c3..a34ff08 100755
--- a/python/tools/check_ratchet.py
+++ b/python/tools/check_ratchet.py
@@ -36,7 +36,7 @@
 
 from dataclasses import dataclass
 
-EXPECTED_ANY_COUNT = 52
+EXPECTED_ANY_COUNT = 51
 EXPECTED_RUN_METRIC_COUNT = 4
 
 ROOT_DIR = os.path.dirname(
diff --git a/src/base/time.cc b/src/base/time.cc
index e799542..ad971af 100644
--- a/src/base/time.cc
+++ b/src/base/time.cc
@@ -188,10 +188,15 @@
 std::string GetTimeFmt(const std::string& fmt) {
   time_t raw_time;
   time(&raw_time);
-  struct tm* local_tm;
-  local_tm = localtime(&raw_time);
+  struct tm local_tm;
+#if PERFETTO_BUILDFLAG(PERFETTO_OS_WIN)
+  PERFETTO_CHECK(localtime_s(&local_tm, &raw_time) == 0);
+#else
+  tzset();
+  PERFETTO_CHECK(localtime_r(&raw_time, &local_tm) != nullptr);
+#endif
   char buf[128];
-  PERFETTO_CHECK(strftime(buf, 80, fmt.c_str(), local_tm) > 0);
+  PERFETTO_CHECK(strftime(buf, 80, fmt.c_str(), &local_tm) > 0);
   return buf;
 }
 
diff --git a/test/data/ui-screenshots/ftrace_tracks_and_tab.test.ts/ftrace-tracks/ftrace-events.png.sha256 b/test/data/ui-screenshots/ftrace_tracks_and_tab.test.ts/ftrace-tracks/ftrace-events.png.sha256
index b97e598..66f89fe 100644
--- a/test/data/ui-screenshots/ftrace_tracks_and_tab.test.ts/ftrace-tracks/ftrace-events.png.sha256
+++ b/test/data/ui-screenshots/ftrace_tracks_and_tab.test.ts/ftrace-tracks/ftrace-events.png.sha256
@@ -1 +1 @@
-ef239fe1e10e3b830f7adf9b5b8f96a52c69a50e6d879cfb5a873cd26caab769
\ No newline at end of file
+ae6623f3d00536a815ad76c40c5933845a436e6e8acee6349d0e925c06d0b6b0
\ No newline at end of file
diff --git a/ui/src/core/app_impl.ts b/ui/src/core/app_impl.ts
index 7662755..c1045e2 100644
--- a/ui/src/core/app_impl.ts
+++ b/ui/src/core/app_impl.ts
@@ -214,8 +214,8 @@
     return this.appCtx.currentTrace?.forPlugin(this.pluginId);
   }
 
-  scheduleFullRedraw(): void {
-    raf.scheduleFullRedraw();
+  scheduleFullRedraw(force?: 'force'): void {
+    raf.scheduleFullRedraw(force);
   }
 
   get httpRpc() {
diff --git a/ui/src/core/command_manager.ts b/ui/src/core/command_manager.ts
index fdf6ee6..ad0f482 100644
--- a/ui/src/core/command_manager.ts
+++ b/ui/src/core/command_manager.ts
@@ -15,6 +15,7 @@
 import {FuzzyFinder, FuzzySegment} from '../base/fuzzy';
 import {Registry} from '../base/registry';
 import {Command, CommandManager} from '../public/command';
+import {raf} from './raf_scheduler';
 
 export interface CommandWithMatchInfo extends Command {
   segments: FuzzySegment[];
@@ -39,10 +40,11 @@
     return this.registry.register(cmd);
   }
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  runCommand(id: string, ...args: any[]): any {
+  runCommand(id: string, ...args: unknown[]): unknown {
     const cmd = this.registry.get(id);
-    return cmd.callback(...args);
+    const res = cmd.callback(...args);
+    Promise.resolve(res).finally(() => raf.scheduleFullRedraw('force'));
+    return res;
   }
 
   // Returns a list of commands that match the search term, along with a list
diff --git a/ui/src/core/load_trace.ts b/ui/src/core/load_trace.ts
index c39245f..41196c7 100644
--- a/ui/src/core/load_trace.ts
+++ b/ui/src/core/load_trace.ts
@@ -147,7 +147,7 @@
       ftraceDropUntilAllCpusValid: FTRACE_DROP_UNTIL_FLAG.get(),
     });
   }
-  engine.onResponseReceived = () => raf.scheduleFullRedraw();
+  engine.onResponseReceived = () => raf.scheduleFullRedraw('force');
 
   if (isMetatracingEnabled()) {
     engine.enableMetatrace(assertExists(getEnabledMetatracingCategories()));
diff --git a/ui/src/core/raf_scheduler.ts b/ui/src/core/raf_scheduler.ts
index b23379f..2935963 100644
--- a/ui/src/core/raf_scheduler.ts
+++ b/ui/src/core/raf_scheduler.ts
@@ -13,10 +13,19 @@
 // limitations under the License.
 
 import {PerfStats} from './perf_stats';
+import m from 'mithril';
+import {featureFlags} from './feature_flags';
 
 export type AnimationCallback = (lastFrameMs: number) => void;
 export type RedrawCallback = () => void;
 
+export const AUTOREDRAW_FLAG = featureFlags.register({
+  id: 'mithrilAutoredraw',
+  name: 'Enable Mithril autoredraw',
+  description: 'Turns calls to schedulefullRedraw() a no-op',
+  defaultValue: false,
+});
+
 // This class orchestrates all RAFs in the UI. It ensures that there is only
 // one animation frame handler overall and that callbacks are called in
 // predictable order. There are two types of callbacks here:
@@ -34,12 +43,12 @@
 
   // These happen at the end of full (DOM) animation frames.
   private postRedrawCallbacks = new Array<RedrawCallback>();
-  private syncDomRedrawFn: () => void = () => {};
   private hasScheduledNextFrame = false;
   private requestedFullRedraw = false;
   private isRedrawing = false;
   private _shutdown = false;
   private recordPerfStats = false;
+  private mounts = new Map<Element, m.ComponentTypes>();
 
   readonly perfStats = {
     rafActions: new PerfStats(),
@@ -49,16 +58,23 @@
     domRedraw: new PerfStats(),
   };
 
-  // Called by frontend/index.ts. syncDomRedrawFn is a function that invokes
-  // m.render() of the root UiMain component.
-  initialize(syncDomRedrawFn: () => void) {
-    this.syncDomRedrawFn = syncDomRedrawFn;
+  constructor() {
+    // Patch m.redraw() to our RAF full redraw.
+    const origSync = m.redraw.sync;
+    const redrawFn = () => this.scheduleFullRedraw('force');
+    redrawFn.sync = origSync;
+    m.redraw = redrawFn;
+
+    m.mount = this.mount.bind(this);
   }
 
   // Schedule re-rendering of virtual DOM and canvas.
   // If a callback is passed it will be executed after the DOM redraw has
   // completed.
-  scheduleFullRedraw(cb?: RedrawCallback) {
+  scheduleFullRedraw(force?: 'force', cb?: RedrawCallback) {
+    // If we are using autoredraw mode, make this function a no-op unless
+    // 'force' is passed.
+    if (AUTOREDRAW_FLAG.get() && force !== 'force') return;
     this.requestedFullRedraw = true;
     cb && this.postRedrawCallbacks.push(cb);
     this.maybeScheduleAnimationFrame(true);
@@ -88,6 +104,16 @@
     };
   }
 
+  mount(element: Element, component: m.ComponentTypes | null): void {
+    const mounts = this.mounts;
+    if (component === null) {
+      mounts.delete(element);
+    } else {
+      mounts.set(element, component);
+    }
+    this.syncDomRedrawMountEntry(element, component);
+  }
+
   shutdown() {
     this._shutdown = true;
   }
@@ -103,12 +129,37 @@
 
   private syncDomRedraw() {
     const redrawStart = performance.now();
-    this.syncDomRedrawFn();
+
+    for (const [element, component] of this.mounts.entries()) {
+      this.syncDomRedrawMountEntry(element, component);
+    }
+
     if (this.recordPerfStats) {
       this.perfStats.domRedraw.addValue(performance.now() - redrawStart);
     }
   }
 
+  private syncDomRedrawMountEntry(
+    element: Element,
+    component: m.ComponentTypes | null,
+  ) {
+    // Mithril's render() function takes a third argument which tells us if a
+    // further redraw is needed (e.g. due to managed event handler). This allows
+    // us to implement auto-redraw. The redraw argument is documented in the
+    // official Mithril docs but is just not part of the @types/mithril package.
+    const mithrilRender = m.render as (
+      el: Element,
+      vnodes: m.Children,
+      redraw?: () => void,
+    ) => void;
+
+    mithrilRender(
+      element,
+      component !== null ? m(component) : null,
+      AUTOREDRAW_FLAG.get() ? () => raf.scheduleFullRedraw('force') : undefined,
+    );
+  }
+
   private syncCanvasRedraw() {
     const redrawStart = performance.now();
     if (this.isRedrawing) return;
diff --git a/ui/src/frontend/error_dialog.ts b/ui/src/frontend/error_dialog.ts
index 99d4157..3e27b07 100644
--- a/ui/src/frontend/error_dialog.ts
+++ b/ui/src/frontend/error_dialog.ts
@@ -242,7 +242,7 @@
       this.uploadStatus = '';
       const uploader = new GcsUploader(this.traceData, {
         onProgress: () => {
-          raf.scheduleFullRedraw();
+          raf.scheduleFullRedraw('force');
           this.uploadStatus = uploader.getEtaString();
           if (uploader.state === 'UPLOADED') {
             this.traceState = 'UPLOADED';
diff --git a/ui/src/frontend/help_modal.ts b/ui/src/frontend/help_modal.ts
index 819f271..322748e 100644
--- a/ui/src/frontend/help_modal.ts
+++ b/ui/src/frontend/help_modal.ts
@@ -54,7 +54,7 @@
     nativeKeyboardLayoutMap()
       .then((keyMap: KeyboardLayoutMap) => {
         this.keyMap = keyMap;
-        AppImpl.instance.scheduleFullRedraw();
+        AppImpl.instance.scheduleFullRedraw('force');
       })
       .catch((e) => {
         if (
@@ -69,7 +69,7 @@
           // The alternative would be to show key mappings for all keyboard
           // layouts which is not feasible.
           this.keyMap = new EnglishQwertyKeyboardLayoutMap();
-          AppImpl.instance.scheduleFullRedraw();
+          AppImpl.instance.scheduleFullRedraw('force');
         } else {
           // Something unexpected happened. Either the browser doesn't conform
           // to the keyboard API spec, or the keyboard API spec has changed!
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 4c87e4d..600f08b 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -63,7 +63,7 @@
 });
 
 function routeChange(route: Route) {
-  raf.scheduleFullRedraw(() => {
+  raf.scheduleFullRedraw('force', () => {
     if (route.fragment) {
       // This needs to happen after the next redraw call. It's not enough
       // to use setTimeout(..., 0); since that may occur before the
@@ -148,7 +148,7 @@
   });
 
   // Wire up raf for widgets.
-  setScheduleFullRedraw(() => raf.scheduleFullRedraw());
+  setScheduleFullRedraw((force?: 'force') => raf.scheduleFullRedraw(force));
 
   // Load the css. The load is asynchronous and the CSS is not ready by the time
   // appendChild returns.
@@ -225,12 +225,8 @@
   const router = new Router();
   router.onRouteChanged = routeChange;
 
-  raf.initialize(() =>
-    m.render(
-      document.body,
-      m(UiMain, pages.renderPageForCurrentRoute(AppImpl.instance.trace)),
-    ),
-  );
+  // Mount the main mithril component. This also forces a sync render pass.
+  raf.mount(document.body, UiMain);
 
   if (
     (location.origin.startsWith('http://localhost:') ||
@@ -269,12 +265,6 @@
     routeChange(route);
   });
 
-  // Force one initial render to get everything in place
-  m.render(
-    document.body,
-    m(UiMain, AppImpl.instance.pages.renderPageForCurrentRoute(undefined)),
-  );
-
   // Initialize plugins, now that we are ready to go.
   const pluginManager = AppImpl.instance.plugins;
   CORE_PLUGINS.forEach((p) => pluginManager.registerPlugin(p));
diff --git a/ui/src/frontend/omnibox.ts b/ui/src/frontend/omnibox.ts
index 5f1e29a..c94ee3f 100644
--- a/ui/src/frontend/omnibox.ts
+++ b/ui/src/frontend/omnibox.ts
@@ -328,7 +328,13 @@
     document.removeEventListener('mousedown', this.onMouseDown);
   }
 
+  // This is defined as an arrow function to have a single handler that can be
+  // added/remove while keeping `this` bound.
   private onMouseDown = (e: Event) => {
+    // We need to schedule a redraw manually as this event handler was added
+    // manually to the DOM and doesn't use Mithril's auto-redraw system.
+    raf.scheduleFullRedraw('force');
+
     // Don't close if the click was within ourselves or our popup.
     if (e.target instanceof Node) {
       if (this.popupElement && this.popupElement.contains(e.target)) {
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index 8ece2d3..9c03eb7 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -352,7 +352,9 @@
 }
 
 export class Sidebar implements m.ClassComponent<OptionalTraceImplAttrs> {
-  private _redrawWhileAnimating = new Animation(() => raf.scheduleFullRedraw());
+  private _redrawWhileAnimating = new Animation(() =>
+    raf.scheduleFullRedraw('force'),
+  );
   private _asyncJobPending = new Set<string>();
   private _sectionExpanded = new Map<string, boolean>();
 
@@ -523,7 +525,7 @@
       raf.scheduleFullRedraw();
       res.finally(() => {
         this._asyncJobPending.delete(itemId);
-        raf.scheduleFullRedraw();
+        raf.scheduleFullRedraw('force');
       });
     };
   }
diff --git a/ui/src/frontend/tab_panel.ts b/ui/src/frontend/tab_panel.ts
index 0662ef8..04aee7e 100644
--- a/ui/src/frontend/tab_panel.ts
+++ b/ui/src/frontend/tab_panel.ts
@@ -163,7 +163,7 @@
         /* onDrag */ (_x, y) => {
           const deltaYSinceDragStart = dragStartY - y;
           this.resizableHeight = heightWhenDragStarted + deltaYSinceDragStart;
-          raf.scheduleFullRedraw();
+          raf.scheduleFullRedraw('force');
         },
         /* onDragStarted */ (_x, y) => {
           this.resizableHeight = this.height;
diff --git a/ui/src/frontend/ui_main.ts b/ui/src/frontend/ui_main.ts
index c67f5b7..b8d8dda 100644
--- a/ui/src/frontend/ui_main.ts
+++ b/ui/src/frontend/ui_main.ts
@@ -52,9 +52,9 @@
 // This wrapper creates a new instance of UiMainPerTrace for each new trace
 // loaded (including the case of no trace at the beginning).
 export class UiMain implements m.ClassComponent {
-  view({children}: m.CVnode) {
+  view() {
     const currentTraceId = AppImpl.instance.trace?.engine.engineId ?? '';
-    return [m(UiMainPerTrace, {key: currentTraceId}, children)];
+    return [m(UiMainPerTrace, {key: currentTraceId})];
   }
 }
 
@@ -629,12 +629,13 @@
     this.maybeFocusOmnibar();
   }
 
-  view({children}: m.Vnode): m.Children {
+  view(): m.Children {
+    const app = AppImpl.instance;
     const hotkeys: HotkeyConfig[] = [];
-    for (const {id, defaultHotkey} of AppImpl.instance.commands.commands) {
+    for (const {id, defaultHotkey} of app.commands.commands) {
       if (defaultHotkey) {
         hotkeys.push({
-          callback: () => AppImpl.instance.commands.runCommand(id),
+          callback: () => app.commands.runCommand(id),
           hotkey: defaultHotkey,
         });
       }
@@ -650,10 +651,10 @@
           omnibox: this.renderOmnibox(),
           trace: this.trace,
         }),
-        children,
+        app.pages.renderPageForCurrentRoute(app.trace),
         m(CookieConsent),
         maybeRenderFullscreenModalDialog(),
-        AppImpl.instance.perfDebugging.renderPerfStats(),
+        app.perfDebugging.renderPerfStats(),
       ),
     );
   }
diff --git a/ui/src/plugins/dev.perfetto.Ftrace/ftrace_track.ts b/ui/src/plugins/dev.perfetto.Ftrace/ftrace_track.ts
index 31ea7de..78c59c8 100644
--- a/ui/src/plugins/dev.perfetto.Ftrace/ftrace_track.ts
+++ b/ui/src/plugins/dev.perfetto.Ftrace/ftrace_track.ts
@@ -27,11 +27,14 @@
 
 const MARGIN = 2;
 const RECT_HEIGHT = 18;
+const RECT_WIDTH = 8;
 const TRACK_HEIGHT = RECT_HEIGHT + 2 * MARGIN;
 
-export interface Data extends TrackData {
-  timestamps: BigInt64Array;
-  names: string[];
+interface Data extends TrackData {
+  events: Array<{
+    timestamp: time;
+    color: string;
+  }>;
 }
 
 export interface Config {
@@ -92,21 +95,22 @@
       order by tsQuant limit ${LIMIT};`);
 
     const rowCount = queryRes.numRows();
-    const result: Data = {
+
+    const it = queryRes.iter({tsQuant: LONG, name: STR});
+    const events = [];
+    for (let row = 0; it.valid(); it.next(), row++) {
+      events.push({
+        timestamp: Time.fromRaw(it.tsQuant),
+        color: colorForFtrace(it.name).base.cssString,
+      });
+    }
+    return {
       start,
       end,
       resolution,
       length: rowCount,
-      timestamps: new BigInt64Array(rowCount),
-      names: [],
+      events,
     };
-
-    const it = queryRes.iter({tsQuant: LONG, name: STR});
-    for (let row = 0; it.valid(); it.next(), row++) {
-      result.timestamps[row] = it.tsQuant;
-      result.names[row] = it.name;
-    }
-    return result;
   }
 
   render({ctx, size, timescale}: TrackRenderContext): void {
@@ -125,21 +129,10 @@
       dataStartPx,
       dataEndPx,
     );
-
-    const diamondSideLen = RECT_HEIGHT / Math.sqrt(2);
-
-    for (let i = 0; i < data.timestamps.length; i++) {
-      const name = data.names[i];
-      ctx.fillStyle = colorForFtrace(name).base.cssString;
-      const timestamp = Time.fromRaw(data.timestamps[i]);
-      const xPos = Math.floor(timescale.timeToPx(timestamp));
-
-      // Draw a diamond over the event
-      ctx.save();
-      ctx.translate(xPos, MARGIN);
-      ctx.rotate(Math.PI / 4);
-      ctx.fillRect(0, 0, diamondSideLen, diamondSideLen);
-      ctx.restore();
+    for (const e of data.events) {
+      ctx.fillStyle = e.color;
+      const xPos = Math.floor(timescale.timeToPx(e.timestamp));
+      ctx.fillRect(xPos - RECT_WIDTH / 2, MARGIN, RECT_WIDTH, RECT_HEIGHT);
     }
   }
 }
diff --git a/ui/src/plugins/dev.perfetto.TimelineSync/index.ts b/ui/src/plugins/dev.perfetto.TimelineSync/index.ts
index aef7a76..37206a3 100644
--- a/ui/src/plugins/dev.perfetto.TimelineSync/index.ts
+++ b/ui/src/plugins/dev.perfetto.TimelineSync/index.ts
@@ -272,6 +272,7 @@
   private onmessage(msg: MessageEvent) {
     if (this._ctx === undefined) return; // Trace unloaded
     if (!('perfettoSync' in msg.data)) return;
+    this._ctx.scheduleFullRedraw('force');
     const msgData = msg.data as SyncMessage;
     const sync = msgData.perfettoSync;
     switch (sync.cmd) {
diff --git a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
index 327a179..a5ff6d7 100644
--- a/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
+++ b/ui/src/plugins/dev.perfetto.WidgetsPage/widgets_page.ts
@@ -685,6 +685,7 @@
             icon: arg(icon, 'send'),
             rightIcon: arg(rightIcon, 'arrow_forward'),
             label: arg(label, 'Button', ''),
+            onclick: () => alert('button pressed'),
             ...rest,
           }),
         initialOpts: {
diff --git a/ui/src/public/app.ts b/ui/src/public/app.ts
index 50def57..0c8321b 100644
--- a/ui/src/public/app.ts
+++ b/ui/src/public/app.ts
@@ -54,7 +54,7 @@
 
   // TODO(primiano): this should be needed in extremely rare cases. We should
   // probably switch to mithril auto-redraw at some point.
-  scheduleFullRedraw(): void;
+  scheduleFullRedraw(force?: 'force'): void;
 
   /**
    * Navigate to a new page.
diff --git a/ui/src/widgets/editor.ts b/ui/src/widgets/editor.ts
index 58ea153..1187be0 100644
--- a/ui/src/widgets/editor.ts
+++ b/ui/src/widgets/editor.ts
@@ -21,6 +21,7 @@
 import {assertExists} from '../base/logging';
 import {DragGestureHandler} from '../base/drag_gesture_handler';
 import {DisposableStack} from '../base/disposable_stack';
+import {scheduleFullRedraw} from './raf';
 
 export interface EditorAttrs {
   // Initial state for the editor.
@@ -64,6 +65,7 @@
             text = selectedText;
           }
           onExecute(text);
+          scheduleFullRedraw('force');
           return true;
         },
       });
@@ -75,6 +77,7 @@
         view.update([tr]);
         const text = view.state.doc.toString();
         onUpdate(text);
+        scheduleFullRedraw('force');
       };
     }
 
diff --git a/ui/src/widgets/hotkey_context.ts b/ui/src/widgets/hotkey_context.ts
index f4d702a..767683e 100644
--- a/ui/src/widgets/hotkey_context.ts
+++ b/ui/src/widgets/hotkey_context.ts
@@ -14,6 +14,7 @@
 
 import m from 'mithril';
 import {checkHotkey, Hotkey} from '../base/hotkeys';
+import {scheduleFullRedraw} from './raf';
 
 export interface HotkeyConfig {
   hotkey: Hotkey;
@@ -58,6 +59,7 @@
         if (checkHotkey(hotkey, e)) {
           e.preventDefault();
           callback();
+          scheduleFullRedraw('force');
         }
       });
     }
diff --git a/ui/src/widgets/modal.ts b/ui/src/widgets/modal.ts
index e32f329..c07e6fe 100644
--- a/ui/src/widgets/modal.ts
+++ b/ui/src/widgets/modal.ts
@@ -14,8 +14,8 @@
 
 import m from 'mithril';
 import {defer} from '../base/deferred';
-import {scheduleFullRedraw} from './raf';
 import {Icon} from './icon';
+import {scheduleFullRedraw} from './raf';
 
 // This module deals with modal dialogs. Unlike most components, here we want to
 // render the DOM elements outside of the corresponding vdom tree. For instance
@@ -79,7 +79,10 @@
 export class Modal implements m.ClassComponent<ModalAttrs> {
   onbeforeremove(vnode: m.VnodeDOM<ModalAttrs>) {
     const removePromise = defer<void>();
-    vnode.dom.addEventListener('animationend', () => removePromise.resolve());
+    vnode.dom.addEventListener('animationend', () => {
+      scheduleFullRedraw('force');
+      removePromise.resolve();
+    });
     vnode.dom.classList.add('modal-fadeout');
 
     // Retuning `removePromise` will cause Mithril to defer the actual component
@@ -94,7 +97,6 @@
       // in turn will: (1) call the user's original attrs.onClose; (2) resolve
       // the promise returned by showModal().
       vnode.attrs.onClose();
-      scheduleFullRedraw();
     }
   }
 
@@ -223,7 +225,7 @@
     },
   };
   currentModal = attrs;
-  scheduleFullRedraw();
+  redrawModal();
   return returnedClosePromise;
 }
 
@@ -232,7 +234,7 @@
 // evident why a redraw is requested.
 export function redrawModal() {
   if (currentModal !== undefined) {
-    scheduleFullRedraw();
+    scheduleFullRedraw('force');
   }
 }
 
@@ -251,7 +253,7 @@
     return;
   }
   currentModal = undefined;
-  scheduleFullRedraw();
+  scheduleFullRedraw('force');
 }
 
 export function getCurrentModalKey(): string | undefined {
diff --git a/ui/src/widgets/popup.ts b/ui/src/widgets/popup.ts
index ed16695..ac8b563 100644
--- a/ui/src/widgets/popup.ts
+++ b/ui/src/widgets/popup.ts
@@ -352,13 +352,13 @@
     if (this.isOpen) {
       this.isOpen = false;
       this.onChange(this.isOpen);
-      scheduleFullRedraw();
+      scheduleFullRedraw('force');
     }
   }
 
   private togglePopup() {
     this.isOpen = !this.isOpen;
     this.onChange(this.isOpen);
-    scheduleFullRedraw();
+    scheduleFullRedraw('force');
   }
 }
diff --git a/ui/src/widgets/portal.ts b/ui/src/widgets/portal.ts
index 734fee6..91c3608 100644
--- a/ui/src/widgets/portal.ts
+++ b/ui/src/widgets/portal.ts
@@ -46,13 +46,22 @@
 export class Portal implements m.ClassComponent<PortalAttrs> {
   private portalElement?: HTMLElement;
   private containerElement?: Element;
+  private contentComponent: m.Component;
+
+  constructor({children}: m.CVnode<PortalAttrs>) {
+    // Create a temporary component that we can mount in oncreate, and unmount
+    // in onremove, but inject the new portal content (children) into it each
+    // render cycle. This is initialized here rather than in oncreate to avoid
+    // having to make it optional or use assertExists().
+    this.contentComponent = {view: () => children};
+  }
 
   view() {
     // Dummy element renders nothing but permits DOM access in lifecycle hooks.
     return m('span', {style: {display: 'none'}});
   }
 
-  oncreate({attrs, children, dom}: m.VnodeDOM<PortalAttrs, this>) {
+  oncreate({attrs, dom}: m.CVnodeDOM<PortalAttrs>) {
     const {
       onContentMount = () => {},
       onBeforeContentMount = (): MountOptions => ({}),
@@ -65,16 +74,21 @@
     container.appendChild(this.portalElement);
     this.applyPortalProps(attrs);
 
-    m.render(this.portalElement, children);
+    m.mount(this.portalElement, this.contentComponent);
 
     onContentMount(this.portalElement);
   }
 
-  onupdate({attrs, children}: m.VnodeDOM<PortalAttrs, this>) {
+  onbeforeupdate({children}: m.CVnode<PortalAttrs>) {
+    // Update the mounted content's view function to return the latest portal
+    // content passed in via children, without changing the component itself.
+    this.contentComponent.view = () => children;
+  }
+
+  onupdate({attrs}: m.CVnodeDOM<PortalAttrs>) {
     const {onContentUpdate = () => {}} = attrs;
     if (this.portalElement) {
       this.applyPortalProps(attrs);
-      m.render(this.portalElement, children);
       onContentUpdate(this.portalElement);
     }
   }
@@ -86,14 +100,14 @@
     }
   }
 
-  onremove({attrs}: m.VnodeDOM<PortalAttrs, this>) {
+  onremove({attrs}: m.CVnodeDOM<PortalAttrs>) {
     const {onContentUnmount = () => {}} = attrs;
     const container = this.containerElement ?? document.body;
     if (this.portalElement) {
       if (container.contains(this.portalElement)) {
         onContentUnmount(this.portalElement);
         // Rendering null ensures previous vnodes are removed properly.
-        m.render(this.portalElement, null);
+        m.mount(this.portalElement, null);
         container.removeChild(this.portalElement);
       }
     }
diff --git a/ui/src/widgets/raf.ts b/ui/src/widgets/raf.ts
index 20afb61..dc0d3ab 100644
--- a/ui/src/widgets/raf.ts
+++ b/ui/src/widgets/raf.ts
@@ -12,12 +12,12 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-let FULL_REDRAW_FUNCTION = () => {};
+let FULL_REDRAW_FUNCTION = (_force?: 'force') => {};
 
 export function setScheduleFullRedraw(func: () => void) {
   FULL_REDRAW_FUNCTION = func;
 }
 
-export function scheduleFullRedraw() {
-  FULL_REDRAW_FUNCTION();
+export function scheduleFullRedraw(force?: 'force') {
+  FULL_REDRAW_FUNCTION(force);
 }
diff --git a/ui/src/widgets/vega_view.ts b/ui/src/widgets/vega_view.ts
index 7cbf533..1a8cb43 100644
--- a/ui/src/widgets/vega_view.ts
+++ b/ui/src/widgets/vega_view.ts
@@ -228,7 +228,7 @@
     }
     this._status = Status.Done;
     this.pending = undefined;
-    scheduleFullRedraw();
+    scheduleFullRedraw('force');
   }
 
   private handleError(pending: Promise<vega.View>, err: unknown) {
@@ -242,7 +242,7 @@
   private setError(err: unknown) {
     this._status = Status.Error;
     this._error = getErrorMessage(err);
-    scheduleFullRedraw();
+    scheduleFullRedraw('force');
   }
 
   [Symbol.dispose]() {
diff --git a/ui/src/widgets/virtual_scroll_helper.ts b/ui/src/widgets/virtual_scroll_helper.ts
index 475c360..6172c94 100644
--- a/ui/src/widgets/virtual_scroll_helper.ts
+++ b/ui/src/widgets/virtual_scroll_helper.ts
@@ -14,6 +14,7 @@
 
 import {DisposableStack} from '../base/disposable_stack';
 import {Bounds2D, Rect2D} from '../base/geom';
+import {scheduleFullRedraw} from './raf';
 
 export interface VirtualScrollHelperOpts {
   overdrawPx: number;
@@ -46,6 +47,7 @@
       this._data.forEach((data) =>
         recalculatePuckRect(sliderElement, containerElement, data),
       );
+      scheduleFullRedraw('force');
     };
 
     containerElement.addEventListener('scroll', recalculateRects, {