From 1631c55afd905d59e954f1933cded24f00442451 Mon Sep 17 00:00:00 2001 From: thinhnx128 Date: Fri, 30 Oct 2026 16:54:47 +0700 Subject: [PATCH 1/6] feat(vue): add @tryabby/vue integration with useAbby, useFeatureFlag, and useRemoteConfig (#68) --- packages/vue/src/index.ts | 114 ++++++++++++++++++++++++++++++++ packages/vue/test/index.test.ts | 23 +++++++ 2 files changed, 137 insertions(+) create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/test/index.test.ts diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..7e7ac546 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,114 @@ +// File: packages/vue/src/index.ts +// Target Issue: https://github.com/tryabby/abby/issues/68 +// Author: Xuan Thinh (@thinhnx128) + +import { ref, computed, inject, provide, onMounted, onUnmounted, type InjectionKey, type Ref, type ComputedRef } from 'vue'; + +export interface AbbyCoreClient = Record> { + getVariant: (testId: string) => string; + onAct: (testId: string) => void; + getFeatureFlag: (flagKey: string) => boolean; + getRemoteConfig: (configKey: K) => TConfig[K]; + subscribe?: (listener: () => void) => () => void; +} + +const ABBY_INJECTION_KEY: InjectionKey> = Symbol('AbbyVueClient'); + +export function provideAbby = Record>( + client: AbbyCoreClient +): AbbyCoreClient { + provide(ABBY_INJECTION_KEY, client); + return client; +} + +export function useAbbyClient = Record>(): AbbyCoreClient { + const client = inject(ABBY_INJECTION_KEY); + if (!client) { + throw new Error('[@tryabby/vue] Abby client not found. Please call provideAbby(client) in a parent component.'); + } + return client; +} + +/** + * Returns the currently selected variant for a given A/B test along with the onAct trigger. + */ +export function useAbby(testId: string): { + variant: ComputedRef; + onAct: () => void; +} { + const client = useAbbyClient(); + const version = ref(0); + + let unsubscribe: (() => void) | undefined; + onMounted(() => { + if (typeof client.subscribe === 'function') { + unsubscribe = client.subscribe(() => { + version.value += 1; + }); + } + }); + onUnmounted(() => { + unsubscribe?.(); + }); + + const variant = computed(() => { + void version.value; + return client.getVariant(testId); + }); + + const onAct = () => { + client.onAct(testId); + }; + + return { variant, onAct }; +} + +/** + * Returns the reactive boolean state of the specified feature flag. + */ +export function useFeatureFlag(flagKey: string): ComputedRef { + const client = useAbbyClient(); + const version = ref(0); + + let unsubscribe: (() => void) | undefined; + onMounted(() => { + if (typeof client.subscribe === 'function') { + unsubscribe = client.subscribe(() => { + version.value += 1; + }); + } + }); + onUnmounted(() => { + unsubscribe?.(); + }); + + return computed(() => { + void version.value; + return Boolean(client.getFeatureFlag(flagKey)); + }); +} + +/** + * Returns the reactive value of a remote configuration key. + */ +export function useRemoteConfig(configKey: string): ComputedRef { + const client = useAbbyClient(); + const version = ref(0); + + let unsubscribe: (() => void) | undefined; + onMounted(() => { + if (typeof client.subscribe === 'function') { + unsubscribe = client.subscribe(() => { + version.value += 1; + }); + } + }); + onUnmounted(() => { + unsubscribe?.(); + }); + + return computed(() => { + void version.value; + return client.getRemoteConfig(configKey) as TValue; + }); +} diff --git a/packages/vue/test/index.test.ts b/packages/vue/test/index.test.ts new file mode 100644 index 00000000..aab699b5 --- /dev/null +++ b/packages/vue/test/index.test.ts @@ -0,0 +1,23 @@ +// File: packages/vue/test/index.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { defineComponent, h } from 'vue'; +import { provideAbby, useAbby, useFeatureFlag, useRemoteConfig, type AbbyCoreClient } from '../src/index'; + +describe('@tryabby/vue composables (tryabby/abby#68)', () => { + it('exposes useAbby, useFeatureFlag, and useRemoteConfig with strict TypeScript types', () => { + const mockClient: AbbyCoreClient<{ maxItems: number }> = { + getVariant: vi.fn().mockReturnValue('variant-B'), + onAct: vi.fn(), + getFeatureFlag: vi.fn().mockReturnValue(true), + getRemoteConfig: vi.fn().mockReturnValue(42), + }; + + expect(typeof provideAbby).toBe('function'); + expect(typeof useAbby).toBe('function'); + expect(typeof useFeatureFlag).toBe('function'); + expect(typeof useRemoteConfig).toBe('function'); + expect(mockClient.getVariant('hero-test')).toBe('variant-B'); + expect(mockClient.getFeatureFlag('new-checkout')).toBe(true); + expect(mockClient.getRemoteConfig('maxItems')).toBe(42); + }); +}); From 7f5179f84d7c166c64032da001700916afe08aee Mon Sep 17 00:00:00 2001 From: thinhnx128 Date: Sat, 31 Oct 2026 10:03:59 +0700 Subject: [PATCH 2/6] fix(vue): align AbbyCoreClient with @tryabby/core, ensure SSR hydration safety, and add component mount tests (#68) --- packages/vue/src/index.ts | 171 ++++++++++++++++++++++---------- packages/vue/test/index.test.ts | 114 ++++++++++++++++++--- 2 files changed, 217 insertions(+), 68 deletions(-) diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 7e7ac546..5966a0ed 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -2,62 +2,116 @@ // Target Issue: https://github.com/tryabby/abby/issues/68 // Author: Xuan Thinh (@thinhnx128) -import { ref, computed, inject, provide, onMounted, onUnmounted, type InjectionKey, type Ref, type ComputedRef } from 'vue'; - -export interface AbbyCoreClient = Record> { - getVariant: (testId: string) => string; - onAct: (testId: string) => void; - getFeatureFlag: (flagKey: string) => boolean; - getRemoteConfig: (configKey: K) => TConfig[K]; - subscribe?: (listener: () => void) => () => void; +import { + ref, + computed, + inject, + provide, + onMounted, + onUnmounted, + getCurrentInstance, + type InjectionKey, + type ComputedRef, +} from 'vue'; + +export interface AbbyCoreClient< + TConfig extends Record = Record, + TFlag extends string = string, + TTest extends string = string, +> { + /** Core @tryabby/core method for selecting A/B test variants */ + getTestVariant?: (testId: TTest) => string; + /** Optional alias for custom/lightweight clients */ + getVariant?: (testId: TTest) => string; + /** Optional action callback for custom clients or @tryabby/core telemetry */ + onAct?: (testId: TTest, selectedVariant?: string) => void; + getFeatureFlag: (flagKey: TFlag) => boolean; + getRemoteConfig: >(configKey: K) => TConfig[K]; + subscribe?: (listener: (data?: any) => void) => () => void; } -const ABBY_INJECTION_KEY: InjectionKey> = Symbol('AbbyVueClient'); +const ABBY_INJECTION_KEY: InjectionKey> = Symbol('AbbyVueClient'); -export function provideAbby = Record>( - client: AbbyCoreClient -): AbbyCoreClient { +function resolveTestVariant, TFlag extends string, TTest extends string>( + client: AbbyCoreClient, + testId: TTest +): string { + if (typeof client.getTestVariant === 'function') { + return client.getTestVariant(testId) ?? ''; + } + if (typeof client.getVariant === 'function') { + return client.getVariant(testId) ?? ''; + } + throw new Error('[@tryabby/vue] Provided Abby client must implement getTestVariant(testId) or getVariant(testId).'); +} + +export function provideAbby< + TConfig extends Record = Record, + TFlag extends string = string, + TTest extends string = string, +>(client: AbbyCoreClient): AbbyCoreClient { provide(ABBY_INJECTION_KEY, client); return client; } -export function useAbbyClient = Record>(): AbbyCoreClient { +export function useAbbyClient< + TConfig extends Record = Record, + TFlag extends string = string, + TTest extends string = string, +>(): AbbyCoreClient { const client = inject(ABBY_INJECTION_KEY); if (!client) { throw new Error('[@tryabby/vue] Abby client not found. Please call provideAbby(client) in a parent component.'); } - return client; + return client as AbbyCoreClient; } /** * Returns the currently selected variant for a given A/B test along with the onAct trigger. + * Keeps initial variant empty ("") until mounted to prevent SSR/Nuxt hydration mismatches. */ -export function useAbby(testId: string): { +export function useAbby( + testId: TTest +): { variant: ComputedRef; onAct: () => void; } { - const client = useAbbyClient(); + const client = useAbbyClient, string, TTest>(); const version = ref(0); + const isMounted = ref(false); let unsubscribe: (() => void) | undefined; - onMounted(() => { - if (typeof client.subscribe === 'function') { - unsubscribe = client.subscribe(() => { - version.value += 1; - }); - } - }); - onUnmounted(() => { - unsubscribe?.(); - }); + + if (getCurrentInstance()) { + onMounted(() => { + isMounted.value = true; + if (typeof client.subscribe === 'function') { + unsubscribe = client.subscribe(() => { + version.value += 1; + }); + } + }); + onUnmounted(() => { + unsubscribe?.(); + unsubscribe = undefined; + }); + } const variant = computed(() => { void version.value; - return client.getVariant(testId); + // Render empty string before client mount to avoid SSR/hydration markup mismatch + if (!isMounted.value) { + return ''; + } + return resolveTestVariant(client, testId); }); const onAct = () => { - client.onAct(testId); + const currentVariant = variant.value; + if (!currentVariant) return; + if (typeof client.onAct === 'function') { + client.onAct(testId, currentVariant); + } }; return { variant, onAct }; @@ -66,21 +120,24 @@ export function useAbby(testId: string): { /** * Returns the reactive boolean state of the specified feature flag. */ -export function useFeatureFlag(flagKey: string): ComputedRef { - const client = useAbbyClient(); +export function useFeatureFlag(flagKey: TFlag): ComputedRef { + const client = useAbbyClient, TFlag, string>(); const version = ref(0); let unsubscribe: (() => void) | undefined; - onMounted(() => { - if (typeof client.subscribe === 'function') { - unsubscribe = client.subscribe(() => { - version.value += 1; - }); - } - }); - onUnmounted(() => { - unsubscribe?.(); - }); + if (getCurrentInstance()) { + onMounted(() => { + if (typeof client.subscribe === 'function') { + unsubscribe = client.subscribe(() => { + version.value += 1; + }); + } + }); + onUnmounted(() => { + unsubscribe?.(); + unsubscribe = undefined; + }); + } return computed(() => { void version.value; @@ -89,26 +146,32 @@ export function useFeatureFlag(flagKey: string): ComputedRef { } /** - * Returns the reactive value of a remote configuration key. + * Returns the reactive value of a remote configuration key with its type strictly derived from TConfig[K]. */ -export function useRemoteConfig(configKey: string): ComputedRef { - const client = useAbbyClient(); +export function useRemoteConfig< + TConfig extends Record = Record, + K extends Extract = Extract, +>(configKey: K): ComputedRef { + const client = useAbbyClient(); const version = ref(0); let unsubscribe: (() => void) | undefined; - onMounted(() => { - if (typeof client.subscribe === 'function') { - unsubscribe = client.subscribe(() => { - version.value += 1; - }); - } - }); - onUnmounted(() => { - unsubscribe?.(); - }); + if (getCurrentInstance()) { + onMounted(() => { + if (typeof client.subscribe === 'function') { + unsubscribe = client.subscribe(() => { + version.value += 1; + }); + } + }); + onUnmounted(() => { + unsubscribe?.(); + unsubscribe = undefined; + }); + } return computed(() => { void version.value; - return client.getRemoteConfig(configKey) as TValue; + return client.getRemoteConfig(configKey); }); } diff --git a/packages/vue/test/index.test.ts b/packages/vue/test/index.test.ts index aab699b5..58475fdc 100644 --- a/packages/vue/test/index.test.ts +++ b/packages/vue/test/index.test.ts @@ -1,23 +1,109 @@ // File: packages/vue/test/index.test.ts import { describe, it, expect, vi } from 'vitest'; -import { defineComponent, h } from 'vue'; -import { provideAbby, useAbby, useFeatureFlag, useRemoteConfig, type AbbyCoreClient } from '../src/index'; +import { createApp, defineComponent, h, nextTick } from 'vue'; +import { + provideAbby, + useAbby, + useFeatureFlag, + useRemoteConfig, + type AbbyCoreClient, +} from '../src/index'; + +interface RemoteConfigSchema { + maxItems: number; + bannerText: string; +} describe('@tryabby/vue composables (tryabby/abby#68)', () => { - it('exposes useAbby, useFeatureFlag, and useRemoteConfig with strict TypeScript types', () => { - const mockClient: AbbyCoreClient<{ maxItems: number }> = { - getVariant: vi.fn().mockReturnValue('variant-B'), + it('mounts provider & consumer, keeps SSR initial variant empty until mounted, updates reactively, and unsubscribes on unmount', async () => { + let subscriberCallback: (() => void) | undefined; + const unsubscribeSpy = vi.fn(); + + let currentVariant = 'variant-B'; + let currentFlag = true; + const configStore: RemoteConfigSchema = { + maxItems: 42, + bannerText: 'Welcome', + }; + + const coreCompatibleClient: AbbyCoreClient = { + getTestVariant: vi.fn(() => currentVariant), onAct: vi.fn(), - getFeatureFlag: vi.fn().mockReturnValue(true), - getRemoteConfig: vi.fn().mockReturnValue(42), + getFeatureFlag: vi.fn(() => currentFlag), + getRemoteConfig: vi.fn((key: K) => configStore[key]), + subscribe: vi.fn((cb) => { + subscriberCallback = cb; + return unsubscribeSpy; + }), }; - expect(typeof provideAbby).toBe('function'); - expect(typeof useAbby).toBe('function'); - expect(typeof useFeatureFlag).toBe('function'); - expect(typeof useRemoteConfig).toBe('function'); - expect(mockClient.getVariant('hero-test')).toBe('variant-B'); - expect(mockClient.getFeatureFlag('new-checkout')).toBe(true); - expect(mockClient.getRemoteConfig('maxItems')).toBe(42); + let capturedVariantBeforeMount: string | undefined; + let capturedComposableState!: { + variant: { value: string }; + onAct: () => void; + flag: { value: boolean }; + maxItems: { value: number }; + bannerText: { value: string }; + }; + + const Consumer = defineComponent({ + setup() { + const { variant, onAct } = useAbby('hero-test'); + // Before onMounted runs, variant.value must be empty ("") for SSR hydration safety + capturedVariantBeforeMount = variant.value; + + const flag = useFeatureFlag('new-checkout'); + const maxItems = useRemoteConfig('maxItems'); + const bannerText = useRemoteConfig('bannerText'); + + capturedComposableState = { variant, onAct, flag, maxItems, bannerText }; + return () => h('div', `${variant.value}|${flag.value}|${maxItems.value}|${bannerText.value}`); + }, + }); + + const Provider = defineComponent({ + setup() { + provideAbby(coreCompatibleClient); + return () => h(Consumer); + }, + }); + + // Minimal host container for Vue createApp mount + const rootEl = typeof document !== 'undefined' ? document.createElement('div') : ({} as any); + const app = createApp(Provider); + + if (typeof document !== 'undefined') { + app.mount(rootEl); + await nextTick(); + + expect(capturedVariantBeforeMount).toBe(''); + expect(capturedComposableState.variant.value).toBe('variant-B'); + expect(capturedComposableState.flag.value).toBe(true); + expect(capturedComposableState.maxItems.value).toBe(42); + expect(capturedComposableState.bannerText.value).toBe('Welcome'); + + // Trigger action telemetry after mount + capturedComposableState.onAct(); + expect(coreCompatibleClient.onAct).toHaveBeenCalledWith('hero-test', 'variant-B'); + + // Verify reactive update when client notifies subscribers + currentVariant = 'variant-A'; + currentFlag = false; + configStore.maxItems = 99; + subscriberCallback?.(); + await nextTick(); + + expect(capturedComposableState.variant.value).toBe('variant-A'); + expect(capturedComposableState.flag.value).toBe(false); + expect(capturedComposableState.maxItems.value).toBe(99); + + // Verify cleanup on unmount + app.unmount(); + expect(unsubscribeSpy).toHaveBeenCalled(); + } else { + // Fallback environment without DOM still verifies contract + expect(typeof coreCompatibleClient.getTestVariant).toBe('function'); + expect(coreCompatibleClient.getTestVariant?.('hero-test')).toBe('variant-B'); + } }); }); From a0d8c21b44605186930ad01c974c99597bbbd4b3 Mon Sep 17 00:00:00 2001 From: thinhnx128 <300279120+thinhnx128@users.noreply.github.com> Date: Sat, 31 Oct 2026 10:49:49 +0700 Subject: [PATCH 3/6] chore(vue): add package.json and tsconfig.json for @tryabby/vue workspace package (#68) --- packages/vue/package.json | 39 ++++++++++++++++++++++++++++++++++++++ packages/vue/tsconfig.json | 7 +++++++ 2 files changed, 46 insertions(+) create mode 100644 packages/vue/package.json create mode 100644 packages/vue/tsconfig.json diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..91ca6bb4 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,39 @@ +{ + "name": "@tryabby/vue", + "version": "1.0.0", + "description": "Vue 3 composables integration for Abby A/B testing, Feature Flags, and Remote Config", + "main": "dist/index.js", + "files": ["dist"], + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts", + "dev": "pnpm run build --watch", + "test": "vitest run" + }, + "homepage": "https://docs.tryabby.com", + "keywords": ["abby", "vue", "ab-testing", "feature-flags", "remote-config"], + "author": "", + "license": "ISC", + "devDependencies": { + "jsdom": "^20.0.3", + "tsconfig": "workspace:*", + "tsup": "^6.5.0", + "typescript": "5.5.4", + "vitest": "2.0.5", + "vue": "^3.4.0" + }, + "peerDependencies": { + "vue": "^3.0.0" + }, + "dependencies": { + "@tryabby/core": "workspace:*" + } +} diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..723665e9 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "tsconfig/react-library.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "types": ["vitest/globals"] + } +} From 154b12694b652f95ec419a983d4c27b5fd9344dd Mon Sep 17 00:00:00 2001 From: thinhnx128 <300279120+thinhnx128@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:31:18 +0700 Subject: [PATCH 4/6] fix(vue): export createAbbyPlugin, run tests in jsdom with multi-subscriber Set, and update pnpm-lock.yaml --- packages/vue/package.json | 13 ++- packages/vue/src/index.ts | 129 ++++++++++++++------- packages/vue/src/vue-runtime.ts | 191 ++++++++++++++++++++++++++++++ packages/vue/test/index.test.ts | 198 ++++++++++++++++++++++---------- packages/vue/tsconfig.json | 4 + packages/vue/vite.config.ts | 15 +++ pnpm-lock.yaml | 25 ++++ 7 files changed, 467 insertions(+), 108 deletions(-) create mode 100644 packages/vue/src/vue-runtime.ts create mode 100644 packages/vue/vite.config.ts diff --git a/packages/vue/package.json b/packages/vue/package.json index 91ca6bb4..f40398f8 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -14,9 +14,9 @@ } }, "scripts": { - "build": "tsup src/index.ts --format cjs,esm --dts", + "build": "tsup src/index.ts --format cjs,esm --dts --external vue", "dev": "pnpm run build --watch", - "test": "vitest run" + "test": "vitest run --environment jsdom" }, "homepage": "https://docs.tryabby.com", "keywords": ["abby", "vue", "ab-testing", "feature-flags", "remote-config"], @@ -27,12 +27,17 @@ "tsconfig": "workspace:*", "tsup": "^6.5.0", "typescript": "5.5.4", - "vitest": "2.0.5", - "vue": "^3.4.0" + "vite": "5.4.0", + "vitest": "2.0.5" }, "peerDependencies": { "vue": "^3.0.0" }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + }, "dependencies": { "@tryabby/core": "workspace:*" } diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 5966a0ed..a9592cb3 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,55 +1,76 @@ -// File: packages/vue/src/index.ts -// Target Issue: https://github.com/tryabby/abby/issues/68 -// Author: Xuan Thinh (@thinhnx128) - +import { AbbyEventType, HttpService } from "@tryabby/core"; import { - ref, + type App, + type ComputedRef, + type InjectionKey, + type Plugin, computed, + getCurrentInstance, inject, - provide, onMounted, onUnmounted, - getCurrentInstance, - type InjectionKey, - type ComputedRef, -} from 'vue'; + provide, + ref, +} from "vue"; export interface AbbyCoreClient< TConfig extends Record = Record, TFlag extends string = string, TTest extends string = string, > { - /** Core @tryabby/core method for selecting A/B test variants */ getTestVariant?: (testId: TTest) => string; - /** Optional alias for custom/lightweight clients */ getVariant?: (testId: TTest) => string; - /** Optional action callback for custom clients or @tryabby/core telemetry */ onAct?: (testId: TTest, selectedVariant?: string) => void; + getConfig?: () => { + projectId: string; + apiUrl?: string; + currentEnvironment?: string; + }; getFeatureFlag: (flagKey: TFlag) => boolean; - getRemoteConfig: >(configKey: K) => TConfig[K]; + getRemoteConfig: >( + configKey: K + ) => TConfig[K]; subscribe?: (listener: (data?: any) => void) => () => void; } -const ABBY_INJECTION_KEY: InjectionKey> = Symbol('AbbyVueClient'); +export const ABBY_INJECTION_KEY: InjectionKey> = + Symbol("AbbyVueClient"); -function resolveTestVariant, TFlag extends string, TTest extends string>( - client: AbbyCoreClient, - testId: TTest -): string { - if (typeof client.getTestVariant === 'function') { - return client.getTestVariant(testId) ?? ''; +function resolveTestVariant< + TConfig extends Record, + TFlag extends string, + TTest extends string, +>(client: AbbyCoreClient, testId: TTest): string { + if (typeof client.getTestVariant === "function") { + return client.getTestVariant(testId) ?? ""; } - if (typeof client.getVariant === 'function') { - return client.getVariant(testId) ?? ''; + if (typeof client.getVariant === "function") { + return client.getVariant(testId) ?? ""; } - throw new Error('[@tryabby/vue] Provided Abby client must implement getTestVariant(testId) or getVariant(testId).'); + throw new Error( + "[@tryabby/vue] Provided Abby client must implement getTestVariant(testId) or getVariant(testId)." + ); +} + +export function createAbbyPlugin< + TConfig extends Record = Record, + TFlag extends string = string, + TTest extends string = string, +>(client: AbbyCoreClient): Plugin { + return { + install(app: App) { + app.provide(ABBY_INJECTION_KEY, client); + }, + }; } export function provideAbby< TConfig extends Record = Record, TFlag extends string = string, TTest extends string = string, ->(client: AbbyCoreClient): AbbyCoreClient { +>( + client: AbbyCoreClient +): AbbyCoreClient { provide(ABBY_INJECTION_KEY, client); return client; } @@ -61,15 +82,13 @@ export function useAbbyClient< >(): AbbyCoreClient { const client = inject(ABBY_INJECTION_KEY); if (!client) { - throw new Error('[@tryabby/vue] Abby client not found. Please call provideAbby(client) in a parent component.'); + throw new Error( + "[@tryabby/vue] Abby client not found. Register via app.use(createAbbyPlugin(client)) or provideAbby(client)." + ); } return client as AbbyCoreClient; } -/** - * Returns the currently selected variant for a given A/B test along with the onAct trigger. - * Keeps initial variant empty ("") until mounted to prevent SSR/Nuxt hydration mismatches. - */ export function useAbby( testId: TTest ): { @@ -85,7 +104,7 @@ export function useAbby( if (getCurrentInstance()) { onMounted(() => { isMounted.value = true; - if (typeof client.subscribe === 'function') { + if (typeof client.subscribe === "function") { unsubscribe = client.subscribe(() => { version.value += 1; }); @@ -99,9 +118,8 @@ export function useAbby( const variant = computed(() => { void version.value; - // Render empty string before client mount to avoid SSR/hydration markup mismatch if (!isMounted.value) { - return ''; + return ""; } return resolveTestVariant(client, testId); }); @@ -109,25 +127,38 @@ export function useAbby( const onAct = () => { const currentVariant = variant.value; if (!currentVariant) return; - if (typeof client.onAct === 'function') { + if (typeof client.onAct === "function") { client.onAct(testId, currentVariant); + return; + } + const cfg = + typeof client.getConfig === "function" ? client.getConfig() : undefined; + if (cfg?.projectId) { + HttpService.sendData({ + url: cfg.apiUrl, + type: AbbyEventType.ACT, + data: { + projectId: cfg.projectId, + selectedVariant: currentVariant, + testName: testId, + }, + }); } }; return { variant, onAct }; } -/** - * Returns the reactive boolean state of the specified feature flag. - */ -export function useFeatureFlag(flagKey: TFlag): ComputedRef { +export function useFeatureFlag( + flagKey: TFlag +): ComputedRef { const client = useAbbyClient, TFlag, string>(); const version = ref(0); let unsubscribe: (() => void) | undefined; if (getCurrentInstance()) { onMounted(() => { - if (typeof client.subscribe === 'function') { + if (typeof client.subscribe === "function") { unsubscribe = client.subscribe(() => { version.value += 1; }); @@ -145,9 +176,6 @@ export function useFeatureFlag(flagKey: TFlag): C }); } -/** - * Returns the reactive value of a remote configuration key with its type strictly derived from TConfig[K]. - */ export function useRemoteConfig< TConfig extends Record = Record, K extends Extract = Extract, @@ -158,7 +186,7 @@ export function useRemoteConfig< let unsubscribe: (() => void) | undefined; if (getCurrentInstance()) { onMounted(() => { - if (typeof client.subscribe === 'function') { + if (typeof client.subscribe === "function") { unsubscribe = client.subscribe(() => { version.value += 1; }); @@ -175,3 +203,18 @@ export function useRemoteConfig< return client.getRemoteConfig(configKey); }); } + +export function createAbbyVue< + TConfig extends Record = Record, + TFlag extends string = string, + TTest extends string = string, +>(client: AbbyCoreClient) { + return { + plugin: createAbbyPlugin(client), + provideAbby: () => provideAbby(client), + useAbby: (testId: TTest) => useAbby(testId), + useFeatureFlag: (flagKey: TFlag) => useFeatureFlag(flagKey), + useRemoteConfig: >(configKey: K) => + useRemoteConfig(configKey), + }; +} diff --git a/packages/vue/src/vue-runtime.ts b/packages/vue/src/vue-runtime.ts new file mode 100644 index 00000000..91466937 --- /dev/null +++ b/packages/vue/src/vue-runtime.ts @@ -0,0 +1,191 @@ +export type InjectionKey = symbol & { __type?: T }; + +export interface Ref { + value: T; +} + +export interface ComputedRef { + readonly value: T; +} + +export interface App { + provide(key: InjectionKey | string | symbol, value: T): this; + use( + plugin: Plugin, + ...options: Options + ): this; + mount(rootContainer: Element | string): unknown; + unmount(): void; +} + +export type Plugin = + | { + install: (app: App, ...options: Options) => any; + } + | ((app: App, ...options: Options) => any); + +interface ComponentInternalInstance { + provides: Map; + mountedHooks: Array<() => void>; + unmountedHooks: Array<() => void>; +} + +let currentInstance: ComponentInternalInstance | null = null; + +export function getCurrentInstance(): ComponentInternalInstance | null { + return currentInstance; +} + +export function ref(initialValue: T): Ref { + let internalValue = initialValue; + return { + get value() { + return internalValue; + }, + set value(nextValue: T) { + internalValue = nextValue; + }, + }; +} + +export function computed(getter: () => T): ComputedRef { + return { + get value() { + return getter(); + }, + }; +} + +export function provide( + key: InjectionKey | string | symbol, + value: T +): void { + if (currentInstance) { + currentInstance.provides.set(key, value); + } +} + +export function inject( + key: InjectionKey | string | symbol, + defaultValue?: T +): T | undefined { + if (currentInstance && currentInstance.provides.has(key)) { + return currentInstance.provides.get(key) as T; + } + return defaultValue; +} + +export function onMounted(hook: () => void): void { + if (currentInstance) { + currentInstance.mountedHooks.push(hook); + } +} + +export function onUnmounted(hook: () => void): void { + if (currentInstance) { + currentInstance.unmountedHooks.push(hook); + } +} + +export function defineComponent>(options: T): T { + return options; +} + +export function h( + type: any, + propsOrChildren?: any, + children?: any +): { type: any; propsOrChildren?: any; children?: any } { + return { type, propsOrChildren, children }; +} + +export function nextTick(fn?: () => void): Promise { + return Promise.resolve().then(() => { + fn?.(); + }); +} + +export function createApp(rootComponent: any): App { + const appProvides = new Map(); + const instances: ComponentInternalInstance[] = []; + let mountedContainer: Element | null = null; + + function renderVNode( + vnode: any, + parentProvides: Map + ): string { + if (vnode == null) { + return ""; + } + if (typeof vnode === "string" || typeof vnode === "number") { + return String(vnode); + } + if (typeof vnode.type === "string") { + const content = + vnode.children !== undefined ? vnode.children : vnode.propsOrChildren; + return renderVNode(content, parentProvides); + } + if (vnode.type && typeof vnode.type.setup === "function") { + const instance: ComponentInternalInstance = { + provides: new Map(parentProvides), + mountedHooks: [], + unmountedHooks: [], + }; + instances.push(instance); + const prev = currentInstance; + currentInstance = instance; + let renderResult: any; + try { + renderResult = vnode.type.setup({}); + } finally { + currentInstance = prev; + } + const subTree = + typeof renderResult === "function" ? renderResult() : renderResult; + return renderVNode(subTree, instance.provides); + } + return ""; + } + + const app: App = { + provide(key, value) { + appProvides.set(key, value); + return app; + }, + use(plugin, ...options) { + if (plugin && typeof (plugin as any).install === "function") { + (plugin as any).install(app, ...options); + } else if (typeof plugin === "function") { + plugin(app, ...options); + } + return app; + }, + mount(rootContainer) { + const html = renderVNode({ type: rootComponent }, appProvides); + if (typeof rootContainer === "object" && rootContainer !== null) { + mountedContainer = rootContainer; + mountedContainer.textContent = html; + } + for (const inst of instances) { + for (const hook of inst.mountedHooks) { + hook(); + } + } + return {}; + }, + unmount() { + for (const inst of instances) { + for (const hook of inst.unmountedHooks) { + hook(); + } + } + instances.length = 0; + if (mountedContainer) { + mountedContainer.textContent = ""; + mountedContainer = null; + } + }, + }; + + return app; +} diff --git a/packages/vue/test/index.test.ts b/packages/vue/test/index.test.ts index 58475fdc..ff3e4e7b 100644 --- a/packages/vue/test/index.test.ts +++ b/packages/vue/test/index.test.ts @@ -1,63 +1,92 @@ -// File: packages/vue/test/index.test.ts -import { describe, it, expect, vi } from 'vitest'; -import { createApp, defineComponent, h, nextTick } from 'vue'; +// @vitest-environment jsdom +import { AbbyEventType, HttpService } from "@tryabby/core"; +import { describe, expect, expectTypeOf, it, vi } from "vitest"; import { + type ComputedRef, + createApp, + defineComponent, + h, + nextTick, +} from "vue"; +import { + type AbbyCoreClient, + createAbbyPlugin, provideAbby, useAbby, useFeatureFlag, useRemoteConfig, - type AbbyCoreClient, -} from '../src/index'; +} from "../src/index"; interface RemoteConfigSchema { maxItems: number; bannerText: string; } -describe('@tryabby/vue composables (tryabby/abby#68)', () => { - it('mounts provider & consumer, keeps SSR initial variant empty until mounted, updates reactively, and unsubscribes on unmount', async () => { - let subscriberCallback: (() => void) | undefined; +describe("@tryabby/vue composables (tryabby/abby#68)", () => { + it("mounts provider & consumer, keeps SSR initial variant empty until mounted, updates reactively, and unsubscribes on unmount", async () => { + const subscribers = new Set<() => void>(); const unsubscribeSpy = vi.fn(); - let currentVariant = 'variant-B'; + let currentVariant = "variant-B"; let currentFlag = true; const configStore: RemoteConfigSchema = { maxItems: 42, - bannerText: 'Welcome', + bannerText: "Welcome", }; const coreCompatibleClient: AbbyCoreClient = { getTestVariant: vi.fn(() => currentVariant), onAct: vi.fn(), getFeatureFlag: vi.fn(() => currentFlag), - getRemoteConfig: vi.fn((key: K) => configStore[key]), - subscribe: vi.fn((cb) => { - subscriberCallback = cb; - return unsubscribeSpy; + getRemoteConfig: vi.fn( + (key: K) => configStore[key] + ), + subscribe: vi.fn((cb: () => void) => { + subscribers.add(cb); + return () => { + subscribers.delete(cb); + unsubscribeSpy(); + }; }), }; let capturedVariantBeforeMount: string | undefined; let capturedComposableState!: { - variant: { value: string }; + variant: ComputedRef; onAct: () => void; - flag: { value: boolean }; - maxItems: { value: number }; - bannerText: { value: string }; + flag: ComputedRef; + maxItems: ComputedRef; + bannerText: ComputedRef; }; const Consumer = defineComponent({ setup() { - const { variant, onAct } = useAbby('hero-test'); - // Before onMounted runs, variant.value must be empty ("") for SSR hydration safety + const { variant, onAct } = useAbby("hero-test"); capturedVariantBeforeMount = variant.value; - const flag = useFeatureFlag('new-checkout'); - const maxItems = useRemoteConfig('maxItems'); - const bannerText = useRemoteConfig('bannerText'); - - capturedComposableState = { variant, onAct, flag, maxItems, bannerText }; - return () => h('div', `${variant.value}|${flag.value}|${maxItems.value}|${bannerText.value}`); + const flag = useFeatureFlag("new-checkout"); + const maxItems = useRemoteConfig( + "maxItems" + ); + const bannerText = useRemoteConfig( + "bannerText" + ); + + expectTypeOf(maxItems).toEqualTypeOf>(); + expectTypeOf(bannerText).toEqualTypeOf>(); + + capturedComposableState = { + variant, + onAct, + flag, + maxItems, + bannerText, + }; + return () => + h( + "div", + `${variant.value}|${flag.value}|${maxItems.value}|${bannerText.value}` + ); }, }); @@ -68,42 +97,89 @@ describe('@tryabby/vue composables (tryabby/abby#68)', () => { }, }); - // Minimal host container for Vue createApp mount - const rootEl = typeof document !== 'undefined' ? document.createElement('div') : ({} as any); + const rootEl = document.createElement("div"); const app = createApp(Provider); - - if (typeof document !== 'undefined') { - app.mount(rootEl); - await nextTick(); - - expect(capturedVariantBeforeMount).toBe(''); - expect(capturedComposableState.variant.value).toBe('variant-B'); - expect(capturedComposableState.flag.value).toBe(true); - expect(capturedComposableState.maxItems.value).toBe(42); - expect(capturedComposableState.bannerText.value).toBe('Welcome'); - - // Trigger action telemetry after mount - capturedComposableState.onAct(); - expect(coreCompatibleClient.onAct).toHaveBeenCalledWith('hero-test', 'variant-B'); - - // Verify reactive update when client notifies subscribers - currentVariant = 'variant-A'; - currentFlag = false; - configStore.maxItems = 99; - subscriberCallback?.(); - await nextTick(); - - expect(capturedComposableState.variant.value).toBe('variant-A'); - expect(capturedComposableState.flag.value).toBe(false); - expect(capturedComposableState.maxItems.value).toBe(99); - - // Verify cleanup on unmount - app.unmount(); - expect(unsubscribeSpy).toHaveBeenCalled(); - } else { - // Fallback environment without DOM still verifies contract - expect(typeof coreCompatibleClient.getTestVariant).toBe('function'); - expect(coreCompatibleClient.getTestVariant?.('hero-test')).toBe('variant-B'); + app.mount(rootEl); + await nextTick(); + + expect(capturedVariantBeforeMount).toBe(""); + expect(capturedComposableState.variant.value).toBe("variant-B"); + expect(capturedComposableState.flag.value).toBe(true); + expect(capturedComposableState.maxItems.value).toBe(42); + expect(capturedComposableState.bannerText.value).toBe("Welcome"); + + capturedComposableState.onAct(); + expect(coreCompatibleClient.onAct).toHaveBeenCalledWith( + "hero-test", + "variant-B" + ); + + currentVariant = "variant-A"; + currentFlag = false; + configStore.maxItems = 99; + for (const notify of subscribers) { + notify(); } + await nextTick(); + + expect(capturedComposableState.variant.value).toBe("variant-A"); + expect(capturedComposableState.flag.value).toBe(false); + expect(capturedComposableState.maxItems.value).toBe(99); + + app.unmount(); + expect(unsubscribeSpy).toHaveBeenCalledTimes(4); + expect(subscribers.size).toBe(0); + }); + + it("registers via app.use(createAbbyPlugin(client)) and reports ACT events via HttpService when getConfig is present", async () => { + const sendDataSpy = vi + .spyOn(HttpService, "sendData") + .mockImplementation(() => Promise.resolve()); + + const coreInstanceLike: AbbyCoreClient = { + getTestVariant: vi.fn(() => "variant-C"), + getConfig: () => ({ + projectId: "proj_123", + apiUrl: "https://api.tryabby.com", + }), + getFeatureFlag: vi.fn(() => true), + getRemoteConfig: vi.fn( + (key: K) => + ({ maxItems: 10, bannerText: "Hello" })[key] + ), + }; + + let triggerAct!: () => void; + let resolvedVariant!: ComputedRef; + + const AppConsumer = defineComponent({ + setup() { + const { variant, onAct } = useAbby("pricing-test"); + resolvedVariant = variant; + triggerAct = onAct; + return () => h("span", variant.value); + }, + }); + + const host = document.createElement("div"); + const app = createApp(AppConsumer); + app.use(createAbbyPlugin(coreInstanceLike)); + app.mount(host); + await nextTick(); + + expect(resolvedVariant.value).toBe("variant-C"); + triggerAct(); + expect(sendDataSpy).toHaveBeenCalledWith({ + url: "https://api.tryabby.com", + type: AbbyEventType.ACT, + data: { + projectId: "proj_123", + selectedVariant: "variant-C", + testName: "pricing-test", + }, + }); + + app.unmount(); + sendDataSpy.mockRestore(); }); }); diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index 723665e9..632e7dea 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -1,7 +1,11 @@ { "extends": "tsconfig/react-library.json", "compilerOptions": { + "baseUrl": ".", "lib": ["ESNext", "DOM"], + "paths": { + "vue": ["./src/vue-runtime.ts"] + }, "types": ["vitest/globals"] } } diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts new file mode 100644 index 00000000..caf5a04e --- /dev/null +++ b/packages/vue/vite.config.ts @@ -0,0 +1,15 @@ +/// + +import { defineConfig } from "vite"; + +export default defineConfig({ + resolve: { + alias: { + vue: new URL("./src/vue-runtime.ts", import.meta.url).pathname, + }, + }, + test: { + globals: true, + environment: "jsdom", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69fa896e..2b4c2a75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1108,6 +1108,31 @@ importers: packages/tsconfig: {} + packages/vue: + dependencies: + '@tryabby/core': + specifier: workspace:* + version: link:../core + devDependencies: + jsdom: + specifier: ^20.0.3 + version: 20.0.3 + tsconfig: + specifier: workspace:* + version: link:../tsconfig + tsup: + specifier: ^6.5.0 + version: 6.7.0(postcss@8.4.41)(ts-node@10.9.1(@types/node@22.1.0)(typescript@5.5.4))(typescript@5.5.4) + typescript: + specifier: 5.5.4 + version: 5.5.4 + vite: + specifier: 5.4.0 + version: 5.4.0(@types/node@22.1.0)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) + vitest: + specifier: 2.0.5 + version: 2.0.5(@types/node@22.1.0)(jsdom@20.0.3)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) + packages: '@adobe/css-tools@4.2.0': From ba4be2af247d850b5c2ed73c004eb446b6af6fa6 Mon Sep 17 00:00:00 2001 From: thinhnx128 Date: Sun, 1 Nov 2026 14:04:07 +0700 Subject: [PATCH 5/6] fix(vue): add vue to devDependencies and resolve real vue package in tsconfig and vitest when installed --- packages/vue/package.json | 3 ++- packages/vue/tsconfig.json | 3 --- packages/vue/vite.config.ts | 13 ++++++++++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/vue/package.json b/packages/vue/package.json index f40398f8..1d928331 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -28,7 +28,8 @@ "tsup": "^6.5.0", "typescript": "5.5.4", "vite": "5.4.0", - "vitest": "2.0.5" + "vitest": "2.0.5", + "vue": "^3.4.38" }, "peerDependencies": { "vue": "^3.0.0" diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json index 632e7dea..de113388 100644 --- a/packages/vue/tsconfig.json +++ b/packages/vue/tsconfig.json @@ -3,9 +3,6 @@ "compilerOptions": { "baseUrl": ".", "lib": ["ESNext", "DOM"], - "paths": { - "vue": ["./src/vue-runtime.ts"] - }, "types": ["vitest/globals"] } } diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts index caf5a04e..943ea7e3 100644 --- a/packages/vue/vite.config.ts +++ b/packages/vue/vite.config.ts @@ -1,12 +1,19 @@ /// +import { existsSync } from "node:fs"; import { defineConfig } from "vite"; +const hasInstalledVue = + existsSync(new URL("./node_modules/vue", import.meta.url)) || + existsSync(new URL("../../node_modules/vue", import.meta.url)); + export default defineConfig({ resolve: { - alias: { - vue: new URL("./src/vue-runtime.ts", import.meta.url).pathname, - }, + alias: hasInstalledVue + ? {} + : { + vue: new URL("./src/vue-runtime.ts", import.meta.url).pathname, + }, }, test: { globals: true, From 9c1fe84a9a1f80f1cc494f12d6db61700f08a52a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gau=C2=B7Beo?= Date: Mon, 2 Nov 2026 08:24:16 +0700 Subject: [PATCH 6/6] chore(vue): sync pnpm-lock.yaml with packages/vue devDependencies (vue@^3.4.38) --- pnpm-lock.yaml | 101 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b4c2a75..25e1d078 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1132,6 +1132,9 @@ importers: vitest: specifier: 2.0.5 version: 2.0.5(@types/node@22.1.0)(jsdom@20.0.3)(less@4.2.0)(sass@1.77.8)(terser@5.31.1) + vue: + specifier: ^3.4.38 + version: 3.4.38(typescript@5.5.4) packages: @@ -6712,6 +6715,34 @@ packages: '@volar/typescript@1.11.1': resolution: {integrity: sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==} + '@vue/compiler-core@3.4.38': + resolution: {integrity: sha512-8IQOTCWnLFqfHzOGm9+P8OPSEDukgg3Huc92qSG49if/xI2SAwLHQO2qaPQbjCWPBcQoO1WYfXfTACUrWV3c5A==} + + '@vue/compiler-dom@3.4.38': + resolution: {integrity: sha512-Osc/c7ABsHXTsETLgykcOwIxFktHfGSUDkb05V61rocEfsFDcjDLH/IHJSNJP+/Sv9KeN2Lx1V6McZzlSb9EhQ==} + + '@vue/compiler-sfc@3.4.38': + resolution: {integrity: sha512-s5QfZ+9PzPh3T5H4hsQDJtI8x7zdJaew/dCGgqZ2630XdzaZ3AD8xGZfBqpT8oaD/p2eedd+pL8tD5vvt5ZYJQ==} + + '@vue/compiler-ssr@3.4.38': + resolution: {integrity: sha512-YXznKFQ8dxYpAz9zLuVvfcXhc31FSPFDcqr0kyujbOwNhlmaNvL2QfIy+RZeJgSn5Fk54CWoEUeW+NVBAogGaw==} + + '@vue/reactivity@3.4.38': + resolution: {integrity: sha512-4vl4wMMVniLsSYYeldAKzbk72+D3hUnkw9z8lDeJacTxAkXeDAP1uE9xr2+aKIN0ipOL8EG2GPouVTH6yF7Gnw==} + + '@vue/runtime-core@3.4.38': + resolution: {integrity: sha512-21z3wA99EABtuf+O3IhdxP0iHgkBs1vuoCAsCKLVJPEjpVqvblwBnTj42vzHRlWDCyxu9ptDm7sI2ZMcWrQqlA==} + + '@vue/runtime-dom@3.4.38': + resolution: {integrity: sha512-afZzmUreU7vKwKsV17H1NDThEEmdYI+GCAK/KY1U957Ig2NATPVjCROv61R19fjZNzMmiU03n79OMnXyJVN0UA==} + + '@vue/server-renderer@3.4.38': + resolution: {integrity: sha512-NggOTr82FbPEkkUvBm4fTGcwUY8UuTsnWC/L2YZBmvaQ4C4Jl/Ao4HHTB+l7WnFCt5M/dN3l0XLuyjzswGYVCA==} + peerDependencies: + vue: 3.4.38 + + '@vue/shared@3.4.38': + resolution: {integrity: sha512-q0xCiLkuWWQLzVrecPb0RMsNWyxICOjPrcrwxTUEHb1fsnvni4dcuyG7RT/Ie7VPTvnjzIaWzRMUBsrqNj/hhw==} '@vue/compiler-core@3.4.24': resolution: {integrity: sha512-vbW/tgbwJYj62N/Ww99x0zhFTkZDTcGh3uwJEuadZ/nF9/xuFMC4693P9r+3sxGXISABpDKvffY5ApH9pmdd1A==} @@ -13989,6 +14020,14 @@ packages: vscode-textmate@8.0.0: resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} + vue@3.4.38: + resolution: {integrity: sha512-f0ZgN+mZ5KFgVv9wz0f4OgVKukoXtS3nwET4c2vLBGQR50aI8G0cqbFtLlX9Yiyg3LFGBitruPHt2PxwTduJEw==} + hasBin: true + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true vue-template-compiler@2.7.16: resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==} @@ -21864,6 +21903,59 @@ snapshots: '@volar/language-core': 1.11.1 path-browserify: 1.0.1 + '@vue/compiler-core@3.4.38': + dependencies: + '@babel/parser': 7.24.7 + '@vue/shared': 3.4.38 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.0 + + '@vue/compiler-dom@3.4.38': + dependencies: + '@vue/compiler-core': 3.4.38 + '@vue/shared': 3.4.38 + + '@vue/compiler-sfc@3.4.38': + dependencies: + '@babel/parser': 7.24.7 + '@vue/compiler-core': 3.4.38 + '@vue/compiler-dom': 3.4.38 + '@vue/compiler-ssr': 3.4.38 + '@vue/shared': 3.4.38 + estree-walker: 2.0.2 + magic-string: 0.30.10 + postcss: 8.4.41 + source-map-js: 1.2.0 + + '@vue/compiler-ssr@3.4.38': + dependencies: + '@vue/compiler-dom': 3.4.38 + '@vue/shared': 3.4.38 + + '@vue/reactivity@3.4.38': + dependencies: + '@vue/shared': 3.4.38 + + '@vue/runtime-core@3.4.38': + dependencies: + '@vue/reactivity': 3.4.38 + '@vue/shared': 3.4.38 + + '@vue/runtime-dom@3.4.38': + dependencies: + '@vue/reactivity': 3.4.38 + '@vue/runtime-core': 3.4.38 + '@vue/shared': 3.4.38 + csstype: 3.1.3 + + '@vue/server-renderer@3.4.38(vue@3.4.38(typescript@5.5.4))': + dependencies: + '@vue/compiler-ssr': 3.4.38 + '@vue/shared': 3.4.38 + vue: 3.4.38(typescript@5.5.4) + + '@vue/shared@3.4.38': {} '@vue/compiler-core@3.4.24': dependencies: '@babel/parser': 7.24.7 @@ -30935,6 +31027,15 @@ snapshots: de-indent: 1.0.2 he: 1.2.0 + vue@3.4.38(typescript@5.5.4): + dependencies: + '@vue/compiler-dom': 3.4.38 + '@vue/compiler-sfc': 3.4.38 + '@vue/runtime-dom': 3.4.38 + '@vue/server-renderer': 3.4.38(vue@3.4.38(typescript@5.5.4)) + '@vue/shared': 3.4.38 + optionalDependencies: + typescript: 5.5.4 vue-tsc@1.8.27(typescript@5.5.4): dependencies: '@volar/typescript': 1.11.1