diff --git a/lib/core/renderer.js b/lib/core/renderer.js index ef9b92a..6993359 100644 --- a/lib/core/renderer.js +++ b/lib/core/renderer.js @@ -1,7 +1,8 @@ const TASK_GROUP = Symbol('task_group'), APPLIED_OPTIONS = Hawkejs.APPLIED_OPTIONS, SCOPE_ID = Symbol('scope_id'), - OPTIONS = Symbol('options'); + OPTIONS = Symbol('options'), + REACTIVE_QUEUE = Symbol('reactive_queue'); /** * The Renderer class @@ -78,6 +79,7 @@ const Renderer = Fn.inherits('Hawkejs.Base', function Renderer(hawkejs) { this.active_variables = null; this.state = null; this.compiled_inlines = {}; + this.reactive_queue = null; }); Renderer.setDeprecatedProperty('assign_end', 'assignEnd'); @@ -131,6 +133,33 @@ Renderer.setStatic(function enforceRootProperty(key, fnc) { }); }); +/** + * Set a method that only executes on the root renderer + * + * @author Jelle De Loecker + * @since 2.4.0 + * @version 2.4.0 + * + * @param {String} key + * @param {Function} fnc + */ +Renderer.setStatic(function setRootMethod(key, fnc) { + + if (typeof key == 'function') { + fnc = key; + key = fnc.name; + } + + this.setMethod(key, function rootMethod(...args) { + + if (!this.is_root_renderer) { + return this.root_renderer[key](...args); + } + + return fnc.call(this, ...args); + }); +}); + /** * Set a reference to a specific singleton element * (html, head, body) @@ -176,7 +205,9 @@ Renderer.setStatic(function attachReactiveListeners(element, reactive) { } if (reactive.body?.values?.length) { - reactive.body.values.forEach(optional => optional.onChange(() => reactiveRerender(element))); + reactive.body.values.forEach(optional => optional.onChange(() => { + queueReactiveTask(element, 'body', () => reactiveRerender(element)); + })); } if (reactive.attributes) { @@ -230,11 +261,32 @@ const attachReactiveElementUpdaters = (element, setter, getter, instructions) => } config.values.forEach(optional => optional.onChange(() => { - return updateElementProperty(element, setter, getter, optional, key, config) + return queueReactiveTask(element, 'property', () => updateElementProperty(element, setter, getter, optional, key, config)); })); } }; +/** + * Queue the given task + * + * @author Jelle De Loecker + * @since 2.4.0 + * @version 2.4.0 + * + * @param {HTMLElement} element + * @param {string} type + * @param {Function} task + */ +const queueReactiveTask = (element, type, task) => { + + // Make sure we have all the required info for a rerender + if (type == 'body' && !element.is_custom_hawkejs_element && !element[Hawkejs.RENDER_INSTRUCTION]) { + return; + } + + return element.hawkejs_renderer.queueReactiveTask(element, type, task); +}; + /** * Perform the given reactive property update * @@ -942,6 +994,71 @@ Renderer.setMethod(function toDry() { return result; }); +/** + * Queue a reactive task + * + * @author Jelle De Loecker + * @since 2.4.0 + * @version 2.4.0 + * + * @param {HTMLElement} element The element in question + * @param {string} type What will be affected: property or body + * @param {Function} task The actual task + */ +Renderer.setRootMethod(function queueReactiveTask(element, type, task) { + + if (!this[REACTIVE_QUEUE]) { + this[REACTIVE_QUEUE] = []; + + Blast.nextGroupedImmediate(() => { + // Get the current queue + let queue = this[REACTIVE_QUEUE]; + + // Remove the queue so new tasks can be added + this[REACTIVE_QUEUE] = null; + + // Now we have to take a look at all the queued elements. + // Any element that has a task queued while it's in another element + // that will completely rerender its contents should be skipped + let allowed_queue = [], + elements_to_rerender = new Set(); + + // First pass: get all the elements to re-render + for (let item of queue) { + if (item.type == 'body') { + elements_to_rerender.add(item.element); + } + } + + // Second pass: skip elements inside elements that will be re-rendered + for (let item of queue) { + let is_allowed = true; + + for (let ancestor of elements_to_rerender) { + if (ancestor != item.element && ancestor.contains(item.element)) { + is_allowed = false; + break; + } + } + + if (is_allowed) { + allowed_queue.push(item); + } + } + + for (let item of allowed_queue) { + try { + item.task.call(this, item.element); + } catch (err) { + console.error(err); + } + } + }); + } + + this[REACTIVE_QUEUE].push({element, task, type}); +}); + /** * Convert to JSON * diff --git a/test/10-expressions.js b/test/10-expressions.js index ab96a67..b46fb35 100644 --- a/test/10-expressions.js +++ b/test/10-expressions.js @@ -915,6 +915,8 @@ This should be a converted variable: describe('Reactive variables', () => { + let state; + let tests = [ [ (vars) => vars.set('ref_title', Optional('Original title')), @@ -952,6 +954,132 @@ This should be a converted variable: ref_el.value = el; }, `CHANGED
CHANGED
`, + ], + [ + // Prepare the state & variables + (vars) => { + state = {}; + vars.set('ref_el', Optional()).onChange(val => state.last_ref_el = val); + vars.set('ref_attr', Optional('-')); + vars.set('ref_static', Optional('static')); + state.ref_counter = vars.set('ref_counter', Optional(1)); + }, + // The initial test template (first string is always the template) + ` +
+ {{ &ref_static }} + + {{ ref_counter }} + +
+ `, + // The expected result + ` +
+ static + + 1 + +
+ `, + // New function to change things + (vars) => { + state.current_span = state.last_ref_el; + vars.get('ref_attr').value = 'changed!'; + + // Even though we change it, it should not trigger a rerender + // since the variable was not used reactively in the template + vars.get('ref_counter').value = 2; + }, + // New expected result + ` +
+ static + + 1 + +
+ `, + (vars) => { + // The reference element should still be the same + // (The first div should not have re-rendered its contents) + assert.strictEqual(state.last_ref_el, state.current_span); + }, + ], + [ + // Prepare the state & variables + (vars) => { + state = {}; + vars.set('ref_static', Optional('static')); + state.ref_counter = vars.set('ref_counter', Optional(1)); + state.info_counter = vars.set('info_counter', Optional(0)); + }, + // The initial test template (first string is always the template) + ` +
+ {{ &ref_static }} + + <% info_counter.value += 1 %> + Info counter: {{ &ref_counter }} + + + <% info_counter.value += 1 %> + Nested info counter: {{ &ref_counter }} + + Text + +
+ `, + // The expected result + ` +
+ static + + Info counter: 1 + + + Nested info counter: 1 + + Text + +
+ `, + (vars) => { + assert.strictEqual(state.info_counter.value, 2, 'The info counter should have been increased twice'); + state.ref_counter.value = 2; + }, + // New expected result + ` +
+ static + + Info counter: 2 + + + Nested info counter: 2 + + Text + +
+ `, + (vars) => { + let value = state.info_counter.value; + assert.strictEqual(value, 4, 'The info counter should have been increased twice to four, but it is ' + value); + state.ref_counter.value = 3; + }, + ` +
+ static + + Info counter: 3 + + + Nested info counter: 3 + + Text + +
+ `, ] ]; @@ -1049,7 +1177,11 @@ function createTests(tests) { } } - title = code.replace(/\r\n/g, '\\n').replace(/\n/g, '\\n').replace(/\t/g, '\\t'); + //title = code.replace(/\r\n/g, '\\n').replace(/\n/g, '\\n').replace(/\t/g, '\\t'); + title = code.trim().replace(/\r\n/g, ' ').replace(/\n/g, ' ').replace(/\t/g, ' '); + + // Replace multiple whitespaces with a single space + title = title.replace(/\s+/g, ' '); } else { title = test.template; template = test.template;