epic-state
Version:
Reactive state management for frontend libraries.
153 lines (129 loc) • 4.83 kB
text/typescript
// @ts-ignore All export paths will be type checked, avoid errors if not installed.
import { options } from 'preact'
import { log } from '../helper'
import { type Plugin, type PluginActions, type Property, type ProxyObject, TupleArrayMap } from '../types'
// With preact you can set custom hooks for the render cycle: https://preactjs.com/guide/v10/options
// While the render hook isn't exposed it can still be added as '__r' and used to access
// the currently rendered component reliably.
interface Component<P = {}, S = {}> {
setState<K extends keyof S>(
state: ((prevState: Readonly<S>, props: Readonly<P>) => Pick<S, K> | Partial<S> | null) | (Pick<S, K> | Partial<S> | null),
callback?: () => void,
): void
}
enum OptionsTypes {
// biome-ignore lint/style/useNamingConvention: Preact default.
HOOK = '__h',
// biome-ignore lint/style/useNamingConvention: Preact default.
DIFF = '__b',
// biome-ignore lint/style/useNamingConvention: Preact default.
DIFFED = 'diffed',
// biome-ignore lint/style/useNamingConvention: Preact default.
RENDER = '__r',
// biome-ignore lint/style/useNamingConvention: Preact default.
CATCH_ERROR = '__e',
// biome-ignore lint/style/useNamingConvention: Preact default.
UNMOUNT = 'unmount',
}
// biome-ignore lint/style/useNamingConvention: Will lead to infinite parsing when run with --write.
interface VNode {
/** The component instance for this VNode */
__c: AugmentedComponent
/** The parent VNode */
__?: VNode
/** The DOM node for this VNode */
__e?: Element | Text
/** Props that had Signal values before diffing (used after diffing to subscribe) */
__np?: Record<string, any> | null
}
interface AugmentedComponent extends Component<any, any> {
__v: VNode
_updater?: () => void
_updateFlags: number
}
const HAS_PENDING_UPDATE = 1 << 0
function hook<T extends OptionsTypes>(hookName: T, hookFn: HookFn<T>) {
// @ts-ignore-next-line private options hooks usage
options[hookName] = hookFn.bind(null, options[hookName])
}
interface OptionsType {
[OptionsTypes.HOOK](component: Component, index: number, type: number): void
[OptionsTypes.DIFF](vnode: VNode): void
[OptionsTypes.DIFFED](vnode: VNode): void
[OptionsTypes.RENDER](vnode: VNode): void
[OptionsTypes.CATCH_ERROR](error: any, vnode: VNode, oldNode: VNode): void
[OptionsTypes.UNMOUNT](vnode: VNode): void
}
type HookFn<T extends keyof OptionsType> = (old: OptionsType[T], ...a: Parameters<OptionsType[T]>) => ReturnType<OptionsType[T]>
let currentComponent: AugmentedComponent | undefined
hook(OptionsTypes.RENDER, (old, vnode) => {
const component = vnode.__c
if (component) {
component._updateFlags &= ~HAS_PENDING_UPDATE
if (!component._updater) {
component._updater = function forceUpdate() {
component._updateFlags |= HAS_PENDING_UPDATE
component.setState({})
}
}
}
currentComponent = component
if (old) {
// NOTE missing in original implementation, sometimes causes issues, probably due to order of registration.
old(vnode)
}
})
hook(OptionsTypes.CATCH_ERROR, (old, error, vnode, oldNode) => {
currentComponent = undefined
if (old) {
old(error, vnode, oldNode)
}
})
hook(OptionsTypes.DIFFED, (old, vnode) => {
currentComponent = undefined
if (old) {
old(vnode)
}
})
export const connect: Plugin = (initialize) => {
if (initialize !== 'initialize') {
log('connect plugin cannot be configured', 'warning')
}
const observedProperties = new TupleArrayMap<ProxyObject, Property, () => void>()
return {
set: ({ property, parent, value, previousValue }) => {
if (value === previousValue) {
return
}
const components = observedProperties.get(parent, property)
// Remove, as get will be tracked again during render.
if (observedProperties.has(parent, property)) {
observedProperties.delete(parent, property)
}
// Trigger rerender on components.
if (components) {
for (const component of components) {
component()
}
}
},
get: ({ property, parent }) => {
if (!currentComponent) {
return // Accessed outside a component.
}
if (!currentComponent._updater) {
log('Missing _updater on component', 'warning')
return
}
// Register rerender on current component.
if (observedProperties.has(parent, property)) {
const components = observedProperties.get(parent, property)
if (!components?.includes(currentComponent._updater)) {
components?.push(currentComponent._updater)
}
} else if (!observedProperties.has(parent, property)) {
observedProperties.add(parent, property, currentComponent._updater)
}
},
} as PluginActions
}