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 362ac762c..c32f57271 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -1,13 +1,25 @@ -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 { RenderEffectScope, isRenderEffectScope } from './renderEffectScope' +import { + createChildFragmentDirectives, + invokeWithMount, + invokeWithUnmount, + invokeWithUpdate, +} from './directivesChildFragment' interface ForBlock extends Fragment { - scope: EffectScope + scope: RenderEffectScope /** state, use short key since it's used a lot in generated code */ s: [item: any, key: any, index?: number] update: () => void @@ -15,9 +27,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[], @@ -27,15 +41,166 @@ 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 - renderEffect(() => { - const source = src() + 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()), + ) + + return ref + + function mount( + source: any, + idx: number, + anchor: Node = parentAnchor, + ): ForBlock { + const scope = new RenderEffectScope(instance, parentScope) + + const [item, key, index] = getItem(source, idx) + const block: ForBlock = (newBlocks[idx] = { + nodes: null!, // set later + update: null!, // set later + scope, + s: [item, key, index], + key: getKey && getKey(item, key, index), + memo: getMemo && getMemo(item, key, index), + [fragmentKey]: true, + }) + const res = scope.run(() => renderItem(block))! + block.nodes = res[0] + block.update = res[1] + + invokeWithMount(scope, () => { + if (getMemo) block.update() + if (parent) insert(block.nodes, parent, anchor) + }) + + return block + } + + function mountList(source: any, offset = 0) { + for (let i = offset; i < getLength(source); i++) { + mount(source, i) + } + } + + function 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 + } + } + + function updateWithMemo( + block: ForBlock, + newItem: any, + newKey = block.s[1], + newIndex = block.s[2], + ) { + let needsUpdate = newKey !== block.s[1] || newIndex !== block.s[2] + if (!needsUpdate) { + const oldMemo = block.memo! + const newMemo = (block.memo = getMemo!(newItem, newKey, newIndex)) + for (let i = 0; i < newMemo.length; i++) { + if ((needsUpdate = newMemo[i] !== oldMemo[i])) { + break + } + } + } + + block.s = [newItem, newKey, newIndex] + invokeWithUpdate(block.scope, () => { + if (needsUpdate) { + block.update() + } + }) + } + + function updateWithoutMemo( + block: ForBlock, + newItem: any, + newKey = block.s[1], + newIndex = block.s[2], + ) { + let needsUpdate = + newItem !== block.s[0] || + newKey !== block.s[1] || + newIndex !== block.s[2] || + !isReactive(newItem) + + block.s = [newItem, newKey, newIndex] + invokeWithUpdate(block.scope, () => { + if (needsUpdate) { + block.update() + } + }) + } + + function unmount({ nodes, scope }: ForBlock) { + invokeWithUnmount(scope, () => { + removeBlock(nodes, parent!) + }) + } + + function getLength(source: any): number { + if (isArray(source) || isString(source)) { + return source.length + } else if (typeof source === 'number') { + if (__DEV__ && !Number.isInteger(source)) { + warn(`The v-for range expect an integer value but got ${source}.`) + } + return source + } else if (isObject(source)) { + if (source[Symbol.iterator as any]) { + return Array.from(source as Iterable).length + } else { + return Object.keys(source).length + } + } + return 0 + } + + function getItem( + source: any, + idx: number, + ): [item: any, key: any, index?: number] { + if (isArray(source) || isString(source)) { + return [source[idx], idx, undefined] + } else if (typeof source === 'number') { + return [idx + 1, idx, undefined] + } else if (isObject(source)) { + if (source && source[Symbol.iterator as any]) { + source = Array.from(source as Iterable) + return [source[idx], idx, undefined] + } else { + const key = Object.keys(source)[idx] + return [source[key], key, idx] + } + } + return null! + } + + function doFor(source: any) { const newLength = getLength(source) const oldLength = oldBlocks.length newBlocks = new Array(newLength) @@ -213,129 +378,6 @@ export const createFor = ( } ref.nodes = [(oldBlocks = newBlocks), parentAnchor] - }) - - return ref - - function mount( - source: any, - idx: number, - anchor: Node = parentAnchor, - ): ForBlock { - const scope = effectScope() - const [item, key, index] = getItem(source, idx) - const block: ForBlock = (newBlocks[idx] = { - nodes: null!, // set later - update: null!, // set later - scope, - s: [item, key, index], - key: getKey && getKey(item, key, index), - memo: getMemo && getMemo(item, key, index), - [fragmentKey]: true, - }) - 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) - return block - } - - function mountList(source: any, offset = 0) { - for (let i = offset; i < getLength(source); i++) { - mount(source, i) - } - } - - function 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 - } - } - - function updateWithMemo( - block: ForBlock, - newItem: any, - newKey = block.s[1], - newIndex = block.s[2], - ) { - let needsUpdate = newKey !== block.s[1] || newIndex !== block.s[2] - if (!needsUpdate) { - const oldMemo = block.memo! - const newMemo = (block.memo = getMemo!(newItem, newKey, newIndex)) - for (let i = 0; i < newMemo.length; i++) { - if ((needsUpdate = newMemo[i] !== oldMemo[i])) { - break - } - } - } - if (needsUpdate) { - block.s = [newItem, newKey, newIndex] - block.update() - } - } - - function updateWithoutMemo( - block: ForBlock, - newItem: any, - newKey = block.s[1], - newIndex = block.s[2], - ) { - if ( - newItem !== block.s[0] || - newKey !== block.s[1] || - newIndex !== block.s[2] || - !isReactive(newItem) - ) { - block.s = [newItem, newKey, newIndex] - block.update() - } - } - - function unmount({ nodes, scope }: ForBlock) { - remove(nodes, parent!) - scope.stop() - } - - function getLength(source: any): number { - if (isArray(source) || isString(source)) { - return source.length - } else if (typeof source === 'number') { - if (__DEV__ && !Number.isInteger(source)) { - warn(`The v-for range expect an integer value but got ${source}.`) - } - return source - } else if (isObject(source)) { - if (source[Symbol.iterator as any]) { - return Array.from(source as Iterable).length - } else { - return Object.keys(source).length - } - } - return 0 - } - - function getItem( - source: any, - idx: number, - ): [item: any, key: any, index?: number] { - if (isArray(source) || isString(source)) { - return [source[idx], idx, undefined] - } else if (typeof source === 'number') { - return [idx + 1, idx, undefined] - } else if (isObject(source)) { - if (source && source[Symbol.iterator as any]) { - source = Array.from(source as Iterable) - return [source[idx], idx, undefined] - } else { - const key = Object.keys(source)[idx] - return [source[key], key, idx] - } - } - return null! } } diff --git a/packages/runtime-vapor/src/apiCreateIf.ts b/packages/runtime-vapor/src/apiCreateIf.ts index 967dc3b33..2cfda52b1 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 { RenderEffectScope, isRenderEffectScope } from './renderEffectScope' +import { + createChildFragmentDirectives, + invokeWithMount, + invokeWithUnmount, + invokeWithUpdate, +} from './directivesChildFragment' type BlockFn = () => Block @@ -17,7 +25,8 @@ export const createIf = ( let branch: BlockFn | undefined let parent: ParentNode | undefined | null let block: Block | undefined - let scope: EffectScope | undefined + let scope: RenderEffectScope | undefined + const parentScope = getCurrentScope()! const anchor = __DEV__ ? createComment('if') : createTextNode() const fragment: Fragment = { nodes: [], @@ -25,29 +34,36 @@ 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!) // } - renderEffect(() => { - 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) } - } - }) + }, + ) // TODO: SSR // if (isHydrating) { @@ -55,4 +71,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 RenderEffectScope(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/component.ts b/packages/runtime-vapor/src/component.ts index e33233e10..c43b4c5e8 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -1,7 +1,5 @@ -import { EffectScope } from '@vue/reactivity' import { EMPTY_OBJ, NOOP, isFunction } from '@vue/shared' import type { Block } from './apiRender' -import type { DirectiveBinding } from './directives' import { type ComponentPropsOptions, type NormalizedPropsOptions, @@ -27,6 +25,7 @@ import { VaporLifecycleHooks } from './apiLifecycle' import { warn } from './warning' import { type AppContext, createAppContext } from './apiCreateVaporApp' import type { Data } from '@vue/shared' +import { RenderEffectScope } from './renderEffectScope' export type Component = FunctionalComponent | ObjectComponent @@ -96,10 +95,9 @@ export interface ComponentInternalInstance { parent: ComponentInternalInstance | null provides: Data - scope: EffectScope + scope: RenderEffectScope component: FunctionalComponent | ObjectComponent comps: Set - dirs: Map rawProps: NormalizedRawProps propsOptions: NormalizedPropsOptions @@ -218,11 +216,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 @@ -293,6 +290,7 @@ export function createComponentInstance( */ // [VaporLifecycleHooks.SERVER_PREFETCH]: null, } + instance.scope = new RenderEffectScope(instance, parent ? parent.scope : null) initProps(instance, rawProps, !isFunction(component)) 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 3eab22b2c..2c1d7c052 100644 --- a/packages/runtime-vapor/src/directives.ts +++ b/packages/runtime-vapor/src/directives.ts @@ -1,26 +1,49 @@ -import { isFunction } from '@vue/shared' -import { type ComponentInternalInstance, currentInstance } from './component' -import { pauseTracking, resetTracking, traverse } from '@vue/reactivity' -import { VaporErrorCodes, callWithAsyncErrorHandling } from './errorHandling' -import { renderEffect } from './renderEffect' +import { invokeArrayFns, isFunction } from '@vue/shared' +import { + type ComponentInternalInstance, + currentInstance, + setCurrentInstance, +} from './component' +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 RenderEffectScope, + isRenderEffectScope, +} from './renderEffectScope' 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` @@ -37,7 +60,7 @@ export type ObjectDirective = { [K in DirectiveHookName]?: DirectiveHook | undefined } & { /** Watch value deeply */ - deep?: boolean + deep?: boolean | number } export type FunctionDirective< @@ -66,14 +89,20 @@ export function withDirectives( node: T, directives: DirectiveArguments, ): T { - if (!currentInstance) { - // TODO warning - return node + let bindings: DirectiveBinding[] + const instance = currentInstance! + const parentScope = getCurrentScope() as RenderEffectScope + + if (__DEV__) { + if (!instance || !isRenderEffectScope(parentScope)) + warn(`withDirectives() can only be used inside setup()`) } - const instance = currentInstance - if (!instance.dirs.has(node)) instance.dirs.set(node, []) - const bindings = instance.dirs.get(node)! + 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 @@ -88,25 +117,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 node @@ -115,13 +157,14 @@ export function withDirectives( export function invokeDirectiveHook( instance: ComponentInternalInstance | null, name: DirectiveHookName, - nodes?: IterableIterator, + scope: RenderEffectScope, ) { - 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) } } @@ -149,3 +192,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..518bfa3e2 --- /dev/null +++ b/packages/runtime-vapor/src/directivesChildFragment.ts @@ -0,0 +1,148 @@ +import { ReactiveEffect, getCurrentScope } from '@vue/reactivity' +import { + type Directive, + type DirectiveHookName, + createRenderingUpdateTrigger, + invokeDirectiveHook, +} from './directives' +import { warn } from './warning' +import { + type RenderEffectScope, + isRenderEffectScope, +} from './renderEffectScope' +import { currentInstance } from './component' +import { VaporErrorCodes, callWithErrorHandling } from './errorHandling' +import { queueJob, queuePostFlushCb } from './scheduler' + +/** + * used in createIf and createFor + * manages Directives of child fragments in the component dom. + */ +export function createChildFragmentDirectives( + anchor: Node, + getScopes: () => RenderEffectScope[], + source: () => any, + initCallback: (getValue: () => any) => void, + effectCallback: (getValue: () => any) => void, +) { + let isTriggered = false + const instance = currentInstance! + const parentScope = getCurrentScope() as RenderEffectScope + 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 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(() => + callWithErrorHandling(source, instance, VaporErrorCodes.RENDER_FUNCTION), + ) + 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: RenderEffectScope, handler?: () => any) { + if (isRenderEffectScope(scope.parent) && !scope.parent.im) { + return handler && handler() + } + return invokeWithDirsHooks(scope, 'mount', handler) +} + +export function invokeWithUnmount( + scope: RenderEffectScope, + handler?: () => void, +) { + try { + return invokeWithDirsHooks(scope, 'unmount', handler) + } finally { + scope.stop() + } +} + +export function invokeWithUpdate( + scope: RenderEffectScope, + handler?: () => void, +) { + return invokeWithDirsHooks(scope, 'update', handler) +} + +const lifecycleMap = { + mount: ['beforeMount', 'mounted'], + update: ['beforeUpdate', 'updated'], + unmount: ['beforeUnmount', 'unmounted'], +} as const + +function invokeWithDirsHooks( + scope: RenderEffectScope, + name: keyof typeof lifecycleMap, + handler?: () => any, +) { + const { dirs, ie: 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 97cf5f73e..b61f8c355 100644 --- a/packages/runtime-vapor/src/renderEffect.ts +++ b/packages/runtime-vapor/src/renderEffect.ts @@ -19,66 +19,68 @@ export function renderEffect(cb: () => void) { const instance = getCurrentInstance() const scope = getCurrentScope() - let effect: ReactiveEffect + 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) - const job: SchedulerJob = () => { + effect.run() + + function job() { if (!(effect.flags & EffectFlags.ACTIVE) || !effect.dirty) { return } + const reset = instance && setCurrentInstance(instance) + if (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) + reset && reset() } - - effect.run() } export function firstEffect( diff --git a/packages/runtime-vapor/src/renderEffectScope.ts b/packages/runtime-vapor/src/renderEffectScope.ts new file mode 100644 index 000000000..10ab470a1 --- /dev/null +++ b/packages/runtime-vapor/src/renderEffectScope.ts @@ -0,0 +1,42 @@ +import { EffectScope, getCurrentScope } from '@vue/reactivity' +import type { ComponentInternalInstance } from './component' +import type { DirectiveBindingsMap } from './directives' + +export class RenderEffectScope extends EffectScope { + /** + * instance + * @internal + */ + ie: ComponentInternalInstance + /** + * isMounted + * @internal + */ + im: boolean + /** + * directives + * @internal + */ + dirs: DirectiveBindingsMap | undefined + + constructor( + instance: ComponentInternalInstance, + parentScope: EffectScope | null, + ) { + const isInOtherScope = parentScope && parentScope !== getCurrentScope() + isInOtherScope && parentScope.on() + try { + super(!parentScope) + } finally { + isInOtherScope && parentScope.off() + } + this.im = false + this.ie = instance + } +} + +export function isRenderEffectScope( + scope: EffectScope | undefined, +): scope is RenderEffectScope { + return scope instanceof RenderEffectScope +}