# Solving JavaScript Proxy Onions Thinking hard about interesting experiments in the [Solid JS Discord](https://discord.gg/solidjs), stumble upon something I didn't think before, Proxy onions. Basically two unrelated proxies wrapping each other. What happens is that we end up with stuff like `a(b(a()))`. **Spoiler**: In summary, for when overwrapping is undesired, my discoveries are that a WeakMap is not enough, and caching proxies inside the proxy could unwrap (or skip a layer of the onion) unexpectedly. --- To make it easy to understand, lets gradually build a proxy that support Onions, and Scoped Onions. ## Simple Proxy A simple proxy could look like: ```js class Handler { get(target, key) { return target[key]; } } function createProxy(value) { return new Proxy(value, new Handler()); } ``` The following code will create two different proxies, which _most_ of the time is something we _may_ want to avoid. ```js const value = {}; const o1 = createProxy(value); const o2 = createProxy(value); console.log(o1 !== o2); ``` ## Proxy Cached with WeakMap Lets use a WeakMap to add caching, so whenever we pass an object we always get the same proxy for the same object. ```js class Handler { get(target, key) { return target[key]; } } const wm = new WeakMap(); function createProxy(value) { if (!wm.has(value)) { wm.set(value, new Proxy(value, new Handler())); } return wm.get(value); } ``` Now the returned proxy will be the same ```js const value = {}; const o1 = createProxy(value); const o2 = createProxy(value); console.log(o1 === o2); ``` ## Proxy Onions This is where it gets interesting, because lets add a different proxy. ```js class Handler1 { get(target, key) { return target[key]; } } class Handler2 { get(target, key) { return target[key]; } } const wm1 = new WeakMap(); function createProxy1(value) { if (!wm1.has(value)) { wm1.set(value, new Proxy(value, new Handler1())); } return wm1.get(value); } const wm2 = new WeakMap(); function createProxy2(value) { if (!wm2.has(value)) { wm2.set(value, new Proxy(value, new Handler2())); } return wm2.get(value); } ``` If now we do something on the lines of the following, (which could happen when we pass objects around), we would end with two different `createProxy1` proxies for the _same_ `value`, as our WeakMap doesn't have a reference for any of the objects. ```js const value = {}; const o = createProxy1(createProxy2(createProxy1(value))); ``` _Still, the WeakMap is welcome because any object with a references will reuse a proxy._ ## What We Need Given the following example code, when the last `createProxy1` gets evaluated ```js const o = createProxy1(createProxy2(createProxy1(value))); ``` 1. it needs to _see-through_ `createProxy2` 2. discover that's already proxied with `createProxy1` 3. return **what was passed to it**, and not the first proxy, because if not we will lose `createProxy2` wrapper ## Solving The Onion With The Symbol Trick Learned The Symbol Trick reading [Solid JS Source code](https://github.com/solidjs/solid), basically without adding a new property to the object, we can `get` a special property (usually a `Symbol`) and do something in special if the key responds as we expect. In this case returning a simple `true` ```js const $Handler1 = Symbol(); class Handler1 { get(target, key) { if (key === $Handler1) return true; return target[key]; } } const $Handler2 = Symbol(); class Handler2 { get(target, key) { if (key === $Handler2) return true; return target[key]; } } const wm1 = new WeakMap(); function createProxy1(value) { if (value[$Handler1]) { return value; } if (!wm1.has(value)) { wm1.set(value, new Proxy(value, new Handler1())); } return wm1.get(value); } const wm2 = new WeakMap(); function createProxy2(value) { if (value[$Handler2]) { return value; } if (!wm2.has(value)) { wm2.set(value, new Proxy(value, new Handler2())); } return wm2.get(value); } ``` So now we can _see-through_ `createProxy2` and do nothing. ```js const value = {}; const o1 = createProxy1(createProxy2(createProxy1(value))); const o2 = createProxy2(createProxy1(value)); console.log(o1 === o2); ``` It's a bit counter intuitive, but it works because when we do `proxy2[handler1]` it cannot find it, and then does `target[key]` which is forwarded to `proxy1[handler1]` returning `true`. ## Scoping, Recursively Now if we want for the proxy to have its own recursive cache, without being shared globally (as it happens in the previous example), for writing a `copy-on-write` or something like Solid JS `projections`, we can do the following: [please note this example is only for demonstrating persistent scoped references (it doesnt do `copy-on-write`)] ```js const $Handler = Symbol(); class Handler { constructor(wm) { this.wm = wm; // NOTICE } get(target, key, proxy) { if (key === $Handler) return this.wm; // NOTICE return createProxy(target[key], this.wm); } set(target, key, value, proxy) { target[key] = createProxy(value, this.wm); return true; } } function createProxy(value, wm = new WeakMap()) { if (typeof value !== "object") return value; if (value[$Handler] === wm /* NOTICE**/) { return value; } if (!wm.has(value)) { wm.set(value, new Proxy(value, new Handler(wm))); } return wm.get(value); } ``` When we create a proxy, we wont pass a WeakMap, but it will create one. Passing it to the proxy handler constructor as `new Handler(wm)` and sharing it recursively with `createProxy(value, this.wm)`, whenever the proxy returns an `object`. Making all references to any sub-object stable within the same proxy. ```js const value = { data: { a: "hola" } }; const o1 = createProxy(value); const o2 = createProxy(value); console.log(1, o1 !== o2); console.log(2, o1.data !== value.data); console.log(3, o1.data !== o2.data); o1.data2 = o1.data; console.log(4, o1.data === o1.data2); o1.data2 = value.data; console.log(5, o1.data === o1.data2); o2.data2 = o2.data; o2.data3 = o2.data2; console.log(6, o2.data === o2.data2); console.log(7, o2.data2 === o2.data3); console.log(8, o1.data2 !== o2.data2); console.log(9, o1.data !== o2.data); ``` But there's more, when we do the following (nesting a proxy inside the other): ```js const value = { data: { a: "hola" } }; const o1 = createProxy(value); const o2 = createProxy(value); o1.something = o2; ``` It wont _see-through_ `o2` and return `o2` proxy. The `keyed` symbol returns the `WeakMap` and the condition wont match. ```js if (value[$Handler] === wm /* NOTICE**/) { return value; } ``` The symbol is used for identity matching, not for querying the `WeakMap`. `o2.wm` is not the same one as `o1.wm`. Even then, when the object is not the same, `o2` will still reflect contents as `o1.something` but in a new instance, as our point was to not share references globally, but share them internally. ## A Simple Projection Now with this new knowledge we can create something in the lines of a Solid JS Projection, which will mirror an object and allow you to write to it, without changing the original. https://playground.solidjs.com/anonymous/967cd91f-4087-46c9-ba4c-1c89a46835a6 ## Bonus Track - The following example shows how you can return the wrong layer of an onion (the wrong proxy) if you arent careful https://playground.solidjs.com/anonymous/fe5ccf70-efab-40ab-9323-6aabaf2cedfa - Not all proxies should follow what has been demostrated, but avoiding overwrapping is important. --- ### Links - Ryan Carniato [hackmd profile](https://hackmd.io/@0u1u3zEAQAO0iYWVAStEvw) - loads of intersting notes (he also likes onions, thanks for the term!) - Solid JS [Discord](https://discord.gg/solidjs) - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy