Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
264 changes: 264 additions & 0 deletions packages/pinia/__tests__/storeOptionsAccess.spec.ts
Original file line number Diff line number Diff line change
@@ -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<S, Store> {
stores?: Record<string, StoreDefinition>
customOption?: string
debounce?: Record<string, number>
}

export interface PiniaCustomProperties<Id, S, G, A> {
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<Record<string, any>>(
(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<Record<string, any>>(
(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<Record<string, any>>(
(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)
})
})
1 change: 1 addition & 0 deletions packages/pinia/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type {
StoreOnActionListener,
_StoreOnActionListenerContext,
StoreOnActionListenerContext,
StoreOptionsAccess,
SubscriptionCallback,
SubscriptionCallbackMutation,
SubscriptionCallbackMutationDirect,
Expand Down
24 changes: 23 additions & 1 deletion packages/pinia/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any, unknown>,
Expand Down Expand Up @@ -474,12 +490,18 @@ function createSetupStore<
{
_hmrPayload,
_customProperties: markRaw(new Set<string>()), // 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<Id, S, G, A>

// store the partial store now so the setup of stores can instantiate each other before they are finished without
Expand Down
27 changes: 27 additions & 0 deletions packages/pinia/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,14 @@ export interface StoreProperties<Id extends string> {
*/
_customProperties: Set<string>

/**
* 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.
*
Expand Down Expand Up @@ -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<S, Store> {
* stores?: Record<string, StoreDefinition>;
* }
*
* export interface PiniaCustomProperties<Id, S, G, A> {
* readonly stores: any; // Use any for now, will be properly typed by plugins
* }
* }
* ```
*/
export type StoreOptionsAccess<Store, Key extends keyof any> = any

Comment on lines +539 to +557
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Implement StoreOptionsAccess to actually resolve plugin-augmented option types

Right now it’s any, defeating the PR goal. Make it map a Store instance to its DefineStoreOptionsBase augmentation and pick Key, falling back to undefined when absent.

-export type StoreOptionsAccess<Store, Key extends keyof any> = any
+/**
+ * Resolves the type of a custom option declared via `DefineStoreOptionsBase`
+ * for the given Store instance type and option `Key`.
+ * Returns `undefined` if the option is not declared.
+ */
+export type StoreOptionsAccess<
+  This,
+  Key extends keyof any,
+  Fallback = undefined,
+> =
+  This extends Store<infer Id, infer S, infer G, infer A>
+    ? DefineStoreOptionsBase<S, Store<Id, S, G, A>> extends infer O
+      ? O extends Record<PropertyKey, unknown>
+        ? Key extends keyof O
+          ? O[Key]
+          : Fallback
+        : Fallback
+      : Fallback
+    : Fallback
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* 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<S, Store> {
* stores?: Record<string, StoreDefinition>;
* }
*
* export interface PiniaCustomProperties<Id, S, G, A> {
* readonly stores: any; // Use any for now, will be properly typed by plugins
* }
* }
* ```
*/
export type StoreOptionsAccess<Store, Key extends keyof any> = any
/**
* Utility type to access store options within PiniaCustomProperties.
* This allows plugins to access custom options defined in DefineStoreOptionsBase.
*
* @example
*
🤖 Prompt for AI Agents
In packages/pinia/src/types.ts around lines 539 to 557, StoreOptionsAccess is
currently typed as any; replace it with a conditional/lookup type that, given a
Store instance type, resolves the corresponding augmented DefineStoreOptionsBase
for that store and picks the property Key (returning that property type or
undefined if missing). Implement this using TypeScript conditional/lookup types
that extract the appropriate DefineStoreOptionsBase augmentation for the
provided Store type (e.g., by inferring the store identifier or matching the
Store instance type to the augmented interface) and return the property at Key
or undefined as a fallback.

/**
* Properties that are added to every `store.$state` by `pinia.use()`.
*/
Expand Down
Loading