Skip to content

Commit ad734cc

Browse files
committed
refactor(0): extract createPluginContext factory to eliminate trinity boilerplate
Introduces `createPluginContext` — a factory that generates the standard context/plugin/consumer triple for plugin composables from a namespace, factory function, and optional setup/fallback config. Migrates 6 plugin composables to the factory: - usePermissions, useStorage: simple (no setup/fallback) - useLogger: setup (dev window export) + fallback (safe outside instances) - useHydration: setup (root mixin) + fallback (SSR-safe) - useBreakpoints: setup (resize listener via mixin) - useLocale: fallback (safe outside instances) Removes ~350 lines of identical trinity boilerplate across the codebase.
1 parent 1d99e5b commit ad734cc

File tree

9 files changed

+214
-600
lines changed

9 files changed

+214
-600
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* @module createPluginContext
3+
*
4+
* @see https://0.vuetifyjs.com/composables/foundation/create-plugin-context
5+
*
6+
* @remarks
7+
* Factory for generating the standard context/plugin/consumer triple for plugin composables.
8+
*
9+
* Eliminates boilerplate across plugin composables by generating three standard functions:
10+
* - `createXContext()` — wraps a composable in a named DI context
11+
* - `createXPlugin()` — installs the context as a Vue plugin
12+
* - `useX()` — retrieves the current context instance
13+
*
14+
* Supports an optional `setup` callback for plugin-level initialization (adapters, mixins, etc.)
15+
* and an optional `fallback` factory for composables that must work outside component instances.
16+
*/
17+
18+
// Foundational
19+
import { createContext, useContext } from '#v0/composables/createContext'
20+
import { createPlugin } from '#v0/composables/createPlugin'
21+
import { createTrinity } from '#v0/composables/createTrinity'
22+
23+
// Utilities
24+
import { instanceExists } from '#v0/utilities/instance'
25+
26+
// Types
27+
import type { Plugin } from '#v0/composables/createPlugin'
28+
import type { ContextTrinity } from '#v0/composables/createTrinity'
29+
import type { App } from 'vue'
30+
31+
export interface PluginContextConfig<E> {
32+
/**
33+
* Optional plugin setup callback, called once per Vue app after context provision.
34+
* Use for adapter initialization, Vue app mixins, global side effects, etc.
35+
*/
36+
setup?: (context: E, app: App) => void
37+
/**
38+
* Optional fallback factory. When provided, the generated `useX` consumer uses the
39+
* defensive pattern: returns the fallback when called outside a component instance or
40+
* when the context is not found. Required for composables that may be consumed outside
41+
* component setup (e.g. useLogger, useLocale, useHydration).
42+
*
43+
* Receives the requested namespace so error messages can include it.
44+
*/
45+
fallback?: (namespace: string) => E
46+
}
47+
48+
/**
49+
* Creates the three standard functions for a plugin composable.
50+
*
51+
* @param defaultNamespace The default DI namespace string (e.g. `'v0:logger'`).
52+
* @param factory Function that creates the composable context instance from options.
53+
* @param config Optional setup callback and fallback factory.
54+
* @returns A readonly tuple: `[createXContext, createXPlugin, useX]`.
55+
*
56+
* @example
57+
* ```ts
58+
* // Simple — no setup or fallback
59+
* export const [createStorageContext, createStoragePlugin, useStorage] =
60+
* createPluginContext('v0:storage', options => createStorage(options))
61+
*
62+
* // With fallback — safe outside component instances
63+
* export const [createLoggerContext, createLoggerPlugin, useLogger] =
64+
* createPluginContext('v0:logger', options => createLogger(options), {
65+
* fallback: ns => createFallbackLogger(ns),
66+
* setup: (context) => {
67+
* if (__DEV__ && IN_BROWSER) (window as any).__v0Logger__ = context
68+
* },
69+
* })
70+
* ```
71+
*/
72+
export function createPluginContext<
73+
O extends { namespace?: string } = Record<never, never>,
74+
E = unknown,
75+
> (
76+
defaultNamespace: string,
77+
factory: (options: Omit<O, 'namespace'>) => E,
78+
config?: PluginContextConfig<E>,
79+
): readonly [
80+
<_E extends E = E>(_options?: O) => ContextTrinity<_E>,
81+
(_options?: O) => Plugin,
82+
<_E extends E = E>(namespace?: string) => _E,
83+
] {
84+
function createXContext<_E extends E = E> (_options: O = {} as O): ContextTrinity<_E> {
85+
const { namespace = defaultNamespace, ...options } = _options as O & { namespace?: string }
86+
const [_use, _provide] = createContext<_E>(namespace)
87+
const context = factory(options as Omit<O, 'namespace'>) as _E
88+
89+
function provide (_context: _E = context, app?: App): _E {
90+
return _provide(_context, app)
91+
}
92+
93+
return createTrinity<_E>(_use, provide, context)
94+
}
95+
96+
function createXPlugin (_options: O = {} as O): Plugin {
97+
const { namespace = defaultNamespace, ...options } = _options as O & { namespace?: string }
98+
const [, provide, context] = createXContext({ ...options, namespace } as O)
99+
100+
return createPlugin({
101+
namespace,
102+
provide: app => {
103+
provide(context, app)
104+
},
105+
setup: config?.setup
106+
? app => config.setup!(context, app)
107+
: undefined,
108+
})
109+
}
110+
111+
function useX<_E extends E = E> (namespace = defaultNamespace): _E {
112+
if (config?.fallback) {
113+
const instance = config.fallback(namespace) as _E
114+
if (!instanceExists()) return instance
115+
try {
116+
return useContext<_E>(namespace, instance)
117+
} catch {
118+
return instance
119+
}
120+
}
121+
return useContext<_E>(namespace)
122+
}
123+
124+
return [createXContext, createXPlugin, useX] as const
125+
}

packages/0/src/composables/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './createBreadcrumbs'
33
export * from './createContext'
44
export * from './createDataTable'
55
export * from './createPlugin'
6+
export * from './createPluginContext'
67
export * from './createTrinity'
78
export * from './toArray'
89
export * from './toReactive'

packages/0/src/composables/useBreakpoints/index.ts

Lines changed: 31 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@
2121
import { IN_BROWSER } from '#v0/constants/globals'
2222

2323
// Foundational
24-
import { createContext, useContext } from '#v0/composables/createContext'
25-
import { createPlugin } from '#v0/composables/createPlugin'
26-
import { createTrinity } from '#v0/composables/createTrinity'
24+
import { createPluginContext } from '#v0/composables/createPluginContext'
2725

2826
// Composables
2927
import { useWindowEventListener } from '#v0/composables/useEventListener'
@@ -34,8 +32,7 @@ import { isNull, isNumber, mergeDeep } from '#v0/utilities'
3432
import { onScopeDispose, readonly, shallowRef, watch } from 'vue'
3533

3634
// Types
37-
import type { ContextTrinity } from '#v0/composables/createTrinity'
38-
import type { App, ShallowRef } from 'vue'
35+
import type { ShallowRef } from 'vue'
3936

4037
export type BreakpointName = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'
4138

@@ -213,144 +210,33 @@ export function createBreakpoints<
213210
} as E
214211
}
215212

216-
/**
217-
* Creates a new breakpoints context.
218-
*
219-
* @param options The options for the breakpoints context.
220-
* @template E The type of the breakpoints context.
221-
* @returns A new breakpoints context.
222-
*
223-
* @see https://0.vuetifyjs.com/composables/plugins/use-breakpoints
224-
*
225-
* @example
226-
* ```ts
227-
* import { createBreakpointsContext } from '@vuetify/v0'
228-
*
229-
* export const [useBreakpoints, provideBreakpoints, context] = createBreakpointsContext({
230-
* namespace: 'v0:breakpoints',
231-
* mobileBreakpoint: 'sm',
232-
* })
233-
* ```
234-
*/
235-
export function createBreakpointsContext<
236-
E extends BreakpointsContext = BreakpointsContext,
237-
> (_options: BreakpointsContextOptions = {}): ContextTrinity<E> {
238-
const { namespace = 'v0:breakpoints', ...options } = _options
239-
const [useBreakpointsContext, _provideBreakpointsContext] = createContext<E>(namespace)
240-
const context = createBreakpoints<E>(options)
241-
242-
function provideBreakpointsContext (_context: E = context, app?: App): E {
243-
return _provideBreakpointsContext(_context, app)
244-
}
245-
246-
return createTrinity<E>(useBreakpointsContext, provideBreakpointsContext, context)
247-
}
248-
249-
/**
250-
* Creates a new breakpoints plugin.
251-
*
252-
* @param options The options for the breakpoints plugin.
253-
* @template E The type of the breakpoints context.
254-
* @returns A new breakpoints plugin.
255-
*
256-
* @see https://0.vuetifyjs.com/composables/plugins/use-breakpoints
257-
*
258-
* @example
259-
* ```ts
260-
* import { createApp } from 'vue'
261-
* import { createBreakpointsPlugin } from '@vuetify/v0'
262-
* import App from './App.vue'
263-
*
264-
* const app = createApp(App)
265-
*
266-
* app.use(
267-
* createBreakpointsPlugin({
268-
* namespace: 'v0:breakpoints',
269-
* mobileBreakpoint: 'md',
270-
* breakpoints: {
271-
* xs: 0,
272-
* sm: 600,
273-
* md: 840,
274-
* lg: 1145,
275-
* xl: 1545,
276-
* xxl: 2138,
277-
* },
278-
* })
279-
* )
280-
*
281-
* app.mount('#app')
282-
* ```
283-
*/
284-
export function createBreakpointsPlugin<
285-
E extends BreakpointsContext = BreakpointsContext,
286-
> (_options: BreakpointsPluginOptions = {}) {
287-
const { namespace = 'v0:breakpoints', ...options } = _options
288-
const [, provideBreakpointsContext, context] = createBreakpointsContext<E>({ ...options, namespace })
289-
290-
return createPlugin({
291-
namespace,
292-
provide: (app: App) => {
293-
provideBreakpointsContext(context, app)
213+
export const [createBreakpointsContext, createBreakpointsPlugin, useBreakpoints] =
214+
createPluginContext<BreakpointsContextOptions, BreakpointsContext>(
215+
'v0:breakpoints',
216+
options => createBreakpoints(options),
217+
{
218+
setup: (context, app) => {
219+
app.mixin({
220+
mounted () {
221+
if (!isNull(this.$parent)) return
222+
223+
const hydration = useHydration()
224+
225+
function listener () {
226+
context.update()
227+
}
228+
229+
const unwatch = watch(hydration.isHydrated, hydrated => {
230+
if (hydrated) listener()
231+
}, { immediate: true })
232+
233+
const cleanup = useWindowEventListener('resize', listener, { passive: true })
234+
onScopeDispose(() => {
235+
cleanup()
236+
unwatch()
237+
}, true)
238+
},
239+
})
240+
},
294241
},
295-
setup: (app: App) => {
296-
app.mixin({
297-
mounted () {
298-
if (!isNull(this.$parent)) return
299-
300-
const hydration = useHydration()
301-
302-
function listener () {
303-
context.update()
304-
}
305-
306-
const unwatch = watch(hydration.isHydrated, hydrated => {
307-
if (hydrated) listener()
308-
}, { immediate: true })
309-
310-
const cleanup = useWindowEventListener('resize', listener, { passive: true })
311-
onScopeDispose(() => {
312-
cleanup()
313-
unwatch()
314-
}, true)
315-
},
316-
})
317-
},
318-
})
319-
}
320-
321-
/**
322-
* Returns the current breakpoints instance.
323-
*
324-
* @param namespace The namespace for the breakpoints context. Defaults to `v0:breakpoints`.
325-
* @returns The current breakpoints instance.
326-
*
327-
* @see https://0.vuetifyjs.com/composables/plugins/use-breakpoints
328-
*
329-
* @example
330-
* ```vue
331-
* <script setup lang="ts">
332-
* import { useBreakpoints } from '@vuetify/v0'
333-
*
334-
* // Destructure for template auto-unwrapping
335-
* const { isMobile, mdAndUp } = useBreakpoints()
336-
*
337-
* // In script, use .value
338-
* if (isMobile.value) {
339-
* console.log('Mobile detected')
340-
* }
341-
* </script>
342-
*
343-
* <template>
344-
* <div class="pa-4">
345-
* <!-- Destructured refs auto-unwrap in templates -->
346-
* <p v-if="isMobile">Mobile layout active</p>
347-
* <p v-else-if="mdAndUp">Medium and up layout active</p>
348-
* </div>
349-
* </template>
350-
* ```
351-
*/
352-
export function useBreakpoints<
353-
E extends BreakpointsContext = BreakpointsContext,
354-
> (namespace = 'v0:breakpoints'): E {
355-
return useContext<E>(namespace)
356-
}
242+
)

packages/0/src/composables/useHydration/index.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,8 @@ describe('useHydration SSR', () => {
255255
})
256256

257257
it('useHydration should return fallback when no Vue instance exists', async () => {
258-
vi.doMock('#v0/utilities', () => ({
258+
vi.doMock('#v0/utilities/instance', () => ({
259259
instanceExists: () => false,
260-
isNull: (v: unknown) => v === null,
261260
}))
262261

263262
const { useHydration: useHydrationSSR } = await import('./index')

0 commit comments

Comments
 (0)