Skip to main content

Proxy and Raw Instance Identity

Every Store (and by extension every Component, which extends Store) exists at runtime as two distinct objects: a raw instance, and a reactive Proxy wrapping it. You can see evidence of this directly on any instance — inspect one in a debugger and you'll find a Symbol(gea.store.rootProxy) property pointing from the raw object back to its Proxy counterpart.

Most of the time this distinction is invisible and irrelevant. It stops being irrelevant the moment you build your own mechanism that tracks Store or Component instances by reference — most commonly, a WeakMap keyed by this.

Where This Goes Wrong​

Consider a minimal registry that associates some data with a component instance:

const registry = new WeakMap<object, unknown>()

class ProviderMixin {
provide(value: unknown) {
registry.set(this, value) // `this` here — which one is it?
}
}

If provide() is called from inside a class field initializer —

class MyComponent extends withProviderMixin(Component) {
data = this.provide(someValue)
}

— the this captured by registry.set(this, ...) is the raw instance, because class field initializers run during construction, before the constructed object is handed back wrapped in its reactive Proxy.

Later, when some other part of the framework — a runtime helper walking the component tree, for instance — hands you a reference to that same logical component, what you receive is typically the Proxy, not the raw instance. Even though both refer to "the same component" conceptually, registry.get(theProxyYouWereGiven) returns undefined, because a WeakMap compares by strict object identity, and the Proxy and the raw instance are not the same object.

This is exactly the kind of bug that passes every "does the logic look right" review and only surfaces when you actually render something and inspect it at runtime.

The Fix: Normalize Through GEA_STORE_ROOT​

@geajs/core exports GEA_STORE_ROOT, a symbol that resolves either instance — raw or Proxy — back to the same canonical reference. Normalize both the write side and the read side through it before ever using an instance as a WeakMap key:

import { GEA_STORE_ROOT } from '@geajs/core'

function normalizeKey(instance: any): object {
return instance?.[GEA_STORE_ROOT] ?? instance
}

const registry = new WeakMap<object, unknown>()

class ProviderMixin {
provide(value: unknown) {
registry.set(normalizeKey(this), value)
}
}

function lookup(instance: object) {
return registry.get(normalizeKey(instance))
}

With both sides normalized, it no longer matters whether the reference you're holding is the raw instance or the Proxy — they resolve to the same key.

warning

If you build any WeakMap- or Map-based registry that uses a Store or Component instance as a key — a context registry, a subscription table, a caching layer — always normalize the key through GEA_STORE_ROOT first. Skipping this works fine in quick manual testing (where you often end up holding the same reference on both sides by coincidence) and then fails intermittently once real rendering and lifecycle timing are involved.

The General Lesson​

The underlying issue is broader than Store identity specifically: what this refers to inside a constructor or class field initializer is not guaranteed to be the same object identity that the rest of the framework hands you later, after construction completes. Keep this in mind any time you're tempted to capture this early and compare it against a reference obtained elsewhere.