Skip to content
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

feat: lazy observables #604

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all 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
58 changes: 53 additions & 5 deletions src/vanilla.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,16 +42,52 @@ type CreateSnapshot = <T extends object>(
handlePromise?: HandlePromise
) => T

class ListenerSet<T = Listener> extends Set<T> {
public constructor(
private onBecomeObserved?: () => void,
private onBecomeUnobserved?: () => void
) {
super()
}

public add(listener: T) {
super.add(listener)
if (this.onBecomeObserved) {
const realListeners = [...this].filter((listener) => {
return !(listener as any)[PROP_LISTENER]
})
if (realListeners.length === 1) {
this.onBecomeObserved()
}
}
return this
}

public delete(listener: T) {
const wasRemoved = super.delete(listener)
if (wasRemoved && this.onBecomeUnobserved) {
const realListeners = [...this].filter((listener) => {
return !(listener as any)[PROP_LISTENER]
})
if (realListeners.length === 0) {
this.onBecomeUnobserved()
}
}
return wasRemoved
}
}

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

// shared state
const PROXY_STATE = Symbol()
const PROP_LISTENER = Symbol()
const refSet = new WeakSet()

const buildProxyFunction = (
Expand Down Expand Up @@ -131,7 +167,11 @@ const buildProxyFunction = (

versionHolder = [1] as [number],

proxyFunction = <T extends object>(initialObject: T): T => {
proxyFunction = <T extends object>(
initialObject: T,
onBecomeObserved?: () => void,
onBecomeUnobserved?: () => void
): T => {
if (!isObject(initialObject)) {
throw new Error('object required')
}
Expand All @@ -140,7 +180,8 @@ const buildProxyFunction = (
return found
}
let version = versionHolder[0]
const listeners = new Set<Listener>()
const listeners = new ListenerSet(onBecomeObserved, onBecomeUnobserved)

const notifyUpdate = (op: Op, nextVersion = ++versionHolder[0]) => {
if (version !== nextVersion) {
version = nextVersion
Expand All @@ -156,6 +197,9 @@ const buildProxyFunction = (
newOp[1] = [prop, ...(newOp[1] as Path)]
notifyUpdate(newOp, nextVersion)
}
// tag the prop listeners with the symbol so we can ignore them
// in the listener set for lazy observables
;(propListener as any)[PROP_LISTENER] = true
propListeners.set(prop, propListener)
}
return propListener
Expand Down Expand Up @@ -276,8 +320,12 @@ const buildProxyFunction = (

const [proxyFunction] = buildProxyFunction()

export function proxy<T extends object>(initialObject: T = {} as T): T {
return proxyFunction(initialObject)
export function proxy<T extends object>(
initialObject: T = {} as T,
onBecomeObserved?: () => void,
onBecomeUnobserved?: () => void
): T {
return proxyFunction(initialObject, onBecomeObserved, onBecomeUnobserved)
}

export function getVersion(proxyObject: unknown): number | undefined {
Expand Down
54 changes: 54 additions & 0 deletions tests/async.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,57 @@ it('delayed falsy value', async () => {
await findByText('loading')
await findByText('value: null')
})

it('lazy delayed increment', async () => {
// make a lazily updated async state
let intervalId: string | number | NodeJS.Timeout | undefined
const state = proxy<any>(
{ count: 0, counting: false },
() => {
// initialize the value on observe
state.count = 10
// and start an async interval to update it
intervalId = setInterval(() => {
state.count += 1
}, 100)
},
() => {
// clear async interval when no longer observed
clearInterval(intervalId)
intervalId = -1
}
)

const toggleIncrement = () => {
state.counting = !state.counting
}

const Counter = () => {
const snap = useSnapshot(state)
return (
<>
{state.counting && <div>count: {snap.count}</div>}
<button onClick={toggleIncrement}>button</button>
</>
)
}

const { getByText, findByText } = render(
<StrictMode>
<Counter />
</StrictMode>
)

fireEvent.click(getByText('button'))
// expect initial value to have been set
await findByText('count: 10')
// expect timer to be running
await findByText('count: 11')
expect(intervalId !== -1)
fireEvent.click(getByText('button'))
// expect timer to have stopped
await findByText('count: 11')
expect(intervalId === -1)
sleep(200)
await findByText('count: 11')
})