Skip to content
This repository has been archived by the owner on Nov 27, 2022. It is now read-only.

Commit

Permalink
Abstract ExpressionEvaluator.
Browse files Browse the repository at this point in the history
  • Loading branch information
RubenVerborgh committed Feb 25, 2019
1 parent 6dfa6ac commit b8f708b
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 143 deletions.
4 changes: 2 additions & 2 deletions .eslintrc
Expand Up @@ -134,7 +134,7 @@
func-call-spacing: error,
func-name-matching: error,
func-names: off,
func-style: [ error, declaration ],
func-style: [ error, declaration, { allowArrowFunctions: true } ],
function-paren-newline: off,
id-blacklist: error,
id-length: off,
Expand All @@ -147,7 +147,7 @@
line-comment-position: off,
linebreak-style: error,
lines-around-comment: [ error, { allowObjectStart: true } ],
lines-between-class-members: error,
lines-between-class-members: [ error, always, { exceptAfterSingleLine: true } ],
max-depth: error,
max-len: off,
max-lines: off,
Expand Down
125 changes: 125 additions & 0 deletions src/ExpressionEvaluator.js
@@ -0,0 +1,125 @@
import { createTaskQueue } from './util';
import data from '@solid/query-ldflex';

const evaluatorQueue = createTaskQueue();

/**
* Evaluates a map of LDflex expressions into a singular value or a list.
* Expressions can be changed and/or re-evaluated.
*/
export default class ExpressionEvaluator {
pending = {};
cancel = false;

/** Stops all pending and future evaluations */
destroy() {
this.pending = {};
this.cancel = true;
evaluatorQueue.clear(this);
}

/** Evaluates the given singular value and list expressions. */
async evaluate(values, lists, updateCallback) {
// Create evaluators for each expression, and mark them as pending
const reset = { error: undefined, pending: true };
const evaluators = evaluatorQueue.schedule([
...Object.entries(values).map(([key, expr]) => {
reset[key] = undefined;
return () => this.evaluateAsValue(key, expr, updateCallback);
}),
...Object.entries(lists).map(([key, expr]) => {
reset[key] = [];
return () => this.evaluateAsList(key, expr, updateCallback);
}),
], this);
updateCallback(reset);

// Wait until all evaluators are done (or one of them errors)
try {
await Promise.all(evaluators);
}
catch (error) {
updateCallback({ error });
}

// Update the pending flag if all evaluators wrote their value or errored,
// and if no new evaluators are pending
const statuses = await Promise.all(evaluators.map(e => e.catch(error => {
console.warn('@solid/react-components', 'Expression evaluation failed.', error);
return true;
})));
// Stop if results are no longer needed
if (this.cancel)
return;
// Reset the pending flag if all are done and no others are pending
if (!statuses.some(done => !done) && Object.keys(this.pending).length === 0)
updateCallback({ pending: false });
}

/** Evaluates the property expression as a singular value. */
async evaluateAsValue(key, expr, updateCallback) {
// Obtain and await the promise
const promise = this.resolveExpression(key, expr, 'then');
this.pending[key] = promise;
try {
const value = await promise;
// Stop if another evaluator took over in the meantime (component update)
if (this.pending[key] !== promise)
return false;
updateCallback({ [key]: value });
}
// Ensure the evaluator is removed, even in case of errors
finally {
if (this.pending[key] === promise)
delete this.pending[key];
}
return true;
}

/** Evaluates the property expression as a list. */
async evaluateAsList(key, expr, updateCallback) {
// Create the iterable
const iterable = this.resolveExpression(key, expr, Symbol.asyncIterator);
if (!iterable)
return true;
this.pending[key] = iterable;

// Read the iterable
const items = [];
const update = () => !this.cancel && updateCallback({ [key]: [...items] });
const itemQueue = createTaskQueue({ timeBetween: 100, drop: true });
try {
for await (const item of iterable) {
// Stop if another evaluator took over in the meantime (component update)
if (this.pending[key] !== iterable)
return false;
items.push(item);
itemQueue.schedule(update);
}
}
// Ensure pending updates are applied, and the evaluator is removed
finally {
const needsUpdate = itemQueue.clear();
if (this.pending[key] === iterable) {
if (needsUpdate)
update();
delete this.pending[key];
}
}
return true;
}

/** Resolves the property into an LDflex path. */
resolveExpression(key, expr, expectedProperty) {
// If the property is an LDflex string expression, resolve it
if (!expr)
return '';
const resolved = typeof expr === 'string' ? data.resolve(expr) : expr;

// Ensure that the resolved value is an LDflex path
if (!resolved || typeof resolved[expectedProperty] !== 'function')
throw new Error(`${key} should be an LDflex path or string but is ${expr}`);

return resolved;
}
}
168 changes: 27 additions & 141 deletions src/components/evaluateExpressions.jsx
@@ -1,173 +1,59 @@
import React from 'react';
import withWebId from './withWebId';
import { getDisplayName, createTaskQueue } from '../util';
import data from '@solid/query-ldflex';

const evaluatorQueue = createTaskQueue();
import ExpressionEvaluator from '../ExpressionEvaluator';
import { pick, getDisplayName } from '../util';

/**
* Higher-order component that evaluates LDflex expressions in properties
* and passes their results to the wrapped component.
*/
export default function evaluateExpressions(valueProps, listProps, Component) {
// Shift the optional listProps parameter when not specified
if (!Component) {
Component = listProps;
listProps = [];
}
if (!Component)
[Component, listProps] = [listProps, []];

// Create the initial state for all Component instances
const initialState = { pending: true };
for (const name of valueProps || [])
initialState[name] = undefined;
for (const name of listProps || [])
initialState[name] = [];

// Create a higher-order component that wraps the given Component
class EvaluateExpressions extends React.Component {
static displayName = `EvaluateExpressions(${getDisplayName(Component)})`;

constructor(props) {
super(props);
this.state = { pending: true };
this.pending = {};
this.cancel = false;

this.valueProps = valueProps || [];
this.valueProps.forEach(p => (this.state[p] = undefined));

this.listProps = listProps || [];
this.listProps.forEach(p => (this.state[p] = []));
}
state = initialState;

componentDidMount() {
this.evaluateExpressions(this.valueProps, this.listProps);
this.evaluator = new ExpressionEvaluator();
this.update = state => this.setState(state);
this.evaluate(valueProps, listProps);
}

componentDidUpdate(prevProps) {
// A property needs to be re-evaluated if it changed
// or, if it is a string expression, when the user has changed
// (which might influence the expression's evaluation).
const userChanged = this.props.webId !== prevProps.webId;
const propChanged = name =>
this.props[name] !== prevProps[name] ||
(userChanged && typeof this.props[name] === 'string');

// Re-evaluate changed singular values and lists
const changedValues = this.valueProps.filter(propChanged);
const changedLists = this.listProps.filter(propChanged);
if (changedValues.length > 0 || changedLists.length > 0)
this.evaluateExpressions(changedValues, changedLists);
const newUser = this.props.webId !== prevProps.webId;
const changed = name => this.props[name] !== prevProps[name] ||
newUser && typeof this.props[name] === 'string';
this.evaluate(valueProps.filter(changed), listProps.filter(changed));
}

componentWillUnmount() {
// Avoid state updates from pending evaluators
this.pending = {};
this.cancel = true;
evaluatorQueue.clear(this);
}

/** Evaluates the property expressions into the state. */
async evaluateExpressions(values, lists) {
// Create evaluators for each property, and mark them as pending
const pendingState = { error: undefined, pending: true };
const evaluators = evaluatorQueue.schedule([
...values.map(name => {
pendingState[name] = undefined;
return () => this.evaluateValueExpression(name);
}),
...lists.map(name => {
pendingState[name] = [];
return () => this.evaluateListExpression(name);
}),
], this);
this.setState(pendingState);

// Wait until all evaluators are done (or one of them errors)
try {
await Promise.all(evaluators);
}
catch (error) {
this.setState({ error });
}

// Update the pending flag if all evaluators wrote their value or errored,
// and if no new evaluators are pending
const statuses = await Promise.all(evaluators.map(e => e.catch(error => {
console.warn('@solid/react-components', 'Expression evaluation failed.', error);
return true;
})));
// Stop if results are no longer needed (e.g., unmounted)
if (this.cancel)
return;
// Reset the pending state if all are done and no others are pending
if (!statuses.some(done => !done) && Object.keys(this.pending).length === 0)
this.setState({ pending: false });
}

/** Evaluates the property expression as a singular value. */
async evaluateValueExpression(name) {
// Obtain and await the promise
const promise = this.resolveExpression(name, 'then');
this.pending[name] = promise;
try {
const value = await promise;
// Stop if another evaluator took over in the meantime (component update)
if (this.pending[name] !== promise)
return false;
this.setState({ [name]: value });
}
// Ensure the evaluator is removed, even in case of errors
finally {
if (this.pending[name] === promise)
delete this.pending[name];
}
return true;
}

/** Evaluates the property expression as a list. */
async evaluateListExpression(name) {
// Create the iterable
const iterable = this.resolveExpression(name, Symbol.asyncIterator);
if (!iterable)
return true;
this.pending[name] = iterable;

// Read the iterable
const items = [];
const update = () => this.cancel || this.setState({ [name]: [...items] });
const stateQueue = createTaskQueue({ timeBetween: 100, drop: true });
try {
for await (const item of iterable) {
// Stop if another evaluator took over in the meantime (component update)
if (this.pending[name] !== iterable)
return false;
items.push(item);
stateQueue.schedule(update);
}
}
// Ensure pending updates are applied, and the evaluator is removed
finally {
const needsUpdate = stateQueue.clear();
if (this.pending[name] === iterable) {
if (needsUpdate)
update();
delete this.pending[name];
}
}
return true;
}

/** Resolves the property into an LDflex path. */
resolveExpression(name, expectedProperty) {
// If the property is an LDflex string expression, resolve it
const expr = this.props[name];
if (!expr)
return '';
const resolved = typeof expr === 'string' ? data.resolve(expr) : expr;

// Ensure that the resolved value is an LDflex path
if (!resolved || typeof resolved[expectedProperty] !== 'function')
throw new Error(`${name} should be an LDflex path or string but is ${expr}`);

return resolved;
this.evaluator.destroy();
}

render() {
return <Component {...this.props} {...this.state} />;
}

evaluate(values, lists) {
const { props, evaluator } = this;
if (values.length > 0 || lists.length > 0)
evaluator.evaluate(pick(props, values), pick(props, lists), this.update);
}
}
return withWebId(EvaluateExpressions);
}
10 changes: 10 additions & 0 deletions src/util.js
@@ -1,3 +1,13 @@
/**
* Returns an object with only the given keys from the source.
*/
export function pick(source, keys) {
const destination = {};
for (const key of keys)
destination[key] = source[key];
return destination;
}

/**
* Filters component properties that are safe to use in the DOM.
*/
Expand Down

0 comments on commit b8f708b

Please sign in to comment.