From 538e98eee5468262afd61b4916acaed10b8676e5 Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Sun, 14 Sep 2025 20:28:34 +0300 Subject: [PATCH 1/3] feat: expose store options in PiniaCustomProperties for plugin access - Add StoreOptionsAccess utility type for accessing custom store options - Modify store creation to include _options property with store options - Export StoreOptionsAccess type from main module - Add comprehensive tests for plugin access to custom store options - Support both option stores and setup stores - Maintain backward compatibility with existing plugins Fixes #1247 --- .../__tests__/storeOptionsAccess.spec.ts | 264 ++++++++++++++++++ packages/pinia/src/index.ts | 1 + packages/pinia/src/store.ts | 8 +- packages/pinia/src/types.ts | 27 ++ .../test-dts/storeOptionsAccess.test-d.ts | 139 +++++++++ 5 files changed, 438 insertions(+), 1 deletion(-) create mode 100644 packages/pinia/__tests__/storeOptionsAccess.spec.ts create mode 100644 packages/pinia/test-dts/storeOptionsAccess.test-d.ts diff --git a/packages/pinia/__tests__/storeOptionsAccess.spec.ts b/packages/pinia/__tests__/storeOptionsAccess.spec.ts new file mode 100644 index 0000000000..30d8e7cead --- /dev/null +++ b/packages/pinia/__tests__/storeOptionsAccess.spec.ts @@ -0,0 +1,264 @@ +import { describe, it, expect } from 'vitest' +import { createPinia, defineStore, StoreDefinition } from '../src' +import { mount } from '@vue/test-utils' +import { ref } from 'vue' + +// Extend the types to test the new functionality +declare module '../src' { + export interface DefineStoreOptionsBase { + stores?: Record + customOption?: string + debounce?: Record + } + + export interface PiniaCustomProperties { + readonly stores: any + readonly customOption: any + readonly debounce: any + } +} + +describe('Store Options Access', () => { + it('allows plugins to access custom store options with proper typing', async () => { + // Create some stores to be used in the stores option + const useCounterStore = defineStore('counter', { + state: () => ({ count: 0 }), + actions: { + increment() { + this.count++ + }, + }, + }) + + const useUserStore = defineStore('user', { + state: () => ({ name: 'John' }), + actions: { + setName(name: string) { + this.name = name + }, + }, + }) + + // Create a store with custom options + const useMainStore = defineStore('main', { + state: () => ({ value: 0 }), + actions: { + setValue(val: number) { + this.value = val + }, + }, + // Custom options that should be accessible in plugins + stores: { + counter: useCounterStore, + user: useUserStore, + }, + customOption: 'test-value', + debounce: { + setValue: 300, + }, + }) + + const pinia = createPinia() + mount({ template: 'none' }, { global: { plugins: [pinia] } }) + + let mainStorePluginContext: any = null + + // Plugin that accesses the custom options + pinia.use((context) => { + // Only capture the context for the main store (which has custom options) + if (context.store.$id === 'main') { + mainStorePluginContext = context + } + + // Access the stores option from context.options + const storesOption = context.options.stores + const customOptionValue = context.options.customOption + const debounceOption = context.options.debounce + + return { + get stores() { + if (!storesOption) return {} + return Object.freeze( + Object.entries(storesOption).reduce>( + (acc, [name, definition]) => { + acc[name] = definition() + return acc + }, + {} + ) + ) + }, + get customOption() { + return customOptionValue + }, + get debounce() { + return debounceOption + }, + } + }) + + const store = useMainStore() + + // Verify that the plugin context has access to the options for the main store + expect(mainStorePluginContext).toBeTruthy() + expect(mainStorePluginContext.options.stores).toBeDefined() + expect(mainStorePluginContext.options.stores.counter).toBe(useCounterStore) + expect(mainStorePluginContext.options.stores.user).toBe(useUserStore) + expect(mainStorePluginContext.options.customOption).toBe('test-value') + expect(mainStorePluginContext.options.debounce).toEqual({ setValue: 300 }) + + // Verify that the store has access to the custom properties + expect(store.stores).toBeDefined() + expect(store.stores.counter).toBeDefined() + expect(store.stores.user).toBeDefined() + expect(store.customOption).toBe('test-value') + expect(store.debounce).toEqual({ setValue: 300 }) + + // Verify that the stores are properly instantiated + expect(store.stores.counter.count).toBe(0) + expect(store.stores.user.name).toBe('John') + + // Test that the stores work correctly + store.stores.counter.increment() + expect(store.stores.counter.count).toBe(1) + + store.stores.user.setName('Jane') + expect(store.stores.user.name).toBe('Jane') + }) + + it('works with setup stores', async () => { + const useHelperStore = defineStore('helper', () => { + const value = ref(42) + return { value } + }) + + const useSetupStore = defineStore( + 'setup', + () => { + const count = ref(0) + const increment = () => count.value++ + return { count, increment } + }, + { + stores: { + helper: useHelperStore, + }, + customOption: 'setup-test', + } + ) + + const pinia = createPinia() + mount({ template: 'none' }, { global: { plugins: [pinia] } }) + + let setupStorePluginContext: any = null + + pinia.use((context) => { + // Only capture the context for the setup store (which has custom options) + if (context.store.$id === 'setup') { + setupStorePluginContext = context + } + + const storesOption = context.options.stores + const customOptionValue = context.options.customOption + + return { + get stores() { + if (!storesOption) return {} + return Object.freeze( + Object.entries(storesOption).reduce>( + (acc, [name, definition]) => { + acc[name] = definition() + return acc + }, + {} + ) + ) + }, + get customOption() { + return customOptionValue + }, + } + }) + + const store = useSetupStore() + + // Verify plugin context + expect(setupStorePluginContext.options.stores).toBeDefined() + expect(setupStorePluginContext.options.stores.helper).toBe(useHelperStore) + expect(setupStorePluginContext.options.customOption).toBe('setup-test') + + // Verify store properties + expect(store.stores).toBeDefined() + expect(store.stores.helper).toBeDefined() + expect(store.stores.helper.value).toBe(42) + expect(store.customOption).toBe('setup-test') + }) + + it('handles stores without custom options', async () => { + const useSimpleStore = defineStore('simple', { + state: () => ({ value: 1 }), + }) + + const pinia = createPinia() + mount({ template: 'none' }, { global: { plugins: [pinia] } }) + + pinia.use((context) => { + const storesOption = context.options.stores + const customOptionValue = context.options.customOption + + return { + get stores() { + return storesOption + ? Object.freeze( + Object.entries(storesOption).reduce>( + (acc, [name, definition]) => { + acc[name] = definition() + return acc + }, + {} + ) + ) + : {} + }, + get customOption() { + return customOptionValue + }, + } + }) + + const store = useSimpleStore() + + // Should have empty stores and undefined customOption + expect(store.stores).toEqual({}) + expect(store.customOption).toBeUndefined() + }) + + it('maintains backward compatibility', async () => { + const useCompatStore = defineStore('compat', { + state: () => ({ count: 0 }), + actions: { + increment() { + this.count++ + }, + }, + }) + + const pinia = createPinia() + mount({ template: 'none' }, { global: { plugins: [pinia] } }) + + // Plugin that doesn't use the new functionality + pinia.use(({ store }) => { + return { + pluginProperty: 'test', + } as any + }) + + const store = useCompatStore() + + // Should work as before + expect((store as any).pluginProperty).toBe('test') + expect(store.count).toBe(0) + store.increment() + expect(store.count).toBe(1) + }) +}) diff --git a/packages/pinia/src/index.ts b/packages/pinia/src/index.ts index c398e016f1..a3f9fa3ae7 100644 --- a/packages/pinia/src/index.ts +++ b/packages/pinia/src/index.ts @@ -28,6 +28,7 @@ export type { StoreOnActionListener, _StoreOnActionListenerContext, StoreOnActionListenerContext, + StoreOptionsAccess, SubscriptionCallback, SubscriptionCallbackMutation, SubscriptionCallbackMutationDirect, diff --git a/packages/pinia/src/store.ts b/packages/pinia/src/store.ts index 7ec0e17aeb..8984161620 100644 --- a/packages/pinia/src/store.ts +++ b/packages/pinia/src/store.ts @@ -474,12 +474,18 @@ function createSetupStore< { _hmrPayload, _customProperties: markRaw(new Set()), // devtools custom properties + _options: optionsForPlugin, // store options for plugins }, partialStore // must be added later // setupStore ) - : partialStore + : assign( + { + _options: optionsForPlugin, // store options for plugins + }, + partialStore + ) ) as unknown as Store // store the partial store now so the setup of stores can instantiate each other before they are finished without diff --git a/packages/pinia/src/types.ts b/packages/pinia/src/types.ts index 97651e9922..ae7290100e 100644 --- a/packages/pinia/src/types.ts +++ b/packages/pinia/src/types.ts @@ -274,6 +274,14 @@ export interface StoreProperties { */ _customProperties: Set + /** + * Store options passed to defineStore(). Used internally by plugins to access + * custom options defined in DefineStoreOptionsBase. + * + * @internal + */ + _options?: any + /** * Handles a HMR replacement of this store. Dev Only. * @@ -528,6 +536,25 @@ export interface PiniaCustomProperties< A /* extends ActionsTree */ = _ActionsTree, > {} +/** + * Utility type to access store options within PiniaCustomProperties. + * This allows plugins to access custom options defined in DefineStoreOptionsBase. + * + * @example + * ```ts + * declare module 'pinia' { + * export interface DefineStoreOptionsBase { + * stores?: Record; + * } + * + * export interface PiniaCustomProperties { + * readonly stores: any; // Use any for now, will be properly typed by plugins + * } + * } + * ``` + */ +export type StoreOptionsAccess = any + /** * Properties that are added to every `store.$state` by `pinia.use()`. */ diff --git a/packages/pinia/test-dts/storeOptionsAccess.test-d.ts b/packages/pinia/test-dts/storeOptionsAccess.test-d.ts new file mode 100644 index 0000000000..9d3cfb71ae --- /dev/null +++ b/packages/pinia/test-dts/storeOptionsAccess.test-d.ts @@ -0,0 +1,139 @@ +import { + expectType, + defineStore, + StoreDefinition, + createPinia, + StoreOptionsAccess, +} from './' + +// Test the new store options access functionality +declare module '../dist/pinia' { + export interface DefineStoreOptionsBase { + stores?: Record + customString?: string + customNumber?: number + customObject?: { key: string; value: number } + } + + export interface PiniaCustomProperties { + readonly stores: StoreOptionsAccess + readonly customString: StoreOptionsAccess + readonly customNumber: StoreOptionsAccess + readonly customObject: StoreOptionsAccess + } +} + +// Create test stores +const useCounterStore = defineStore('counter', { + state: () => ({ count: 0 }), + actions: { + increment() { + this.count++ + }, + }, +}) + +const useUserStore = defineStore('user', { + state: () => ({ name: 'John' }), +}) + +// Test store with custom options +const useMainStore = defineStore('main', { + state: () => ({ value: 0 }), + actions: { + setValue(val: number) { + this.value = val + }, + }, + stores: { + counter: useCounterStore, + user: useUserStore, + }, + customString: 'test', + customNumber: 42, + customObject: { key: 'test', value: 123 }, +}) + +const pinia = createPinia() + +// Test plugin context types +pinia.use((context) => { + // Test that options are properly typed + expectType | undefined>( + context.options.stores + ) + expectType(context.options.customString) + expectType(context.options.customNumber) + expectType<{ key: string; value: number } | undefined>( + context.options.customObject + ) + + return { + get stores() { + const storesOption = context.options.stores + if (!storesOption) return {} + return Object.freeze( + Object.entries(storesOption).reduce>( + (acc, [name, definition]) => { + acc[name] = definition() + return acc + }, + {} + ) + ) + }, + get customString() { + return context.options.customString + }, + get customNumber() { + return context.options.customNumber + }, + get customObject() { + return context.options.customObject + }, + } +}) + +const store = useMainStore() + +// Test that store properties are properly typed +expectType>(store.stores) +expectType(store.customString) +expectType(store.customNumber) +expectType<{ key: string; value: number } | undefined>(store.customObject) + +// Test accessing nested store properties +if (store.stores.counter) { + expectType(store.stores.counter) +} + +if (store.stores.user) { + expectType(store.stores.user) +} + +// Test setup store with custom options +const useSetupStore = defineStore( + 'setup', + () => { + return { count: 0 } + }, + { + customString: 'setup-test', + customNumber: 100, + } +) + +const setupStore = useSetupStore() +expectType(setupStore.customString) +expectType(setupStore.customNumber) + +// Test store without custom options +const useSimpleStore = defineStore('simple', { + state: () => ({ value: 1 }), +}) + +const simpleStore = useSimpleStore() +expectType>(simpleStore.stores) +expectType(simpleStore.customString) +expectType(simpleStore.customNumber) +expectType<{ key: string; value: number } | undefined>(simpleStore.customObject) From fcf686b4913b251b05d1d868cbd905c33b212c78 Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Mon, 15 Sep 2025 08:50:58 +0300 Subject: [PATCH 2/3] fix(docstring): added docstring to store.ts --- packages/pinia/src/store.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/pinia/src/store.ts b/packages/pinia/src/store.ts index 8984161620..fd541dfbb6 100644 --- a/packages/pinia/src/store.ts +++ b/packages/pinia/src/store.ts @@ -213,6 +213,22 @@ function createOptionsStore< return store as any } +/** + * Create and register a Pinia store implemented with the setup API (core factory). + * + * Builds the reactive store instance, wires its state into the global Pinia state tree, + * wraps actions for $onAction tracking, attaches $patch/$reset/$subscribe/$dispose helpers, + * applies plugins and devtools metadata, and registers the store on the provided Pinia + * instance. Also prepares Hot Module Replacement (HMR) support and optional hydration logic. + * + * @param $id - Unique store id used as the key in pinia.state and for registration. + * @param setup - Store setup function that receives setup helpers and returns state, getters, and actions. + * @param options - Optional store definition/options; used for plugins, getters (options API compatibility), and hydration. + * @param pinia - The Pinia root instance where the store will be registered. + * @param hot - When true, build the store in hot-update mode (uses a temporary hotState and enables HMR-specific wiring). + * @param isOptionsStore - Set to true for stores created from the Options API, so certain setup-store behaviors (like state wiring) are skipped. + * @returns The reactive Store instance is exposing state, getters, actions, and Pinia helpers. + */ function createSetupStore< Id extends string, SS extends Record, From c904a347cab5a2028ee7a12850c4f39e313bfc65 Mon Sep 17 00:00:00 2001 From: doubledare704 Date: Tue, 16 Sep 2025 22:27:19 +0300 Subject: [PATCH 3/3] fix(store): mark store options as raw and sync _options property - Update docstring for `createSetupStore` return type for clarity - Mark `_options` as raw to prevent reactivity issues - Add syncing of `_options` property during HMR - Ensure `_options` is non-enumerable and consistent in internal properties --- packages/pinia/src/store.ts | 40 ++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/pinia/src/store.ts b/packages/pinia/src/store.ts index fd541dfbb6..8497ff6618 100644 --- a/packages/pinia/src/store.ts +++ b/packages/pinia/src/store.ts @@ -227,7 +227,7 @@ function createOptionsStore< * @param pinia - The Pinia root instance where the store will be registered. * @param hot - When true, build the store in hot-update mode (uses a temporary hotState and enables HMR-specific wiring). * @param isOptionsStore - Set to true for stores created from the Options API, so certain setup-store behaviors (like state wiring) are skipped. - * @returns The reactive Store instance is exposing state, getters, actions, and Pinia helpers. + * @returns A reactive store instance that exposes state, getters, actions, and Pinia helpers. */ function createSetupStore< Id extends string, @@ -490,7 +490,7 @@ function createSetupStore< { _hmrPayload, _customProperties: markRaw(new Set()), // devtools custom properties - _options: optionsForPlugin, // store options for plugins + _options: markRaw(optionsForPlugin), // store options for plugins }, partialStore // must be added later @@ -498,7 +498,7 @@ function createSetupStore< ) : assign( { - _options: optionsForPlugin, // store options for plugins + _options: markRaw(optionsForPlugin), // store options for plugins }, partialStore ) @@ -688,6 +688,16 @@ function createSetupStore< } }) + // sync plugin options + if ('_options' in newStore) { + Object.defineProperty(store, '_options', { + value: newStore._options, + enumerable: false, + configurable: true, + writable: false, + }) + } + // update the values used in devtools and to allow deleting new properties later on store._hmrPayload = newStore._hmrPayload store._getters = newStore._getters @@ -704,15 +714,21 @@ function createSetupStore< } // avoid listing internal properties in devtools - ;(['_p', '_hmrPayload', '_getters', '_customProperties'] as const).forEach( - (p) => { - Object.defineProperty( - store, - p, - assign({ value: store[p] }, nonEnumerable) - ) - } - ) + ;( + [ + '_p', + '_hmrPayload', + '_getters', + '_customProperties', + '_options', + ] as const + ).forEach((p) => { + Object.defineProperty( + store, + p, + assign({ value: store[p] }, nonEnumerable) + ) + }) } // apply all plugins