Skip to content
Oxford Harrison edited this page Jul 1, 2023 · 6 revisions

Observer API Docs

ProjectMotivationOverviewPolyfillDiscusionsIssues

Description

A simple set of functions for intercepting and observing JavaScript objects and arrays.

An Overview

As an improvement over its original form - Object.observe(), the object observability API is being re-imagined!

Whereas the previous API lived off the global "Object" object, the new idea is to have it on the global "Reflect" object as if being one of the missing pieces in a puzzle! (This is subject to whether everything considered here still falls within the scope of the Reflect API.) But that's just one of two possibilitis considered! The other is to have it on a new Reflect-like object called Observer, which is, this time, a fully-featured metaprogramming API!

While that unfolds, a prototype of the Observer API exists today as Observer, featuring a superset of each Reflect method in addition to other specialized APIs. Much of the code here is based on this Observer namespace.

Observer API Reflect API
apply() apply()
construct() construct()
observe() -
set() set()
setPrototypeOf() setPrototypeOf()

Being fully compatible with the Reflect API, the Observer API can be used today as a drop-in replacement for Reflect.

To begin, here's the observe() method:

└ Signatures

// Observe all properties
Observer.observe(object, callback);
// Observe a list of properties
Observer.observe(object, [ propertyName, ... ], callback);
// Observe a value
Observer.observe(object, propertyName, inspect);

└ Handler

function callback(mutations) {
  mutations.forEach(inspect);
}
function inspect(m) {
  console.log(m.type, m.key, m.value, m.oldValue, m.isUpdate);
}

From here, additional features become necessary!

Featuring AbortSignals

Instead of an .unobserve() method, an AbortController is returned by default for unsubscribing an observer.

const abortController = Observer.observe(object, callback);

└ You stop observing at any time by calling abort() on the returned abortController:

// Remove listener
abortController.abort();

└ And you can provide your own Abort Signal instance:

// Providing an AbortSignal
const abortController = new AbortController;
Observer.observe(object, callback, { signal: abortController.signal } );
// Abort at any time
abortController.abort();

└ This is especially useful for aborting multiple observers at a go! (Compare abortable fetch(), abortable event listeners, and maybe Mass Proxy Revocation)

In addition to returning an AbortController, the emitter also passes in an auto-generated AbortSignal to listeners as a lifecyle flag. This signal automatically aborts at the listener's "next turn", and that makes it possible to tie child observers to a parent listener's lifecycle!

--> Leverage "AbortSignal-cascading" to tie child observers to parent observer's lifecycle:

// Parent
const abortController = Observer.observe(object, (mutations, flags) => {

    // Child 1
    Observer.observe(object, callback, { signal: flags.signal } ); // <<<---- AbortSignal-cascading

    // Child 2
    Observer.observe(object, callback, { signal: flags.signal } ); // <<<---- AbortSignal-cascading

});

"Child listeners" get automatically aborted at parent's "next turn", and at parent's own abortion!

Featuring Path Observability

A much needed feature for Object.observe() was path observability! The void that leaving it out created required much boilerplate to fill. A few polyfills at the time (e.g. Polymer's observe-js) took that to heart. And that should certainly have a place in the new API!

This time, instead of following a string-based "path" approach - level1.level2 - a path is represented by an array - a Path array instance:

const path = Observer.path('level1', 'level2');

An array allows us to support property names that themselves have a dot in them. And by using Observer's Path array instance, we are able to distinguish between normal "property list" array - as seen earlier - and actual "path" array.

The idea here is to observe "the value" at a path in a given tree:

// A tree structure that satisfies the path above
const object = {
  level1: {
    level2: 'level2-value',
  },
};
function inspectPath(m) {
  console.log(m.type, m.path, m.value, m.isUpdate);
}
Observer.observe(object, path, inspectPath);
object.level1.level2 = 'level2-new-value';
Console
type path value isUpdate
set [ level1, level2, ] level2-new-value true

And well, the initial tree structure can be whatever:

// A tree structure that is yet to be built
const object = {};
const path = Observer.path('level1', 'level2', 'level3', 'level4');
Observer.observe(object, path, inspectPath);

Now, any operation that changes what "the value" at the path resolves to - either by tree extension or tree truncation - will fire our listener:

object.level1 = { level2: {}, };
Console
type path value isUpdate
set [ level1, level2, level3, level4, ] undefined false

Meanwhile, this next one completes the tree, and the listener reports a value at its observed path:

object.level1.level2 = { level3: { level4: 'level4-value', }, };
Console
type path value isUpdate
set [ level1, level2, level3, level4, ] level4-value false

If you were to find the exact point at which mutation happened in the path in an audit trail, use the event's context property to inspect the parent event:

let context = m.context;
console.log(context);

And up again one level until the root event:

let parentContext = context.context;
console.log(parentContext);

And you can observe trees that are built asynchronously! Where a promise is encountered along the path, further access is paused until promise resolves:

object.level1.level2 = Promise.resolve({ level3: { level4: 'level4-new-value', }, });

Array and Wildcard Segments

Whereas ordinary path expression like [ 'level1', 'level2', 'level3', 'level4', ] are used to observe "a bare property" at a path, it is possible to observe multiple properties at the same path - this time, using array segments and wildcard segements.

To observe multiple named properties at the same path, an array segment is used:

const path = Observer.path('level1', 'level2', 'level3', [ 'level4_a', 'level4_b' ]);
Observer.observe(object, path, mutations => {
  // Argument #1 is a mutations "array" now
  mutations.forEach(inspectPath);
});

To observe all properties at a path, the Infinity global property is used:

const path = Observer.path('level1', 'level2', 'level3', Infinity);
Observer.observe(object, path, mutations => {
  // Argument #1 is also a mutations "array" now
  mutations.forEach(inspectPath);
});

And array and wildcard segments can be employed at any point in the path:

const path = Observer.path('level1', 'level2', Infinity, 'level4');
Observer.observe(object, path, (m) => {
  // Argument #1 is a mutation "object"
  inspectPath(m);
});

Featuring Reflect API Supersets

In the original Object.observe() API, observability worked with literal operators:

object.property = value;
delete object.property;

Which themselves have their respective programmatic equivalent:

Reflect.set(object, 'property', value);
Reflect.deleteProperty(object, 'property');

This remains the goal for this proposal!

But while this is how mutations naturally happen, you may soon begin to want more from an "event-based" communication model that the whole idea really represents!

For example, how do your observers know who made a particular mutation? That detail isn't obvious in how mutations naturally happen as seen above. But how about making that detail possible via the Reflect.set() version?

That and more are the kind of questions that the programmatic equivalents we have of Reflect can answer!

The Observer API is thus further designed to feature the same Reflect APIs as part of the idea, this time, with a few additional parameters for advanced usecases!

An "Options" Parameter On Each Method

Each method now supports an options parameter for passing in additional details!

--> Pass some custom detail - an arbitrary value - to observers via an options.detail property:

// A set operation with detail
Reflect.set(object, 'property', value, { detail: 'Certain detail' });

└ Observers recieve this detail on their mutation.detail property:

// An observer that works with detail
Observer.observe(object, 'property', m => {
    console.log( 'A mutation has been made with detail:' + m.detail );
});

--> Get a value from a property using same detail:

const value = Observer.get(object, 'property', { detail: 'Certain detail' });

└ Traps (which we'll be coming to shortly) recieve this detail on their operation.detail property:

// A trap that works with detail
Observer.intercept(object, {
  get: (operation, prevState, nextTrap) => {
    console.log( 'A read operation is being made with detail:' + operation.detail );
    return nextTrap(value);
  },
});

A "Callback" Parameter On the .get() Method

The .get() method now supports a "callback" parameter to support "read" operations on asynchronous data structures without needing a different API!

--> Get a value from a property using a callback:

Observer.get(object, 'property', value => {
  console.log(value);
});

└ Stay subscribed to updates on that property using the options.live parameter - wherein the .observe() method is automatically employed under the hood:

Observer.get(object, 'property', value => {

  console.log(value);

}, { live: true }); // <----- THIS FLAG: live

--> Get a value from a path:

const value = Observer.get(object, Observer.path('level1', 'level2'));

└ Optionally stay subscribed!

Others


Note that, coincidentally, observability over literal operators comes as a limitation for current implementation. Here, observability works only with mutations made via mutation methods:

Observer.set(object, 'property', value);
Observer.deleteProperty(object, 'property');
Observer.defineProperty(object, 'property', {});

But it remains possible to still have a dot syntax over that via proxies and accessors:

const obj = new Proxy(object, {
  set(target, key, value) {
    Observer.set(target, key, value); // Observable by Object.observe()
  }
});
obj.property = value;

And the implementation goes further to offer a utility method for creating proxies that automatically use the relevant mutation method to forward calls to Observer.observe() as above:

const obj = Observer.proxy(object);
obj.property = value;

And along with that, a utility method for creating accessors that automatically use the relevant mutation method to forward calls to Observer.observe():

Observer.accessorize(object, [ 'property' ]);
object.property = value;

This way, any fashion of a syntax becomes easy!


Featuring Traps

There exists a great sense of symmetry in design between the Reflect API and Proxy traps! For every Reflect operator, there is a Proxy trap of the same name:

Reflect API Proxy Trap
apply() apply() {}
construct() construct() {}
set() set() {}
setPrototypeOf() setPrototypeOf() {}

What isn't so symmetric is how Proxy traps don't complement Reflect operators at the same operational level! Whereas Reflect methods (operators) are universally-operational - i.e. works at the object level universally, Proxy traps are wrapper-scoped - i.e. works at the wrapper level and sees just the interactions routing through the specific wrapper!

Given the problematic nature of tracking wrappers[#29] for code that needs to observe actual objects, for which Object.observe() returns today, how about extending the boxes being checked by the Observer API to the whole concept of traps?

This could come as an intercpt() method on the Reflect Observer object, where the "operators" themselves already exist! And that completes the symmetry between "operators" and "traps"!

Observer.intercept(object, {
  construct: trap,
  apply: trap2,
});
Observer.construct(object);
Observer.apply(object, args);

This time, unlike the case with proxy traps, the design allows multiple parts of the codebase to register their own traps (just like Object.observe()). Multiple traps then form a pipeline, or middleware pattern, wherein a trap takes responsibility to call the next.

Observer.intercept(object, {
  set: (operation, prevState, nextTrap) => {
    if (operation.key === 'url' && prevState !== true) {
      operation.value = operation.value.toLowerCase();
      return nextTrap(); // Call next trap if exists or defer to default handling
    }
    return nextTrap(); // Call next trap if exists or defer to default handling
  },
});

At the end of the day, a developer has one complete tool:

--> for observing mutations - i.e. observing set, deleteProperty, defineProperty operations, after they've just happened.

Observer.observe(object, mutations => {
  mutations.forEach(m => {
    console.log(m.type, m.key, m.value, m.oldValue, m.isUpdate);
  })
});

This being a non-usecase for "proxy-like" interceptions, yet for which proxies were the only way - a distinction rightly underscored in prior discussion.

--> for intercepting/trapping operations - i.e. taking charge of operations and controlling how they happen.

Observer.intercept(object, {
  get: (operation, prevState, nextTrap) => { ... },
  set: (operation, prevState, nextTrap) => { ... },
});

This being the usecase for "proxy-like" interceptions, but not necessarily proxies; being also what completes the picture for requests like Steve's [+] from the Knockout.js team in prior discussion.

And when it's time to really work with proxies, proxies:

--> for when you need to "pose" a specific interface, or many of such, over an object for certain actors, and where "identity" isn't a concern!

Timing and Batching

The original Object.observe() API followed a turn-based processing model, wherein changes were delivered at "end of the turn" - asynchronously! In contrast, this new API is designed to be default-sync and optionally-async! This isn't in ignorance of prior design discussion, but a decision to solve for the most common usage scenarios - and not "screw up [that] in annoying ways"! (Prior design was entirely unusable for anything "sync"!)

What about the concerns shared previously? Here's like a summary and a way to think about the problem:

--> "DOM Mutation Events were a disaster being that listeners were fired 'during/before the actual mutation', but 'happens so often, and so widely' that the initial browser operation gets invalidated from running user code."[]

On the contrary, listeners around Object.observe() didn't need to be called before actual operation!

--> "Synchronous doesn't actually exist!??? Where multiple listeners exist for an event, new mutations could be made by any running listener and subsequent listeners don't get to see that."[]

Well, the same problem, if at all, would also exist for an "end of turn" model, as listeners are still unrolled synchronously[], being just at "end of turn"!

Also, any such changes that happen while a listener runs (child events) should naturally apply recursively - i.e. trigger their own associated listeners - before the running listener returns! (This is just how anything works!) So, the idea of "here are all the changes since the last time you were invoked"[] now correctly becomes "here's a child event", then "here's the parent event that was being fired" - all synchronously! And when you think of it, "child events" being received as child events is just clearer and has more timing significance to any code than a flattened events list that no one can make sense of!

Yet, as an improvement in the new API, records can optionally be received live wherein current running listener has their child events injected! (Intriguing?)

Here's the new sync model in action, with details in the comments:

--> "Own recursions" are default-ignored:

// Event
object.prop = 'initial-value';
// Runs once
Observer.observe(object, [ 'prop' ], mutations => {
  object.prop = 'recurse-value-a'; // Recurse attempt a
  console.log('total:', mutations.length); // 1
});
// Runs twice
// >> first for 'recurse-value-a' in previous listener
// >> then for 'initial-value'
Observer.observe(object, [ 'prop' ], mutations => {
  object.prop = 'recurse-value-b'; // Recurse attempt b
  console.log('total:', mutations.length); // 1 ('recurse-value-a') * 1 ('initial-value')
});

--> Records can be optionally made "live" (having "own recursions" injected):

// Event
object.prop = 'initial-value';
// Runs once... but...
Observer.observe(object, [ 'prop' ], mutations => {
  object.prop = 'recurse-value-a'; // Recurse attempt a
  console.log('total:', mutations.length); // 2 ('initial-value' + 'recurse-value-a')

  // Child observer
  Observer.observe(object, 'prop', mutations2 => {
    object.prop = 'recurse-value-a-plus'; // Recurse attempt a-plus
    console.log('total-2:', mutations2.length); // 1 ('recurse-value-b')
  });

  object.prop = 'recurse-value-b'; // Recurse attempt b
  console.log('total:', mutations.length); // 4 ('initial-value' + 'recurse-value-a' + 'recurse-value-b' + 'recurse-value-a-plus')

}, { recursions: 'inject' } ); // <------ THIS FLAG

--> "Own recursions" can be requested for manual handling:

// Event
object.prop = 'initial-value';
// Runs once... but...
Observer.observe(object, [ 'prop' ], mutations => {
  // Manual handling
  if (mutations[0].value === 'recurse-value-a') return;

  object.prop = 'recurse-value-a'; // Recurse attempt a
  console.log('total:', mutations.length); // 1 ('initial-value')
}, { recursions: 'force-sync' } ); // <------ THIS FLAG

--> "Own recursions" can be requested for manual handling - but at "end of own turn":

// Event
object.prop = 'initial-value';
// Runs once... but...
Observer.observe(object, [ 'prop' ], async mutations => {
  // Manual handling
  if (mutations[0].value === 'recurse-value-a') return;

  await Promise.resolve();

  object.prop = 'recurse-value-a'; // Recurse attempt a
  console.log('total:', mutations.length); // 1 ('initial-value')
}, { recursions: 'force-async' } ); // <------ THIS FLAG

This helps developers gain the needed control over what's happening in their application. Generally, the new sync design has the same userland case as the sync processing model of proxy traps, accessors, DOM events, etc. Anything that will be misused will be misused, and that isn't an "Observer API" thing!

(function recurse() {
  recurse();
})()

That said, solving for async scenarios isn't excluded in the design! Enter batching! (See API for details!)

Putting It All Together

A unifying API over related but disparate things like Object.observe(), Reflect APIs, and the "traps" API!

Observer API Reflect API Trap
apply() apply() {}
batch() × -
construct() construct() {}
defineProperty() defineProperty() {}
deleteProperty() deleteProperty() {}
get() get() {}
getOwnPropertyDescriptor() getOwnPropertyDescriptor() {}
getPrototypeOf() getPrototypeOf() {}
has() has() {}
intercept() × -
isExtensible() isExtensible() {}
observe() × -
ownKeys() ownKeys() {}
path() × -
preventExtensions() preventExtensions() {}
set() set() {}
setPrototypeOf() setPrototypeOf() {}
. . .
accessorize() × -
proxy() × -