Bug 1599882 - Memoize tabs selectors. r=bhackett
authorJason Laster <jlaster@mozilla.com>
Fri, 06 Dec 2019 01:53:12 +0000
changeset 567938 7cb21c0caf6315278909f44c28eb0cf1cfab3531
parent 567937 10160518ddc88716272c62894075860f62188013
child 567939 87ee29f02a8844be5123fa7f26f885b27d33f6e7
push id12493
push userffxbld-merge
push dateMon, 06 Jan 2020 15:38:57 +0000
treeherdermozilla-beta@63ae456b848d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbhackett
bugs1599882
milestone73.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1599882 - Memoize tabs selectors. r=bhackett Differential Revision: https://phabricator.services.mozilla.com/D55015
devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js
devtools/client/debugger/src/actions/pause/mapFrames.js
devtools/client/debugger/src/actions/source-actors.js
devtools/client/debugger/src/actions/sources/breakableLines.js
devtools/client/debugger/src/actions/sources/loadSourceText.js
devtools/client/debugger/src/actions/sources/newSources.js
devtools/client/debugger/src/actions/sources/select.js
devtools/client/debugger/src/actions/sources/symbols.js
devtools/client/debugger/src/actions/tabs.js
devtools/client/debugger/src/actions/tests/preview.spec.js
devtools/client/debugger/src/actions/types/SourceAction.js
devtools/client/debugger/src/actions/types/index.js
devtools/client/debugger/src/client/index.js
devtools/client/debugger/src/components/Editor/Tab.js
devtools/client/debugger/src/components/Editor/Tabs.js
devtools/client/debugger/src/reducers/sources.js
devtools/client/debugger/src/reducers/tabs.js
devtools/client/debugger/src/reducers/types.js
devtools/client/debugger/src/utils/bootstrap.js
devtools/client/debugger/src/utils/memoizableAction.js
devtools/client/debugger/src/utils/source.js
devtools/client/debugger/src/utils/tabs.js
devtools/client/debugger/src/utils/test-head.js
devtools/client/debugger/src/utils/tests/source.spec.js
devtools/client/debugger/test/mochitest/browser_dbg-tabs-without-urls.js
devtools/client/debugger/test/mochitest/helpers.js
--- a/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js
+++ b/devtools/client/debugger/src/actions/breakpoints/breakpointPositions.js
@@ -156,17 +156,17 @@ async function _setBreakpointPositions(c
     }
   } else {
     if (typeof line !== "number") {
       throw new Error("Line is required for generated sources");
     }
 
     const actorColumns = await Promise.all(
       getSourceActorsForSource(getState(), generatedSource.id).map(actor =>
-        dispatch(loadSourceActorBreakpointColumns({ id: actor.id, line }))
+        dispatch(loadSourceActorBreakpointColumns({ id: actor.id, line, cx }))
       )
     );
 
     for (const columns of actorColumns) {
       results[line] = (results[line] || []).concat(columns);
     }
   }
 
@@ -200,17 +200,17 @@ function generatedSourceActorKey(state, 
     ? getSourceActorsForSource(state, generatedSource.id).map(
         ({ actor }) => actor
       )
     : [];
   return [sourceId, ...actors].join(":");
 }
 
 export const setBreakpointPositions: MemoizedAction<
-  { cx: Context, sourceId: string, line?: number },
+  {| cx: Context, sourceId: string, line?: number |},
   ?BreakpointPositions
 > = memoizeableAction("setBreakpointPositions", {
   getValue: ({ sourceId, line }, { getState }) => {
     const positions = getBreakpointPositionsForSource(getState(), sourceId);
     if (!positions) {
       return null;
     }
 
--- a/devtools/client/debugger/src/actions/pause/mapFrames.js
+++ b/devtools/client/debugger/src/actions/pause/mapFrames.js
@@ -196,16 +196,17 @@ export function mapFrames(cx: ThreadCont
     mappedFrames = await expandFrames(mappedFrames, sourceMaps, getState);
     mappedFrames = mapDisplayNames(mappedFrames, getState);
 
     const selectedFrameId = getSelectedFrameId(
       getState(),
       cx.thread,
       mappedFrames
     );
+
     dispatch({
       type: "MAP_FRAMES",
       cx,
       thread: cx.thread,
       frames: mappedFrames,
       selectedFrameId,
     });
   };
--- a/devtools/client/debugger/src/actions/source-actors.js
+++ b/devtools/client/debugger/src/actions/source-actors.js
@@ -1,28 +1,30 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
-import type { ThunkArgs } from "./types";
 import {
   getSourceActor,
   getSourceActorBreakableLines,
   getSourceActorBreakpointColumns,
   type SourceActorId,
   type SourceActor,
 } from "../reducers/source-actors";
 import {
   memoizeableAction,
   type MemoizedAction,
 } from "../utils/memoizableAction";
 import { PROMISE } from "./utils/middleware/promise";
 
+import type { ThunkArgs } from "./types";
+import type { Context } from "../utils/context";
+
 export function insertSourceActor(item: SourceActor) {
   return insertSourceActors([item]);
 }
 export function insertSourceActors(items: Array<SourceActor>) {
   return function({ dispatch }: ThunkArgs) {
     dispatch({
       type: "INSERT_SOURCE_ACTORS",
       items,
@@ -38,17 +40,17 @@ export function removeSourceActors(items
     dispatch({
       type: "REMOVE_SOURCE_ACTORS",
       items,
     });
   };
 }
 
 export const loadSourceActorBreakpointColumns: MemoizedAction<
-  { id: SourceActorId, line: number },
+  {| id: SourceActorId, line: number, cx: Context |},
   Array<number>
 > = memoizeableAction("loadSourceActorBreakpointColumns", {
   createKey: ({ id, line }) => `${id}:${line}`,
   getValue: ({ id, line }, { getState }) =>
     getSourceActorBreakpointColumns(getState(), id, line),
   action: async ({ id, line }, { dispatch, getState, client }) => {
     await dispatch({
       type: "SET_SOURCE_ACTOR_BREAKPOINT_COLUMNS",
@@ -65,17 +67,17 @@ export const loadSourceActorBreakpointCo
 
         return positions[line] || [];
       })(),
     });
   },
 });
 
 export const loadSourceActorBreakableLines: MemoizedAction<
-  { id: SourceActorId },
+  {| id: SourceActorId, cx: Context |},
   Array<number>
 > = memoizeableAction("loadSourceActorBreakableLines", {
   createKey: args => args.id,
   getValue: ({ id }, { getState }) =>
     getSourceActorBreakableLines(getState(), id),
   action: async ({ id }, { dispatch, getState, client }) => {
     await dispatch({
       type: "SET_SOURCE_ACTOR_BREAKABLE_LINES",
--- a/devtools/client/debugger/src/actions/sources/breakableLines.js
+++ b/devtools/client/debugger/src/actions/sources/breakableLines.js
@@ -43,14 +43,14 @@ export function setBreakableLines(cx: Co
         sourceId,
         breakableLines,
       });
     } else {
       const actors = getSourceActorsForSource(getState(), sourceId);
 
       await Promise.all(
         actors.map(actor =>
-          dispatch(loadSourceActorBreakableLines({ id: actor.id }))
+          dispatch(loadSourceActorBreakableLines({ id: actor.id, cx }))
         )
       );
     }
   };
 }
--- a/devtools/client/debugger/src/actions/sources/loadSourceText.js
+++ b/devtools/client/debugger/src/actions/sources/loadSourceText.js
@@ -141,17 +141,17 @@ async function loadSourceTextPromise(
 export function loadSourceById(cx: Context, sourceId: string) {
   return ({ getState, dispatch }: ThunkArgs) => {
     const source = getSourceFromId(getState(), sourceId);
     return dispatch(loadSourceText({ cx, source }));
   };
 }
 
 export const loadSourceText: MemoizedAction<
-  { cx: Context, source: Source },
+  {| cx: Context, source: Source |},
   ?Source
 > = memoizeableAction("loadSourceText", {
   getValue: ({ source }, { getState }) => {
     source = source ? getSource(getState(), source.id) : null;
     if (!source) {
       return null;
     }
 
--- a/devtools/client/debugger/src/actions/sources/newSources.js
+++ b/devtools/client/debugger/src/actions/sources/newSources.js
@@ -202,16 +202,17 @@ function checkPendingBreakpoints(cx: Con
     );
 
     if (pendingBreakpoints.length === 0) {
       return;
     }
 
     // load the source text if there is a pending breakpoint for it
     await dispatch(loadSourceText({ cx, source }));
+
     await dispatch(setBreakableLines(cx, source.id));
 
     await Promise.all(
       pendingBreakpoints.map(bp => {
         return dispatch(syncBreakpoint(cx, sourceId, bp));
       })
     );
   };
--- a/devtools/client/debugger/src/actions/sources/select.js
+++ b/devtools/client/debugger/src/actions/sources/select.js
@@ -7,17 +7,17 @@
 /**
  * Redux actions for the sources state
  * @module actions/sources
  */
 
 import { isOriginalId } from "devtools-source-map";
 
 import { getSourceFromId, getSourceWithContent } from "../../reducers/sources";
-import { getSourcesForTabs } from "../../reducers/tabs";
+import { tabExists } from "../../reducers/tabs";
 import { setSymbols } from "./symbols";
 import { setInScopeLines } from "../ast";
 import { closeActiveSearch, updateActiveFileSearch } from "../ui";
 import { togglePrettyPrint } from "./prettyPrint";
 import { addTab, closeTab } from "../tabs";
 import { loadSourceText } from "./loadSourceText";
 import { setBreakableLines } from ".";
 
@@ -151,32 +151,32 @@ export function selectLocation(
       keepContext &&
       selectedSource &&
       isOriginalId(selectedSource.id) != isOriginalId(location.sourceId)
     ) {
       location = await mapLocation(getState(), sourceMaps, location);
       source = getSourceFromId(getState(), location.sourceId);
     }
 
-    const tabSources = getSourcesForTabs(getState());
-    if (!tabSources.includes(source)) {
+    if (tabExists(getState(), source.id)) {
       dispatch(addTab(source));
     }
 
     dispatch(setSelectedLocation(cx, source, location));
 
     await dispatch(loadSourceText({ cx, source }));
     await dispatch(setBreakableLines(cx, source.id));
 
     const loadedSource = getSource(getState(), source.id);
 
     if (!loadedSource) {
       // If there was a navigation while we were loading the loadedSource
       return;
     }
+
     const sourceWithContent = getSourceWithContent(getState(), source.id);
 
     if (
       keepContext &&
       prefs.autoPrettyPrint &&
       !getPrettySource(getState(), loadedSource.id) &&
       canPrettyPrintSource(getState(), loadedSource.id) &&
       isMinified(sourceWithContent)
--- a/devtools/client/debugger/src/actions/sources/symbols.js
+++ b/devtools/client/debugger/src/actions/sources/symbols.js
@@ -32,29 +32,27 @@ async function doSetSymbols(cx, source, 
   });
 
   const symbols = getSymbols(getState(), source);
   if (symbols && symbols.framework) {
     dispatch(updateTab(source, symbols.framework));
   }
 }
 
-type Args = { cx: Context, source: Source };
-
-export const setSymbols: MemoizedAction<Args, ?Symbols> = memoizeableAction(
-  "setSymbols",
-  {
-    getValue: ({ source }, { getState }) => {
-      if (source.isWasm) {
-        return fulfilled(null);
-      }
+export const setSymbols: MemoizedAction<
+  {| cx: Context, source: Source |},
+  ?Symbols
+> = memoizeableAction("setSymbols", {
+  getValue: ({ source }, { getState }) => {
+    if (source.isWasm) {
+      return fulfilled(null);
+    }
 
-      const symbols = getSymbols(getState(), source);
-      if (!symbols || symbols.loading) {
-        return null;
-      }
+    const symbols = getSymbols(getState(), source);
+    if (!symbols || symbols.loading) {
+      return null;
+    }
 
-      return fulfilled(symbols);
-    },
-    createKey: ({ source }) => source.id,
-    action: ({ cx, source }, thunkArgs) => doSetSymbols(cx, source, thunkArgs),
-  }
-);
+    return fulfilled(symbols);
+  },
+  createKey: ({ source }) => source.id,
+  action: ({ cx, source }, thunkArgs) => doSetSymbols(cx, source, thunkArgs),
+});
--- a/devtools/client/debugger/src/actions/tabs.js
+++ b/devtools/client/debugger/src/actions/tabs.js
@@ -10,21 +10,19 @@
  */
 
 import { isOriginalId } from "devtools-source-map";
 
 import { removeDocument } from "../utils/editor";
 import { selectSource } from "./sources";
 
 import {
+  getSourceByURL,
   getSourceTabs,
-  getSourceByURL,
   getNewSelectedSourceId,
-  removeSourceFromTabList,
-  removeSourcesFromTabList,
 } from "../selectors";
 
 import type { Action, ThunkArgs } from "./types";
 import type { Source, Context } from "../types";
 
 export function updateTab(source: Source, framework: string): Action {
   const { url, id: sourceId } = source;
   const isOriginal = isOriginalId(source.id);
@@ -59,37 +57,36 @@ export function moveTab(url: string, tab
 }
 
 /**
  * @memberof actions/tabs
  * @static
  */
 export function closeTab(cx: Context, source: Source) {
   return ({ dispatch, getState, client }: ThunkArgs) => {
-    const { id, url } = source;
-
-    removeDocument(id);
+    removeDocument(source.id);
 
-    const tabs = removeSourceFromTabList(getSourceTabs(getState()), source);
+    const tabs = getSourceTabs(getState());
+    dispatch(({ type: "CLOSE_TAB", source }: Action));
+
     const sourceId = getNewSelectedSourceId(getState(), tabs);
-    dispatch(({ type: "CLOSE_TAB", url, tabs }: Action));
     dispatch(selectSource(cx, sourceId));
   };
 }
 
 /**
  * @memberof actions/tabs
  * @static
  */
 export function closeTabs(cx: Context, urls: string[]) {
   return ({ dispatch, getState, client }: ThunkArgs) => {
     const sources = urls
       .map(url => getSourceByURL(getState(), url))
       .filter(Boolean);
+
+    const tabs = getSourceTabs(getState());
     sources.map(source => removeDocument(source.id));
-
-    const tabs = removeSourcesFromTabList(getSourceTabs(getState()), sources);
-    dispatch(({ type: "CLOSE_TABS", sources, tabs }: Action));
+    dispatch(({ type: "CLOSE_TABS", sources }: Action));
 
     const sourceId = getNewSelectedSourceId(getState(), tabs);
     dispatch(selectSource(cx, sourceId));
   };
 }
--- a/devtools/client/debugger/src/actions/tests/preview.spec.js
+++ b/devtools/client/debugger/src/actions/tests/preview.spec.js
@@ -51,49 +51,54 @@ function dispatchSetPreview(dispatch, co
       },
       { line: 2, column: 3 },
       target.getBoundingClientRect(),
       target
     )
   );
 }
 
-async function pause({ dispatch, cx }, client) {
+async function pause(store, client) {
+  const { dispatch, cx } = store;
   const base = await dispatch(
     actions.newGeneratedSource(makeSource("base.js"))
   );
 
   await dispatch(actions.selectSource(cx, base.id));
-  const frames = [makeFrame({ id: "frame1", sourceId: base.id })];
+  await waitForState(store, state => selectors.hasSymbols(state, base));
+
+  const thread = cx.thread;
+  const frames = [makeFrame({ id: "frame1", sourceId: base.id, thread })];
   client.getFrames = async () => frames;
 
   await dispatch(
     actions.paused({
-      thread: "FakeThread",
+      thread,
       frame: frames[0],
       loadedObjects: [],
       why: { type: "debuggerStatement" },
     })
   );
 }
 
 describe("preview", () => {
   it("should generate previews", async () => {
     const store = createStore(mockThreadFront());
     const { dispatch, getState, cx } = store;
     const base = await dispatch(
       actions.newGeneratedSource(makeSource("base.js"))
     );
 
     await dispatch(actions.selectSource(cx, base.id));
+    await waitForState(store, state => selectors.hasSymbols(state, base));
     const frames = [makeFrame({ id: "f1", sourceId: base.id })];
 
     await dispatch(
       actions.paused({
-        thread: "FakeThread",
+        thread: store.cx.thread,
         frame: frames[0],
         frames,
         loadedObjects: [],
         why: { type: "debuggerStatement" },
       })
     );
 
     const newCx = selectors.getContext(getState());
@@ -158,27 +163,27 @@ describe("preview", () => {
     const client = mockThreadFront({
       loadObjectProperties: () => promises.shift().promise,
     });
     const store = createStore(client);
 
     const { dispatch, getState } = store;
     await pause(store, client);
 
-    const newCx = selectors.getContext(getState());
+    const cx = selectors.getThreadContext(getState());
     const firstTarget = document.createElement("div");
     const secondTarget = document.createElement("div");
 
     // Start the dispatch of the first setPreview. At this point, it will not
     // finish execution until we resolve the firstSetPreview
-    dispatchSetPreview(dispatch, newCx, "firstSetPreview", firstTarget);
+    dispatchSetPreview(dispatch, cx, "firstSetPreview", firstTarget);
 
     // Start the dispatch of the second setPreview. At this point, it will not
     // finish execution until we resolve the secondSetPreview
-    dispatchSetPreview(dispatch, newCx, "secondSetPreview", secondTarget);
+    dispatchSetPreview(dispatch, cx, "secondSetPreview", secondTarget);
 
     let fail = false;
 
     secondSetPreview.resolve();
     await waitForPreview(store, "secondSetPreview");
 
     firstSetPreview.resolve();
     waitForPreview(store, "firstSetPreview").then(() => {
--- a/devtools/client/debugger/src/actions/types/SourceAction.js
+++ b/devtools/client/debugger/src/actions/types/SourceAction.js
@@ -58,22 +58,20 @@ export type SourceAction =
     >
   | {|
       +type: "MOVE_TAB",
       +url: string,
       +tabIndex: number,
     |}
   | {|
       +type: "CLOSE_TAB",
-      +url: string,
-      +tabs: any,
+      +source: Source,
     |}
   | {|
       +type: "CLOSE_TABS",
       +sources: Array<Source>,
-      +tabs: any,
     |}
   | {|
       type: "SET_ORIGINAL_BREAKABLE_LINES",
       +cx: Context,
       breakableLines: number[],
       sourceId: string,
     |};
--- a/devtools/client/debugger/src/actions/types/index.js
+++ b/devtools/client/debugger/src/actions/types/index.js
@@ -64,25 +64,25 @@ type ProjectTextSearchResult = {
   matches: MatchedLocations[],
 };
 
 type AddTabAction = {|
   +type: "ADD_TAB",
   +url: string,
   +framework?: string,
   +isOriginal?: boolean,
-  +sourceId?: string,
+  +sourceId: string,
 |};
 
 type UpdateTabAction = {|
   +type: "UPDATE_TAB",
   +url: string,
   +framework?: string,
   +isOriginal?: boolean,
-  +sourceId?: string,
+  +sourceId: string,
 |};
 
 type NavigateAction =
   | {|
       +type: "CONNECT",
       +mainThread: Thread,
       +traits: Object,
       +isWebExtension: boolean,
--- a/devtools/client/debugger/src/client/index.js
+++ b/devtools/client/debugger/src/client/index.js
@@ -36,20 +36,19 @@ function syncXHRBreakpoints() {
         firefox.clientCommands.setXHRBreakpoint(path, method);
       }
     });
   });
 }
 
 async function loadInitialState() {
   const pendingBreakpoints = await asyncStore.pendingBreakpoints;
-  const tabs = await asyncStore.tabs;
+  const tabs = { tabs: await asyncStore.tabs };
   const xhrBreakpoints = await asyncStore.xhrBreakpoints;
   const eventListenerBreakpoints = await asyncStore.eventListenerBreakpoints;
-
   const breakpoints = initialBreakpointsState(xhrBreakpoints);
 
   return {
     pendingBreakpoints,
     tabs,
     breakpoints,
     eventListenerBreakpoints,
   };
--- a/devtools/client/debugger/src/components/Editor/Tab.js
+++ b/devtools/client/debugger/src/components/Editor/Tab.js
@@ -9,16 +9,17 @@ import { connect } from "../../utils/con
 
 import { showMenu, buildMenu } from "devtools-contextmenu";
 
 import SourceIcon from "../shared/SourceIcon";
 import { CloseButton } from "../shared/Button";
 import { copyToTheClipboard } from "../../utils/clipboard";
 
 import type { Source, Context } from "../../types";
+import type { TabsSources } from "../../reducers/types";
 
 import actions from "../../actions";
 
 import {
   getDisplayPath,
   getFileURL,
   getRawSourceURL,
   getSourceQueryString,
@@ -39,17 +40,17 @@ import type { ActiveSearchType } from ".
 
 import classnames from "classnames";
 
 type OwnProps = {|
   source: Source,
 |};
 type Props = {
   cx: Context,
-  tabSources: Source[],
+  tabSources: TabsSources,
   selectedSource: ?Source,
   source: Source,
   activeSearch: ?ActiveSearchType,
   hasSiblingOfSameName: boolean,
   selectSource: typeof actions.selectSource,
   closeTab: typeof actions.closeTab,
   closeTabs: typeof actions.closeTabs,
   copyToClipboard: typeof actions.copyToClipboard,
--- a/devtools/client/debugger/src/components/Editor/Tabs.js
+++ b/devtools/client/debugger/src/components/Editor/Tabs.js
@@ -25,47 +25,46 @@ import "./Tabs.css";
 
 import Tab from "./Tab";
 import { PaneToggleButton } from "../shared/Button";
 import Dropdown from "../shared/Dropdown";
 import AccessibleImage from "../shared/AccessibleImage";
 import CommandBar from "../SecondaryPanes/CommandBar";
 
 import type { Source, Context } from "../../types";
-
-type SourcesList = Source[];
+import type { TabsSources } from "../../reducers/types";
 
 type OwnProps = {|
   horizontal: boolean,
   startPanelCollapsed: boolean,
   endPanelCollapsed: boolean,
 |};
 type Props = {
   cx: Context,
-  tabSources: SourcesList,
+  tabSources: TabsSources,
   selectedSource: ?Source,
   horizontal: boolean,
   startPanelCollapsed: boolean,
   endPanelCollapsed: boolean,
   moveTab: typeof actions.moveTab,
   closeTab: typeof actions.closeTab,
   togglePaneCollapse: typeof actions.togglePaneCollapse,
   showSource: typeof actions.showSource,
   selectSource: typeof actions.selectSource,
   isPaused: boolean,
 };
 
 type State = {
   dropdownShown: boolean,
-  hiddenTabs: SourcesList,
+  hiddenTabs: TabsSources,
 };
 
 function haveTabSourcesChanged(
-  tabSources: SourcesList,
-  prevTabSources: SourcesList
+  tabSources: TabsSources,
+  prevTabSources: TabsSources
 ): boolean {
   if (tabSources.length !== prevTabSources.length) {
     return true;
   }
 
   for (let i = 0; i < tabSources.length; ++i) {
     if (tabSources[i].id !== prevTabSources[i].id) {
       return true;
--- a/devtools/client/debugger/src/reducers/sources.js
+++ b/devtools/client/debugger/src/reducers/sources.js
@@ -257,17 +257,17 @@ function update(
 
     case "SET_FOCUSED_SOURCE_ITEM":
       return { ...state, focusedItem: action.item };
   }
 
   return state;
 }
 
-const resourceAsSourceBase = memoizeResourceShallow(
+export const resourceAsSourceBase = memoizeResourceShallow(
   ({ content, ...source }: SourceResource): SourceBase => source
 );
 
 const resourceAsSourceWithContent = memoizeResourceShallow(
   ({ content, ...source }: SourceResource): SourceWithContent => ({
     ...source,
     content: asSettled(content),
   })
--- a/devtools/client/debugger/src/reducers/tabs.js
+++ b/devtools/client/debugger/src/reducers/tabs.js
@@ -8,134 +8,107 @@
  * Tabs reducer
  * @module reducers/tabs
  */
 
 import { createSelector } from "reselect";
 import { isOriginalId } from "devtools-source-map";
 import move from "lodash-move";
 
-import { asyncStore } from "../utils/prefs";
+import { isSimilarTab, persistTabs } from "../utils/tabs";
+import { makeShallowQuery } from "../utils/resource";
+
 import {
   getSource,
+  getSpecificSourceByURL,
   getSources,
-  getSourceInSources,
-  getUrls,
-  getSpecificSourceByURL,
-  getSpecificSourceByURLInSources,
+  resourceAsSourceBase,
 } from "./sources";
 
 import type { Action } from "../actions/types";
-import type { SourcesState } from "./sources";
-import type { Source } from "../types";
-import type { Selector } from "./types";
+import type { Selector, State } from "./types";
+import type { SourceBase } from "./sources";
 
-export type Tab = {
+export type PersistedTab = {|
   url: string,
   framework?: string | null,
   isOriginal: boolean,
-  sourceId?: string,
-};
+  sourceId: null,
+|};
+
+export type VisibleTab = {| ...Tab, sourceId: string |};
+
+export type Tab = PersistedTab | VisibleTab;
+
 export type TabList = Tab[];
 
-function isSimilarTab(tab: Tab, url: string, isOriginal: boolean) {
-  return tab.url === url && tab.isOriginal === isOriginal;
+export type TabsSources = $ReadOnlyArray<SourceBase>;
+
+export type TabsState = {
+  tabs: TabList,
+};
+
+function initialTabState() {
+  return { tabs: [] };
 }
 
-function update(state: TabList = [], action: Action): TabList {
+function resetTabState(state) {
+  const tabs = persistTabs(state.tabs);
+  return { tabs };
+}
+
+function update(
+  state: TabsState = initialTabState(),
+  action: Action
+): TabsState {
   switch (action.type) {
     case "ADD_TAB":
     case "UPDATE_TAB":
       return updateTabList(state, action);
 
     case "MOVE_TAB":
       return moveTabInList(state, action);
 
     case "CLOSE_TAB":
+      return removeSourceFromTabList(state, action);
+
     case "CLOSE_TABS":
-      asyncStore.tabs = action.tabs;
-      return action.tabs;
+      return removeSourcesFromTabList(state, action);
+
+    case "ADD_SOURCE":
+      return addVisibleTabs(state, [action.source]);
+
+    case "ADD_SOURCES":
+      return addVisibleTabs(state, action.sources);
+
+    case "SET_SELECTED_LOCATION": {
+      return addSelectedSource(state, action.source);
+    }
+
+    case "NAVIGATE": {
+      return resetTabState(state);
+    }
 
     default:
       return state;
   }
 }
 
-export function removeSourceFromTabList(
-  tabs: TabList,
-  source: Source
-): TabList {
-  return tabs.filter(
-    tab => tab.url !== source.url || tab.isOriginal != isOriginalId(source.id)
-  );
-}
-
-export function removeSourcesFromTabList(tabs: TabList, sources: Source[]) {
-  return sources.reduce(
-    (t, source) => removeSourceFromTabList(t, source),
-    tabs
-  );
-}
-
-/**
- * Adds the new source to the tab list if it is not already there
- * @memberof reducers/tabs
- * @static
- */
-function updateTabList(
-  tabs: TabList,
-  { url, framework = null, sourceId, isOriginal = false }
-) {
-  // Set currentIndex to -1 for URL-less tabs so that they aren't
-  // filtered by isSimilarTab
-  const currentIndex = url
-    ? tabs.findIndex(tab => isSimilarTab(tab, url, isOriginal))
-    : -1;
-
-  if (currentIndex === -1) {
-    tabs = [{ url, framework, sourceId, isOriginal }, ...tabs];
-  } else if (framework) {
-    tabs[currentIndex].framework = framework;
-  }
-
-  asyncStore.tabs = persistTabs(tabs);
-  return tabs;
-}
-
-function persistTabs(tabs) {
-  return tabs
-    .filter(tab => tab.url)
-    .map(tab => {
-      const newTab = { ...tab };
-      delete newTab.sourceId;
-      return newTab;
-    });
-}
-
-function moveTabInList(tabs: TabList, { url, tabIndex: newIndex }) {
-  const currentIndex = tabs.findIndex(tab => tab.url == url);
-  tabs = move(tabs, currentIndex, newIndex);
-  asyncStore.tabs = tabs;
-  return tabs;
-}
-
 /**
  * Gets the next tab to select when a tab closes. Heuristics:
  * 1. if the selected tab is available, it remains selected
  * 2. if it is gone, the next available tab to the left should be active
  * 3. if the first tab is active and closed, select the second tab
  *
  * @memberof reducers/tabs
  * @static
  */
-export function getNewSelectedSourceId(
-  state: OuterState,
-  availableTabs: TabList
-): string {
+export function getNewSelectedSourceId(state: State, tabList: TabList): string {
   const selectedLocation = state.sources.selectedLocation;
+  const availableTabs = state.tabs.tabs;
   if (!selectedLocation) {
     return "";
   }
 
   const selectedTab = getSource(state, selectedLocation.sourceId);
   if (!selectedTab) {
     return "";
   }
@@ -158,17 +131,17 @@ export function getNewSelectedSourceId(
 
     if (selectedSource) {
       return selectedSource.id;
     }
 
     return "";
   }
 
-  const tabUrls = state.tabs.map(t => t.url);
+  const tabUrls = tabList.map(t => t.url);
   const leftNeighborIndex = Math.max(tabUrls.indexOf(selectedTab.url) - 1, 0);
   const lastAvailbleTabIndex = availableTabs.length - 1;
   const newSelectedTabIndex = Math.min(leftNeighborIndex, lastAvailbleTabIndex);
   const availableTab = availableTabs[newSelectedTabIndex];
 
   if (availableTab) {
     const tabSource = getSpecificSourceByURL(
       state,
@@ -179,51 +152,135 @@ export function getNewSelectedSourceId(
     if (tabSource) {
       return tabSource.id;
     }
   }
 
   return "";
 }
 
+function matchesSource(tab: VisibleTab, source) {
+  return tab.sourceId === source.id || matchesUrl(tab, source);
+}
+
+function matchesUrl(tab: Tab, source) {
+  return tab.url === source.url && tab.isOriginal == isOriginalId(source.id);
+}
+
+function addSelectedSource(state: TabsState, source) {
+  if (
+    state.tabs
+      .filter(({ sourceId }) => sourceId)
+      .map(({ sourceId }) => sourceId)
+      .includes(source.id)
+  ) {
+    return state;
+  }
+
+  const isOriginal = isOriginalId(source.id);
+  return updateTabList(state, {
+    url: source.url,
+    isOriginal,
+    framework: null,
+    sourceId: source.id,
+  });
+}
+
+function addVisibleTabs(state: TabsState, sources) {
+  const tabCount = state.tabs.filter(({ sourceId }) => sourceId).length;
+  const tabs = state.tabs
+    .map(tab => {
+      const source = sources.find(src => matchesUrl(tab, src));
+      if (!source) {
+        return tab;
+      }
+      return { ...tab, sourceId: source.id };
+    })
+    .filter(tab => tab.sourceId);
+
+  if (tabs.length == tabCount) {
+    return state;
+  }
+
+  return { tabs };
+}
+
+function removeSourceFromTabList(state: TabsState, { source }): TabsState {
+  const { tabs } = state;
+  const newTabs = tabs.filter(tab => !matchesSource(tab, source));
+  return { tabs: newTabs };
+}
+
+function removeSourcesFromTabList(state: TabsState, { sources }) {
+  const { tabs } = state;
+
+  const newTabs = sources.reduce(
+    (tabList, source) => tabList.filter(tab => !matchesSource(tab, source)),
+    tabs
+  );
+
+  return { tabs: newTabs };
+}
+
+/**
+ * Adds the new source to the tab list if it is not already there
+ * @memberof reducers/tabs
+ * @static
+ */
+function updateTabList(
+  state: TabsState,
+  { url, framework = null, sourceId, isOriginal = false }
+) {
+  let { tabs } = state;
+  // Set currentIndex to -1 for URL-less tabs so that they aren't
+  // filtered by isSimilarTab
+  const currentIndex = url
+    ? tabs.findIndex(tab => isSimilarTab(tab, url, isOriginal))
+    : -1;
+
+  if (currentIndex === -1) {
+    const newTab = {
+      url,
+      framework,
+      sourceId,
+      isOriginal,
+    };
+    tabs = [newTab, ...tabs];
+  } else if (framework) {
+    tabs[currentIndex].framework = framework;
+  }
+
+  return { ...state, tabs };
+}
+
+function moveTabInList(state: TabsState, { url, tabIndex: newIndex }) {
+  let { tabs } = state;
+  const currentIndex = tabs.findIndex(tab => tab.url == url);
+  tabs = move(tabs, currentIndex, newIndex);
+  return { tabs };
+}
+
 // Selectors
 
-// Unfortunately, it's really hard to make these functions accept just
-// the state that we care about and still type it with Flow. The
-// problem is that we want to re-export all selectors from a single
-// module for the UI, and all of those selectors should take the
-// top-level app state, so we'd have to "wrap" them to automatically
-// pick off the piece of state we're interested in. It's impossible
-// (right now) to type those wrapped functions.
-type OuterState = { tabs: TabList, sources: SourcesState };
+export const getTabs = (state: State): TabList => state.tabs.tabs;
 
-export const getTabs = (state: OuterState): TabList => state.tabs;
-
-export const getSourceTabs: Selector<Tab[]> = createSelector(
-  getTabs,
-  getSources,
-  getUrls,
-  (tabs, sources, urls) =>
-    tabs.filter(tab => getTabWithOrWithoutUrl(tab, sources, urls))
+export const getSourceTabs: Selector<VisibleTab[]> = createSelector(
+  state => state.tabs,
+  ({ tabs }) => tabs.filter(tab => tab.sourceId)
 );
 
-export const getSourcesForTabs: Selector<Source[]> = createSelector(
-  getSourceTabs,
-  getSources,
-  getUrls,
-  (tabs, sources, urls) =>
-    tabs.map(tab => getTabWithOrWithoutUrl(tab, sources, urls)).filter(Boolean)
-);
+export const getSourcesForTabs: Selector<TabsSources> = state => {
+  const tabs = getSourceTabs(state);
+  const sources = getSources(state);
+  return querySourcesForTabs(sources, tabs);
+};
 
-function getTabWithOrWithoutUrl(tab, sources, urls) {
-  if (tab.url) {
-    return getSpecificSourceByURLInSources(
-      sources,
-      urls,
-      tab.url,
-      tab.isOriginal
-    );
-  }
+const querySourcesForTabs = makeShallowQuery({
+  filter: (_, tabs) => tabs.map(({ sourceId }) => sourceId),
+  map: resourceAsSourceBase,
+  reduce: items => items,
+});
 
-  return tab.sourceId ? getSourceInSources(sources, tab.sourceId) : null;
+export function tabExists(state: State, sourceId: string) {
+  return !!getSourceTabs(state).find(tab => tab.sourceId == sourceId);
 }
 
 export default update;
--- a/devtools/client/debugger/src/reducers/types.js
+++ b/devtools/client/debugger/src/reducers/types.js
@@ -15,17 +15,17 @@ import type { ExpressionState } from "./
 import type { ThreadsState } from "./threads";
 import type { FileSearchState } from "./file-search";
 import type { PauseState } from "./pause";
 import type { PreviewState } from "./preview";
 import type { PendingBreakpointsState } from "../selectors";
 import type { ProjectTextSearchState } from "./project-text-search";
 import type { SourcesState } from "./sources";
 import type { SourceActorsState } from "./source-actors";
-import type { TabList } from "./tabs";
+import type { TabsState } from "./tabs";
 import type { UIState } from "./ui";
 import type { QuickOpenState } from "./quick-open";
 import type { EventListenersState } from "./event-listeners";
 
 export type State = {
   ast: ASTState,
   breakpoints: BreakpointsState,
   expressions: ExpressionState,
@@ -33,17 +33,17 @@ export type State = {
   threads: ThreadsState,
   fileSearch: FileSearchState,
   pause: PauseState,
   preview: PreviewState,
   pendingBreakpoints: PendingBreakpointsState,
   projectTextSearch: ProjectTextSearchState,
   sources: SourcesState,
   sourceActors: SourceActorsState,
-  tabs: TabList,
+  tabs: TabsState,
   ui: UIState,
   quickOpen: QuickOpenState,
 };
 
 export type Selector<T> = State => T;
 
 export type PendingSelectedLocation = {
   url: string,
@@ -56,8 +56,9 @@ export type {
   SourcesMapByThread,
   SourceResourceState,
 } from "./sources";
 export type { ActiveSearchType, OrientationType } from "./ui";
 export type { BreakpointsMap, XHRBreakpointsList } from "./breakpoints";
 export type { Command } from "./pause";
 export type { LoadedSymbols, Symbols } from "./ast";
 export type { Preview } from "./preview";
+export type { Tab, TabList, TabsSources } from "./tabs";
--- a/devtools/client/debugger/src/utils/bootstrap.js
+++ b/devtools/client/debugger/src/utils/bootstrap.js
@@ -21,16 +21,17 @@ import * as search from "../workers/sear
 import * as prettyPrint from "../workers/pretty-print";
 import { ParserDispatcher } from "../workers/parser";
 
 import configureStore from "../actions/utils/create-store";
 import reducers from "../reducers";
 import * as selectors from "../selectors";
 import App from "../components/App";
 import { asyncStore, prefs } from "./prefs";
+import { persistTabs } from "../utils/tabs";
 
 import type { Panel } from "../client/firefox/types";
 
 let parser;
 
 function renderPanel(component, store, panel: Panel) {
   const root = document.createElement("div");
   root.className = "launchpad-root theme-body";
@@ -125,34 +126,42 @@ export function bootstrapApp(store: any,
     const { renderRoot } = require("devtools-launchpad");
     renderRoot(React, ReactDOM, App, store);
   }
 }
 
 let currentPendingBreakpoints;
 let currentXHRBreakpoints;
 let currentEventBreakpoints;
+let currentTabs;
+
 function updatePrefs(state: any) {
   const previousPendingBreakpoints = currentPendingBreakpoints;
   const previousXHRBreakpoints = currentXHRBreakpoints;
   const previousEventBreakpoints = currentEventBreakpoints;
+  const previousTabs = currentTabs;
   currentPendingBreakpoints = selectors.getPendingBreakpoints(state);
   currentXHRBreakpoints = selectors.getXHRBreakpoints(state);
   currentEventBreakpoints = state.eventListenerBreakpoints;
+  currentTabs = selectors.getTabs(state);
 
   if (
     previousPendingBreakpoints &&
     currentPendingBreakpoints !== previousPendingBreakpoints
   ) {
     asyncStore.pendingBreakpoints = currentPendingBreakpoints;
   }
 
   if (
     previousEventBreakpoints &&
     previousEventBreakpoints !== currentEventBreakpoints
   ) {
     asyncStore.eventListenerBreakpoints = currentEventBreakpoints;
   }
 
+  if (previousTabs && previousTabs !== currentTabs) {
+    asyncStore.tabs = persistTabs(currentTabs);
+  }
+
   if (currentXHRBreakpoints !== previousXHRBreakpoints) {
     asyncStore.xhrBreakpoints = currentXHRBreakpoints;
   }
 }
--- a/devtools/client/debugger/src/utils/memoizableAction.js
+++ b/devtools/client/debugger/src/utils/memoizableAction.js
@@ -1,21 +1,26 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
 import type { ThunkArgs } from "../actions/types";
 import { asSettled, type AsyncValue } from "./async-value";
+import { validateContext } from "./context";
+import type { Context } from "./context";
+
+type ArgsWithContext = { cx: Context };
 
 export type MemoizedAction<
   Args,
   Result
 > = Args => ThunkArgs => Promise<Result | null>;
+
 type MemoizableActionParams<Args, Result> = {
   getValue: (args: Args, thunkArgs: ThunkArgs) => AsyncValue<Result> | null,
   createKey: (args: Args, thunkArgs: ThunkArgs) => string,
   action: (args: Args, thunkArgs: ThunkArgs) => Promise<mixed>,
 };
 
 /*
  * memoizableActon is a utility for actions that should only be performed
@@ -34,17 +39,17 @@ type MemoizableActionParams<Args, Result
  *     hasValue: ({ a }, { getState }) => hasItem(getState(), a),
  *     getValue: ({ a }, { getState }) => getItem(getState(), a),
  *     createKey: ({ a }) => a,
  *     action: ({ a }, thunkArgs) => doSetItem(a, thunkArgs)
  *   }
  * );
  *
  */
-export function memoizeableAction<Args, Result>(
+export function memoizeableAction<Args: ArgsWithContext, Result>(
   name: string,
   { getValue, createKey, action }: MemoizableActionParams<Args, Result>
 ): MemoizedAction<Args, Result> {
   const requests = new Map();
   return args => async thunkArgs => {
     let result = asSettled(getValue(args, thunkArgs));
     if (!result) {
       const key = createKey(args, thunkArgs);
@@ -60,18 +65,21 @@ export function memoizeableAction<Args, 
               requests.delete(key);
             }
           })()
         );
       }
 
       await requests.get(key);
 
+      if (args.cx) {
+        validateContext(thunkArgs.getState(), args.cx);
+      }
+
       result = asSettled(getValue(args, thunkArgs));
-
       if (!result) {
         // Returning null here is not ideal. This means that the action
         // resolved but 'getValue' didn't return a loaded value, for instance
         // if the data the action was meant to store was deleted. In a perfect
         // world we'd throw a ContextError here or handle cancellation somehow.
         // Throwing will also allow us to change the return type on the action
         // to always return a promise for the getValue AsyncValue type, but
         // for now we have to add an additional '| null' for this case.
--- a/devtools/client/debugger/src/utils/source.js
+++ b/devtools/client/debugger/src/utils/source.js
@@ -25,18 +25,19 @@ import { features } from "./prefs";
 import type {
   SourceId,
   Source,
   SourceActor,
   SourceContent,
   SourceLocation,
   ThreadId,
 } from "../types";
+
 import { isFulfilled, type AsyncValue } from "./async-value";
-import type { Symbols } from "../reducers/types";
+import type { Symbols, TabsSources } from "../reducers/types";
 
 type transformUrlCallback = string => string;
 
 export const sourceTypes = {
   coffee: "coffeescript",
   js: "javascript",
   jsx: "react",
   ts: "typescript",
@@ -194,17 +195,20 @@ export function getTruncatedFileName(
 
 /* Gets path for files with same filename for editor tabs, breakpoints, etc.
  * Pass the source, and list of other sources
  *
  * @memberof utils/source
  * @static
  */
 
-export function getDisplayPath(mySource: Source, sources: Source[]) {
+export function getDisplayPath(
+  mySource: Source,
+  sources: Source[] | TabsSources
+) {
   const rawSourceURL = getRawSourceURL(mySource.url);
   const filename = getFilename(mySource, rawSourceURL);
 
   // Find sources that have the same filename, but different paths
   // as the original source
   const similarSources = sources.filter(source => {
     const rawSource = getRawSourceURL(source.url);
     return (
--- a/devtools/client/debugger/src/utils/tabs.js
+++ b/devtools/client/debugger/src/utils/tabs.js
@@ -1,32 +1,31 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
 
 // @flow
 
-import type { Source } from "../types";
-import type { TabList } from "../reducers/tabs";
+import type { PersistedTab, VisibleTab } from "../reducers/tabs";
+import type { TabList, Tab, TabsSources } from "../reducers/types";
 
-type SourcesList = Source[];
 /*
  * Finds the hidden tabs by comparing the tabs' top offset.
  * hidden tabs will have a great top offset.
  *
  * @param sourceTabs Immutable.list
  * @param sourceTabEls HTMLCollection
  *
  * @returns Immutable.list
  */
 
 export function getHiddenTabs(
-  sourceTabs: SourcesList,
+  sourceTabs: TabsSources,
   sourceTabEls: Array<any>
-): SourcesList {
+): TabsSources {
   sourceTabEls = [].slice.call(sourceTabEls);
   function getTopOffset() {
     const topOffsets = sourceTabEls.map(t => t.getBoundingClientRect().top);
     return Math.min(...topOffsets);
   }
 
   function hasTopOffset(el) {
     // adding 10px helps account for cases where the tab might be offset by
@@ -104,8 +103,22 @@ export function getTabMenuItems() {
     prettyPrint: {
       id: "node-menu-pretty-print",
       label: L10N.getStr("sourceTabs.prettyPrint"),
       accesskey: L10N.getStr("sourceTabs.prettyPrint.accesskey"),
       disabled: false,
     },
   };
 }
+
+export function isSimilarTab(tab: Tab, url: string, isOriginal: boolean) {
+  return tab.url === url && tab.isOriginal === isOriginal;
+}
+
+export function persistTabs(tabs: VisibleTab[]): PersistedTab[] {
+  return [...tabs]
+    .filter(tab => tab.url)
+    .map(tab => {
+      const newTab = { ...tab };
+      newTab.sourceId = null;
+      return newTab;
+    });
+}
--- a/devtools/client/debugger/src/utils/test-head.js
+++ b/devtools/client/debugger/src/utils/test-head.js
@@ -66,22 +66,22 @@ function createStore(client: any, initia
 /**
  * @memberof utils/test-head
  * @static
  */
 function commonLog(msg: string, data: any = {}) {
   console.log(`[INFO] ${msg} ${JSON.stringify(data)}`);
 }
 
-function makeFrame({ id, sourceId }: Object, opts: Object = {}) {
+function makeFrame({ id, sourceId, thread }: Object, opts: Object = {}) {
   return {
     id,
     scope: { bindings: { variables: {}, arguments: [] } },
     location: { sourceId, line: 4 },
-    thread: "FakeThread",
+    thread: thread || "FakeThread",
     ...opts,
   };
 }
 
 function createSourceObject(
   filename: string,
   props: {
     introductionType?: string,
@@ -210,17 +210,18 @@ function waitForState(store: any, predic
     if (ret) {
       resolve(ret);
     }
 
     const unsubscribe = store.subscribe(() => {
       ret = predicate(store.getState());
       if (ret) {
         unsubscribe();
-        resolve(ret);
+        // NOTE: memoizableAction adds an additional tick for validating context
+        setTimeout(() => resolve(ret));
       }
     });
   });
 }
 
 function watchForState(store: any, predicate: any): () => boolean {
   let sawState = false;
   const checkState = function() {
--- a/devtools/client/debugger/src/utils/tests/source.spec.js
+++ b/devtools/client/debugger/src/utils/tests/source.spec.js
@@ -21,16 +21,18 @@ import {
 import {
   makeMockSource,
   makeMockSourceWithContent,
   makeMockSourceAndContent,
   makeMockWasmSourceWithContent,
 } from "../test-mockup";
 import { isFulfilled } from "../async-value.js";
 
+import type { Source } from "../../types";
+
 const defaultSymbolDeclarations = {
   classes: [],
   functions: [],
   memberExpressions: [],
   callExpressions: [],
   objectProperties: [],
   identifiers: [],
   imports: [],
@@ -109,102 +111,101 @@ describe("sources", () => {
           30
         )
       ).toBe("測測測測測測測測測測測測測…測測測測測測測測測.html");
     });
   });
 
   describe("getDisplayPath", () => {
     it("should give us the path for files with same name", () => {
+      const sources: Source[] = [
+        makeMockSource("http://localhost.com:7999/increment/xyz/hello.html"),
+        makeMockSource("http://localhost.com:7999/increment/abc/hello.html"),
+        makeMockSource("http://localhost.com:7999/increment/hello.html"),
+      ];
       expect(
         getDisplayPath(
           makeMockSource("http://localhost.com:7999/increment/abc/hello.html"),
-          [
-            makeMockSource(
-              "http://localhost.com:7999/increment/xyz/hello.html"
-            ),
-            makeMockSource(
-              "http://localhost.com:7999/increment/abc/hello.html"
-            ),
-            makeMockSource("http://localhost.com:7999/increment/hello.html"),
-          ]
+          sources
         )
       ).toBe("abc");
     });
 
     it(`should give us the path for files with same name
       in directories with same name`, () => {
+      const sources: Source[] = [
+        makeMockSource(
+          "http://localhost.com:7999/increment/xyz/web/hello.html"
+        ),
+        makeMockSource(
+          "http://localhost.com:7999/increment/abc/web/hello.html"
+        ),
+        makeMockSource("http://localhost.com:7999/increment/hello.html"),
+      ];
       expect(
         getDisplayPath(
           makeMockSource(
             "http://localhost.com:7999/increment/abc/web/hello.html"
           ),
-          [
-            makeMockSource(
-              "http://localhost.com:7999/increment/xyz/web/hello.html"
-            ),
-            makeMockSource(
-              "http://localhost.com:7999/increment/abc/web/hello.html"
-            ),
-            makeMockSource("http://localhost.com:7999/increment/hello.html"),
-          ]
+          sources
         )
       ).toBe("abc/web");
     });
 
     it("should give no path for files with unique name", () => {
+      const sources: Source[] = [
+        makeMockSource("http://localhost.com:7999/increment/xyz.html"),
+        makeMockSource("http://localhost.com:7999/increment/abc.html"),
+        makeMockSource("http://localhost.com:7999/increment/hello.html"),
+      ];
       expect(
         getDisplayPath(
           makeMockSource("http://localhost.com:7999/increment/abc/web.html"),
-          [
-            makeMockSource("http://localhost.com:7999/increment/xyz.html"),
-            makeMockSource("http://localhost.com:7999/increment/abc.html"),
-            makeMockSource("http://localhost.com:7999/increment/hello.html"),
-          ]
+          sources
         )
       ).toBe(undefined);
     });
     it("should not show display path for pretty file", () => {
+      const sources: Source[] = [
+        makeMockSource("http://localhost.com:7999/increment/abc/web/hell.html"),
+        makeMockSource(
+          "http://localhost.com:7999/increment/abc/web/hello.html"
+        ),
+        makeMockSource(
+          "http://localhost.com:7999/increment/xyz.html:formatted"
+        ),
+      ];
       expect(
         getDisplayPath(
           makeMockSource(
             "http://localhost.com:7999/increment/abc/web/hello.html:formatted"
           ),
-          [
-            makeMockSource(
-              "http://localhost.com:7999/increment/abc/web/hell.html"
-            ),
-            makeMockSource(
-              "http://localhost.com:7999/increment/abc/web/hello.html"
-            ),
-            makeMockSource(
-              "http://localhost.com:7999/increment/xyz.html:formatted"
-            ),
-          ]
+          sources
         )
       ).toBe(undefined);
     });
     it(`should give us the path for files with same name when both
       are pretty and different path`, () => {
+      const sources: Source[] = [
+        makeMockSource(
+          "http://localhost.com:7999/increment/xyz/web/hello.html:formatted"
+        ),
+        makeMockSource(
+          "http://localhost.com:7999/increment/abc/web/hello.html:formatted"
+        ),
+        makeMockSource(
+          "http://localhost.com:7999/increment/hello.html:formatted"
+        ),
+      ];
       expect(
         getDisplayPath(
           makeMockSource(
             "http://localhost.com:7999/increment/abc/web/hello.html:formatted"
           ),
-          [
-            makeMockSource(
-              "http://localhost.com:7999/increment/xyz/web/hello.html:formatted"
-            ),
-            makeMockSource(
-              "http://localhost.com:7999/increment/abc/web/hello.html:formatted"
-            ),
-            makeMockSource(
-              "http://localhost.com:7999/increment/hello.html:formatted"
-            ),
-          ]
+          sources
         )
       ).toBe("abc/web");
     });
   });
 
   describe("getFileURL", () => {
     it("should give us the file url", () => {
       expect(
--- a/devtools/client/debugger/test/mochitest/browser_dbg-tabs-without-urls.js
+++ b/devtools/client/debugger/test/mochitest/browser_dbg-tabs-without-urls.js
@@ -1,12 +1,12 @@
 /* Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/publicdomain/zero/1.0/ */
 
-// Test that URL-less sources have tabs added to the UI but 
+// Test that URL-less sources have tabs added to the UI but
 // do not persist upon reload
 add_task(async function() {
   const dbg = await initDebugger("doc-scripts.html", "simple1", "simple2");
 
   await selectSource(dbg, "simple1");
   await selectSource(dbg, "simple2");
 
   is(countTabs(dbg), 2);
--- a/devtools/client/debugger/test/mochitest/helpers.js
+++ b/devtools/client/debugger/test/mochitest/helpers.js
@@ -1937,8 +1937,9 @@ const { PromiseTestUtils } = ChromeUtils
 // Debugger operations that are canceled because they were rendered obsolete by
 // a navigation or pause/resume end up as uncaught rejections. These never
 // indicate errors and are whitelisted in all debugger tests.
 PromiseTestUtils.whitelistRejectionsGlobally(/Page has navigated/);
 PromiseTestUtils.whitelistRejectionsGlobally(/Current thread has changed/);
 PromiseTestUtils.whitelistRejectionsGlobally(
   /Current thread has paused or resumed/
 );
+PromiseTestUtils.whitelistRejectionsGlobally(/Connection closed/);