Repository: vuejs/vue-next (ts)

Issue:
父子组件传值，页面更新后，未触发子组件 $attrs 更新，更新值流转到了 $props 上
### Version
3.2.22

### Reproduction link
[sfc.vuejs.org/](https://sfc.vuejs.org/#eyJBcHAudnVlIjoiPHNjcmlwdCBsYW5nPVwidHNcIj5cbiAgaW1wb3J0IGNvbUEgZnJvbSAnLi9BLnZ1ZSdcbiAgZXhwb3J0IGRlZmF1bHQge1xuICAgIGNvbXBvbmVudHM6IHsgXCJjb20tYVwiOiBjb21BIH0sXG4gIGRhdGEoKSB7XG4gICAgcmV0dXJuIHtcbiAgICAgIG5hbWU6ICdtYWluJyxcbiAgICAgIHJvb3REYXRhOiB7XG4gICAgICAgIGN1c3RvbUNsYXNzOiB1bmRlZmluZWQsXG4gICAgICAgIG5hbWU6ICdNYWluIOe7hOS7tuagh+ivhidcbiAgICAgIH0sXG4gICAgfVxuICB9LFxuICAgIHByb3BzOiBbXSxcbiAgICBtZXRob2RzOiB7XG4gICAgICBjaGFuZ2UoKSB7XG4gICAgICAgIHRoaXMucm9vdERhdGEuY3VzdG9tQ2xhc3MgPSAneHh4eHgteHgnXG4gICAgICB9XG4gICAgfVxuICB9XG48L3NjcmlwdD5cblxuPHRlbXBsYXRlPlxuICB7eyByb290RGF0YS5jdXN0b21DbGFzcyB9fVxuICA8YnV0dG9uIEBjbGljaz1cImNoYW5nZVwiPueCueWHuzwvYnV0dG9uPlxuICA8Y29tLWEgOm15LWNsYXNzPVwicm9vdERhdGEuY3VzdG9tQ2xhc3NcIiAvPlxuPC90ZW1wbGF0ZT5cblxuIiwiaW1wb3J0LW1hcC5qc29uIjoie1xuICBcImltcG9ydHNcIjoge1xuICAgIFwidnVlXCI6IFwiaHR0cHM6Ly9zZmMudnVlanMub3JnL3Z1ZS5ydW50aW1lLmVzbS1icm93c2VyLmpzXCJcbiAgfVxufSIsIkEudnVlIjoiPHRlbXBsYXRlPlxuICA8aDE+XG4gICAgY3VzdG9tQ2xhc3M6IHt7ICRhdHRyc1snbXktY2xhc3MnXSB9fTxiciAvPlxuICAgIEEg57uE5Lu2IHByb3BzIHt7ICRwcm9wcyB9fSA8YnIgLz5cbiAgICBBIOe7hOS7tiBhdHRycyB7eyAkYXR0cnMgfX1cbiAgPC9oMT5cbjwvdGVtcGxhdGU+XG5cbjxzY3JpcHQ+XG5cbmV4cG9ydCBkZWZhdWx0IHtcbiAgcHJvcHM6IFtdLFxuICBkYXRhKCkge1xuICAgIHJldHVybiB7XG4gICAgICByb290RGF0YToge1xuICAgICAgICBuYW1lOiAnQSDnu4Tku7bmoIfor4YnXG4gICAgICB9XG4gICAgfVxuICB9LFxuICBjb21wb25lbnRzOiB7fSxcbiAgbW91bnRlZCgpIHtcbiAgICBjb25zb2xlLmxvZygnQSBtb3VudGVkJywgdGhpcywgJz09PSAkcHJvcHMgPT09JywgdGhpcy4kcHJvcHMsICc9PT0gJGF0dHJzID09PScsIHRoaXMuJGF0dHJzKVxuICB9LFxuICB1cGRhdGVkKCkge1xuICAgIGNvbnNvbGUubG9nKCdBIHVwZGF0ZWQnLCB0aGlzLCAnPT09ICRwcm9wcyA9PT0nLCB0aGlzLiRwcm9wcywgJz09PSAkYXR0cnMgPT09JywgdGhpcy4kYXR0cnMpXG4gIH0sXG4gIHdhdGNoOiB7XG4gICAgJGF0dHJzOiB7XG4gICAgICBoYW5kbGVyKG5ld1YpIHtcbiAgICAgICAgY29uc29sZS5sb2coJz09PT09PSB3YXRjaCAkYXR0cnMgPT09PT4nLCBuZXdWKVxuICAgICAgfSxcbiAgICAgIGltbWVkaWF0ZTogdHJ1ZSxcbiAgICAgIGRlZXA6IHRydWVcbiAgICB9LFxuXG4gICAgJHByb3BzOiB7XG4gICAgICBoYW5kbGVyKG5ld1YpIHtcbiAgICAgICAgY29uc29sZS5sb2coJz09PT09PSB3YXRjaCAkcHJvcHMgPT09PT4nLCBuZXdWKVxuICAgICAgfSxcbiAgICAgIGltbWVkaWF0ZTogdHJ1ZSxcbiAgICAgIGRlZXA6IHRydWVcbiAgICB9XG4gIH0sXG4gIG1ldGhvZHM6IHt9XG59XG48L3NjcmlwdD4ifQ==)







### Steps to reproduce
父子组件传值，子组件声明 attr 并且初始值是 underfined
此时，更新 attr, 子组件 $attrs 并未收到更新，发现此时的更新进入 $props 中（并未在子组件中，声明改 attr 为 prop 属性）

排查发现：
updateProps 会在 _hmrId 为 underfined 时，把 attribute 设置到 $porps 中


### What is expected?
期望 $attrs 更新，而不是 $porps 更新

### What is actually happening?
$attrs 未更新，prop 进入 $porps 且转换为驼峰规则

<!-- generated by vue-issues. DO NOT REMOVE -->

Pull request description:
fix(runtime-core): prop assigned to undefined also need to put into attrs
fix #5016

Patch:
diff --git a/packages/compiler-core/src/transforms/hoistStatic.ts b/packages/compiler-core/src/transforms/hoistStatic.ts
index 920ba8d4a..215792ed4 100644
--- a/packages/compiler-core/src/transforms/hoistStatic.ts
+++ b/packages/compiler-core/src/transforms/hoistStatic.ts
@@ -52,16 +52,6 @@ function walk(
   context: TransformContext,
   doNotHoistNode: boolean = false
 ) {
-  // Some transforms, e.g. transformAssetUrls from @vue/compiler-sfc, replaces
-  // static bindings with expressions. These expressions are guaranteed to be
-  // constant so they are still eligible for hoisting, but they are only
-  // available at runtime and therefore cannot be evaluated ahead of time.
-  // This is only a concern for pre-stringification (via transformHoist by
-  // @vue/compiler-dom), but doing it here allows us to perform only one full
-  // walk of the AST and allow `stringifyStatic` to stop walking as soon as its
-  // stringification threshold is met.
-  let canStringify = true
-
   const { children } = node
   const originalCount = children.length
   let hoistedCount = 0
@@ -77,9 +67,6 @@ function walk(
         ? ConstantTypes.NOT_CONSTANT
         : getConstantType(child, context)
       if (constantType > ConstantTypes.NOT_CONSTANT) {
-        if (constantType < ConstantTypes.CAN_STRINGIFY) {
-          canStringify = false
-        }
         if (constantType >= ConstantTypes.CAN_HOIST) {
           ;(child.codegenNode as VNodeCall).patchFlag =
             PatchFlags.HOISTED + (__DEV__ ? ` /* HOISTED */` : ``)
@@ -110,17 +97,12 @@ function walk(
           }
         }
       }
-    } else if (child.type === NodeTypes.TEXT_CALL) {
-      const contentType = getConstantType(child.content, context)
-      if (contentType > 0) {
-        if (contentType < ConstantTypes.CAN_STRINGIFY) {
-          canStringify = false
-        }
-        if (contentType >= ConstantTypes.CAN_HOIST) {
-          child.codegenNode = context.hoist(child.codegenNode)
-          hoistedCount++
-        }
-      }
+    } else if (
+      child.type === NodeTypes.TEXT_CALL &&
+      getConstantType(child.content, context) >= ConstantTypes.CAN_HOIST
+    ) {
+      child.codegenNode = context.hoist(child.codegenNode)
+      hoistedCount++
     }
 
     // walk further
@@ -148,7 +130,7 @@ function walk(
     }
   }
 
-  if (canStringify && hoistedCount && context.transformHoist) {
+  if (hoistedCount && context.transformHoist) {
     context.transformHoist(children, context, node)
   }
 
diff --git a/packages/compiler-core/src/utils.ts b/packages/compiler-core/src/utils.ts
index 416cf79e9..6e2443ea6 100644
--- a/packages/compiler-core/src/utils.ts
+++ b/packages/compiler-core/src/utils.ts
@@ -363,9 +363,6 @@ export function injectProp(
   context: TransformContext
 ) {
   let propsWithInjection: ObjectExpression | CallExpression | undefined
-  const originalProps =
-    node.type === NodeTypes.VNODE_CALL ? node.props : node.arguments[2]
-
   /**
    * 1. mergeProps(...)
    * 2. toHandlers(...)
@@ -374,7 +371,7 @@ export function injectProp(
    *
    * we need to get the real props before normalization
    */
-  let props = originalProps
+  let props = node.type === NodeTypes.VNODE_CALL ? node.props : node.arguments[2]
   let callPath: CallExpression[] = []
   let parentCall: CallExpression | undefined
   if (
diff --git a/packages/compiler-dom/src/transforms/stringifyStatic.ts b/packages/compiler-dom/src/transforms/stringifyStatic.ts
index 4f31f53ca..e257e3254 100644
--- a/packages/compiler-dom/src/transforms/stringifyStatic.ts
+++ b/packages/compiler-dom/src/transforms/stringifyStatic.ts
@@ -14,7 +14,8 @@ import {
   ElementTypes,
   PlainElementNode,
   JSChildNode,
-  TextCallNode
+  TextCallNode,
+  ConstantTypes
 } from '@vue/compiler-core'
 import {
   isVoidTag,
@@ -38,6 +39,12 @@ export const enum StringifyThresholds {
 
 type StringifiableNode = PlainElementNode | TextCallNode
 
+/**
+ * Regex for replacing placeholders for embedded constant variables
+ * (e.g. import URL string constants generated by compiler-sfc)
+ */
+const expReplaceRE = /__VUE_EXP_START__(.*?)__VUE_EXP_END__/g
+
 /**
  * Turn eligible hoisted static trees into stringified static nodes, e.g.
  *
@@ -80,7 +87,7 @@ export const stringifyStatic: HoistTransform = (children, context, parent) => {
       const staticCall = createCallExpression(context.helper(CREATE_STATIC), [
         JSON.stringify(
           currentChunk.map(node => stringifyNode(node, context)).join('')
-        ),
+        ).replace(expReplaceRE, `" + $1 + "`),
         // the 2nd argument indicates the number of DOM nodes this static vnode
         // will insert / hydrate
         String(currentChunk.length)
@@ -165,7 +172,7 @@ const isNonStringifiable = /*#__PURE__*/ makeMap(
 
 /**
  * for a hoisted node, analyze it and return:
- * - false: bailed (contains runtime constant)
+ * - false: bailed (contains non-stringifiable props or runtime constant)
  * - [nc, ec] where
  *   - nc is the number of nodes inside
  *   - ec is the number of element with bindings inside
@@ -210,6 +217,13 @@ function analyzeNode(node: StringifiableNode): [number, number] | false {
         ) {
           return bail()
         }
+        if (
+          p.exp &&
+          (p.exp.type === NodeTypes.COMPOUND_EXPRESSION ||
+            p.exp.constType < ConstantTypes.CAN_STRINGIFY)
+        ) {
+          return bail()
+        }
       }
     }
     for (let i = 0; i < node.children.length; i++) {
@@ -273,8 +287,17 @@ function stringifyElement(
         res += `="${escapeHtml(p.value.content)}"`
       }
     } else if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind') {
+      const exp = p.exp as SimpleExpressionNode
+      if (exp.content[0] === '_') {
+        // internally generated string constant references
+        // e.g. imported URL strings via compiler-sfc transformAssetUrl plugin
+        res += ` ${(p.arg as SimpleExpressionNode).content}="__VUE_EXP_START__${
+          exp.content
+        }__VUE_EXP_END__"`
+        continue
+      }
       // constant v-bind, e.g. :foo="1"
-      let evaluated = evaluateConstant(p.exp as SimpleExpressionNode)
+      let evaluated = evaluateConstant(exp)
       if (evaluated != null) {
         const arg = p.arg && (p.arg as SimpleExpressionNode).content
         if (arg === 'class') {
diff --git a/packages/compiler-sfc/src/templateTransformAssetUrl.ts b/packages/compiler-sfc/src/templateTransformAssetUrl.ts
index 4246817df..2f6152b8e 100644
--- a/packages/compiler-sfc/src/templateTransformAssetUrl.ts
+++ b/packages/compiler-sfc/src/templateTransformAssetUrl.ts
@@ -162,7 +162,12 @@ function getImportsExpressionExp(
       exp = context.imports[existingIndex].exp as SimpleExpressionNode
     } else {
       name = `_imports_${context.imports.length}`
-      exp = createSimpleExpression(name, false, loc, ConstantTypes.CAN_HOIST)
+      exp = createSimpleExpression(
+        name,
+        false,
+        loc,
+        ConstantTypes.CAN_STRINGIFY
+      )
       context.imports.push({ exp, path })
     }
 
@@ -184,13 +189,13 @@ function getImportsExpressionExp(
         `_hoisted_${existingHoistIndex + 1}`,
         false,
         loc,
-        ConstantTypes.CAN_HOIST
+        ConstantTypes.CAN_STRINGIFY
       )
     }
     return context.hoist(
-      createSimpleExpression(hashExp, false, loc, ConstantTypes.CAN_HOIST)
+      createSimpleExpression(hashExp, false, loc, ConstantTypes.CAN_STRINGIFY)
     )
   } else {
-    return createSimpleExpression(`''`, false, loc, ConstantTypes.CAN_HOIST)
+    return createSimpleExpression(`''`, false, loc, ConstantTypes.CAN_STRINGIFY)
   }
 }
diff --git a/packages/compiler-sfc/src/templateTransformSrcset.ts b/packages/compiler-sfc/src/templateTransformSrcset.ts
index 02cda03ab..ccdcc721e 100644
--- a/packages/compiler-sfc/src/templateTransformSrcset.ts
+++ b/packages/compiler-sfc/src/templateTransformSrcset.ts
@@ -57,7 +57,7 @@ export const transformSrcset: NodeTransform = (
             return { url, descriptor }
           })
 
-          // data urls contains comma after the ecoding so we need to re-merge
+          // data urls contains comma after the encoding so we need to re-merge
           // them
           for (let i = 0; i < imageCandidates.length; i++) {
             const { url } = imageCandidates[i]
@@ -113,14 +113,14 @@ export const transformSrcset: NodeTransform = (
                     `_imports_${existingImportsIndex}`,
                     false,
                     attr.loc,
-                    ConstantTypes.CAN_HOIST
+                    ConstantTypes.CAN_STRINGIFY
                   )
                 } else {
                   exp = createSimpleExpression(
                     `_imports_${context.imports.length}`,
                     false,
                     attr.loc,
-                    ConstantTypes.CAN_HOIST
+                    ConstantTypes.CAN_STRINGIFY
                   )
                   context.imports.push({ exp, path })
                 }
@@ -131,7 +131,7 @@ export const transformSrcset: NodeTransform = (
                 `"${url}"`,
                 false,
                 attr.loc,
-                ConstantTypes.CAN_HOIST
+                ConstantTypes.CAN_STRINGIFY
               )
               compoundExpression.children.push(exp)
             }
@@ -146,7 +146,7 @@ export const transformSrcset: NodeTransform = (
           })
 
           const hoisted = context.hoist(compoundExpression)
-          hoisted.constType = ConstantTypes.CAN_HOIST
+          hoisted.constType = ConstantTypes.CAN_STRINGIFY
 
           node.props[index] = {
             type: NodeTypes.DIRECTIVE,
diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts
index d608e8b8f..c61999bd6 100644
--- a/packages/reactivity/src/index.ts
+++ b/packages/reactivity/src/index.ts
@@ -12,6 +12,7 @@ export {
   ToRef,
   ToRefs,
   UnwrapRef,
+  ShallowRef,
   ShallowUnwrapRef,
   RefUnwrapBailTypes
 } from './ref'
diff --git a/packages/reactivity/src/ref.ts b/packages/reactivity/src/ref.ts
index 93eb4fa89..3e00541c7 100644
--- a/packages/reactivity/src/ref.ts
+++ b/packages/reactivity/src/ref.ts
@@ -77,7 +77,7 @@ export function ref(value?: unknown) {
 
 declare const ShallowRefMarker: unique symbol
 
-type ShallowRef<T = any> = Ref<T> & { [ShallowRefMarker]?: true }
+export type ShallowRef<T = any> = Ref<T> & { [ShallowRefMarker]?: true }
 
 export function shallowRef<T extends object>(
   value: T
diff --git a/packages/runtime-core/src/apiSetupHelpers.ts b/packages/runtime-core/src/apiSetupHelpers.ts
index 02ca5d53e..64f5aa865 100644
--- a/packages/runtime-core/src/apiSetupHelpers.ts
+++ b/packages/runtime-core/src/apiSetupHelpers.ts
@@ -118,7 +118,7 @@ export function defineEmits() {
  * This is only usable inside `<script setup>`, is compiled away in the
  * output and should **not** be actually called at runtime.
  */
-export function defineExpose(exposed?: Record<string, any>) {
+export function defineExpose<Exposed extends Record<string, any> = Record<string, any>>(exposed?: Exposed) {
   if (__DEV__) {
     warnRuntimeUsage(`defineExpose`)
   }
diff --git a/packages/runtime-core/src/compat/componentFunctional.ts b/packages/runtime-core/src/compat/componentFunctional.ts
index 80af32a1d..1b1146bba 100644
--- a/packages/runtime-core/src/compat/componentFunctional.ts
+++ b/packages/runtime-core/src/compat/componentFunctional.ts
@@ -55,6 +55,7 @@ export function convertLegacyFunctionalComponent(comp: ComponentOptions) {
   }
   Func.props = comp.props
   Func.displayName = comp.name
+  Func.compatConfig = comp.compatConfig
   // v2 functional components do not inherit attrs
   Func.inheritAttrs = false
 
diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts
index 515bd16d2..cda15d8bd 100644
--- a/packages/runtime-core/src/component.ts
+++ b/packages/runtime-core/src/component.ts
@@ -60,7 +60,11 @@ import { markAttrsAccessed } from './componentRenderUtils'
 import { currentRenderingInstance } from './componentRenderContext'
 import { startMeasure, endMeasure } from './profiling'
 import { convertLegacyRenderFn } from './compat/renderFn'
-import { globalCompatConfig, validateCompatConfig } from './compat/compatConfig'
+import {
+  CompatConfig,
+  globalCompatConfig,
+  validateCompatConfig
+} from './compat/compatConfig'
 import { SchedulerJob } from './scheduler'
 
 export type Data = Record<string, unknown>
@@ -111,6 +115,7 @@ export interface FunctionalComponent<P = {}, E extends EmitsOptions = {}>
   emits?: E | (keyof E)[]
   inheritAttrs?: boolean
   displayName?: string
+  compatConfig?: CompatConfig
 }
 
 export interface ClassComponent {
diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts
index c99949222..cf73261fc 100644
--- a/packages/runtime-core/src/componentProps.ts
+++ b/packages/runtime-core/src/componentProps.ts
@@ -369,7 +369,7 @@ function setFullProps(
             continue
           }
         }
-        if (value !== attrs[key]) {
+        if (!(key in attrs) || value !== attrs[key]) {
           attrs[key] = value
           hasAttrsChanged = true
         }
diff --git a/packages/runtime-dom/src/apiCustomElement.ts b/packages/runtime-dom/src/apiCustomElement.ts
index 5127e4a47..059fcac16 100644
--- a/packages/runtime-dom/src/apiCustomElement.ts
+++ b/packages/runtime-dom/src/apiCustomElement.ts
@@ -321,7 +321,7 @@ export class VueElement extends BaseClass {
         // HMR
         if (__DEV__) {
           instance.ceReload = newStyles => {
-            // alawys reset styles
+            // always reset styles
             if (this._styles) {
               this._styles.forEach(s => this.shadowRoot!.removeChild(s))
               this._styles.length = 0
diff --git a/packages/runtime-dom/src/components/TransitionGroup.ts b/packages/runtime-dom/src/components/TransitionGroup.ts
index 8c5739ac1..5c78be26d 100644
--- a/packages/runtime-dom/src/components/TransitionGroup.ts
+++ b/packages/runtime-dom/src/components/TransitionGroup.ts
@@ -27,13 +27,8 @@ import {
 } from '@vue/runtime-core'
 import { extend } from '@vue/shared'
 
-interface Position {
-  top: number
-  left: number
-}
-
-const positionMap = new WeakMap<VNode, Position>()
-const newPositionMap = new WeakMap<VNode, Position>()
+const positionMap = new WeakMap<VNode, DOMRect>()
+const newPositionMap = new WeakMap<VNode, DOMRect>()
 
 export type TransitionGroupProps = Omit<TransitionProps, 'mode'> & {
   tag?: string
diff --git a/packages/sfc-playground/package.json b/packages/sfc-playground/package.json
index 6e425e737..136a866b9 100644
--- a/packages/sfc-playground/package.json
+++ b/packages/sfc-playground/package.json
@@ -12,7 +12,7 @@
   },
   "dependencies": {
     "vue": "3.2.23",
-    "@vue/repl": "^0.4.4",
+    "@vue/repl": "^0.4.5",
     "file-saver": "^2.0.5",
     "jszip": "^3.6.0"
   }
diff --git a/packages/template-explorer/local.html b/packages/template-explorer/local.html
index 1aab3c26f..6ec0ba20a 100644
--- a/packages/template-explorer/local.html
+++ b/packages/template-explorer/local.html
@@ -1,5 +1,5 @@
 <title>Vue Template Explorer</title>
-<link rel="stylesheet" data-name="vs/editor/editor.main" href="../../node_modules/monaco-editor/min/vs/editor/editor.main.css">
+<link rel="stylesheet" data-name="vs/editor/editor.main" href="./node_modules/monaco-editor/min/vs/editor/editor.main.css">
 <link rel="stylesheet" href="./style.css">
 
 <div id="header"></div>
diff --git a/packages/template-explorer/src/index.ts b/packages/template-explorer/src/index.ts
index d083d35ca..1e8efdb95 100644
--- a/packages/template-explorer/src/index.ts
+++ b/packages/template-explorer/src/index.ts
@@ -40,6 +40,9 @@ window.init = () => {
       localStorage.getItem('state') ||
       `{}`
   )
+  // functions are not persistable, so delete it in case we sometimes need
+  // to debug with custom nodeTransforms
+  delete persistedState.options.nodeTransforms
 
   ssrMode.value = persistedState.ssr
   Object.assign(compilerOptions, persistedState.options)
diff --git a/packages/vue-compat/README.md b/packages/vue-compat/README.md
index 44ad0f9d0..d19b4e021 100644
--- a/packages/vue-compat/README.md
+++ b/packages/vue-compat/README.md
@@ -89,6 +89,7 @@ The following workflow walks through the steps of migrating an actual Vue 2 app
              }
            }
          })
+     }
    }
    ```
 
diff --git a/packages/vue/package.json b/packages/vue/package.json
index f1607b63b..3248b268c 100644
--- a/packages/vue/package.json
+++ b/packages/vue/package.json
@@ -34,7 +34,7 @@
     },
     "./dist/*": "./dist/*",
     "./package.json": "./package.json",
-    "./ref-macros.d.ts": "./ref-macros.d.ts"
+    "./ref-macros": "./ref-macros.d.ts"
   },
   "buildOptions": {
     "name": "Vue",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6ccb63275..4d38338b5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -234,12 +234,12 @@ importers:
   packages/sfc-playground:
     specifiers:
       '@vitejs/plugin-vue': ^1.9.3
-      '@vue/repl': ^0.4.4
+      '@vue/repl': ^0.4.5
       file-saver: ^2.0.5
       jszip: ^3.6.0
       vue: 3.2.23
     dependencies:
-      '@vue/repl': 0.4.4
+      '@vue/repl': 0.4.5
       file-saver: 2.0.5
       jszip: 3.7.1
       vue: link:../vue
@@ -1268,8 +1268,8 @@ packages:
     engines: {node: '>= 0.12.0'}
     dev: true
 
-  /@vue/repl/0.4.4:
-    resolution: {integrity: sha512-Mj0Zasc6V7bymDg7DNiN1bW/MZVNS4PmKzDkG9hq9ysrkrZoTHcwA+gcAxF+25Dfn0Tz8WSZD9FuEgEKoA681g==}
+  /@vue/repl/0.4.5:
+    resolution: {integrity: sha512-FnKLdekdKsgVaFTi2wwE0ZjQfxIOlCEFeU0TtO5Quaa13k4/VJvqTKsJGEqcdC4rc4E7avKe5HhnND0tEnC+ag==}
     peerDependencies:
       vue: ^3.2.13
     dev: false


Test patch:
diff --git a/packages/compiler-dom/__tests__/transforms/__snapshots__/stringifyStatic.spec.ts.snap b/packages/compiler-dom/__tests__/transforms/__snapshots__/stringifyStatic.spec.ts.snap
new file mode 100644
index 000000000..efa75df3f
--- /dev/null
+++ b/packages/compiler-dom/__tests__/transforms/__snapshots__/stringifyStatic.spec.ts.snap
@@ -0,0 +1,34 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`stringify static html should bail on bindings that are hoisted but not stringifiable 1`] = `
+"const { createElementVNode: _createElementVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = Vue
+
+const _hoisted_1 = /*#__PURE__*/_createElementVNode(\\"div\\", null, [
+  /*#__PURE__*/_createElementVNode(\\"span\\", { class: \\"foo\\" }, \\"foo\\"),
+  /*#__PURE__*/_createElementVNode(\\"span\\", { class: \\"foo\\" }, \\"foo\\"),
+  /*#__PURE__*/_createElementVNode(\\"span\\", { class: \\"foo\\" }, \\"foo\\"),
+  /*#__PURE__*/_createElementVNode(\\"span\\", { class: \\"foo\\" }, \\"foo\\"),
+  /*#__PURE__*/_createElementVNode(\\"span\\", { class: \\"foo\\" }, \\"foo\\"),
+  /*#__PURE__*/_createElementVNode(\\"img\\", { src: _imports_0_ })
+], -1 /* HOISTED */)
+const _hoisted_2 = [
+  _hoisted_1
+]
+
+return function render(_ctx, _cache) {
+  return (_openBlock(), _createElementBlock(\\"div\\", null, _hoisted_2))
+}"
+`;
+
+exports[`stringify static html should work with bindings that are non-static but stringifiable 1`] = `
+"const { createElementVNode: _createElementVNode, createStaticVNode: _createStaticVNode, openBlock: _openBlock, createElementBlock: _createElementBlock } = Vue
+
+const _hoisted_1 = /*#__PURE__*/_createStaticVNode(\\"<div><span class=\\\\\\"foo\\\\\\">foo</span><span class=\\\\\\"foo\\\\\\">foo</span><span class=\\\\\\"foo\\\\\\">foo</span><span class=\\\\\\"foo\\\\\\">foo</span><span class=\\\\\\"foo\\\\\\">foo</span><img src=\\\\\\"\\" + _imports_0_ + \\"\\\\\\"></div>\\", 1)
+const _hoisted_2 = [
+  _hoisted_1
+]
+
+return function render(_ctx, _cache) {
+  return (_openBlock(), _createElementBlock(\\"div\\", null, _hoisted_2))
+}"
+`;
diff --git a/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts b/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts
index 0923f0816..0beb42585 100644
--- a/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts
+++ b/packages/compiler-dom/__tests__/transforms/stringifyStatic.spec.ts
@@ -181,8 +181,8 @@ describe('stringify static html', () => {
     ])
   })
 
-  test('should bail on runtime constant v-bind bindings', () => {
-    const { ast } = compile(
+  test('should bail on bindings that are hoisted but not stringifiable', () => {
+    const { ast, code } = compile(
       `<div><div>${repeat(
         `<span class="foo">foo</span>`,
         StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
@@ -216,13 +216,62 @@ describe('stringify static html', () => {
     expect(ast.hoists).toMatchObject([
       {
         // the expression and the tree are still hoistable
-        // but if it's stringified it will be NodeTypes.CALL_EXPRESSION
+        // but should stay NodeTypes.VNODE_CALL
+        // if it's stringified it will be NodeTypes.JS_CALL_EXPRESSION
         type: NodeTypes.VNODE_CALL
       },
       {
         type: NodeTypes.JS_ARRAY_EXPRESSION
       }
     ])
+    expect(code).toMatchSnapshot()
+  })
+
+  test('should work with bindings that are non-static but stringifiable', () => {
+    // if a binding is non-static but marked as CAN_STRINGIFY, it means it's
+    // a known reference to a constant string.
+    const { ast, code } = compile(
+      `<div><div>${repeat(
+        `<span class="foo">foo</span>`,
+        StringifyThresholds.ELEMENT_WITH_BINDING_COUNT
+      )}<img src="./foo" /></div></div>`,
+      {
+        hoistStatic: true,
+        prefixIdentifiers: true,
+        transformHoist: stringifyStatic,
+        nodeTransforms: [
+          node => {
+            if (node.type === NodeTypes.ELEMENT && node.tag === 'img') {
+              const exp = createSimpleExpression(
+                '_imports_0_',
+                false,
+                node.loc,
+                ConstantTypes.CAN_STRINGIFY
+              )
+              node.props[0] = {
+                type: NodeTypes.DIRECTIVE,
+                name: 'bind',
+                arg: createSimpleExpression('src', true),
+                exp,
+                modifiers: [],
+                loc: node.loc
+              }
+            }
+          }
+        ]
+      }
+    )
+    expect(ast.hoists).toMatchObject([
+      {
+        // the hoisted node should be NodeTypes.JS_CALL_EXPRESSION
+        // of `createStaticVNode()` instead of dynamic NodeTypes.VNODE_CALL
+        type: NodeTypes.JS_CALL_EXPRESSION
+      },
+      {
+        type: NodeTypes.JS_ARRAY_EXPRESSION
+      }
+    ])
+    expect(code).toMatchSnapshot()
   })
 
   // #1128
diff --git a/packages/compiler-sfc/__tests__/__snapshots__/templateTransformAssetUrl.spec.ts.snap b/packages/compiler-sfc/__tests__/__snapshots__/templateTransformAssetUrl.spec.ts.snap
index 37545d772..c0fceecee 100644
--- a/packages/compiler-sfc/__tests__/__snapshots__/templateTransformAssetUrl.spec.ts.snap
+++ b/packages/compiler-sfc/__tests__/__snapshots__/templateTransformAssetUrl.spec.ts.snap
@@ -74,6 +74,22 @@ export function render(_ctx, _cache) {
 }"
 `;
 
+exports[`compiler sfc: transform asset url transform with stringify 1`] = `
+"import { createElementVNode as _createElementVNode, createStaticVNode as _createStaticVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from \\"vue\\"
+import _imports_0 from './bar.png'
+import _imports_1 from '/bar.png'
+
+
+const _hoisted_1 = /*#__PURE__*/_createStaticVNode(\\"<img src=\\\\\\"\\" + _imports_0 + \\"\\\\\\"><img src=\\\\\\"\\" + _imports_1 + \\"\\\\\\"><img src=\\\\\\"https://foo.bar/baz.png\\\\\\"><img src=\\\\\\"//foo.bar/baz.png\\\\\\"><img src=\\\\\\"\\" + _imports_0 + \\"\\\\\\">\\", 5)
+const _hoisted_6 = [
+  _hoisted_1
+]
+
+export function render(_ctx, _cache) {
+  return (_openBlock(), _createElementBlock(\\"div\\", null, _hoisted_6))
+}"
+`;
+
 exports[`compiler sfc: transform asset url with explicit base 1`] = `
 "import { createElementVNode as _createElementVNode, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock } from \\"vue\\"
 import _imports_0 from 'bar.png'
diff --git a/packages/compiler-sfc/__tests__/__snapshots__/templateTransformSrcset.spec.ts.snap b/packages/compiler-sfc/__tests__/__snapshots__/templateTransformSrcset.spec.ts.snap
index 5cc0fe2a9..89e487f09 100644
--- a/packages/compiler-sfc/__tests__/__snapshots__/templateTransformSrcset.spec.ts.snap
+++ b/packages/compiler-sfc/__tests__/__snapshots__/templateTransformSrcset.spec.ts.snap
@@ -194,3 +194,28 @@ export function render(_ctx, _cache) {
   ], 64 /* STABLE_FRAGMENT */))
 }"
 `;
+
+exports[`compiler sfc: transform srcset transform srcset w/ stringify 1`] = `
+"import { createElementVNode as _createElementVNode, createStaticVNode as _createStaticVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from \\"vue\\"
+import _imports_0 from './logo.png'
+import _imports_1 from '/logo.png'
+
+
+const _hoisted_1 = _imports_0
+const _hoisted_2 = _imports_0 + ' 2x'
+const _hoisted_3 = _imports_0 + ' 2x'
+const _hoisted_4 = _imports_0 + ', ' + _imports_0 + ' 2x'
+const _hoisted_5 = _imports_0 + ' 2x, ' + _imports_0
+const _hoisted_6 = _imports_0 + ' 2x, ' + _imports_0 + ' 3x'
+const _hoisted_7 = _imports_0 + ', ' + _imports_0 + ' 2x, ' + _imports_0 + ' 3x'
+const _hoisted_8 = _imports_1 + ', ' + _imports_1 + ' 2x'
+const _hoisted_9 = _imports_1 + ', ' + _imports_0 + ' 2x'
+const _hoisted_10 = /*#__PURE__*/_createStaticVNode(\\"<img src=\\\\\\"./logo.png\\\\\\" srcset=\\\\\\"\\\\\\"><img src=\\\\\\"./logo.png\\\\\\" srcset=\\\\\\"\\" + _hoisted_1 + \\"\\\\\\"><img src=\\\\\\"./logo.png\\\\\\" srcset=\\\\\\"\\" + _hoisted_2 + \\"\\\\\\"><img src=\\\\\\"./logo.png\\\\\\" srcset=\\\\\\"\\" + _hoisted_3 + \\"\\\\\\"><img src=\\\\\\"./logo.png\\\\\\" srcset=\\\\\\"\\" + _hoisted_4 + \\"\\\\\\"><img src=\\\\\\"./logo.png\\\\\\" srcset=\\\\\\"\\" + _hoisted_5 + \\"\\\\\\"><img src=\\\\\\"./logo.png\\\\\\" srcset=\\\\\\"\\" + _hoisted_6 + \\"\\\\\\"><img src=\\\\\\"./logo.png\\\\\\" srcset=\\\\\\"\\" + _hoisted_7 + \\"\\\\\\"><img src=\\\\\\"/logo.png\\\\\\" srcset=\\\\\\"\\" + _hoisted_8 + \\"\\\\\\"><img src=\\\\\\"https://example.com/logo.png\\\\\\" srcset=\\\\\\"https://example.com/logo.png, https://example.com/logo.png 2x\\\\\\"><img src=\\\\\\"/logo.png\\\\\\" srcset=\\\\\\"\\" + _hoisted_9 + \\"\\\\\\"><img src=\\\\\\"data:image/png;base64,i\\\\\\" srcset=\\\\\\"data:image/png;base64,i 1x, data:image/png;base64,i 2x\\\\\\">\\", 12)
+const _hoisted_22 = [
+  _hoisted_10
+]
+
+export function render(_ctx, _cache) {
+  return (_openBlock(), _createElementBlock(\\"div\\", null, _hoisted_22))
+}"
+`;
diff --git a/packages/compiler-sfc/__tests__/templateTransformAssetUrl.spec.ts b/packages/compiler-sfc/__tests__/templateTransformAssetUrl.spec.ts
index 18e2cb5e9..44ef092aa 100644
--- a/packages/compiler-sfc/__tests__/templateTransformAssetUrl.spec.ts
+++ b/packages/compiler-sfc/__tests__/templateTransformAssetUrl.spec.ts
@@ -1,4 +1,9 @@
-import { generate, baseParse, transform } from '@vue/compiler-core'
+import {
+  generate,
+  baseParse,
+  transform,
+  TransformOptions
+} from '@vue/compiler-core'
 import {
   transformAssetUrl,
   createAssetUrlTransformWithOptions,
@@ -7,8 +12,13 @@ import {
 } from '../src/templateTransformAssetUrl'
 import { transformElement } from '../../compiler-core/src/transforms/transformElement'
 import { transformBind } from '../../compiler-core/src/transforms/vBind'
+import { stringifyStatic } from '../../compiler-dom/src/transforms/stringifyStatic'
 
-function compileWithAssetUrls(template: string, options?: AssetURLOptions) {
+function compileWithAssetUrls(
+  template: string,
+  options?: AssetURLOptions,
+  transformOptions?: TransformOptions
+) {
   const ast = baseParse(template)
   const t = options
     ? createAssetUrlTransformWithOptions(normalizeOptions(options))
@@ -17,7 +27,8 @@ function compileWithAssetUrls(template: string, options?: AssetURLOptions) {
     nodeTransforms: [t, transformElement],
     directiveTransforms: {
       bind: transformBind
-    }
+    },
+    ...transformOptions
   })
   return generate(ast, { mode: 'module' })
 }
@@ -131,4 +142,25 @@ describe('compiler sfc: transform asset url', () => {
 
     expect(code).toMatchSnapshot()
   })
+
+  test('transform with stringify', () => {
+    const { code } = compileWithAssetUrls(
+      `<div>` +
+        `<img src="./bar.png"/>` +
+        `<img src="/bar.png"/>` +
+        `<img src="https://foo.bar/baz.png"/>` +
+        `<img src="//foo.bar/baz.png"/>` +
+        `<img src="./bar.png"/>` +
+        `</div>`,
+      {
+        includeAbsolute: true
+      },
+      {
+        hoistStatic: true,
+        transformHoist: stringifyStatic
+      }
+    )
+    expect(code).toMatch(`_createStaticVNode`)
+    expect(code).toMatchSnapshot()
+  })
 })
diff --git a/packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts b/packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts
index 465ca5670..f88706c42 100644
--- a/packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts
+++ b/packages/compiler-sfc/__tests__/templateTransformSrcset.spec.ts
@@ -1,4 +1,9 @@
-import { generate, baseParse, transform } from '@vue/compiler-core'
+import {
+  generate,
+  baseParse,
+  transform,
+  TransformOptions
+} from '@vue/compiler-core'
 import {
   transformSrcset,
   createSrcsetTransformWithOptions
@@ -9,8 +14,13 @@ import {
   AssetURLOptions,
   normalizeOptions
 } from '../src/templateTransformAssetUrl'
+import { stringifyStatic } from '../../compiler-dom/src/transforms/stringifyStatic'
 
-function compileWithSrcset(template: string, options?: AssetURLOptions) {
+function compileWithSrcset(
+  template: string,
+  options?: AssetURLOptions,
+  transformOptions?: TransformOptions
+) {
   const ast = baseParse(template)
   const srcsetTransform = options
     ? createSrcsetTransformWithOptions(normalizeOptions(options))
@@ -19,7 +29,8 @@ function compileWithSrcset(template: string, options?: AssetURLOptions) {
     nodeTransforms: [srcsetTransform, transformElement],
     directiveTransforms: {
       bind: transformBind
-    }
+    },
+    ...transformOptions
   })
   return generate(ast, { mode: 'module' })
 }
@@ -59,4 +70,19 @@ describe('compiler sfc: transform srcset', () => {
       }).code
     ).toMatchSnapshot()
   })
+
+  test('transform srcset w/ stringify', () => {
+    const code = compileWithSrcset(
+      `<div>${src}</div>`,
+      {
+        includeAbsolute: true
+      },
+      {
+        hoistStatic: true,
+        transformHoist: stringifyStatic
+      }
+    ).code
+    expect(code).toMatch(`_createStaticVNode`)
+    expect(code).toMatchSnapshot()
+  })
 })
diff --git a/packages/runtime-core/__tests__/componentProps.spec.ts b/packages/runtime-core/__tests__/componentProps.spec.ts
index 863b12d06..40725dfcd 100644
--- a/packages/runtime-core/__tests__/componentProps.spec.ts
+++ b/packages/runtime-core/__tests__/componentProps.spec.ts
@@ -573,4 +573,26 @@ describe('component props', () => {
       render(h(Comp, { foo: null }), root)
     }).not.toThrow()
   })
+
+  // #5016
+  test('handling attr with undefined value', () => {
+    const Comp = {
+      render(this: any) {
+        return JSON.stringify(this.$attrs) + Object.keys(this.$attrs)
+      }
+    }
+    const root = nodeOps.createElement('div')
+
+    let attrs: any = { foo: undefined }
+
+    render(h(Comp, attrs), root)
+    expect(serializeInner(root)).toBe(
+      JSON.stringify(attrs) + Object.keys(attrs)
+    )
+
+    render(h(Comp, (attrs = { foo: 'bar' })), root)
+    expect(serializeInner(root)).toBe(
+      JSON.stringify(attrs) + Object.keys(attrs)
+    )
+  })
 })
diff --git a/packages/vue-compat/__tests__/componentFunctional.spec.ts b/packages/vue-compat/__tests__/componentFunctional.spec.ts
index 82587cbf8..e17534ce2 100644
--- a/packages/vue-compat/__tests__/componentFunctional.spec.ts
+++ b/packages/vue-compat/__tests__/componentFunctional.spec.ts
@@ -18,45 +18,80 @@ afterEach(() => {
   Vue.configureCompat({ MODE: 3 })
 })
 
-test('COMPONENT_FUNCTIONAL', async () => {
-  const func = {
-    name: 'Func',
-    functional: true,
-    props: {
-      x: String
-    },
-    inject: ['foo'],
-    render: (h: any, { data, props, injections, slots }: any) => {
-      return h('div', { id: props.x, class: data.class }, [
-        h('div', { class: 'inject' }, injections.foo),
-        h('div', { class: 'slot' }, slots().default)
-      ])
+describe('COMPONENT_FUNCTIONAL', () => {
+  test('basic usage', async () => {
+    const func = {
+      name: 'Func',
+      functional: true,
+      props: {
+        x: String
+      },
+      inject: ['foo'],
+      render: (h: any, { data, props, injections, slots }: any) => {
+        return h('div', { id: props.x, class: data.class }, [
+          h('div', { class: 'inject' }, injections.foo),
+          h('div', { class: 'slot' }, slots().default)
+        ])
+      }
     }
-  }
 
-  const vm = new Vue({
-    provide() {
-      return {
-        foo: 123
+    const vm = new Vue({
+      provide() {
+        return {
+          foo: 123
+        }
+      },
+      components: {
+        func
+      },
+      template: `<func class="foo" x="foo">hello</func>`
+    }).$mount()
+
+    expect(vm.$el.id).toBe('foo')
+    expect(vm.$el.className).toBe('foo')
+    expect(vm.$el.querySelector('.inject').textContent).toBe('123')
+    expect(vm.$el.querySelector('.slot').textContent).toBe('hello')
+    expect(vm.$el.outerHTML).toMatchInlineSnapshot(
+      `"<div id=\\"foo\\" class=\\"foo\\"><div class=\\"inject\\">123</div><div class=\\"slot\\">hello</div></div>"`
+    )
+
+    expect(
+      (
+        deprecationData[DeprecationTypes.COMPONENT_FUNCTIONAL]
+          .message as Function
+      )(func)
+    ).toHaveBeenWarned()
+  })
+
+  test('copies compatConfig option', () => {
+    const func = {
+      name: 'Func',
+      functional: true,
+      compatConfig: {
+        ATTR_FALSE_VALUE: 'suppress-warning' as const
+      },
+      render: (h: any) => {
+        // should not render required: false due to compatConfig
+        return h('div', { 'data-some-attr': false })
       }
-    },
-    components: {
-      func
-    },
-    template: `<func class="foo" x="foo">hello</func>`
-  }).$mount()
-
-  expect(vm.$el.id).toBe('foo')
-  expect(vm.$el.className).toBe('foo')
-  expect(vm.$el.querySelector('.inject').textContent).toBe('123')
-  expect(vm.$el.querySelector('.slot').textContent).toBe('hello')
-  expect(vm.$el.outerHTML).toMatchInlineSnapshot(
-    `"<div id=\\"foo\\" class=\\"foo\\"><div class=\\"inject\\">123</div><div class=\\"slot\\">hello</div></div>"`
-  )
-
-  expect(
-    (
-      deprecationData[DeprecationTypes.COMPONENT_FUNCTIONAL].message as Function
-    )(func)
-  ).toHaveBeenWarned()
+    }
+
+    const vm = new Vue({
+      components: { func },
+      template: `<func class="foo" x="foo">hello</func>`
+    }).$mount()
+
+    expect(vm.$el.outerHTML).toMatchInlineSnapshot(`"<div></div>"`)
+    expect(
+      (
+        deprecationData[DeprecationTypes.COMPONENT_FUNCTIONAL]
+          .message as Function
+      )(func)
+    ).toHaveBeenWarned()
+    expect(
+      (deprecationData[DeprecationTypes.ATTR_FALSE_VALUE].message as Function)(
+        func
+      )
+    ).not.toHaveBeenWarned()
+  })
 })