]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(compiler): convert text mixed with elements into createVNode calls
authorEvan You <yyx990803@gmail.com>
Mon, 21 Oct 2019 19:52:29 +0000 (15:52 -0400)
committerEvan You <yyx990803@gmail.com>
Mon, 21 Oct 2019 19:52:29 +0000 (15:52 -0400)
This ensures they are tracked as dynamic children when inside blocks.
Also guaruntees compiled vnodes always have vnode children in arrays
so that they can skip normalizeVNode safely in optimized mode.

13 files changed:
packages/compiler-core/__tests__/__snapshots__/compile.spec.ts.snap
packages/compiler-core/__tests__/transform.spec.ts
packages/compiler-core/__tests__/transforms/__snapshots__/optimizeText.spec.ts.snap [deleted file]
packages/compiler-core/__tests__/transforms/__snapshots__/transformText.spec.ts.snap [new file with mode: 0644]
packages/compiler-core/__tests__/transforms/transformElement.spec.ts
packages/compiler-core/__tests__/transforms/transformText.spec.ts [moved from packages/compiler-core/__tests__/transforms/optimizeText.spec.ts with 53% similarity]
packages/compiler-core/src/ast.ts
packages/compiler-core/src/codegen.ts
packages/compiler-core/src/index.ts
packages/compiler-core/src/transforms/hoistStatic.ts
packages/compiler-core/src/transforms/transformText.ts [moved from packages/compiler-core/src/transforms/optimizeText.ts with 57% similarity]
packages/compiler-core/src/utils.ts
packages/runtime-core/src/createRenderer.ts

index 6414eed975a6727ca6286d7646891bf36a32912e..9374c67c4511c3a9756298d7345c0748ffa733a3 100644 (file)
@@ -5,13 +5,13 @@ exports[`compiler: integration tests function mode 1`] = `
 
 return function render() {
   with (this) {
-    const { toString: _toString, openBlock: _openBlock, createVNode: _createVNode, createBlock: _createBlock, Comment: _Comment, Fragment: _Fragment, renderList: _renderList } = _Vue
+    const { toString: _toString, openBlock: _openBlock, createVNode: _createVNode, createBlock: _createBlock, Comment: _Comment, Fragment: _Fragment, renderList: _renderList, Text: _Text } = _Vue
     
     return (_openBlock(), _createBlock(\\"div\\", {
       id: \\"foo\\",
       class: bar.baz
     }, [
-      _toString(world.burn()),
+      _createVNode(_Text, null, _toString(world.burn()), 1 /* TEXT */),
       (_openBlock(), ok
         ? _createBlock(\\"div\\", { key: 0 }, \\"yes\\")
         : _createBlock(_Fragment, { key: 1 }, [\\"no\\"])),
@@ -26,7 +26,7 @@ return function render() {
 `;
 
 exports[`compiler: integration tests function mode w/ prefixIdentifiers: true 1`] = `
-"const { toString, openBlock, createVNode, createBlock, Comment, Fragment, renderList } = Vue
+"const { toString, openBlock, createVNode, createBlock, Comment, Fragment, renderList, Text } = Vue
 
 return function render() {
   const _ctx = this
@@ -34,7 +34,7 @@ return function render() {
     id: \\"foo\\",
     class: _ctx.bar.baz
   }, [
-    toString(_ctx.world.burn()),
+    createVNode(Text, null, toString(_ctx.world.burn()), 1 /* TEXT */),
     (openBlock(), (_ctx.ok)
       ? createBlock(\\"div\\", { key: 0 }, \\"yes\\")
       : createBlock(Fragment, { key: 1 }, [\\"no\\"])),
@@ -48,7 +48,7 @@ return function render() {
 `;
 
 exports[`compiler: integration tests module mode 1`] = `
-"import { toString, openBlock, createVNode, createBlock, Comment, Fragment, renderList } from \\"vue\\"
+"import { toString, openBlock, createVNode, createBlock, Comment, Fragment, renderList, Text } from \\"vue\\"
 
 export default function render() {
   const _ctx = this
@@ -56,7 +56,7 @@ export default function render() {
     id: \\"foo\\",
     class: _ctx.bar.baz
   }, [
-    toString(_ctx.world.burn()),
+    createVNode(Text, null, toString(_ctx.world.burn()), 1 /* TEXT */),
     (openBlock(), (_ctx.ok)
       ? createBlock(\\"div\\", { key: 0 }, \\"yes\\")
       : createBlock(Fragment, { key: 1 }, [\\"no\\"])),
index 3119fa01b739b608003f4cfcbe38d2a17a123df2..2190ad118a61b5059484afb2b6e790a6c6e2a361 100644 (file)
@@ -21,7 +21,7 @@ import { transformIf } from '../src/transforms/vIf'
 import { transformFor } from '../src/transforms/vFor'
 import { transformElement } from '../src/transforms/transformElement'
 import { transformSlotOutlet } from '../src/transforms/transformSlotOutlet'
-import { optimizeText } from '../src/transforms/optimizeText'
+import { transformText } from '../src/transforms/transformText'
 
 describe('compiler: transform', () => {
   test('context state', () => {
@@ -243,7 +243,7 @@ describe('compiler: transform', () => {
         nodeTransforms: [
           transformIf,
           transformFor,
-          optimizeText,
+          transformText,
           transformSlotOutlet,
           transformElement
         ]
diff --git a/packages/compiler-core/__tests__/transforms/__snapshots__/optimizeText.spec.ts.snap b/packages/compiler-core/__tests__/transforms/__snapshots__/optimizeText.spec.ts.snap
deleted file mode 100644 (file)
index ce57542..0000000
+++ /dev/null
@@ -1,68 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`compiler: optimize interpolation consecutive text 1`] = `
-"const _Vue = Vue
-
-return function render() {
-  with (this) {
-    const { toString: _toString } = _Vue
-    
-    return _toString(foo) + \\" bar \\" + _toString(baz)
-  }
-}"
-`;
-
-exports[`compiler: optimize interpolation consecutive text between elements 1`] = `
-"const _Vue = Vue
-
-return function render() {
-  with (this) {
-    const { createVNode: _createVNode, toString: _toString, createBlock: _createBlock, Fragment: _Fragment, openBlock: _openBlock } = _Vue
-    
-    return (_openBlock(), _createBlock(_Fragment, null, [
-      _createVNode(\\"div\\"),
-      _toString(foo) + \\" bar \\" + _toString(baz),
-      _createVNode(\\"div\\")
-    ]))
-  }
-}"
-`;
-
-exports[`compiler: optimize interpolation consecutive text mixed with elements 1`] = `
-"const _Vue = Vue
-
-return function render() {
-  with (this) {
-    const { createVNode: _createVNode, toString: _toString, createBlock: _createBlock, Fragment: _Fragment, openBlock: _openBlock } = _Vue
-    
-    return (_openBlock(), _createBlock(_Fragment, null, [
-      _createVNode(\\"div\\"),
-      _toString(foo) + \\" bar \\" + _toString(baz),
-      _createVNode(\\"div\\"),
-      _toString(foo) + \\" bar \\" + _toString(baz),
-      _createVNode(\\"div\\")
-    ]))
-  }
-}"
-`;
-
-exports[`compiler: optimize interpolation no consecutive text 1`] = `
-"const _Vue = Vue
-
-return function render() {
-  with (this) {
-    const { toString: _toString } = _Vue
-    
-    return _toString(foo)
-  }
-}"
-`;
-
-exports[`compiler: optimize interpolation with prefixIdentifiers: true 1`] = `
-"const { toString } = Vue
-
-return function render() {
-  const _ctx = this
-  return toString(_ctx.foo) + \\" bar \\" + toString(_ctx.baz + _ctx.qux)
-}"
-`;
diff --git a/packages/compiler-core/__tests__/transforms/__snapshots__/transformText.spec.ts.snap b/packages/compiler-core/__tests__/transforms/__snapshots__/transformText.spec.ts.snap
new file mode 100644 (file)
index 0000000..9e1f736
--- /dev/null
@@ -0,0 +1,84 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`compiler: transform text consecutive text 1`] = `
+"const _Vue = Vue
+
+return function render() {
+  with (this) {
+    const { toString: _toString } = _Vue
+    
+    return _toString(foo) + \\" bar \\" + _toString(baz)
+  }
+}"
+`;
+
+exports[`compiler: transform text consecutive text between elements 1`] = `
+"const _Vue = Vue
+
+return function render() {
+  with (this) {
+    const { createVNode: _createVNode, toString: _toString, Text: _Text, createBlock: _createBlock, Fragment: _Fragment, openBlock: _openBlock } = _Vue
+    
+    return (_openBlock(), _createBlock(_Fragment, null, [
+      _createVNode(\\"div\\"),
+      _createVNode(_Text, null, _toString(foo) + \\" bar \\" + _toString(baz), 1 /* TEXT */),
+      _createVNode(\\"div\\")
+    ]))
+  }
+}"
+`;
+
+exports[`compiler: transform text consecutive text mixed with elements 1`] = `
+"const _Vue = Vue
+
+return function render() {
+  with (this) {
+    const { createVNode: _createVNode, toString: _toString, Text: _Text, createBlock: _createBlock, Fragment: _Fragment, openBlock: _openBlock } = _Vue
+    
+    return (_openBlock(), _createBlock(_Fragment, null, [
+      _createVNode(\\"div\\"),
+      _createVNode(_Text, null, _toString(foo) + \\" bar \\" + _toString(baz), 1 /* TEXT */),
+      _createVNode(\\"div\\"),
+      _createVNode(_Text, null, \\"hello\\"),
+      _createVNode(\\"div\\")
+    ]))
+  }
+}"
+`;
+
+exports[`compiler: transform text no consecutive text 1`] = `
+"const _Vue = Vue
+
+return function render() {
+  with (this) {
+    const { toString: _toString } = _Vue
+    
+    return _toString(foo)
+  }
+}"
+`;
+
+exports[`compiler: transform text text between elements (static) 1`] = `
+"const _Vue = Vue
+
+return function render() {
+  with (this) {
+    const { createVNode: _createVNode, Text: _Text, createBlock: _createBlock, Fragment: _Fragment, openBlock: _openBlock } = _Vue
+    
+    return (_openBlock(), _createBlock(_Fragment, null, [
+      _createVNode(\\"div\\"),
+      _createVNode(_Text, null, \\"hello\\"),
+      _createVNode(\\"div\\")
+    ]))
+  }
+}"
+`;
+
+exports[`compiler: transform text with prefixIdentifiers: true 1`] = `
+"const { toString } = Vue
+
+return function render() {
+  const _ctx = this
+  return toString(_ctx.foo) + \\" bar \\" + toString(_ctx.baz + _ctx.qux)
+}"
+`;
index 74b2cc878e2753f1fc06fd1248f1a726f8a60454..06ed14e54d1565f389ff2f9092e5e371384f10d0 100644 (file)
@@ -23,7 +23,7 @@ import { transformOn } from '../../src/transforms/vOn'
 import { transformBind } from '../../src/transforms/vBind'
 import { PatchFlags } from '@vue/shared'
 import { createObjectMatcher, genFlagText } from '../testUtils'
-import { optimizeText } from '../../src/transforms/optimizeText'
+import { transformText } from '../../src/transforms/transformText'
 
 function parseWithElementTransform(
   template: string,
@@ -36,7 +36,7 @@ function parseWithElementTransform(
   // block as root node
   const ast = parse(`<div>${template}</div>`, options)
   transform(ast, {
-    nodeTransforms: [transformElement, optimizeText],
+    nodeTransforms: [transformElement, transformText],
     ...options
   })
   const codegenNode = (ast as any).children[0].children[0]
similarity index 53%
rename from packages/compiler-core/__tests__/transforms/optimizeText.spec.ts
rename to packages/compiler-core/__tests__/transforms/transformText.spec.ts
index 3c8c2638b1b0ea90ed56216ed12a4e16757e74a9..ec43e0ddd9a9f5cb5ffe9ee2f2da7647be37505f 100644 (file)
@@ -5,16 +5,19 @@ import {
   NodeTypes,
   generate
 } from '../../src'
-import { optimizeText } from '../../src/transforms/optimizeText'
+import { transformText } from '../../src/transforms/transformText'
 import { transformExpression } from '../../src/transforms/transformExpression'
 import { transformElement } from '../../src/transforms/transformElement'
+import { CREATE_VNODE, TEXT } from '../../src/runtimeHelpers'
+import { genFlagText } from '../testUtils'
+import { PatchFlags } from '@vue/shared'
 
 function transformWithTextOpt(template: string, options: CompilerOptions = {}) {
   const ast = parse(template)
   transform(ast, {
     nodeTransforms: [
       ...(options.prefixIdentifiers ? [transformExpression] : []),
-      optimizeText,
+      transformText,
       transformElement
     ],
     ...options
@@ -22,7 +25,7 @@ function transformWithTextOpt(template: string, options: CompilerOptions = {}) {
   return ast
 }
 
-describe('compiler: optimize interpolation', () => {
+describe('compiler: transform text', () => {
   test('no consecutive text', () => {
     const root = transformWithTextOpt(`{{ foo }}`)
     expect(root.children[0]).toMatchObject({
@@ -55,14 +58,52 @@ describe('compiler: optimize interpolation', () => {
     expect(root.children.length).toBe(3)
     expect(root.children[0].type).toBe(NodeTypes.ELEMENT)
     expect(root.children[1]).toMatchObject({
-      type: NodeTypes.COMPOUND_EXPRESSION,
-      children: [
-        { type: NodeTypes.INTERPOLATION, content: { content: `foo` } },
-        ` + `,
-        { type: NodeTypes.TEXT, content: ` bar ` },
-        ` + `,
-        { type: NodeTypes.INTERPOLATION, content: { content: `baz` } }
-      ]
+      // when mixed with elements, should convert it into a text node call
+      type: NodeTypes.TEXT_CALL,
+      codegenNode: {
+        type: NodeTypes.JS_CALL_EXPRESSION,
+        callee: CREATE_VNODE,
+        arguments: [
+          TEXT,
+          `null`,
+          {
+            type: NodeTypes.COMPOUND_EXPRESSION,
+            children: [
+              { type: NodeTypes.INTERPOLATION, content: { content: `foo` } },
+              ` + `,
+              { type: NodeTypes.TEXT, content: ` bar ` },
+              ` + `,
+              { type: NodeTypes.INTERPOLATION, content: { content: `baz` } }
+            ]
+          },
+          genFlagText(PatchFlags.TEXT)
+        ]
+      }
+    })
+    expect(root.children[2].type).toBe(NodeTypes.ELEMENT)
+    expect(generate(root).code).toMatchSnapshot()
+  })
+
+  test('text between elements (static)', () => {
+    const root = transformWithTextOpt(`<div/>hello<div/>`)
+    expect(root.children.length).toBe(3)
+    expect(root.children[0].type).toBe(NodeTypes.ELEMENT)
+    expect(root.children[1]).toMatchObject({
+      // when mixed with elements, should convert it into a text node call
+      type: NodeTypes.TEXT_CALL,
+      codegenNode: {
+        type: NodeTypes.JS_CALL_EXPRESSION,
+        callee: CREATE_VNODE,
+        arguments: [
+          TEXT,
+          `null`,
+          {
+            type: NodeTypes.TEXT,
+            content: `hello`
+          }
+          // should have no flag
+        ]
+      }
     })
     expect(root.children[2].type).toBe(NodeTypes.ELEMENT)
     expect(generate(root).code).toMatchSnapshot()
@@ -70,30 +111,47 @@ describe('compiler: optimize interpolation', () => {
 
   test('consecutive text mixed with elements', () => {
     const root = transformWithTextOpt(
-      `<div/>{{ foo }} bar {{ baz }}<div/>{{ foo }} bar {{ baz }}<div/>`
+      `<div/>{{ foo }} bar {{ baz }}<div/>hello<div/>`
     )
     expect(root.children.length).toBe(5)
     expect(root.children[0].type).toBe(NodeTypes.ELEMENT)
     expect(root.children[1]).toMatchObject({
-      type: NodeTypes.COMPOUND_EXPRESSION,
-      children: [
-        { type: NodeTypes.INTERPOLATION, content: { content: `foo` } },
-        ` + `,
-        { type: NodeTypes.TEXT, content: ` bar ` },
-        ` + `,
-        { type: NodeTypes.INTERPOLATION, content: { content: `baz` } }
-      ]
+      type: NodeTypes.TEXT_CALL,
+      codegenNode: {
+        type: NodeTypes.JS_CALL_EXPRESSION,
+        callee: CREATE_VNODE,
+        arguments: [
+          TEXT,
+          `null`,
+          {
+            type: NodeTypes.COMPOUND_EXPRESSION,
+            children: [
+              { type: NodeTypes.INTERPOLATION, content: { content: `foo` } },
+              ` + `,
+              { type: NodeTypes.TEXT, content: ` bar ` },
+              ` + `,
+              { type: NodeTypes.INTERPOLATION, content: { content: `baz` } }
+            ]
+          },
+          genFlagText(PatchFlags.TEXT)
+        ]
+      }
     })
     expect(root.children[2].type).toBe(NodeTypes.ELEMENT)
     expect(root.children[3]).toMatchObject({
-      type: NodeTypes.COMPOUND_EXPRESSION,
-      children: [
-        { type: NodeTypes.INTERPOLATION, content: { content: `foo` } },
-        ` + `,
-        { type: NodeTypes.TEXT, content: ` bar ` },
-        ` + `,
-        { type: NodeTypes.INTERPOLATION, content: { content: `baz` } }
-      ]
+      type: NodeTypes.TEXT_CALL,
+      codegenNode: {
+        type: NodeTypes.JS_CALL_EXPRESSION,
+        callee: CREATE_VNODE,
+        arguments: [
+          TEXT,
+          `null`,
+          {
+            type: NodeTypes.TEXT,
+            content: `hello`
+          }
+        ]
+      }
     })
     expect(root.children[4].type).toBe(NodeTypes.ELEMENT)
     expect(generate(root).code).toMatchSnapshot()
index 3d0fbd2a4c1abf58fd84e4257019e16ed63be6d9..740da9b1584f4f223593b826dc6d33d540363c8e 100644 (file)
@@ -35,6 +35,7 @@ export const enum NodeTypes {
   IF,
   IF_BRANCH,
   FOR,
+  TEXT_CALL,
   // codegen
   JS_CALL_EXPRESSION,
   JS_OBJECT_EXPRESSION,
@@ -86,6 +87,7 @@ export type TemplateChildNode =
   | CommentNode
   | IfNode
   | ForNode
+  | TextCallNode
 
 export interface RootNode extends Node {
   type: NodeTypes.ROOT
@@ -227,6 +229,12 @@ export interface ForNode extends Node {
   codegenNode: ForCodegenNode
 }
 
+export interface TextCallNode extends Node {
+  type: NodeTypes.TEXT_CALL
+  content: TextNode | InterpolationNode | CompoundExpressionNode
+  codegenNode: CallExpression
+}
+
 // We also include a number of JavaScript AST nodes for code generation.
 // The AST is an intentionally minimal subset just to meet the exact needs of
 // Vue render function generation.
index 946162af1155484412700ad67b8e5a8f7b7b21c9..ba49ad16d7a1a7cf21eafa7cbd1f20cadb5ea6e4 100644 (file)
@@ -400,6 +400,9 @@ function genNode(node: CodegenNode | symbol | string, context: CodegenContext) {
     case NodeTypes.INTERPOLATION:
       genInterpolation(node, context)
       break
+    case NodeTypes.TEXT_CALL:
+      genNode(node.codegenNode, context)
+      break
     case NodeTypes.COMPOUND_EXPRESSION:
       genCompoundExpression(node, context)
       break
index 9b3fa799ba86a2c5cddf4e86580c837de45e9e9c..097f891733ec0d0cb9566651bbeb7035fdcfe6d3 100644 (file)
@@ -12,7 +12,7 @@ import { transformOn } from './transforms/vOn'
 import { transformBind } from './transforms/vBind'
 import { defaultOnError, createCompilerError, ErrorCodes } from './errors'
 import { trackSlotScopes, trackVForSlotScopes } from './transforms/vSlot'
-import { optimizeText } from './transforms/optimizeText'
+import { transformText } from './transforms/transformText'
 import { transformOnce } from './transforms/vOnce'
 import { transformModel } from './transforms/vModel'
 
@@ -56,7 +56,7 @@ export function baseCompile(
       transformSlotOutlet,
       transformElement,
       trackSlotScopes,
-      optimizeText,
+      transformText,
       ...(options.nodeTransforms || []) // user transforms
     ],
     directiveTransforms: {
index 18646c51054c77474c7b6ff05e5a617158565ba8..9ef9dda2a0f038769c5dc8d37dc465dd6a7b9e93 100644 (file)
@@ -122,6 +122,7 @@ export function isStaticNode(
     case NodeTypes.FOR:
       return false
     case NodeTypes.INTERPOLATION:
+    case NodeTypes.TEXT_CALL:
       return isStaticNode(node.content, resultCache)
     case NodeTypes.SIMPLE_EXPRESSION:
       return node.isConstant
similarity index 57%
rename from packages/compiler-core/src/transforms/optimizeText.ts
rename to packages/compiler-core/src/transforms/transformText.ts
index 93bd15b7f06d9b9768874b89ab059540314fb9ae..1251a30dcaf29d6b8194b1ec7b641f8755448ccf 100644 (file)
@@ -4,8 +4,11 @@ import {
   TemplateChildNode,
   TextNode,
   InterpolationNode,
-  CompoundExpressionNode
+  CompoundExpressionNode,
+  createCallExpression
 } from '../ast'
+import { TEXT, CREATE_VNODE } from '../runtimeHelpers'
+import { PatchFlags, PatchFlagNames } from '@vue/shared'
 
 const isText = (
   node: TemplateChildNode
@@ -14,16 +17,19 @@ const isText = (
 
 // Merge adjacent text nodes and expressions into a single expression
 // e.g. <div>abc {{ d }} {{ e }}</div> should have a single expression node as child.
-export const optimizeText: NodeTransform = node => {
+export const transformText: NodeTransform = (node, context) => {
   if (node.type === NodeTypes.ROOT || node.type === NodeTypes.ELEMENT) {
     // perform the transform on node exit so that all expressions have already
     // been processed.
     return () => {
       const children = node.children
       let currentContainer: CompoundExpressionNode | undefined = undefined
+      let hasText = false
+
       for (let i = 0; i < children.length; i++) {
         const child = children[i]
         if (isText(child)) {
+          hasText = true
           for (let j = i + 1; j < children.length; j++) {
             const next = children[j]
             if (isText(next)) {
@@ -45,6 +51,31 @@ export const optimizeText: NodeTransform = node => {
           }
         }
       }
+
+      if (hasText && children.length > 1) {
+        // when an element has mixed text/element children, convert text nodes
+        // into createVNode(Text) calls.
+        for (let i = 0; i < children.length; i++) {
+          const child = children[i]
+          if (isText(child) || child.type === NodeTypes.COMPOUND_EXPRESSION) {
+            const callArgs = [context.helper(TEXT), `null`, child]
+            if (child.type !== NodeTypes.TEXT) {
+              callArgs.push(
+                `${PatchFlags.TEXT} /* ${PatchFlagNames[PatchFlags.TEXT]} */`
+              )
+            }
+            children[i] = {
+              type: NodeTypes.TEXT_CALL,
+              content: child,
+              loc: child.loc,
+              codegenNode: createCallExpression(
+                context.helper(CREATE_VNODE),
+                callArgs
+              )
+            }
+          }
+        }
+      }
     }
   }
 }
index aed084586aee277d4d93c4a8e95e0fd39ce68a49..2017d6910bc47d93b788dd3713b1e163f73e3ac8 100644 (file)
@@ -293,9 +293,16 @@ export function hasScopeRef(
     case NodeTypes.COMPOUND_EXPRESSION:
       return node.children.some(c => isObject(c) && hasScopeRef(c, ids))
     case NodeTypes.INTERPOLATION:
+    case NodeTypes.TEXT_CALL:
       return hasScopeRef(node.content, ids)
+    case NodeTypes.TEXT:
+    case NodeTypes.COMMENT:
+      return false
     default:
-      // TextNode or CommentNode
+      if (__DEV__) {
+        const exhaustiveCheck: never = node
+        exhaustiveCheck
+      }
       return false
   }
 }
index f2bc0715aa4aa5ec8291575475807d2cc4bf6233..572c7ddc51e2cda2b2b30835d2dae7b0c0a73f9d 100644 (file)
@@ -488,7 +488,7 @@ export function createRenderer<
         }
         return // terminal
       }
-    } else if (!optimized) {
+    } else if (!optimized && dynamicChildren == null) {
       // unoptimized, full diff
       patchProps(
         el,