Skip to content

fix(vanilla, react): use experimental_use and some refactors #545

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 21 commits into from
Oct 15, 2022
Merged
Show file tree
Hide file tree
Changes from 16 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
50 changes: 46 additions & 4 deletions src/react.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { useCallback, useDebugValue, useEffect, useMemo, useRef } from 'react'
import {
affectedToPathList,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
experimental_use,
Copy link

@SukkaW SukkaW Sep 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DefinitelyTyped/DefinitelyTyped#62189

It seems that the typing of experimental_use is now added to the @types/react, and ts-ignore could be removed.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, yeah, I updated there and there, but forgot here. Thanks.

useCallback,
useDebugValue,
useEffect,
useMemo,
useRef,
} from 'react'
import {
// affectedToPathList,
createProxy as createProxyToCompare,
getUntracked,
isChanged,
} from 'proxy-compare'
// import { useSyncExternalStore } from 'use-sync-external-store/shim'
Expand All @@ -14,6 +24,38 @@ import type { INTERNAL_Snapshot } from './vanilla'

const { useSyncExternalStore } = useSyncExternalStoreExports

// customized version of affectedToPathList
// we need to avoid invoking getters
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dai-shi ahhh, okay, this change (skipping getters in the output of affectedToPathList) is why I've been confused.

I've been watching the output of useAffectedDebugValue (maybe a little too religiously in retrospect) and was very confused by the lack of getters.

This made me think "valtio is not working" / "doesn't know my getters were invoked", when obviously I could tell it did, b/c the re-renders still worked correctly.

I finally reproduced the behavior in proxy-compare and "get it"--getters (and method invocations) are always in the affected map, and are always handled correctly by isChanged, it is merely affectedToPathList that is leaving them off.

What was the rationale here? I'm sure it makes sense, just curious b/c it tripped me up. Wondering if I can work the rationale/behavior in a doc/"gotchas" update.

Thanks!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Without it, it loops infinitely and hangs, AFAIR.

const affectedToPathList = (
obj: unknown,
affected: WeakMap<object, unknown>
) => {
const list: (string | symbol)[][] = []
const seen = new WeakSet()
const walk = (x: unknown, path?: (string | symbol)[]) => {
if (seen.has(x as object)) {
// for object with cycles
return
}
let used: Set<string | symbol> | undefined
if (typeof x === 'object' && x !== null) {
seen.add(x)
used = affected.get(getUntracked(x) || x) as any
}
if (used) {
used.forEach((key) => {
if ('value' in (Object.getOwnPropertyDescriptor(x, key) || {})) {
walk((x as any)[key], path ? [...path, key] : [key])
}
})
} else if (path) {
list.push(path)
}
}
walk(obj)
return list
}

const useAffectedDebugValue = (
state: object,
affected: WeakMap<object, unknown>
Expand Down Expand Up @@ -119,7 +161,7 @@ export function useSnapshot<T extends object>(
[proxyObject, notifyInSync]
),
() => {
const nextSnapshot = snapshot(proxyObject)
const nextSnapshot = snapshot(proxyObject, experimental_use)
try {
if (
!inRender &&
Expand All @@ -140,7 +182,7 @@ export function useSnapshot<T extends object>(
}
return nextSnapshot
},
() => snapshot(proxyObject)
() => snapshot(proxyObject, experimental_use)
)
inRender = false
const currAffected = new WeakMap()
Expand Down
180 changes: 101 additions & 79 deletions src/vanilla.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,24 @@ export type INTERNAL_Snapshot<T> = T extends AnyFunction
readonly [K in keyof T]: INTERNAL_Snapshot<T[K]>
}

type CreateSnapshot = <T extends object>(
target: T,
receiver: object,
version: number,
use?: <V>(promise: Promise<V>) => V
) => T

type ProxyState = [
target: object,
receiver: object,
version: number,
createSnapshot: CreateSnapshot,
listeners: Set<Listener>
]

// shared state
const PROXY_STATE = __DEV__ ? Symbol('PROXY_STATE') : Symbol()
const refSet = new WeakSet()
const VERSION = __DEV__ ? Symbol('VERSION') : Symbol()
const LISTENERS = __DEV__ ? Symbol('LISTENERS') : Symbol()
const SNAPSHOT = __DEV__ ? Symbol('SNAPSHOT') : Symbol()

const buildProxyFunction = (
objectIs = Object.is,
Expand All @@ -56,51 +69,60 @@ const buildProxyFunction = (
!(x instanceof RegExp) &&
!(x instanceof ArrayBuffer),

PROMISE_RESULT = __DEV__ ? Symbol('PROMISE_RESULT') : Symbol(),
PROMISE_ERROR = __DEV__ ? Symbol('PROMISE_ERROR') : Symbol(),
defaultUse = (() => {
type PromiseState = { v?: unknown; e?: unknown }
const PROMISE_STATE = Symbol()
return <V>(promise: Promise<V>): V => {
let state: PromiseState | undefined = (promise as any)[PROMISE_STATE]
if (!state) {
state = {}
;(promise as any)[PROMISE_STATE] = state
promise
.then((v) => ((state as PromiseState).v = v))
.catch((e) => ((state as PromiseState).e = e))
}
if ('v' in state) {
return state.v as V
}
throw 'e' in state ? state.e : promise
}
})(),

snapshotCache = new WeakMap<object, [version: number, snapshot: unknown]>(),
snapCache = new WeakMap<object, [version: number, snap: unknown]>(),

createSnapshot = <T extends object>(
version: number,
createSnapshot: CreateSnapshot = <T extends object>(
target: T,
receiver: any
receiver: object,
version: number,
use = defaultUse
): T => {
const cache = snapshotCache.get(receiver)
const cache = snapCache.get(receiver)
if (cache?.[0] === version) {
return cache[1] as T
}
const snapshot: any = Array.isArray(target)
const snap: any = Array.isArray(target)
? []
: Object.create(Object.getPrototypeOf(target))
markToTrack(snapshot, true) // mark to track
snapshotCache.set(receiver, [version, snapshot])
markToTrack(snap, true) // mark to track
snapCache.set(receiver, [version, snap])
Reflect.ownKeys(target).forEach((key) => {
const value = Reflect.get(target, key, receiver)
if (refSet.has(value)) {
markToTrack(value, false) // mark not to track
snapshot[key] = value
snap[key] = value
} else if (value instanceof Promise) {
if (PROMISE_RESULT in value) {
snapshot[key] = (value as any)[PROMISE_RESULT]
} else {
const errorOrPromise = (value as any)[PROMISE_ERROR] || value
Object.defineProperty(snapshot, key, {
get() {
if (PROMISE_RESULT in value) {
return (value as any)[PROMISE_RESULT]
}
throw errorOrPromise
},
})
}
} else if (value?.[LISTENERS]) {
snapshot[key] = value[SNAPSHOT]
Object.defineProperty(snap, key, {
get() {
return use(value)
},
})
} else if (value?.[PROXY_STATE]) {
snap[key] = snapshot(value, use)
} else {
snapshot[key] = value
snap[key] = value
}
})
return Object.freeze(snapshot)
return Object.freeze(snap)
},

proxyCache = new WeakMap<object, ProxyObject>(),
Expand Down Expand Up @@ -145,65 +167,64 @@ const buildProxyFunction = (
? []
: Object.create(Object.getPrototypeOf(initialObject))
const handler: ProxyHandler<T> = {
get(target: T, prop: string | symbol, receiver: any) {
if (prop === VERSION) {
return version
}
if (prop === LISTENERS) {
return listeners
}
if (prop === SNAPSHOT) {
return createSnapshot(version, target, receiver)
get(target: T, prop: string | symbol, receiver: object) {
if (prop === PROXY_STATE) {
const state: ProxyState = [
target,
receiver,
version,
createSnapshot,
listeners,
]
return state
}
return Reflect.get(target, prop, receiver)
},
deleteProperty(target: T, prop: string | symbol) {
const prevValue = Reflect.get(target, prop)
const childListeners = prevValue?.[LISTENERS]
const childListeners = (
(prevValue as any)?.[PROXY_STATE] as ProxyState | undefined
)?.[4]
if (childListeners) {
childListeners.delete(popPropListener(prop))
childListeners.delete(popPropListener(prop) as Listener)
}
const deleted = Reflect.deleteProperty(target, prop)
if (deleted) {
notifyUpdate(['delete', [prop], prevValue])
}
return deleted
},
set(target: T, prop: string | symbol, value: any, receiver: any) {
set(target: T, prop: string | symbol, value: any, receiver: object) {
const hasPrevValue = Reflect.has(target, prop)
const prevValue = Reflect.get(target, prop, receiver)
if (hasPrevValue && objectIs(prevValue, value)) {
return true
}
const childListeners = prevValue?.[LISTENERS]
const childListeners = (
(prevValue as any)?.[PROXY_STATE] as ProxyState | undefined
)?.[4]
if (childListeners) {
childListeners.delete(popPropListener(prop))
childListeners.delete(popPropListener(prop) as Listener)
}
if (isObject(value)) {
value = getUntracked(value) || value
}
let nextValue: any
let nextValue = value
if (Object.getOwnPropertyDescriptor(target, prop)?.set) {
nextValue = value
// do nothing
} else if (value instanceof Promise) {
nextValue = value
.then((v) => {
nextValue[PROMISE_RESULT] = v
notifyUpdate(['resolve', [prop], v])
return v
})
.catch((e) => {
nextValue[PROMISE_ERROR] = e
notifyUpdate(['reject', [prop], e])
})
} else if (value?.[LISTENERS]) {
nextValue = value
nextValue[LISTENERS].add(getPropListener(prop))
} else if (canProxy(value)) {
nextValue = proxy(value)
nextValue[LISTENERS].add(getPropListener(prop))
value
.then((v) => notifyUpdate(['resolve', [prop], v]))
.catch((e) => notifyUpdate(['reject', [prop], e]))
} else {
nextValue = value
if (!value?.[PROXY_STATE] && canProxy(value)) {
nextValue = proxy(value)
}
if (nextValue?.[PROXY_STATE]) {
;(nextValue[PROXY_STATE] as ProxyState)[4].add(
getPropListener(prop)
)
}
}
Reflect.set(target, prop, nextValue, receiver)
notifyUpdate(['set', [prop], value, prevValue])
Expand All @@ -230,17 +251,14 @@ const buildProxyFunction = (
// public functions
proxyFunction,
// shared state
PROXY_STATE,
refSet,
VERSION,
LISTENERS,
SNAPSHOT,
// internal things
objectIs,
newProxy,
canProxy,
PROMISE_RESULT,
PROMISE_ERROR,
snapshotCache,
defaultUse,
snapCache,
createSnapshot,
proxyCache,
versionHolder,
Expand All @@ -253,15 +271,16 @@ export function proxy<T extends object>(initialObject: T = {} as T): T {
}

export function getVersion(proxyObject: unknown): number | undefined {
return isObject(proxyObject) ? (proxyObject as any)[VERSION] : undefined
const state: ProxyState | undefined = (proxyObject as any)?.[PROXY_STATE]
return state?.[2]
}

export function subscribe<T extends object>(
proxyObject: T,
callback: (ops: Op[]) => void,
notifyInSync?: boolean
) {
if (__DEV__ && !(proxyObject as any)?.[LISTENERS]) {
if (__DEV__ && !(proxyObject as any)?.[PROXY_STATE]) {
console.warn('Please use proxy object')
}
let promise: Promise<void> | undefined
Expand All @@ -279,19 +298,22 @@ export function subscribe<T extends object>(
})
}
}
;(proxyObject as any)[LISTENERS].add(listener)
return () => {
;(proxyObject as any)[LISTENERS].delete(listener)
}
const listeners = ((proxyObject as any)[PROXY_STATE] as ProxyState)[4]
listeners.add(listener)
return () => listeners.delete(listener)
}

export function snapshot<T extends object>(
proxyObject: T
proxyObject: T,
use?: <V>(promise: Promise<V>) => V
): INTERNAL_Snapshot<T> {
if (__DEV__ && !(proxyObject as any)?.[SNAPSHOT]) {
if (__DEV__ && !(proxyObject as any)?.[PROXY_STATE]) {
console.warn('Please use proxy object')
}
return (proxyObject as any)[SNAPSHOT]
const [target, receiver, version, createSnapshot] = (proxyObject as any)[
PROXY_STATE
] as ProxyState
return createSnapshot(target, receiver, version, use) as INTERNAL_Snapshot<T>
}

export function ref<T extends object>(obj: T): T & AsRef {
Expand Down