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 go #46

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
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
121 changes: 120 additions & 1 deletion packages/core/src/property.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Atom, newAtom } from './atom'

import { never, newObservable } from './observable'
import { constVoid } from '@frp-ts/utils'
import { combine, flatten, fromObservable, newProperty, Property, scan, tap } from './property'
import { combine, flatten, fromObservable, go, newProperty, Property, scan, tap } from './property'
import { from, Observable, Subject } from 'rxjs'
import { action, newEmitter } from './emitter'
import { attachSubscription } from '@frp-ts/test-utils'
Expand Down Expand Up @@ -536,3 +536,122 @@ describe('scan', () => {
expect(getA()).toBe(3)
})
})

describe('go', () => {
it('gets executes computation and returns result', () => {
const a = newAtom(1)
const b = newAtom(2)
const c = go((at) => at(a) + at(b))
expect(c.get()).toEqual(3)
})
it('propagates notifications', () => {
const a = newAtom(1)
const b = newAtom(2)
const c = go((at) => at(a) + at(b))
const next = jest.fn()
c.subscribe({ next })
a.set(2)
expect(next).toHaveBeenCalledTimes(1)
expect(c.get()).toEqual(4)
})
it('gets only requires dependencies', () => {
const getA = jest.fn(() => 1)
const a = newProperty(getA, never.subscribe)
const getB = jest.fn(() => 2)
const b = newProperty(getB, never.subscribe)
// eslint-disable-next-line no-constant-condition
const c = go((at) => (1 < 2 ? at(a) : at(b)))
expect(c.get()).toEqual(1)
expect(getA).toHaveBeenCalled()
expect(getB).not.toHaveBeenCalled()
})
it('subscribes only to required dependencies', () => {
const a = newAtom(1)
const b = newAtom(2)
// eslint-disable-next-line no-constant-condition
const c = go((at) => (1 < 2 ? at(a) : at(b)))
const next = jest.fn()
c.subscribe({ next })
expect(next).not.toHaveBeenCalled()
a.set(2)
expect(next).toHaveBeenCalledTimes(1)
b.set(3)
expect(next).toHaveBeenCalledTimes(1)
})
it('gets only required dependencies if layout changes', () => {
const getA = jest.fn(() => 1)
const a = newProperty(getA, never.subscribe)
const getB = jest.fn(() => 2)
const b = newProperty(getB, never.subscribe)
const c = newAtom(3)
const d = go((at) => (at(c) === 3 ? at(a) : at(b)))
expect(getA).not.toHaveBeenCalled()
expect(getB).not.toHaveBeenCalled()
expect(d.get()).toEqual(1)
expect(getA).toHaveBeenCalledTimes(1)
expect(getB).toHaveBeenCalledTimes(0)
getA.mockClear()
getB.mockClear()
c.set(4)
expect(d.get()).toEqual(2)
expect(getA).toHaveBeenCalledTimes(0)
expect(getB).toHaveBeenCalledTimes(1)
})
it('does not emit if result value did not change and result property has at least one consumer', () => {
const a = newAtom(1)
const b = newAtom(2)
const c = go((at) => at(a) + at(b))
const next = jest.fn()
c.subscribe({ next })
expect(next).toHaveBeenCalledTimes(0)
// imitate consumer to warm up the cache
c.get()
action(() => {
a.set(2)
b.set(1)
})
expect(next).toHaveBeenCalledTimes(0)
})
it('emits the very first time when there is no consumer and then skips duplicates', () => {
const a = newAtom(1)
const b = newAtom(2)
const c = go((at) => at(a) + at(b))
const next = jest.fn()
c.subscribe({ next })
expect(next).toHaveBeenCalledTimes(0)
action(() => {
a.set(2)
b.set(1)
})
action(() => {
a.set(1)
b.set(2)
})
expect(next).toHaveBeenCalledTimes(1)
})
it('covers use case', () => {
const firstName = newAtom('John')
const lastName = newAtom('Doe')
const isFirstNameShort = go((at) => at(firstName).length < 10)
const buildFullName = jest.fn((firstName: string, lastName: string) => {
return `${firstName} ${lastName}`
})
const fullName = go((at) => buildFullName(at(firstName), at(lastName)))
const displayName = go((at) => (at(isFirstNameShort) ? at(firstName) : at(fullName)))
const next = jest.fn()
displayName.subscribe({ next })
expect(displayName.get()).toBe('John')
expect(next).toHaveBeenCalledTimes(0)
expect(buildFullName).toHaveBeenCalledTimes(0)

firstName.set('123456789') // less than 10 symbols
expect(displayName.get()).toBe('123456789')
expect(buildFullName).toHaveBeenCalledTimes(0)
expect(next).toHaveBeenCalledTimes(1)

firstName.set('1234567890') // 10 symbols
// expect(displayName.get()).toBe('1234567890 Doe')
// expect(buildFullName).toHaveBeenCalledTimes(1)
expect(next).toHaveBeenCalledTimes(2)
})
})
30 changes: 29 additions & 1 deletion packages/core/src/property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { never, Observable, Observer, Subscription, subscriptionNone } from './o
import { mergeMany, multicast, newEmitter } from './emitter'
import { newAtom } from './atom'
import { Time } from './clock'
import { memo0, memo1, memo2, memo3, memo4, memo5, memoMany } from '@frp-ts/utils'
import { memo0, memo1, memo2, memo3, memo4, memo5, memoMany, identity } from '@frp-ts/utils'
import { InteropObservableHolder, newInteropObservable, observableSymbol } from './interop-observable'

export interface Property<A> extends Observable<Time>, InteropObservableHolder<A> {
Expand Down Expand Up @@ -164,3 +164,31 @@ const memoizeProjectionFunction = <Result>(
return memoF.apply(undefined, values)
}
}

export function go<Result>(execution: (at: <Value>(input: Property<Value>) => Value) => Result): Property<Result> {
const subscriptions = new Map<Property<unknown>, Subscription>()
const emitter = newEmitter()
const sample = <Value>(input: Property<Value>): Value => {
!subscriptions.has(input) && subscriptions.set(input, input.subscribe(emitter))
return input.get()
}
let isInitialized = false
const proxy = newProperty(
() => {
for (const s of subscriptions.values()) s.unsubscribe()
subscriptions.clear()
return execution(sample)
},
(observer) => {
// warmup cache
if (!isInitialized) {
isInitialized = true
execution(sample)
}
return emitter.subscribe(observer)
},
)
return proxy
// TODO skip duplicates for subscriptions
return combine<[Property<Result>], Result>(proxy, identity)
}
3 changes: 2 additions & 1 deletion packages/utils/src/function.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,5 +132,6 @@ export const memoMany = <Args extends readonly unknown[], Result>(
}
}

// eslint-disable-next-line @typescript-eslint/no-empty-function
export const constVoid = (): void => {}

export const identity = <T>(value: T): T => value
2 changes: 1 addition & 1 deletion packages/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { memo0, memo1, memo2, memo3, memo4, memo5, memoMany, constVoid } from './function'
export { memo0, memo1, memo2, memo3, memo4, memo5, memoMany, constVoid, identity } from './function'