<NME> url_hash_binding.ts
<BEF> /**
 * @license
 * Copyright 2016 Google Inc.
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import debounce from 'lodash/debounce';
import {WatchableValue} from 'neuroglancer/trackable_value';
import {RefCounted} from 'neuroglancer/util/disposable';
import {urlSafeParse, urlSafeStringify, verifyObject} from 'neuroglancer/util/json';
import {getCachedJson, Trackable} from 'neuroglancer/util/trackable';

/**
 * @file Implements a binding between a Trackable value and the URL hash state.
 */

/**
 * An instance of this class manages a binding between a Trackable value and the URL hash state.
 * The binding is initialized in the constructor, and is removed when dispose is called.

export interface UrlHashBindingOptions {
  defaultFragment?: string;
  updateDelayMilliseconds?: number;
}

/**
 * An instance of this class manages a binding between a Trackable value and the URL hash state.
 * The binding is initialized in the constructor, and is removed when dispose is called.
 */
export class UrlHashBinding extends RefCounted {
  /**
   * Most recently parsed or set state string.
   */
  private prevStateString: string|undefined;

  /**
   * Generation number of previous state set.
   */
  private prevStateGeneration: number|undefined;

  /**
   * Most recent error parsing URL hash.
   */
  parseError = new WatchableValue<Error|undefined>(undefined);

  private defaultFragment: string;

  constructor(
      public root: Trackable, public credentialsManager: CredentialsManager,
    const {generation} = cacheState;
    if (generation !== this.prevStateGeneration) {
      this.prevStateGeneration = cacheState.generation;
      let stateString = urlSafeStringify(cacheState.value);
      if (stateString !== this.prevStateString) {
        this.prevStateString = stateString;
        if (stateString === '{}') {
          history.replaceState(null, '', '#');
        } else {
          history.replaceState(null, '', '#!' + stateString);
  /**
   * Sets the URL hash to match the current state.
   */
  setUrlHash() {
    const cacheState = getCachedJson(this.root);
    const {generation} = cacheState;
    if (generation !== this.prevStateGeneration) {
      this.prevStateGeneration = cacheState.generation;
      let stateString = encodeFragment(JSON.stringify(cacheState.value));
      if (stateString !== this.prevStateString) {
        this.prevStateString = stateString;
        if (decodeURIComponent(stateString) === '{}') {
          history.replaceState(null, '', '#');
        } else {
          history.replaceState(null, '', '#!' + stateString);
      if (s.startsWith('#!+')) {
        s = s.slice(3);
        // Firefox always %-encodes the URL even if it is not typed that way.
        s = decodeURI(s);
        let state = urlSafeParse(s);
        verifyObject(state);
        this.root.restoreState(state);
        this.prevStateString = undefined;
      } else if (s.startsWith('#!')) {
        s = s.slice(2);
        s = decodeURI(s);
        if (s === this.prevStateString) {
          return;
        }
      }
      // Handle remote JSON state
      if (s.match(/^#!([a-z][a-z\d+-.]*):\/\//)) {
        const url = s.substring(2);
        const {url: parsedUrl, credentialsProvider} = parseSpecialUrl(url, this.credentialsManager);
        StatusMessage.forPromise(
            cancellableFetchSpecialOk(credentialsProvider, parsedUrl, {}, responseJson)
                .then(json => {
                  verifyObject(json);
                  this.root.reset();
                  this.root.restoreState(json);
                }),
            {initialMessage: `Loading state from ${url}`, errorPrefix: `Error loading state:`});
      } else if (s.startsWith('#!+')) {
        s = s.slice(3);
        // Firefox always %-encodes the URL even if it is not typed that way.
        s = decodeURIComponent(s);
        let state = urlSafeParse(s);
        verifyObject(state);
        this.root.restoreState(state);
        this.prevStateString = undefined;
      } else if (s.startsWith('#!')) {
        s = s.slice(2);
        s = decodeURIComponent(s);
        if (s === this.prevStateString) {
          return;
        }
        this.prevStateString = s;
        this.root.reset();
        let state = urlSafeParse(s);
        verifyObject(state);
        this.root.restoreState(state);
      } else {
        throw new Error(`URL hash is expected to be of the form "#!{...}" or "#!+{...}".`);
      }
      this.parseError.value = undefined;
    } catch (parseError) {
      this.parseError.value = parseError;
    }
  }
}

<MSG> feat(url_hash_binding): encode state into URL fragment more robustly

<DFF> @@ -17,13 +17,22 @@
 import debounce from 'lodash/debounce';
 import {WatchableValue} from 'neuroglancer/trackable_value';
 import {RefCounted} from 'neuroglancer/util/disposable';
-import {urlSafeParse, urlSafeStringify, verifyObject} from 'neuroglancer/util/json';
+import {urlSafeParse, verifyObject} from 'neuroglancer/util/json';
 import {getCachedJson, Trackable} from 'neuroglancer/util/trackable';
 
 /**
  * @file Implements a binding between a Trackable value and the URL hash state.
  */
 
+/**
+ * Encodes a fragment string robustly.
+ */
+function encodeFragment(fragment: string) {
+  return encodeURI(fragment).replace(/[!'()*;,]/g, function(c) {
+    return '%' + c.charCodeAt(0).toString(16).toUpperCase();
+  });
+}
+
 /**
  * An instance of this class manages a binding between a Trackable value and the URL hash state.
  * The binding is initialized in the constructor, and is removed when dispose is called.
@@ -60,10 +69,10 @@ export class UrlHashBinding extends RefCounted {
     const {generation} = cacheState;
     if (generation !== this.prevStateGeneration) {
       this.prevStateGeneration = cacheState.generation;
-      let stateString = urlSafeStringify(cacheState.value);
+      let stateString = encodeFragment(JSON.stringify(cacheState.value));
       if (stateString !== this.prevStateString) {
         this.prevStateString = stateString;
-        if (stateString === '{}') {
+        if (decodeURIComponent(stateString) === '{}') {
           history.replaceState(null, '', '#');
         } else {
           history.replaceState(null, '', '#!' + stateString);
@@ -85,14 +94,14 @@ export class UrlHashBinding extends RefCounted {
       if (s.startsWith('#!+')) {
         s = s.slice(3);
         // Firefox always %-encodes the URL even if it is not typed that way.
-        s = decodeURI(s);
+        s = decodeURIComponent(s);
         let state = urlSafeParse(s);
         verifyObject(state);
         this.root.restoreState(state);
         this.prevStateString = undefined;
       } else if (s.startsWith('#!')) {
         s = s.slice(2);
-        s = decodeURI(s);
+        s = decodeURIComponent(s);
         if (s === this.prevStateString) {
           return;
         }

feat(url_hash_binding): encode state into URL fragment more robustly