# :eyes: Observable Slim

[![Build Status](https://app.travis-ci.com/ElliotNB/observable-slim.svg?branch=master)](https://app.travis-ci.com/ElliotNB/observable-slim) [![Coverage Status](https://coveralls.io/repos/github/ElliotNB/observable-slim/badge.svg)](https://coveralls.io/github/ElliotNB/observable-slim) [![Monthly Downloads](https://img.shields.io/npm/dm/observable-slim.svg)](https://www.npmjs.com/package/observable-slim)

https://github.com/elliotnb/observable-slim

*A small, dependency-free library that watches deep changes in plain JavaScript objects/arrays and tells you exactly what changed.*

Observable Slim mirrors JavaScript objects and arrays using ES2015 Proxy, letting you observe deeply nested changes with clear notifications using dotted paths and RFC-6901 JSON Pointers. Engineered for performance, it features **linear O(N) initialization** and lazy path generation, allowing it to scale efficiently even on deep state trees. It safely handles circular references, ensures fast **O(1) lookups** via WeakMap identity tables, and keeps paths accurate even when arrays reorder. You can batch updates for smoother UI rendering, pause/resume observers, and rely on automatic garbage collection for removed data. Lightweight (~6 KB minified), dependency-free, and memory-safe.

## Table of Contents

- [Design (Deep Dive)](#design-deep-dive)
- [Features](#features)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [API](#api)
- [Change Record Shape](#change-record-shape)
- [Usage Examples](#usage-examples)
- [Limitations and Browser Support](#limitations-and-browser-support)
- [TypeScript](#typescript)
- [Development](#development)
- [Contributing](#contributing)

## Design (Deep Dive)

Curious about the underlying architecture and implementation? See **[docs/design.md](docs/design.md)** for the problem model, core algorithms, complexity, invariants, cycle-safe instrumentation, cross-proxy fan-out, lazy path generation (linked-list lineage), reachability-based teardown, and correctness arguments. It's optional reading, but helpful if you want to understand how the internals stay fast and memory-safe. For validation of scaling/time complexity performance, please see **[bench/results.md](bench/results.md)** for the most recent benchmarking run.

## Features

- **Deep observation** of objects and arrays (all nested children)
- **High performance creation** with **O(N) linear scaling**, suitable for deeply nested state trees.
- **Structured change records** with: `type`, `property`, `currentPath` (dot notation), `jsonPointer` (RFC6901), `previousValue`, `newValue`, `target`, `proxy`
- **Batched notifications** with `domDelay` (boolean or ms number)
- **Multiple proxies per target** with safe cross-proxy propagation
- **Pause/resume** observers and **pause/resume changes** (dry-run validation)
- **Accurate array length tracking** using WeakMap bookkeeping
- **Introspection helpers**: `isProxy`, `getTarget`, `getParent`, `getPath`
- **Advanced symbol capabilities** for collision-free internals
- **Configurable orphan-cleanup scheduler** (`configure({ cleanupDelayMs })`) and a **test hook** (`flushCleanup()`) to run pending cleanups immediately
- **TypeScript declarations** included (`observable-slim.d.ts`)

## Installation

**Browser (UMD):**

```html
<script src="https://unpkg.com/observable-slim"></script>
<script>
  const state = { hello: "world" };
  const proxy = ObservableSlim.create(state, false, (changes) => console.log(changes));
</script>
```

**NPM (CommonJS):**

```bash
npm install observable-slim --save
```

```js
const ObservableSlim = require('observable-slim');
```

**ES Modules (via bundlers that can import CJS):**

```js
import ObservableSlim from 'observable-slim';
```

## Quick Start

```js
const state = { user: { name: 'Ada' }, todos: [] };
const p = ObservableSlim.create(state, true, (changes) => {
  // Array of change records batched on a small timeout when domDelay === true
  console.log(changes);
});

p.todos.push({ title: 'Write tests', done: false });
p.user.name = 'Ada Lovelace';
```

## API

### `ObservableSlim.create(target, domDelay, observer?)`

Create a new Proxy that mirrors `target` and observes all deep changes.

- `target`: *object* (required) – plain object/array to observe.
- `domDelay`: *boolean|number* (required) – `true` to batch notifications on \~10ms timeout; `false` to notify synchronously; number `> 0` to use a custom delay (ms).
- `observer(changes)`: *function* (optional) – receives an *array* of change records.
- **Returns**: the Proxy.

> Note: Passing an existing Proxy produced by Observable Slim is supported; the underlying original target will be used to avoid nested proxying.

### `ObservableSlim.observe(proxy, observer)`

Attach an additional observer to an existing proxy. Observers are called with arrays of change records.

### `ObservableSlim.pause(proxy)` / `resume(proxy)`

Temporarily disable/enable *observer callbacks* for the given proxy (no changes are blocked).

### `ObservableSlim.pauseChanges(proxy)` / `resumeChanges(proxy)`

Disable/enable *writes to the underlying target* while still issuing change records. Useful for approval flows or validations.

### `ObservableSlim.remove(proxy)`

Detach all observers and bookkeeping for the given proxy and its nested proxies created for the same root observable.

### `ObservableSlim.isProxy(obj)`

Return `true` if the argument is a Proxy created by Observable Slim.

### `ObservableSlim.getTarget(proxy)`

Return the original target object behind a Proxy created by Observable Slim.

### `ObservableSlim.getParent(proxy, depth=1)`

Return the parent object of a proxy relative to the top-level observable (climb `depth` levels; default `1`).

### `ObservableSlim.getPath(proxy, options)`

Return the path string of a proxy relative to its root observable.

- `options = { jsonPointer?: boolean }` – when `true`, return RFC6901 pointer (e.g., `/foo/0/bar`); otherwise dot path (e.g., `foo.0.bar`).

### Advanced: `ObservableSlim.symbols`

For advanced users who need capability-style access without relying on public helpers, the library exposes collision-free Symbols:

- `ObservableSlim.symbols.IS_PROXY` – brand symbol; `proxy[IS_PROXY] === true`
- `ObservableSlim.symbols.TARGET` – unwrap symbol; `proxy[TARGET] === originalObject`
- `ObservableSlim.symbols.PARENT` – function symbol; `proxy[PARENT](depth)` returns the ancestor
- `ObservableSlim.symbols.PATH` – path symbol; `proxy[PATH]` returns the dot path

> Symbols are not enumerable and won’t collide with user properties. Prefer the public helpers for most use cases.

### `ObservableSlim.configure(options)`

Configure behavior that affects all observables created in this runtime.

- `options.cleanupDelayMs: number` – delay (ms) used by the orphan-cleanup scheduler. Set to `0` to run cleanups eagerly; increase to coalesce more work.

```js
ObservableSlim.configure({ cleanupDelayMs: 25 });
```

### `ObservableSlim.flushCleanup()`

Force any pending orphan cleanups to run immediately. Useful in tests for deterministic timing; safe to call in production if you need to flush scheduled cleanups now.

```js
ObservableSlim.flushCleanup();
```

## Change Record Shape

Every notification contains an array of objects like:

```ts
{
  type: 'add' | 'update' | 'delete',
  property: string,
  currentPath: string,   // e.g. "foo.0.bar"
  jsonPointer: string,   // e.g. "/foo/0/bar"
  target: object,        // the concrete target that changed
  proxy: object,         // proxy for the target
  newValue: any,
  previousValue?: any
}
```

## Usage Examples

### Observer output:

Below, every mutation is followed by the array that your observer handler function receives:

```js
const test = {};
const p = ObservableSlim.create(test, false, (changes) => {
  console.log(JSON.stringify(changes));
});

p.hello = "world";
// => [{
//   "type":"add","target":{"hello":"world"},"property":"hello",
//   "newValue":"world","currentPath":"hello","jsonPointer":"/hello",
//   "proxy":{"hello":"world"}
// }]

p.hello = "WORLD";
// => [{
//   "type":"update","target":{"hello":"WORLD"},"property":"hello",
//   "newValue":"WORLD","previousValue":"world",
//   "currentPath":"hello","jsonPointer":"/hello",
//   "proxy":{"hello":"WORLD"}
// }]

p.testing = {};
// => [{
//   "type":"add","target":{"hello":"WORLD","testing":{}},
//   "property":"testing","newValue":{},
//   "currentPath":"testing","jsonPointer":"/testing",
//   "proxy":{"hello":"WORLD","testing":{}}
// }]

p.testing.blah = 42;
// => [{
//   "type":"add","target":{"blah":42},"property":"blah","newValue":42,
//   "currentPath":"testing.blah","jsonPointer":"/testing/blah",
//   "proxy":{"blah":42}
// }]

p.arr = [];
// => [{
//   "type":"add","target":{"hello":"WORLD","testing":{"blah":42},"arr":[]},
//   "property":"arr","newValue":[],
//   "currentPath":"arr","jsonPointer":"/arr",
//   "proxy":{"hello":"WORLD","testing":{"blah":42},"arr":[]}
// }]

p.arr.push("hello world");
// => [{
//   "type":"add","target":["hello world"],"property":"0",
//   "newValue":"hello world","currentPath":"arr.0","jsonPointer":"/arr/0",
//   "proxy":["hello world"]
// }]

delete p.hello;
// => [{
//   "type":"delete","target":{"testing":{"blah":42},"arr":["hello world"]},
//   "property":"hello","newValue":null,"previousValue":"WORLD",
//   "currentPath":"hello","jsonPointer":"/hello",
//   "proxy":{"testing":{"blah":42},"arr":["hello world"]}
// }]

p.arr.splice(0,1);
// => [{
//   "type":"delete","target":[],"property":"0","newValue":null,
//   "previousValue":"hello world","currentPath":"arr.0","jsonPointer":"/arr/0",
//   "proxy":[]
// },{
//   "type":"update","target":[],"property":"length","newValue":0,
//   "previousValue":1,"currentPath":"arr.length","jsonPointer":"/arr/length",
//   "proxy":[]
// }]
```

### Arrays in detail (push/unshift/pop/shift/splice)

```js
const p = ObservableSlim.create({ arr: ["foo","bar"] }, false, (c) => console.log(JSON.stringify(c)));

p.arr.unshift("hello");
// 1) add index 2 moved -> implementation may record reindexes; canonical signal is:
// [{"type":"add","target":["hello","foo","bar"],"property":"0","newValue":"hello",
//   "currentPath":"arr.0","jsonPointer":"/arr/0","proxy":["hello","foo","bar"]}]

p.arr.pop();
// Deleting last element and updating length; commonly two records over one or two callbacks:
// [{"type":"delete","target":["hello","foo"],"property":"2","newValue":null,
//   "previousValue":"bar","currentPath":"arr.2","jsonPointer":"/arr/2","proxy":["hello","foo"]}]
// [{"type":"update","target":["hello","foo"],"property":"length","newValue":2,
//   "previousValue":3,"currentPath":"arr.length","jsonPointer":"/arr/length","proxy":["hello","foo"]}]

p.arr.splice(1,0,"X");
// Insert at index 1 and reindex subsequent items:
// [{"type":"add","target":["hello","X","foo"],"property":"1","newValue":"X",
//   "currentPath":"arr.1","jsonPointer":"/arr/1","proxy":["hello","X","foo"]}]

p.arr.shift();
// Move index 1 down to 0, delete old 1, update length. Typical sequence:
// [{"type":"update","target":["X","foo"],"property":"0","newValue":"X",
//   "previousValue":"hello","currentPath":"arr.0","jsonPointer":"/arr/0","proxy":["X","foo"]}]
// [{"type":"delete","target":["X","foo"],"property":"1","newValue":null,
//   "previousValue":"foo","currentPath":"arr.1","jsonPointer":"/arr/1","proxy":["X","foo"]}]
// [{"type":"update","target":["X","foo"],"property":"length","newValue":2,
//   "previousValue":3,"currentPath":"arr.length","jsonPointer":"/arr/length","proxy":["X","foo"]}]
```

> Notes: Exact batching of array index/length signals can vary by engine and call path. The shapes above are representative and covered by the test suite (push, unshift, pop, shift, splice, and length tracking).

### Multiple observers and multiple observables

```js
const data = { foo: { bar: "bar" } };
const p1 = ObservableSlim.create(data, false, (c) => console.log("p1", c));
ObservableSlim.observe(p1, (c) => console.log("p1-second", c));

const p2 = ObservableSlim.create(data, false, (c) => console.log("p2", c));

p2.foo.bar = "baz"; // triggers both observers on p1 and p2
```

### Pausing observers vs. pausing changes

```js
const p = ObservableSlim.create({ x: 0 }, false, (c) => console.log("obs", c));

ObservableSlim.pause(p);
p.x = 1;     // no observer callbacks
ObservableSlim.resume(p);

ObservableSlim.pauseChanges(p);
p.x = 2;     // observer fires, but underlying target is NOT updated
console.log(p.x); // still 0
ObservableSlim.resumeChanges(p);
p.x = 3;     // observer fires and target is updated
```

### Introspection helpers and Symbols

```js
const state = { a: { b: 1 } };
const proxy = ObservableSlim.create(state, false, () => {});

console.log(ObservableSlim.isProxy(proxy));     // true
console.log(ObservableSlim.getTarget(proxy) === state); // true

const child = proxy.a;
console.log(ObservableSlim.getParent(child) === proxy);   // parent proxy
console.log(ObservableSlim.getPath(child));               // 'a'
console.log(ObservableSlim.getPath(child, { jsonPointer:true })); // '/a'

const { TARGET, IS_PROXY, PARENT, PATH } = ObservableSlim.symbols;
console.log(proxy[IS_PROXY]);      // true
console.log(proxy[TARGET] === state); // true
console.log(child[PARENT](1) === proxy); // true
console.log(child[PATH]);          // 'a'
```

### Remove an observable

```js
const p = ObservableSlim.create({ y: 1 }, false, () => console.log('called'));
ObservableSlim.remove(p);
p.y = 2; // no callbacks after removal
```

## Limitations and Browser Support

This library requires native ES2015 **Proxy**, **WeakMap** and **Symbol** support.

- ✅ Chrome 49+, Edge 12+, Firefox 18+, Opera 36+, Safari 10+ (per MDN guidance)
- ❌ Internet Explorer: not supported

> Polyfills cannot fully emulate `Proxy`; features like property addition/deletion and `.length` interception will not work under a polyfill.

## TypeScript

Type declarations are published with the package (`observable-slim.d.ts`). Observer callbacks are strongly typed with the change record shape described above.

## Development

- **Install deps:** `npm ci`
- **Run tests:** `npm run test`
- **Lint:** `npm run lint` / `npm run lint:fix` to identify and correct code formatting.
- **Type declarations:** `npm run type` generates the `d.ts` file for TypeScript declarations.
- **Build (minified):** `npm run build` emits `.cjs`, `.mjs`, `.js` and `.d.ts` artifacts into the `dist` folder.

## Contributing

Issues and PRs are welcome! Please:

1. Write tests for behavioral changes.
2. Keep the API surface small and predictable.
3. Run `npm run lint` and `npm run test` before submitting.

## License

[MIT](./LICENSE)

---

## Migration from legacy magic properties

Earlier versions exposed *string-named* magic fields (e.g., `__isProxy`, `__getTarget`, `__getParent()`, `__getPath`). These have been replaced by safer helpers and Symbols:

| Legacy (deprecated)         | New API                                                                                   |
| --------------------------- | ----------------------------------------------------------------------------------------- |
| `proxy.__isProxy`           | `ObservableSlim.isProxy(proxy)` or `proxy[ObservableSlim.symbols.IS_PROXY]`               |
| `proxy.__getTarget`         | `ObservableSlim.getTarget(proxy)` or `proxy[ObservableSlim.symbols.TARGET]`               |
| `proxy.__getParent(depth?)` | `ObservableSlim.getParent(proxy, depth)` or `proxy[ObservableSlim.symbols.PARENT](depth)` |
| `proxy.__getPath`           | `ObservableSlim.getPath(proxy)` or `proxy[ObservableSlim.symbols.PATH]`                   |

The helpers are preferred for readability and to avoid re-entering traps unnecessarily.

