classy-solid
Version:
Solid.js reactivity patterns for classes, and class components.
194 lines (166 loc) • 6.66 kB
text/typescript
import {getInheritedDescriptor} from 'lowclass/dist/getInheritedDescriptor.js'
import {$PROXY, untrack} from 'solid-js'
import type {PropKey} from '../decorators/types.js'
import {createSignalFunction, type SignalFunction} from './createSignalFunction.js'
import {isMemoGetter, isSignalGetter} from '../_state.js'
type AnyObject = Record<PropertyKey, unknown>
/**
* Convert properties on an object into Solid signal-backed properties.
*
* There are two ways to use this:
*
* 1. Define which properties to convert to signal-backed properties by
* providing property names as trailing arguments. Properties that are
* function-valued (methods) are included as values of the signal properties.
* 2. If no property names are provided, all non-function-valued properties on
* the object will be automatically converted to signal-backed properties.
*
* If any property is already memoified with `memoify()`, or already signalified
* with `signalify()`, it will be skipped.
*
* Example with a class:
*
* ```js
* import {signalify} from 'classy-solid'
* import {createEffect} from 'solid-js'
*
* class Counter {
* count = 0
*
* constructor() {
* signalify(this, 'count')
* setInterval(() => this.count++, 1000)
* }
* }
*
* const counter = new Counter
*
* createEffect(() => {
* console.log('count:', counter.count)
* })
* ```
*
* Example with a plain object:
*
* ```js
* import {signalify} from 'classy-solid'
* import {createEffect} from 'solid-js'
*
* const counter = {
* count: 0
* }
*
* signalify(counter, 'count')
* setInterval(() => counter.count++, 1000)
*
* createEffect(() => {
* console.log('count:', counter.count)
* })
* ```
*/
export function signalify<T extends object, K extends keyof T>(obj: T): T
export function signalify<T extends object>(obj: T, ...props: (keyof T)[]): T
/** This overload is for initial value support for downstream use cases. */
export function signalify<T extends object>(obj: T, ...props: [key: keyof T, initialValue: unknown][]): T
export function signalify(obj: AnyObject, ...props: [key: PropertyKey, initialValue: unknown][] | PropertyKey[]) {
// Special case for Solid proxies: if the object is already a solid proxy,
// all properties are already reactive, no need to signalify.
// @ts-expect-error special indexed access
const proxy = obj[$PROXY] as T
if (proxy) return obj
const skipFunctionProperties = props.length === 0
const _props = props.length ? props : (Object.keys(obj) as PropKey[]).concat(Object.getOwnPropertySymbols(obj))
// Use `untrack` here to be extra safe the initial value doesn't count as a
// dependency and cause a reactivity loop.
for (const prop of _props) {
const isTuple = Array.isArray(prop)
// We cast from PropertyKey to PropKey because keys can't actually be number, only string | symbol.
const _prop = (isTuple ? prop[0] : prop) as PropKey
const initialValue = isTuple ? prop[1] : untrack(() => obj[_prop])
createSignalAccessor__(obj, _prop, initialValue, skipFunctionProperties)
}
return obj
}
// propsSetAtLeastOnce is a Set that tracks which reactive properties have been
// set at least once.
const propsSetAtLeastOnce = new WeakMap<object, Set<string | symbol>>()
// @lume/element uses this to detect if a reactive prop has been set, and if so
// will not overwrite the value with any pre-existing value from custom element
// pre-upgrade.
export function isPropSetAtLeastOnce__(instance: object, prop: string | symbol) {
return !!propsSetAtLeastOnce.get(instance)?.has(prop)
}
export function trackPropSetAtLeastOnce__(instance: object, prop: string | symbol) {
if (!propsSetAtLeastOnce.has(instance)) propsSetAtLeastOnce.set(instance, new Set())
propsSetAtLeastOnce.get(instance)!.add(prop)
}
export function createSignalAccessor__<T extends object>(
obj: T,
prop: Exclude<keyof T, number>,
initialVal: unknown,
skipFunctionProperties = false,
): void {
let descriptor: PropertyDescriptor | undefined = getInheritedDescriptor(obj, prop)
let originalGet: (() => any) | undefined
let originalSet: ((v: any) => void) | undefined
const isAccessor = !!(descriptor?.get || descriptor?.set)
if (descriptor) {
if (skipFunctionProperties && typeof descriptor.value === 'function') return
originalGet = descriptor.get
originalSet = descriptor.set
// If the original getter is already a signal getter, skip re-signalifying.
if (originalGet && isSignalGetter.has(originalGet)) return
// If the original getter is already a memo getter, skip signalifying.
if (originalGet && isMemoGetter.has(originalGet)) return
// Signals require both getter and setter to work properly.
if (isAccessor && !(originalGet && originalSet)) return
if (!isAccessor) {
// No need to make a signal that can't be written to.
if (!descriptor.writable) return warnNotWritable(prop)
// If there was a value descriptor, trust it as the source of truth
// for initialVal. For example, if the user class modifies the value
// after the initializer, it will have a different value than what
// we tracked from the initializer.
initialVal = descriptor.value
}
}
const signalStorage = new WeakMap<object, SignalFunction<unknown>>()
const newDescriptor = {
configurable: true,
enumerable: descriptor?.enumerable,
get: isAccessor
? function (this: object): unknown {
getSignal__(this, signalStorage, initialVal)()
return originalGet!.call(this)
}
: function (this: object): unknown {
return getSignal__(this, signalStorage, initialVal)()
},
set: isAccessor
? function (this: object, newValue: unknown) {
originalSet!.call(this, newValue)
trackPropSetAtLeastOnce__(this, prop)
const s = getSignal__(this, signalStorage, initialVal)
s(typeof newValue === 'function' ? () => newValue : newValue)
}
: function (this: object, newValue: unknown) {
trackPropSetAtLeastOnce__(this, prop)
const s = getSignal__(this, signalStorage, initialVal)
s(typeof newValue === 'function' ? () => newValue : newValue)
},
}
isSignalGetter.add(newDescriptor.get!)
Object.defineProperty(obj, prop, newDescriptor)
}
export function getSignal__(obj: object, storage: WeakMap<object, SignalFunction<unknown>>, initialVal: unknown) {
let s = storage.get(obj)
if (!s) storage.set(obj, (s = createSignalFunction(initialVal, {equals: false})))
return s
}
function warnNotWritable(prop: PropertyKey) {
console.warn(
`The \`@signal\` decorator was used on a property named "${String(
prop,
)}" that is not writable. Reactivity is not enabled for non-writable properties.`,
)
}