Skip to content

Commit

Permalink
feat(reactive)!: track inplace mutable functions of built-ins
Browse files Browse the repository at this point in the history
  • Loading branch information
lowlighter committed Nov 19, 2024
1 parent f1cf121 commit e33c24e
Show file tree
Hide file tree
Showing 4 changed files with 129 additions and 8 deletions.
27 changes: 27 additions & 0 deletions reactive/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@ context.target.bar() // Triggers the "call" and "change" events
- Applies recursively!
- Supports inherited context.

## 🕊️ Migrating from `4.x.x` to `5.x.x`

### `Context.unproxyable` default value

`Map`, `Set` and `Date` are not in `Context.unproxyable` by default anymore.
To restore the previous behavior, you can add them back:

```diff
+ Context.unproxyable.unshift(Map, Set, Date)
```

### Now tracking inplace data changes for built-in objects

When a built-in object is modified in place by a known method (e.g. `Array.prototype.push`, `Array.prototype.pop`, etc.), a `"set"` event is now also emitted, in addition to the `"change"` and `"call"` events.

This event has the same properties as if the object was set entirely, with the only difference being that the `value` property is `null` rather than a `{ old, new }` object (since the object has been changed inplace, creating this diff would cause a significant performance and memory overhead).

```ts ignore
const context = new Context({ foo: ["a", "b"] })
context.target.foo.push("c")
// Dispatches a "set" event with the following properties:
// - path: []
// - target: context.target.foo
// - property: "foo"
// - value: null
```

## 📜 License

```plaintext
Expand Down
32 changes: 28 additions & 4 deletions reactive/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,9 @@ export class Context<T extends record = record> extends EventTarget {
try {
return Reflect.apply(callable, target, args)
} finally {
if (Context.mutable(target, path.at(-1)!.toString())) {
this.#dispatch("set", { path: path.slice(0, -2), target, property: path.at(-2)!, value: null })
}
this.#dispatch("call", { path, target, property: path.at(-1)!, args })
}
}
Expand Down Expand Up @@ -346,7 +349,7 @@ export class Context<T extends record = record> extends EventTarget {
#dispatch(type: string, detail: Omit<detail, "type">) {
Object.assign(detail, { type })
this.dispatchEvent(new Context.Event(type, { detail }))
if ((type === "set") || (type === "delete") || (type === "call")) {
if (((type === "set") && (detail.value !== null)) || (type === "delete") || (type === "call")) {
this.dispatchEvent(new Context.Event("change", { detail }))
}
for (const child of this.#children) {
Expand Down Expand Up @@ -382,9 +385,6 @@ export class Context<T extends record = record> extends EventTarget {
* You can also remove classes from this list if you know what you are doing or if you are sure to never work with them to increase performance.
*/
static unproxyable = [
Map,
Set,
Date,
RegExp,
Promise,
Error,
Expand Down Expand Up @@ -426,6 +426,30 @@ export class Context<T extends record = record> extends EventTarget {
globalThis.Intl?.Segmenter,
] as Array<callback | undefined>

/**
* Test if a property mutates the object.
*
* It is used to track inplace changes to objects like `Array`, `Map`, `Set`, `Date`.
*/
static mutable(object: unknown, property: string): boolean {
if (typeof object !== "object") {
return false
}
if (Array.isArray(object)) {
return ["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"].includes(property)
}
if (object instanceof Map) {
return ["set", "delete", "clear"].includes(property)
}
if (object instanceof Set) {
return ["add", "delete", "clear"].includes(property)
}
if (object instanceof Date) {
return /^set(?:(?:(?:UTC)?(Date|FullYear|Month|Hours|Minutes|Seconds|Milliseconds))|(?:Year|Time|UTCDate))$/.test(property)
}
return false
}

/** Context event. */
static readonly Event = class ContextEvent extends CustomEvent<detail> {} as typeof CustomEvent
}
Expand Down
76 changes: 73 additions & 3 deletions reactive/context_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,15 +444,12 @@ test("all")("`Context.target` skips proxification of `Context.unproxyable` objec
}

const { observable, target } = observe({
map: new Map(),
set: new Set(),
error: new Error(),
regexp: new RegExp(""),
weakmap: new WeakMap(),
weakset: new WeakSet(),
weakref: new WeakRef({}),
promise: Promise.resolve(),
date: new Date(),
arraybuffer: uint8.buffer,
typedarray: uint8,
symbol: Symbol("unique"),
Expand Down Expand Up @@ -570,3 +567,76 @@ test("all")("`Context.with()` contexts supports `Map()` instance methods", () =>
expect([...a.target.foo]).toEqual([["foo", 0], ["bar", 1], ["baz", 2]])
expect([...b.target.foo]).toEqual([["foo", 0], ["bar", 1], ["baz", 2]])
})

test("all")('`Context.with()` is able to track mutable functions of `Array`-like objects and dispatch a `"set"` event', () => {
const { observable, target, listeners } = observe({ foo: ["a", "b"] })

expect(target.foo).toEqual(["a", "b"])
observable.foo.push("c")
expect(target.foo).toEqual(["a", "b", "c"])
expect(listeners.set).toHaveBeenCalledTimes(1)
expect(listeners.set.event).toMatchObject({ path: [], target: target.foo, property: "foo", value: null })

observable.foo.shift()
expect(target.foo).toEqual(["b", "c"])
expect(listeners.set).toHaveBeenCalledTimes(2)
expect(listeners.set.event).toMatchObject({ path: [], target: target.foo, property: "foo", value: null })
})

test("all")('`Context.with()` is able to track mutable functions of collections-like objects and dispatch a `"set"` event', () => {
const { observable, target, listeners } = observe({ foo: new Set(["a", "b"]) })

expect(target.foo).toEqual(new Set(["a", "b"]))
observable.foo.add("c")
expect(target.foo).toEqual(new Set(["a", "b", "c"]))
expect(listeners.set).toHaveBeenCalledTimes(1)
expect(listeners.set.event).toMatchObject({ path: [], target: target.foo, property: "foo", value: null })

observable.foo.delete("a")
expect(target.foo).toEqual(new Set(["b", "c"]))
expect(listeners.set).toHaveBeenCalledTimes(2)
expect(listeners.set.event).toMatchObject({ path: [], target: target.foo, property: "foo", value: null })
})

test("all")("`Context.mutable()` returns `true` for methods that changes target inplace", () => {
expect(Context.mutable(1, "foo")).toBe(false)
expect(Context.mutable({}, "foo")).toBe(false)

expect(Context.mutable([], "push")).toBe(true)
expect(Context.mutable([], "pop")).toBe(true)
expect(Context.mutable([], "shift")).toBe(true)
expect(Context.mutable([], "unshift")).toBe(true)
expect(Context.mutable([], "splice")).toBe(true)
expect(Context.mutable([], "sort")).toBe(true)
expect(Context.mutable([], "reverse")).toBe(true)
expect(Context.mutable([], "fill")).toBe(true)
expect(Context.mutable([], "copyWithin")).toBe(true)
expect(Context.mutable([], "foo")).toBe(false)

expect(Context.mutable(new Map(), "set")).toBe(true)
expect(Context.mutable(new Map(), "delete")).toBe(true)
expect(Context.mutable(new Map(), "clear")).toBe(true)
expect(Context.mutable(new Map(), "foo")).toBe(false)

expect(Context.mutable(new Set(), "add")).toBe(true)
expect(Context.mutable(new Set(), "delete")).toBe(true)
expect(Context.mutable(new Set(), "clear")).toBe(true)
expect(Context.mutable(new Set(), "foo")).toBe(false)

expect(Context.mutable(new Date(), "setFullYear")).toBe(true)
expect(Context.mutable(new Date(), "setMonth")).toBe(true)
expect(Context.mutable(new Date(), "setDate")).toBe(true)
expect(Context.mutable(new Date(), "setHours")).toBe(true)
expect(Context.mutable(new Date(), "setMinutes")).toBe(true)
expect(Context.mutable(new Date(), "setSeconds")).toBe(true)
expect(Context.mutable(new Date(), "setMilliseconds")).toBe(true)
expect(Context.mutable(new Date(), "setTime")).toBe(true)
expect(Context.mutable(new Date(), "setUTCFullYear")).toBe(true)
expect(Context.mutable(new Date(), "setUTCMonth")).toBe(true)
expect(Context.mutable(new Date(), "setUTCDate")).toBe(true)
expect(Context.mutable(new Date(), "setUTCHours")).toBe(true)
expect(Context.mutable(new Date(), "setUTCMinutes")).toBe(true)
expect(Context.mutable(new Date(), "setUTCSeconds")).toBe(true)
expect(Context.mutable(new Date(), "setUTCMilliseconds")).toBe(true)
expect(Context.mutable(new Date(), "foo")).toBe(false)
})
2 changes: 1 addition & 1 deletion reactive/deno.jsonc
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"icon": "🎯",
"name": "@libs/reactive",
"version": "4.0.0",
"version": "5.0.0",
"description": "Reactive utilities for observable objects.",
"keywords": [
"reactivity",
Expand Down

0 comments on commit e33c24e

Please sign in to comment.