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) {