Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/controllers/eventEmitter/eventEmitter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,86 @@ describe('EventEmitter', () => {
expect(oldController.onErrorIds).toHaveLength(0)
})
})

describe('Property tracking', () => {
it('should track updated keys after emitUpdate', () => {
const emitter = new (class extends EventEmitter {
foo = ''
baz = 0
emit() {
this.emitUpdate()
}
})() as any
emitter.emit()
emitter.getUpdatedKeys() // clear initial
emitter.foo = 'bar'
emitter.baz = 123
emitter.emit()
expect(emitter.getUpdatedKeys()).toEqual(['foo', 'baz'])
expect(emitter.getUpdatedKeys()).toEqual([])
})

it('should not track private-looking keys', () => {
const emitter = new (class extends EventEmitter {
_internal = ''
emit() {
this.emitUpdate()
}
})()
emitter.emit()
emitter.getUpdatedKeys() // clear initial
emitter._internal = 'something'
emitter.emit()
expect(emitter.getUpdatedKeys()).toEqual([])
})

it('should handle native private fields correctly with shallow comparison', () => {
class TestEmitter extends EventEmitter {
#privateVal = 'secret'

getMyPrivateVal() {
return this.#privateVal
}

emit() {
this.emitUpdate()
}
}
const emitter = new TestEmitter()
emitter.emit() // Initial update
emitter.getUpdatedKeys() // Clear initial
expect(emitter.getMyPrivateVal()).toBe('secret')
expect(emitter.getUpdatedKeys()).toEqual([])
})

it('should NOT track updated keys during the first emitUpdate', () => {
const emitter = new (class extends EventEmitter {
foo = 'bar'
get baz() { return 'qux' }
emit() {
this.emitUpdate()
}
})() as any

// Before first emit, should be empty
expect(emitter.getUpdatedKeys()).toEqual([])

// Track keys during the first emit
let keysDuringFirstEmit: string[] = []
emitter.onUpdate(() => {
keysDuringFirstEmit = emitter.getUpdatedKeys()
})

emitter.emit()
// Should be empty because it is the first emit
expect(keysDuringFirstEmit).toEqual([])

// After first emit, tracking should work
emitter.foo = 'new'
emitter.emit()
// baz is a getter, so it is always included if tracking has started
expect(emitter.getUpdatedKeys()).toContain('foo')
expect(emitter.getUpdatedKeys()).toContain('baz')
})
})
})
115 changes: 115 additions & 0 deletions src/controllers/eventEmitter/eventEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,48 @@ import wait from '../../utils/wait'

const LIMIT_ON_THE_NUMBER_OF_ERRORS = 100

// Cache to ensure referential stability. When a nested object is accessed multiple times,
// it should return the same Proxy instance, preventing infinite re-render loops in React
// caused by unstable references in selectors.
const proxyCache = new WeakMap<any, any>()

function createDeepProxy(target: any, topLevelKey: string, updatedKeys: Set<string>): any {
if (!isPlainObjectOrArray(target)) {
return target
}

const cachedProxy = proxyCache.get(target)
if (cachedProxy) return cachedProxy

const proxy = new Proxy(target, {
get(obj, prop) {
const value = Reflect.get(obj, prop)
if (isPlainObjectOrArray(value)) {
return createDeepProxy(value, topLevelKey, updatedKeys)
}
return value
},
set(obj, prop, value, receiver) {
const currentValue = Reflect.get(obj, prop)
if (currentValue !== value) {
Reflect.set(obj, prop, value, receiver)
updatedKeys.add(topLevelKey)
}
return true
}
})

proxyCache.set(target, proxy)
return proxy
}

function isPlainObjectOrArray(value: any): boolean {
if (value === null || typeof value !== 'object') return false
const proto = Object.getPrototypeOf(value)
// Only return true for {} and []
return proto === Object.prototype || proto === Array.prototype || proto === null
}

export default class EventEmitter {
id: string

Expand All @@ -29,6 +71,12 @@ export default class EventEmitter {

statuses: Statuses<string> = {}

#updatedKeys: Set<string> = new Set()

#lastState: { [key: string]: any } = {}

#hasEmittedUpdate: boolean = false

/**
*
* @param registry - EventEmitterRegistryController instance to be used by this controller. Controllers
Expand Down Expand Up @@ -67,6 +115,64 @@ export default class EventEmitter {
return this.#errors
}

getUpdatedKeys(): string[] {
if (!this.#hasEmittedUpdate) {
this.#updatedKeys.clear()
return []
}

const keys = new Set(this.#updatedKeys)

for (const key of Object.keys(this)) {
if (key.startsWith('_') || key.startsWith('#')) continue

const val = (this as any)[key]
if (val instanceof Set || val instanceof Map) {
keys.add(key)
}
}

const getterKeys = this.#getGettersKeys()
for (const key of getterKeys) {
keys.add(key)
}

this.#updatedKeys.clear()

return Array.from(keys)
}

#getGettersKeys(): string[] {
const proto = Object.getPrototypeOf(this)
const descriptors = Object.getOwnPropertyDescriptors(proto)
return Object.keys(descriptors).filter((key) => {
if (key.startsWith('#') || key.startsWith('_') || key === 'toJSON') return false
return typeof descriptors[key]?.get === 'function'
})
}

protected trackUpdates() {
for (const key of Object.keys(this)) {
if (key === 'updatedKeys' || key.startsWith('_') || key.startsWith('#')) continue

const currentValue = (this as any)[key]
if (typeof currentValue === 'function') continue

if (currentValue !== this.#lastState[key]) {
this.#updatedKeys.add(key)

// Wrap objects/arrays in a proxy and reassign back to `this` so direct mutations are caught
if (isPlainObjectOrArray(currentValue)) {
const proxiedValue = createDeepProxy(currentValue, key, this.#updatedKeys)
;(this as any)[key] = proxiedValue
this.#lastState[key] = proxiedValue
} else {
this.#lastState[key] = currentValue
}
}
}
}

/**
* Emits an update immediately, bypassing both background batching
* (where updates on the same tick are debounced and batched for performance)
Expand All @@ -79,6 +185,7 @@ export default class EventEmitter {
* normal batching may skip intermediate states and only emit the first and last ones.
*/
async forceEmitUpdate() {
this.trackUpdates()
// Bypassing background batching on the same tick
await wait(1)

Expand All @@ -87,13 +194,18 @@ export default class EventEmitter {
for (const i of this.#callbacksWithId) i.cb(true)
// eslint-disable-next-line no-restricted-syntax
for (const cb of this.#callbacks) cb(true)

this.#hasEmittedUpdate = true
}

protected emitUpdate() {
this.trackUpdates()
// eslint-disable-next-line no-restricted-syntax
for (const i of this.#callbacksWithId) i.cb()
// eslint-disable-next-line no-restricted-syntax
for (const cb of this.#callbacks) cb()

this.#hasEmittedUpdate = true
}

/**
Expand Down Expand Up @@ -123,10 +235,13 @@ export default class EventEmitter {
* and the controller updates its own state), use `emitUpdate()` or `forceEmitUpdate()`.
*/
protected propagateUpdate(forceEmit?: boolean) {
this.trackUpdates()
// eslint-disable-next-line no-restricted-syntax
for (const i of this.#callbacksWithId) i.cb(forceEmit)
// eslint-disable-next-line no-restricted-syntax
for (const cb of this.#callbacks) cb(forceEmit)

this.#hasEmittedUpdate = true
}

protected emitError(error: ErrorRef) {
Expand Down
45 changes: 0 additions & 45 deletions src/libs/richJson/richJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,48 +64,3 @@ export function parse(json: string) {
return value
})
}

export function cloneDeep(value: any): any {
if (value === null || typeof value !== 'object') {
if (typeof value === 'function' || typeof value === 'symbol' || value === undefined) {
return undefined
}
return value
}

if (value instanceof Error) {
const error: any = new Error(value.message)
Object.getOwnPropertyNames(value).forEach((propName) => {
if (propName !== 'message') {
error[propName] = (value as any)[propName]
}
})
return error
}

if (Array.isArray(value)) {
return value.map((item) => {
const cloned = cloneDeep(item)
// JSON.stringify turns undefined/functions in arrays into null
return cloned === undefined ? null : cloned
})
}

const objToClone = typeof value.toJSON === 'function' ? value.toJSON() : value

// If toJSON returned a primitive, return it
if (objToClone === null || typeof objToClone !== 'object') {
return objToClone
}

const clone: any = {}
for (const key in objToClone) {
if (Object.prototype.hasOwnProperty.call(objToClone, key) && typeof key !== 'symbol') {
const clonedVal = cloneDeep(objToClone[key])
if (clonedVal !== undefined) {
clone[key] = clonedVal
}
}
}
return clone
}
Loading