diff --git a/packages/reactivity/src/effectScope.ts b/packages/reactivity/src/effectScope.ts index ef858a74a..26e02fc9f 100644 --- a/packages/reactivity/src/effectScope.ts +++ b/packages/reactivity/src/effectScope.ts @@ -34,13 +34,13 @@ export class EffectScope { */ private index: number | undefined - constructor(public detached = false) { - this.parent = activeEffectScope - if (!detached && activeEffectScope) { - this.index = - (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push( - this, - ) - 1 + constructor( + public detached = false, + parent = activeEffectScope, + ) { + this.parent = parent + if (!detached && parent) { + this.index = (parent.scopes || (parent.scopes = [])).push(this) - 1 } } diff --git a/packages/runtime-vapor/__tests__/directives/vShow.spec.ts b/packages/runtime-vapor/__tests__/directives/vShow.spec.ts index 8f627b975..75c44f8c7 100644 --- a/packages/runtime-vapor/__tests__/directives/vShow.spec.ts +++ b/packages/runtime-vapor/__tests__/directives/vShow.spec.ts @@ -77,7 +77,7 @@ describe('directive: v-show', () => { }).render() expect(host.innerHTML).toBe('
child
') - expect(instance.dirs.get(n0)![0].dir).toBe(vShow) + expect(instance.scope.dirs!.get(n0)![0].dir).toBe(vShow) const btn = host.querySelector('button') btn?.click() diff --git a/packages/runtime-vapor/__tests__/for.spec.ts b/packages/runtime-vapor/__tests__/for.spec.ts index e95f28851..fba012c62 100644 --- a/packages/runtime-vapor/__tests__/for.spec.ts +++ b/packages/runtime-vapor/__tests__/for.spec.ts @@ -1,5 +1,16 @@ -import { createFor, nextTick, ref, renderEffect } from '../src' +import { NOOP } from '@vue/shared' +import { + type Directive, + children, + createFor, + nextTick, + ref, + renderEffect, + template, + withDirectives, +} from '../src' import { makeRender } from './_utils' +import { unmountComponent } from '../src/apiRender' const define = makeRender() @@ -184,4 +195,92 @@ describe('createFor', () => { await nextTick() expect(host.innerHTML).toBe('') }) + + test('should work with directive hooks', async () => { + const calls: string[] = [] + const list = ref([0]) + const update = ref(0) + const add = () => list.value.push(list.value.length) + const spySrcFn = vi.fn(() => list.value) + + const vDirective: Directive = { + created: (el, { value }) => calls.push(`${value} created`), + beforeMount: (el, { value }) => calls.push(`${value} beforeMount`), + mounted: (el, { value }) => calls.push(`${value} mounted`), + beforeUpdate: (el, { value }) => calls.push(`${value} beforeUpdate`), + updated: (el, { value }) => calls.push(`${value} updated`), + beforeUnmount: (el, { value }) => calls.push(`${value} beforeUnmount`), + unmounted: (el, { value }) => calls.push(`${value} unmounted`), + } + + const t0 = template('

') + const { instance } = define(() => { + const n1 = createFor(spySrcFn, block => { + const n2 = t0() + const n3 = children(n2, 0) + withDirectives(n3, [[vDirective, () => block.s[0]]]) + return [n2, NOOP] + }) + renderEffect(() => update.value) + return [n1] + }).render() + + await nextTick() + // `${item index} ${hook name}` + expect(calls).toEqual(['0 created', '0 beforeMount', '0 mounted']) + calls.length = 0 + expect(spySrcFn).toHaveBeenCalledTimes(1) + + add() + await nextTick() + expect(calls).toEqual([ + '0 beforeUpdate', + '1 created', + '1 beforeMount', + '0 updated', + '1 mounted', + ]) + calls.length = 0 + expect(spySrcFn).toHaveBeenCalledTimes(2) + + list.value.reverse() + await nextTick() + expect(calls).toEqual([ + '1 beforeUpdate', + '0 beforeUpdate', + '1 updated', + '0 updated', + ]) + expect(spySrcFn).toHaveBeenCalledTimes(3) + list.value.reverse() + await nextTick() + calls.length = 0 + expect(spySrcFn).toHaveBeenCalledTimes(4) + + update.value++ + await nextTick() + expect(calls).toEqual([ + '0 beforeUpdate', + '1 beforeUpdate', + '0 updated', + '1 updated', + ]) + calls.length = 0 + expect(spySrcFn).toHaveBeenCalledTimes(4) + + list.value.pop() + await nextTick() + expect(calls).toEqual([ + '0 beforeUpdate', + '1 beforeUnmount', + '0 updated', + '1 unmounted', + ]) + calls.length = 0 + expect(spySrcFn).toHaveBeenCalledTimes(5) + + unmountComponent(instance) + expect(calls).toEqual(['0 beforeUnmount', '0 unmounted']) + expect(spySrcFn).toHaveBeenCalledTimes(5) + }) }) diff --git a/packages/runtime-vapor/__tests__/if.spec.ts b/packages/runtime-vapor/__tests__/if.spec.ts index ad03753b0..03a94ff36 100644 --- a/packages/runtime-vapor/__tests__/if.spec.ts +++ b/packages/runtime-vapor/__tests__/if.spec.ts @@ -1,4 +1,5 @@ import { + children, createIf, insert, nextTick, @@ -6,9 +7,11 @@ import { renderEffect, setText, template, + withDirectives, } from '../src' import type { Mock } from 'vitest' import { makeRender } from './_utils' +import { unmountComponent } from '../src/apiRender' const define = makeRender() @@ -24,6 +27,8 @@ describe('createIf', () => { let spyElseFn: Mock const count = ref(0) + const spyConditionFn = vi.fn(() => count.value) + // templates can be reused through caching. const t0 = template('
') const t1 = template('

') @@ -34,7 +39,7 @@ describe('createIf', () => { insert( createIf( - () => count.value, + spyConditionFn, // v-if (spyIfFn ||= vi.fn(() => { const n2 = t1() @@ -55,24 +60,28 @@ describe('createIf', () => { }).render() expect(host.innerHTML).toBe('

zero

') + expect(spyConditionFn).toHaveBeenCalledTimes(1) expect(spyIfFn!).toHaveBeenCalledTimes(0) expect(spyElseFn!).toHaveBeenCalledTimes(1) count.value++ await nextTick() expect(host.innerHTML).toBe('

1

') + expect(spyConditionFn).toHaveBeenCalledTimes(2) expect(spyIfFn!).toHaveBeenCalledTimes(1) expect(spyElseFn!).toHaveBeenCalledTimes(1) count.value++ await nextTick() expect(host.innerHTML).toBe('

2

') + expect(spyConditionFn).toHaveBeenCalledTimes(3) expect(spyIfFn!).toHaveBeenCalledTimes(1) expect(spyElseFn!).toHaveBeenCalledTimes(1) count.value = 0 await nextTick() expect(host.innerHTML).toBe('

zero

') + expect(spyConditionFn).toHaveBeenCalledTimes(4) expect(spyIfFn!).toHaveBeenCalledTimes(1) expect(spyElseFn!).toHaveBeenCalledTimes(2) }) @@ -124,4 +133,113 @@ describe('createIf', () => { await nextTick() expect(host.innerHTML).toBe('') }) + + test('should work with directive hooks', async () => { + const calls: string[] = [] + const show1 = ref(true) + const show2 = ref(true) + const update = ref(0) + + const spyConditionFn1 = vi.fn(() => show1.value) + const spyConditionFn2 = vi.fn(() => show2.value) + + const vDirective: any = { + created: (el: any, { value }: any) => calls.push(`${value} created`), + beforeMount: (el: any, { value }: any) => + calls.push(`${value} beforeMount`), + mounted: (el: any, { value }: any) => calls.push(`${value} mounted`), + beforeUpdate: (el: any, { value }: any) => + calls.push(`${value} beforeUpdate`), + updated: (el: any, { value }: any) => calls.push(`${value} updated`), + beforeUnmount: (el: any, { value }: any) => + calls.push(`${value} beforeUnmount`), + unmounted: (el: any, { value }: any) => calls.push(`${value} unmounted`), + } + + const t0 = template('

') + const { instance } = define(() => { + const n1 = createIf( + spyConditionFn1, + () => { + const n2 = t0() + withDirectives(children(n2, 0), [ + [vDirective, () => (update.value, '1')], + ]) + return n2 + }, + () => + createIf( + spyConditionFn2, + () => { + const n2 = t0() + withDirectives(children(n2, 0), [[vDirective, () => '2']]) + return n2 + }, + () => { + const n2 = t0() + withDirectives(children(n2, 0), [[vDirective, () => '3']]) + return n2 + }, + ), + ) + return [n1] + }).render() + + await nextTick() + expect(calls).toEqual(['1 created', '1 beforeMount', '1 mounted']) + calls.length = 0 + expect(spyConditionFn1).toHaveBeenCalledTimes(1) + expect(spyConditionFn2).toHaveBeenCalledTimes(0) + + show1.value = false + await nextTick() + expect(calls).toEqual([ + '1 beforeUnmount', + '2 created', + '2 beforeMount', + '1 unmounted', + '2 mounted', + ]) + calls.length = 0 + expect(spyConditionFn1).toHaveBeenCalledTimes(2) + expect(spyConditionFn2).toHaveBeenCalledTimes(1) + + show2.value = false + await nextTick() + expect(calls).toEqual([ + '2 beforeUnmount', + '3 created', + '3 beforeMount', + '2 unmounted', + '3 mounted', + ]) + calls.length = 0 + expect(spyConditionFn1).toHaveBeenCalledTimes(2) + expect(spyConditionFn2).toHaveBeenCalledTimes(2) + + show1.value = true + await nextTick() + expect(calls).toEqual([ + '3 beforeUnmount', + '1 created', + '1 beforeMount', + '3 unmounted', + '1 mounted', + ]) + calls.length = 0 + expect(spyConditionFn1).toHaveBeenCalledTimes(3) + expect(spyConditionFn2).toHaveBeenCalledTimes(2) + + update.value++ + await nextTick() + expect(calls).toEqual(['1 beforeUpdate', '1 updated']) + calls.length = 0 + expect(spyConditionFn1).toHaveBeenCalledTimes(3) + expect(spyConditionFn2).toHaveBeenCalledTimes(2) + + unmountComponent(instance) + expect(calls).toEqual(['1 beforeUnmount', '1 unmounted']) + expect(spyConditionFn1).toHaveBeenCalledTimes(3) + expect(spyConditionFn2).toHaveBeenCalledTimes(2) + }) }) diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index fd7274c56..3bad7f36b 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -1,14 +1,26 @@ -import { type EffectScope, effectScope, isReactive } from '@vue/reactivity' +import { getCurrentScope, isReactive, traverse } from '@vue/reactivity' import { isArray, isObject, isString } from '@vue/shared' -import { createComment, createTextNode, insert, remove } from './dom/element' -import { renderEffect } from './renderEffect' +import { + createComment, + createTextNode, + insert, + remove as removeBlock, +} from './dom/element' import { type Block, type Fragment, fragmentKey } from './apiRender' import { warn } from './warning' +import { currentInstance } from './component' import { componentKey } from './component' +import { BlockEffectScope, isRenderEffectScope } from './blockEffectScope' +import { + createChildFragmentDirectives, + invokeWithMount, + invokeWithUnmount, + invokeWithUpdate, +} from './directivesChildFragment' import type { DynamicSlot } from './componentSlots' interface ForBlock extends Fragment { - scope: EffectScope + scope: BlockEffectScope /** state, use short key since it's used a lot in generated code */ s: [item: any, key: any, index?: number] update: () => void @@ -16,9 +28,11 @@ interface ForBlock extends Fragment { memo: any[] | undefined } +type Source = any[] | Record | number | Set | Map + /*! #__NO_SIDE_EFFECTS__ */ export const createFor = ( - src: () => any[] | Record | number | Set | Map, + src: () => Source, renderItem: (block: ForBlock) => [Block, () => void], getKey?: (item: any, key: any, index?: number) => any, getMemo?: (item: any, key: any, index?: number) => any[], @@ -29,18 +43,34 @@ export const createFor = ( let oldBlocks: ForBlock[] = [] let newBlocks: ForBlock[] let parent: ParentNode | undefined | null + const update = getMemo ? updateWithMemo : updateWithoutMemo + const parentScope = getCurrentScope()! const parentAnchor = __DEV__ ? createComment('for') : createTextNode() const ref: Fragment = { nodes: oldBlocks, [fragmentKey]: true, } - const update = getMemo ? updateWithMemo : updateWithoutMemo - once ? renderList() : renderEffect(renderList) + + const instance = currentInstance! + if (__DEV__ && (!instance || !isRenderEffectScope(parentScope))) { + warn('createFor() can only be used inside setup()') + } + + createChildFragmentDirectives( + parentAnchor, + () => oldBlocks.map(b => b.scope), + // source getter + () => traverse(src(), 1), + // init cb + getValue => doFor(getValue()), + // effect cb + getValue => doFor(getValue()), + once, + ) return ref - function renderList() { - const source = src() + function doFor(source: any) { const newLength = getLength(source) const oldLength = oldBlocks.length newBlocks = new Array(newLength) @@ -225,7 +255,8 @@ export const createFor = ( idx: number, anchor: Node = parentAnchor, ): ForBlock { - const scope = effectScope() + const scope = new BlockEffectScope(instance, parentScope) + const [item, key, index] = getItem(source, idx) const block: ForBlock = (newBlocks[idx] = { nodes: null!, // set later @@ -239,8 +270,12 @@ export const createFor = ( const res = scope.run(() => renderItem(block))! block.nodes = res[0] block.update = res[1] - if (getMemo) block.update() - if (parent) insert(block.nodes, parent, anchor) + + invokeWithMount(scope, () => { + if (getMemo) block.update() + if (parent) insert(block.nodes, parent, anchor) + }) + return block } @@ -275,10 +310,13 @@ export const createFor = ( } } } - if (needsUpdate) { - block.s = [newItem, newKey, newIndex] - block.update() - } + + block.s = [newItem, newKey, newIndex] + invokeWithUpdate(block.scope, () => { + if (needsUpdate) { + block.update() + } + }) } function updateWithoutMemo( @@ -287,20 +325,24 @@ export const createFor = ( newKey = block.s[1], newIndex = block.s[2], ) { - if ( + let needsUpdate = newItem !== block.s[0] || newKey !== block.s[1] || newIndex !== block.s[2] || !isReactive(newItem) - ) { - block.s = [newItem, newKey, newIndex] - block.update() - } + + block.s = [newItem, newKey, newIndex] + invokeWithUpdate(block.scope, () => { + if (needsUpdate) { + block.update() + } + }) } function unmount({ nodes, scope }: ForBlock) { - remove(nodes, parent!) - scope.stop() + invokeWithUnmount(scope, () => { + removeBlock(nodes, parent!) + }) } } diff --git a/packages/runtime-vapor/src/apiCreateIf.ts b/packages/runtime-vapor/src/apiCreateIf.ts index b05fc3b05..dce38bae9 100644 --- a/packages/runtime-vapor/src/apiCreateIf.ts +++ b/packages/runtime-vapor/src/apiCreateIf.ts @@ -1,7 +1,15 @@ -import { renderEffect } from './renderEffect' import { type Block, type Fragment, fragmentKey } from './apiRender' -import { type EffectScope, effectScope } from '@vue/reactivity' +import { getCurrentScope } from '@vue/reactivity' import { createComment, createTextNode, insert, remove } from './dom/element' +import { currentInstance } from './component' +import { warn } from './warning' +import { BlockEffectScope, isRenderEffectScope } from './blockEffectScope' +import { + createChildFragmentDirectives, + invokeWithMount, + invokeWithUnmount, + invokeWithUpdate, +} from './directivesChildFragment' type BlockFn = () => Block @@ -18,7 +26,8 @@ export const createIf = ( let branch: BlockFn | undefined let parent: ParentNode | undefined | null let block: Block | undefined - let scope: EffectScope | undefined + let scope: BlockEffectScope | undefined + const parentScope = getCurrentScope()! const anchor = __DEV__ ? createComment('if') : createTextNode() const fragment: Fragment = { nodes: [], @@ -26,35 +35,37 @@ export const createIf = ( [fragmentKey]: true, } + const instance = currentInstance! + if (__DEV__ && (!instance || !isRenderEffectScope(parentScope))) { + warn('createIf() can only be used inside setup()') + } + // TODO: SSR // if (isHydrating) { // parent = hydrationNode!.parentNode // setCurrentHydrationNode(hydrationNode!) // } - if (once) { - doIf() - } else { - renderEffect(() => doIf()) - } - - function doIf() { - if ((newValue = !!condition()) !== oldValue) { - parent ||= anchor.parentNode - if (block) { - scope!.stop() - remove(block, parent!) - } - if ((branch = (oldValue = newValue) ? b1 : b2)) { - scope = effectScope() - fragment.nodes = block = scope.run(branch)! - parent && insert(block, parent, anchor) - } else { - scope = block = undefined - fragment.nodes = [] + createChildFragmentDirectives( + anchor, + () => (scope ? [scope] : []), + // source getter + condition, + // init cb + getValue => { + newValue = !!getValue() + doIf() + }, + // effect cb + getValue => { + if ((newValue = !!getValue()) !== oldValue) { + doIf() + } else if (scope) { + invokeWithUpdate(scope) } - } - } + }, + once, + ) // TODO: SSR // if (isHydrating) { @@ -62,4 +73,19 @@ export const createIf = ( // } return fragment + + function doIf() { + parent ||= anchor.parentNode + if (block) { + invokeWithUnmount(scope!, () => remove(block!, parent!)) + } + if ((branch = (oldValue = newValue) ? b1 : b2)) { + scope = new BlockEffectScope(instance, parentScope) + fragment.nodes = block = scope.run(branch)! + invokeWithMount(scope, () => parent && insert(block!, parent, anchor)) + } else { + scope = block = undefined + fragment.nodes = [] + } + } } diff --git a/packages/runtime-vapor/src/blockEffectScope.ts b/packages/runtime-vapor/src/blockEffectScope.ts new file mode 100644 index 000000000..92fc01983 --- /dev/null +++ b/packages/runtime-vapor/src/blockEffectScope.ts @@ -0,0 +1,36 @@ +import { EffectScope } from '@vue/reactivity' +import type { ComponentInternalInstance } from './component' +import type { DirectiveBindingsMap } from './directives' + +export class BlockEffectScope extends EffectScope { + /** + * instance + * @internal + */ + it: ComponentInternalInstance + /** + * isMounted + * @internal + */ + im: boolean + /** + * directives + * @internal + */ + dirs?: DirectiveBindingsMap + + constructor( + instance: ComponentInternalInstance, + parentScope: EffectScope | null, + ) { + super(false, parentScope || undefined) + this.im = false + this.it = instance + } +} + +export function isRenderEffectScope( + scope: EffectScope | undefined, +): scope is BlockEffectScope { + return scope instanceof BlockEffectScope +} diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index bb98adc85..28ca6c2ab 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -1,7 +1,6 @@ -import { EffectScope, isRef } from '@vue/reactivity' +import { isRef } from '@vue/reactivity' import { EMPTY_OBJ, hasOwn, isArray, isFunction } from '@vue/shared' import type { Block } from './apiRender' -import type { DirectiveBinding } from './directives' import { type ComponentPropsOptions, type NormalizedPropsOptions, @@ -27,6 +26,7 @@ import { VaporLifecycleHooks } from './apiLifecycle' import { warn } from './warning' import { type AppContext, createAppContext } from './apiCreateVaporApp' import type { Data } from '@vue/runtime-shared' +import { BlockEffectScope } from './blockEffectScope' export type Component = FunctionalComponent | ObjectComponent @@ -154,10 +154,9 @@ export interface ComponentInternalInstance { parent: ComponentInternalInstance | null provides: Data - scope: EffectScope + scope: BlockEffectScope component: Component comps: Set - dirs: Map rawProps: NormalizedRawProps propsOptions: NormalizedPropsOptions @@ -280,11 +279,10 @@ export function createComponentInstance( parent, - scope: new EffectScope(true /* detached */)!, + scope: null!, provides: parent ? parent.provides : Object.create(_appContext.provides), component, comps: new Set(), - dirs: new Map(), // resolved props and emits options rawProps: null!, // set later @@ -355,6 +353,7 @@ export function createComponentInstance( */ // [VaporLifecycleHooks.SERVER_PREFETCH]: null, } + instance.scope = new BlockEffectScope(instance, parent && parent.scope) initProps(instance, rawProps, !isFunction(component), once) initSlots(instance, slots, dynamicSlots) instance.emit = emit.bind(null, instance) diff --git a/packages/runtime-vapor/src/componentLifecycle.ts b/packages/runtime-vapor/src/componentLifecycle.ts index c67093e27..bb97b089c 100644 --- a/packages/runtime-vapor/src/componentLifecycle.ts +++ b/packages/runtime-vapor/src/componentLifecycle.ts @@ -25,7 +25,7 @@ export function invokeLifecycle( post ? queuePostFlushCb(fn) : fn() } - invokeDirectiveHook(instance, directive) + invokeDirectiveHook(instance, directive, instance.scope) } function invokeSub() { diff --git a/packages/runtime-vapor/src/directives.ts b/packages/runtime-vapor/src/directives.ts index ab295678b..3ec374623 100644 --- a/packages/runtime-vapor/src/directives.ts +++ b/packages/runtime-vapor/src/directives.ts @@ -1,32 +1,48 @@ -import { isFunction } from '@vue/shared' +import { invokeArrayFns, isFunction } from '@vue/shared' import { type ComponentInternalInstance, currentInstance, isVaporComponent, + setCurrentInstance, } from './component' -import { pauseTracking, resetTracking, traverse } from '@vue/reactivity' -import { VaporErrorCodes, callWithAsyncErrorHandling } from './errorHandling' -import { renderEffect } from './renderEffect' +import { + EffectFlags, + ReactiveEffect, + type SchedulerJob, + getCurrentScope, + pauseTracking, + resetTracking, + traverse, +} from '@vue/reactivity' +import { + VaporErrorCodes, + callWithAsyncErrorHandling, + callWithErrorHandling, +} from './errorHandling' +import { queueJob, queuePostFlushCb } from './scheduler' import { warn } from './warning' +import { type BlockEffectScope, isRenderEffectScope } from './blockEffectScope' import { normalizeBlock } from './dom/element' export type DirectiveModifiers = Record -export interface DirectiveBinding { +export interface DirectiveBinding { instance: ComponentInternalInstance source?: () => V value: V oldValue: V | null arg?: string modifiers?: DirectiveModifiers - dir: ObjectDirective + dir: ObjectDirective } +export type DirectiveBindingsMap = Map + export type DirectiveHook< T = any | null, V = any, M extends string = string, -> = (node: T, binding: DirectiveBinding) => void +> = (node: T, binding: DirectiveBinding) => void // create node -> `created` -> node operation -> `beforeMount` -> node mounted -> `mounted` // effect update -> `beforeUpdate` -> node updated -> `updated` @@ -43,7 +59,7 @@ export type ObjectDirective = { [K in DirectiveHookName]?: DirectiveHook | undefined } & { /** Watch value deeply */ - deep?: boolean + deep?: boolean | number } export type FunctionDirective< @@ -86,9 +102,18 @@ export function withDirectives( node = nodeOrComponent } - const instance = currentInstance - if (!instance.dirs.has(node)) instance.dirs.set(node, []) - const bindings = instance.dirs.get(node)! + let bindings: DirectiveBinding[] + const instance = currentInstance! + const parentScope = getCurrentScope() as BlockEffectScope + + if (__DEV__ && !isRenderEffectScope(parentScope)) { + warn(`Directives should be used inside of RenderEffectScope.`) + } + + const directivesMap = (parentScope.dirs ||= new Map()) + if (!(bindings = directivesMap.get(node))) { + directivesMap.set(node, (bindings = [])) + } for (const directive of directives) { let [dir, source, arg, modifiers] = directive @@ -103,25 +128,38 @@ export function withDirectives( const binding: DirectiveBinding = { dir, instance, - source, value: null, // set later oldValue: undefined, arg, modifiers, } - bindings.push(binding) - - callDirectiveHook(node, binding, instance, 'created') - // register source if (source) { if (dir.deep) { const deep = dir.deep === true ? undefined : dir.deep const baseSource = source source = () => traverse(baseSource(), deep) } - renderEffect(source) + + const effect = new ReactiveEffect(() => + callWithErrorHandling( + source!, + instance, + VaporErrorCodes.RENDER_FUNCTION, + ), + ) + const triggerRenderingUpdate = createRenderingUpdateTrigger( + instance, + effect, + ) + effect.scheduler = () => queueJob(triggerRenderingUpdate) + + binding.source = effect.run.bind(effect) } + + bindings.push(binding) + + callDirectiveHook(node, binding, instance, 'created') } return nodeOrComponent @@ -145,13 +183,14 @@ function getComponentNode(component: ComponentInternalInstance) { export function invokeDirectiveHook( instance: ComponentInternalInstance | null, name: DirectiveHookName, - nodes?: IterableIterator, + scope: BlockEffectScope, ) { - if (!instance) return - nodes = nodes || instance.dirs.keys() - for (const node of nodes) { - const directives = instance.dirs.get(node) || [] - for (const binding of directives) { + const { dirs } = scope + if (name === 'mounted') scope.im = true + if (!dirs) return + const iterator = dirs.entries() + for (const [node, bindings] of iterator) { + for (const binding of bindings) { callDirectiveHook(node, binding, instance, name) } } @@ -179,3 +218,43 @@ function callDirectiveHook( ]) resetTracking() } + +export function createRenderingUpdateTrigger( + instance: ComponentInternalInstance, + effect: ReactiveEffect, +): SchedulerJob { + job.id = instance.uid + return job + function job() { + if (!(effect.flags & EffectFlags.ACTIVE) || !effect.dirty) { + return + } + + if (instance.isMounted && !instance.isUpdating) { + instance.isUpdating = true + const reset = setCurrentInstance(instance) + + const { bu, u, scope } = instance + const { dirs } = scope + // beforeUpdate hook + if (bu) { + invokeArrayFns(bu) + } + invokeDirectiveHook(instance, 'beforeUpdate', scope) + + queuePostFlushCb(() => { + instance.isUpdating = false + const reset = setCurrentInstance(instance) + if (dirs) { + invokeDirectiveHook(instance, 'updated', scope) + } + // updated hook + if (u) { + queuePostFlushCb(u) + } + reset() + }) + reset() + } + } +} diff --git a/packages/runtime-vapor/src/directivesChildFragment.ts b/packages/runtime-vapor/src/directivesChildFragment.ts new file mode 100644 index 000000000..ff2f6e72f --- /dev/null +++ b/packages/runtime-vapor/src/directivesChildFragment.ts @@ -0,0 +1,152 @@ +import { ReactiveEffect, getCurrentScope } from '@vue/reactivity' +import { + type Directive, + type DirectiveHookName, + createRenderingUpdateTrigger, + invokeDirectiveHook, +} from './directives' +import { warn } from './warning' +import { type BlockEffectScope, isRenderEffectScope } from './blockEffectScope' +import { currentInstance } from './component' +import { VaporErrorCodes, callWithErrorHandling } from './errorHandling' +import { queueJob, queuePostFlushCb } from './scheduler' + +/** + * used in createIf and createFor + * manage directives of child fragments in components. + */ +export function createChildFragmentDirectives( + anchor: Node, + getScopes: () => BlockEffectScope[], + source: () => any, + initCallback: (getValue: () => any) => void, + effectCallback: (getValue: () => any) => void, + once?: boolean, +) { + let isTriggered = false + const instance = currentInstance! + const parentScope = getCurrentScope() as BlockEffectScope + if (__DEV__) { + if (!isRenderEffectScope(parentScope)) { + warn('child directives can only be added to a render effect scope') + } + if (!instance) { + warn('child directives can only be added in a component') + } + } + + const callSourceWithErrorHandling = () => + callWithErrorHandling(source, instance, VaporErrorCodes.RENDER_FUNCTION) + + if (once) { + initCallback(callSourceWithErrorHandling) + return + } + + const directiveBindingsMap = (parentScope.dirs ||= new Map()) + const dir: Directive = { + beforeUpdate: onDirectiveBeforeUpdate, + beforeMount: () => invokeChildrenDirectives('beforeMount'), + mounted: () => invokeChildrenDirectives('mounted'), + beforeUnmount: () => invokeChildrenDirectives('beforeUnmount'), + unmounted: () => invokeChildrenDirectives('unmounted'), + } + directiveBindingsMap.set(anchor, [ + { + dir, + instance, + value: null, + oldValue: undefined, + }, + ]) + + const effect = new ReactiveEffect(callSourceWithErrorHandling) + const triggerRenderingUpdate = createRenderingUpdateTrigger(instance, effect) + effect.scheduler = () => { + isTriggered = true + queueJob(triggerRenderingUpdate) + } + + const getValue = () => effect.run() + + initCallback(getValue) + + function onDirectiveBeforeUpdate() { + if (isTriggered) { + isTriggered = false + effectCallback(getValue) + } else { + const scopes = getScopes() + for (const scope of scopes) { + invokeWithUpdate(scope) + } + return + } + } + + function invokeChildrenDirectives(name: DirectiveHookName) { + const scopes = getScopes() + for (const scope of scopes) { + invokeDirectiveHook(instance, name, scope) + } + } +} + +export function invokeWithMount(scope: BlockEffectScope, handler?: () => any) { + if (isRenderEffectScope(scope.parent) && !scope.parent.im) { + return handler && handler() + } + return invokeWithDirsHooks(scope, 'mount', handler) +} + +export function invokeWithUnmount( + scope: BlockEffectScope, + handler?: () => void, +) { + try { + return invokeWithDirsHooks(scope, 'unmount', handler) + } finally { + scope.stop() + } +} + +export function invokeWithUpdate( + scope: BlockEffectScope, + handler?: () => void, +) { + return invokeWithDirsHooks(scope, 'update', handler) +} + +const lifecycleMap = { + mount: ['beforeMount', 'mounted'], + update: ['beforeUpdate', 'updated'], + unmount: ['beforeUnmount', 'unmounted'], +} as const + +function invokeWithDirsHooks( + scope: BlockEffectScope, + name: keyof typeof lifecycleMap, + handler?: () => any, +) { + const { dirs, it: instance } = scope + const [before, after] = lifecycleMap[name] + + if (!dirs) { + const res = handler && handler() + if (name === 'mount') { + queuePostFlushCb(() => (scope.im = true)) + } + return res + } + + invokeDirectiveHook(instance, before, scope) + try { + if (handler) { + return handler() + } + } finally { + queuePostFlushCb(() => { + invokeDirectiveHook(instance, after, scope) + }) + } +} diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index bdb418897..dcd7088cd 100644 --- a/packages/runtime-vapor/src/renderEffect.ts +++ b/packages/runtime-vapor/src/renderEffect.ts @@ -18,73 +18,77 @@ import { invokeDirectiveHook } from './directives' export function renderEffect(cb: () => void) { const instance = getCurrentInstance() const scope = getCurrentScope() - let effect: ReactiveEffect - const job: SchedulerJob = () => { + if (scope) { + const baseCb = cb + cb = () => scope.run(baseCb) + } + + if (instance) { + const baseCb = cb + cb = () => { + const reset = setCurrentInstance(instance) + baseCb() + reset() + } + job.id = instance.uid + } + + const effect = new ReactiveEffect(() => + callWithAsyncErrorHandling(cb, instance, VaporErrorCodes.RENDER_FUNCTION), + ) + + effect.scheduler = () => queueJob(job) + if (__DEV__ && instance) { + effect.onTrack = instance.rtc + ? e => invokeArrayFns(instance.rtc!, e) + : void 0 + effect.onTrigger = instance.rtg + ? e => invokeArrayFns(instance.rtg!, e) + : void 0 + } + effect.run() + + function job() { if (!(effect.flags & EffectFlags.ACTIVE) || !effect.dirty) { return } + const reset = instance && setCurrentInstance(instance) + if (instance && instance.isMounted && !instance.isUpdating) { instance.isUpdating = true - const { bu, u, dirs } = instance + const { bu, u, scope } = instance + const { dirs } = scope // beforeUpdate hook if (bu) { invokeArrayFns(bu) } if (dirs) { - invokeDirectiveHook(instance, 'beforeUpdate') + invokeDirectiveHook(instance, 'beforeUpdate', scope) } effect.run() queuePostFlushCb(() => { instance.isUpdating = false + const reset = setCurrentInstance(instance) if (dirs) { - invokeDirectiveHook(instance, 'updated') + invokeDirectiveHook(instance, 'updated', scope) } // updated hook if (u) { queuePostFlushCb(u) } + reset() }) } else { effect.run() } - } - - if (scope) { - const baseCb = cb - cb = () => scope.run(baseCb) - } - - if (instance) { - const baseCb = cb - cb = () => { - const reset = setCurrentInstance(instance) - baseCb() - reset() - } - } - effect = new ReactiveEffect(() => - callWithAsyncErrorHandling(cb, instance, VaporErrorCodes.RENDER_FUNCTION), - ) - - effect.scheduler = () => { - if (instance) job.id = instance.uid - queueJob(job) - } - if (__DEV__ && instance) { - effect.onTrack = instance.rtc - ? e => invokeArrayFns(instance.rtc!, e) - : void 0 - effect.onTrigger = instance.rtg - ? e => invokeArrayFns(instance.rtg!, e) - : void 0 + reset && reset() } - effect.run() } export function firstEffect(