Skip to content

Commit 65ed737

Browse files
committed
Add unwrap method for proxy edge cases
1 parent 4a14cf5 commit 65ed737

6 files changed

Lines changed: 132 additions & 10 deletions

File tree

src/exports.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import shallowEqual from './utils/shallowEqual'
99

1010
import Provider from './components/Provider'
1111
import { defaultNoopBatch } from './utils/batch'
12-
import { SignalProvider, useSignalSelector } from './signals'
12+
import { SignalProvider, useSignalSelector, unwrap } from './signals'
1313

1414
export { ReactReduxContext } from './components/Context'
1515
export type { ReactReduxContextValue } from './components/Context'
@@ -56,5 +56,6 @@ export {
5656
legacy_connect,
5757
shallowEqual,
5858
SignalProvider,
59+
unwrap,
5960
useSignalSelector,
6061
}

src/index-rsc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export {
3131
throwNotSupportedError as useSelector,
3232
throwNotSupportedError as useStore,
3333
throwNotSupportedError as SignalProvider,
34+
throwNotSupportedError as unwrap,
3435
throwNotSupportedError as useSignalSelector,
3536
}
3637
export const ReactReduxContext = {} as any

src/signals/arrayMethodOverrides.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getProxyTarget } from './trackingProxy'
1+
import { unwrap } from './trackingProxy'
22

33
/**
44
* Non-mutating array methods that we override on the tracking proxy.
@@ -147,7 +147,7 @@ export function createArrayMethodInterceptor(
147147
// so the comparison works against raw array elements.
148148
if (m === 'includes' || m === 'indexOf' || m === 'lastIndexOf') {
149149
const unwrappedArgs = args.map((arg, i) =>
150-
i === 0 ? getProxyTarget(arg) : arg,
150+
i === 0 ? unwrap(arg) : arg,
151151
)
152152
return (target as any)[m](...unwrappedArgs)
153153
}

src/signals/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@ export type {
1818
export { createPathSignalRegistry } from './pathSignalRegistry'
1919
export type { PathSignalRegistry } from './pathSignalRegistry'
2020

21-
export { createTrackingProxy } from './trackingProxy'
21+
export { createTrackingProxy, unwrap } from './trackingProxy'
2222

2323
export { diffAndUpdateSignals, reconcileState } from './diff'

src/signals/trackingProxy.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,28 @@ export function getProxyPath(value: unknown): string | undefined {
6060

6161
/**
6262
* Get the raw target object from a tracking proxy, or the value itself if not a proxy.
63-
* Used to unwrap proxy arguments in array methods like includes/indexOf.
64-
* @param value - The value to unwrap
65-
* @returns The raw target object, or the original value if not a proxy
63+
*
64+
* Use this when you need identity comparison between values that may be
65+
* tracking proxies. Since `proxy === rawObject` is always `false` in JS,
66+
* unwrapping both sides allows correct identity checks:
67+
*
68+
* ```ts
69+
* import { unwrap } from 'react-redux/signals'
70+
*
71+
* const selector = (state) => {
72+
* const current = unwrap(state.current)
73+
* return state.items.find(item => item === current)
74+
* }
75+
* ```
76+
*
77+
* Safe to call on non-proxy values — returns them unchanged.
78+
*
79+
* @param value - The value to unwrap (proxy or raw)
80+
* @returns The raw target object, or the original value if not a tracking proxy
6681
*/
67-
export function getProxyTarget(value: unknown): unknown {
82+
export function unwrap<T>(value: T): T {
6883
if (value !== null && typeof value === 'object') {
69-
return proxyTargetMap.get(value) ?? value
84+
return (proxyTargetMap.get(value as object) as T) ?? value
7085
}
7186
return value
7287
}

test/signals/trackingProxy.spec.ts

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi } from 'vitest'
2-
import { createTrackingProxy, getProxyPath } from '../../src/signals/trackingProxy'
2+
import { createTrackingProxy, getProxyPath, unwrap } from '../../src/signals/trackingProxy'
33
import { createPathSignalRegistry } from '../../src/signals/pathSignalRegistry'
44
import { alienEngine } from '../../src/signals/engine'
55
import type { PathSignalRegistry } from '../../src/signals/pathSignalRegistry'
@@ -707,3 +707,108 @@ describe('createTrackingProxy', () => {
707707
scope.stop()
708708
})
709709
})
710+
711+
describe('unwrap', () => {
712+
function makeRegistry(): PathSignalRegistry {
713+
return createPathSignalRegistry(alienEngine)
714+
}
715+
716+
it('returns the raw target from a tracking proxy', () => {
717+
const state = { name: 'Alice', nested: { x: 1 } }
718+
const registry = makeRegistry()
719+
const proxy = createTrackingProxy(state, '', registry, registry.proxyCache)
720+
721+
expect(unwrap(proxy)).toBe(state)
722+
})
723+
724+
it('returns nested raw target from a child proxy', () => {
725+
const state = { nested: { x: 1 } }
726+
const registry = makeRegistry()
727+
const proxy = createTrackingProxy(state, '', registry, registry.proxyCache)
728+
729+
const childProxy = proxy.nested
730+
expect(childProxy).not.toBe(state.nested) // is a proxy
731+
expect(unwrap(childProxy)).toBe(state.nested) // unwraps to raw
732+
})
733+
734+
it('returns the value unchanged for non-proxy objects', () => {
735+
const obj = { a: 1 }
736+
expect(unwrap(obj)).toBe(obj)
737+
})
738+
739+
it('returns primitives unchanged', () => {
740+
expect(unwrap(42)).toBe(42)
741+
expect(unwrap('hello')).toBe('hello')
742+
expect(unwrap(true)).toBe(true)
743+
expect(unwrap(null)).toBe(null)
744+
expect(unwrap(undefined)).toBe(undefined)
745+
})
746+
747+
it('enables identity comparison between unwrapped proxy and raw value', () => {
748+
const item = { id: 1, name: 'first' }
749+
const state = { items: [item], current: item }
750+
const registry = makeRegistry()
751+
const proxy = createTrackingProxy(state, '', registry, registry.proxyCache)
752+
753+
// Without unwrap: proxy !== raw
754+
const proxiedCurrent = proxy.current
755+
expect(proxiedCurrent === item).toBe(false)
756+
757+
// With unwrap: raw === raw
758+
expect(unwrap(proxiedCurrent) === item).toBe(true)
759+
})
760+
761+
it('indexOf/includes auto-unwrap proxy arguments', () => {
762+
const state = {
763+
items: [
764+
{ id: 1, name: 'a' },
765+
{ id: 2, name: 'b' },
766+
{ id: 3, name: 'c' },
767+
],
768+
}
769+
const registry = makeRegistry()
770+
const proxy = createTrackingProxy(state, '', registry, registry.proxyCache)
771+
772+
// Access an item through the proxy — get back a proxy wrapper
773+
const proxiedItem = proxy.items[1]
774+
expect(proxiedItem).not.toBe(state.items[1]) // it's a proxy
775+
776+
// indexOf/includes auto-unwrap proxy args internally, so these just work
777+
expect(proxy.items.indexOf(proxiedItem)).toBe(1)
778+
expect(proxy.items.includes(proxiedItem)).toBe(true)
779+
780+
// Also works with manually unwrapped value
781+
expect(proxy.items.indexOf(unwrap(proxiedItem))).toBe(1)
782+
})
783+
784+
it('find() callback needs unwrap for identity comparison', () => {
785+
const state = {
786+
items: [
787+
{ id: 1, name: 'a' },
788+
{ id: 2, name: 'b' },
789+
{ id: 3, name: 'c' },
790+
],
791+
}
792+
const registry = makeRegistry()
793+
const proxy = createTrackingProxy(state, '', registry, registry.proxyCache)
794+
795+
// Get a proxy-wrapped item
796+
const proxiedItem = proxy.items[1]
797+
const rawItem = unwrap(proxiedItem)
798+
799+
// find callback receives raw elements; unwrapped proxy === raw element
800+
const found = proxy.items.find(
801+
(item: { id: number; name: string }) => item === rawItem,
802+
)
803+
804+
expect(found).toBeDefined()
805+
// find() returns a proxied result (lazy signal registration)
806+
expect(found).not.toBe(state.items[1])
807+
expect(unwrap(found)).toBe(state.items[1])
808+
809+
// Accessing properties on the found proxy registers signals
810+
const name = (found as { name: string }).name
811+
expect(name).toBe('b')
812+
expect(registry.has('items.{id:2}.name')).toBe(true)
813+
})
814+
})

0 commit comments

Comments
 (0)