ソースを参照

Merge pull request #4041 from afshin/multiple-windows

Support multiple open JupyterLab windows: multiple state databases.
Ian Rose 7 年 前
コミット
02bd226a0c

+ 3 - 3
packages/application-extension/src/index.tsx

@@ -63,8 +63,8 @@ namespace Patterns {
  */
 const main: JupyterLabPlugin<void> = {
   id: '@jupyterlab/application-extension:main',
-  requires: [ICommandPalette],
-  activate: (app: JupyterLab, palette: ICommandPalette) => {
+  requires: [ICommandPalette, IRouter],
+  activate: (app: JupyterLab, palette: ICommandPalette, router: IRouter) => {
     // If there were errors registering plugins, tell the user.
     if (app.registerPluginErrors.length !== 0) {
       const body = (
@@ -101,7 +101,7 @@ const main: JupyterLabPlugin<void> = {
         });
       }).then(result => {
         if (result.button.accept) {
-          location.reload();
+          router.reload();
         }
       }).catch(err => {
         showDialog({

+ 23 - 1
packages/application/src/router.ts

@@ -82,6 +82,11 @@ interface IRouter {
    */
   register(options: IRouter.IRegisterOptions): IDisposable;
 
+  /**
+   * Cause a hard reload of the document.
+   */
+  reload(): void;
+
   /**
    * Route a specific path to an action.
    *
@@ -135,6 +140,12 @@ namespace IRouter {
    */
   export
   interface INavOptions {
+    /**
+     * Whether the navigation should be hard URL change instead of an HTML
+     * history API change.
+     */
+    hard?: boolean;
+
     /**
      * Whether the navigation should be added to the browser's history.
      */
@@ -224,7 +235,7 @@ class Router implements IRouter {
   navigate(path: string, options: IRouter.INavOptions = { }): void {
     const url = path ? URLExt.join(this.base, path) : this.base;
     const { history } = window;
-    const { silent } = options;
+    const { hard, silent } = options;
 
     if (silent) {
       history.replaceState({ }, '', url);
@@ -232,6 +243,10 @@ class Router implements IRouter {
       history.pushState({ }, '', url);
     }
 
+    if (hard) {
+      return this.reload();
+    }
+
     // Because a `route()` call may still be in the stack after having received
     // a `stop` token, wait for the next stack frame before calling `route()`.
     requestAnimationFrame(() => { this.route(); });
@@ -254,6 +269,13 @@ class Router implements IRouter {
     return new DisposableDelegate(() => { rules.delete(pattern); });
   }
 
+  /**
+   * Cause a hard reload of the document.
+   */
+  reload(): void {
+    window.location.reload();
+  }
+
   /**
    * Route a specific path to an action.
    *

+ 1 - 0
packages/apputils-extension/package.json

@@ -37,6 +37,7 @@
     "@jupyterlab/coreutils": "^1.1.2",
     "@jupyterlab/mainmenu": "^0.5.2",
     "@jupyterlab/services": "^2.0.2",
+    "@phosphor/commands": "^1.5.0",
     "@phosphor/coreutils": "^1.3.0",
     "@phosphor/disposable": "^1.1.2",
     "@phosphor/widgets": "^1.6.0",

+ 169 - 42
packages/apputils-extension/src/index.ts

@@ -8,7 +8,8 @@ import {
 } from '@jupyterlab/application';
 
 import {
-  Dialog, ICommandPalette, ISplashScreen, IThemeManager, ThemeManager
+  Dialog, ICommandPalette, ISplashScreen, IThemeManager, IWindowResolver,
+  ThemeManager, WindowResolver
 } from '@jupyterlab/apputils';
 
 import {
@@ -23,6 +24,10 @@ import {
   ServiceManager
 } from '@jupyterlab/services';
 
+import {
+  CommandRegistry
+} from '@phosphor/commands';
+
 import {
   PromiseDelegate
 } from '@phosphor/coreutils';
@@ -36,9 +41,13 @@ import {
 } from '@phosphor/widgets';
 
 import {
-  activatePalette
+  activatePalette, restorePalette
 } from './palette';
 
+import {
+  createRedirectForm
+} from './redirect';
+
 import '../style/index.css';
 
 
@@ -47,7 +56,7 @@ import '../style/index.css';
  * to allow for multiple quickly executed state changes to result in a single
  * workspace save operation.
  */
-const WORKSPACE_SAVE_DEBOUNCE_INTERVAL = 1500;
+const WORKSPACE_SAVE_DEBOUNCE_INTERVAL = 750;
 
 /**
  * The interval in milliseconds before recover options appear during splash.
@@ -83,6 +92,9 @@ namespace CommandIDs {
  * The routing regular expressions used by the apputils plugin.
  */
 namespace Patterns {
+  export
+  const cloneState = /(\?clone\=|\&clone\=)([^&]+)($|&)/;
+
   export
   const loadState = /^\/workspaces\/([^?]+)/;
 
@@ -133,6 +145,22 @@ const palette: JupyterLabPlugin<ICommandPalette> = {
   activate: activatePalette,
   id: '@jupyterlab/apputils-extension:palette',
   provides: ICommandPalette,
+  autoStart: true
+};
+
+
+/**
+ * The default commmand palette's restoration extension.
+ *
+ * #### Notes
+ * The command palette's restoration logic is handled separately from the
+ * command palette provider extension because the layout restorer dependency
+ * causes the command palette to be unavailable to other extensions earlier
+ * in the application load cycle.
+ */
+const paletteRestorer: JupyterLabPlugin<void> = {
+  activate: restorePalette,
+  id: '@jupyterlab/apputils-extension:palette-restorer',
   requires: [ILayoutRestorer],
   autoStart: true
 };
@@ -160,18 +188,12 @@ const themes: JupyterLabPlugin<IThemeManager> = {
   id: '@jupyterlab/apputils-extension:themes',
   requires: [ISettingRegistry, ISplashScreen],
   optional: [ICommandPalette, IMainMenu],
-  activate: (app: JupyterLab, settingRegistry: ISettingRegistry, splash: ISplashScreen, palette: ICommandPalette | null, mainMenu: IMainMenu | null): IThemeManager => {
+  activate: (app: JupyterLab, settings: ISettingRegistry, splash: ISplashScreen, palette: ICommandPalette | null, mainMenu: IMainMenu | null): IThemeManager => {
     const host = app.shell;
-    const when = app.started;
     const commands = app.commands;
-
-    const manager = new ThemeManager({
-      key: themes.id,
-      host, settingRegistry,
-      url: app.info.urls.themes,
-      splash,
-      when
-    });
+    const url = app.info.urls.themes;
+    const key = themes.id;
+    const manager = new ThemeManager({ key, host, settings, splash, url });
 
     // Keep a synchronously set reference to the current theme,
     // since the asynchronous setting of the theme in `changeTheme`
@@ -195,12 +217,11 @@ const themes: JupyterLabPlugin<IThemeManager> = {
       }
     });
 
-    // If we have a main menu, add the theme manager
-    // to the settings menu.
+    // If we have a main menu, add the theme manager to the settings menu.
     if (mainMenu) {
       const themeMenu = new Menu({ commands });
       themeMenu.title.label = 'JupyterLab Theme';
-      manager.ready.then(() => {
+      app.restored.then(() => {
         const command = CommandIDs.changeTheme;
         const isPalette = false;
 
@@ -215,7 +236,7 @@ const themes: JupyterLabPlugin<IThemeManager> = {
 
     // If we have a command palette, add theme switching options to it.
     if (palette) {
-      manager.ready.then(() => {
+      app.restored.then(() => {
         const category = 'Settings';
         const command = CommandIDs.changeTheme;
         const isPalette = true;
@@ -234,6 +255,29 @@ const themes: JupyterLabPlugin<IThemeManager> = {
 };
 
 
+/**
+ * The default window name resolver provider.
+ */
+const resolver: JupyterLabPlugin<IWindowResolver> = {
+  id: '@jupyterlab/apputils-extension:resolver',
+  autoStart: true,
+  provides: IWindowResolver,
+  requires: [IRouter],
+  activate: (app: JupyterLab, router: IRouter) => {
+    const candidate = Private.getWorkspace(router) || '';
+    const resolver = new WindowResolver();
+
+    return resolver.resolve(candidate)
+      .catch(reason => {
+        console.warn('Window resolution failed:', reason);
+
+        return Private.redirect(router);
+      })
+      .then(() => resolver);
+  }
+};
+
+
 /**
  * The default splash screen provider.
  */
@@ -245,9 +289,8 @@ const splash: JupyterLabPlugin<ISplashScreen> = {
     return {
       show: () => {
         const { commands, restored } = app;
-        const recovery = () => { commands.execute(CommandIDs.reset); };
 
-        return Private.showSplash(restored, recovery);
+        return Private.showSplash(restored, commands, CommandIDs.reset);
       }
     };
   }
@@ -261,8 +304,8 @@ const state: JupyterLabPlugin<IStateDB> = {
   id: '@jupyterlab/apputils-extension:state',
   autoStart: true,
   provides: IStateDB,
-  requires: [IRouter],
-  activate: (app: JupyterLab, router: IRouter) => {
+  requires: [IRouter, IWindowResolver],
+  activate: (app: JupyterLab, router: IRouter, resolver: IWindowResolver) => {
     let debouncer: number;
     let resolved = false;
 
@@ -271,7 +314,8 @@ const state: JupyterLabPlugin<IStateDB> = {
     const transform = new PromiseDelegate<StateDB.DataTransform>();
     const state = new StateDB({
       namespace: info.namespace,
-      transform: transform.promise
+      transform: transform.promise,
+      windowName: resolver.name
     });
 
     commands.addCommand(CommandIDs.recoverState, {
@@ -314,6 +358,11 @@ const state: JupyterLabPlugin<IStateDB> = {
         }
 
         debouncer = window.setTimeout(() => {
+          // Prevent a race condition between the timeout and saving.
+          if (!conflated) {
+            return;
+          }
+
           state.toJSON()
             .then(data => workspaces.save(id, { data, metadata }))
             .then(() => {
@@ -336,26 +385,44 @@ const state: JupyterLabPlugin<IStateDB> = {
 
     commands.addCommand(CommandIDs.loadState, {
       execute: (args: IRouter.ILocation) => {
-        const workspace = Private.getWorkspace(router);
-
-        // If there is no workspace, bail.
-        if (!workspace) {
+        // Since the command can be executed an arbitrary number of times, make
+        // sure it is safe to call multiple times.
+        if (resolved) {
           return;
         }
 
-        // Any time the local state database changes, save the workspace.
-        state.changed.connect(listener, state);
+        const { hash, path, search } = args;
+        const workspace = Private.getWorkspace(router);
+        const query = URLExt.queryStringToObject(search || '');
+        const clone = query['clone'];
+        const source = typeof clone === 'string' ? clone : workspace;
+
+        let promise: Promise<any>;
+
+        // If the default /lab workspace is being cloned, copy it out of local
+        // storage instead of making a round trip to the server because it
+        // does not exist on the server.
+        if (source === clone && source === '') {
+          const prefix = `${source}:${info.namespace}:`;
+          const mask = (key: string) => key.replace(prefix, '');
+          const contents = StateDB.toJSON(prefix, mask);
+
+          resolved = true;
+          transform.resolve({ type: 'overwrite', contents });
+          promise = Promise.resolve();
+        }
+
 
-        // Fetch the workspace and overwrite the state database.
-        return workspaces.fetch(workspace).then(session => {
+        // If there is no promise, fetch the source and overwrite the database.
+        promise = promise || workspaces.fetch(source).then(saved => {
           // If this command is called after a reset, the state database will
           // already be resolved.
           if (!resolved) {
             resolved = true;
-            transform.resolve({ type: 'overwrite', contents: session.data });
+            transform.resolve({ type: 'overwrite', contents: saved.data });
           }
         }).catch(reason => {
-          console.warn(`Fetching workspace (${workspace}) failed.`, reason);
+          console.warn(`Fetching workspace (${workspace}) failed:`, reason);
 
           // If the workspace does not exist, cancel the data transformation and
           // save a workspace with the current user state data.
@@ -363,22 +430,48 @@ const state: JupyterLabPlugin<IStateDB> = {
             resolved = true;
             transform.resolve({ type: 'cancel', contents: null });
           }
+        }).then(() => {
+          // Any time the local state database changes, save the workspace.
+          if (workspace) {
+            state.changed.connect(listener, state);
+          }
+        });
+
+        return promise.catch(reason => {
+          console.warn(`${CommandIDs.loadState} failed:`, reason);
+        }).then(() => {
+          const immediate = true;
+
+          if (source === clone) {
+            // Maintain the query string parameters but remove `clone`.
+            delete query['clone'];
+
+            const url = path + URLExt.objectToQueryString(query) + hash;
+            const silent = true;
 
-          return commands.execute(CommandIDs.saveState);
+            router.navigate(url, { silent });
+          }
+
+          // After the state database has finished loading, save it.
+          return commands.execute(CommandIDs.saveState, { immediate });
         });
       }
     });
+    // Both the load state and clone state patterns should trigger the load
+    // state command if the URL matches one of them.
+    router.register({
+      command: CommandIDs.loadState, pattern: Patterns.loadState
+    });
     router.register({
-      command: CommandIDs.loadState,
-      pattern: Patterns.loadState
+      command: CommandIDs.loadState, pattern: Patterns.cloneState
     });
 
     commands.addCommand(CommandIDs.reset, {
       label: 'Reset Application State',
       execute: () => {
         commands.execute(CommandIDs.recoverState)
-          .then(() => { document.location.reload(); })
-          .catch(() => { document.location.reload(); });
+          .then(() => { router.reload(); })
+          .catch(() => { router.reload(); });
       }
     });
 
@@ -395,7 +488,7 @@ const state: JupyterLabPlugin<IStateDB> = {
         // If the state database has already been resolved, resetting is
         // impossible without reloading.
         if (resolved) {
-          return document.location.reload();
+          return router.reload();
         }
 
         // Empty the state database.
@@ -442,7 +535,7 @@ const state: JupyterLabPlugin<IStateDB> = {
  * Export the plugins as default.
  */
 const plugins: JupyterLabPlugin<any>[] = [
-  palette, settings, state, splash, themes
+  palette, paletteRestorer, resolver, settings, state, splash, themes
 ];
 export default plugins;
 
@@ -531,6 +624,38 @@ namespace Private {
       debouncer = window.setTimeout(() => {
         recover(fn);
       }, SPLASH_RECOVER_TIMEOUT);
+    }).catch(() => { /* no-op */ });
+  }
+
+  /**
+   * Allows the user to clear state if splash screen takes too long.
+   */
+  export
+  function redirect(router: IRouter, warn = false): Promise<void> {
+    const form = createRedirectForm(warn);
+    const dialog = new Dialog({
+      title: 'Please use a different workspace.',
+      body: form,
+      focusNodeSelector: 'input',
+      buttons: [Dialog.okButton({ label: 'Switch Workspace' })]
+    });
+
+    return dialog.launch().then(result => {
+      dialog.dispose();
+
+      if (result.value) {
+        const url = `workspaces/${result.value}`;
+
+        // Navigate to a new workspace URL and abandon this session altogether.
+        router.navigate(url, { hard: true, silent: true });
+
+        // This promise will never resolve because the application navigates
+        // away to a new location. It only exists to satisfy the return type
+        // of the `redirect` function.
+        return new Promise<void>(() => { /* no-op */ });
+      }
+
+      return redirect(router, true);
     });
   }
 
@@ -549,10 +674,10 @@ namespace Private {
    *
    * @param ready - A promise that must be resolved before splash disappears.
    *
-   * @param recovery - A function that recovers from a hanging splash.
+   * @param recovery - A command that recovers from a hanging splash.
    */
   export
-  function showSplash(ready: Promise<any>, recovery: () => void): IDisposable {
+  function showSplash(ready: Promise<any>, commands: CommandRegistry, recovery: string): IDisposable {
     splash.classList.remove('splash-fade');
     splashCount++;
 
@@ -560,7 +685,9 @@ namespace Private {
       window.clearTimeout(debouncer);
     }
     debouncer = window.setTimeout(() => {
-      recover(recovery);
+      if (commands.hasCommand(recovery)) {
+        recover(() => { commands.execute(recovery); });
+      }
     }, SPLASH_RECOVER_TIMEOUT);
 
     document.body.appendChild(splash);

+ 39 - 10
packages/apputils-extension/src/palette.ts

@@ -78,17 +78,9 @@ class Palette implements ICommandPalette {
  * Activate the command palette.
  */
 export
-function activatePalette(app: JupyterLab, restorer: ILayoutRestorer): ICommandPalette {
+function activatePalette(app: JupyterLab): ICommandPalette {
   const { commands, shell } = app;
-  const palette = new CommandPalette({ commands });
-
-  // Let the application restorer track the command palette for restoration of
-  // application state (e.g. setting the command palette as the current side bar
-  // widget).
-  restorer.add(palette, 'command-palette');
-
-  palette.id = 'command-palette';
-  palette.title.label = 'Commands';
+  const palette = Private.createPalette(app);
 
   commands.addCommand(CommandIDs.activate, {
     execute: () => { shell.activateById(palette.id); },
@@ -101,3 +93,40 @@ function activatePalette(app: JupyterLab, restorer: ILayoutRestorer): ICommandPa
 
   return new Palette(palette);
 }
+
+/**
+ * Restore the command palette.
+ */
+export
+function restorePalette(app: JupyterLab, restorer: ILayoutRestorer): void {
+  const palette = Private.createPalette(app);
+
+  // Let the application restorer track the command palette for restoration of
+  // application state (e.g. setting the command palette as the current side bar
+  // widget).
+  restorer.add(palette, 'command-palette');
+}
+
+/**
+ * The namespace for module private data.
+ */
+namespace Private {
+  /**
+   * The private command palette instance.
+   */
+  let palette: CommandPalette;
+
+  /**
+   * Create the application-wide command palette.
+   */
+  export
+  function createPalette(app: JupyterLab): CommandPalette {
+    if (!palette) {
+      palette = new CommandPalette({ commands: app.commands });
+      palette.id = 'command-palette';
+      palette.title.label = 'Commands';
+    }
+
+    return palette;
+  }
+}

+ 119 - 0
packages/apputils-extension/src/redirect.ts

@@ -0,0 +1,119 @@
+/*-----------------------------------------------------------------------------
+| Copyright (c) Jupyter Development Team.
+| Distributed under the terms of the Modified BSD License.
+|----------------------------------------------------------------------------*/
+
+import {
+  Widget
+} from '@phosphor/widgets';
+
+
+/**
+ * The form label.
+ */
+const LABEL = `This workspace is already in use in another JupyterLab window.
+  Please enter another workspace name.`;
+
+/**
+ * The form input field placeholder.
+ */
+const PLACEHOLDER = 'url-friendly-workspace-name';
+
+/**
+ * The form warning message if an empty value was submitted.
+ */
+const WARNING = 'Please enter a value.';
+
+
+/**
+ * The UI for the recovery option to redirect to a different workspace.
+ */
+export
+class RedirectForm extends Widget {
+  /**
+   * Create a redirect form.
+   */
+  constructor() {
+    super({ node: Private.createNode() });
+  }
+
+  /**
+   * The text label of the form.
+   */
+  get label(): string {
+    return this.node.querySelector('label span').textContent;
+  }
+  set label(label: string) {
+    this.node.querySelector('label span').textContent = label;
+  }
+
+  /**
+   * The input placeholder.
+   */
+  get placeholder(): string {
+    return this.node.querySelector('input').placeholder;
+  }
+  set placeholder(placeholder: string) {
+    this.node.querySelector('input').placeholder = placeholder;
+  }
+
+  /**
+   * The warning message.
+   */
+  get warning(): string {
+    return this.node.querySelector('.jp-RedirectForm-warning').textContent;
+  }
+  set warning(warning: string) {
+    this.node.querySelector('.jp-RedirectForm-warning').textContent = warning;
+  }
+
+  /**
+   * Returns the input value.
+   */
+  getValue(): string {
+    return encodeURIComponent(this.node.querySelector('input').value);
+  }
+}
+
+
+/**
+ * Return a new redirect form, populated with default language.
+ */
+export
+function createRedirectForm(warn = false): RedirectForm {
+  const form = new RedirectForm();
+
+  form.label = LABEL;
+  form.placeholder = PLACEHOLDER;
+  form.warning = warn ? WARNING : '';
+
+  return form;
+}
+
+
+/**
+ * A namespace for private module data.
+ */
+namespace Private {
+  /**
+   * Create the redirect form's content.
+   */
+  export
+  function createNode(): HTMLElement {
+    const node = document.createElement('div');
+    const label = document.createElement('label');
+    const input = document.createElement('input');
+    const text = document.createElement('span');
+    const warning = document.createElement('div');
+
+    node.className = 'jp-RedirectForm';
+    warning.className = 'jp-RedirectForm-warning';
+
+    label.appendChild(text);
+    label.appendChild(input);
+    node.appendChild(label);
+    node.appendChild(warning);
+
+    return node;
+  }
+}

+ 1 - 0
packages/apputils-extension/style/index.css

@@ -4,4 +4,5 @@
 | Distributed under the terms of the Modified BSD License.
 |----------------------------------------------------------------------------*/
 
+@import './redirect.css';
 @import './splash.css';

+ 16 - 0
packages/apputils-extension/style/redirect.css

@@ -0,0 +1,16 @@
+/*-----------------------------------------------------------------------------
+| Copyright (c) 2014-2017, Jupyter Development Team.
+|
+| Distributed under the terms of the Modified BSD License.
+|----------------------------------------------------------------------------*/
+
+.jp-RedirectForm input {
+	display: block;
+	margin-top: 10px;
+	width: 100%;
+}
+
+
+.jp-RedirectForm-warning {
+	color: var(--jp-warn-color0);
+}

+ 3 - 1
packages/apputils/src/dialog.ts

@@ -318,7 +318,9 @@ class Dialog<T> extends Widget {
     ArrayExt.removeFirstOf(Private.launchQueue, promise.promise);
     let body = this._body;
     let value: T | null = null;
-    if (button.accept && body instanceof Widget && typeof body.getValue === 'function') {
+    if (button.accept &&
+      body instanceof Widget &&
+      typeof body.getValue === 'function') {
       value = body.getValue();
     }
     this.dispose();

+ 1 - 0
packages/apputils/src/index.ts

@@ -20,3 +20,4 @@ export * from './styling';
 export * from './thememanager';
 export * from './toolbar';
 export * from './vdom';
+export * from './windowresolver';

+ 142 - 133
packages/apputils/src/thememanager.ts

@@ -10,7 +10,7 @@ import {
 } from '@phosphor/algorithm';
 
 import {
-  PromiseDelegate, Token
+  Token
 } from '@phosphor/coreutils';
 
 import {
@@ -46,6 +46,17 @@ export
 interface IThemeManager extends ThemeManager {}
 
 
+/**
+ * The number of milliseconds between theme loading attempts.
+ */
+const REQUEST_INTERVAL = 75;
+
+/**
+ * The number of times to attempt to load a theme before giving up.
+ */
+const REQUEST_THRESHOLD = 20;
+
+
 /**
  * A class that provides theme management.
  */
@@ -55,35 +66,25 @@ class ThemeManager {
    * Construct a new theme manager.
    */
   constructor(options: ThemeManager.IOptions) {
-    const { key, when, url, splash } = options;
-    const registry = options.settingRegistry;
-    const promises = Promise.all([registry.load(key), when]);
-    this._splash = splash;
+    const { host, key, splash, url } = options;
+    const registry = options.settings;
 
-    this._baseUrl = url;
-
-    when.then(() => { this._sealed = true; });
+    this._base = url;
+    this._host = host;
+    this._splash = splash;
 
-    this._host = options.host;
-    this._splashDisposable = splash.show();
-    this.ready = promises.then(([settings]) => {
+    registry.load(key).then(settings => {
       this._settings = settings;
-      this._settings.changed.connect(this._onSettingsChanged, this);
-
-      return this._handleSettings();
+      this._settings.changed.connect(this._loadSettings, this);
+      this._loadSettings();
     });
   }
 
-  /**
-   * A promise that resolves when the theme manager is ready.
-   */
-  readonly ready: Promise<void>;
-
   /**
    * Get the name of the current theme.
    */
   get theme(): string | null {
-    return this._loadedTheme;
+    return this._current;
   }
 
   /**
@@ -94,10 +95,29 @@ class ThemeManager {
   }
 
   /**
-   * Set the current theme.
+   * Load a theme CSS file by path.
+   *
+   * @param path - The path of the file to load.
    */
-  setTheme(name: string): Promise<void> {
-    return this.ready.then(() => this._settings.set('theme', name));
+  loadCSS(path: string): Promise<void> {
+    const base = this._base;
+    const href = URLExt.isLocal(path) ? URLExt.join(base, path) : path;
+    const links = this._links;
+
+    return new Promise((resolve, reject) => {
+      const link = document.createElement('link');
+
+      link.setAttribute('rel', 'stylesheet');
+      link.setAttribute('type', 'text/css');
+      link.setAttribute('href', href);
+      link.addEventListener('load', () => { resolve(undefined); });
+      link.addEventListener('error', () => {
+        reject(`Stylesheet failed to load: ${href}`);
+      });
+
+      document.body.appendChild(link);
+      links.push(link);
+    });
   }
 
   /**
@@ -108,129 +128,124 @@ class ThemeManager {
    * @returns A disposable that can be used to unregister the theme.
    */
   register(theme: ThemeManager.ITheme): IDisposable {
-    if (this._sealed) {
-      throw new Error('Cannot register themes after startup');
-    }
-
-    const name = theme.name;
+    const { name } = theme;
+    const themes = this._themes;
 
-    if (this._themes[name]) {
+    if (themes[name]) {
       throw new Error(`Theme already registered for ${name}`);
     }
-    this._themes[name] = theme;
 
-    return new DisposableDelegate(() => { delete this._themes[name]; });
+    themes[name] = theme;
+
+    return new DisposableDelegate(() => { delete themes[name]; });
   }
 
   /**
-   * Load a theme CSS file by path.
-   *
-   * @param path - The path of the file to load.
+   * Set the current theme.
    */
-  loadCSS(path: string): Promise<void> {
-    const link = document.createElement('link');
-    const delegate = new PromiseDelegate<void>();
-    const href = URLExt.isLocal(path) ? URLExt.join(this._baseUrl, path) : path;
-
-    link.rel = 'stylesheet';
-    link.type = 'text/css';
-    link.href = href;
-    link.addEventListener('load', () => { delegate.resolve(undefined); });
-    link.addEventListener('error', () => {
-      delegate.reject(`Stylesheet failed to load: ${href}`);
-    });
-    document.body.appendChild(link);
-    this._links.push(link);
-
-    return delegate.promise;
+  setTheme(name: string): Promise<void> {
+    return this._settings.set('theme', name);
   }
 
   /**
-   * Handle a change to the settings.
+   * Handle the current settings.
    */
-  private _onSettingsChanged(sender: ISettingRegistry.ISettings): void {
-    this._pendingTheme = sender.composite['theme'] as string;
-    if (!this._themes[this._pendingTheme]) {
-      return;
+  private _loadSettings(): void {
+    const outstanding = this._outstanding;
+    const pending = this._pending;
+    const requests = this._requests;
+
+    // If another request is pending, cancel it.
+    if (pending) {
+      window.clearTimeout(pending);
+      this._pending = 0;
     }
-    if (this._pendingTheme === this._loadedTheme) {
+
+    const settings = this._settings;
+    const themes = this._themes;
+    const theme = settings.composite['theme'] as string;
+
+    // If another promise is outstanding, wait until it finishes before
+    // attempting to load the settings. Because outstanding promises cannot
+    // be aborted, the order in which they occur must be enforced.
+    if (outstanding) {
+      outstanding
+        .then(() => { this._loadSettings(); })
+        .catch(() => { this._loadSettings(); });
+      this._outstanding = null;
       return;
     }
-    if (this._loadPromise) {
+
+    // Increment the request counter.
+    requests[theme] = requests[theme] ? requests[theme] + 1 : 1;
+
+    // If the theme exists, load it right away.
+    if (themes[theme]) {
+      this._outstanding = this._loadTheme(theme);
+      delete requests[theme];
       return;
     }
-    this._loadTheme();
-  }
 
-  /**
-   * Handle the current settings.
-   */
-  private _handleSettings(): Promise<void> {
-    const settings = this._settings;
-    let theme = settings.composite['theme'] as string;
+    // If the request has taken too long, give up.
+    if (requests[theme] > REQUEST_THRESHOLD) {
+      const fallback = settings.default('theme') as string;
 
-    if (!this._themes[theme]) {
-      const old = theme;
+      // Stop tracking the requests for this theme.
+      delete requests[theme];
 
-      theme = settings.default('theme') as string;
-      if (!this._themes[theme]) {
-        this._onError(new Error(`Default theme "${theme}" did not load.`));
+      if (!themes[fallback]) {
+        this._onError(`Neither theme ${theme} nor default ${fallback} loaded.`);
+        return;
       }
-      console.warn(`Could not load theme "${old}", using default "${theme}".`);
+
+      console.warn(`Could not load theme ${theme}, using default ${fallback}.`);
+      this._outstanding = this._loadTheme(fallback);
+      return;
     }
-    this._pendingTheme = theme;
 
-    return this._loadTheme().catch(reason => { this._onError(reason); });
+    // If the theme does not yet exist, attempt to wait for it.
+    this._pending = window.setTimeout(() => {
+      this._loadSettings();
+    }, REQUEST_INTERVAL);
   }
 
   /**
    * Load the theme.
+   *
+   * #### Notes
+   * This method assumes that the `theme` exists.
    */
-  private _loadTheme(): Promise<void> {
-    let newTheme = this._themes[this._pendingTheme];
-    let oldPromise = Promise.resolve(void 0);
-    let oldTheme = this._themes[this._loadedTheme];
-    if (oldTheme) {
-      this._splashDisposable = this._splash.show();
-      oldPromise = oldTheme.unload();
-    }
-    this._pendingTheme = '';
-    this._loadPromise = oldPromise.then(() => {
-      this._links.forEach(link => {
-        if (link.parentElement) {
-          link.parentElement.removeChild(link);
-        }
-      });
-      this._links.length = 0;
-      return newTheme.load();
-    }).then(() => {
-      this._loadedTheme = newTheme.name;
-      this._finishLoad();
-    }).then(() => {
-      this._splashDisposable.dispose();
-    }).catch(error => {
-      this._onError(error);
+  private _loadTheme(theme: string): Promise<void> {
+    const current = this._current;
+    const links = this._links;
+    const themes = this._themes;
+    const splash = this._splash.show();
+
+    // Unload any CSS files that have been loaded.
+    links.forEach(link => {
+      if (link.parentElement) {
+        link.parentElement.removeChild(link);
+      }
+    });
+    links.length = 0;
+
+    // Unload the previously loaded theme.
+    const old = current ? themes[current].unload() : Promise.resolve();
+
+    return Promise.all([old, themes[theme].load()]).then(() => {
+      this._current = theme;
+      Private.fitAll(this._host);
+      splash.dispose();
+    }).catch(reason => {
+      this._onError(reason);
+      splash.dispose();
     });
-    return this._loadPromise;
-  }
-
-  /**
-   * Handle a load finished.
-   */
-  private _finishLoad(): void {
-    Private.fitAll(this._host);
-    this._loadPromise = null;
-
-    if (this._pendingTheme) {
-      this._loadTheme();
-    }
   }
 
   /**
    * Handle a theme error.
    */
   private _onError(reason: any): void {
-    this._splashDisposable.dispose();
     showDialog({
       title: 'Error Loading Theme',
       body: String(reason),
@@ -238,17 +253,16 @@ class ThemeManager {
     });
   }
 
-  private _baseUrl: string;
-  private _themes: { [key: string]: ThemeManager.ITheme } = {};
-  private _links: HTMLLinkElement[] = [];
+  private _base: string;
+  private _current: string | null = null;
   private _host: Widget;
+  private _links: HTMLLinkElement[] = [];
+  private _outstanding: Promise<void> | null = null;
+  private _pending = 0;
+  private _requests: { [theme: string]: number } = { };
   private _settings: ISettingRegistry.ISettings;
-  private _pendingTheme = '';
-  private _loadedTheme: string | null = null;
-  private _loadPromise: Promise<void> | null = null;
-  private _sealed = false;
   private _splash: ISplashScreen;
-  private _splashDisposable: IDisposable;
+  private _themes: { [key: string]: ThemeManager.ITheme } = { };
 }
 
 
@@ -263,34 +277,29 @@ namespace ThemeManager {
   export
   interface IOptions {
     /**
-     * The setting registry key that holds theme setting data.
+     * The host widget for the theme manager.
      */
-    key: string;
+    host: Widget;
 
     /**
-     * The url for local theme loading.
+     * The setting registry key that holds theme setting data.
      */
-    url: string;
+    key: string;
 
     /**
      * The settings registry.
      */
-    settingRegistry: ISettingRegistry;
+    settings: ISettingRegistry;
 
     /**
-     * The host widget for the theme manager.
-     */
-    host: Widget;
-
-    /**
-     * A promise for when all themes should have been registered.
+     * The splash screen to show when loading themes.
      */
-    when: Promise<void>;
+    splash: ISplashScreen;
 
     /**
-     * The splash screen to show when loading themes.
+     * The url for local theme loading.
      */
-    splash: ISplashScreen;
+    url: string;
   }
 
   /**

+ 218 - 0
packages/apputils/src/windowresolver.ts

@@ -0,0 +1,218 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import {
+  PromiseDelegate, Token
+} from '@phosphor/coreutils';
+
+
+/* tslint:disable */
+/**
+ * The default window resolver token.
+ */
+export
+const IWindowResolver = new Token<IWindowResolver>('@jupyterlab/apputils:IWindowResolver');
+/* tslint:enable */
+
+
+/**
+ * The description of a window name resolver.
+ */
+export
+interface IWindowResolver {
+  /**
+   * A window name to use as a handle among shared resources.
+   */
+  readonly name: string;
+}
+
+
+/**
+ * A concrete implementation of a window name resolver.
+ */
+export
+class WindowResolver implements IWindowResolver {
+  /**
+   * The resolved window name.
+   */
+  get name(): string {
+    return this._name;
+  }
+
+  /**
+   * Resolve a window name to use as a handle among shared resources.
+   *
+   * @param candidate - The potential window name being resolved.
+   *
+   * #### Notes
+   * Typically, the name candidate should be a JupyterLab workspace name or
+   * an empty string if there is no workspace.
+   *
+   * If the returned promise rejects, a window name cannot be resolved without
+   * user intervention, which typically means navigation to a new URL.
+   */
+  resolve(candidate: string): Promise<void> {
+    return Private.resolve(candidate).then(name => { this._name = name; });
+  }
+
+  private _name: string | null = null;
+}
+
+
+/*
+ * A namespace for private module data.
+ */
+namespace Private {
+  /**
+   * The internal prefix for private local storage keys.
+   */
+  const PREFIX = '@jupyterlab/coreutils:StateDB';
+
+  /**
+   * The local storage beacon key.
+   */
+  const BEACON = `${PREFIX}:beacon`;
+
+  /**
+   * The timeout (in ms) to wait for beacon responders.
+   *
+   * #### Notes
+   * This value is a whole number between 200 and 500 in order to prevent
+   * perfect timeout collisions between multiple simultaneously opening windows
+   * that have the same URL. This is an edge case because multiple windows
+   * should not ordinarily share the same URL, but it can be contrived.
+   */
+  const TIMEOUT = Math.floor(200 + Math.random() * 300);
+
+  /**
+   * The local storage window key.
+   */
+  const WINDOW = `${PREFIX}:window`;
+
+  /**
+   * A potential preferred default window name.
+   */
+  let candidate: string | null = null;
+
+  /**
+   * The window name promise.
+   */
+  let delegate = new PromiseDelegate<string>();
+
+  /**
+   * The known window names.
+   */
+  let known: { [window: string]: null } = { };
+
+  /**
+   * The window name.
+   */
+  let name: string | null = null;
+
+  /**
+   * Whether the name resolution has completed.
+   */
+  let resolved = false;
+
+  /**
+   * Start the storage event handler.
+   */
+  function initialize(): void {
+    // Listen to all storage events for beacons and window names.
+    window.addEventListener('storage', (event: StorageEvent) => {
+      const { key, newValue } = event;
+
+      // All the keys we care about have values.
+      if (newValue === null) {
+        return;
+      }
+
+      // If the beacon was fired, respond with a ping.
+      if (key === BEACON && candidate !== null) {
+        ping(resolved ? name : candidate);
+        return;
+      }
+
+      // If the window name is resolved, bail.
+      if (resolved || key !== WINDOW) {
+        return;
+      }
+
+      const reported = newValue.replace(/\-\d+$/, '');
+
+      // Store the reported window name.
+      known[reported] = null;
+
+      // If a reported window name and candidate collide, reject the candidate.
+      if (candidate in known) {
+        reject();
+      }
+    });
+  }
+
+  /**
+   * Ping peers with payload.
+   */
+  function ping(payload: string): void {
+    if (payload === null) {
+      return;
+    }
+
+    const { localStorage } = window;
+
+    localStorage.setItem(WINDOW, `${payload}-${(new Date().getTime())}`);
+  }
+
+  /**
+   * Reject the candidate.
+   */
+  function reject(): void {
+    resolved = true;
+    delegate.reject(`Window name candidate "${candidate}" already exists`);
+  }
+
+  /**
+   * Returns a promise that resolves with the window name used for restoration.
+   */
+  export
+  function resolve(potential: string): Promise<string> {
+    if (resolved) {
+      return delegate.promise;
+    }
+
+    // Set the local candidate.
+    candidate = potential;
+
+    if (candidate in known) {
+      reject();
+      return delegate.promise;
+    }
+
+    const { localStorage, setTimeout } = window;
+
+    // Wait until other windows have reported before claiming the candidate.
+    setTimeout(() => {
+      if (resolved) {
+        return;
+      }
+
+      // If the window name has not already been resolved, check one last time
+      // to confirm it is not a duplicate before resolving.
+      if (candidate in known) {
+        return reject();
+      }
+
+      resolved = true;
+      delegate.resolve(name = candidate);
+      ping(name);
+    }, TIMEOUT);
+
+    // Fire the beacon to collect other windows' names.
+    localStorage.setItem(BEACON, `${Math.random()}-${(new Date()).getTime()}`);
+
+    return delegate.promise;
+  }
+
+  // Initialize the storage listener at runtime.
+  (() => { initialize(); })();
+}

+ 118 - 69
packages/coreutils/src/statedb.ts

@@ -98,16 +98,17 @@ class StateDB implements IStateDB {
    * @param options - The instantiation options for a state database.
    */
   constructor(options: StateDB.IOptions) {
-    const { namespace, transform } = options;
+    const { namespace, transform, windowName } = options;
 
     this.namespace = namespace;
 
-    if (!transform) {
-      this._ready = Promise.resolve(undefined);
-      return;
-    }
+    this._window = windowName || '';
+    this._ready = (transform || Promise.resolve(null)).then(transformation => {
+
+      if (!transformation) {
+        return;
+      }
 
-    this._ready = transform.then(transformation => {
       const { contents, type } = transformation;
 
       switch (type) {
@@ -180,16 +181,7 @@ class StateDB implements IStateDB {
    * retrieving the data. Non-existence of an `id` will succeed with `null`.
    */
   fetch(id: string): Promise<ReadonlyJSONValue | undefined> {
-    return this._ready.then(() => {
-      const key = `${this.namespace}:${id}`;
-      const value = window.localStorage.getItem(key);
-
-      if (value) {
-        const envelope = JSON.parse(value) as Private.Envelope;
-
-        return envelope.v;
-      }
-    });
+    return this._ready.then(() => this._fetch(id));
   }
 
   /**
@@ -197,7 +189,7 @@ class StateDB implements IStateDB {
    *
    * @param namespace - The namespace to retrieve.
    *
-   * @returns A promise that bears a collection data payloads for a namespace.
+   * @returns A promise that bears a collection of payloads for a namespace.
    *
    * #### Notes
    * Namespaces are entirely conventional entities. The `id` values of stored
@@ -210,32 +202,10 @@ class StateDB implements IStateDB {
    */
   fetchNamespace(namespace: string): Promise<IStateItem[]> {
     return this._ready.then(() => {
-      const { localStorage } = window;
-      const prefix = `${this.namespace}:${namespace}:`;
-      let items: IStateItem[] = [];
-      let i = localStorage.length;
-
-      while (i) {
-        let key = localStorage.key(--i);
-
-        if (key && key.indexOf(prefix) === 0) {
-          let value = localStorage.getItem(key);
-
-          try {
-            let envelope = JSON.parse(value) as Private.Envelope;
-
-            items.push({
-              id: key.replace(`${this.namespace}:`, ''),
-              value: envelope ? envelope.v : undefined
-            });
-          } catch (error) {
-            console.warn(error);
-            localStorage.removeItem(key);
-          }
-        }
-      }
+      const prefix = `${this._window}:${this.namespace}:`;
+      const mask = (key: string) => key.replace(prefix, '');
 
-      return items;
+      return StateDB.fetchNamespace(`${prefix}${namespace}:`, mask);
     });
   }
 
@@ -248,7 +218,7 @@ class StateDB implements IStateDB {
    */
   remove(id: string): Promise<void> {
     return this._ready.then(() => {
-      window.localStorage.removeItem(`${this.namespace}:${id}`);
+      this._remove(id);
       this._changed.emit({ id, type: 'remove' });
     });
   }
@@ -283,31 +253,10 @@ class StateDB implements IStateDB {
    */
   toJSON(): Promise<ReadonlyJSONObject> {
     return this._ready.then(() => {
-      const { localStorage } = window;
-      const prefix = `${this.namespace}:`;
-      const contents: Partial<ReadonlyJSONObject> =  { };
-      let i = localStorage.length;
-
-      while (i) {
-        let key = localStorage.key(--i);
-
-        if (key && key.indexOf(prefix) === 0) {
-          let value = localStorage.getItem(key);
-
-          try {
-            let envelope = JSON.parse(value) as Private.Envelope;
-
-            if (envelope) {
-              contents[key.replace(prefix, '')] = envelope.v;
-            }
-          } catch (error) {
-            console.warn(error);
-            localStorage.removeItem(key);
-          }
-        }
-      }
+      const prefix = `${this._window}:${this.namespace}:`;
+      const mask = (key: string) => key.replace(prefix, '');
 
-      return contents;
+      return StateDB.toJSON(prefix, mask);
     });
   }
 
@@ -319,7 +268,7 @@ class StateDB implements IStateDB {
    */
   private _clear(): void {
     const { localStorage } = window;
-    const prefix = `${this.namespace}:`;
+    const prefix = `${this._window}:${this.namespace}:`;
     let i = localStorage.length;
 
     while (i) {
@@ -331,6 +280,25 @@ class StateDB implements IStateDB {
     }
   }
 
+  /**
+   * Fetch a value from the database.
+   *
+   * #### Notes
+   * Unlike the public `fetch` method, this method is synchronous.
+   */
+  private _fetch(id: string): ReadonlyJSONValue | undefined {
+      const key = `${this._window}:${this.namespace}:${id}`;
+      const value = window.localStorage.getItem(key);
+
+      if (value) {
+        const envelope = JSON.parse(value) as Private.Envelope;
+
+        return envelope.v;
+      }
+
+      return undefined;
+  }
+
   /**
    * Merge data into the state database.
    */
@@ -346,6 +314,18 @@ class StateDB implements IStateDB {
     this._merge(contents);
   }
 
+  /**
+   * Remove a key in the database.
+   *
+   * #### Notes
+   * Unlike the public `remove` method, this method is synchronous.
+   */
+  private _remove(id: string): void {
+    const key = `${this._window}:${this.namespace}:${id}`;
+
+    window.localStorage.removeItem(key);
+  }
+
   /**
    * Save a key and its value in the database.
    *
@@ -353,7 +333,7 @@ class StateDB implements IStateDB {
    * Unlike the public `save` method, this method is synchronous.
    */
   private _save(id: string, value: ReadonlyJSONValue): void {
-    const key = `${this.namespace}:${id}`;
+    const key = `${this._window}:${this.namespace}:${id}`;
     const envelope: Private.Envelope = { v: value };
     const serialized = JSON.stringify(envelope);
     const length = serialized.length;
@@ -368,6 +348,7 @@ class StateDB implements IStateDB {
 
   private _changed = new Signal<this, StateDB.Change>(this);
   private _ready: Promise<void>;
+  private _window: string;
 }
 
 /**
@@ -426,6 +407,74 @@ namespace StateDB {
      * client requests.
      */
     transform?: Promise<DataTransform>;
+
+    /**
+     * An optional name for the application window.
+     *
+     * #### Notes
+     * In environments where multiple windows can instantiate a state database,
+     * a window name is necessary to prefix all keys that are stored within the
+     * local storage that is shared by all windows. In JupyterLab, this window
+     * name is generated by the `IWindowResolver` extension.
+     */
+    windowName?: string;
+  }
+
+  /**
+   * Retrieve all the saved bundles for a given namespace in local storage.
+   *
+   * @param prefix - The namespace to retrieve.
+   *
+   * @param mask - Optional mask function to transform each key retrieved.
+   *
+   * @returns A collection of data payloads for a given prefix.
+   *
+   * #### Notes
+   * If there are any errors in retrieving the data, they will be logged to the
+   * console in order to optimistically return any extant data without failing.
+   */
+  export
+  function fetchNamespace(namespace: string, mask: (key: string) => string = key => key): IStateItem[] {
+    const { localStorage } = window;
+
+    let items: IStateItem[] = [];
+    let i = localStorage.length;
+
+    while (i) {
+      let key = localStorage.key(--i);
+
+      if (key && key.indexOf(namespace) === 0) {
+        let value = localStorage.getItem(key);
+
+        try {
+          let envelope = JSON.parse(value) as Private.Envelope;
+
+          items.push({
+            id: mask(key),
+            value: envelope ? envelope.v : undefined
+          });
+        } catch (error) {
+          console.warn(error);
+          localStorage.removeItem(key);
+        }
+      }
+    }
+
+    return items;
+  }
+
+
+  /**
+   * Return a serialized copy of a namespace's contents from local storage.
+   *
+   * @returns The namespace contents as JSON.
+   */
+  export
+  function toJSON(namespace: string, mask: (key: string) => string = key => key): ReadonlyJSONObject {
+    return fetchNamespace(namespace, mask).reduce((acc, val) => {
+      acc[val.id] = val.value;
+      return acc;
+    }, { } as Partial<ReadonlyJSONObject>);
   }
 }
 

+ 1 - 1
packages/coreutils/src/url.ts

@@ -104,7 +104,7 @@ namespace URLExt {
    * Return a parsed object that represents the values in a query string.
    */
   export
-  function queryStringToObject(value: string): JSONObject {
+  function queryStringToObject(value: string): { [key: string]: string } {
     return value.replace(/^\?/, '').split('&').reduce((acc, val) => {
       const [key, value] = val.split('=');
 

+ 1 - 4
packages/services/src/workspace/index.ts

@@ -77,10 +77,7 @@ class WorkspaceManager {
     const { baseUrl, pageUrl } = serverSettings;
     const base = baseUrl + pageUrl;
     const url = Private.url(base, id);
-    const init = {
-      body: JSON.stringify(workspace),
-      method: 'PUT'
-    };
+    const init = { body: JSON.stringify(workspace), method: 'PUT' };
     const promise = ServerConnection.makeRequest(url, init, serverSettings);
 
     return promise.then(response => {

+ 5 - 7
packages/theme-dark-extension/src/index.ts

@@ -16,15 +16,13 @@ import {
 const plugin: JupyterLabPlugin<void> = {
   id: '@jupyterlab/theme-dark-extension:plugin',
   requires: [IThemeManager],
-  activate: function(app: JupyterLab, manager: IThemeManager) {
+  activate: (app: JupyterLab, manager: IThemeManager) => {
+    const style = '@jupyterlab/theme-dark-extension/index.css';
+
     manager.register({
       name: 'JupyterLab Dark',
-      load: function() {
-        return manager.loadCSS('@jupyterlab/theme-dark-extension/index.css');
-      },
-      unload: function() {
-        return Promise.resolve(void 0);
-      }
+      load: () => manager.loadCSS(style),
+      unload: () => Promise.resolve(undefined)
     });
   },
   autoStart: true

+ 4 - 6
packages/theme-light-extension/src/index.ts

@@ -17,14 +17,12 @@ const plugin: JupyterLabPlugin<void> = {
   id: '@jupyterlab/theme-light-extension:plugin',
   requires: [IThemeManager],
   activate: function(app: JupyterLab, manager: IThemeManager) {
+    const style = '@jupyterlab/theme-light-extension/index.css';
+
     manager.register({
       name: 'JupyterLab Light',
-      load: function() {
-        return manager.loadCSS('@jupyterlab/theme-light-extension/index.css');
-      },
-      unload: function() {
-        return Promise.resolve(void 0);
-      }
+      load: () => manager.loadCSS(style),
+      unload: () => Promise.resolve(undefined)
     });
   },
   autoStart: true

+ 7 - 1
tests/test-coreutils/src/statedb.spec.ts

@@ -30,15 +30,21 @@ describe('StateDB', () => {
       let key = 'foo';
       let correct = 'bar';
       let incorrect = 'baz';
+      let transformation: StateDB.DataTransform = {
+        type: 'overwrite',
+        contents: { [key]: correct }
+      };
 
       // By sharing a namespace, the two databases will share data.
       prepopulate.save(key, incorrect)
+        .then(() => prepopulate.fetch(key))
+        .then(value => { expect(value).to.be(incorrect); })
+        .then(() => { transform.resolve(transformation); })
         .then(() => db.fetch(key))
         .then(value => { expect(value).to.be(correct); })
         .then(() => db.clear())
         .then(done)
         .catch(done);
-      transform.resolve({ type: 'overwrite', contents: { [key]: correct } });
     });
 
     it('should allow a merge data transformation', done => {

+ 1 - 1
yarn.lock

@@ -8176,7 +8176,7 @@ unzip-response@^2.0.1:
   version "2.0.1"
   resolved "https://registry.npmjs.org/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
 
-upath@^1.0.0, upath@^1.1.0:
+upath@^1.0.0:
   version "1.1.0"
   resolved "https://registry.npmjs.org/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd"