]> git.ipfire.org Git - thirdparty/vuejs/pinia.git/commitdiff
fix: setup devtools once fix/devtools-setup-once
authorEduardo San Martin Morote <posva13@gmail.com>
Mon, 3 Nov 2025 10:47:01 +0000 (11:47 +0100)
committerEduardo San Martin Morote <posva13@gmail.com>
Mon, 3 Nov 2025 10:47:01 +0000 (11:47 +0100)
Fix #2818

Close #2858

packages/pinia/src/devtools/plugin.ts

index a46d2843965848955bc49737170404a7b8fb8195..dddbcede1017e78bbefadcbd74030d935a5f028a 100644 (file)
@@ -52,6 +52,12 @@ interface TimelineEvent<TData = any, TMeta = any> {
  */
 const getStoreType = (id: string) => '๐Ÿ ' + id
 
+type DevtoolsPluginAPI = Parameters<
+  Parameters<typeof setupDevtoolsPlugin>[1]
+>[0]
+
+const registeredApps = new WeakMap<App, DevtoolsPluginAPI>()
+
 /**
  * Add the pinia plugin without any store. Allows displaying a Pinia plugin tab
  * as soon as it is added to the application.
@@ -59,454 +65,444 @@ const getStoreType = (id: string) => '๐Ÿ ' + id
  * @param app - Vue application
  * @param pinia - pinia instance
  */
-export function registerPiniaDevtools(app: App, pinia: Pinia) {
-  setupDevtoolsPlugin(
-    {
-      id: 'dev.esm.pinia',
-      label: 'Pinia ๐Ÿ',
-      logo: 'https://pinia.vuejs.org/logo.svg',
-      packageName: 'pinia',
-      homepage: 'https://pinia.vuejs.org',
-      componentStateTypes,
-      app,
-    },
-    (api) => {
-      if (typeof api.now !== 'function') {
-        toastMessage(
-          'You seem to be using an outdated version of Vue Devtools. Are you still using the Beta release instead of the stable one? You can find the links at https://devtools.vuejs.org/guide/installation.html.'
-        )
-      }
+export async function registerPiniaDevtools(app: App, pinia: Pinia) {
+  return (
+    registeredApps.get(app) ||
+    new Promise<DevtoolsPluginAPI>((resolve) => {
+      setupDevtoolsPlugin(
+        {
+          id: 'dev.esm.pinia',
+          label: 'Pinia ๐Ÿ',
+          logo: 'https://pinia.vuejs.org/logo.svg',
+          packageName: 'pinia',
+          homepage: 'https://pinia.vuejs.org',
+          componentStateTypes,
+          app,
+        },
+        (api) => {
+          registeredApps.set(app, api)
 
-      api.addTimelineLayer({
-        id: MUTATIONS_LAYER_ID,
-        label: `Pinia ๐Ÿ`,
-        color: 0xe5df88,
-      })
+          if (typeof api.now !== 'function') {
+            toastMessage(
+              'You seem to be using an outdated version of Vue Devtools. Are you still using the Beta release instead of the stable one? You can find the links at https://devtools.vuejs.org/guide/installation.html.'
+            )
+          }
 
-      api.addInspector({
-        id: INSPECTOR_ID,
-        label: 'Pinia ๐Ÿ',
-        icon: 'storage',
-        treeFilterPlaceholder: 'Search stores',
-        actions: [
-          {
-            icon: 'content_copy',
-            action: () => {
-              actionGlobalCopyState(pinia)
-            },
-            tooltip: 'Serialize and copy the state',
-          },
-          {
-            icon: 'content_paste',
-            action: async () => {
-              await actionGlobalPasteState(pinia)
-              api.sendInspectorTree(INSPECTOR_ID)
-              api.sendInspectorState(INSPECTOR_ID)
-            },
-            tooltip: 'Replace the state with the content of your clipboard',
-          },
-          {
-            icon: 'save',
-            action: () => {
-              actionGlobalSaveState(pinia)
-            },
-            tooltip: 'Save the state as a JSON file',
-          },
-          {
-            icon: 'folder_open',
-            action: async () => {
-              await actionGlobalOpenStateFile(pinia)
-              api.sendInspectorTree(INSPECTOR_ID)
-              api.sendInspectorState(INSPECTOR_ID)
-            },
-            tooltip: 'Import the state from a JSON file',
-          },
-        ],
-        nodeActions: [
-          {
-            icon: 'restore',
-            tooltip: 'Reset the state (with "$reset")',
-            action: (nodeId) => {
-              const store = pinia._s.get(nodeId)
-              if (!store) {
-                toastMessage(
-                  `Cannot reset "${nodeId}" store because it wasn't found.`,
-                  'warn'
-                )
-              } else if (typeof store.$reset !== 'function') {
-                toastMessage(
-                  `Cannot reset "${nodeId}" store because it doesn't have a "$reset" method implemented.`,
-                  'warn'
-                )
-              } else {
-                store.$reset()
-                toastMessage(`Store "${nodeId}" reset.`)
-              }
-            },
-          },
-        ],
-      })
+          api.addTimelineLayer({
+            id: MUTATIONS_LAYER_ID,
+            label: `Pinia ๐Ÿ`,
+            color: 0xe5df88,
+          })
 
-      api.on.inspectComponent((payload) => {
-        const proxy = (payload.componentInstance &&
-          payload.componentInstance.proxy) as
-          | ComponentPublicInstance
-          | undefined
-        if (proxy && proxy._pStores) {
-          const piniaStores = (
-            payload.componentInstance.proxy as ComponentPublicInstance
-          )._pStores!
-
-          Object.values(piniaStores).forEach((store) => {
-            payload.instanceData.state.push({
-              type: getStoreType(store.$id),
-              key: 'state',
-              editable: true,
-              value: store._isOptionsAPI
-                ? {
-                    _custom: {
-                      value: toRaw(store.$state),
-                      actions: [
-                        {
-                          icon: 'restore',
-                          tooltip: 'Reset the state of this store',
-                          action: () => store.$reset(),
-                        },
-                      ],
-                    },
-                  }
-                : // NOTE: workaround to unwrap transferred refs
-                  Object.keys(store.$state).reduce((state, key) => {
-                    state[key] = store.$state[key]
-                    return state
-                  }, {} as StateTree),
-            })
-
-            if (store._getters && store._getters.length) {
-              payload.instanceData.state.push({
-                type: getStoreType(store.$id),
-                key: 'getters',
-                editable: false,
-                value: store._getters.reduce((getters, key) => {
-                  try {
-                    getters[key] = store[key]
-                  } catch (error) {
-                    // @ts-expect-error: we just want to show it in devtools
-                    getters[key] = error
+          api.addInspector({
+            id: INSPECTOR_ID,
+            label: 'Pinia ๐Ÿ',
+            icon: 'storage',
+            treeFilterPlaceholder: 'Search stores',
+            actions: [
+              {
+                icon: 'content_copy',
+                action: () => {
+                  actionGlobalCopyState(pinia)
+                },
+                tooltip: 'Serialize and copy the state',
+              },
+              {
+                icon: 'content_paste',
+                action: async () => {
+                  await actionGlobalPasteState(pinia)
+                  api.sendInspectorTree(INSPECTOR_ID)
+                  api.sendInspectorState(INSPECTOR_ID)
+                },
+                tooltip: 'Replace the state with the content of your clipboard',
+              },
+              {
+                icon: 'save',
+                action: () => {
+                  actionGlobalSaveState(pinia)
+                },
+                tooltip: 'Save the state as a JSON file',
+              },
+              {
+                icon: 'folder_open',
+                action: async () => {
+                  await actionGlobalOpenStateFile(pinia)
+                  api.sendInspectorTree(INSPECTOR_ID)
+                  api.sendInspectorState(INSPECTOR_ID)
+                },
+                tooltip: 'Import the state from a JSON file',
+              },
+            ],
+            nodeActions: [
+              {
+                icon: 'restore',
+                tooltip: 'Reset the state (with "$reset")',
+                action: (nodeId) => {
+                  const store = pinia._s.get(nodeId)
+                  if (!store) {
+                    toastMessage(
+                      `Cannot reset "${nodeId}" store because it wasn't found.`,
+                      'warn'
+                    )
+                  } else if (typeof store.$reset !== 'function') {
+                    toastMessage(
+                      `Cannot reset "${nodeId}" store because it doesn't have a "$reset" method implemented.`,
+                      'warn'
+                    )
+                  } else {
+                    store.$reset()
+                    toastMessage(`Store "${nodeId}" reset.`)
                   }
-                  return getters
-                }, {} as _GettersTree<StateTree>),
+                },
+              },
+            ],
+          })
+
+          api.on.inspectComponent((payload) => {
+            const proxy = (payload.componentInstance &&
+              payload.componentInstance.proxy) as
+              | ComponentPublicInstance
+              | undefined
+            if (proxy && proxy._pStores) {
+              const piniaStores = (
+                payload.componentInstance.proxy as ComponentPublicInstance
+              )._pStores!
+
+              Object.values(piniaStores).forEach((store) => {
+                payload.instanceData.state.push({
+                  type: getStoreType(store.$id),
+                  key: 'state',
+                  editable: true,
+                  value: store._isOptionsAPI
+                    ? {
+                        _custom: {
+                          value: toRaw(store.$state),
+                          actions: [
+                            {
+                              icon: 'restore',
+                              tooltip: 'Reset the state of this store',
+                              action: () => store.$reset(),
+                            },
+                          ],
+                        },
+                      }
+                    : // NOTE: workaround to unwrap transferred refs
+                      Object.keys(store.$state).reduce((state, key) => {
+                        state[key] = store.$state[key]
+                        return state
+                      }, {} as StateTree),
+                })
+
+                if (store._getters && store._getters.length) {
+                  payload.instanceData.state.push({
+                    type: getStoreType(store.$id),
+                    key: 'getters',
+                    editable: false,
+                    value: store._getters.reduce((getters, key) => {
+                      try {
+                        getters[key] = store[key]
+                      } catch (error) {
+                        // @ts-expect-error: we just want to show it in devtools
+                        getters[key] = error
+                      }
+                      return getters
+                    }, {} as _GettersTree<StateTree>),
+                  })
+                }
               })
             }
           })
-        }
-      })
-
-      api.on.getInspectorTree((payload) => {
-        if (payload.app === app && payload.inspectorId === INSPECTOR_ID) {
-          let stores: Array<StoreGeneric | Pinia> = [pinia]
-          stores = stores.concat(Array.from(pinia._s.values()))
-
-          payload.rootNodes = (
-            payload.filter
-              ? stores.filter((store) =>
-                  '$id' in store
-                    ? store.$id
-                        .toLowerCase()
-                        .includes(payload.filter.toLowerCase())
-                    : PINIA_ROOT_LABEL.toLowerCase().includes(
-                        payload.filter.toLowerCase()
-                      )
-                )
-              : stores
-          ).map(formatStoreForInspectorTree)
-        }
-      })
 
-      // Expose pinia instance as $pinia to window
-      globalThis.$pinia = pinia
+          api.on.getInspectorTree((payload) => {
+            if (payload.app === app && payload.inspectorId === INSPECTOR_ID) {
+              let stores: Array<StoreGeneric | Pinia> = [pinia]
+              stores = stores.concat(Array.from(pinia._s.values()))
+
+              payload.rootNodes = (
+                payload.filter
+                  ? stores.filter((store) =>
+                      '$id' in store
+                        ? store.$id
+                            .toLowerCase()
+                            .includes(payload.filter.toLowerCase())
+                        : PINIA_ROOT_LABEL.toLowerCase().includes(
+                            payload.filter.toLowerCase()
+                          )
+                    )
+                  : stores
+              ).map(formatStoreForInspectorTree)
+            }
+          })
 
-      api.on.getInspectorState((payload) => {
-        if (payload.app === app && payload.inspectorId === INSPECTOR_ID) {
-          const inspectedStore =
-            payload.nodeId === PINIA_ROOT_ID
-              ? pinia
-              : pinia._s.get(payload.nodeId)
+          // Expose pinia instance as $pinia to window
+          globalThis.$pinia = pinia
 
-          if (!inspectedStore) {
-            // this could be the selected store restored for a different project
-            // so it's better not to say anything here
-            return
-          }
-
-          if (inspectedStore) {
-            // Expose selected store as $store to window
-            if (payload.nodeId !== PINIA_ROOT_ID)
-              globalThis.$store = toRaw(inspectedStore as StoreGeneric)
-            payload.state = formatStoreForInspectorState(inspectedStore)
-          }
-        }
-      })
+          api.on.getInspectorState((payload) => {
+            if (payload.app === app && payload.inspectorId === INSPECTOR_ID) {
+              const inspectedStore =
+                payload.nodeId === PINIA_ROOT_ID
+                  ? pinia
+                  : pinia._s.get(payload.nodeId)
 
-      api.on.editInspectorState((payload) => {
-        if (payload.app === app && payload.inspectorId === INSPECTOR_ID) {
-          const inspectedStore =
-            payload.nodeId === PINIA_ROOT_ID
-              ? pinia
-              : pinia._s.get(payload.nodeId)
+              if (!inspectedStore) {
+                // this could be the selected store restored for a different project
+                // so it's better not to say anything here
+                return
+              }
 
-          if (!inspectedStore) {
-            return toastMessage(`store "${payload.nodeId}" not found`, 'error')
-          }
+              if (inspectedStore) {
+                // Expose selected store as $store to window
+                if (payload.nodeId !== PINIA_ROOT_ID)
+                  globalThis.$store = toRaw(inspectedStore as StoreGeneric)
+                payload.state = formatStoreForInspectorState(inspectedStore)
+              }
+            }
+          })
 
-          const { path } = payload
+          api.on.editInspectorState((payload) => {
+            if (payload.app === app && payload.inspectorId === INSPECTOR_ID) {
+              const inspectedStore =
+                payload.nodeId === PINIA_ROOT_ID
+                  ? pinia
+                  : pinia._s.get(payload.nodeId)
+
+              if (!inspectedStore) {
+                return toastMessage(
+                  `store "${payload.nodeId}" not found`,
+                  'error'
+                )
+              }
 
-          if (!isPinia(inspectedStore)) {
-            // access only the state
-            if (
-              path.length !== 1 ||
-              !inspectedStore._customProperties.has(path[0]) ||
-              path[0] in inspectedStore.$state
-            ) {
-              path.unshift('$state')
+              const { path } = payload
+
+              if (!isPinia(inspectedStore)) {
+                // access only the state
+                if (
+                  path.length !== 1 ||
+                  !inspectedStore._customProperties.has(path[0]) ||
+                  path[0] in inspectedStore.$state
+                ) {
+                  path.unshift('$state')
+                }
+              } else {
+                // Root access, we can omit the `.value` because the devtools API does it for us
+                path.unshift('state')
+              }
+              isTimelineActive = false
+              payload.set(inspectedStore, path, payload.state.value)
+              isTimelineActive = true
             }
-          } else {
-            // Root access, we can omit the `.value` because the devtools API does it for us
-            path.unshift('state')
-          }
-          isTimelineActive = false
-          payload.set(inspectedStore, path, payload.state.value)
-          isTimelineActive = true
-        }
-      })
+          })
 
-      api.on.editComponentState((payload) => {
-        if (payload.type.startsWith('๐Ÿ')) {
-          const storeId = payload.type.replace(/^๐Ÿ\s*/, '')
-          const store = pinia._s.get(storeId)
+          api.on.editComponentState((payload) => {
+            if (payload.type.startsWith('๐Ÿ')) {
+              const storeId = payload.type.replace(/^๐Ÿ\s*/, '')
+              const store = pinia._s.get(storeId)
 
-          if (!store) {
-            return toastMessage(`store "${storeId}" not found`, 'error')
-          }
+              if (!store) {
+                return toastMessage(`store "${storeId}" not found`, 'error')
+              }
 
-          const { path } = payload
-          if (path[0] !== 'state') {
-            return toastMessage(
-              `Invalid path for store "${storeId}":\n${path}\nOnly state can be modified.`
-            )
-          }
+              const { path } = payload
+              if (path[0] !== 'state') {
+                return toastMessage(
+                  `Invalid path for store "${storeId}":\n${path}\nOnly state can be modified.`
+                )
+              }
 
-          // rewrite the first entry to be able to directly set the state as
-          // well as any other path
-          path[0] = '$state'
-          isTimelineActive = false
-          payload.set(store, path, payload.state.value)
-          isTimelineActive = true
+              // rewrite the first entry to be able to directly set the state as
+              // well as any other path
+              path[0] = '$state'
+              isTimelineActive = false
+              payload.set(store, path, payload.state.value)
+              isTimelineActive = true
+            }
+          })
+
+          resolve(api)
         }
-      })
-    }
+      )
+    })
   )
 }
 
-function addStoreToDevtools(app: App, store: StoreGeneric) {
+async function addStoreToDevtools(app: App, store: StoreGeneric) {
   if (!componentStateTypes.includes(getStoreType(store.$id))) {
     componentStateTypes.push(getStoreType(store.$id))
   }
 
-  setupDevtoolsPlugin(
-    {
-      id: 'dev.esm.pinia',
-      label: 'Pinia ๐Ÿ',
-      logo: 'https://pinia.vuejs.org/logo.svg',
-      packageName: 'pinia',
-      homepage: 'https://pinia.vuejs.org',
-      componentStateTypes,
-      app,
-      settings: {
-        logStoreChanges: {
-          label: 'Notify about new/deleted stores',
-          type: 'boolean',
-          defaultValue: true,
+  const api =
+    registeredApps.get(app) || (await registerPiniaDevtools(app, store._p))
+
+  // gracefully handle errors
+  const now = typeof api.now === 'function' ? api.now.bind(api) : Date.now
+
+  store.$onAction(({ after, onError, name, args }) => {
+    const groupId = runningActionId++
+
+    api.addTimelineEvent({
+      layerId: MUTATIONS_LAYER_ID,
+      event: {
+        time: now(),
+        title: '๐Ÿ›ซ ' + name,
+        subtitle: 'start',
+        data: {
+          store: formatDisplay(store.$id),
+          action: formatDisplay(name),
+          args,
         },
-        // useEmojis: {
-        //   label: 'Use emojis in messages โšก๏ธ',
-        //   type: 'boolean',
-        //   defaultValue: true,
-        // },
+        groupId,
       },
-    },
-    (api) => {
-      // gracefully handle errors
-      const now = typeof api.now === 'function' ? api.now.bind(api) : Date.now
-
-      store.$onAction(({ after, onError, name, args }) => {
-        const groupId = runningActionId++
-
-        api.addTimelineEvent({
-          layerId: MUTATIONS_LAYER_ID,
-          event: {
-            time: now(),
-            title: '๐Ÿ›ซ ' + name,
-            subtitle: 'start',
-            data: {
-              store: formatDisplay(store.$id),
-              action: formatDisplay(name),
-              args,
-            },
-            groupId,
+    })
+
+    after((result) => {
+      activeAction = undefined
+      api.addTimelineEvent({
+        layerId: MUTATIONS_LAYER_ID,
+        event: {
+          time: now(),
+          title: '๐Ÿ›ฌ ' + name,
+          subtitle: 'end',
+          data: {
+            store: formatDisplay(store.$id),
+            action: formatDisplay(name),
+            args,
+            result,
           },
-        })
+          groupId,
+        },
+      })
+    })
 
-        after((result) => {
-          activeAction = undefined
-          api.addTimelineEvent({
-            layerId: MUTATIONS_LAYER_ID,
-            event: {
-              time: now(),
-              title: '๐Ÿ›ฌ ' + name,
-              subtitle: 'end',
-              data: {
-                store: formatDisplay(store.$id),
-                action: formatDisplay(name),
-                args,
-                result,
-              },
-              groupId,
-            },
-          })
-        })
+    onError((error) => {
+      activeAction = undefined
+      api.addTimelineEvent({
+        layerId: MUTATIONS_LAYER_ID,
+        event: {
+          time: now(),
+          logType: 'error',
+          title: '๐Ÿ’ฅ ' + name,
+          subtitle: 'end',
+          data: {
+            store: formatDisplay(store.$id),
+            action: formatDisplay(name),
+            args,
+            error,
+          },
+          groupId,
+        },
+      })
+    })
+  }, true)
 
-        onError((error) => {
-          activeAction = undefined
+  store._customProperties.forEach((name) => {
+    watch(
+      () => unref<unknown>(store[name]),
+      (newValue, oldValue) => {
+        api.notifyComponentUpdate()
+        api.sendInspectorState(INSPECTOR_ID)
+        if (isTimelineActive) {
           api.addTimelineEvent({
             layerId: MUTATIONS_LAYER_ID,
             event: {
               time: now(),
-              logType: 'error',
-              title: '๐Ÿ’ฅ ' + name,
-              subtitle: 'end',
+              title: 'Change',
+              subtitle: name,
               data: {
-                store: formatDisplay(store.$id),
-                action: formatDisplay(name),
-                args,
-                error,
+                newValue,
+                oldValue,
               },
-              groupId,
+              groupId: activeAction,
             },
           })
-        })
-      }, true)
-
-      store._customProperties.forEach((name) => {
-        watch(
-          () => unref<unknown>(store[name]),
-          (newValue, oldValue) => {
-            api.notifyComponentUpdate()
-            api.sendInspectorState(INSPECTOR_ID)
-            if (isTimelineActive) {
-              api.addTimelineEvent({
-                layerId: MUTATIONS_LAYER_ID,
-                event: {
-                  time: now(),
-                  title: 'Change',
-                  subtitle: name,
-                  data: {
-                    newValue,
-                    oldValue,
-                  },
-                  groupId: activeAction,
-                },
-              })
-            }
-          },
-          { deep: true }
-        )
-      })
-
-      store.$subscribe(
-        ({ events, type }, state) => {
-          api.notifyComponentUpdate()
-          api.sendInspectorState(INSPECTOR_ID)
-
-          if (!isTimelineActive) return
-          // rootStore.state[store.id] = state
-
-          const eventData: TimelineEvent = {
-            time: now(),
-            title: formatMutationType(type),
-            data: assign(
-              { store: formatDisplay(store.$id) },
-              formatEventData(events)
-            ),
-            groupId: activeAction,
-          }
+        }
+      },
+      { deep: true }
+    )
+  })
 
-          if (type === MutationType.patchFunction) {
-            eventData.subtitle = 'โคต๏ธ'
-          } else if (type === MutationType.patchObject) {
-            eventData.subtitle = '๐Ÿงฉ'
-          } else if (events && !Array.isArray(events)) {
-            eventData.subtitle = events.type
-          }
+  store.$subscribe(
+    ({ events, type }, state) => {
+      api.notifyComponentUpdate()
+      api.sendInspectorState(INSPECTOR_ID)
 
-          if (events) {
-            eventData.data['rawEvent(s)'] = {
-              _custom: {
-                display: 'DebuggerEvent',
-                type: 'object',
-                tooltip: 'raw DebuggerEvent[]',
-                value: events,
-              },
-            }
-          }
+      if (!isTimelineActive) return
+      // rootStore.state[store.id] = state
+
+      const eventData: TimelineEvent = {
+        time: now(),
+        title: formatMutationType(type),
+        data: assign(
+          { store: formatDisplay(store.$id) },
+          formatEventData(events)
+        ),
+        groupId: activeAction,
+      }
 
-          api.addTimelineEvent({
-            layerId: MUTATIONS_LAYER_ID,
-            event: eventData,
-          })
-        },
-        { detached: true, flush: 'sync' }
-      )
+      if (type === MutationType.patchFunction) {
+        eventData.subtitle = 'โคต๏ธ'
+      } else if (type === MutationType.patchObject) {
+        eventData.subtitle = '๐Ÿงฉ'
+      } else if (events && !Array.isArray(events)) {
+        eventData.subtitle = events.type
+      }
 
-      const hotUpdate = store._hotUpdate
-      store._hotUpdate = markRaw((newStore) => {
-        hotUpdate(newStore)
-        api.addTimelineEvent({
-          layerId: MUTATIONS_LAYER_ID,
-          event: {
-            time: now(),
-            title: '๐Ÿ”ฅ ' + store.$id,
-            subtitle: 'HMR update',
-            data: {
-              store: formatDisplay(store.$id),
-              info: formatDisplay(`HMR update`),
-            },
+      if (events) {
+        eventData.data['rawEvent(s)'] = {
+          _custom: {
+            display: 'DebuggerEvent',
+            type: 'object',
+            tooltip: 'raw DebuggerEvent[]',
+            value: events,
           },
-        })
-        // update the devtools too
-        api.notifyComponentUpdate()
-        api.sendInspectorTree(INSPECTOR_ID)
-        api.sendInspectorState(INSPECTOR_ID)
-      })
-
-      const { $dispose } = store
-      store.$dispose = () => {
-        $dispose()
-        api.notifyComponentUpdate()
-        api.sendInspectorTree(INSPECTOR_ID)
-        api.sendInspectorState(INSPECTOR_ID)
-        api.getSettings().logStoreChanges &&
-          toastMessage(`Disposed "${store.$id}" store ๐Ÿ—‘`)
+        }
       }
 
-      // trigger an update so it can display new registered stores
-      api.notifyComponentUpdate()
-      api.sendInspectorTree(INSPECTOR_ID)
-      api.sendInspectorState(INSPECTOR_ID)
-      api.getSettings().logStoreChanges &&
-        toastMessage(`"${store.$id}" store installed ๐Ÿ†•`)
-    }
+      api.addTimelineEvent({
+        layerId: MUTATIONS_LAYER_ID,
+        event: eventData,
+      })
+    },
+    { detached: true, flush: 'sync' }
   )
+
+  const hotUpdate = store._hotUpdate
+  store._hotUpdate = markRaw((newStore) => {
+    hotUpdate(newStore)
+    api.addTimelineEvent({
+      layerId: MUTATIONS_LAYER_ID,
+      event: {
+        time: now(),
+        title: '๐Ÿ”ฅ ' + store.$id,
+        subtitle: 'HMR update',
+        data: {
+          store: formatDisplay(store.$id),
+          info: formatDisplay(`HMR update`),
+        },
+      },
+    })
+    // update the devtools too
+    api.notifyComponentUpdate()
+    api.sendInspectorTree(INSPECTOR_ID)
+    api.sendInspectorState(INSPECTOR_ID)
+  })
+
+  const { $dispose } = store
+  store.$dispose = () => {
+    $dispose()
+    api.notifyComponentUpdate()
+    api.sendInspectorTree(INSPECTOR_ID)
+    api.sendInspectorState(INSPECTOR_ID)
+    api.getSettings().logStoreChanges &&
+      toastMessage(`Disposed "${store.$id}" store ๐Ÿ—‘`)
+  }
+
+  // trigger an update so it can display new registered stores
+  api.notifyComponentUpdate()
+  api.sendInspectorTree(INSPECTOR_ID)
+  api.sendInspectorState(INSPECTOR_ID)
+  api.getSettings().logStoreChanges &&
+    toastMessage(`"${store.$id}" store installed ๐Ÿ†•`)
 }
 
 let runningActionId = 0