@selfage/observable

1.0.2 • Public • Published

@selfage/observable

Install

npm install @selfage/observable

Overview

Written in TypeScript and compiled to ES6 with inline source map & source. See @selfage/tsconfig for full compiler options. Provides a runtime lib to be used together with ObservableDescriptor generated by @selfage/generator_cli, which can parse, copy and merge observable objects.

An observable object exposes events/callbacks to observe every state change.

Example generated code

See @selfage/generator_cli#observable for how to generate ObservableDescriptor. Suppose the following has been generated and committed as basic.ts. We will continue using the example below.

import { ObservableArray } from '@selfage/observable_array';
import { EventEmitter } from 'events';
import { ObservableDescriptor, ArrayType } from '@selfage/observable/descriptor';
import { PrimitiveType } from '@selfage/message/descriptor';

export interface BasicData {
  on(event: 'numberField', listener: (newValue: number, oldValue: number) => void): this;
  on(event: 'stringArrayField', listener: (newValue: Array<string>, oldValue: Array<string>) => void): this;
  on(event: 'observableArrayField', listener: (newValue: ObservableArray<boolean>, oldValue: ObservableArray<boolean>) => void): this;
  on(event: 'init', listener: () => void): this;
}

export class BasicData extends EventEmitter {
  private numberField_?: number;
  get numberField(): number {
    return this.numberField_;
  }
  set numberField(value: number) {
    let oldValue = this.numberField_;
    if (value === oldValue) {
      return;
    }
    this.numberField_ = value;
    this.emit('numberField', this.numberField_, oldValue);
  }

  private stringArrayField_?: Array<string>;
  get stringArrayField(): Array<string> {
    return this.stringArrayField_;
  }
  set stringArrayField(value: Array<string>) {
    let oldValue = this.stringArrayField_;
    if (value === oldValue) {
      return;
    }
    this.stringArrayField_ = value;
    this.emit('stringArrayField', this.stringArrayField_, oldValue);
  }

  private observableArrayField_?: ObservableArray<boolean>;
  get observableArrayField(): ObservableArray<boolean> {
    return this.observableArrayField_;
  }
  set observableArrayField(value: ObservableArray<boolean>) {
    let oldValue = this.observableArrayField_;
    if (value === oldValue) {
      return;
    }
    this.observableArrayField_ = value;
    this.emit('observableArrayField', this.observableArrayField_, oldValue);
  }

  public triggerInitialEvents(): void {
    if (this.numberField_ !== undefined) {
      this.emit('numberField', this.numberField_, undefined);
    }
    if (this.stringArrayField_ !== undefined) {
      this.emit('stringArrayField', this.stringArrayField_, undefined);
    }
    if (this.observableArrayField_ !== undefined) {
      this.emit('observableArrayField', this.observableArrayField_, undefined);
    }
    this.emit('init');
  }

  public toJSON(): Object {
    return {
      numberField: this.numberField,
      stringArrayField: this.stringArrayField,
      observableArrayField: this.observableArrayField,
    };
  }
}

export let BASIC_DATA: ObservableDescriptor<BasicData> = {
  name: 'BasicData',
  constructor: BasicData,
  fields: [
    {
      name: 'numberField',
      primitiveType: PrimitiveType.NUMBER,
    },
    {
      name: 'stringArrayField',
      primitiveType: PrimitiveType.STRING,
      asArray: ArrayType.NORMAL,
    },
    {
      name: 'observableArrayField',
      primitiveType: PrimitiveType.BOOLEAN,
      asArray: ArrayType.OBSERVABLE,
    },
  ]
};

Listen on observable object

Changes are detected through TypeScript setter. Events are emitted via NodeJs's EventEmitter.

import { BasicData } from './basic'; // Generated by @selfage/generator_cli.
import { ObservableArray } from '@selfage/observable_array';

let basicData = new BasicData();
basicData.on('numberField', (newValue, oldValue) => {
  console.log(`newValue: ${newValue}; oldValue: ${oldValue};`);
});
basicData.numberField = 10;
// Print: newValue: 10; oldValue: undefined;
basicData.numberField = 100;
// Print: newValue: 100; oldValue: 10;
delete basicData.numberField;
// Actually does nothing. basicData.numberField is still 100.
basicData.numberField = undefined;
// Print: newValue: undefined; oldValue: 100;

basicData.on('stringArrayField', (newValue, oldValue) => {
  console.log(`newValue: ${JSON.stringify(newValue)}; oldValue: ${JSON.stringify(oldValue)};`);
});
basicData.stringArrayField = ['str1', 'str2'];
// Print: newValue: ['str1','str2']; oldValue: undefined;
basicData.stringArrayField = ['str1', 'str2'];
// Print: newValue: ['str1','str2']; oldValue: ['str1','str2'];
// This is because the new and old ObservableArray's are not the instance. I.e., they are not equal by `===`.
basicData.stringArrayField.push('str3');
// Nothing to print as changes are not bubbled up.

basicData.on('observableArrayField', (newValue, oldValue) => {
  console.log(`newValue: ${JSON.stringify(newValue)}; oldValue: ${JSON.stringify(oldValue)};`);
});
basicData.observableArrayField = ObservableArray.of(true, false);
// Print: newValue: [true,false]; oldValue: undefined;
basicData.observableArrayField.push(false);
// Nothing to print as changes are not bubbled up.

Note that changes on arrays or objects are not bubbled up.

In order to observe arrays, you need to add a listener on basicData.observableArrayField directly. Refer to package @selfage/observable_array for how to observe an ObservableArray.

Similarly, if you nest BasicData inside another observable object, you need to add listeners on nested observable objects directly.

Trigger initial events

If you have created an observable object before you could add listeners to it, you can trigger initial events, such that listeners called as if each field is just assigned with the new value.

import { BASIC_DATA, BasicData } from './basic'; // Generated by @selfage/generator_cli.

let data = new BasicData();
data.numberField = 111;
data.triggerInitialEvents();
// Emit `numberField` event with newValue as 111, and oldValue as undefined.
// A special 'init' event will also be triggered which passes nothing to the listener. It can be used to flip undefined fields.

Parse observables

You might not create an observable object directly, but parse a JSON-parsed object as the following.

import { parseObservable } from '@selfage/observable/parser';
import { BASIC_DATA, BasicData } from './basic'; // Generated by @selfage/generator_cli.

let raw = JSON.parse(`{ "numberField": 111, "otherField": "random", "stringArrayField": ["str1", "str2"] }`);
let basicData = parseObservable(raw, BASIC_DATA); // Of type `BasicData`.

You can also supply an in-place output object.

let output = new BasicData();
parseObservable(raw, BASIC_DATA, output);

Note that it will overwrite everything in output.

Copy observables

You can copy observables.

import { copyObservable } from '@selfage/observable/copier';
import { BASIC_DATA, BasicData } from './basic'; // Generated by @selfage/generator_cli.

let basicData = new BasicData();
basic.numberField = 100;
let dest = copyObservable(basicData, BASIC_DATA);
// Or in-place copy.
let dest2 = new BasicData();
copyObservable(data, BASIC_DATA, dest2);

Merge observables

If provided with a destination/existing observable object, both parseObservable and copyObservable will replace every field with the new one. mergeObservable, however, will only overwrite a field if the corresponding new field actually has a value.

import { mergeObservable } from '@selfage/observable/merger';
import { BASIC_DATA, BasicData } from './basic'; // Generated by @selfage/generator_cli.

let source = new BasicData();
source.stringArrayField = ["123"];
let existing = new BasicData();
existing.numberField = 111;
mergeMessage(source, BASIC_DATA, existing);
// Now `existing` becomes: { numberField: 111, stringArrayField: ["123"] }

Test matcher

By importing @selfage/observable/test_matcher, you can use it together with @selfage/test_matcher to match messages.

import { BasicData, BASIC_DATA } from './basic'; // Generated by @selfage/generator_cli.
import { eqObservable } from '@selfage/observable/test_matcher';
import { assertThat } from '@selfage/test_matcher'; // Install @selfage/test_matcher

let basicData = new BasicData();
basic.numberField = 111;
let expectedData = new BasicData();
assertThat(basicData, eqObservable(expectedData, BASIC_DATA), `basicData`);

Design considerations for observable object

We have also provided @selfage/observable_js in pure JavaScript to convert any objects into observable objects via ES6 proxy. The main reason we didn't do the same thing in TypeScript is that we failed to find a way to make the converted observable objects type-safe. I.e., what would be the return type for function toObservable<T>(obj: T): ? requring on(event: '<field name>', listener:...) to be added to T and can be type checked by TypeScript?

As for why we didn't allow bubbling up changes, it's because:

  1. Our main use case is to observe changes on states to trigger UI changes, where each component can own its own observable object. Nested objects should be observed by nested components. It could be messy to ignore nested objects.
  2. If you want to push new states into browser history, you probably don't want to push upon every single change, because an operation might trigger multiple changes which should be grouped into one history entry.

Package Sidebar

Install

npm i @selfage/observable

Weekly Downloads

0

Version

1.0.2

License

GPL-3.0-or-later

Unpacked Size

84 kB

Total Files

15

Last publish

Collaborators

  • teststaybaka