From: Eduardo San Martin Morote Date: Mon, 3 Nov 2025 10:47:01 +0000 (+0100) Subject: fix: setup devtools once X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=refs%2Fheads%2Ffix%2Fdevtools-setup-once;p=thirdparty%2Fvuejs%2Fpinia.git fix: setup devtools once Fix #2818 Close #2858 --- diff --git a/packages/pinia/src/devtools/plugin.ts b/packages/pinia/src/devtools/plugin.ts index a46d2843..dddbcede 100644 --- a/packages/pinia/src/devtools/plugin.ts +++ b/packages/pinia/src/devtools/plugin.ts @@ -52,6 +52,12 @@ interface TimelineEvent { */ const getStoreType = (id: string) => '🍍 ' + id +type DevtoolsPluginAPI = Parameters< + Parameters[1] +>[0] + +const registeredApps = new WeakMap() + /** * 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((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), + }, + }, + ], + }) + + 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), + }) + } }) } }) - } - }) - - api.on.getInspectorTree((payload) => { - if (payload.app === app && payload.inspectorId === INSPECTOR_ID) { - let stores: Array = [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 = [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(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(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