Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 57 additions & 5 deletions packages/reactivity/src/dep.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
import { isArray, isIntegerKey, isMap, isSymbol } from '@vue/shared'
import { type TrackOpTypes, TriggerOpTypes } from './constants'
import { TrackOpTypes, TriggerOpTypes } from './constants'
import { onTrack, triggerEventInfos } from './debug'
import {
type Link,
ReactiveFlags,
type ReactiveNode,
activeSub,
batchDepth,
endBatch,
flush,
link,
propagate,
shallowPropagate,
startBatch,
} from './system'

class Dep implements ReactiveNode {
export class Dep implements ReactiveNode {
_subs: Link | undefined = undefined
subsTail: Link | undefined = undefined
flags: ReactiveFlags = ReactiveFlags.None

// a standalone dep has no map to leave once nothing subscribes it
constructor(
private map: KeyToDepMap,
private key: unknown,
private map?: KeyToDepMap | undefined,
private key?: unknown,
) {}

get subs(): Link | undefined {
Expand All @@ -29,12 +32,61 @@ class Dep implements ReactiveNode {

set subs(value: Link | undefined) {
this._subs = value
if (value === undefined) {
if (value === undefined && this.map) {
this.map.delete(this.key)
}
}
}

/**
* Tracks a dep its owner keys on its own, outside `targetMap`.
* @internal
*/
export function trackDep(
dep: ReactiveNode,
target: object = dep,
key: unknown = 'value',
): void {
if (activeSub !== undefined) {
if (__DEV__) {
onTrack(activeSub, { target, type: TrackOpTypes.GET, key })
}
link(dep, activeSub)
}
}

/**
* @internal
*/
export function triggerDep(
dep: ReactiveNode,
target: object = dep,
key: unknown = 'value',
newValue?: unknown,
oldValue?: unknown,
): void {
const subs = dep.subs
if (subs !== undefined) {
if (__DEV__) {
triggerEventInfos.push({
target,
type: TriggerOpTypes.SET,
key,
newValue,
oldValue,
})
}
propagate(subs)
shallowPropagate(subs)
if (!batchDepth) {
flush()
}
if (__DEV__) {
triggerEventInfos.pop()
}
}
}

// The main WeakMap that stores {target -> key -> dep} connections.
// Conceptually, it's easier to think of a dependency as a Dep class
// which maintains a Set of subscribers, but we simply store them as
Expand Down
6 changes: 5 additions & 1 deletion packages/reactivity/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export {
ARRAY_ITERATE_KEY,
MAP_KEY_ITERATE_KEY,
} from './dep'
/**
* @internal
*/
export { Dep, trackDep, triggerDep } from './dep'
export {
effectScope,
EffectScope,
Expand Down Expand Up @@ -105,4 +109,4 @@ export {
/**
* @internal
*/
export { setActiveSub } from './system'
export { activeSub, endBatch, setActiveSub, startBatch } from './system'
2 changes: 2 additions & 0 deletions packages/runtime-core/src/apiCreateApp.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { EffectScope } from '@vue/reactivity'
import {
type Component,
type ComponentInternalInstance,
Expand Down Expand Up @@ -288,6 +289,7 @@ export interface VdomInVaporInterface {
props?: any,
slots?: any,
once?: boolean,
inputScope?: EffectScope,
) => any
slot: (
slots: any,
Expand Down
18 changes: 18 additions & 0 deletions packages/runtime-vapor/__tests__/apiCreateVaporApp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,24 @@ describe('api: createVaporApp', () => {
expect(`already been mounted`).toHaveBeenWarned()
})

test('preserves root context while evaluating root props', () => {
const Comp = defineVaporComponent({
props: ['value'],
setup(props) {
return createTextNode(String(props.value))
},
})
const app = createVaporApp(Comp, { value: () => inject('token') })
app.provide('token', 'provided value')
const root = document.createElement('div')
try {
app.mount(root)
expect(root.textContent).toBe('provided value')
} finally {
app.unmount()
}
})

test('mount should no-op when selector returns null', () => {
const Comp = defineVaporComponent({
setup() {
Expand Down
109 changes: 109 additions & 0 deletions packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1515,6 +1515,115 @@ describe('api: defineAsyncComponent', () => {
app.unmount()
},
)

test.each([false, true])(
'forwards delivered wrapper inputs without resampling them (v-once: %s)',
async once => {
const source = ref('a')
const sample = vi.fn(() => source.value)
const data = ref({ sample })
let resolve!: (component: VaporComponent) => void
let read!: () => unknown
const Child = defineVaporComponent({
props: ['value'],
setup(props) {
read = () => props.value
const node = template('<span></span>')()
renderEffect(() => setElementText(node, String(props.value)))
return node
},
})
const Async = defineVaporAsyncComponent(
() => new Promise<VaporComponent>(r => (resolve = r)),
)
const App = compile(
`<template><div><components.Async ${once ? 'v-once' : ''}
:value="data.sample()" /></div></template>`,
data,
{ Async },
)
const { app, host } = define(App).render()
expect(sample).toHaveBeenCalledTimes(1)

source.value = 'b'
await nextTick()
expect(sample).toHaveBeenCalledTimes(once ? 1 : 2)
resolve(Child)
await timeout()

const delivered = once ? 'a' : 'b'
expect(host.textContent).toBe(delivered)
expect(read()).toBe(delivered)
expect(sample).toHaveBeenCalledTimes(once ? 1 : 2)

source.value = 'c'
expect(read()).toBe(delivered)
await nextTick()
expect(read()).toBe(once ? 'a' : 'c')
expect(host.textContent).toBe(once ? 'a' : 'c')
expect(sample).toHaveBeenCalledTimes(once ? 1 : 3)
app.unmount()
},
)

test('forwards delivered props and listeners to the error component without resampling sources', async () => {
const first = vi.fn()
const second = vi.fn()
const source = ref({ name: 'a', listener: first })
const sample = vi.fn(() => ({
item: { name: source.value.name },
title: source.value.name,
onClick: source.value.listener,
}))
const data = ref({ sample })
const failure = new Error('failed')
let reject!: (error: Error) => void
const ErrorComp = compile(
`<script setup>defineProps(['item', 'error'])</script>
<template><button>{{ item.name }}:{{ error.message }}</button></template>`,
data,
)
const Async = defineVaporAsyncComponent({
loader: () => new Promise<VaporComponent>((_, r) => (reject = r)),
errorComponent: ErrorComp,
})
const App = compile(
'<template><div><components.Async v-bind="data.sample()" /></div></template>',
data,
{ Async },
)
const { app, host, mount } = define(App).create()
const errors: unknown[] = []
app.config.errorHandler = error => errors.push(error)
mount()
expect(sample).toHaveBeenCalledTimes(1)

reject(failure)
await timeout()
const button = host.querySelector('button')!
expect(button.textContent).toBe('a:failed')
expect(button.title).toBe('a')
expect(sample).toHaveBeenCalledTimes(1)
button.click()
expect(first).toHaveBeenCalledTimes(1)
expect(second).not.toHaveBeenCalled()

source.value = { name: 'b', listener: second }
button.click()
expect(first).toHaveBeenCalledTimes(2)
expect(second).not.toHaveBeenCalled()
await nextTick()

expect(host.querySelector('button')).toBe(button)
expect(button.textContent).toBe('b:failed')
expect(button.title).toBe('b')
expect(sample).toHaveBeenCalledTimes(2)
button.click()
expect(first).toHaveBeenCalledTimes(2)
expect(second).toHaveBeenCalledTimes(1)
expect(errors).toEqual([failure])
app.unmount()
})
})

function mountAsyncError(
Expand Down
6 changes: 4 additions & 2 deletions packages/runtime-vapor/__tests__/apiSetupHelpers.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { createComponent, defineVaporComponent, template } from '../src'
import { ref, useAttrs, useSlots } from '@vue/runtime-dom'
import { nextTick, ref, useAttrs, useSlots } from '@vue/runtime-dom'
import { makeRender } from './_utils'
import type { VaporComponentInstance } from '../src/component'

const define = makeRender<any>()

describe('SFC <script setup> helpers', () => {
test('useSlots / useAttrs (no args)', () => {
test('useSlots / useAttrs (no args)', async () => {
let slots: VaporComponentInstance['slots'] | undefined
let attrs: VaporComponentInstance['attrs'] | undefined

Expand Down Expand Up @@ -35,6 +35,8 @@ describe('SFC <script setup> helpers', () => {
expect(attrs).toMatchObject({ id: 0 })

count.value++
expect(attrs).toMatchObject({ id: 0 })
await nextTick()
expect(attrs).toMatchObject({ id: 1 })
})

Expand Down
3 changes: 1 addition & 2 deletions packages/runtime-vapor/__tests__/apiWatch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,8 +299,7 @@ describe('apiWatch', () => {
}
define(Comp).render()
// should not record watcher in detached scope
// the 1 is the props validation effect
expect(getEffectsCount(instance!.scope)).toBe(1)
expect(getEffectsCount(instance!.scope)).toBe(0)
})

test('watchEffect should keep running if created in a detached scope', async () => {
Expand Down
6 changes: 4 additions & 2 deletions packages/runtime-vapor/__tests__/component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,8 @@ describe('component', () => {
},
}).render()

expect(host.innerHTML).toBe('<div>0</div>')
await nextTick()
expect(host.innerHTML).toBe('<div>1</div>')
})

Expand Down Expand Up @@ -415,8 +417,8 @@ describe('component', () => {
}).render()

const i = instance as VaporComponentInstance
// watchEffect + renderEffect + props validation effect
expect(getEffectsCount(i.scope)).toBe(3)
// watchEffect + renderEffect
expect(getEffectsCount(i.scope)).toBe(2)
expect(host.innerHTML).toBe('<div>0</div>')

app.unmount()
Expand Down
Loading
Loading