WebUI: Introduce Observable, to replace Polymer subproperty observation.

Observable observers produce the same notifications as Polymer observers
without actually using Polymer (see a more detailed design at
go/subproperty-observation). This will allow WebUIs that heavily rely on
Polymer's subproperty observation to migrate to Lit, starting with Print
Preview.

Bug: 331681689
Change-Id: I4cbc02525d021bd78fc5c332ac1d6f5162b5c2fa
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/5405773
Reviewed-by: Rebekah Potter <rbpotter@chromium.org>
Commit-Queue: Demetrios Papadopoulos <dpapad@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1448627}
diff --git a/chrome/browser/resources/print_preview/BUILD.gn b/chrome/browser/resources/print_preview/BUILD.gn
index 4d17415..4cf654c 100644
--- a/chrome/browser/resources/print_preview/BUILD.gn
+++ b/chrome/browser/resources/print_preview/BUILD.gn
@@ -65,6 +65,7 @@
     "data/margins.ts",
     "data/measurement_system.ts",
     "data/model.ts",
+    "data/observable.ts",
     "data/printable_area.ts",
     "data/scaling.ts",
     "data/size.ts",
diff --git a/chrome/browser/resources/print_preview/data/observable.ts b/chrome/browser/resources/print_preview/data/observable.ts
new file mode 100644
index 0000000..94445e6
--- /dev/null
+++ b/chrome/browser/resources/print_preview/data/observable.ts
@@ -0,0 +1,340 @@
+// Copyright 2025 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Non-Polymer subproperty observation implementation, to facilitate Lit
+// migrations for WebUIs that heavily depend on Polymer's subproperty
+// observation.
+//
+// High level design:
+//  - buildProxy(): Helper function to wrap the object to be observed with an ES
+//    Proxy. The function calls itself recursively (lazily) to create as many
+//    Proxy instances as needed to intercept all sub-objects/arrays. 'callback'
+//    is invoked whenever any path changes. This function is the primitive upon
+//    the rest of this approach is based on.
+//  - ObserverTree: A tree data structure of ObserverNode instances, to keep
+//    track of observers that are registered for each node of the original
+//    object.
+//  - Observable: The class to be used by client code to observe an object,
+//    leverages buildProxy and ObserverTree internally.
+//
+//  The format of change notifications is following Polymer observers
+//  notifications (with extensive test coverage to ensure compatibility), to
+//  make it easier to migrate Polymer observers to Observable observers.
+//
+//  Note: Array push() and splice() notifications are not implemented yet, will
+//  be implemented if/when the need arises.
+
+import {assert} from 'chrome://resources/js/assert.js';
+
+export type ChangeCallback =
+    (newValue: any, previousValue: any, path: string) => void;
+
+export interface WildcardChangeRecord {
+  path: string;
+  value: any;
+  base: Record<string, any>;
+}
+
+type WildcardChangeCallback = (change: WildcardChangeRecord) => void;
+
+function buildProxy(
+    obj: Record<string, any>, callback: ChangeCallback, path: string[],
+    proxyCache: WeakMap<object, object>): Record<string, any> {
+  function getPath(prop: string): string {
+    return path.slice(1).concat(prop).join('.');
+  }
+
+  return new Proxy(obj, {
+    get(target: Record<string, any>, prop: string) {
+      const value = target[prop];
+
+      if (value && typeof value === 'object' &&
+          ['Array', 'Object'].includes(value.constructor.name)) {
+        let proxy = proxyCache.get(value) || null;
+        if (proxy === null) {
+          proxy = buildProxy(value, callback, path.concat(prop), proxyCache);
+          proxyCache.set(value, proxy);
+        }
+        return proxy;
+      }
+
+      return value;
+    },
+
+    set(target: Record<string, any>, prop: string, value: any) {
+      const previousValue = target[prop];
+
+      if (previousValue === value) {
+        return true;
+      }
+
+      target[prop] = value;
+      callback(value, previousValue, getPath(prop));
+      return true;
+    },
+  });
+}
+
+function getValueAtPath(pathParts: string[], obj: Record<string, any>) {
+  let result: Record<string, any> = obj;
+  let counter = pathParts.length;
+  while (counter > 1) {
+    const current = pathParts[pathParts.length - counter--];
+    result = result[current];
+  }
+  return result[pathParts.at(-1)!];
+}
+
+interface ObserverEntry {
+  id: number;
+  callback: ChangeCallback|WildcardChangeCallback;
+  isWildcard: boolean;
+}
+
+interface ObserverNode {
+  parent?: ObserverNode;
+  key: string;
+  observers?: Set<ObserverEntry>;
+  children?: Map<string, ObserverNode>;
+}
+
+// A tree data structure to keep track of observers for a nested data object. It
+// replicates the behavior of Polymer observers.
+class ObserverTree {
+  root: ObserverNode = {key: ''};
+
+  // Internal book keeping to make it easy to remove observers using an ID.
+  private nextObserverId_: number = -1;
+  private observers_: Map<number, ObserverNode> = new Map();
+
+  getNode(path: string, create: boolean = false): ObserverNode|null {
+    const pathParts = path.split('.');
+    let node: ObserverNode|null = this.root;
+
+    while (pathParts.length > 0 && node !== null) {
+      const currentPart = pathParts.shift()!;
+      if (create && !node.children) {
+        node.children = new Map();
+      }
+
+      let child: ObserverNode|null = node.children?.get(currentPart) || null;
+
+      if (create && child === null) {
+        child = {parent: node, key: currentPart};
+        node.children!.set(child.key, child);
+      }
+
+      node = child;
+    }
+
+    return node;
+  }
+
+  // Traverses all nodes between the root of the tree and the node corresponding
+  // to 'path' (including that node as well).
+  traversePath(
+      path: string,
+      callback:
+          (node: ObserverNode, isLast: boolean, pathParts: string[]) => void):
+      void {
+    const pathParts = path.split('.');
+    const traversedParts = [this.root.key];
+    let node: ObserverNode|null = this.root;
+    callback(node, false, traversedParts);
+
+    while (pathParts.length > 0 && node !== null) {
+      const currentPart = pathParts.shift()!;
+      traversedParts.push(currentPart);
+      node = node.children?.get(currentPart) || null;
+      if (node !== null) {
+        callback(node, pathParts.length === 0, traversedParts);
+      }
+    }
+  }
+
+  // Traverses all nodes from the given root node and below (including the root
+  // itself) and invokes the callback on each node visited. Besides the
+  // current node, it also passes the path from the original input `node` to the
+  // current node.
+  traverseTree(
+      node: ObserverNode,
+      callback: (node: ObserverNode, relativePath: string[]) => void): void {
+    function visitNode(node: ObserverNode, relativePath: string[]) {
+      callback(node, relativePath);
+
+      if (node.children) {
+        for (const child of node.children.values()) {
+          visitNode(child, relativePath.concat(child.key));
+        }
+      }
+    }
+
+    visitNode(node, []);
+  }
+
+  addObserver(path: string, callback: ChangeCallback): number {
+    let effectivePath = path;
+
+    // Observers ending with '.*' receive notifications for any change
+    // happening under the corresponding node.
+    const isWildcard = path.endsWith('.*');
+    if (isWildcard) {
+      effectivePath = path.slice(0, -2);
+    }
+
+    const node = this.getNode(effectivePath, /*create=*/ true)!;
+    if (!node.observers) {
+      node.observers = new Set();
+    }
+
+    // Add observer to the ObserverNode.
+    const id = ++this.nextObserverId_;
+    node.observers.add({id, isWildcard, callback});
+
+    // Add entry in `observers_` to be used in removeObserver.
+    this.observers_.set(id, node);
+
+    return id;
+  }
+
+  removeObserver(id: number): boolean {
+    const node = this.observers_.get(id) || null;
+    if (!node) {
+      return false;
+    }
+
+    assert(node.observers);
+    const observerEntry =
+        Array.from(node.observers).find(node => node.id === id) || null;
+    assert(observerEntry);
+
+    this.observers_.delete(id);
+    const deleted = node.observers.delete(observerEntry);
+    assert(deleted);
+
+    return true;
+  }
+
+  removeAllObservers() {
+    for (const id of this.observers_.keys()) {
+      this.removeObserver(id);
+    }
+  }
+}
+
+export class Observable<T extends Record<string, any>> {
+  private proxyCache_: WeakMap<object, object> = new WeakMap();
+  private proxy_: T;
+  private target_: T;
+
+  private observerTree_ = new ObserverTree();
+
+  constructor(target: T) {
+    this.target_ = target;
+    this.proxy_ =
+        buildProxy(target, this.onChange_.bind(this), [''], this.proxyCache_) as
+        T;
+  }
+
+  getProxy(): T {
+    return this.proxy_;
+  }
+
+  // Returns the original raw object. Useful when it needs to be serialized.
+  getTarget(): T {
+    return this.target_;
+  }
+
+  private onChange_(newValue: any, previousValue: any, path: string) {
+    let lastNode: ObserverNode|null = null;
+
+    this.observerTree_.traversePath(
+        path, (node: ObserverNode, isLast: boolean, pathParts: string[]) => {
+          if (isLast) {
+            // Remember the last ObserverNode on 'path' for later.
+            lastNode = node;
+            return;
+          }
+
+          if (node.observers) {
+            const base = getValueAtPath(pathParts.slice(1), this.proxy_);
+            for (const {isWildcard, callback} of node.observers) {
+              // Notify '.*' observers between the root and the modified node.
+              // For wildcard observers above the changed node, report the
+              // changed path and new values verbatim.
+              if (isWildcard) {
+                (callback as
+                 WildcardChangeCallback)({path, value: newValue, base});
+              }
+            }
+          }
+        });
+
+    if (lastNode === null) {
+      // No observers exist. Nothing to do.
+      return;
+    }
+
+    // Notify observers directly on the modified node or anywhere below it.
+    this.observerTree_.traverseTree(
+        lastNode, (node: ObserverNode, relativePath: string[]) => {
+          if (!node.observers) {
+            return;
+          }
+
+          let observerNewValue = newValue;
+          let observerPreviousValue = previousValue;
+
+          // Calculate the `newValue` and `previousValue` from each observer's
+          // point of view.
+          if (node !== lastNode) {
+            observerNewValue = getValueAtPath(relativePath, newValue);
+            observerPreviousValue = getValueAtPath(relativePath, previousValue);
+          }
+
+          const observedPath = [path, ...relativePath].join('.');
+
+          for (const observer of node.observers) {
+            if (observer.isWildcard) {
+              if (node !== lastNode) {
+                // For wildcard observers below the changed node, report the
+                // observed path as 'path' and the relative new value as
+                // 'value' and 'base'. This is to maintain parity with Polymer,
+                // even though it is a bit odd.
+                (observer.callback as WildcardChangeCallback)({
+                  path: observedPath,
+                  value: observerNewValue,
+                  base: observerNewValue,
+                });
+              } else {
+                // For wildcard observers at the changed node, report the
+                // changed path as 'path' and the new value verbatim as
+                // 'value'.
+                (observer.callback as WildcardChangeCallback)(
+                    {path, value: newValue, base: newValue});
+              }
+              continue;
+            }
+
+            // For non-wildcard observers below or at the changed node, report
+            // the observed path as 'path' and the relative new value as
+            // 'value'.
+            observer.callback(
+                observerNewValue, observerPreviousValue, observedPath);
+          }
+        });
+  }
+
+  addObserver(path: string, callback: ChangeCallback): number {
+    return this.observerTree_.addObserver(path, callback);
+  }
+
+  removeObserver(id: number): boolean {
+    return this.observerTree_.removeObserver(id);
+  }
+
+  removeAllObservers() {
+    return this.observerTree_.removeAllObservers();
+  }
+}
diff --git a/chrome/browser/resources/print_preview/print_preview.ts b/chrome/browser/resources/print_preview/print_preview.ts
index ac9a9ef..12d0b198 100644
--- a/chrome/browser/resources/print_preview/print_preview.ts
+++ b/chrome/browser/resources/print_preview/print_preview.ts
@@ -17,6 +17,7 @@
 export {CustomMarginsOrientation, Margins, MarginsSetting, MarginsType} from './data/margins.js';
 export {MeasurementSystem, MeasurementSystemUnitType} from './data/measurement_system.js';
 export {DuplexMode, getInstance, PolicyObjectEntry, PrintPreviewModelElement, PrintTicket, SerializedSettings, Setting, Settings, whenReady} from './data/model.js';
+export {Observable, WildcardChangeRecord} from './data/observable.js';
 export {ScalingType} from './data/scaling.js';
 export {Size} from './data/size.js';
 export {Error, State} from './data/state.js';
diff --git a/chrome/test/data/webui/print_preview/BUILD.gn b/chrome/test/data/webui/print_preview/BUILD.gn
index bff906462..ba481943 100644
--- a/chrome/test/data/webui/print_preview/BUILD.gn
+++ b/chrome/test/data/webui/print_preview/BUILD.gn
@@ -39,6 +39,7 @@
     "native_layer_stub.ts",
     "number_settings_section_interactive_test.ts",
     "number_settings_section_test.ts",
+    "observable_test.ts",
     "other_options_settings_test.ts",
     "pages_per_sheet_settings_test.ts",
     "pages_settings_test.ts",
diff --git a/chrome/test/data/webui/print_preview/observable_test.ts b/chrome/test/data/webui/print_preview/observable_test.ts
new file mode 100644
index 0000000..d35cfea0
--- /dev/null
+++ b/chrome/test/data/webui/print_preview/observable_test.ts
@@ -0,0 +1,447 @@
+// Copyright 2025 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {Observable} from 'chrome://print/print_preview.js';
+import type {WildcardChangeRecord} from 'chrome://print/print_preview.js';
+import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
+import {assertDeepEquals, assertEquals, assertFalse} from 'chrome://webui-test/chai_assert.js';
+
+suite('Observable', function() {
+  interface Prefs {
+    foo: {value: number};
+    bar: {value: number};
+  }
+
+  function createPrefs(): Prefs {
+    return {
+      foo: {value: 1},
+      bar: {value: 2},
+    };
+  }
+
+  let observable: Observable<Prefs>;
+  let prefs: Prefs;
+
+  setup(function() {
+    document.body.innerHTML = window.trustedTypes!.emptyHTML;
+    observable = new Observable(createPrefs());
+    prefs = observable.getProxy();
+  });
+
+  test('ObserversAddedRemoved', () => {
+    let fired: string[] = [];
+
+    // Add observers and remember their IDs.
+    const ids = [
+      observable.addObserver('foo.*', () => fired.push('foo.*')),
+      observable.addObserver('foo', () => fired.push('foo')),
+      observable.addObserver('foo.value', () => fired.push('foo.value')),
+    ];
+
+    // Case1: Modifying an entire subtree.
+    prefs.foo = {value: 3};
+    assertDeepEquals(['foo.*', 'foo', 'foo.value'], fired);
+
+    // Case2: Modifying a leaf node.
+    fired = [];
+    prefs.foo.value = 4;
+    assertDeepEquals(['foo.*', 'foo.value'], fired);
+
+    // Case3: Modifying an entire subtree for a node that has no observers.
+    // Ensure no error is thrown.
+    fired = [];
+    prefs.bar = {value: 5};
+    assertDeepEquals([], fired);
+
+    // Case4: Modifying a leaf node for a node that has no observers. Ensure no
+    // error is thrown.
+    fired = [];
+    prefs.bar.value = 6;
+    assertDeepEquals([], fired);
+
+    // Remove observers and ensure they no longer trigger.
+
+    fired = [];
+    observable.removeObserver(ids[0]!);
+    prefs.foo = {value: 7};
+    assertDeepEquals(['foo', 'foo.value'], fired);
+
+    fired = [];
+    observable.removeObserver(ids[1]!);
+    prefs.foo = {value: 8};
+    assertDeepEquals(['foo.value'], fired);
+
+    fired = [];
+    observable.removeAllObservers();
+    prefs.foo = {value: 9};
+    assertDeepEquals([], fired);
+  });
+
+  test('ObserverParmetersRelativeToObservedPath', () => {
+    const notifications: Map<string, any[]> = new Map();
+
+    observable.addObserver('foo', (...args) => {
+      notifications.set('foo', args);
+    });
+    observable.addObserver('foo.value', (...args) => {
+      notifications.set('foo.value', args);
+    });
+    observable.addObserver('foo.*', change => {
+      notifications.set('foo.*', change);
+    });
+
+    observable.addObserver('foo.value.*', change => {
+      notifications.set('foo.value.*', change);
+    });
+
+    // ------------- Case1: Modify an entire subtree. -------------------------
+    notifications.clear();
+    prefs.foo = {value: 3};
+
+    // Check for notifications for observers of the changed node itself.
+    assertDeepEquals([{value: 3}, {value: 1}, 'foo'], notifications.get('foo'));
+
+    // Check notifications for observers below the changed node.
+    assertDeepEquals([3, 1, 'foo.value'], notifications.get('foo.value'));
+
+    // Check notifications for "star" observers at the changed node.
+    assertDeepEquals(
+        {path: 'foo', value: {value: 3}, base: {value: 3}},
+        notifications.get('foo.*'));
+
+    // Check notifications for "star" observers below the changed node.
+    assertDeepEquals(
+        {'path': 'foo.value', 'value': 3, 'base': 3},
+        notifications.get('foo.value.*'));
+
+    // ------------- Case2: Modify a leaf node. --------------------------------
+    notifications.clear();
+    prefs.foo.value = 4;
+
+    // Check notifications for non-star observers above the changed node (there
+    // should be no notifications).
+    assertFalse(notifications.has('foo'));
+
+    // Check notifications for observers of the changed node itself.
+    assertDeepEquals([4, 3, 'foo.value'], notifications.get('foo.value'));
+
+    // Check notifications for "star" observers above the changed node.
+    assertDeepEquals(
+        {path: 'foo.value', value: 4, base: {value: 4}},
+        notifications.get('foo.*'));
+
+    // Check notifications for "star" observers at the changed node.
+    assertDeepEquals(
+        {path: 'foo.value', value: 4, base: 4},
+        notifications.get('foo.value.*'));
+  });
+});
+
+
+suite('ObservablePolymerCompatibility', function() {
+  interface Prefs {
+    foo: {value: number};
+    bar: {value: number[]};
+  }
+
+  function createPrefs(): Prefs {
+    return {
+      foo: {value: 1},
+      bar: {value: [0, 1]},
+    };
+  }
+
+  class TestElement extends PolymerElement {
+    static get is() {
+      return 'test-element';
+    }
+
+    static get properties() {
+      return {
+        prefs: Object,
+      };
+    }
+
+    declare prefs: Prefs;
+    observable: Observable<Prefs>;
+
+    polymerNotifications: Map<string, any[]> = new Map();
+    observableNotifications: Map<string, any[]> = new Map();
+
+    // Register Polymer observers.
+    static get observers() {
+      return [
+        // Regular observers.
+        'onFooChanged_(prefs.foo)',
+        'onFooValueChanged_(prefs.foo.value)',
+        // Wildcard observers.
+        'onFooStarChanged_(prefs.foo.*)',
+        'onFooValueStarChanged_(prefs.foo.value.*)',
+
+        // Regular array observers.
+        'onBarValueChanged_(prefs.bar.value)',
+        'onBarValueZeroChanged_(prefs.bar.value.0)',
+        'onBarValueOneChanged_(prefs.bar.value.1)',
+        'onBarValueLengthChanged_(prefs.bar.value.length)',
+        // Wildcard array observers.
+        'onBarValueStarChanged_(prefs.bar.value.*)',
+      ];
+    }
+
+    constructor() {
+      super();
+
+      this.observable = new Observable<Prefs>(createPrefs());
+      this.prefs = this.observable.getProxy();
+
+      // Register `Observable` observers (alternative non-Polymer mechanism).
+      this.observable.addObserver('foo', (...args) => {
+        this.observableNotifications.set('foo', args);
+      });
+      this.observable.addObserver('foo.value', (...args) => {
+        this.observableNotifications.set('foo.value', args);
+      });
+      this.observable.addObserver('foo.*', (change: WildcardChangeRecord) => {
+        this.observableNotifications.set('foo.*', [change]);
+      });
+      this.observable.addObserver(
+          'foo.value.*', (change: WildcardChangeRecord) => {
+            this.observableNotifications.set('foo.value.*', [change]);
+          });
+
+      // Register `Observable` observers for an array property.
+      this.observable.addObserver('bar.value', (...args) => {
+        this.observableNotifications.set('bar.value', args);
+      });
+      this.observable.addObserver('bar.value.0', (...args) => {
+        this.observableNotifications.set('bar.value.0', args);
+      });
+      this.observable.addObserver('bar.value.1', (...args) => {
+        this.observableNotifications.set('bar.value.1', args);
+      });
+      this.observable.addObserver('bar.value.length', (...args) => {
+        this.observableNotifications.set('bar.value.length', args);
+      });
+      this.observable.addObserver(
+          'bar.value.*', (change: WildcardChangeRecord) => {
+            this.observableNotifications.set('bar.value.*', [change]);
+          });
+    }
+
+    protected onFooChanged_(...args: any[]) {
+      this.polymerNotifications.set('foo', args);
+    }
+
+    protected onFooValueChanged_(...args: any[]) {
+      this.polymerNotifications.set('foo.value', args);
+    }
+
+    protected onFooStarChanged_(change: WildcardChangeRecord) {
+      this.polymerNotifications.set('foo.*', [change]);
+    }
+
+    protected onFooValueStarChanged_(change: WildcardChangeRecord) {
+      this.polymerNotifications.set('foo.value.*', [change]);
+    }
+
+    /**
+     * @param directAccess Whether to skip Polymer's set() helper and directly
+     *     operate on the 'prefs' object. This is used to ensure that changes
+     *     made either way propagate to bot types of observers (Polymer and
+     *     Observable).
+     */
+    modifyFoo(directAccess: boolean) {
+      if (directAccess) {
+        this.prefs.foo = {value: 3};
+        this.notifyPath('prefs.foo');
+        return;
+      }
+
+      this.set('prefs.foo', {value: 3});
+    }
+
+    /**
+     * @param directAccess Whether to skip Polymer's set() helper and directly
+     *     operate on the 'prefs' object. This is used to ensure that changes
+     *     made either way propagate to bot types of observers (Polymer and
+     *     Observable).
+     */
+    modifyFooValue(directAccess: boolean) {
+      if (directAccess) {
+        this.prefs.foo.value = 4;
+        this.notifyPath('prefs.foo.value');
+        return;
+      }
+
+      this.set('prefs.foo.value', 4);
+    }
+
+    protected onBarValueChanged_(...args: any[]) {
+      this.polymerNotifications.set('bar.value', args);
+    }
+
+    protected onBarValueZeroChanged_(...args: any[]) {
+      this.polymerNotifications.set('bar.value.0', args);
+    }
+
+    protected onBarValueOneChanged_(...args: any[]) {
+      this.polymerNotifications.set('bar.value.1', args);
+    }
+
+    protected onBarValueLengthChanged_(...args: any[]) {
+      this.polymerNotifications.set('bar.value.length', args);
+    }
+
+    protected onBarValueStarChanged_(...args: any[]) {
+      this.polymerNotifications.set('bar.value.*', args);
+    }
+
+    modifyBarValue(directAccess: boolean) {
+      if (directAccess) {
+        this.prefs.bar.value = [4, 5];
+        this.notifyPath('prefs.bar.value');
+        return;
+      }
+
+      this.set('prefs.bar.value', [4, 5]);
+    }
+
+    modifyBarValueReplaceItem(directAccess: boolean) {
+      if (directAccess) {
+        this.prefs.bar.value[0] = 100;
+        this.notifyPath('prefs.bar.value.0');
+        return;
+      }
+
+      this.set('prefs.bar.value.0', 100);
+    }
+
+    // TODO(crbug.com/331681689): Implement notification checks for push(),
+    // splice() and add tests.
+    /*modifyBarValuePushItem() {
+      this.push('prefs.bar.value', 200);
+    }*/
+
+    clear() {
+      this.polymerNotifications.clear();
+      this.observableNotifications.clear();
+    }
+  }
+
+  customElements.define(TestElement.is, TestElement);
+
+  let element: TestElement;
+
+  setup(function() {
+    document.body.innerHTML = window.trustedTypes!.emptyHTML;
+    element = document.createElement('test-element') as TestElement;
+    document.body.appendChild(element);
+  });
+
+  function assertNotifications(
+      polymerExpectation: any[], observableExpectation: any[],
+      observerPath: string) {
+    assertEquals(
+        JSON.stringify(polymerExpectation),
+        JSON.stringify(element.polymerNotifications.get(observerPath)));
+    assertEquals(
+        JSON.stringify(observableExpectation),
+        JSON.stringify(element.observableNotifications.get(observerPath)));
+  }
+
+  function testNotifications(directAccess: boolean) {
+    element.clear();
+
+    // Case1: Modify an entire object (non-leaf node).
+    element.modifyFoo(directAccess);
+    assertEquals(4, element.polymerNotifications.size);
+    assertEquals(4, element.observableNotifications.size);
+
+    // Regular observers.
+    assertNotifications(
+        [{'value': 3}], [{'value': 3}, {'value': 1}, 'foo'], 'foo');
+    assertNotifications([3], [3, 1, 'foo.value'], 'foo.value');
+    // Wildcard observers.
+    assertNotifications(
+        [{'path': 'prefs.foo', 'value': {'value': 3}, 'base': {'value': 3}}],
+        [{'path': 'foo', 'value': {'value': 3}, 'base': {'value': 3}}],
+        'foo.*');
+    assertNotifications(
+        [{'path': 'prefs.foo.value', 'value': 3, 'base': 3}],
+        [{'path': 'foo.value', 'value': 3, 'base': 3}], 'foo.value.*');
+
+    element.clear();
+
+    // Case2: Modify a value (leaf node).
+    element.modifyFooValue(directAccess);
+    assertEquals(3, element.polymerNotifications.size);
+    assertEquals(3, element.observableNotifications.size);
+
+    // Regular observers.
+    assertNotifications([4], [4, 3, 'foo.value'], 'foo.value');
+
+    // Wildcard observers.
+    assertNotifications(
+        [{'path': 'prefs.foo.value', 'value': 4, 'base': {'value': 4}}],
+        [{'path': 'foo.value', 'value': 4, 'base': {'value': 4}}], 'foo.*');
+    assertNotifications(
+        [{'path': 'prefs.foo.value', 'value': 4, 'base': 4}],
+        [{'path': 'foo.value', 'value': 4, 'base': 4}], 'foo.value.*');
+  }
+
+  test('MutateViaPolymer', function() {
+    testNotifications(/*directAccess=*/ false);
+  });
+
+  test('MutateViaDirectAccess', function() {
+    testNotifications(/*directAccess=*/ true);
+  });
+
+  function testArrayNotifications(directAccess: boolean) {
+    element.clear();
+    assertEquals(0, element.polymerNotifications.size);
+    assertEquals(0, element.observableNotifications.size);
+
+    // Case1: Modify the entire array (non-leaf node).
+    element.modifyBarValue(directAccess);
+    assertEquals(5, element.polymerNotifications.size);
+    assertEquals(5, element.observableNotifications.size);
+
+    // Regular observers.
+    assertNotifications([[4, 5]], [[4, 5], [0, 1], 'bar.value'], 'bar.value');
+    assertNotifications([4], [4, 0, 'bar.value.0'], 'bar.value.0');
+    assertNotifications([5], [5, 1, 'bar.value.1'], 'bar.value.1');
+    assertNotifications([2], [2, 2, 'bar.value.length'], 'bar.value.length');
+
+    // Wildcard observers.
+    assertNotifications(
+        [{'path': 'prefs.bar.value', 'value': [4, 5], 'base': [4, 5]}],
+        [{'path': 'bar.value', 'value': [4, 5], 'base': [4, 5]}],
+        'bar.value.*');
+
+    element.clear();
+
+    // Case2: Modify a specific position in the array (leaf node).
+    element.modifyBarValueReplaceItem(directAccess);
+    assertEquals(2, element.polymerNotifications.size);
+    assertEquals(2, element.observableNotifications.size);
+
+    // Regular observers.
+    assertNotifications([100], [100, 4, 'bar.value.0'], 'bar.value.0');
+
+    // Wildcard observers.
+    assertNotifications(
+        [{'path': 'prefs.bar.value.0', 'value': 100, 'base': [100, 5]}],
+        [{'path': 'bar.value.0', 'value': 100, 'base': [100, 5]}],
+        'bar.value.*');
+  }
+
+  test('MutateArrayViaPolymer', function() {
+    testArrayNotifications(/*directAccess=*/ false);
+  });
+
+  test('MutateArrayViaDirectAccess', function() {
+    testArrayNotifications(/*directAccess=*/ true);
+  });
+});
diff --git a/chrome/test/data/webui/print_preview/print_preview_browsertest.cc b/chrome/test/data/webui/print_preview/print_preview_browsertest.cc
index 75364d1..f84fa0e 100644
--- a/chrome/test/data/webui/print_preview/print_preview_browsertest.cc
+++ b/chrome/test/data/webui/print_preview/print_preview_browsertest.cc
@@ -78,6 +78,10 @@
   RunTest("print_preview/settings_select_test.js", "mocha.run()");
 }
 
+IN_PROC_BROWSER_TEST_F(PrintPreviewTest, Observable) {
+  RunTest("print_preview/observable_test.js", "mocha.run()");
+}
+
 class PrintPreviewAppTest : public PrintPreviewBrowserTest {
  protected:
   void RunTestCase(const std::string& testCase) {