diff --git a/scripts/dev.sh b/scripts/dev.sh index 103e4b4117..a1c94c276c 100644 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -47,4 +47,4 @@ if [ ! -d dev ]; then info "Couldn't find an the \`dev\` directory, creating one for you..." fi -vite --host +vite diff --git a/src/__test__/patch.spec.ts b/src/__test__/patch.spec.ts index 395c4954d9..73ae55ecd6 100644 --- a/src/__test__/patch.spec.ts +++ b/src/__test__/patch.spec.ts @@ -1,7 +1,6 @@ import { createElement } from '../createElement'; import { m, INSERT, UPDATE, DELETE } from '../m'; import { patch } from '../patch'; -// import { patch } from '../patch'; import { VFlags, VNode, VProps } from '../structs'; const h = (tag: string, props?: VProps, ...children: VNode[]) => @@ -132,4 +131,47 @@ describe('.patch', () => { ); expect(el.childNodes.length).toEqual(0); }); + + it('should used keyed algorithm when flag is passed', () => { + const el = document.createElement('ul'); + const list1 = ['foo', 'bar', 'baz']; + const newVNode1 = m( + 'ul', + undefined, + list1.map((item) => m('li', { key: item }, [item])), + VFlags.ONLY_KEYED_CHILDREN, + ); + patch(el, newVNode1, m('ul', undefined, undefined, VFlags.NO_CHILDREN)); + expect(el).toEqual(createElement(newVNode1)); + + const list2 = ['foo', 'baz', 'bar']; + const newVNode2 = m( + 'ul', + undefined, + list2.map((item) => m('li', { key: item }, [item])), + VFlags.ONLY_KEYED_CHILDREN, + ); + patch(el, newVNode2, newVNode1); + expect(el).toEqual(createElement(newVNode2)); + + const list3 = ['foo0', 'foo', 'bar', 'baz', 'foo1', 'bar1', 'baz1']; + const newVNode3 = m( + 'ul', + undefined, + list3.map((item) => m('li', { key: item }, [item])), + VFlags.ONLY_KEYED_CHILDREN, + ); + patch(el, newVNode3, newVNode2); + expect(el).toEqual(createElement(newVNode3)); + + const list4 = list3.reverse(); + const newVNode4 = m( + 'ul', + undefined, + list4.map((item) => m('li', { key: item }, [item])), + VFlags.ONLY_KEYED_CHILDREN, + ); + patch(el, newVNode4, newVNode3); + expect(el).toEqual(createElement(newVNode4)); + }); }); diff --git a/src/patch.ts b/src/patch.ts index cf2316a9b6..0b00508c67 100644 --- a/src/patch.ts +++ b/src/patch.ts @@ -16,22 +16,39 @@ import { * @param {VProps} newProps - New VNode props * @returns {void} */ -export const patchProps = (el: HTMLElement, oldProps: VProps, newProps: VProps): void => { +export const patchProps = ( + el: HTMLElement, + oldProps: VProps, + newProps: VProps, + workQueue: (() => void)[], +): void => { const skip = new Set(); + for (const oldPropName of Object.keys(oldProps)) { const newPropValue = newProps[oldPropName]; if (newPropValue) { - el[oldPropName] = newPropValue; + const oldPropValue = oldProps[oldPropName]; + if (newPropValue !== oldPropValue) { + if (typeof oldPropValue === 'function' && typeof newPropValue === 'function') { + if (oldPropValue.toString() !== newPropValue.toString()) { + workQueue.push(() => (el[oldPropName] = newPropValue)); + } + } else { + workQueue.push(() => (el[oldPropName] = newPropValue)); + } + } skip.add(oldPropName); } else { - el.removeAttribute(oldPropName); - delete el[oldPropName]; + workQueue.push(() => { + el.removeAttribute(oldPropName); + delete el[oldPropName]; + }); } } for (const newPropName of Object.keys(newProps)) { if (!skip.has(newPropName)) { - el[newPropName] = newProps[newPropName]; + workQueue.push(() => (el[newPropName] = newProps[newPropName])); } } }; @@ -48,55 +65,125 @@ export const patchChildren = ( oldVNodeChildren: VNode[], newVNodeChildren: VNode[], keyed: boolean, - delta?: VDelta, + delta: VDelta | undefined, + workQueue: (() => void)[], ): void => { if (!newVNodeChildren) { - el.textContent = ''; + workQueue.push(() => (el.textContent = '')); } else if (delta) { for (let i = 0; i < delta.length; i++) { const [deltaType, deltaPosition] = delta[i]; switch (deltaType) { - case VDeltaOperationTypes.INSERT: { - el.insertBefore( - createElement(newVNodeChildren[deltaPosition]), - el.childNodes[deltaPosition], + case VDeltaOperationTypes.INSERT: + workQueue.push(() => + el.insertBefore( + createElement(newVNodeChildren[deltaPosition]), + el.childNodes[deltaPosition], + ), ); break; - } - case VDeltaOperationTypes.UPDATE: { + case VDeltaOperationTypes.UPDATE: patch( el.childNodes[deltaPosition], newVNodeChildren[deltaPosition], oldVNodeChildren[deltaPosition], + workQueue, ); break; - } - case VDeltaOperationTypes.DELETE: { - el.removeChild(el.childNodes[deltaPosition]); + case VDeltaOperationTypes.DELETE: + workQueue.push(() => el.removeChild(el.childNodes[deltaPosition])); break; + } + } + } else if (keyed && oldVNodeChildren.length > 0) { + // Keyed reconciliation algorithm originally adapted from [Fre](https://github.com/yisar/fre) + let oldHead = 0; + let newHead = 0; + let oldTail = oldVNodeChildren.length - 1; + let newTail = newVNodeChildren.length - 1; + + // Constrain tails to dirty vnodes: [X, A, B, C], [Y, A, B, C] -> [X], [Y] + while (oldHead <= oldTail && newHead <= newTail) { + if ((oldVNodeChildren[oldTail]).key !== (newVNodeChildren[newTail]).key) { + break; + } + oldTail--; + newTail--; + } + + // Constrain heads to dirty vnodes: [A, B, C, X], [A, B, C, Y] -> [X], [Y] + while (oldHead <= oldTail && newHead <= newTail) { + if ((oldVNodeChildren[oldHead]).key !== (newVNodeChildren[newHead]).key) { + break; + } + oldHead++; + newHead++; + } + + if (oldHead > oldTail) { + // There are no dirty old children: [], [X, Y, Z] + while (newHead <= newTail) { + const newHeadIndex = Number(newHead++); + const node = el.childNodes[newHeadIndex]; + workQueue.push(() => + el.insertBefore(createElement(newVNodeChildren[newHeadIndex], false), node), + ); + } + } else if (newHead > newTail) { + // There are no dirty new children: [X, Y, Z], [] + while (oldHead <= oldTail) { + const node = el.childNodes[oldHead++]; + workQueue.push(() => el.removeChild(node)); + } + } else { + const keyMap: Record = {}; + for (let i = oldHead; i <= oldTail; ++i) { + keyMap[(oldVNodeChildren[i]).key!] = i; + } + while (newHead <= newTail) { + const newVNodeChild = newVNodeChildren[newHead]; + const oldVNodePosition = keyMap[newVNodeChild.key!]; + const node = el.childNodes[oldVNodePosition]; + const refNode = el.childNodes[newVNodeChildren.length + newHead++]; + + if ( + oldVNodePosition !== undefined && + newVNodeChild.key === (oldVNodeChildren[oldVNodePosition]).key + ) { + // Determine move for child that moved: [X, A, B, C] -> [A, B, C, X] + workQueue.push(() => el.insertBefore(node, refNode)); + delete keyMap[newVNodeChild.key!]; + } else { + // VNode doesn't exist yet: [] -> [X] + workQueue.push(() => el.insertBefore(createElement(newVNodeChild, false), refNode)); } } + for (const oldVNodePosition of Object.values(keyMap)) { + // VNode wasn't found in new vnodes, so it's cleaned up: [X] -> [] + const node = el.childNodes[oldVNodePosition]; + workQueue.push(() => el.removeChild(node)); + } } - } else if (keyed) { - // TODO: Efficient diffing by keys with moves (#107) } else { if (oldVNodeChildren) { // Interates backwards, so in case a childNode is destroyed, it will not shift the nodes // and break accessing by index for (let i = oldVNodeChildren.length - 1; i >= 0; --i) { - patch(el.childNodes[i], newVNodeChildren[i], oldVNodeChildren[i]); + patch( + el.childNodes[i], + newVNodeChildren[i], + oldVNodeChildren[i], + workQueue, + ); } } for (let i = oldVNodeChildren.length ?? 0; i < newVNodeChildren.length; ++i) { - el.appendChild(createElement(newVNodeChildren[i], false)); + const node = createElement(newVNodeChildren[i], false); + workQueue.push(() => el.appendChild(node)); } } }; -const replaceElementWithVNode = (el: HTMLElement | Text, newVNode: VNode): void => { - el.replaceWith(createElement(newVNode)); -}; - /** * Diffs two VNodes and modifies the DOM node based on the necessary changes * @param {HTMLElement|Text} el - Target element to be modified @@ -104,39 +191,49 @@ const replaceElementWithVNode = (el: HTMLElement | Text, newVNode: VNode): void * @param {VNode=} prevVNode - Previous VNode * @returns {void} */ -export const patch = (el: HTMLElement | Text, newVNode: VNode, prevVNode?: VNode): void => { +export const patch = ( + el: HTMLElement | Text, + newVNode: VNode, + prevVNode?: VNode, + workQueue: (() => void)[] = [], +): void => { if (!newVNode) { - el.remove(); + workQueue.push(() => el.remove()); } else { const oldVNode: VNode | undefined = prevVNode ?? el[OLD_VNODE_FIELD]; const hasString = typeof oldVNode === 'string' || typeof newVNode === 'string'; if (hasString && oldVNode !== newVNode) { - replaceElementWithVNode(el, newVNode); + workQueue.push(() => el.replaceWith(createElement(newVNode))); } else if (!hasString) { if ( (!(oldVNode)?.key && !(newVNode)?.key) || (oldVNode)?.key !== (newVNode)?.key ) { if ((oldVNode)?.tag !== (newVNode)?.tag || el instanceof Text) { - replaceElementWithVNode(el, newVNode); + workQueue.push(() => el.replaceWith(createElement(newVNode))); } else { - patchProps(el, (oldVNode)?.props || {}, (newVNode).props || {}); + patchProps( + el, + (oldVNode)?.props || {}, + (newVNode).props || {}, + workQueue, + ); // Flags allow for greater optimizability by reducing condition branches. // Generally, you should use a compiler to generate these flags, but // hand-writing them is also possible switch ((newVNode).flag) { - case VFlags.NO_CHILDREN: { - el.textContent = ''; + case VFlags.NO_CHILDREN: + workQueue.push(() => (el.textContent = '')); break; - } - case VFlags.ONLY_TEXT_CHILDREN: { + case VFlags.ONLY_TEXT_CHILDREN: // Joining is faster than setting textContent to an array - el.textContent = (newVNode).children!.join(''); + workQueue.push( + () => (el.textContent = (newVNode).children!.join('')), + ); break; - } - default: { + default: patchChildren( el, (oldVNode)?.children || [], @@ -145,14 +242,20 @@ export const patch = (el: HTMLElement | Text, newVNode: VNode, prevVNode?: VNode // We need to pass delta here because this function does not have // a reference to the actual vnode. (newVNode).delta, + workQueue, ); break; - } } } } } } + for (let i = 0; i < workQueue.length; i++) { + workQueue[i](); + // eslint-disable-next-line no-debugger + // debugger; + } + if (!prevVNode) el[OLD_VNODE_FIELD] = newVNode; };