Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(hmr): extend acceptHMRUpdate to allow for cleaning up side effects of existing store #2793

Open
wants to merge 1 commit into
base: v2
Choose a base branch
from
Open
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
42 changes: 40 additions & 2 deletions packages/docs/cookbook/hot-module-replacement.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

Pinia supports Hot Module replacement so you can edit your stores and interact with them directly in your app without reloading the page, allowing you to keep the existing state, add, or even remove state, actions, and getters.

At the moment, only [Vite](https://vitejs.dev/guide/api-hmr.html#hmr-api) is officially supported but any bundler implementing the `import.meta.hot` spec should work (e.g. [webpack](https://webpack.js.org/api/module-variables/#importmetawebpackhot) seems to use `import.meta.webpackHot` instead of `import.meta.hot`).
You need to add this snippet of code next to any store declaration. Let's say you have three stores: `auth.js`, `cart.js`, and `chat.js`, you will have to add (and adapt) this after the creation of the _store definition_:
At the moment, only [Vite](https://vitejs.dev/guide/api-hmr.html#hmr-api) is officially supported but any bundler implementing the `import.meta.hot` spec should work (e.g. [webpack](https://webpack.js.org/api/module-variables/#importmetawebpackhot) seems to use `import.meta.webpackHot` instead of `import.meta.hot`).
You need to add this snippet of code next to any store declaration. Let's say you have three stores: `auth.js`, `chat.js`, and `scroll.js`, you will have to add (and adapt) this after the creation of the _store definition_:

```js
// auth.js
Expand All @@ -18,3 +18,41 @@ if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useAuth, import.meta.hot))
}
```

You can pass a cleanup function as an optional third argument in order to clean up side effects of the existing store before initializing the new store. This is useful if you have event listeners or other side effects that need to be cleaned up.

```js
// scroll.js
import { defineStore, acceptHMRUpdate } from 'pinia'
import { ref } from 'vue'

export const useScroll = defineStore('scroll', () => {
const scrollTop = ref(window.scrollY)

function onScroll () {
scrollTop.value = window.scrollY
}

function trackScroll () {
window.addEventListener('scroll', onScroll, { passive: true })
}

trackScroll()

function $cleanUp () {
window.removeEventListener('scroll', onScroll)
}

return {
scrollTop,
trackScroll,
$cleanUp,
}
})

if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useScroll, import.meta.hot, (existingStore) => {
existingStore.$cleanUp()
}))
}
```
17 changes: 14 additions & 3 deletions packages/pinia/src/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isPlainObject,
StateTree,
StoreDefinition,
Store,
StoreGeneric,
_GettersTree,
_Method,
Expand Down Expand Up @@ -77,13 +78,18 @@ export function patchObject(
*
* @param initialUseStore - return of the defineStore to hot update
* @param hot - `import.meta.hot`
* @param cleanup - function to clean up side effects of the existing store
*/
export function acceptHMRUpdate<
Id extends string = string,
S extends StateTree = StateTree,
G extends _GettersTree<S> = _GettersTree<S>,
A = _ActionsTree,
>(initialUseStore: StoreDefinition<Id, S, G, A>, hot: any) {
>(
initialUseStore: StoreDefinition<Id, S, G, A>,
hot: any,
cleanup?: (existingStore: Store<Id, S, G, A>) => void
) {
// strip as much as possible from iife.prod
if (!__DEV__) {
return () => {}
Expand Down Expand Up @@ -111,15 +117,20 @@ export function acceptHMRUpdate<
console.warn(
`The id of the store changed from "${initialUseStore.$id}" to "${id}". Reloading.`
)
// return import.meta.hot.invalidate()
return hot.invalidate()
}

const existingStore: StoreGeneric = pinia._s.get(id)!
if (!existingStore) {
console.log(`[Pinia]: skipping hmr because store doesn't exist yet`)
console.log(`[🍍]: Skipping HMR because store doesn't exist yet`)
return
}

// allow the old store to clean up side effects
if (typeof cleanup === 'function') {
cleanup(existingStore as Store<Id, S, G, A>)
}

useStore(pinia, existingStore)
}
}
Expand Down
43 changes: 43 additions & 0 deletions packages/playground/src/stores/scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { defineStore, acceptHMRUpdate } from 'pinia'
import { onScopeDispose, ref } from 'vue'

export const useScroll = defineStore('scroll', () => {
const scrollTop = ref(window.scrollY)

function onScroll() {
scrollTop.value = window.scrollY
}

function trackScroll() {
window.addEventListener('scroll', onScroll, { passive: true })
}

trackScroll()

function $cleanUp() {
console.log('Cleaning up old scroll event listeners')
window.removeEventListener('scroll', onScroll)
}

// if someone wants the scroll tracking only to happen on a certain route,
// one can dispose the store before leaving the route.
onScopeDispose(() => {
console.log('onScopeDispose')
$cleanUp()
})

return {
scrollTop,
trackScroll,
$cleanUp,
}
})

if (import.meta.hot) {
import.meta.hot.accept(
acceptHMRUpdate(useScroll, import.meta.hot, (existingStore) => {
console.log('HMR update')
existingStore.$cleanUp()
})
)
}
38 changes: 38 additions & 0 deletions packages/playground/src/views/ScrollStore.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script lang="ts" setup>
import { onBeforeUnmount, onMounted } from 'vue'
import { getActivePinia } from 'pinia';
import { onBeforeRouteLeave } from 'vue-router';
import { useScroll } from '../stores/scroll'

const bodyStyle = document.body.style
const appStyle = document.getElementById('app')!.style

const scrollStore = useScroll()

onMounted(() => {
bodyStyle.setProperty('height', '300vh')
appStyle.setProperty('position', 'sticky')
appStyle.setProperty('top', '0')
})

onBeforeUnmount(() => {
bodyStyle.removeProperty('height')
appStyle.removeProperty('position')
appStyle.removeProperty('top')
})

onBeforeRouteLeave(() => {
scrollStore.$dispose()
const pinia = getActivePinia()
delete pinia!.state.value[scrollStore.$id]
})
</script>

<template>
<div style="position: sticky; top: 0;">
<h2>Scroll Store</h2>
<p><strong>Scroll top:</strong> {{ scrollStore.scrollTop }}</p>
<p>During development, after saving changes in <code>/stores/scroll.ts</code>, the <code>acceptHMRUpdate</code> function is configured to run the <code>$cleanUp</code> function on the existing store just before the new store is initialized.</p>
<p>You can only verify this manually by making changes in <code>/stores/scroll.ts</code> and checking what scroll event listeners are on the <code>&lt;html&gt;</code> element. There should always only be one.</p>
</div>
</template>