lowlighter / libs

🍱 Collection of carefully crafted TypeScript standalone libraries. Minimal, unbloated, convenient.
https://jsr.io/@libs
MIT License
118 stars 11 forks source link

feat(reactive): support deletion of overriden properties in child context #50

Closed lowlighter closed 2 months ago

lowlighter commented 5 months ago

When using Scope.with, if an overriden property is deleted from child context, it should not reappear from parent context.

This is currently not supported because it adds a priori a lot of complexity (we need to track which property from the root object were overriden and then deleted, in addition to remove this flag if it's set again, and everything without impacting parent context).

The following test case can be used to test this behaviour:


test("all")("Scope.with() contexts operates unidirectionaly when value is overidden from parent (delete operation)", () => {
  const a = new Context({ d: 0 })
  const b = a.with<testing>({ d: 1 })
  const listeners = { a: fn(), b: fn() }
  a.addEventListener("change", listeners.a)
  b.addEventListener("change", listeners.b)
  delete b.target.d
  expect(a.target.d).toBe(0)
  expect(b.target.d).toBeUndefined()
  expect(a.target).toHaveProperty("d")
  expect(b.target).not.toHaveProperty("d")
  expect(listeners.a).toHaveBeenCalledTimes(0)
  expect(listeners.b).toHaveBeenCalledTimes(1)
})
okikio commented 2 months ago

This is potentially really simple, I've got a possible fix in a pr #71, along with a number of other fixes including support for multi-depth contexts, e.g. grand child contexts, great grand child contexts, etc...

We basically just need a Set to store all the keys for what should be isolated data. In the proxy traps before reaching out to the parent we just check if said property has already been marked as isolated, if so we skip otherwise traverse up the tree.

The biggest question with this implementation is the deletion of shared data, e.g. the child or the grand child context would be able to delete a property that is shared, this better matches expectation but can lead to potential lock-ups or conflicts. We might need some sort of mutex lock to avoid race-conditions or conflicts with shared data.

Please let me know what you think.

export class Context<T extends record = record> extends EventTarget {
  /** Constructor. */
  constructor(target = {} as T, { parent = null as Nullable<Context<record>> } = {}) {
    super()
    this.#parent = parent
    this.#target = target
    this.#isolated = new Set<PropertyKey>(Object.keys(this.#target))
    this.target = this.#proxify(this.#target)
  }

  readonly #isolated

  /** Trap function calls. */
  #trap_apply(path: PropertyKey[], callable: trap<"apply", 0>, that: trap<"apply", 1>, args: trap<"apply", 2>) {
    // As a part of this change we no longer proxy Set, Map, etc... at all, it is harder to know when the Set `add` function is called but ensures a better match with expectation
    try {
      return Reflect.apply(callable, that, args)
    } finally {
      const target = this.#access(path.slice(0, -1))
      const property = path.at(-1)!;

      // To avoid multiple dispatches
      if (target && Reflect.has(target, property)) {
        this.#dispatch("call", { path, target, property, args })
      }
    }
  }

  /** Trap property access. */
  #trap_get(path: PropertyKey[], target: trap<"get", 0>, property: trap<"get", 1>) {
    if ((this.#parent) && (!path.length)) {
      if (!this.#isolated.has(property) && !Reflect.has(target, property)) {
        return Reflect.get(this.#parent.target, property);
      }
    }

    const value = Reflect.get(target, property)
    try {
      if (value) {
        let proxify = false
        if (typeof value === "function") {
          // Skip and constructors
          if ((property === "constructor") && (value !== Object.prototype.constructor)) {
            return value
          }
          proxify = true
        } else if (typeof value === "object") {
          // Skip built-in objects
          if (Context.#isNotProxyable(value)) {
            return value
          }
          proxify = true
        }
        if (proxify) {
          if (!this.#cache.has(value)) {
            this.#cache.set(value, this.#proxify(value, { path: [...path, property] }))
          }
          return this.#cache.get(value)
        }
      }
      return value
    } finally {
      this.#dispatch("get", { path, target, property, value })
    }
  }

  /** Trap property assignment. */
  #trap_set(path: PropertyKey[], target: trap<"set", 0>, property: trap<"set", 1>, value: trap<"set", 2>) {
    if ((this.#parent) && (!path.length) && (!Reflect.has(this.#target, property)) && (Reflect.has(this.#parent.target, property)) && !this.#isolated.has(property)) {
      return Reflect.set(this.#parent.target, property, value)
    }

    const old = Reflect.get(target, property)
    try {
      return Reflect.set(target, property, value)
    } finally {
      this.#dispatch("set", { path, target, property, value: { old, new: value } })
    }
  }

  /** Trap property deletion. */
  #trap_delete(path: PropertyKey[], target: trap<"deleteProperty", 0>, property: trap<"deleteProperty", 1>) {
    if ((this.#parent) && (!path.length) && (!Reflect.has(this.#target, property)) && (Reflect.has(this.#parent.target, property)) && !this.#isolated.has(property)) {
      return Reflect.deleteProperty(this.#parent.target, property)
    }

    const deleted = Reflect.get(target, property)
    try {
      return Reflect.deleteProperty(target, property)
    } finally {
      this.#dispatch("delete", { path, target, property, value: deleted })
    }
  }

  /** Trap property keys. */
  #trap_keys(target: trap<"ownKeys", 0>) {
    const isolatedKeys = this.#isolated; // Convert isolated Set to array
    const parentKeys = Reflect.ownKeys(this.#parent!.target);
    const targetKeys = Reflect.ownKeys(target);

    // Create the filtered result
    const allKeys = Array.from(new Set(parentKeys.concat(targetKeys)))
      .filter((key) => {
        const inIsolated = isolatedKeys.has(key);
        const inTarget = targetKeys.includes(key);

        // Keep the key if:
        // - It is in both isolatedKeys and targetKeys (keep it)
        // - It is not in isolatedKeys (keep it)
        return (inIsolated && inTarget) || !inIsolated;
      });

    return allKeys;
  }

  /** Trap property descriptors. */
  #trap_descriptors(target: trap<"getOwnPropertyDescriptor", 0>, property: trap<"getOwnPropertyDescriptor", 1>) {
    const descriptor = Reflect.getOwnPropertyDescriptor(target, property)
    return descriptor ?? (!this.#isolated.has(property) ? Reflect.getOwnPropertyDescriptor(this.#parent!.target, property) : descriptor)
  }

  /** Trap property existence tests. */
  #trap_has(target: trap<"has", 0>, property: trap<"has", 1>) {
    return Reflect.has(target, property) || (!this.#isolated.has(property) && Reflect.has(this.#parent!.target, property))
  }

  /** Dispatch event. */
  #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")) {
      this.dispatchEvent(new Context.Event("change", { detail }))
    }

    this.dispatchEvent(new Context.Event("all", { detail }))
    for (const child of this.#children) {
      const property = detail.path[0] ?? detail.property
      if (!child.#isolated.has(property) && !Reflect.has(child.#target, property)) {
        child.#dispatch(type, detail)
      }
    }
  }

  /** 
   * Check if object is a native class or type that should not be proxied.
   *
   * The following objects are avoided by default because:
   * 
   * - **Map, Set, WeakMap, WeakSet**: These collections have internal slots that rely on the object being intact for correct behavior.
   * - **WeakRef**: Holds a weak reference to an object, preventing interference with garbage collection.
   * - **Promise**: Proxying promises can interfere with their state management and chaining.
   * - **Error**: Proxying errors can disrupt stack traces and error handling mechanisms.
   * - **RegExp**: Regular expressions rely on internal optimizations that can be disrupted by proxying.
   * - **Date**: Dates have special methods like `getTime()` and `toISOString()` that are tightly coupled with the internal state of the `Date` object.
   * - **ArrayBuffer, TypedArray**: These represent binary data and are performance-critical. Proxying them could cause significant performance degradation.
   * - **Function**: Functions have special behavior when called or applied. Proxying can lead to unexpected side effects.
   * - **Symbol**: Symbols are unique and immutable, making proxying unnecessary and potentially harmful.
   * - **BigInt**: BigInts are immutable and behave like primitives; proxying them is not meaningful.
   * - **Intl Objects**: Objects like `Intl.DateTimeFormat`, `Intl.NumberFormat`, and `Intl.Collator` are optimized for locale-aware formatting and should not be altered.
   * - **MessagePort, MessageChannel, Worker, SharedWorker**: Used for communication between contexts; proxying could disrupt message-passing mechanisms.
   * - **ImageBitmap**: Represents image data optimized for performance; proxying could slow down rendering operations.
   * - **OffscreenCanvas**: Enables off-main-thread rendering; proxying could break parallel rendering tasks.
   * - **ReadableStream, WritableStream, TransformStream**: Streams are designed for efficient data handling. Proxying could introduce latency or disrupt data flow.
   * - **AudioData, VideoFrame**: Media-related transferables used in real-time processing; proxying could cause performance issues.
   *
   * @param obj - The object to check.
   * @returns `true` if the object is a native class or type that should not be proxied, otherwise `false`.
   */
  static #isNotProxyable(obj: unknown): boolean {
    return (
      ('Map' in globalThis && obj instanceof globalThis.Map) ||
      ('Set' in globalThis && obj instanceof globalThis.Set) ||
      ('WeakMap' in globalThis && obj instanceof globalThis.WeakMap) ||
      ('WeakSet' in globalThis && obj instanceof globalThis.WeakSet) ||
      ('WeakRef' in globalThis && obj instanceof globalThis.WeakRef) ||
      ('Promise' in globalThis && obj instanceof globalThis.Promise) ||
      ('Error' in globalThis && obj instanceof globalThis.Error) ||
      ('RegExp' in globalThis && obj instanceof globalThis.RegExp) ||
      ('Date' in globalThis && obj instanceof globalThis.Date) ||
      ('ArrayBuffer' in globalThis && obj instanceof globalThis.ArrayBuffer) ||
      ('ArrayBuffer' in globalThis && globalThis.ArrayBuffer.isView(obj)) || // Covers TypedArrays (Uint8Array, Float32Array, etc.)
      ('Function' in globalThis && obj instanceof globalThis.Function) ||
      ('BigInt' in globalThis && typeof obj === 'bigint') ||
      ('Symbol' in globalThis && typeof obj === 'symbol') ||
      ('Intl' in globalThis && 'DateTimeFormat' in globalThis.Intl && obj instanceof globalThis.Intl.DateTimeFormat) ||
      ('Intl' in globalThis && 'NumberFormat' in globalThis.Intl && obj instanceof globalThis.Intl.NumberFormat) ||
      ('Intl' in globalThis && 'Collator' in globalThis.Intl && obj instanceof globalThis.Intl.Collator) ||
      ('Worker' in globalThis && obj instanceof globalThis.Worker) ||
      // @ts-ignore Not all environments support SharedWorkers
      ('SharedWorker' in globalThis && obj instanceof globalThis.SharedWorker) ||
      ('MessageChannel' in globalThis && obj instanceof globalThis.MessageChannel) ||
      ('MessagePort' in globalThis && obj instanceof globalThis.MessagePort) ||
      ('ImageBitmap' in globalThis && obj instanceof globalThis.ImageBitmap) ||
      // @ts-ignore Deno doesn't support `OffscreenCanvas`, but the browsers do
      ('OffscreenCanvas' in globalThis && obj instanceof globalThis.OffscreenCanvas) ||
      ('ReadableStream' in globalThis && obj instanceof globalThis.ReadableStream) ||
      ('WritableStream' in globalThis && obj instanceof globalThis.WritableStream) ||
      ('TransformStream' in globalThis && obj instanceof globalThis.TransformStream) ||
      // @ts-ignore Deno doesn't support `AudioData`, but the browsers do
      ('AudioData' in globalThis && obj instanceof globalThis.AudioData) ||
      // @ts-ignore Deno doesn't support `VideoFrame`, but the browsers do
      ('VideoFrame' in globalThis && obj instanceof globalThis.VideoFrame)
    );
  }