refactor(compiler): better constant hoist/stringify checks
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ConstantTypes,
|
||||
RootNode,
|
||||
NodeTypes,
|
||||
TemplateChildNode,
|
||||
@@ -37,16 +38,10 @@ export function isSingleElementRoot(
|
||||
)
|
||||
}
|
||||
|
||||
const enum StaticType {
|
||||
NOT_STATIC = 0,
|
||||
FULL_STATIC,
|
||||
HAS_RUNTIME_CONSTANT
|
||||
}
|
||||
|
||||
function walk(
|
||||
node: ParentNode,
|
||||
context: TransformContext,
|
||||
resultCache: Map<TemplateChildNode, StaticType>,
|
||||
resultCache: Map<TemplateChildNode, ConstantTypes>,
|
||||
doNotHoistNode: boolean = false
|
||||
) {
|
||||
let hasHoistedNode = false
|
||||
@@ -58,7 +53,7 @@ function walk(
|
||||
// @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
|
||||
// stringficiation threshold is met.
|
||||
let hasRuntimeConstant = false
|
||||
let canStringify = true
|
||||
|
||||
const { children } = node
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
@@ -68,20 +63,20 @@ function walk(
|
||||
child.type === NodeTypes.ELEMENT &&
|
||||
child.tagType === ElementTypes.ELEMENT
|
||||
) {
|
||||
let staticType
|
||||
if (
|
||||
!doNotHoistNode &&
|
||||
(staticType = getStaticType(child, resultCache)) > 0
|
||||
) {
|
||||
if (staticType === StaticType.HAS_RUNTIME_CONSTANT) {
|
||||
hasRuntimeConstant = true
|
||||
const constantType = doNotHoistNode
|
||||
? ConstantTypes.NOT_CONSTANT
|
||||
: getConstantType(child, resultCache)
|
||||
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 */` : ``)
|
||||
child.codegenNode = context.hoist(child.codegenNode!)
|
||||
hasHoistedNode = true
|
||||
continue
|
||||
}
|
||||
// whole tree is static
|
||||
;(child.codegenNode as VNodeCall).patchFlag =
|
||||
PatchFlags.HOISTED + (__DEV__ ? ` /* HOISTED */` : ``)
|
||||
child.codegenNode = context.hoist(child.codegenNode!)
|
||||
hasHoistedNode = true
|
||||
continue
|
||||
} else {
|
||||
// node may contain dynamic children, but its props may be eligible for
|
||||
// hoisting.
|
||||
@@ -92,7 +87,8 @@ function walk(
|
||||
(!flag ||
|
||||
flag === PatchFlags.NEED_PATCH ||
|
||||
flag === PatchFlags.TEXT) &&
|
||||
!hasNonHoistableProps(child)
|
||||
getGeneratedPropsConstantType(child, resultCache) >=
|
||||
ConstantTypes.CAN_HOIST
|
||||
) {
|
||||
const props = getNodeProps(child)
|
||||
if (props) {
|
||||
@@ -102,13 +98,15 @@ function walk(
|
||||
}
|
||||
}
|
||||
} else if (child.type === NodeTypes.TEXT_CALL) {
|
||||
const staticType = getStaticType(child.content, resultCache)
|
||||
if (staticType > 0) {
|
||||
if (staticType === StaticType.HAS_RUNTIME_CONSTANT) {
|
||||
hasRuntimeConstant = true
|
||||
const contentType = getConstantType(child.content, resultCache)
|
||||
if (contentType > 0) {
|
||||
if (contentType < ConstantTypes.CAN_STRINGIFY) {
|
||||
canStringify = false
|
||||
}
|
||||
if (contentType >= ConstantTypes.CAN_HOIST) {
|
||||
child.codegenNode = context.hoist(child.codegenNode)
|
||||
hasHoistedNode = true
|
||||
}
|
||||
child.codegenNode = context.hoist(child.codegenNode)
|
||||
hasHoistedNode = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,19 +129,19 @@ function walk(
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRuntimeConstant && hasHoistedNode && context.transformHoist) {
|
||||
if (canStringify && hasHoistedNode && context.transformHoist) {
|
||||
context.transformHoist(children, context, node)
|
||||
}
|
||||
}
|
||||
|
||||
export function getStaticType(
|
||||
export function getConstantType(
|
||||
node: TemplateChildNode | SimpleExpressionNode,
|
||||
resultCache: Map<TemplateChildNode, StaticType> = new Map()
|
||||
): StaticType {
|
||||
resultCache: Map<TemplateChildNode, ConstantTypes> = new Map()
|
||||
): ConstantTypes {
|
||||
switch (node.type) {
|
||||
case NodeTypes.ELEMENT:
|
||||
if (node.tagType !== ElementTypes.ELEMENT) {
|
||||
return StaticType.NOT_STATIC
|
||||
return ConstantTypes.NOT_CONSTANT
|
||||
}
|
||||
const cached = resultCache.get(node)
|
||||
if (cached !== undefined) {
|
||||
@@ -151,40 +149,64 @@ export function getStaticType(
|
||||
}
|
||||
const codegenNode = node.codegenNode!
|
||||
if (codegenNode.type !== NodeTypes.VNODE_CALL) {
|
||||
return StaticType.NOT_STATIC
|
||||
return ConstantTypes.NOT_CONSTANT
|
||||
}
|
||||
const flag = getPatchFlag(codegenNode)
|
||||
if (!flag && !hasNonHoistableProps(node)) {
|
||||
// element self is static. check its children.
|
||||
let returnType = StaticType.FULL_STATIC
|
||||
if (!flag) {
|
||||
let returnType = ConstantTypes.CAN_STRINGIFY
|
||||
|
||||
// Element itself has no patch flag. However we still need to check:
|
||||
|
||||
// 1. Even for a node with no patch flag, it is possible for it to contain
|
||||
// non-hoistable expressions that refers to scope variables, e.g. compiler
|
||||
// injected keys or cached event handlers. Therefore we need to always
|
||||
// check the codegenNode's props to be sure.
|
||||
const generatedPropsType = getGeneratedPropsConstantType(
|
||||
node,
|
||||
resultCache
|
||||
)
|
||||
if (generatedPropsType === ConstantTypes.NOT_CONSTANT) {
|
||||
resultCache.set(node, ConstantTypes.NOT_CONSTANT)
|
||||
return ConstantTypes.NOT_CONSTANT
|
||||
}
|
||||
if (generatedPropsType < returnType) {
|
||||
returnType = generatedPropsType
|
||||
}
|
||||
|
||||
// 2. its children.
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const childType = getStaticType(node.children[i], resultCache)
|
||||
if (childType === StaticType.NOT_STATIC) {
|
||||
resultCache.set(node, StaticType.NOT_STATIC)
|
||||
return StaticType.NOT_STATIC
|
||||
} else if (childType === StaticType.HAS_RUNTIME_CONSTANT) {
|
||||
returnType = StaticType.HAS_RUNTIME_CONSTANT
|
||||
const childType = getConstantType(node.children[i], resultCache)
|
||||
if (childType === ConstantTypes.NOT_CONSTANT) {
|
||||
resultCache.set(node, ConstantTypes.NOT_CONSTANT)
|
||||
return ConstantTypes.NOT_CONSTANT
|
||||
}
|
||||
if (childType < returnType) {
|
||||
returnType = childType
|
||||
}
|
||||
}
|
||||
|
||||
// check if any of the props contain runtime constants
|
||||
if (returnType !== StaticType.HAS_RUNTIME_CONSTANT) {
|
||||
// 3. if the type is not already CAN_SKIP_PATCH which is the lowest non-0
|
||||
// type, check if any of the props can cause the type to be lowered
|
||||
// we can skip can_patch because it's guaranteed by the absence of a
|
||||
// patchFlag.
|
||||
if (returnType > ConstantTypes.CAN_SKIP_PATCH) {
|
||||
for (let i = 0; i < node.props.length; i++) {
|
||||
const p = node.props[i]
|
||||
if (
|
||||
p.type === NodeTypes.DIRECTIVE &&
|
||||
p.name === 'bind' &&
|
||||
p.exp &&
|
||||
(p.exp.type === NodeTypes.COMPOUND_EXPRESSION ||
|
||||
p.exp.isRuntimeConstant)
|
||||
) {
|
||||
returnType = StaticType.HAS_RUNTIME_CONSTANT
|
||||
if (p.type === NodeTypes.DIRECTIVE && p.name === 'bind' && p.exp) {
|
||||
const expType = getConstantType(p.exp, resultCache)
|
||||
if (expType === ConstantTypes.NOT_CONSTANT) {
|
||||
resultCache.set(node, ConstantTypes.NOT_CONSTANT)
|
||||
return ConstantTypes.NOT_CONSTANT
|
||||
}
|
||||
if (expType < returnType) {
|
||||
returnType = expType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// only svg/foreignObject could be block here, however if they are
|
||||
// stati then they don't need to be blocks since there will be no
|
||||
// static then they don't need to be blocks since there will be no
|
||||
// nested updates.
|
||||
if (codegenNode.isBlock) {
|
||||
codegenNode.isBlock = false
|
||||
@@ -193,37 +215,33 @@ export function getStaticType(
|
||||
resultCache.set(node, returnType)
|
||||
return returnType
|
||||
} else {
|
||||
resultCache.set(node, StaticType.NOT_STATIC)
|
||||
return StaticType.NOT_STATIC
|
||||
resultCache.set(node, ConstantTypes.NOT_CONSTANT)
|
||||
return ConstantTypes.NOT_CONSTANT
|
||||
}
|
||||
case NodeTypes.TEXT:
|
||||
case NodeTypes.COMMENT:
|
||||
return StaticType.FULL_STATIC
|
||||
return ConstantTypes.CAN_STRINGIFY
|
||||
case NodeTypes.IF:
|
||||
case NodeTypes.FOR:
|
||||
case NodeTypes.IF_BRANCH:
|
||||
return StaticType.NOT_STATIC
|
||||
return ConstantTypes.NOT_CONSTANT
|
||||
case NodeTypes.INTERPOLATION:
|
||||
case NodeTypes.TEXT_CALL:
|
||||
return getStaticType(node.content, resultCache)
|
||||
return getConstantType(node.content, resultCache)
|
||||
case NodeTypes.SIMPLE_EXPRESSION:
|
||||
return node.isRuntimeConstant
|
||||
? StaticType.HAS_RUNTIME_CONSTANT
|
||||
: node.isConstant
|
||||
? StaticType.FULL_STATIC
|
||||
: StaticType.NOT_STATIC
|
||||
return node.constType
|
||||
case NodeTypes.COMPOUND_EXPRESSION:
|
||||
let returnType = StaticType.FULL_STATIC
|
||||
let returnType = ConstantTypes.CAN_STRINGIFY
|
||||
for (let i = 0; i < node.children.length; i++) {
|
||||
const child = node.children[i]
|
||||
if (isString(child) || isSymbol(child)) {
|
||||
continue
|
||||
}
|
||||
const childType = getStaticType(child, resultCache)
|
||||
if (childType === StaticType.NOT_STATIC) {
|
||||
return StaticType.NOT_STATIC
|
||||
} else if (childType === StaticType.HAS_RUNTIME_CONSTANT) {
|
||||
returnType = StaticType.HAS_RUNTIME_CONSTANT
|
||||
const childType = getConstantType(child, resultCache)
|
||||
if (childType === ConstantTypes.NOT_CONSTANT) {
|
||||
return ConstantTypes.NOT_CONSTANT
|
||||
} else if (childType < returnType) {
|
||||
returnType = childType
|
||||
}
|
||||
}
|
||||
return returnType
|
||||
@@ -232,33 +250,40 @@ export function getStaticType(
|
||||
const exhaustiveCheck: never = node
|
||||
exhaustiveCheck
|
||||
}
|
||||
return StaticType.NOT_STATIC
|
||||
return ConstantTypes.NOT_CONSTANT
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Even for a node with no patch flag, it is possible for it to contain
|
||||
* non-hoistable expressions that refers to scope variables, e.g. compiler
|
||||
* injected keys or cached event handlers. Therefore we need to always check the
|
||||
* codegenNode's props to be sure.
|
||||
*/
|
||||
function hasNonHoistableProps(node: PlainElementNode): boolean {
|
||||
function getGeneratedPropsConstantType(
|
||||
node: PlainElementNode,
|
||||
resultCache: Map<TemplateChildNode, ConstantTypes>
|
||||
): ConstantTypes {
|
||||
let returnType = ConstantTypes.CAN_STRINGIFY
|
||||
const props = getNodeProps(node)
|
||||
if (props && props.type === NodeTypes.JS_OBJECT_EXPRESSION) {
|
||||
const { properties } = props
|
||||
for (let i = 0; i < properties.length; i++) {
|
||||
const { key, value } = properties[i]
|
||||
if (
|
||||
key.type !== NodeTypes.SIMPLE_EXPRESSION ||
|
||||
!key.isStatic ||
|
||||
(value.type !== NodeTypes.SIMPLE_EXPRESSION ||
|
||||
(!value.isStatic && !value.isConstant))
|
||||
) {
|
||||
return true
|
||||
const keyType = getConstantType(key, resultCache)
|
||||
if (keyType === ConstantTypes.NOT_CONSTANT) {
|
||||
return keyType
|
||||
}
|
||||
if (keyType < returnType) {
|
||||
returnType = keyType
|
||||
}
|
||||
if (value.type !== NodeTypes.SIMPLE_EXPRESSION) {
|
||||
return ConstantTypes.NOT_CONSTANT
|
||||
}
|
||||
const valueType = getConstantType(value, resultCache)
|
||||
if (valueType === ConstantTypes.NOT_CONSTANT) {
|
||||
return valueType
|
||||
}
|
||||
if (valueType < returnType) {
|
||||
returnType = valueType
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return returnType
|
||||
}
|
||||
|
||||
function getNodeProps(node: PlainElementNode) {
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
VNodeCall,
|
||||
TemplateTextChildNode,
|
||||
DirectiveArguments,
|
||||
createVNodeCall
|
||||
createVNodeCall,
|
||||
ConstantTypes
|
||||
} from '../ast'
|
||||
import {
|
||||
PatchFlags,
|
||||
@@ -53,7 +54,7 @@ import {
|
||||
isStaticExp
|
||||
} from '../utils'
|
||||
import { buildSlots } from './vSlot'
|
||||
import { getStaticType } from './hoistStatic'
|
||||
import { getConstantType } from './hoistStatic'
|
||||
import { BindingTypes } from '../options'
|
||||
|
||||
// some directive transforms (e.g. v-model) may return a symbol for runtime
|
||||
@@ -166,7 +167,10 @@ export const transformElement: NodeTransform = (node, context) => {
|
||||
const hasDynamicTextChild =
|
||||
type === NodeTypes.INTERPOLATION ||
|
||||
type === NodeTypes.COMPOUND_EXPRESSION
|
||||
if (hasDynamicTextChild && !getStaticType(child)) {
|
||||
if (
|
||||
hasDynamicTextChild &&
|
||||
getConstantType(child) === ConstantTypes.NOT_CONSTANT
|
||||
) {
|
||||
patchFlag |= PatchFlags.TEXT
|
||||
}
|
||||
// pass directly if the only child is a text node
|
||||
@@ -343,7 +347,7 @@ export function buildProps(
|
||||
value.type === NodeTypes.JS_CACHE_EXPRESSION ||
|
||||
((value.type === NodeTypes.SIMPLE_EXPRESSION ||
|
||||
value.type === NodeTypes.COMPOUND_EXPRESSION) &&
|
||||
getStaticType(value) > 0)
|
||||
getConstantType(value) > 0)
|
||||
) {
|
||||
// skip if the prop is a cached handler or has constant value
|
||||
return
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
ExpressionNode,
|
||||
SimpleExpressionNode,
|
||||
CompoundExpressionNode,
|
||||
createCompoundExpression
|
||||
createCompoundExpression,
|
||||
ConstantTypes
|
||||
} from '../ast'
|
||||
import { advancePositionWithClone, isSimpleIdentifier } from '../utils'
|
||||
import {
|
||||
@@ -190,25 +191,26 @@ export function processExpression(
|
||||
|
||||
// fast path if expression is a simple identifier.
|
||||
const rawExp = node.content
|
||||
// bail on parens to prevent any possible function invocations.
|
||||
const bailConstant = rawExp.indexOf(`(`) > -1
|
||||
// bail constant on parens (function invocation) and dot (member access)
|
||||
const bailConstant = rawExp.indexOf(`(`) > -1 || rawExp.indexOf('.') > 0
|
||||
|
||||
if (isSimpleIdentifier(rawExp)) {
|
||||
// const bindings exposed from setup - we know they never change
|
||||
// marking it as runtime constant will prevent it from being listed as
|
||||
// a dynamic prop.
|
||||
if (bindingMetadata[node.content] === BindingTypes.SETUP_CONST) {
|
||||
node.isRuntimeConstant = true
|
||||
}
|
||||
if (
|
||||
!asParams &&
|
||||
!context.identifiers[rawExp] &&
|
||||
!isGloballyWhitelisted(rawExp) &&
|
||||
!isLiteralWhitelisted(rawExp)
|
||||
) {
|
||||
const isScopeVarReference = context.identifiers[rawExp]
|
||||
const isAllowedGlobal = isGloballyWhitelisted(rawExp)
|
||||
const isLiteral = isLiteralWhitelisted(rawExp)
|
||||
if (!asParams && !isScopeVarReference && !isAllowedGlobal && !isLiteral) {
|
||||
// const bindings exposed from setup can be skipped for patching but
|
||||
// cannot be hoisted to module scope
|
||||
if (bindingMetadata[node.content] === BindingTypes.SETUP_CONST) {
|
||||
node.constType = ConstantTypes.CAN_SKIP_PATCH
|
||||
}
|
||||
node.content = rewriteIdentifier(rawExp)
|
||||
} else if (!context.identifiers[rawExp] && !bailConstant) {
|
||||
// mark node constant for hoisting unless it's referring a scope variable
|
||||
node.isConstant = true
|
||||
} else if (!isScopeVarReference) {
|
||||
if (isLiteral) {
|
||||
node.constType = ConstantTypes.CAN_STRINGIFY
|
||||
} else {
|
||||
node.constType = ConstantTypes.CAN_HOIST
|
||||
}
|
||||
}
|
||||
return node
|
||||
}
|
||||
@@ -342,7 +344,7 @@ export function processExpression(
|
||||
start: advancePositionWithClone(node.loc.start, source, start),
|
||||
end: advancePositionWithClone(node.loc.start, source, end)
|
||||
},
|
||||
id.isConstant /* isConstant */
|
||||
id.isConstant ? ConstantTypes.CAN_STRINGIFY : ConstantTypes.NOT_CONSTANT
|
||||
)
|
||||
)
|
||||
if (i === ids.length - 1 && end < rawExp.length) {
|
||||
@@ -355,7 +357,9 @@ export function processExpression(
|
||||
ret = createCompoundExpression(children, node.loc)
|
||||
} else {
|
||||
ret = node
|
||||
ret.isConstant = !bailConstant
|
||||
ret.constType = bailConstant
|
||||
? ConstantTypes.NOT_CONSTANT
|
||||
: ConstantTypes.CAN_STRINGIFY
|
||||
}
|
||||
ret.identifiers = Object.keys(knownIds)
|
||||
return ret
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
CompoundExpressionNode,
|
||||
createCallExpression,
|
||||
CallExpression,
|
||||
ElementTypes
|
||||
ElementTypes,
|
||||
ConstantTypes
|
||||
} from '../ast'
|
||||
import { isText } from '../utils'
|
||||
import { CREATE_TEXT } from '../runtimeHelpers'
|
||||
import { PatchFlags, PatchFlagNames } from '@vue/shared'
|
||||
import { getConstantType } from './hoistStatic'
|
||||
|
||||
// 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.
|
||||
@@ -78,7 +80,10 @@ export const transformText: NodeTransform = (node, context) => {
|
||||
callArgs.push(child)
|
||||
}
|
||||
// mark dynamic text with flag so it gets patched inside a block
|
||||
if (!context.ssr && child.type !== NodeTypes.TEXT) {
|
||||
if (
|
||||
!context.ssr &&
|
||||
getConstantType(child) === ConstantTypes.NOT_CONSTANT
|
||||
) {
|
||||
callArgs.push(
|
||||
`${PatchFlags.TEXT} /* ${PatchFlagNames[PatchFlags.TEXT]} */`
|
||||
)
|
||||
|
||||
@@ -77,7 +77,7 @@ export const transformFor = createStructuralDirectiveTransform(
|
||||
|
||||
const isStableFragment =
|
||||
forNode.source.type === NodeTypes.SIMPLE_EXPRESSION &&
|
||||
forNode.source.isConstant
|
||||
forNode.source.constType > 0
|
||||
const fragmentFlag = isStableFragment
|
||||
? PatchFlags.STABLE_FRAGMENT
|
||||
: keyProp
|
||||
|
||||
@@ -21,7 +21,8 @@ import {
|
||||
createVNodeCall,
|
||||
AttributeNode,
|
||||
locStub,
|
||||
CacheExpression
|
||||
CacheExpression,
|
||||
ConstantTypes
|
||||
} from '../ast'
|
||||
import { createCompilerError, ErrorCodes } from '../errors'
|
||||
import { processExpression } from './transformExpression'
|
||||
@@ -227,7 +228,12 @@ function createChildrenCodegenNode(
|
||||
const { helper } = context
|
||||
const keyProperty = createObjectProperty(
|
||||
`key`,
|
||||
createSimpleExpression(`${keyIndex}`, false, locStub, true)
|
||||
createSimpleExpression(
|
||||
`${keyIndex}`,
|
||||
false,
|
||||
locStub,
|
||||
ConstantTypes.CAN_HOIST
|
||||
)
|
||||
)
|
||||
const { children } = branch
|
||||
const firstChild = children[0]
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
NodeTypes,
|
||||
Property,
|
||||
ElementTypes,
|
||||
ExpressionNode
|
||||
ExpressionNode,
|
||||
ConstantTypes
|
||||
} from '../ast'
|
||||
import { createCompilerError, ErrorCodes } from '../errors'
|
||||
import {
|
||||
@@ -125,7 +126,12 @@ export const transformModel: DirectiveTransform = (dir, node, context) => {
|
||||
props.push(
|
||||
createObjectProperty(
|
||||
modifiersKey,
|
||||
createSimpleExpression(`{ ${modifiers} }`, false, dir.loc, true)
|
||||
createSimpleExpression(
|
||||
`{ ${modifiers} }`,
|
||||
false,
|
||||
dir.loc,
|
||||
ConstantTypes.CAN_HOIST
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export const transformOn: DirectiveTransform = (
|
||||
context.cacheHandlers &&
|
||||
// runtime constants don't need to be cached
|
||||
// (this is analyzed by compileScript in SFC <script setup>)
|
||||
!(exp.type === NodeTypes.SIMPLE_EXPRESSION && exp.isRuntimeConstant) &&
|
||||
!(exp.type === NodeTypes.SIMPLE_EXPRESSION && exp.constType > 0) &&
|
||||
// #1541 bail if this is a member exp handler passed to a component -
|
||||
// we need to use the original function to preserve arity,
|
||||
// e.g. <transition> relies on checking cb.length to determine
|
||||
|
||||
Reference in New Issue
Block a user