Reading Vue Keep-Alive.

date: 2024-06-16

Vue.js Logo

Motivation

I wanna implement some builtin components in vapor.
At first, I'll implement a KeepAlive component.

However, I don't know how non-vapor KeepAlive builtin component works.
So, let's read original source.

Reading Some Test Cases

At first, I'll read some test cases.
Before reading some details of concrete internal implementation, I should read interfaces expected working.

The source:

core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/cf8be999df06beb1b1a722000b88956a634beff1/packages/runtime-core/__tests__/components/KeepAlive.spec.ts
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor

Previous knowledge:

Mainly, using tow components defined with Options API called one and two. They are set on beforeEach.

core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/cf8be999df06beb1b1a722000b88956a634beff1/packages/runtime-core/__tests__/components/KeepAlive.spec.ts#L38-L68
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor

The tow components are mostly some, and configure mock function at lifecycle options.
Maybe, KeepAlive component's test case are mainly about lifecycle.

hmm... btw, vapor has some problem about lifecycle, bellow:
https://github.com/vuejs/core-vapor/issues/216

If I've implemented KeepAlive component, some cases will be not worked.

hmmmmmmm, should I fix it before implementing KeepAlive component? I hesitate a little...
Well, leaving aside which one to do first, for now let's read some test cases and KeepAlive component's source!


Test Case 1

I don't wanna paste all test cases, I'll pick up some cases. For now, the first case.

core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/cf8be999df06beb1b1a722000b88956a634beff1/packages/runtime-core/__tests__/components/KeepAlive.spec.ts#L80-L101
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor
test('should preserve state', async () => {
  const viewRef = ref('one')
  const instanceRef = ref<any>(null)
  const App = {
    render() {
      return h(KeepAlive, null, {
        default: () => h(views[viewRef.value], { ref: instanceRef }),
      })
    },
  }
  render(h(App), root)
  expect(serializeInner(root)).toBe(`<div>one</div>`)
  instanceRef.value.msg = 'changed'
  await nextTick()
  expect(serializeInner(root)).toBe(`<div>changed</div>`)
  viewRef.value = 'two'
  await nextTick()
  expect(serializeInner(root)).toBe(`<div>two</div>`)
  viewRef.value = 'one'
  await nextTick()
  expect(serializeInner(root)).toBe(`<div>changed</div>`)
})

I see.. state preservation.

The reason of appearing instanceRef.value.msg is component defs on beforeEach.

render(this: any) {
  return h('div', this.msg)
},

Ok, it's quite easy.

I'll read all of others.


should call correct lifecycle hooks

core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/cf8be999df06beb1b1a722000b88956a634beff1/packages/runtime-core/__tests__/components/KeepAlive.spec.ts#L103
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor

test lifecycle via assertHookCalls.
not difficult.

that being said, I may have to implement activated/deactivated hook too.
because it used in test cases.

should call correct lifecycle hooks when toggle the KeepAlive first

core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/cf8be999df06beb1b1a722000b88956a634beff1/packages/runtime-core/__tests__/components/KeepAlive.spec.ts#L186
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor
one.render = () => h(two)

const toggle = ref(true)
const App = {
  render() {
    return h(KeepAlive, () => (toggle.value ? h(one) : null))
  },
}

hmm, it's ok.


should call lifecycle hooks on nested components when root component no hooks

core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/cf8be999df06beb1b1a722000b88956a634beff1/packages/runtime-core/__tests__/components/KeepAlive.spec.ts#L221
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at cf8be999df06beb1b1a722000b88956a634beff1 · vuejs/core-vapor

related: https://github.com/vuejs/core/issues/1742

ha?

ha...?

return h(KeepAlive, () => (toggle.value ? h(one) : null))

ah, I see.


should call correct hooks for nested keep-alive

core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at 80acfa5030a0f54c27987e00e6502d64e41f8aa0 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at 80acfa5030a0f54c27987e00e6502d64e41f8aa0 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/80acfa5030a0f54c27987e00e6502d64e41f8aa0/packages/runtime-core/__tests__/components/KeepAlive.spec.ts#L250
core-vapor/packages/runtime-core/__tests__/components/KeepAlive.spec.ts at 80acfa5030a0f54c27987e00e6502d64e41f8aa0 · vuejs/core-vapor
one.render = () => h(KeepAlive, () => (toggle2.value ? h(two) : null))

const toggle1 = ref(true)
const App = {
  render() {
    return h(KeepAlive, () => (toggle1.value ? h(one) : null))
  },
}

OK.


props

Oh, so we also need to implement KeepAlive Props.

assertNameMatch

I'll read a little more.

async function assertNameMatch(props: KeepAliveProps) {
  const outerRef = ref(true)
  const viewRef = ref('one')
  const App = {
    render() {
      return outerRef.value
        ? h(KeepAlive, props, () => h(views[viewRef.value]))
    // ...

it's just pass the props to KeepAlive. ok.
ok, ok, just a cases of include, exclude, max props.
it's easy.

cache invalidation

I am not good at cache invalidation.
uu...

on include change

on exclude change

Ok. but, how relates to caching...?

on include change + view switch

// two should be pruned
assertHookCalls(two, [1, 1, 1, 1, 1])

Yes!

on exclude change + view switch

excludeRef.value = 'two'
// two should be pruned
assertHookCalls(two, [1, 1, 1, 1, 1])

Yes, Yes.
Ah, if same components paths are set to both include and exclude, how does it work...? (It seems a bit suspicious)
Is it written in the document?

Is this not written? 😵‍💫😵‍💫

Vue.js
Vue.js - The Progressive JavaScript Framework
Vue.js favicon https://vuejs.org/guide/built-ins/keep-alive#include-exclude
Vue.js

Well, according to the test cases, it seems to be prior to exclude.

should not prune current active instance

OK.


should not cache anonymous component when include is specified

should cache anonymous components if include is not specified

relates: https://github.com/vuejs/vue/issues/6938

haha, exactly.
I never realized this until I read the test case.
Realizing my own shortcomings.

h(
  KeepAlive,
  {
    include: include ? 'one' : undefined,
  },
  () => h(views[viewRef.value]),
)

assert(1, include ? 2 : 1)

Ok.


should not destroy active instance when pruning cache

includeRef.value = ['foo', 'bar']
await nextTick()
includeRef.value = []
await nextTick()
expect(Foo.unmounted).not.toHaveBeenCalled()

OK.

should update re-activated component if props have changed

render(h(App), root)
expect(serializeInner(root)).toBe(`0`)

toggle.value = false
await nextTick()
expect(serializeInner(root)).toBe(`<!---->`)

n.value++
await nextTick()
toggle.value = true
await nextTick()
expect(serializeInner(root)).toBe(`1`)

OK.

should call correct vnode hooks

?

Vapor doesn't have vnode hooks.

Ignore.

should work with cloned root due to scopeId / fallthrough attrs

relates: vuejs/core #1511 | KeepAlive with keyed child and scoped style not working

const App = {
  __scopeId: 'foo',
  // ...
}

expect(serializeInner(root)).toBe(`<div foo>one</div>`)
instanceRef.value.msg = 'changed'
await nextTick()
expect(serializeInner(root)).toBe(`<div foo>changed</div>`)
viewRef.value = 'two'
await nextTick()
expect(serializeInner(root)).toBe(`<div foo>two</div>`)
viewRef.value = 'one'
await nextTick()
expect(serializeInner(root)).toBe(`<div foo>changed</div>`)

OK.

// #1513
test('should work with cloned root due to scopeId / fallthrough attrs', async () => {
  //...
})

in source code, #1513 is mentioned, but it's incorrect. haha.

relates: vuejs/core #1513 | KeepAlive does not work with a child whose key = NaN

NaN === NaN // false

ahhhhhhhhhhhhhhhhhhhh

should work with async component

Vapor has not async component yet.
worked at https://github.com/vuejs/core-vapor/pull/239.

however, it's worth to read tests.

// async component has not been resolved
expect(serializeInner(root)).toBe('<!---->')

resolve!({
  name: 'Foo',
  data: () => ({ count: 0 }),
  render() {
    return h('p', this.count)
  },
})

await timeout()
// resolved
expect(serializeInner(root)).toBe('<p>0</p>')

// change state + toggle out
instanceRef.value.count++
toggle.value = false
await nextTick()
expect(serializeInner(root)).toBe('<!---->')

// toggle in, state should be maintained
toggle.value = true
await nextTick()
expect(serializeInner(root)).toBe('<p>1</p>')

OK.

handle error in async onActivated

relates:
vuejs/core #4976 | errorHandler doesn't catch errors from onActivated when callback is async

const Child = {
  setup() {
    onActivated(async () => {
      throw err
    })
  },
  render() {},
}

app.config.errorHandler = handler
app.mount(nodeOps.createElement('div'))

await nextTick()
expect(handler).toHaveBeenCalledWith(err, {}, 'activated hook')

OK.

should avoid unmount later included components

relates:
vuejs/core #3648 | 使用keepAlive缓存路由,首次跳别的路由时候,被缓存的路由会执行unmounted方法

app.mount(root)

expect(serializeInner(root)).toBe(`A`)
expect(mountedA).toHaveBeenCalledTimes(1)
expect(unmountedA).toHaveBeenCalledTimes(0)
expect(activatedA).toHaveBeenCalledTimes(0)
expect(deactivatedA).toHaveBeenCalledTimes(0)
expect(mountedB).toHaveBeenCalledTimes(0)
expect(unmountedB).toHaveBeenCalledTimes(0)

include.push('A') // cache A
await nextTick()
current.value = B // toggle to B
await nextTick()
expect(serializeInner(root)).toBe(`B`)
expect(mountedA).toHaveBeenCalledTimes(1)
expect(unmountedA).toHaveBeenCalledTimes(0)
expect(activatedA).toHaveBeenCalledTimes(0)
expect(deactivatedA).toHaveBeenCalledTimes(1)
expect(mountedB).toHaveBeenCalledTimes(1)
expect(unmountedB).toHaveBeenCalledTimes(0)

OK.


There are so many edge cases!!

I have read all the tests for now!

Reading Actual Implementation

core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/20b6594d621a8501c64c17a81890150d90caa9b2/packages/runtime-core/src/components/KeepAlive.ts
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor

Well, first, where should I start reading from...

isKeepAlive

export const isKeepAlive = (vnode: VNode): boolean =>
  (vnode.type as any).__isKeepAlive

Where is isKeepAlive called...?

->

  • KeepAlive.ts registerKeepAliveHook
  • apiAsyncComponent.ts asyncComponent defs setup/load.then
  • componentSlots.ts normalizeVNodeSlots
  • BaseTransition.ts Transition/emptyPlaceholder, getInnerChild

OK.

Now, vapor has not BaseTransition yet.
So,I'll be careful about apiAsyncComponent and componentSlots.

Basically, KeepAlive is just component definition.

core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/20b6594d621a8501c64c17a81890150d90caa9b2/packages/runtime-core/src/components/KeepAlive.ts#L78
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor

Let's read the component options. (maybe, internal setup process is main)

Ah? Vapor may not have renderContext...

core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/20b6594d621a8501c64c17a81890150d90caa9b2/packages/runtime-core/src/components/KeepAlive.ts#L99
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor

We need to think about what to do.
And we need to think where to put the cache too.

core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/20b6594d621a8501c64c17a81890150d90caa9b2/packages/runtime-core/src/components/KeepAlive.ts#L110-L116
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor

Shared Contexts have signature like below:

export interface KeepAliveContext extends ComponentRenderContext {
  renderer: RendererInternals
  activate: (
    vnode: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    namespace: ElementNamespace,
    optimized: boolean,
  ) => void
  deactivate: (vnode: VNode) => void
}
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/20b6594d621a8501c64c17a81890150d90caa9b2/packages/runtime-core/src/components/KeepAlive.ts#L63-L73
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor

and, setup shared context methods behavior in setup option.

const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,
  // ...
  setup() {
    const instance = getCurrentInstance()!
    const sharedContext = instance.ctx as KeepAliveContext
    // ...

    sharedContext.activate = (vnode, container, anchor, namespace, optimized) => {
      // ...
    }

    sharedContext.deactivate = (vnode) => {
      // ...
    }

    // ..

    return () => {
      // ...
    }
  },
}

As a internal methods, unmount, pruneCache, pruneCacheEntry, cacheSubtree is exist.
And the others, there some implementations about lifecycle.

  • include, exclude watcher
  • onMounted
  • onUpdated
  • onBeforeUnmount

Fow now, I'll read the details!

sharedContext.activate

move(vnode, container, anchor, MoveType.ENTER, parentSuspense)

???
What is this all about, right away?
I don't really understand.

Next, it just seems to be doing patching and toggling isDeactivated in queuePostRenderEffect, as well as executing lifecycle hooks.

I don't really understand why invoke move, however, the other parts are not difficult.

sharedContext.deactivate

move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)

I see.
Is it process for transition?

and the others are just lifecycle hooks (and toggle related flags).

internal methods

unmount

It's just sharedContext.renderer.unmount wrapper. (resetShapeFlag + unmount)
Maybe, vapor doesn't need to this method.

pruneCache

As the name suggests, it's a method to prune the cache.

signature:

function pruneCache(filter?: (name: string) => boolean)

usage:

pruneCache(name => matches(include, name))

And, pruneCache calls pruneCacheEntry.

pruneCacheEntry

signature:

function pruneCacheEntry(key: CacheKey)

Ff current vnode is not exist or vnode is not same type, unmount cached node.
Non-vapor use vnode as caches, so we need to think about how to cache in vapor...

cacheSubtree

caching vnode subtree.

cache.set(pendingCacheKey, getInnerChild(instance.subTree))

Lifecycle and watchers

And then, I'll read handler subscriptions.

onMounted:

onMounted(cacheSubtree)

onUpdated:

onUpdated(cacheSubtree)

onBeforeUnmount:

call deactivated hook and unmount cache.

onBeforeUnmount(() => {
  cache.forEach(cached => {
    // ...
    if (cached.type === vnode.type && cached.key === vnode.key) {
      const da = vnode.component!.da
      da && queuePostRenderEffect(da, suspense)
    } else {
      unmount(cached).
    }
  })
})

watchers:

watch include/exclude props and prune cache.

watch(
  () => [props.include, props.exclude],
  ([include, exclude]) => {
    include && pruneCache(name => matches(include, name))
    exclude && pruneCache(name => !matches(exclude, name))
  },
  // prune post-render after `current` has been updated
  { flush: 'post', deep: true },
)

Render part

core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor
Vue Vapor is a variant of Vue that offers rendering without the Virtual DOM. - vuejs/core-vapor
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor favicon https://github.com/vuejs/core-vapor/blob/20b6594d621a8501c64c17a81890150d90caa9b2/packages/runtime-core/src/components/KeepAlive.ts#L265-L352
core-vapor/packages/runtime-core/src/components/KeepAlive.ts at 20b6594d621a8501c64c17a81890150d90caa9b2 · vuejs/core-vapor

OK! I read almost all of the source code!

© 2024-PRESENT ubugeeei. All rights reserved.