diff --git a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap index cb14f56afdb..c87a37ba5d2 100644 --- a/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap +++ b/packages/compiler-vapor/__tests__/transforms/__snapshots__/vFor.spec.ts.snap @@ -47,6 +47,27 @@ export function render(_ctx) { }" `; +exports[`compiler: v-for > key only binding pattern 1`] = ` +"import { child as _child, toDisplayString as _toDisplayString, setText as _setText, createFor as _createFor, template as _template } from 'vue'; +const t0 = _template(" ", true) + +export function render(_ctx) { + const n0 = _createFor(() => (_ctx.rows), (_for_item0) => { + const n2 = t0() + const x2 = _child(n2) + let _row, _row_id + { + _row = _for_item0.value + _row_id = _row.id + + } + _setText(x2, _toDisplayString(_row_id + _row_id)) + return n2 + }, (row) => (row.id)) + return n0 +}" +`; + exports[`compiler: v-for > multi effect 1`] = ` "import { setProp as _setProp, renderEffect as _renderEffect, createFor as _createFor, template as _template } from 'vue'; const t0 = _template("
", true) @@ -115,6 +136,75 @@ export function render(_ctx) { }" `; +exports[`compiler: v-for > selector pattern 1`] = ` +"import { child as _child, toDisplayString as _toDisplayString, setText as _setText, createFor as _createFor, template as _template } from 'vue'; +const t0 = _template(" ", true) + +export function render(_ctx) { + const n0 = _createFor(() => (_ctx.rows), (_for_item0) => { + const n2 = t0() + const x2 = _child(n2) + _selector0_0(() => { + _setText(x2, _toDisplayString(_ctx.selected === _for_item0.value.id ? 'danger' : '')) + }) + return n2 + }, (row) => (row.id)) + const _selector0_0 = n0.useSelector(() => _ctx.selected) + return n0 +}" +`; + +exports[`compiler: v-for > selector pattern 2`] = ` +"import { setClass as _setClass, createFor as _createFor, template as _template } from 'vue'; +const t0 = _template("", true) + +export function render(_ctx) { + const n0 = _createFor(() => (_ctx.rows), (_for_item0) => { + const n2 = t0() + _selector0_0(() => { + _setClass(n2, _ctx.selected === _for_item0.value.id ? 'danger' : '') + }) + return n2 + }, (row) => (row.id)) + const _selector0_0 = n0.useSelector(() => _ctx.selected) + return n0 +}" +`; + +exports[`compiler: v-for > selector pattern 3`] = ` +"import { setClass as _setClass, renderEffect as _renderEffect, createFor as _createFor, template as _template } from 'vue'; +const t0 = _template("", true) + +export function render(_ctx) { + const n0 = _createFor(() => (_ctx.rows), (_for_item0) => { + const n2 = t0() + _renderEffect(() => { + const _row = _for_item0.value + _setClass(n2, _row.label === _row.id ? 'danger' : '') + }) + return n2 + }, (row) => (row.id)) + return n0 +}" +`; + +exports[`compiler: v-for > selector pattern 4`] = ` +"import { setClass as _setClass, createFor as _createFor, template as _template } from 'vue'; +const t0 = _template("", true) + +export function render(_ctx) { + const n0 = _createFor(() => (_ctx.rows), (_for_item0) => { + const n2 = t0() + _selector0_0(() => { + _setClass(n2, { danger: _for_item0.value.id === _ctx.selected }) + }) + return n2 + }, (row) => (row.id)) + const _selector0_0 = n0.useSelector(() => _ctx.selected) + return n0 +}" +`; + exports[`compiler: v-for > v-for aliases w/ complex expressions 1`] = ` "import { getDefaultValue as _getDefaultValue, child as _child, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, createFor as _createFor, template as _template } from 'vue'; const t0 = _template("
", true) diff --git a/packages/compiler-vapor/__tests__/transforms/vFor.spec.ts b/packages/compiler-vapor/__tests__/transforms/vFor.spec.ts index 0008df7f4c7..91b8526b329 100644 --- a/packages/compiler-vapor/__tests__/transforms/vFor.spec.ts +++ b/packages/compiler-vapor/__tests__/transforms/vFor.spec.ts @@ -67,6 +67,73 @@ describe('compiler: v-for', () => { ).lengthOf(1) }) + test('key only binding pattern', () => { + expect( + compileWithVFor( + ` + + {{ row.id + row.id }} + + `, + ).code, + ).matchSnapshot() + }) + + test('selector pattern', () => { + expect( + compileWithVFor( + ` + + {{ selected === row.id ? 'danger' : '' }} + + `, + ).code, + ).matchSnapshot() + + expect( + compileWithVFor( + ` + + `, + ).code, + ).matchSnapshot() + + // Should not be optimized because row.label is not from parent scope + expect( + compileWithVFor( + ` + + `, + ).code, + ).matchSnapshot() + + expect( + compileWithVFor( + ` + + `, + ).code, + ).matchSnapshot() + }) + test('multi effect', () => { const { code } = compileWithVFor( `
`, diff --git a/packages/compiler-vapor/src/generators/block.ts b/packages/compiler-vapor/src/generators/block.ts index b161b8f45d1..66b57c58378 100644 --- a/packages/compiler-vapor/src/generators/block.ts +++ b/packages/compiler-vapor/src/generators/block.ts @@ -19,14 +19,13 @@ export function genBlock( context: CodegenContext, args: CodeFragment[] = [], root?: boolean, - customReturns?: (returns: CodeFragment[]) => CodeFragment[], ): CodeFragment[] { return [ '(', ...args, ') => {', INDENT_START, - ...genBlockContent(oper, context, root, customReturns), + ...genBlockContent(oper, context, root), INDENT_END, NEWLINE, '}', @@ -37,7 +36,7 @@ export function genBlockContent( block: BlockIRNode, context: CodegenContext, root?: boolean, - customReturns?: (returns: CodeFragment[]) => CodeFragment[], + genEffectsExtraFrag?: () => CodeFragment[], ): CodeFragment[] { const [frag, push] = buildCodeFragment() const { dynamic, effect, operation, returns } = block @@ -56,7 +55,7 @@ export function genBlockContent( } push(...genOperations(operation, context)) - push(...genEffects(effect, context)) + push(...genEffects(effect, context, genEffectsExtraFrag)) push(NEWLINE, `return `) @@ -65,7 +64,7 @@ export function genBlockContent( returnNodes.length > 1 ? genMulti(DELIMITERS_ARRAY, ...returnNodes) : [returnNodes[0] || 'null'] - push(...(customReturns ? customReturns(returnsCode) : returnsCode)) + push(...returnsCode) resetBlock() return frag diff --git a/packages/compiler-vapor/src/generators/expression.ts b/packages/compiler-vapor/src/generators/expression.ts index eedaeeb380a..ba8b9941562 100644 --- a/packages/compiler-vapor/src/generators/expression.ts +++ b/packages/compiler-vapor/src/generators/expression.ts @@ -230,6 +230,7 @@ function canPrefix(name: string) { type DeclarationResult = { ids: Record frag: CodeFragment[] + varNames: string[] } type DeclarationValue = { name: string @@ -243,6 +244,7 @@ type DeclarationValue = { export function processExpressions( context: CodegenContext, expressions: SimpleExpressionNode[], + shouldDeclareConst: boolean, ): DeclarationResult { // analyze variables const { seenVariable, variableToExpMap, expToVariableMap, seenIdentifier } = @@ -266,7 +268,11 @@ export function processExpressions( varDeclarations, ) - return genDeclarations([...varDeclarations, ...expDeclarations], context) + return genDeclarations( + [...varDeclarations, ...expDeclarations], + context, + shouldDeclareConst, + ) } function analyzeExpressions(expressions: SimpleExpressionNode[]) { @@ -507,15 +513,21 @@ function processRepeatedExpressions( function genDeclarations( declarations: DeclarationValue[], context: CodegenContext, + shouldDeclareConst: boolean, ): DeclarationResult { const [frag, push] = buildCodeFragment() const ids: Record = Object.create(null) + const varNames = new Set() // process identifiers first as expressions may rely on them declarations.forEach(({ name, isIdentifier, value }) => { if (isIdentifier) { const varName = (ids[name] = `_${name}`) - push(`const ${varName} = `, ...genExpression(value, context), NEWLINE) + varNames.add(varName) + if (shouldDeclareConst) { + push(`const `) + } + push(`${varName} = `, ...genExpression(value, context), NEWLINE) } }) @@ -523,15 +535,19 @@ function genDeclarations( declarations.forEach(({ name, isIdentifier, value }) => { if (!isIdentifier) { const varName = (ids[name] = `_${name}`) + varNames.add(varName) + if (shouldDeclareConst) { + push(`const `) + } push( - `const ${varName} = `, + `${varName} = `, ...context.withId(() => genExpression(value, context), ids), NEWLINE, ) } }) - return { ids, frag } + return { ids, frag, varNames: [...varNames] } } function escapeRegExp(string: string) { diff --git a/packages/compiler-vapor/src/generators/for.ts b/packages/compiler-vapor/src/generators/for.ts index fbb72c61d47..40f002a8536 100644 --- a/packages/compiler-vapor/src/generators/for.ts +++ b/packages/compiler-vapor/src/generators/for.ts @@ -1,16 +1,32 @@ import { type SimpleExpressionNode, createSimpleExpression, + isStaticNode, walkIdentifiers, } from '@vue/compiler-dom' -import { genBlock } from './block' +import { genBlockContent } from './block' import { genExpression } from './expression' import type { CodegenContext } from '../generate' -import type { ForIRNode } from '../ir' -import { type CodeFragment, NEWLINE, genCall, genMulti } from './utils' -import type { Identifier } from '@babel/types' +import type { BlockIRNode, ForIRNode, IREffect } from '../ir' +import { + type CodeFragment, + INDENT_END, + INDENT_START, + NEWLINE, + genCall, + genMulti, +} from './utils' +import { + type Expression, + type Identifier, + type Node, + isNodesEquivalent, +} from '@babel/types' import { parseExpression } from '@babel/parser' import { VaporVForFlags } from '../../../shared/src/vaporFlags' +import { walk } from 'estree-walker' +import { genOperation } from './operation' +import { extend, isGloballyAllowed } from '@vue/shared' export function genFor( oper: ForIRNode, @@ -78,7 +94,62 @@ export function genFor( idMap[indexVar] = null } - const blockFn = context.withId(() => genBlock(render, context, args), idMap) + const { selectorPatterns, keyOnlyBindingPatterns } = matchPatterns( + render, + keyProp, + idMap, + ) + const patternFrag: CodeFragment[] = [] + + for (let i = 0; i < selectorPatterns.length; i++) { + const { selector } = selectorPatterns[i] + const selectorName = `_selector${id}_${i}` + patternFrag.push( + NEWLINE, + `const ${selectorName} = `, + ...genCall(`n${id}.useSelector`, [ + `() => `, + ...genExpression(selector, context), + ]), + ) + } + + const blockFn = context.withId(() => { + const frag: CodeFragment[] = [] + frag.push('(', ...args, ') => {', INDENT_START) + if (selectorPatterns.length || keyOnlyBindingPatterns.length) { + frag.push( + ...genBlockContent(render, context, false, () => { + const patternFrag: CodeFragment[] = [] + + for (let i = 0; i < selectorPatterns.length; i++) { + const { effect } = selectorPatterns[i] + patternFrag.push( + NEWLINE, + `_selector${id}_${i}(() => {`, + INDENT_START, + ) + for (const oper of effect.operations) { + patternFrag.push(...genOperation(oper, context)) + } + patternFrag.push(INDENT_END, NEWLINE, `})`) + } + + for (const { effect } of keyOnlyBindingPatterns) { + for (const oper of effect.operations) { + patternFrag.push(...genOperation(oper, context)) + } + } + + return patternFrag + }), + ) + } else { + frag.push(...genBlockContent(render, context)) + } + frag.push(INDENT_END, NEWLINE, '}') + return frag + }, idMap) exitScope() let flags = 0 @@ -103,6 +174,7 @@ export function genFor( flags ? String(flags) : undefined, // todo: hydrationNode ), + ...patternFrag, ] // construct a id -> accessor path map. @@ -234,3 +306,223 @@ export function genFor( return idMap } } + +function matchPatterns( + render: BlockIRNode, + keyProp: SimpleExpressionNode | undefined, + idMap: Record, +) { + const selectorPatterns: NonNullable< + ReturnType + >[] = [] + const keyOnlyBindingPatterns: NonNullable< + ReturnType + >[] = [] + + render.effect = render.effect.filter(effect => { + if (keyProp !== undefined) { + const selector = matchSelectorPattern(effect, keyProp.ast, idMap) + if (selector) { + selectorPatterns.push(selector) + return false + } + const keyOnly = matchKeyOnlyBindingPattern(effect, keyProp.ast) + if (keyOnly) { + keyOnlyBindingPatterns.push(keyOnly) + return false + } + } + + return true + }) + + return { + keyOnlyBindingPatterns, + selectorPatterns, + } +} + +function matchKeyOnlyBindingPattern( + effect: IREffect, + keyAst: any, +): + | { + effect: IREffect + } + | undefined { + // TODO: expressions can be multiple? + if (effect.expressions.length === 1) { + const ast = effect.expressions[0].ast + if (typeof ast === 'object' && ast !== null) { + if (isKeyOnlyBinding(ast, keyAst)) { + return { effect } + } + } + } +} + +function matchSelectorPattern( + effect: IREffect, + keyAst: any, + idMap: Record, +): + | { + effect: IREffect + selector: SimpleExpressionNode + } + | undefined { + // TODO: expressions can be multiple? + if (effect.expressions.length === 1) { + const ast = effect.expressions[0].ast + if (typeof ast === 'object' && ast) { + const matcheds: [key: Expression, selector: Expression][] = [] + + walk(ast, { + enter(node) { + if ( + typeof node === 'object' && + node && + node.type === 'BinaryExpression' && + node.operator === '===' && + node.left.type !== 'PrivateName' + ) { + const { left, right } = node + for (const [a, b] of [ + [left, right], + [right, left], + ]) { + const aIsKey = isKeyOnlyBinding(a, keyAst) + const bIsKey = isKeyOnlyBinding(b, keyAst) + const bVars = analyzeVariableScopes(b, idMap) + if (aIsKey && !bIsKey && !bVars.locals.length) { + matcheds.push([a, b]) + } + } + } + }, + }) + + if (matcheds.length === 1) { + const [key, selector] = matcheds[0] + const content = effect.expressions[0].content + + let hasExtraId = false + const parentStackMap = new Map() + const parentStack: Node[] = [] + walkIdentifiers( + ast, + id => { + if (id.start !== key.start && id.start !== selector.start) { + hasExtraId = true + } + parentStackMap.set(id, parentStack.slice()) + }, + false, + parentStack, + ) + + if (!hasExtraId) { + const name = content.slice(selector.start! - 1, selector.end! - 1) + return { + effect, + // @ts-expect-error + selector: { + content: name, + ast: extend({}, selector, { + start: 1, + end: name.length + 1, + }), + loc: selector.loc as any, + isStatic: false, + }, + } + } + } + } + + const content = effect.expressions[0].content + if ( + typeof ast === 'object' && + ast && + ast.type === 'ConditionalExpression' && + ast.test.type === 'BinaryExpression' && + ast.test.operator === '===' && + ast.test.left.type !== 'PrivateName' && + isStaticNode(ast.consequent) && + isStaticNode(ast.alternate) + ) { + const left = ast.test.left + const right = ast.test.right + for (const [a, b] of [ + [left, right], + [right, left], + ]) { + const aIsKey = isKeyOnlyBinding(a, keyAst) + const bIsKey = isKeyOnlyBinding(b, keyAst) + const bVars = analyzeVariableScopes(b, idMap) + if (aIsKey && !bIsKey && !bVars.locals.length) { + return { + effect, + // @ts-expect-error + selector: { + content: content.slice(b.start! - 1, b.end! - 1), + ast: b, + loc: b.loc as any, + isStatic: false, + }, + } + } + } + } + } +} + +function analyzeVariableScopes( + ast: Node, + idMap: Record, +) { + let globals: string[] = [] + let locals: string[] = [] + + const ids: Identifier[] = [] + const parentStackMap = new Map() + const parentStack: Node[] = [] + walkIdentifiers( + ast, + id => { + ids.push(id) + parentStackMap.set(id, parentStack.slice()) + }, + false, + parentStack, + ) + + for (const id of ids) { + if (isGloballyAllowed(id.name)) { + continue + } + if (idMap[id.name]) { + locals.push(id.name) + } else { + globals.push(id.name) + } + } + + return { globals, locals } +} + +function isKeyOnlyBinding(expr: Node, keyAst: any) { + let only = true + walk(expr, { + enter(node) { + if (isNodesEquivalent(node, keyAst)) { + this.skip() + return + } + if (node.type === 'Identifier') { + only = false + } + }, + }) + return only +} diff --git a/packages/compiler-vapor/src/generators/operation.ts b/packages/compiler-vapor/src/generators/operation.ts index 4247bc6feca..b5f99f8ed07 100644 --- a/packages/compiler-vapor/src/generators/operation.ts +++ b/packages/compiler-vapor/src/generators/operation.ts @@ -98,17 +98,20 @@ export function genOperation( export function genEffects( effects: IREffect[], context: CodegenContext, + genExtraFrag?: () => CodeFragment[], ): CodeFragment[] { const { helper, block: { expressions }, } = context const [frag, push, unshift] = buildCodeFragment() + const shouldDeclareConst = genExtraFrag === undefined let operationsCount = 0 - const { ids, frag: declarationFrags } = processExpressions( - context, - expressions, - ) + const { + ids, + frag: declarationFrags, + varNames, + } = processExpressions(context, expressions, shouldDeclareConst) push(...declarationFrags) for (let i = 0; i < effects.length; i++) { const effect = effects[i] @@ -125,6 +128,9 @@ export function genEffects( if (newLineCount > 1 || operationsCount > 1 || declarationFrags.length > 0) { unshift(`{`, INDENT_START, NEWLINE) push(INDENT_END, NEWLINE, '}') + if (!effects.length) { + unshift(NEWLINE) + } } if (effects.length) { @@ -132,6 +138,14 @@ export function genEffects( push(`)`) } + if (!shouldDeclareConst && varNames.length) { + unshift(NEWLINE, `let `, varNames.join(', ')) + } + + if (genExtraFrag) { + push(...context.withId(genExtraFrag, ids)) + } + return frag } diff --git a/packages/reactivity/__tests__/computed.spec.ts b/packages/reactivity/__tests__/computed.spec.ts index db2984cc1ef..62b3237c429 100644 --- a/packages/reactivity/__tests__/computed.spec.ts +++ b/packages/reactivity/__tests__/computed.spec.ts @@ -467,12 +467,8 @@ describe('reactivity/computed', () => { const c2 = computed(() => c1.value) as unknown as ComputedRefImpl c2.value - expect( - c1.flags & (SubscriberFlags.Dirty | SubscriberFlags.PendingComputed), - ).toBe(0) - expect( - c2.flags & (SubscriberFlags.Dirty | SubscriberFlags.PendingComputed), - ).toBe(0) + expect(c1.flags & (SubscriberFlags.Dirty | SubscriberFlags.Pending)).toBe(0) + expect(c2.flags & (SubscriberFlags.Dirty | SubscriberFlags.Pending)).toBe(0) }) it('should chained computeds dirtyLevel update with first computed effect', () => { diff --git a/packages/reactivity/__tests__/effectScope.spec.ts b/packages/reactivity/__tests__/effectScope.spec.ts index 84310b985f2..6e42ef94ecb 100644 --- a/packages/reactivity/__tests__/effectScope.spec.ts +++ b/packages/reactivity/__tests__/effectScope.spec.ts @@ -2,6 +2,7 @@ import { nextTick, watch, watchEffect } from '@vue/runtime-core' import { type ComputedRef, EffectScope, + ReactiveEffect, computed, effect, effectScope, @@ -9,6 +10,7 @@ import { onScopeDispose, reactive, ref, + setCurrentScope, } from '../src' describe('reactivity/effect/scope', () => { @@ -20,7 +22,7 @@ describe('reactivity/effect/scope', () => { it('should accept zero argument', () => { const scope = effectScope() - expect(scope.effects.length).toBe(0) + expect(getEffectsCount(scope)).toBe(0) }) it('should return run value', () => { @@ -29,7 +31,8 @@ describe('reactivity/effect/scope', () => { it('should work w/ active property', () => { const scope = effectScope() - scope.run(() => 1) + const src = computed(() => 1) + scope.run(() => src.value) expect(scope.active).toBe(true) scope.stop() expect(scope.active).toBe(false) @@ -47,7 +50,7 @@ describe('reactivity/effect/scope', () => { expect(dummy).toBe(7) }) - expect(scope.effects.length).toBe(1) + expect(getEffectsCount(scope)).toBe(1) }) it('stop', () => { @@ -60,7 +63,7 @@ describe('reactivity/effect/scope', () => { effect(() => (doubled = counter.num * 2)) }) - expect(scope.effects.length).toBe(2) + expect(getEffectsCount(scope)).toBe(2) expect(dummy).toBe(0) counter.num = 7 @@ -87,9 +90,8 @@ describe('reactivity/effect/scope', () => { }) }) - expect(scope.effects.length).toBe(1) - expect(scope.scopes!.length).toBe(1) - expect(scope.scopes![0]).toBeInstanceOf(EffectScope) + expect(getEffectsCount(scope)).toBe(1) + expect(scope.deps?.nextDep?.dep).toBeInstanceOf(EffectScope) expect(dummy).toBe(0) counter.num = 7 @@ -117,7 +119,7 @@ describe('reactivity/effect/scope', () => { }) }) - expect(scope.effects.length).toBe(1) + expect(getEffectsCount(scope)).toBe(1) expect(dummy).toBe(0) counter.num = 7 @@ -142,13 +144,13 @@ describe('reactivity/effect/scope', () => { effect(() => (dummy = counter.num)) }) - expect(scope.effects.length).toBe(1) + expect(getEffectsCount(scope)).toBe(1) scope.run(() => { effect(() => (doubled = counter.num * 2)) }) - expect(scope.effects.length).toBe(2) + expect(getEffectsCount(scope)).toBe(2) counter.num = 7 expect(dummy).toBe(7) @@ -166,21 +168,21 @@ describe('reactivity/effect/scope', () => { effect(() => (dummy = counter.num)) }) - expect(scope.effects.length).toBe(1) + expect(getEffectsCount(scope)).toBe(1) scope.stop() + expect(getEffectsCount(scope)).toBe(0) + scope.run(() => { effect(() => (doubled = counter.num * 2)) }) - expect('[Vue warn] cannot run an inactive effect scope.').toHaveBeenWarned() - - expect(scope.effects.length).toBe(0) + expect(getEffectsCount(scope)).toBe(1) counter.num = 7 expect(dummy).toBe(0) - expect(doubled).toBe(undefined) + expect(doubled).toBe(14) }) it('should fire onScopeDispose hook', () => { @@ -224,9 +226,9 @@ describe('reactivity/effect/scope', () => { it('should dereference child scope from parent scope after stopping child scope (no memleaks)', () => { const parent = effectScope() const child = parent.run(() => effectScope())! - expect(parent.scopes!.includes(child)).toBe(true) + expect(parent.deps?.dep).toBe(child) child.stop() - expect(parent.scopes!.includes(child)).toBe(false) + expect(parent.deps).toBeUndefined() }) it('test with higher level APIs', async () => { @@ -290,21 +292,7 @@ describe('reactivity/effect/scope', () => { parentScope.run(() => { const childScope = effectScope(true) - childScope.on() - childScope.off() - expect(getCurrentScope()).toBe(parentScope) - }) - }) - - it('calling on() and off() multiple times inside an active scope should not break currentScope', () => { - const parentScope = effectScope() - parentScope.run(() => { - const childScope = effectScope(true) - childScope.on() - childScope.on() - childScope.off() - childScope.off() - childScope.off() + setCurrentScope(setCurrentScope(childScope)) expect(getCurrentScope()).toBe(parentScope) }) }) @@ -372,7 +360,17 @@ describe('reactivity/effect/scope', () => { expect(watcherCalls).toBe(3) expect(cleanupCalls).toBe(1) - expect(scope.effects.length).toBe(0) - expect(scope.cleanups.length).toBe(0) + expect(getEffectsCount(scope)).toBe(0) + expect(scope.cleanups).toBe(0) }) }) + +function getEffectsCount(scope: EffectScope): number { + let n = 0 + for (let dep = scope.deps; dep !== undefined; dep = dep.nextDep) { + if (dep.dep instanceof ReactiveEffect) { + n++ + } + } + return n +} diff --git a/packages/reactivity/src/computed.ts b/packages/reactivity/src/computed.ts index 70670d81ec2..6e6488cea87 100644 --- a/packages/reactivity/src/computed.ts +++ b/packages/reactivity/src/computed.ts @@ -14,11 +14,11 @@ import { type Link, type Subscriber, SubscriberFlags, + checkDirty, endTracking, link, processComputedUpdate, startTracking, - updateDirtyFlag, } from './system' import { warn } from './warning' @@ -66,7 +66,7 @@ export class ComputedRefImpl implements Dependency, Subscriber { // Subscriber deps: Link | undefined = undefined depsTail: Link | undefined = undefined - flags: SubscriberFlags = SubscriberFlags.Computed | SubscriberFlags.Dirty + flags: SubscriberFlags = SubscriberFlags.Dirty /** * @internal @@ -93,13 +93,17 @@ export class ComputedRefImpl implements Dependency, Subscriber { */ get _dirty(): boolean { const flags = this.flags - if ( - flags & SubscriberFlags.Dirty || - (flags & SubscriberFlags.PendingComputed && - updateDirtyFlag(this, this.flags)) - ) { + if (flags & SubscriberFlags.Dirty) { return true } + if (flags & SubscriberFlags.Pending) { + if (checkDirty(this.deps!)) { + this.flags = flags | SubscriberFlags.Dirty + return true + } else { + this.flags = flags & ~SubscriberFlags.Pending + } + } return false } /** @@ -110,7 +114,7 @@ export class ComputedRefImpl implements Dependency, Subscriber { if (v) { this.flags |= SubscriberFlags.Dirty } else { - this.flags &= ~(SubscriberFlags.Dirty | SubscriberFlags.PendingComputed) + this.flags &= ~(SubscriberFlags.Dirty | SubscriberFlags.Pending) } } @@ -134,7 +138,7 @@ export class ComputedRefImpl implements Dependency, Subscriber { get value(): T { const flags = this.flags - if (flags & (SubscriberFlags.Dirty | SubscriberFlags.PendingComputed)) { + if (flags & (SubscriberFlags.Dirty | SubscriberFlags.Pending)) { processComputedUpdate(this, flags) } if (activeSub !== undefined) { diff --git a/packages/reactivity/src/debug.ts b/packages/reactivity/src/debug.ts index 5503dc8a11b..c1f35b62ad8 100644 --- a/packages/reactivity/src/debug.ts +++ b/packages/reactivity/src/debug.ts @@ -69,8 +69,11 @@ function setupFlagsHandler(target: Subscriber): void { }, set(value) { if ( - !((target as any)._flags & SubscriberFlags.Propagated) && - !!(value & SubscriberFlags.Propagated) + !( + (target as any)._flags & + (SubscriberFlags.Dirty | SubscriberFlags.Pending) + ) && + !!(value & (SubscriberFlags.Dirty | SubscriberFlags.Pending)) ) { onTrigger(this) } diff --git a/packages/reactivity/src/effect.ts b/packages/reactivity/src/effect.ts index a77c4bf2b18..c26d455807b 100644 --- a/packages/reactivity/src/effect.ts +++ b/packages/reactivity/src/effect.ts @@ -3,12 +3,15 @@ import type { TrackOpTypes, TriggerOpTypes } from './constants' import { setupOnTrigger } from './debug' import { activeEffectScope } from './effectScope' import { + type Dependency, type Link, type Subscriber, SubscriberFlags, + checkDirty, endTracking, + link, startTracking, - updateDirtyFlag, + unlink, } from './system' import { warn } from './warning' @@ -49,32 +52,40 @@ export enum EffectFlags { ALLOW_RECURSE = 1 << 7, PAUSED = 1 << 8, NOTIFIED = 1 << 9, - STOP = 1 << 10, } -export class ReactiveEffect implements ReactiveEffectOptions { +export class ReactiveEffect + implements ReactiveEffectOptions, Dependency, Subscriber +{ // Subscriber deps: Link | undefined = undefined depsTail: Link | undefined = undefined - flags: number = SubscriberFlags.Effect + flags: number = SubscriberFlags.Dirty + cleanups: number = 0 - /** - * @internal - */ - cleanup?: () => void = undefined + // Dependency + subs: Link | undefined = undefined + subsTail: Link | undefined = undefined - onStop?: () => void + // dev only onTrack?: (event: DebuggerEvent) => void + // dev only onTrigger?: (event: DebuggerEvent) => void - constructor(public fn: () => T) { - if (activeEffectScope && activeEffectScope.active) { - activeEffectScope.effects.push(this) + // @ts-expect-error + fn(): T {} + + constructor(fn?: () => T) { + if (fn !== undefined) { + this.fn = fn + } + if (activeEffectScope) { + link(this, activeEffectScope) } } get active(): boolean { - return !(this.flags & EffectFlags.STOP) + return this.deps !== undefined } pause(): void { @@ -84,12 +95,12 @@ export class ReactiveEffect implements ReactiveEffectOptions { } resume(): void { - const flags = this.flags + let flags = this.flags if (flags & EffectFlags.PAUSED) { - this.flags &= ~EffectFlags.PAUSED + this.flags = flags &= ~EffectFlags.PAUSED } if (flags & EffectFlags.NOTIFIED) { - this.flags &= ~EffectFlags.NOTIFIED + this.flags = flags &= ~EffectFlags.NOTIFIED this.notify() } } @@ -99,7 +110,7 @@ export class ReactiveEffect implements ReactiveEffectOptions { if (!(flags & EffectFlags.PAUSED)) { this.scheduler() } else { - this.flags |= EffectFlags.NOTIFIED + this.flags = flags | EffectFlags.NOTIFIED } } @@ -110,13 +121,10 @@ export class ReactiveEffect implements ReactiveEffectOptions { } run(): T { - // TODO cleanupEffect - - if (!this.active) { - // stopped during cleanup - return this.fn() + const cleanups = this.cleanups + if (cleanups) { + cleanup(this, cleanups) } - cleanupEffect(this) const prevSub = activeSub setActiveSub(this) startTracking(this) @@ -132,34 +140,43 @@ export class ReactiveEffect implements ReactiveEffectOptions { } setActiveSub(prevSub) endTracking(this) + const flags = this.flags if ( - this.flags & SubscriberFlags.Recursed && - this.flags & EffectFlags.ALLOW_RECURSE + (flags & (SubscriberFlags.Recursed | EffectFlags.ALLOW_RECURSE)) === + (SubscriberFlags.Recursed | EffectFlags.ALLOW_RECURSE) ) { - this.flags &= ~SubscriberFlags.Recursed + this.flags = flags & ~SubscriberFlags.Recursed this.notify() } } } stop(): void { - if (this.active) { - startTracking(this) - endTracking(this) - cleanupEffect(this) - this.onStop && this.onStop() - this.flags |= EffectFlags.STOP + const sub = this.subs + const cleanups = this.cleanups + if (sub !== undefined) { + unlink(sub) + } + startTracking(this) + endTracking(this) + if (cleanups) { + cleanup(this, cleanups) } } get dirty(): boolean { const flags = this.flags - if ( - flags & SubscriberFlags.Dirty || - (flags & SubscriberFlags.PendingComputed && updateDirtyFlag(this, flags)) - ) { + if (flags & SubscriberFlags.Dirty) { return true } + if (flags & SubscriberFlags.Pending) { + if (checkDirty(this.deps!)) { + this.flags = flags | SubscriberFlags.Dirty + return true + } else { + this.flags = flags & ~SubscriberFlags.Pending + } + } return false } } @@ -183,6 +200,15 @@ export function effect( const e = new ReactiveEffect(fn) if (options) { + const onStop = options.onStop + if (onStop !== undefined) { + options.onStop = undefined + const stop = e.stop.bind(e) + e.stop = () => { + stop() + onStop() + } + } extend(e, options) } try { @@ -254,6 +280,32 @@ export function resetTracking(): void { } } +const cleanupCbs = new WeakMap() + +export function onCleanup( + sub: Subscriber & { cleanups: number }, + cb: () => void, +): void { + const cbs = cleanupCbs.get(sub) + if (cbs === undefined) { + cleanupCbs.set(sub, [cb]) + sub.cleanups = 1 + } else { + cbs[sub.cleanups!++] = cb + } +} + +export function cleanup( + sub: Subscriber & { cleanups: number }, + length: number, +): void { + const cbs = cleanupCbs.get(sub)! + for (let i = 0; i < length; ++i) { + cbs[i]() + } + sub.cleanups = 0 +} + /** * Registers a cleanup function for the current active effect. * The cleanup function is called right before the next effect run, or when the @@ -267,8 +319,9 @@ export function resetTracking(): void { * an active effect. */ export function onEffectCleanup(fn: () => void, failSilently = false): void { - if (activeSub instanceof ReactiveEffect) { - activeSub.cleanup = fn + const e = activeSub + if (e instanceof ReactiveEffect) { + onCleanup(e, () => cleanupEffect(fn)) } else if (__DEV__ && !failSilently) { warn( `onEffectCleanup() was called when there was no active effect` + @@ -277,18 +330,14 @@ export function onEffectCleanup(fn: () => void, failSilently = false): void { } } -function cleanupEffect(e: ReactiveEffect) { - const { cleanup } = e - e.cleanup = undefined - if (cleanup !== undefined) { - // run cleanup without active effect - const prevSub = activeSub - activeSub = undefined - try { - cleanup() - } finally { - activeSub = prevSub - } +function cleanupEffect(fn: () => void) { + // run cleanup without active effect + const prevSub = activeSub + activeSub = undefined + try { + fn() + } finally { + activeSub = prevSub } } diff --git a/packages/reactivity/src/effectScope.ts b/packages/reactivity/src/effectScope.ts index 00fa403b02e..2c109c2084f 100644 --- a/packages/reactivity/src/effectScope.ts +++ b/packages/reactivity/src/effectScope.ts @@ -1,76 +1,49 @@ -import { EffectFlags, type ReactiveEffect } from './effect' +import { EffectFlags, cleanup, onCleanup } from './effect' import { + type Dependency, type Link, type Subscriber, endTracking, + link, startTracking, + unlink, } from './system' import { warn } from './warning' export let activeEffectScope: EffectScope | undefined -export class EffectScope implements Subscriber { - // Subscriber: In order to collect orphans computeds +export class EffectScope implements Subscriber, Dependency { + // Subscriber deps: Link | undefined = undefined depsTail: Link | undefined = undefined flags: number = 0 + cleanups: number = 0 - /** - * @internal track `on` calls, allow `on` call multiple times - */ - private _on = 0 - /** - * @internal - */ - effects: ReactiveEffect[] = [] - /** - * @internal - */ - cleanups: (() => void)[] = [] + // Dependency + subs: Link | undefined = undefined + subsTail: Link | undefined = undefined - /** - * only assigned by undetached scope - * @internal - */ - parent: EffectScope | undefined - /** - * record undetached scopes - * @internal - */ - scopes: EffectScope[] | undefined - /** - * track a child scope's index in its parent's scopes array for optimized - * removal - * @internal - */ - private index: number | undefined - - constructor( - public detached = false, - parent: EffectScope | undefined = activeEffectScope, - ) { - this.parent = parent - if (!detached && parent) { - this.index = (parent.scopes || (parent.scopes = [])).push(this) - 1 + constructor(detached = false) { + if (!detached && activeEffectScope) { + link(this, activeEffectScope) } } get active(): boolean { - return !(this.flags & EffectFlags.STOP) + return this.deps !== undefined } + notify(): void {} + pause(): void { if (!(this.flags & EffectFlags.PAUSED)) { this.flags |= EffectFlags.PAUSED - let i, l - if (this.scopes) { - for (i = 0, l = this.scopes.length; i < l; i++) { - this.scopes[i].pause() + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const dep = link.dep + if ('pause' in dep) { + dep.pause() } } - for (i = 0, l = this.effects.length; i < l; i++) { - this.effects[i].pause() - } } } @@ -78,90 +51,38 @@ export class EffectScope implements Subscriber { * Resumes the effect scope, including all child scopes and effects. */ resume(): void { - if (this.flags & EffectFlags.PAUSED) { - this.flags &= ~EffectFlags.PAUSED - let i, l - if (this.scopes) { - for (i = 0, l = this.scopes.length; i < l; i++) { - this.scopes[i].resume() + const flags = this.flags + if (flags & EffectFlags.PAUSED) { + this.flags = flags & ~EffectFlags.PAUSED + for (let link = this.deps; link !== undefined; link = link.nextDep) { + const dep = link.dep + if ('resume' in dep) { + dep.resume() } } - for (i = 0, l = this.effects.length; i < l; i++) { - this.effects[i].resume() - } } } run(fn: () => T): T | undefined { - if (this.active) { - const prevEffectScope = activeEffectScope - try { - activeEffectScope = this - return fn() - } finally { - activeEffectScope = prevEffectScope - } - } else if (__DEV__) { - warn(`cannot run an inactive effect scope.`) - } - } - - prevScope: EffectScope | undefined - /** - * This should only be called on non-detached scopes - * @internal - */ - on(): void { - if (++this._on === 1) { - this.prevScope = activeEffectScope + const prevEffectScope = activeEffectScope + try { activeEffectScope = this + return fn() + } finally { + activeEffectScope = prevEffectScope } } - /** - * This should only be called on non-detached scopes - * @internal - */ - off(): void { - if (this._on > 0 && --this._on === 0) { - activeEffectScope = this.prevScope - this.prevScope = undefined + stop(): void { + const sub = this.subs + const cleanups = this.cleanups + if (sub !== undefined) { + unlink(sub) } - } - - stop(fromParent?: boolean): void { - if (this.active) { - this.flags |= EffectFlags.STOP - startTracking(this) - endTracking(this) - let i, l - for (i = 0, l = this.effects.length; i < l; i++) { - this.effects[i].stop() - } - this.effects.length = 0 - - for (i = 0, l = this.cleanups.length; i < l; i++) { - this.cleanups[i]() - } - this.cleanups.length = 0 - - if (this.scopes) { - for (i = 0, l = this.scopes.length; i < l; i++) { - this.scopes[i].stop(true) - } - this.scopes.length = 0 - } - - // nested scope, dereference from parent to avoid memory leaks - if (!this.detached && this.parent && !fromParent) { - // optimized O(1) removal - const last = this.parent.scopes!.pop() - if (last && last !== this) { - this.parent.scopes![this.index!] = last - last.index = this.index! - } - } - this.parent = undefined + startTracking(this) + endTracking(this) + if (cleanups) { + cleanup(this, cleanups) } } } @@ -188,6 +109,14 @@ export function getCurrentScope(): EffectScope | undefined { return activeEffectScope } +export function setCurrentScope( + scope: EffectScope | undefined, +): EffectScope | undefined { + const prevScope = activeEffectScope + activeEffectScope = scope + return prevScope +} + /** * Registers a dispose callback on the current active effect scope. The * callback will be invoked when the associated effect scope is stopped. @@ -196,8 +125,8 @@ export function getCurrentScope(): EffectScope | undefined { * @see {@link https://vuejs.org/api/reactivity-advanced.html#onscopedispose} */ export function onScopeDispose(fn: () => void, failSilently = false): void { - if (activeEffectScope) { - activeEffectScope.cleanups.push(fn) + if (activeEffectScope !== undefined) { + onCleanup(activeEffectScope, fn) } else if (__DEV__ && !failSilently) { warn( `onScopeDispose() is called when there is no active effect scope` + diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index f0445e87da0..509152663da 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -76,6 +76,10 @@ export { effectScope, EffectScope, getCurrentScope, + /** + * @internal + */ + setCurrentScope, onScopeDispose, } from './effectScope' export { reactiveReadArray, shallowReadArray } from './arrayInstrumentations' diff --git a/packages/reactivity/src/ref.ts b/packages/reactivity/src/ref.ts index 5239f34bf3f..b8b1c1e32fc 100644 --- a/packages/reactivity/src/ref.ts +++ b/packages/reactivity/src/ref.ts @@ -144,19 +144,22 @@ class RefImpl implements Dependency { if (hasChanged(newValue, oldValue)) { this._rawValue = newValue this._value = - this._wrap && !useDirectValue ? this._wrap(newValue) : newValue - if (__DEV__) { - triggerEventInfos.push({ - target: this, - type: TriggerOpTypes.SET, - key: 'value', - newValue, - oldValue, - }) - } - triggerRef(this as unknown as Ref) - if (__DEV__) { - triggerEventInfos.pop() + !useDirectValue && this._wrap ? this._wrap(newValue) : newValue + const subs = this.subs + if (subs !== undefined) { + if (__DEV__) { + triggerEventInfos.push({ + target: this, + type: TriggerOpTypes.SET, + key: 'value', + newValue, + oldValue, + }) + } + propagate(subs) + if (__DEV__) { + triggerEventInfos.pop() + } } } } diff --git a/packages/reactivity/src/system.ts b/packages/reactivity/src/system.ts index b3699f727c8..6e6ecfd6467 100644 --- a/packages/reactivity/src/system.ts +++ b/packages/reactivity/src/system.ts @@ -2,6 +2,7 @@ // Ported from https://github.com/stackblitz/alien-signals/blob/v1.0.13/src/system.ts import type { ComputedRefImpl as Computed } from './computed.js' import type { ReactiveEffect as Effect } from './effect.js' +import { EffectScope } from './effectScope.js' export interface Dependency { subs: Link | undefined @@ -15,21 +16,19 @@ export interface Subscriber { } export interface Link { - dep: Dependency | Computed - sub: Subscriber | Computed | Effect + dep: Dependency | Computed | Effect | EffectScope + sub: Subscriber | Computed | Effect | EffectScope prevSub: Link | undefined nextSub: Link | undefined + prevDep: Link | undefined nextDep: Link | undefined } export const enum SubscriberFlags { - Computed = 1 << 0, - Effect = 1 << 1, Tracking = 1 << 2, Recursed = 1 << 4, Dirty = 1 << 5, - PendingComputed = 1 << 6, - Propagated = Dirty | PendingComputed, + Pending = 1 << 6, } interface OneWayLink { @@ -37,7 +36,7 @@ interface OneWayLink { linked: OneWayLink | undefined } -const notifyBuffer: (Effect | undefined)[] = [] +const notifyBuffer: (Effect | EffectScope | undefined)[] = [] let batchDepth = 0 let notifyIndex = 0 @@ -53,12 +52,12 @@ export function endBatch(): void { } } -export function link(dep: Dependency, sub: Subscriber): Link | undefined { - const currentDep = sub.depsTail - if (currentDep !== undefined && currentDep.dep === dep) { +export function link(dep: Dependency, sub: Subscriber): void { + const prevDep = sub.depsTail + if (prevDep !== undefined && prevDep.dep === dep) { return } - const nextDep = currentDep !== undefined ? currentDep.nextDep : sub.deps + const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps if (nextDep !== undefined && nextDep.dep === dep) { sub.depsTail = nextDep return @@ -71,7 +70,75 @@ export function link(dep: Dependency, sub: Subscriber): Link | undefined { ) { return } - return linkNewDep(dep, sub, nextDep, currentDep) + const newLink: Link = { + dep, + sub, + prevDep, + nextDep, + prevSub: undefined, + nextSub: undefined, + } + if (prevDep === undefined) { + sub.deps = newLink + } else { + prevDep.nextDep = newLink + } + if (dep.subs === undefined) { + dep.subs = newLink + } else { + const oldTail = dep.subsTail! + newLink.prevSub = oldTail + oldTail.nextSub = newLink + } + if (nextDep !== undefined) { + nextDep.prevDep = newLink + } + sub.depsTail = newLink + dep.subsTail = newLink +} + +export function unlink( + link: Link, + sub: Subscriber = link.sub, +): Link | undefined { + const dep = link.dep + const prevDep = link.prevDep + const nextDep = link.nextDep + const nextSub = link.nextSub + const prevSub = link.prevSub + if (nextSub !== undefined) { + nextSub.prevSub = prevSub + } else { + dep.subsTail = prevSub + } + if (prevSub !== undefined) { + prevSub.nextSub = nextSub + } else { + dep.subs = nextSub + } + if (nextDep !== undefined) { + nextDep.prevDep = prevDep + } else { + sub.depsTail = prevDep + } + if (prevDep !== undefined) { + prevDep.nextDep = nextDep + } else { + sub.deps = nextDep + } + if (dep.subs === undefined && 'deps' in dep) { + let toRemove = dep.deps + while (toRemove !== undefined) { + toRemove = unlink(toRemove, dep) + } + const depFlags = dep.flags + if ('stop' in dep) { + dep.stop() + } else if (!(depFlags & SubscriberFlags.Dirty)) { + dep.flags = depFlags | SubscriberFlags.Dirty + } + } + return nextDep } export function propagate(current: Link): void { @@ -91,7 +158,8 @@ export function propagate(current: Link): void { subFlags & (SubscriberFlags.Tracking | SubscriberFlags.Recursed | - SubscriberFlags.Propagated) + SubscriberFlags.Dirty | + SubscriberFlags.Pending) ) ) { sub.flags = subFlags | targetFlag @@ -103,33 +171,34 @@ export function propagate(current: Link): void { sub.flags = (subFlags & ~SubscriberFlags.Recursed) | targetFlag shouldNotify = true } else if ( - !(subFlags & SubscriberFlags.Propagated) && + !(subFlags & (SubscriberFlags.Dirty | SubscriberFlags.Pending)) && isValidLink(current, sub) ) { sub.flags = subFlags | SubscriberFlags.Recursed | targetFlag - shouldNotify = (sub as Dependency).subs !== undefined + shouldNotify = !('notify' in sub) } if (shouldNotify) { - const subSubs = (sub as Dependency).subs - if (subSubs !== undefined) { - current = subSubs - if (subSubs.nextSub !== undefined) { - branchs = { target: next, linked: branchs } - ++branchDepth - next = current.nextSub + if ('notify' in sub) { + notifyBuffer[notifyBufferLength++] = sub + } else { + const subSubs = (sub as Dependency).subs + if (subSubs !== undefined) { + current = subSubs + if (subSubs.nextSub !== undefined) { + branchs = { target: next, linked: branchs } + ++branchDepth + next = current.nextSub + } + targetFlag = SubscriberFlags.Pending + continue } - targetFlag = SubscriberFlags.PendingComputed - continue - } - if (subFlags & SubscriberFlags.Effect) { - notifyBuffer[notifyBufferLength++] = sub as Effect } } else if (!(subFlags & (SubscriberFlags.Tracking | targetFlag))) { sub.flags = subFlags | targetFlag } else if ( !(subFlags & targetFlag) && - subFlags & SubscriberFlags.Propagated && + subFlags & (SubscriberFlags.Dirty | SubscriberFlags.Pending) && isValidLink(current, sub) ) { sub.flags = subFlags | targetFlag @@ -137,9 +206,7 @@ export function propagate(current: Link): void { if ((current = next!) !== undefined) { next = current.nextSub - targetFlag = branchDepth - ? SubscriberFlags.PendingComputed - : SubscriberFlags.Dirty + targetFlag = branchDepth ? SubscriberFlags.Pending : SubscriberFlags.Dirty continue } @@ -149,7 +216,7 @@ export function propagate(current: Link): void { if (current !== undefined) { next = current.nextSub targetFlag = branchDepth - ? SubscriberFlags.PendingComputed + ? SubscriberFlags.Pending : SubscriberFlags.Dirty continue top } @@ -166,38 +233,24 @@ export function propagate(current: Link): void { export function startTracking(sub: Subscriber): void { sub.depsTail = undefined sub.flags = - (sub.flags & ~(SubscriberFlags.Recursed | SubscriberFlags.Propagated)) | + (sub.flags & + ~( + SubscriberFlags.Recursed | + SubscriberFlags.Dirty | + SubscriberFlags.Pending + )) | SubscriberFlags.Tracking } export function endTracking(sub: Subscriber): void { const depsTail = sub.depsTail - if (depsTail !== undefined) { - const nextDep = depsTail.nextDep - if (nextDep !== undefined) { - clearTracking(nextDep) - depsTail.nextDep = undefined - } - } else if (sub.deps !== undefined) { - clearTracking(sub.deps) - sub.deps = undefined + let toRemove = depsTail !== undefined ? depsTail.nextDep : sub.deps + while (toRemove !== undefined) { + toRemove = unlink(toRemove, sub) } sub.flags &= ~SubscriberFlags.Tracking } -export function updateDirtyFlag( - sub: Subscriber, - flags: SubscriberFlags, -): boolean { - if (checkDirty(sub.deps!)) { - sub.flags = flags | SubscriberFlags.Dirty - return true - } else { - sub.flags = flags & ~SubscriberFlags.PendingComputed - return false - } -} - export function processComputedUpdate( computed: Computed, flags: SubscriberFlags, @@ -210,7 +263,7 @@ export function processComputedUpdate( } } } else { - computed.flags = flags & ~SubscriberFlags.PendingComputed + computed.flags = flags & ~SubscriberFlags.Pending } } @@ -224,41 +277,7 @@ export function processEffectNotifications(): void { notifyBufferLength = 0 } -function linkNewDep( - dep: Dependency, - sub: Subscriber, - nextDep: Link | undefined, - depsTail: Link | undefined, -): Link { - const newLink: Link = { - dep, - sub, - nextDep, - prevSub: undefined, - nextSub: undefined, - } - - if (depsTail === undefined) { - sub.deps = newLink - } else { - depsTail.nextDep = newLink - } - - if (dep.subs === undefined) { - dep.subs = newLink - } else { - const oldTail = dep.subsTail! - newLink.prevSub = oldTail - oldTail.nextSub = newLink - } - - sub.depsTail = newLink - dep.subsTail = newLink - - return newLink -} - -function checkDirty(current: Link): boolean { +export function checkDirty(current: Link): boolean { let prevLinks: OneWayLink | undefined let checkDepth = 0 let dirty: boolean @@ -269,12 +288,9 @@ function checkDirty(current: Link): boolean { if (current.sub.flags & SubscriberFlags.Dirty) { dirty = true - } else if ('flags' in dep) { + } else if ('update' in dep) { const depFlags = dep.flags - if ( - (depFlags & (SubscriberFlags.Computed | SubscriberFlags.Dirty)) === - (SubscriberFlags.Computed | SubscriberFlags.Dirty) - ) { + if (depFlags & SubscriberFlags.Dirty) { if ((dep as Computed).update()) { const subs = dep.subs! if (subs.nextSub !== undefined) { @@ -282,11 +298,7 @@ function checkDirty(current: Link): boolean { } dirty = true } - } else if ( - (depFlags & - (SubscriberFlags.Computed | SubscriberFlags.PendingComputed)) === - (SubscriberFlags.Computed | SubscriberFlags.PendingComputed) - ) { + } else if (depFlags & SubscriberFlags.Pending) { if (current.nextSub !== undefined || current.prevSub !== undefined) { prevLinks = { target: current, linked: prevLinks } } @@ -317,7 +329,7 @@ function checkDirty(current: Link): boolean { continue } } else { - sub.flags &= ~SubscriberFlags.PendingComputed + sub.flags &= ~SubscriberFlags.Pending } if (firstSub.nextSub !== undefined) { current = prevLinks!.target @@ -341,8 +353,8 @@ function shallowPropagate(link: Link): void { const sub = link.sub const subFlags = sub.flags if ( - (subFlags & (SubscriberFlags.PendingComputed | SubscriberFlags.Dirty)) === - SubscriberFlags.PendingComputed + (subFlags & (SubscriberFlags.Pending | SubscriberFlags.Dirty)) === + SubscriberFlags.Pending ) { sub.flags = subFlags | SubscriberFlags.Dirty } @@ -366,40 +378,3 @@ function isValidLink(checkLink: Link, sub: Subscriber): boolean { } return false } - -function clearTracking(link: Link): void { - do { - const dep = link.dep - const nextDep = link.nextDep - const nextSub = link.nextSub - const prevSub = link.prevSub - - if (nextSub !== undefined) { - nextSub.prevSub = prevSub - } else { - dep.subsTail = prevSub - } - - if (prevSub !== undefined) { - prevSub.nextSub = nextSub - } else { - dep.subs = nextSub - } - - if (dep.subs === undefined && 'deps' in dep) { - const depFlags = dep.flags - if (!(depFlags & SubscriberFlags.Dirty)) { - dep.flags = depFlags | SubscriberFlags.Dirty - } - const depDeps = dep.deps - if (depDeps !== undefined) { - link = depDeps - dep.depsTail!.nextDep = nextDep - dep.deps = undefined - dep.depsTail = undefined - continue - } - } - link = nextDep! - } while (link !== undefined) -} diff --git a/packages/reactivity/src/watch.ts b/packages/reactivity/src/watch.ts index 094bf226ca8..bac8e1ba40d 100644 --- a/packages/reactivity/src/watch.ts +++ b/packages/reactivity/src/watch.ts @@ -8,18 +8,17 @@ import { isObject, isPlainObject, isSet, - remove, } from '@vue/shared' import type { ComputedRef } from './computed' import { ReactiveFlags } from './constants' import { type DebuggerOptions, - type EffectScheduler, ReactiveEffect, + cleanup, + onCleanup, pauseTracking, resetTracking, } from './effect' -import { getCurrentScope } from './effectScope' import { isReactive, isShallow } from './reactive' import { type Ref, isRef } from './ref' import { warn } from './warning' @@ -78,8 +77,7 @@ const INITIAL_WATCHER_VALUE = {} export type WatchScheduler = (job: () => void, isFirstRun: boolean) => void -const cleanupMap: WeakMap void)[]> = new WeakMap() -let activeWatcher: ReactiveEffect | undefined = undefined +let activeWatcher: WatcherEffect | undefined = undefined /** * Returns the current active effect if there is one. @@ -102,12 +100,15 @@ export function getCurrentWatcher(): ReactiveEffect | undefined { export function onWatcherCleanup( cleanupFn: () => void, failSilently = false, - owner: ReactiveEffect | undefined = activeWatcher, + owner: WatcherEffect | undefined = activeWatcher, ): void { if (owner) { - let cleanups = cleanupMap.get(owner) - if (!cleanups) cleanupMap.set(owner, (cleanups = [])) - cleanups.push(cleanupFn) + const { call } = owner.options + if (call) { + onCleanup(owner, () => call(cleanupFn, WatchErrorCodes.WATCH_CLEANUP)) + } else { + onCleanup(owner, cleanupFn) + } } else if (__DEV__ && !failSilently) { warn( `onWatcherCleanup() was called when there was no active watcher` + @@ -116,212 +117,212 @@ export function onWatcherCleanup( } } -export function watch( - source: WatchSource | WatchSource[] | WatchEffect | object, - cb?: WatchCallback | null, - options: WatchOptions = EMPTY_OBJ, -): WatchHandle { - const { immediate, deep, once, scheduler, augmentJob, call } = options - - const warnInvalidSource = (s: unknown) => { - ;(options.onWarn || warn)( - `Invalid watch source: `, - s, - `A watch source can only be a getter/effect function, a ref, ` + - `a reactive object, or an array of these types.`, - ) - } - - const reactiveGetter = (source: object) => { - // traverse will happen in wrapped getter below - if (deep) return source - // for `deep: false | 0` or shallow reactive, only traverse root-level properties - if (isShallow(source) || deep === false || deep === 0) - return traverse(source, 1) - // for `deep: undefined` on a reactive object, deeply traverse all properties - return traverse(source) - } - - let effect: ReactiveEffect - let getter: () => any - let cleanup: (() => void) | undefined - let boundCleanup: typeof onWatcherCleanup - let forceTrigger = false - let isMultiSource = false - - if (isRef(source)) { - getter = () => source.value - forceTrigger = isShallow(source) - } else if (isReactive(source)) { - getter = () => reactiveGetter(source) - forceTrigger = true - } else if (isArray(source)) { - isMultiSource = true - forceTrigger = source.some(s => isReactive(s) || isShallow(s)) - getter = () => - source.map(s => { - if (isRef(s)) { - return s.value - } else if (isReactive(s)) { - return reactiveGetter(s) - } else if (isFunction(s)) { - return call ? call(s, WatchErrorCodes.WATCH_GETTER) : s() - } else { - __DEV__ && warnInvalidSource(s) - } - }) - } else if (isFunction(source)) { - if (cb) { - // getter with cb - getter = call - ? () => call(source, WatchErrorCodes.WATCH_GETTER) - : (source as () => any) - } else { - // no cb -> simple effect - getter = () => { - if (cleanup) { - pauseTracking() +class WatcherEffect extends ReactiveEffect { + forceTrigger: boolean + isMultiSource: boolean + oldValue: any + boundCleanup: typeof onWatcherCleanup = fn => + onWatcherCleanup(fn, false, this) + + constructor( + source: WatchSource | WatchSource[] | WatchEffect | object, + public cb?: WatchCallback | null | undefined, + public options: WatchOptions = EMPTY_OBJ, + ) { + const { immediate, deep, once, scheduler, augmentJob, call, onWarn } = + options + + let getter: () => any + let forceTrigger = false + let isMultiSource = false + + if (isRef(source)) { + getter = () => source.value + forceTrigger = isShallow(source) + } else if (isReactive(source)) { + getter = () => reactiveGetter(source, deep) + forceTrigger = true + } else if (isArray(source)) { + isMultiSource = true + forceTrigger = source.some(s => isReactive(s) || isShallow(s)) + getter = () => + source.map(s => { + if (isRef(s)) { + return s.value + } else if (isReactive(s)) { + return reactiveGetter(s, deep) + } else if (isFunction(s)) { + return call ? call(s, WatchErrorCodes.WATCH_GETTER) : s() + } else { + __DEV__ && warnInvalidSource(s, onWarn) + } + }) + } else if (isFunction(source)) { + if (cb) { + // getter with cb + getter = call + ? () => call(source, WatchErrorCodes.WATCH_GETTER) + : (source as () => any) + } else { + // no cb -> simple effect + getter = () => { + if (this.cleanups) { + pauseTracking() + try { + cleanup(this, this.cleanups) + } finally { + resetTracking() + } + } + const currentEffect = activeWatcher + activeWatcher = this try { - cleanup() + return call + ? call(source, WatchErrorCodes.WATCH_CALLBACK, [ + this.boundCleanup, + ]) + : source(this.boundCleanup) } finally { - resetTracking() + activeWatcher = currentEffect } } - const currentEffect = activeWatcher - activeWatcher = effect - try { - return call - ? call(source, WatchErrorCodes.WATCH_CALLBACK, [boundCleanup]) - : source(boundCleanup) - } finally { - activeWatcher = currentEffect - } } + } else { + getter = NOOP + __DEV__ && warnInvalidSource(source, onWarn) } - } else { - getter = NOOP - __DEV__ && warnInvalidSource(source) - } - if (cb && deep) { - const baseGetter = getter - const depth = deep === true ? Infinity : deep - getter = () => traverse(baseGetter(), depth) - } + if (cb && deep) { + const baseGetter = getter + const depth = deep === true ? Infinity : deep + getter = () => traverse(baseGetter(), depth) + } - const scope = getCurrentScope() - const watchHandle: WatchHandle = () => { - effect.stop() - if (scope && scope.active) { - remove(scope.effects, effect) + super(getter) + this.forceTrigger = forceTrigger + this.isMultiSource = isMultiSource + + if (once && cb) { + const _cb = cb + cb = (...args) => { + _cb(...args) + this.stop() + } } - } - if (once && cb) { - const _cb = cb - cb = (...args) => { - _cb(...args) - watchHandle() + this.cb = cb + + this.oldValue = isMultiSource + ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) + : INITIAL_WATCHER_VALUE + + const job = this.scheduler.bind(this) + + if (augmentJob) { + augmentJob(job) + } + + if (scheduler) { + this.scheduler = () => scheduler(job, false) + } + + if (__DEV__) { + this.onTrack = options.onTrack + this.onTrigger = options.onTrigger } - } - let oldValue: any = isMultiSource - ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE) - : INITIAL_WATCHER_VALUE + // initial run + if (cb) { + if (immediate) { + job() + } else { + this.oldValue = this.run() + } + } else if (scheduler) { + scheduler(job, true) + } else { + this.run() + } + } - const job = (immediateFirstRun?: boolean) => { - if (!effect.active || (!immediateFirstRun && !effect.dirty)) { + scheduler(): void { + if (!this.dirty) { return } - if (cb) { + if (this.cb) { // watch(source, cb) - const newValue = effect.run() + const newValue = this.run() + const { deep, call } = this.options if ( deep || - forceTrigger || - (isMultiSource - ? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i])) - : hasChanged(newValue, oldValue)) + this.forceTrigger || + (this.isMultiSource + ? (newValue as any[]).some((v, i) => hasChanged(v, this.oldValue[i])) + : hasChanged(newValue, this.oldValue)) ) { // cleanup before running cb again - if (cleanup) { - cleanup() + if (this.cleanups) { + cleanup(this, this.cleanups) } const currentWatcher = activeWatcher - activeWatcher = effect + activeWatcher = this try { const args = [ newValue, // pass undefined as the old value when it's changed for the first time - oldValue === INITIAL_WATCHER_VALUE + this.oldValue === INITIAL_WATCHER_VALUE ? undefined - : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE + : this.isMultiSource && this.oldValue[0] === INITIAL_WATCHER_VALUE ? [] - : oldValue, - boundCleanup, + : this.oldValue, + this.boundCleanup, ] call - ? call(cb!, WatchErrorCodes.WATCH_CALLBACK, args) + ? call(this.cb, WatchErrorCodes.WATCH_CALLBACK, args) : // @ts-expect-error - cb!(...args) - oldValue = newValue + this.cb(...args) + this.oldValue = newValue } finally { activeWatcher = currentWatcher } } } else { // watchEffect - effect.run() + this.run() } } +} - if (augmentJob) { - augmentJob(job) - } - - effect = new ReactiveEffect(getter) - - effect.scheduler = scheduler - ? () => scheduler(job, false) - : (job as EffectScheduler) - - boundCleanup = fn => onWatcherCleanup(fn, false, effect) - - cleanup = effect.onStop = () => { - const cleanups = cleanupMap.get(effect) - if (cleanups) { - if (call) { - call(cleanups, WatchErrorCodes.WATCH_CLEANUP) - } else { - for (const cleanup of cleanups) cleanup() - } - cleanupMap.delete(effect) - } - } - - if (__DEV__) { - effect.onTrack = options.onTrack - effect.onTrigger = options.onTrigger - } - - // initial run - if (cb) { - if (immediate) { - job(true) - } else { - oldValue = effect.run() - } - } else if (scheduler) { - scheduler(job.bind(null, true), true) - } else { - effect.run() - } +function reactiveGetter(source: object, deep: WatchOptions['deep']): unknown { + // traverse will happen in wrapped getter below + if (deep) return source + // for `deep: false | 0` or shallow reactive, only traverse root-level properties + if (isShallow(source) || deep === false || deep === 0) + return traverse(source, 1) + // for `deep: undefined` on a reactive object, deeply traverse all properties + return traverse(source) +} - watchHandle.pause = effect.pause.bind(effect) - watchHandle.resume = effect.resume.bind(effect) - watchHandle.stop = watchHandle +function warnInvalidSource(s: object, onWarn: WatchOptions['onWarn']): void { + ;(onWarn || warn)( + `Invalid watch source: `, + s, + `A watch source can only be a getter/effect function, a ref, ` + + `a reactive object, or an array of these types.`, + ) +} - return watchHandle +export function watch( + source: WatchSource | WatchSource[] | WatchEffect | object, + cb?: WatchCallback | null, + options: WatchOptions = EMPTY_OBJ, +): WatchHandle { + const effect = new WatcherEffect(source, cb, options) + const stop = effect.stop.bind(effect) as WatchHandle + stop.pause = effect.pause.bind(effect) + stop.resume = effect.resume.bind(effect) + stop.stop = stop + return stop } export function traverse( diff --git a/packages/runtime-core/__tests__/apiWatch.spec.ts b/packages/runtime-core/__tests__/apiWatch.spec.ts index 39032a63699..932a762da90 100644 --- a/packages/runtime-core/__tests__/apiWatch.spec.ts +++ b/packages/runtime-core/__tests__/apiWatch.spec.ts @@ -25,7 +25,9 @@ import { } from '@vue/runtime-test' import { type DebuggerEvent, + type EffectScope, ITERATE_KEY, + ReactiveEffect, type Ref, type ShallowRef, TrackOpTypes, @@ -1332,16 +1334,15 @@ describe('api: watch', () => { render(h(Comp), nodeOps.createElement('div')) expect(instance!).toBeDefined() - expect(instance!.scope.effects).toBeInstanceOf(Array) // includes the component's own render effect AND the watcher effect - expect(instance!.scope.effects.length).toBe(2) + expect(getEffectsCount(instance!.scope)).toBe(2) _show!.value = false await nextTick() await nextTick() - expect(instance!.scope.effects.length).toBe(0) + expect(getEffectsCount(instance!.scope)).toBe(0) }) test('this.$watch should pass `this.proxy` to watch source as the first argument ', () => { @@ -1489,7 +1490,7 @@ describe('api: watch', () => { createApp(Comp).mount(root) // should not record watcher in detached scope and only the instance's // own update effect - expect(instance!.scope.effects.length).toBe(1) + expect(getEffectsCount(instance!.scope)).toBe(1) }) test('watchEffect should keep running if created in a detached scope', async () => { @@ -1796,9 +1797,9 @@ describe('api: watch', () => { } const root = nodeOps.createElement('div') createApp(Comp).mount(root) - expect(instance!.scope.effects.length).toBe(2) + expect(getEffectsCount(instance!.scope)).toBe(2) unwatch!() - expect(instance!.scope.effects.length).toBe(1) + expect(getEffectsCount(instance!.scope)).toBe(1) const scope = effectScope() scope.run(() => { @@ -1806,14 +1807,14 @@ describe('api: watch', () => { console.log(num.value) }) }) - expect(scope.effects.length).toBe(1) + expect(getEffectsCount(scope)).toBe(1) unwatch!() - expect(scope.effects.length).toBe(0) + expect(getEffectsCount(scope)).toBe(0) scope.run(() => { watch(num, () => {}, { once: true, immediate: true }) }) - expect(scope.effects.length).toBe(0) + expect(getEffectsCount(scope)).toBe(0) }) // simplified case of VueUse syncRef @@ -2011,3 +2012,13 @@ describe('api: watch', () => { expect(onCleanup).toBeCalledTimes(0) }) }) + +function getEffectsCount(scope: EffectScope): number { + let n = 0 + for (let dep = scope.deps; dep !== undefined; dep = dep.nextDep) { + if (dep.dep instanceof ReactiveEffect) { + n++ + } + } + return n +} diff --git a/packages/runtime-core/__tests__/scheduler.spec.ts b/packages/runtime-core/__tests__/scheduler.spec.ts index f2de08a4032..39a316fcbb3 100644 --- a/packages/runtime-core/__tests__/scheduler.spec.ts +++ b/packages/runtime-core/__tests__/scheduler.spec.ts @@ -125,9 +125,8 @@ describe('scheduler', () => { calls.push('cb1') queueJob(job1) } - cb1.flags! |= SchedulerJobFlags.PRE - queueJob(cb1) + queueJob(cb1, true) await nextTick() expect(calls).toEqual(['cb1', 'job1']) }) @@ -143,24 +142,21 @@ describe('scheduler', () => { calls.push('cb1') queueJob(job1) // cb2 should execute before the job - queueJob(cb2) - queueJob(cb3) + queueJob(cb2, true) + queueJob(cb3, true) } - cb1.flags! |= SchedulerJobFlags.PRE const cb2: SchedulerJob = () => { calls.push('cb2') } - cb2.flags! |= SchedulerJobFlags.PRE cb2.id = 1 const cb3: SchedulerJob = () => { calls.push('cb3') } - cb3.flags! |= SchedulerJobFlags.PRE cb3.id = 1 - queueJob(cb1) + queueJob(cb1, true) await nextTick() expect(calls).toEqual(['cb1', 'cb2', 'cb3', 'job1']) }) @@ -171,24 +167,20 @@ describe('scheduler', () => { calls.push('job1') } job1.id = 1 - job1.flags! |= SchedulerJobFlags.PRE const job2: SchedulerJob = () => { calls.push('job2') queueJob(job5) - queueJob(job6) + queueJob(job6, true) } job2.id = 2 - job2.flags! |= SchedulerJobFlags.PRE const job3: SchedulerJob = () => { calls.push('job3') } job3.id = 2 - job3.flags! |= SchedulerJobFlags.PRE const job4: SchedulerJob = () => { calls.push('job4') } job4.id = 3 - job4.flags! |= SchedulerJobFlags.PRE const job5: SchedulerJob = () => { calls.push('job5') } @@ -197,14 +189,13 @@ describe('scheduler', () => { calls.push('job6') } job6.id = 2 - job6.flags! |= SchedulerJobFlags.PRE // We need several jobs to test this properly, otherwise // findInsertionIndex can yield the correct index by chance - queueJob(job4) - queueJob(job2) - queueJob(job3) - queueJob(job1) + queueJob(job4, true) + queueJob(job2, true) + queueJob(job3, true) + queueJob(job1, true) await nextTick() expect(calls).toEqual(['job1', 'job2', 'job3', 'job6', 'job5', 'job4']) @@ -217,8 +208,8 @@ describe('scheduler', () => { // when updating the props of a child component. This is handled // directly inside `updateComponentPreRender` to avoid non atomic // cb triggers (#1763) - queueJob(cb1) - queueJob(cb2) + queueJob(cb1, true) + queueJob(cb2, true) flushPreFlushCbs() calls.push('job1') } @@ -227,11 +218,9 @@ describe('scheduler', () => { // a cb triggers its parent job, which should be skipped queueJob(job1) } - cb1.flags! |= SchedulerJobFlags.PRE const cb2: SchedulerJob = () => { calls.push('cb2') } - cb2.flags! |= SchedulerJobFlags.PRE queueJob(job1) await nextTick() @@ -242,29 +231,25 @@ describe('scheduler', () => { const calls: string[] = [] const job1: SchedulerJob = () => { calls.push('job1') - queueJob(job3) - queueJob(job4) + queueJob(job3, true) + queueJob(job4, true) } // job1 has no id - job1.flags! |= SchedulerJobFlags.PRE const job2: SchedulerJob = () => { calls.push('job2') } job2.id = 1 - job2.flags! |= SchedulerJobFlags.PRE const job3: SchedulerJob = () => { calls.push('job3') } // job3 has no id - job3.flags! |= SchedulerJobFlags.PRE const job4: SchedulerJob = () => { calls.push('job4') } // job4 has no id - job4.flags! |= SchedulerJobFlags.PRE - queueJob(job1) - queueJob(job2) + queueJob(job1, true) + queueJob(job2, true) await nextTick() expect(calls).toEqual(['job1', 'job3', 'job4', 'job2']) }) @@ -273,9 +258,8 @@ describe('scheduler', () => { it('queue preFlushCb inside postFlushCb', async () => { const spy = vi.fn() const cb: SchedulerJob = () => spy() - cb.flags! |= SchedulerJobFlags.PRE queuePostFlushCb(() => { - queueJob(cb) + queueJob(cb, true) }) await nextTick() expect(spy).toHaveBeenCalled() @@ -476,16 +460,14 @@ describe('scheduler', () => { job3.id = 1 const job4: SchedulerJob = () => calls.push('job4') job4.id = 2 - job4.flags! |= SchedulerJobFlags.PRE const job5: SchedulerJob = () => calls.push('job5') // job5 has no id - job5.flags! |= SchedulerJobFlags.PRE queueJob(job1) queueJob(job2) queueJob(job3) - queueJob(job4) - queueJob(job5) + queueJob(job4, true) + queueJob(job5, true) await nextTick() expect(calls).toEqual(['job5', 'job3', 'job4', 'job2', 'job1']) }) @@ -685,40 +667,38 @@ describe('scheduler', () => { let recurse = true const job1: SchedulerJob = vi.fn(() => { - queueJob(job3) - queueJob(job3) + queueJob(job3, true) + queueJob(job3, true) flushPreFlushCbs() }) job1.id = 1 - job1.flags = SchedulerJobFlags.PRE const job2: SchedulerJob = vi.fn(() => { if (recurse) { // job2 does not allow recurse, so this shouldn't do anything - queueJob(job2) + queueJob(job2, true) // job3 is already queued, so this shouldn't do anything - queueJob(job3) + queueJob(job3, true) recurse = false } }) job2.id = 2 - job2.flags = SchedulerJobFlags.PRE const job3: SchedulerJob = vi.fn(() => { if (recurse) { - queueJob(job2) - queueJob(job3) + queueJob(job2, true) + queueJob(job3, true) // The jobs are already queued, so these should have no effect - queueJob(job2) - queueJob(job3) + queueJob(job2, true) + queueJob(job3, true) } }) job3.id = 3 - job3.flags = SchedulerJobFlags.ALLOW_RECURSE | SchedulerJobFlags.PRE + job3.flags = SchedulerJobFlags.ALLOW_RECURSE - queueJob(job1) + queueJob(job1, true) await nextTick() @@ -775,8 +755,7 @@ describe('scheduler', () => { spy() flushPreFlushCbs() } - job.flags! |= SchedulerJobFlags.PRE - queueJob(job) + queueJob(job, true) await nextTick() expect(spy).toHaveBeenCalledTimes(1) }) @@ -789,17 +768,15 @@ describe('scheduler', () => { calls.push('job1') } job1.id = 1 - job1.flags! |= SchedulerJobFlags.PRE const job2: SchedulerJob = () => { calls.push('job2') } job2.id = 2 - job2.flags! |= SchedulerJobFlags.PRE queuePostFlushCb(() => { - queueJob(job2) - queueJob(job1) + queueJob(job2, true) + queueJob(job1, true) // e.g. nested app.mount() call flushPreFlushCbs() diff --git a/packages/runtime-core/src/apiAsyncComponent.ts b/packages/runtime-core/src/apiAsyncComponent.ts index 07e7fc67fef..2769c2b3b78 100644 --- a/packages/runtime-core/src/apiAsyncComponent.ts +++ b/packages/runtime-core/src/apiAsyncComponent.ts @@ -214,7 +214,7 @@ export function defineAsyncComponent< ) { // parent is keep-alive, force update so the loaded component's // name is taken into account - ;(instance.parent as ComponentInternalInstance).update() + ;(instance.parent as ComponentInternalInstance).effect.run() } }) .catch(err => { diff --git a/packages/runtime-core/src/apiLifecycle.ts b/packages/runtime-core/src/apiLifecycle.ts index 93af3a2b01c..09efe090a1b 100644 --- a/packages/runtime-core/src/apiLifecycle.ts +++ b/packages/runtime-core/src/apiLifecycle.ts @@ -37,11 +37,11 @@ export function injectHook( // Set currentInstance during hook invocation. // This assumes the hook does not synchronously trigger other hooks, which // can only be false when the user does something really funky. - const reset = setCurrentInstance(target) + const prev = setCurrentInstance(target) try { return callWithAsyncErrorHandling(hook, target, type, args) } finally { - reset() + setCurrentInstance(...prev) resetTracking() } }) diff --git a/packages/runtime-core/src/apiSetupHelpers.ts b/packages/runtime-core/src/apiSetupHelpers.ts index 6a5532ad555..45b1d28f807 100644 --- a/packages/runtime-core/src/apiSetupHelpers.ts +++ b/packages/runtime-core/src/apiSetupHelpers.ts @@ -14,7 +14,6 @@ import { createSetupContext, getCurrentGenericInstance, setCurrentInstance, - unsetCurrentInstance, } from './component' import type { EmitFn, EmitsOptions, ObjectEmitsOptions } from './componentEmits' import type { @@ -511,7 +510,7 @@ export function withAsyncContext(getAwaitable: () => any): [any, () => void] { ) } let awaitable = getAwaitable() - unsetCurrentInstance() + setCurrentInstance(null, undefined) if (isPromise(awaitable)) { awaitable = awaitable.catch(e => { setCurrentInstance(ctx) diff --git a/packages/runtime-core/src/apiWatch.ts b/packages/runtime-core/src/apiWatch.ts index 8f6168cdf29..ec2a5a2482c 100644 --- a/packages/runtime-core/src/apiWatch.ts +++ b/packages/runtime-core/src/apiWatch.ts @@ -203,7 +203,7 @@ function doWatch( if (isFirstRun) { job() } else { - queueJob(job) + queueJob(job, true) } } } @@ -214,12 +214,9 @@ function doWatch( if (cb) { job.flags! |= SchedulerJobFlags.ALLOW_RECURSE } - if (isPre) { - job.flags! |= SchedulerJobFlags.PRE - if (instance) { - job.id = instance.uid - ;(job as SchedulerJob).i = instance - } + if (isPre && instance) { + job.id = instance.uid + ;(job as SchedulerJob).i = instance } } @@ -256,9 +253,9 @@ export function instanceWatch( cb = value.handler as Function options = value } - const reset = setCurrentInstance(this) + const prev = setCurrentInstance(this) const res = doWatch(getter, cb.bind(publicThis), options) - reset() + setCurrentInstance(...prev) return res } diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index f6ff8803c87..43c0877bd2b 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -97,7 +97,6 @@ import type { RendererElement } from './renderer' import { setCurrentInstance, setInSSRSetupState, - unsetCurrentInstance, } from './componentCurrentInstance' export * from './componentCurrentInstance' @@ -554,10 +553,6 @@ export interface ComponentInternalInstance extends GenericComponentInstance { * Render effect instance */ effect: ReactiveEffect - /** - * Force update render effect - */ - update: () => void /** * Render effect job to be passed to scheduler (checks if dirty) */ @@ -718,7 +713,6 @@ export function createComponentInstance( next: null, subTree: null!, // will be set synchronously right after creation effect: null!, - update: null!, // will be set synchronously right after creation job: null!, scope: new EffectScope(true /* detached */), render: null, @@ -891,7 +885,7 @@ function setupStatefulComponent( pauseTracking() const setupContext = (instance.setupContext = setup.length > 1 ? createSetupContext(instance) : null) - const reset = setCurrentInstance(instance) + const prev = setCurrentInstance(instance) const setupResult = callWithErrorHandling( setup, instance, @@ -903,7 +897,7 @@ function setupStatefulComponent( ) const isAsyncSetup = isPromise(setupResult) resetTracking() - reset() + setCurrentInstance(...prev) if ((isAsyncSetup || instance.sp) && !isAsyncWrapper(instance)) { // async setup / serverPrefetch, mark as async boundary for useId() @@ -911,6 +905,9 @@ function setupStatefulComponent( } if (isAsyncSetup) { + const unsetCurrentInstance = (): void => { + setCurrentInstance(null, undefined) + } setupResult.then(unsetCurrentInstance, unsetCurrentInstance) if (isSSR) { // return the promise so server-renderer can wait on it @@ -1083,13 +1080,13 @@ export function finishComponentSetup( // support for 2.x options if (__FEATURE_OPTIONS_API__ && !(__COMPAT__ && skipOptions)) { - const reset = setCurrentInstance(instance) + const prev = setCurrentInstance(instance) pauseTracking() try { applyOptions(instance) } finally { resetTracking() - reset() + setCurrentInstance(...prev) } } diff --git a/packages/runtime-core/src/componentCurrentInstance.ts b/packages/runtime-core/src/componentCurrentInstance.ts index c091b9c693d..bace000486c 100644 --- a/packages/runtime-core/src/componentCurrentInstance.ts +++ b/packages/runtime-core/src/componentCurrentInstance.ts @@ -4,6 +4,7 @@ import type { GenericComponentInstance, } from './component' import { currentRenderingInstance } from './componentRenderContext' +import { type EffectScope, setCurrentScope } from '@vue/reactivity' /** * @internal @@ -25,7 +26,10 @@ export let isInSSRComponentSetup = false export let setInSSRSetupState: (state: boolean) => void -let internalSetCurrentInstance: ( +/** + * @internal + */ +export let simpleSetCurrentInstance: ( instance: GenericComponentInstance | null, ) => void @@ -53,7 +57,7 @@ if (__SSR__) { else setters[0](v) } } - internalSetCurrentInstance = registerGlobalSetter( + simpleSetCurrentInstance = registerGlobalSetter( `__VUE_INSTANCE_SETTERS__`, v => (currentInstance = v), ) @@ -66,7 +70,7 @@ if (__SSR__) { v => (isInSSRComponentSetup = v), ) } else { - internalSetCurrentInstance = i => { + simpleSetCurrentInstance = i => { currentInstance = i } setInSSRSetupState = v => { @@ -74,34 +78,14 @@ if (__SSR__) { } } -export const setCurrentInstance = (instance: GenericComponentInstance) => { +export const setCurrentInstance = ( + instance: GenericComponentInstance | null, + scope: EffectScope | undefined = instance !== null + ? instance.scope + : undefined, +): [GenericComponentInstance | null, EffectScope | undefined] => { const prev = currentInstance - internalSetCurrentInstance(instance) - instance.scope.on() - return (): void => { - instance.scope.off() - internalSetCurrentInstance(prev) - } -} - -export const unsetCurrentInstance = (): void => { - currentInstance && currentInstance.scope.off() - internalSetCurrentInstance(null) -} - -/** - * Exposed for vapor only. Vapor never runs during SSR so we don't want to pay - * for the extra overhead - * @internal - */ -export const simpleSetCurrentInstance = ( - i: GenericComponentInstance | null, - unset?: GenericComponentInstance | null, -): void => { - currentInstance = i - if (unset) { - unset.scope.off() - } else if (i) { - i.scope.on() - } + simpleSetCurrentInstance(instance) + const prevScope = setCurrentScope(scope) + return [prev, prevScope] } diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts index d0fe97ff03d..bdbb66ccd0e 100644 --- a/packages/runtime-core/src/componentProps.ts +++ b/packages/runtime-core/src/componentProps.ts @@ -522,7 +522,7 @@ function baseResolveDefault( key: string, ) { let value - const reset = setCurrentInstance(instance) + const prev = setCurrentInstance(instance) const props = toRaw(instance.props) value = factory.call( __COMPAT__ && isCompatEnabled(DeprecationTypes.PROPS_DEFAULT_THIS, instance) @@ -530,7 +530,7 @@ function baseResolveDefault( : null, props, ) - reset() + setCurrentInstance(...prev) return value } diff --git a/packages/runtime-core/src/componentPublicInstance.ts b/packages/runtime-core/src/componentPublicInstance.ts index a43c99e2f45..3605e27c4f1 100644 --- a/packages/runtime-core/src/componentPublicInstance.ts +++ b/packages/runtime-core/src/componentPublicInstance.ts @@ -383,7 +383,7 @@ export const publicPropertiesMap: PublicPropertiesMap = $forceUpdate: i => i.f || (i.f = () => { - queueJob(i.update) + queueJob(() => i.effect.run()) }), $nextTick: i => i.n || (i.n = nextTick.bind(i.proxy!)), $watch: i => (__FEATURE_OPTIONS_API__ ? instanceWatch.bind(i) : NOOP), diff --git a/packages/runtime-core/src/components/BaseTransition.ts b/packages/runtime-core/src/components/BaseTransition.ts index 2b58bc3fc43..71977df20d1 100644 --- a/packages/runtime-core/src/components/BaseTransition.ts +++ b/packages/runtime-core/src/components/BaseTransition.ts @@ -224,7 +224,7 @@ const BaseTransitionImpl: ComponentOptions = { // #6835 // it also needs to be updated when active is undefined if (!(instance.job.flags! & SchedulerJobFlags.DISPOSED)) { - instance.update() + instance.effect.run() } delete leavingHooks.afterLeave oldInnerChild = undefined diff --git a/packages/runtime-core/src/hmr.ts b/packages/runtime-core/src/hmr.ts index ed5d8b081a0..6483e22416f 100644 --- a/packages/runtime-core/src/hmr.ts +++ b/packages/runtime-core/src/hmr.ts @@ -100,7 +100,7 @@ function rerender(id: string, newRender?: Function): void { } else { const i = instance as ComponentInternalInstance i.renderCache = [] - i.update() + i.effect.run() } nextTick(() => { isHmrUpdating = false @@ -160,7 +160,7 @@ function reload(id: string, newComp: HMRComponent): void { if (parent.vapor) { parent.hmrRerender!() } else { - ;(parent as ComponentInternalInstance).update() + ;(parent as ComponentInternalInstance).effect.run() } nextTick(() => { isHmrUpdating = false diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index e309554f2f6..617b4d4d8d8 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -543,6 +543,7 @@ export { */ export { currentInstance, + setCurrentInstance, simpleSetCurrentInstance, } from './componentCurrentInstance' /** diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index 5a18d62a8e1..48f915402e5 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -59,6 +59,7 @@ import { ReactiveEffect, pauseTracking, resetTracking, + setCurrentScope, } from '@vue/reactivity' import { updateProps } from './componentProps' import { updateSlots } from './componentSlots' @@ -1303,7 +1304,7 @@ function baseCreateRenderer( // normal update instance.next = n2 // instance.update is the reactive effect. - instance.update() + instance.effect.run() } } else { // no update needed. just copy over properties @@ -1594,13 +1595,11 @@ function baseCreateRenderer( } // create reactive effect for rendering - instance.scope.on() + const prevScope = setCurrentScope(instance.scope) const effect = (instance.effect = new ReactiveEffect(componentUpdateFn)) - instance.scope.off() + setCurrentScope(prevScope) - const update = (instance.update = effect.run.bind(effect)) - const job: SchedulerJob = (instance.job = () => - effect.dirty && effect.run()) + const job: SchedulerJob = (instance.job = effect.scheduler.bind(effect)) job.i = instance job.id = instance.uid effect.scheduler = () => queueJob(job) @@ -1618,7 +1617,7 @@ function baseCreateRenderer( : void 0 } - update() + effect.run() } const updateComponentPreRender = ( diff --git a/packages/runtime-core/src/scheduler.ts b/packages/runtime-core/src/scheduler.ts index a75eba300f7..2b5b8e98ed8 100644 --- a/packages/runtime-core/src/scheduler.ts +++ b/packages/runtime-core/src/scheduler.ts @@ -1,10 +1,9 @@ import { ErrorCodes, callWithErrorHandling, handleError } from './errorHandling' -import { NOOP, isArray } from '@vue/shared' +import { isArray } from '@vue/shared' import { type GenericComponentInstance, getComponentName } from './component' export enum SchedulerJobFlags { QUEUED = 1 << 0, - PRE = 1 << 1, /** * Indicates whether the effect is allowed to recursively trigger itself * when managed by the scheduler. @@ -20,8 +19,8 @@ export enum SchedulerJobFlags { * responsibility to perform recursive state mutation that eventually * stabilizes (#1727). */ - ALLOW_RECURSE = 1 << 2, - DISPOSED = 1 << 3, + ALLOW_RECURSE = 1 << 1, + DISPOSED = 1 << 2, } export interface SchedulerJob extends Function { @@ -40,15 +39,21 @@ export interface SchedulerJob extends Function { export type SchedulerJobs = SchedulerJob | SchedulerJob[] -const queue: SchedulerJob[] = [] -let flushIndex = -1 +const queueMainJobs: (SchedulerJob | undefined)[] = [] +const queuePreJobs: (SchedulerJob | undefined)[] = [] +const queuePostJobs: (SchedulerJob | undefined)[] = [] -const pendingPostFlushCbs: SchedulerJob[] = [] -let activePostFlushCbs: SchedulerJob[] | null = null +let mainFlushIndex = -1 +let preFlushIndex = -1 let postFlushIndex = 0 +let mainJobsLength = 0 +let preJobsLength = 0 +let postJobsLength = 0 +let flushingPreJob = false +let activePostFlushCbs: SchedulerJob[] | null = null +let currentFlushPromise: Promise | null = null const resolvedPromise = /*@__PURE__*/ Promise.resolve() as Promise -let currentFlushPromise: Promise | null = null const RECURSION_LIMIT = 100 type CountMap = Map @@ -70,18 +75,15 @@ export function nextTick( // A pre watcher will have the same id as its component's update job. The // watcher should be inserted immediately before the update job. This allows // watchers to be skipped if the component is unmounted by the parent update. -function findInsertionIndex(id: number) { - let start = flushIndex + 1 - let end = queue.length +function findInsertionIndex(id: number, isPre: boolean) { + let start = (isPre ? preFlushIndex : mainFlushIndex) + 1 + let end = isPre ? preJobsLength : mainJobsLength + const queue = isPre ? queuePreJobs : queueMainJobs while (start < end) { const middle = (start + end) >>> 1 - const middleJob = queue[middle] - const middleJobId = getId(middleJob) - if ( - middleJobId < id || - (middleJobId === id && middleJob.flags! & SchedulerJobFlags.PRE) - ) { + const middleJob = queue[middle]! + if (middleJob.id! <= id) { start = middle + 1 } else { end = middle @@ -94,22 +96,25 @@ function findInsertionIndex(id: number) { /** * @internal for runtime-vapor only */ -export function queueJob(job: SchedulerJob): void { - if (!(job.flags! & SchedulerJobFlags.QUEUED)) { - const jobId = getId(job) - const lastJob = queue[queue.length - 1] +export function queueJob(job: SchedulerJob, isPre = false): void { + const flags = job.flags! + if (!(flags & SchedulerJobFlags.QUEUED)) { + if (job.id === undefined) { + job.id = isPre ? -1 : Infinity + } + const queueLength = isPre ? preJobsLength : mainJobsLength + const queue = isPre ? queuePreJobs : queueMainJobs if ( - !lastJob || + !queueLength || // fast path when the job id is larger than the tail - (!(job.flags! & SchedulerJobFlags.PRE) && jobId >= getId(lastJob)) + job.id >= queue[queueLength - 1]!.id! ) { - queue.push(job) + queue[queueLength] = job } else { - queue.splice(findInsertionIndex(jobId), 0, job) + queue.splice(findInsertionIndex(job.id, isPre), 0, job) } - - job.flags! |= SchedulerJobFlags.QUEUED - + isPre ? preJobsLength++ : mainJobsLength++ + job.flags! = flags | SchedulerJobFlags.QUEUED queueFlush() } } @@ -125,17 +130,25 @@ function queueFlush() { export function queuePostFlushCb(cb: SchedulerJobs): void { if (!isArray(cb)) { + if (cb.id === undefined) { + cb.id = Infinity + } if (activePostFlushCbs && cb.id === -1) { activePostFlushCbs.splice(postFlushIndex + 1, 0, cb) } else if (!(cb.flags! & SchedulerJobFlags.QUEUED)) { - pendingPostFlushCbs.push(cb) + queuePostJobs[postJobsLength++] = cb cb.flags! |= SchedulerJobFlags.QUEUED } } else { // if cb is an array, it is a component lifecycle hook which can only be // triggered by a job, which is already deduped in the main queue, so // we can skip duplicate check here to improve perf - pendingPostFlushCbs.push(...cb) + for (const job of cb) { + if (job.id === undefined) { + job.id = Infinity + } + queuePostJobs[postJobsLength++] = job + } } queueFlush() } @@ -143,22 +156,25 @@ export function queuePostFlushCb(cb: SchedulerJobs): void { export function flushPreFlushCbs( instance?: GenericComponentInstance, seen?: CountMap, - // skip the current job - i: number = flushIndex + 1, ): void { if (__DEV__) { seen = seen || new Map() } - for (; i < queue.length; i++) { - const cb = queue[i] - if (cb && cb.flags! & SchedulerJobFlags.PRE) { + for ( + let i = flushingPreJob ? preFlushIndex + 1 : preFlushIndex; + i < preJobsLength; + i++ + ) { + const cb = queuePreJobs[i] + if (cb) { if (instance && cb.id !== instance.uid) { continue } if (__DEV__ && checkRecursiveUpdates(seen!, cb)) { continue } - queue.splice(i, 1) + queuePreJobs.splice(i, 1) + preJobsLength-- i-- if (cb.flags! & SchedulerJobFlags.ALLOW_RECURSE) { cb.flags! &= ~SchedulerJobFlags.QUEUED @@ -172,19 +188,24 @@ export function flushPreFlushCbs( } export function flushPostFlushCbs(seen?: CountMap): void { - if (pendingPostFlushCbs.length) { - const deduped = [...new Set(pendingPostFlushCbs)].sort( - (a, b) => getId(a) - getId(b), - ) - pendingPostFlushCbs.length = 0 + if (postJobsLength) { + const deduped = new Set() + for (let i = 0; i < postJobsLength; i++) { + const job = queuePostJobs[i]! + queuePostJobs[i] = undefined + deduped.add(job) + } + postJobsLength = 0 + + const sorted = [...deduped].sort((a, b) => a.id! - b.id!) // #1947 already has active queue, nested flushPostFlushCbs call if (activePostFlushCbs) { - activePostFlushCbs.push(...deduped) + activePostFlushCbs.push(...sorted) return } - activePostFlushCbs = deduped + activePostFlushCbs = sorted if (__DEV__) { seen = seen || new Map() } @@ -227,28 +248,49 @@ export function flushOnAppMount(): void { } } -const getId = (job: SchedulerJob): number => - job.id == null ? (job.flags! & SchedulerJobFlags.PRE ? -1 : Infinity) : job.id - function flushJobs(seen?: CountMap) { if (__DEV__) { seen = seen || new Map() } - // conditional usage of checkRecursiveUpdate must be determined out of - // try ... catch block since Rollup by default de-optimizes treeshaking - // inside try-catch. This can leave all warning code unshaked. Although - // they would get eventually shaken by a minifier like terser, some minifiers - // would fail to do that (e.g. https://github.com/evanw/esbuild/issues/1610) - const check = __DEV__ - ? (job: SchedulerJob) => checkRecursiveUpdates(seen!, job) - : NOOP - try { - for (flushIndex = 0; flushIndex < queue.length; flushIndex++) { - const job = queue[flushIndex] - if (job && !(job.flags! & SchedulerJobFlags.DISPOSED)) { - if (__DEV__ && check(job)) { + preFlushIndex = 0 + mainFlushIndex = 0 + + while (preFlushIndex < preJobsLength || mainFlushIndex < mainJobsLength) { + let job: SchedulerJob + if (preFlushIndex < preJobsLength) { + if (mainFlushIndex < mainJobsLength) { + const preJob = queuePreJobs[preFlushIndex]! + const mainJob = queueMainJobs[mainFlushIndex]! + if (preJob.id! <= mainJob.id!) { + job = preJob + flushingPreJob = true + } else { + job = mainJob + flushingPreJob = false + } + } else { + job = queuePreJobs[preFlushIndex]! + flushingPreJob = true + } + } else { + job = queueMainJobs[mainFlushIndex]! + flushingPreJob = false + } + + if (!(job.flags! & SchedulerJobFlags.DISPOSED)) { + // conditional usage of checkRecursiveUpdate must be determined out of + // try ... catch block since Rollup by default de-optimizes treeshaking + // inside try-catch. This can leave all warning code unshaked. Although + // they would get eventually shaken by a minifier like terser, some minifiers + // would fail to do that (e.g. https://github.com/evanw/esbuild/issues/1610) + if (__DEV__ && checkRecursiveUpdates(seen!, job)) { + if (flushingPreJob) { + queuePreJobs[preFlushIndex++] = undefined + } else { + queueMainJobs[mainFlushIndex++] = undefined + } continue } if (job.flags! & SchedulerJobFlags.ALLOW_RECURSE) { @@ -263,24 +305,41 @@ function flushJobs(seen?: CountMap) { job.flags! &= ~SchedulerJobFlags.QUEUED } } + + if (flushingPreJob) { + queuePreJobs[preFlushIndex++] = undefined + } else { + queueMainJobs[mainFlushIndex++] = undefined + } } } finally { // If there was an error we still need to clear the QUEUED flags - for (; flushIndex < queue.length; flushIndex++) { - const job = queue[flushIndex] + while (preFlushIndex < preJobsLength) { + const job = queuePreJobs[preFlushIndex] + queuePreJobs[preFlushIndex++] = undefined + if (job) { + job.flags! &= ~SchedulerJobFlags.QUEUED + } + } + while (mainFlushIndex < mainJobsLength) { + const job = queueMainJobs[mainFlushIndex] + queueMainJobs[mainFlushIndex++] = undefined if (job) { job.flags! &= ~SchedulerJobFlags.QUEUED } } - flushIndex = -1 - queue.length = 0 + preFlushIndex = -1 + mainFlushIndex = -1 + preJobsLength = 0 + mainJobsLength = 0 + flushingPreJob = false flushPostFlushCbs(seen) currentFlushPromise = null // If new jobs have been added to either queue, keep flushing - if (queue.length || pendingPostFlushCbs.length) { + if (preJobsLength || mainJobsLength || postJobsLength) { flushJobs(seen) } } diff --git a/packages/runtime-vapor/__tests__/apiWatch.spec.ts b/packages/runtime-vapor/__tests__/apiWatch.spec.ts index 068791b8ad2..290c509552c 100644 --- a/packages/runtime-vapor/__tests__/apiWatch.spec.ts +++ b/packages/runtime-vapor/__tests__/apiWatch.spec.ts @@ -1,4 +1,6 @@ import { + type EffectScope, + ReactiveEffect, currentInstance, effectScope, nextTick, @@ -298,7 +300,7 @@ describe('apiWatch', () => { define(Comp).render() // should not record watcher in detached scope // the 1 is the props validation effect - expect(instance!.scope.effects.length).toBe(1) + expect(getEffectsCount(instance!.scope)).toBe(1) }) test('watchEffect should keep running if created in a detached scope', async () => { @@ -336,3 +338,13 @@ describe('apiWatch', () => { expect(countW).toBe(2) }) }) + +function getEffectsCount(scope: EffectScope): number { + let n = 0 + for (let dep = scope.deps; dep !== undefined; dep = dep.nextDep) { + if (dep.dep instanceof ReactiveEffect) { + n++ + } + } + return n +} diff --git a/packages/runtime-vapor/__tests__/component.spec.ts b/packages/runtime-vapor/__tests__/component.spec.ts index 5fdff8eafe4..07699ba0fc4 100644 --- a/packages/runtime-vapor/__tests__/component.spec.ts +++ b/packages/runtime-vapor/__tests__/component.spec.ts @@ -1,4 +1,6 @@ import { + type EffectScope, + ReactiveEffect, type Ref, inject, nextTick, @@ -280,12 +282,12 @@ describe('component', () => { const i = instance as VaporComponentInstance // watchEffect + renderEffect + props validation effect - expect(i.scope.effects.length).toBe(3) + expect(getEffectsCount(i.scope)).toBe(3) expect(host.innerHTML).toBe('
0
') app.unmount() expect(host.innerHTML).toBe('') - expect(i.scope.effects.length).toBe(0) + expect(getEffectsCount(i.scope)).toBe(0) }) test('should mount component only with template in production mode', () => { @@ -328,3 +330,13 @@ describe('component', () => { ).toHaveBeenWarned() }) }) + +function getEffectsCount(scope: EffectScope): number { + let n = 0 + for (let dep = scope.deps; dep !== undefined; dep = dep.nextDep) { + if (dep.dep instanceof ReactiveEffect) { + n++ + } + } + return n +} diff --git a/packages/runtime-vapor/__tests__/dom/prop.spec.ts b/packages/runtime-vapor/__tests__/dom/prop.spec.ts index e879b7103e5..9d07b413541 100644 --- a/packages/runtime-vapor/__tests__/dom/prop.spec.ts +++ b/packages/runtime-vapor/__tests__/dom/prop.spec.ts @@ -12,20 +12,13 @@ import { } from '../../src/dom/prop' import { setStyle } from '../../src/dom/prop' import { VaporComponentInstance } from '../../src/component' -import { - currentInstance, - ref, - simpleSetCurrentInstance, -} from '@vue/runtime-dom' +import { ref, setCurrentInstance } from '@vue/runtime-dom' let removeComponentInstance = NOOP beforeEach(() => { const instance = new VaporComponentInstance({}, {}, null) - const prev = currentInstance - simpleSetCurrentInstance(instance) - removeComponentInstance = () => { - simpleSetCurrentInstance(prev) - } + const prev = setCurrentInstance(instance) + removeComponentInstance = () => setCurrentInstance(...prev) }) afterEach(() => { removeComponentInstance() diff --git a/packages/runtime-vapor/__tests__/for.spec.ts b/packages/runtime-vapor/__tests__/for.spec.ts index 7ba6023b1e9..02120002607 100644 --- a/packages/runtime-vapor/__tests__/for.spec.ts +++ b/packages/runtime-vapor/__tests__/for.spec.ts @@ -94,7 +94,7 @@ describe('createFor', () => { }) return span }, - item => item.name, + item => item, ) return n1 }).render() diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 0cd8317532f..9df529f995e 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -8,8 +8,9 @@ import { shallowReadArray, shallowRef, toReactive, + watch, } from '@vue/reactivity' -import { getSequence, isArray, isObject, isString } from '@vue/shared' +import { isArray, isObject, isString } from '@vue/shared' import { createComment, createTextNode } from './dom/node' import { type Block, @@ -78,12 +79,18 @@ export const createFor = ( let oldBlocks: ForBlock[] = [] let newBlocks: ForBlock[] let parent: ParentNode | undefined | null + // useSelector only + let currentKey: any // TODO handle this in hydration const parentAnchor = __DEV__ ? createComment('for') : createTextNode() const frag = new VaporFragment(oldBlocks) const instance = currentInstance! - const canUseFastRemove = flags & VaporVForFlags.FAST_REMOVE - const isComponent = flags & VaporVForFlags.IS_COMPONENT + const canUseFastRemove = !!(flags & VaporVForFlags.FAST_REMOVE) + const isComponent = !!(flags & VaporVForFlags.IS_COMPONENT) + const selectors: { + deregister: (key: any) => void + cleanup: () => void + }[] = [] if (__DEV__ && !instance) { warn('createFor() can only be used inside setup()') @@ -111,9 +118,12 @@ export const createFor = ( } } else if (!newLength) { // fast path for clearing all + for (const selector of selectors) { + selector.cleanup() + } const doRemove = !canUseFastRemove for (let i = 0; i < oldLength; i++) { - unmount(oldBlocks[i], doRemove) + unmount(oldBlocks[i], doRemove, false) } if (canUseFastRemove) { parent!.textContent = '' @@ -132,150 +142,135 @@ export const createFor = ( unmount(oldBlocks[i]) } } else { - let i = 0 - let e1 = oldLength - 1 // prev ending index - let e2 = newLength - 1 // next ending index - - // 1. sync from start - // (a b) c - // (a b) d e - while (i <= e1 && i <= e2) { - if (tryPatchIndex(source, i)) { - i++ - } else { - break + const commonLength = Math.min(oldLength, newLength) + const oldKeyToIndexMap = new Map() + const pendingNews: [ + index: number, + item: ReturnType, + key: any, + ][] = [] + + let defaultAnchor: Node = parentAnchor + let right = 0 + let left = 0 + + while (right < commonLength) { + const index = newLength - right - 1 + const item = getItem(source, index) + const key = getKey.apply(null, item) + const block = oldBlocks[oldLength - right - 1] + if (block.key === key) { + update(block, ...item) + newBlocks[index] = block + right++ + continue + } + if (right !== 0) { + defaultAnchor = normalizeAnchor(newBlocks[index + 1].nodes) } + break } - // 2. sync from end - // a (b c) - // d e (b c) - while (i <= e1 && i <= e2) { - if (tryPatchIndex(source, i)) { - e1-- - e2-- + while (left < commonLength - right) { + const item = getItem(source, left) + const key = getKey.apply(null, item) + const oldBlock = oldBlocks[left] + const oldKey = oldBlock.key + if (oldKey === key) { + update((newBlocks[left] = oldBlock), item[0]) } else { - break + pendingNews.push([left, item, key]) + oldKeyToIndexMap.set(oldKey, left) } + left++ } - // 3. common sequence + mount - // (a b) - // (a b) c - // i = 2, e1 = 1, e2 = 2 - // (a b) - // c (a b) - // i = 0, e1 = -1, e2 = 0 - if (i > e1) { - if (i <= e2) { - const nextPos = e2 + 1 - const anchor = - nextPos < newLength - ? normalizeAnchor(newBlocks[nextPos].nodes) - : parentAnchor - while (i <= e2) { - mount(source, i, anchor) - i++ - } - } + for (let i = left; i < oldLength - right; i++) { + oldKeyToIndexMap.set(oldBlocks[i].key, i) } - // 4. common sequence + unmount - // (a b) c - // (a b) - // i = 2, e1 = 2, e2 = 1 - // a (b c) - // (b c) - // i = 0, e1 = 0, e2 = -1 - else if (i > e2) { - while (i <= e1) { - unmount(oldBlocks[i]) - i++ - } + const prepareLength = Math.min(newLength - right, commonLength) + for (let i = left; i < prepareLength; i++) { + const item = getItem(source, i) + const key = getKey.apply(null, item) + pendingNews.push([i, item, key]) } - // 5. unknown sequence - // [i ... e1 + 1]: a b [c d e] f g - // [i ... e2 + 1]: a b [e d c h] f g - // i = 2, e1 = 4, e2 = 5 - else { - const s1 = i // prev starting index - const s2 = i // next starting index - - // 5.1 build key:index map for newChildren - const keyToNewIndexMap = new Map() - for (i = s2; i <= e2; i++) { - keyToNewIndexMap.set(getKey(...getItem(source, i)), i) + const pendingMounts: [ + index: number, + item: ReturnType, + key: any, + anchorIndex: number, + ][] = [] + const moveOrMount = ( + index: number, + item: ReturnType, + key: any, + anchorIndex: number, + ) => { + const oldIndex = oldKeyToIndexMap.get(key) + if (oldIndex !== undefined) { + const block = (newBlocks[index] = oldBlocks[oldIndex]) + update(block, ...item) + insert( + block, + parent!, + anchorIndex === -1 + ? defaultAnchor + : normalizeAnchor(newBlocks[anchorIndex].nodes), + ) + oldKeyToIndexMap.delete(key) + } else { + pendingMounts.push([index, item, key, anchorIndex]) } + } - // 5.2 loop through old children left to be patched and try to patch - // matching nodes & remove nodes that are no longer present - let j - let patched = 0 - const toBePatched = e2 - s2 + 1 - let moved = false - // used to track whether any node has moved - let maxNewIndexSoFar = 0 - // works as Map - // Note that oldIndex is offset by +1 - // and oldIndex = 0 is a special value indicating the new node has - // no corresponding old node. - // used for determining longest stable subsequence - const newIndexToOldIndexMap = new Array(toBePatched).fill(0) - - for (i = s1; i <= e1; i++) { - const prevBlock = oldBlocks[i] - if (patched >= toBePatched) { - // all new children have been patched so this can only be a removal - unmount(prevBlock) - } else { - const newIndex = keyToNewIndexMap.get(prevBlock.key) - if (newIndex == null) { - unmount(prevBlock) - } else { - newIndexToOldIndexMap[newIndex - s2] = i + 1 - if (newIndex >= maxNewIndexSoFar) { - maxNewIndexSoFar = newIndex - } else { - moved = true - } - update( - (newBlocks[newIndex] = prevBlock), - ...getItem(source, newIndex), - ) - patched++ - } - } - } + for (let i = pendingNews.length - 1; i >= 0; i--) { + const [index, item, key] = pendingNews[i] + moveOrMount( + index, + item, + key, + index < prepareLength - 1 ? index + 1 : -1, + ) + } + + for (let i = prepareLength; i < newLength - right; i++) { + const item = getItem(source, i) + const key = getKey.apply(null, item) + moveOrMount(i, item, key, -1) + } - // 5.3 move and mount - // generate longest stable subsequence only when nodes have moved - const increasingNewIndexSequence = moved - ? getSequence(newIndexToOldIndexMap) - : [] - j = increasingNewIndexSequence.length - 1 - // looping backwards so that we can use last patched node as anchor - for (i = toBePatched - 1; i >= 0; i--) { - const nextIndex = s2 + i - const anchor = - nextIndex + 1 < newLength - ? normalizeAnchor(newBlocks[nextIndex + 1].nodes) - : parentAnchor - if (newIndexToOldIndexMap[i] === 0) { - // mount new - mount(source, nextIndex, anchor) - } else if (moved) { - // move if: - // There is no stable subsequence (e.g. a reverse) - // OR current node is not among the stable sequence - if (j < 0 || i !== increasingNewIndexSequence[j]) { - insert(newBlocks[nextIndex].nodes, parent!, anchor) - } else { - j-- - } - } + const shouldUseFastRemove = pendingMounts.length === newLength + + for (const i of oldKeyToIndexMap.values()) { + unmount( + oldBlocks[i], + !(shouldUseFastRemove && canUseFastRemove), + !shouldUseFastRemove, + ) + } + if (shouldUseFastRemove) { + for (const selector of selectors) { + selector.cleanup() + } + if (canUseFastRemove) { + parent!.textContent = '' + parent!.appendChild(parentAnchor) } } + + for (const [index, item, key, anchorIndex] of pendingMounts) { + mount( + source, + index, + item, + key, + anchorIndex === -1 + ? defaultAnchor + : normalizeAnchor(newBlocks[anchorIndex].nodes), + ) + } } } @@ -293,14 +288,16 @@ export const createFor = ( const mount = ( source: ResolvedSource, idx: number, + [item, key, index] = getItem(source, idx), + key2 = getKey && getKey(item, key, index), anchor: Node | undefined = parentAnchor, ): ForBlock => { - const [item, key, index] = getItem(source, idx) const itemRef = shallowRef(item) // avoid creating refs if the render fn doesn't need it const keyRef = needKey ? shallowRef(key) : undefined const indexRef = needIndex ? shallowRef(index) : undefined + currentKey = key2 let nodes: Block let scope: EffectScope | undefined if (isComponent) { @@ -319,7 +316,7 @@ export const createFor = ( itemRef, keyRef, indexRef, - getKey && getKey(item, key, index), + key2, )) if (parent) insert(block.nodes, parent, anchor) @@ -327,15 +324,6 @@ export const createFor = ( return block } - const tryPatchIndex = (source: any, idx: number) => { - const block = oldBlocks[idx] - const [item, key, index] = getItem(source, idx) - if (block.key === getKey!(item, key, index)) { - update((newBlocks[idx] = block), item) - return true - } - } - const update = ( { itemRef, keyRef, indexRef }: ForBlock, newItem: any, @@ -353,9 +341,18 @@ export const createFor = ( } } - const unmount = ({ nodes, scope }: ForBlock, doRemove = true) => { - scope && scope.stop() - doRemove && removeBlock(nodes, parent!) + const unmount = (block: ForBlock, doRemove = true, doDeregister = true) => { + if (!isComponent) { + block.scope!.stop() + } + if (doRemove) { + removeBlock(block.nodes, parent!) + } + if (doDeregister) { + for (const selector of selectors) { + selector.deregister(block.key) + } + } } if (flags & VaporVForFlags.ONCE) { @@ -368,7 +365,61 @@ export const createFor = ( insert(frag, _insertionParent, _insertionAnchor) } + // @ts-expect-error + frag.useSelector = useSelector + return frag + + function useSelector( + getActiveKey: () => any, + ): (key: any, cb: () => void) => void { + let operMap = new Map void)[]>() + let activeKey = getActiveKey() + let activeOpers: (() => void)[] | undefined + + watch(getActiveKey, newValue => { + if (activeOpers !== undefined) { + for (const oper of activeOpers) { + oper() + } + } + activeOpers = operMap.get(newValue) + if (activeOpers !== undefined) { + for (const oper of activeOpers) { + oper() + } + } + }) + + selectors.push({ deregister, cleanup }) + return register + + function cleanup() { + operMap = new Map() + activeOpers = undefined + } + + function register(oper: () => void) { + oper() + let opers = operMap.get(currentKey) + if (opers !== undefined) { + opers.push(oper) + } else { + opers = [oper] + operMap.set(currentKey, opers) + if (currentKey === activeKey) { + activeOpers = opers + } + } + } + + function deregister(key: any) { + operMap.delete(key) + if (key === activeKey) { + activeOpers = undefined + } + } + } } export function createForSlots( diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 548babebf8b..83113e356f6 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -20,7 +20,7 @@ import { pushWarningContext, queuePostFlushCb, registerHMR, - simpleSetCurrentInstance, + setCurrentInstance, startMeasure, unregisterHMR, warn, @@ -191,8 +191,7 @@ export function createComponent( instance.emitsOptions = normalizeEmitsOptions(component) } - const prev = currentInstance - simpleSetCurrentInstance(instance) + const prev = setCurrentInstance(instance) pauseTracking() if (__DEV__) { @@ -260,7 +259,7 @@ export function createComponent( } resetTracking() - simpleSetCurrentInstance(prev, instance) + setCurrentInstance(...prev) if (__DEV__) { popWarningContext() diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index a5e9daad229..7aa11694172 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -12,12 +12,11 @@ import type { VaporComponent, VaporComponentInstance } from './component' import { type NormalizedPropsOptions, baseNormalizePropsOptions, - currentInstance, isEmitListener, popWarningContext, pushWarningContext, resolvePropValue, - simpleSetCurrentInstance, + setCurrentInstance, validateProps, warn, } from '@vue/runtime-dom' @@ -257,10 +256,9 @@ function resolveDefault( factory: (props: Record) => unknown, instance: VaporComponentInstance, ) { - const prev = currentInstance - simpleSetCurrentInstance(instance) + const prev = setCurrentInstance(instance) const res = factory.call(null, instance.props) - simpleSetCurrentInstance(prev, instance) + setCurrentInstance(...prev) return res } diff --git a/packages/runtime-vapor/src/hmr.ts b/packages/runtime-vapor/src/hmr.ts index 741f385861d..c96c1afa130 100644 --- a/packages/runtime-vapor/src/hmr.ts +++ b/packages/runtime-vapor/src/hmr.ts @@ -1,8 +1,7 @@ import { - currentInstance, popWarningContext, pushWarningContext, - simpleSetCurrentInstance, + setCurrentInstance, } from '@vue/runtime-dom' import { insert, normalizeBlock, remove } from './block' import { @@ -19,12 +18,11 @@ export function hmrRerender(instance: VaporComponentInstance): void { const parent = normalized[0].parentNode! const anchor = normalized[normalized.length - 1].nextSibling remove(instance.block, parent) - const prev = currentInstance - simpleSetCurrentInstance(instance) + const prev = setCurrentInstance(instance) pushWarningContext(instance) devRender(instance) popWarningContext() - simpleSetCurrentInstance(prev, instance) + setCurrentInstance(...prev) insert(instance.block, parent, anchor) } @@ -36,14 +34,13 @@ export function hmrReload( const parent = normalized[0].parentNode! const anchor = normalized[normalized.length - 1].nextSibling unmountComponent(instance, parent) - const prev = currentInstance - simpleSetCurrentInstance(instance.parent) + const prev = setCurrentInstance(instance.parent) const newInstance = createComponent( newComp, instance.rawProps, instance.rawSlots, instance.isSingleRoot, ) - simpleSetCurrentInstance(prev, instance.parent) + setCurrentInstance(...prev) mountComponent(newInstance, parent, anchor) } diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index a9fa9b33562..3cc2c7b6334 100644 --- a/packages/runtime-vapor/src/renderEffect.ts +++ b/packages/runtime-vapor/src/renderEffect.ts @@ -1,70 +1,102 @@ -import { ReactiveEffect, getCurrentScope } from '@vue/reactivity' +import { EffectFlags, type EffectScope, ReactiveEffect } from '@vue/reactivity' import { type SchedulerJob, currentInstance, queueJob, queuePostFlushCb, - simpleSetCurrentInstance, + setCurrentInstance, startMeasure, warn, } from '@vue/runtime-dom' import { type VaporComponentInstance, isVaporComponent } from './component' import { invokeArrayFns } from '@vue/shared' -export function renderEffect(fn: () => void, noLifecycle = false): void { - const instance = currentInstance as VaporComponentInstance | null - const scope = getCurrentScope() - if (__DEV__ && !__TEST__ && !scope && !isVaporComponent(instance)) { - warn('renderEffect called without active EffectScope or Vapor instance.') - } +class RenderEffect extends ReactiveEffect { + i: VaporComponentInstance | null + job: SchedulerJob + + constructor(public render: () => void) { + super() + const instance = currentInstance as VaporComponentInstance | null + if (__DEV__ && !__TEST__ && !this.subs && !isVaporComponent(instance)) { + warn('renderEffect called without active EffectScope or Vapor instance.') + } + + const job: SchedulerJob = super.scheduler.bind(this) - // renderEffect is always called after user has registered all hooks - const hasUpdateHooks = instance && (instance.bu || instance.u) - const renderEffectFn = noLifecycle - ? fn - : () => { - if (__DEV__ && instance) { - startMeasure(instance, `renderEffect`) - } - const prev = currentInstance - simpleSetCurrentInstance(instance) - if (scope) scope.on() - if (hasUpdateHooks && instance.isMounted && !instance.isUpdating) { - instance.isUpdating = true - instance.bu && invokeArrayFns(instance.bu) - fn() - queuePostFlushCb(() => { - instance.isUpdating = false - instance.u && invokeArrayFns(instance.u) - }) - } else { - fn() - } - if (scope) scope.off() - simpleSetCurrentInstance(prev, instance) - if (__DEV__ && instance) { - startMeasure(instance, `renderEffect`) - } + if (instance) { + if (__DEV__) { + this.onTrack = instance.rtc + ? e => invokeArrayFns(instance.rtc!, e) + : void 0 + this.onTrigger = instance.rtg + ? e => invokeArrayFns(instance.rtg!, e) + : void 0 } + job.i = instance + job.id = instance.uid + } - const effect = new ReactiveEffect(renderEffectFn) - const job: SchedulerJob = () => effect.dirty && effect.run() + this.job = job + this.i = instance - if (instance) { - if (__DEV__) { - effect.onTrack = instance.rtc - ? e => invokeArrayFns(instance.rtc!, e) - : void 0 - effect.onTrigger = instance.rtg - ? e => invokeArrayFns(instance.rtg!, e) - : void 0 + // TODO recurse handling + } + + fn(): void { + const instance = this.i + const scope = this.subs ? (this.subs.sub as EffectScope) : undefined + // renderEffect is always called after user has registered all hooks + const hasUpdateHooks = instance && (instance.bu || instance.u) + if (__DEV__ && instance) { + startMeasure(instance, `renderEffect`) + } + const prev = setCurrentInstance(instance, scope) + if (hasUpdateHooks && instance.isMounted && !instance.isUpdating) { + instance.isUpdating = true + instance.bu && invokeArrayFns(instance.bu) + this.render() + if (instance.u) { + queuePostFlushCb(() => { + instance.isUpdating = false + invokeArrayFns(instance.u!) + }) + } else { + instance.isUpdating = false + } + } else { + this.render() + } + setCurrentInstance(...prev) + if (__DEV__ && instance) { + startMeasure(instance, `renderEffect`) } - job.i = instance - job.id = instance.uid } - effect.scheduler = () => queueJob(job) - effect.run() + notify(): void { + const flags = this.flags + if (!(flags & EffectFlags.PAUSED)) { + queueJob(this.job) + } else { + this.flags = flags | EffectFlags.NOTIFIED + } + } +} + +class RenderEffect_NoLifecycle extends RenderEffect { + constructor(render: () => void) { + super(render) + } - // TODO recurse handling + fn() { + this.render() + } +} + +export function renderEffect(fn: () => void, noLifecycle = false): void { + if (noLifecycle) { + new RenderEffect_NoLifecycle(fn).run() + } else { + new RenderEffect(fn).run() + } } diff --git a/rollup.config.js b/rollup.config.js index 7f2ecb8c864..3db827e0447 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -218,7 +218,8 @@ function createConfig(format, output, plugins = []) { } }, treeshake: { - moduleSideEffects: false, + // should be true to awllow @babel/types side effects + moduleSideEffects: true, }, }