diff --git a/.eslintrc b/.eslintrc index 9b9fc9b..f0219b2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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, @@ -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, diff --git a/src/ExpressionEvaluator.js b/src/ExpressionEvaluator.js new file mode 100644 index 0000000..6420844 --- /dev/null +++ b/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; + } +} diff --git a/src/components/evaluateExpressions.jsx b/src/components/evaluateExpressions.jsx index d83878a..5570562 100644 --- a/src/components/evaluateExpressions.jsx +++ b/src/components/evaluateExpressions.jsx @@ -1,9 +1,7 @@ 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 @@ -11,163 +9,51 @@ const evaluatorQueue = createTaskQueue(); */ 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 ; } + + 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); } diff --git a/src/util.js b/src/util.js index 2951ef7..2987c18 100644 --- a/src/util.js +++ b/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. */