Skip to content

Instantly share code, notes, and snippets.

@chandlerprall
Created April 24, 2020 16:25
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save chandlerprall/6b65048646d9e19f6fed6893099a40a4 to your computer and use it in GitHub Desktop.
Save chandlerprall/6b65048646d9e19f6fed6893099a40a4 to your computer and use it in GitHub Desktop.
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);
});
});
});
});
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
});
}
}
}
}
}
import { createContext } from 'react';
import Propagate from './propagate';
export default createContext<Propagate>(new Propagate);
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);
});
});
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