Created
April 24, 2020 16:25
-
-
Save chandlerprall/6b65048646d9e19f6fed6893099a40a4 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import Propagate from './propagate'; | |
describe('Propagate', () => { | |
describe('value storage & looking', () => { | |
it('stores a single value for lookup', () => { | |
const propagate = new Propagate(); | |
propagate.set('mykey', 'myvalue'); | |
expect(propagate.get('mykey')).toBe('myvalue'); | |
}); | |
it('stores multiple values for lookup', () => { | |
const propagate = new Propagate(); | |
propagate.set('mykey', 'myvalue'); | |
propagate.set('otherkey', 'othervalue'); | |
expect(propagate.get('mykey')).toBe('myvalue'); | |
expect(propagate.get('otherkey')).toBe('othervalue'); | |
}); | |
}); | |
describe('subscriptions', () => { | |
it('allows subscribing to a single value', () => { | |
const propagate = new Propagate(); | |
const subscriber = jest.fn(); | |
propagate.subscribe('key', subscriber); | |
expect(subscriber).toHaveBeenCalledTimes(0); | |
propagate.set('key', 'value'); | |
expect(subscriber).toHaveBeenCalledTimes(1); | |
expect(subscriber).lastCalledWith('value'); | |
}); | |
it('allows unsubscribing', () => { | |
const propagate = new Propagate(); | |
const subscriber = jest.fn(); | |
const unsubscribe = propagate.subscribe('key', subscriber); | |
expect(subscriber).toHaveBeenCalledTimes(0); | |
propagate.set('key', 'value'); | |
expect(subscriber).toHaveBeenCalledTimes(1); | |
expect(subscriber).lastCalledWith('value'); | |
unsubscribe(); | |
propagate.set('key', 'value2'); | |
expect(subscriber).toHaveBeenCalledTimes(1); | |
expect(subscriber).lastCalledWith('value'); | |
}); | |
}); | |
describe('computations', () => { | |
it('computes a single field on creation', () => { | |
const propagate = new Propagate(); | |
propagate.set('key', [], () => 5); | |
expect(propagate.get('key')).toBe(5); | |
}); | |
it('allows references to static values', () => { | |
const propagate = new Propagate(); | |
propagate.set('four', 4); | |
propagate.set('six', 6); | |
propagate.set('ten', ['four', 'six'], (...args) => args.reduce((acc, val) => acc + val, 0)); | |
expect(propagate.get('ten')).toBe(10); | |
}); | |
it('allows computations to depend on other computations', () => { | |
const propagate = new Propagate(); | |
propagate.set('chars', [], () => ['A', 'B', 'C']); | |
propagate.set('charsLower', ['chars'], chars => chars.map(char => char.toLowerCase())); | |
expect(propagate.get('charsLower')).toEqual(['a', 'b', 'c']); | |
}); | |
it('re-computes a value when a static dependency updates', () => { | |
const propagate = new Propagate(); | |
propagate.set('root', 4); | |
propagate.set('square', ['root'], root => root**2); | |
expect(propagate.get('square')).toBe(16); | |
propagate.set('root', 3); | |
expect(propagate.get('square')).toBe(9); | |
}); | |
describe('circular dependencies', () => { | |
it('errors when one field depends on itself', () => { | |
const propagate = new Propagate(); | |
expect(() => { | |
propagate.set('first', ['first'], () => null); | |
}).toThrow(); | |
}); | |
it('errors when two fields depend on each other', () => { | |
const propagate = new Propagate(); | |
propagate.set('first', ['second'], () => null); | |
expect(() => { | |
propagate.set('second', ['first'], () => null); | |
}).toThrow(); | |
}); | |
it('errors when two fields depend on each other through separation', () => { | |
const propagate = new Propagate(); | |
propagate.set('first', ['second'], () => null); | |
propagate.set('second', ['third'], () => null); | |
expect(() => { | |
propagate.set('third', ['third'], () => null); | |
}).toThrow(); | |
}); | |
it('does not error when a potential circular dependency is first resolved', () => { | |
const propagate = new Propagate(); | |
propagate.set('first', ['second'], () => null); | |
propagate.set('first', 1); | |
propagate.set('second', ['first'], x => x + 1); | |
expect(propagate.get('second')).toBe(2); | |
}); | |
}); | |
describe('computation subscriptions', () => { | |
it('calls a subscription to a computed value on update', () => { | |
const propagate = new Propagate(); | |
propagate.set('root', 3); | |
propagate.set('square', ['root'], root => root**2); | |
const subscriber = jest.fn(); | |
propagate.subscribe('square', subscriber); | |
expect(subscriber).toHaveBeenCalledTimes(0); | |
propagate.set('root', 4); | |
expect(subscriber).toHaveBeenCalledTimes(1); | |
expect(subscriber).toHaveBeenLastCalledWith(16); | |
}); | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
type Subscriber = (value: string) => void; | |
type Computation = (...args: any[]) => any; | |
type ComputationDef = [string[], Computation]; | |
export default class Propagate { | |
values = new Map<string, any>(); | |
subscriptions = new Map<string, Set<Subscriber>>(); | |
computations = new Map<string, [string[], Computation]>(); | |
dependencies = new Map<string, string[]>(); | |
dependants = new Map<string, Set<string>>(); | |
set(key: string, references: string[], computation: Computation) | |
set(key: string, value: any) | |
set(key: string, valueOrReferences: any | string[], computation?: Computation) { | |
if (typeof computation !== 'undefined') { | |
const references = valueOrReferences as string[]; | |
const computationDefinition: ComputationDef = [references, computation]; | |
// update dependants lists for the referenced values | |
this.checkForCircularDependencies(key, references); | |
// remove old dependencies first, then refresh with the new list | |
const oldDependencies = this.dependencies.get(key) || []; | |
for (let i = 0; i < oldDependencies.length; i++) { | |
const referencedKey = oldDependencies[i]; | |
const referencedDependants = this.dependants.get(referencedKey); | |
if (referencedDependants) { | |
referencedDependants.delete(key); | |
this.dependants.set(referencedKey, referencedDependants); | |
} | |
} | |
for (let i = 0; i < references.length; i++) { | |
const referencedKey = references[i]; | |
const referencedDependants = this.dependants.get(referencedKey) || new Set(); | |
referencedDependants.add(key); | |
this.dependants.set(referencedKey, referencedDependants); | |
} | |
this.dependencies.set(key, references); | |
this.computations.set(key, computationDefinition); | |
this.computeValue(key); | |
} else { | |
// this value no longer has any references, if they ever existed | |
this.dependencies.set(key, []); | |
this.values.set(key, valueOrReferences); | |
} | |
// update dependants | |
const dependants = this.dependants.get(key); | |
if (dependants) { | |
dependants.forEach(dependantKey => { | |
this.computeValue(dependantKey); | |
}); | |
} | |
// call subscriptions | |
const subscriptions = this.subscriptions.get(key) || new Set(); | |
const value = this.values.get(key); | |
subscriptions.forEach(subscription => subscription(value)); | |
} | |
get(key: string) { | |
return this.values.get(key); | |
} | |
subscribe(key: string, subscriber: Subscriber) { | |
const subscriptions = this.subscriptions.get(key) || new Set(); | |
subscriptions.add(subscriber); | |
this.subscriptions.set(key, subscriptions); | |
return () => { | |
const subscriptions = this.subscriptions.get(key) || new Set(); | |
subscriptions.delete(subscriber); | |
this.subscriptions.set(key, subscriptions); | |
}; | |
} | |
computeValue(key: string) { | |
const computationDef = this.computations.get(key); | |
if (computationDef === undefined) return undefined; | |
const [references, computation] = computationDef; | |
const dependentValues = references.map(key => this.get(key)); | |
const value = computation(...dependentValues); | |
this.values.set(key, value); | |
// call subscriptions | |
const subscriptions = this.subscriptions.get(key) || new Set(); | |
subscriptions.forEach(subscription => subscription(value)); | |
} | |
checkForCircularDependencies(key: string, nextReferences: string[]) { | |
interface Check { | |
path: Set<string>; | |
dependencies: string[]; | |
} | |
const remainingChecks: Check[] = [{ path: new Set([key]), dependencies: nextReferences }]; | |
while (remainingChecks.length) { | |
const check = remainingChecks.shift(); | |
const { path, dependencies } = check; | |
for (let i = 0; i < dependencies.length; i++) { | |
const dependencyKey = dependencies[i]; | |
if (path.has(dependencyKey)) { | |
throw new Error(`Circular dependency: ${Array.from(path).join('->')}`); | |
} | |
const subDependencies = this.dependencies.get(dependencyKey); | |
if (subDependencies) { | |
const subPath = new Set(path).add(dependencyKey); | |
remainingChecks.push({ | |
path: subPath, | |
dependencies: subDependencies | |
}); | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createContext } from 'react'; | |
import Propagate from './propagate'; | |
export default createContext<Propagate>(new Propagate); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as React from 'react'; | |
import * as ReactDOM from 'react-dom'; | |
import {act} from 'react-dom/test-utils'; | |
import Propagate from './propagate'; | |
import PropagateContext from './propagate_context'; | |
import usePropagate from './use_propagate'; | |
const container = document.createElement('div'); | |
describe('usePropagation', () => { | |
it('starts with the references values', () => { | |
const propagate = new Propagate(); | |
propagate.set('one', 1); | |
propagate.set('two', ['one'], one => one + 1); | |
const Component = () => { | |
const values = usePropagate(['one', 'two']); | |
return <span>{values.join(',')}</span>; | |
}; | |
ReactDOM.render( | |
<PropagateContext.Provider value={propagate}> | |
<Component /> | |
</PropagateContext.Provider>, | |
container | |
); | |
expect(container.innerHTML).toBe('<span>1,2</span>'); | |
ReactDOM.unmountComponentAtNode(container); | |
}); | |
it('re-renders when referenced values update', async () => { | |
const propagate = new Propagate(); | |
propagate.set('root', 4); | |
propagate.set('square', ['root'], root => root**2); | |
const Component = () => { | |
const values = usePropagate(['root', 'square']); | |
return <span>{values.join(',')}</span>; | |
}; | |
ReactDOM.render( | |
<PropagateContext.Provider value={propagate}> | |
<Component /> | |
</PropagateContext.Provider>, | |
container | |
); | |
expect(container.innerHTML).toBe('<span>4,16</span>'); | |
await new Promise(resolve => setTimeout(resolve, 100)); | |
act(() => propagate.set('root', 3)); | |
await new Promise(resolve => setTimeout(resolve, 100)); | |
expect(container.innerHTML).toBe('<span>3,9</span>'); | |
ReactDOM.unmountComponentAtNode(container); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useContext, useEffect, useState } from 'react'; | |
import PropagateContext from './propagate_context'; | |
export default function usePropagate(references: string[]) { | |
const propagate = useContext(PropagateContext); | |
const [values, setValues] = useState(references.map(reference => propagate.get(reference))); | |
useEffect( | |
() => { | |
const unsubscribes = []; | |
for (let i = 0; i < references.length; i++) { | |
const reference = references[i]; | |
const index = i; | |
const unsubscribe = propagate.subscribe( | |
reference, | |
value => { | |
setValues(values => { | |
const nextValues = [...values]; | |
nextValues[index] = value; | |
return nextValues; | |
}); | |
} | |
); | |
unsubscribes.push(unsubscribe); | |
} | |
return () => { | |
for (let i = 0; i < unsubscribes.length; i++) { | |
unsubscribes[i](); | |
} | |
} | |
}, | |
[propagate, references, setValues] | |
); | |
return values; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment