diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..1d928331 --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,45 @@ +{ + "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 --external vue", + "dev": "pnpm run build --watch", + "test": "vitest run --environment jsdom" + }, + "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", + "vite": "5.4.0", + "vitest": "2.0.5", + "vue": "^3.4.38" + }, + "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 new file mode 100644 index 00000000..a9592cb3 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,220 @@ +import { AbbyEventType, HttpService } from "@tryabby/core"; +import { + type App, + type ComputedRef, + type InjectionKey, + type Plugin, + computed, + getCurrentInstance, + inject, + onMounted, + onUnmounted, + provide, + ref, +} from "vue"; + +export interface AbbyCoreClient< + TConfig extends Record = Record, + TFlag extends string = string, + TTest extends string = string, +> { + getTestVariant?: (testId: TTest) => string; + getVariant?: (testId: TTest) => string; + onAct?: (testId: TTest, selectedVariant?: string) => void; + getConfig?: () => { + projectId: string; + apiUrl?: string; + currentEnvironment?: string; + }; + getFeatureFlag: (flagKey: TFlag) => boolean; + getRemoteConfig: >( + configKey: K + ) => TConfig[K]; + subscribe?: (listener: (data?: any) => void) => () => void; +} + +export const ABBY_INJECTION_KEY: InjectionKey> = + Symbol("AbbyVueClient"); + +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) ?? ""; + } + 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 { + provide(ABBY_INJECTION_KEY, client); + return client; +} + +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. Register via app.use(createAbbyPlugin(client)) or provideAbby(client)." + ); + } + return client as AbbyCoreClient; +} + +export function useAbby( + testId: TTest +): { + variant: ComputedRef; + onAct: () => void; +} { + const client = useAbbyClient, string, TTest>(); + const version = ref(0); + const isMounted = ref(false); + + let unsubscribe: (() => void) | undefined; + + 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; + if (!isMounted.value) { + return ""; + } + return resolveTestVariant(client, testId); + }); + + const onAct = () => { + const currentVariant = variant.value; + if (!currentVariant) return; + 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 }; +} + +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") { + unsubscribe = client.subscribe(() => { + version.value += 1; + }); + } + }); + onUnmounted(() => { + unsubscribe?.(); + unsubscribe = undefined; + }); + } + + return computed(() => { + void version.value; + return Boolean(client.getFeatureFlag(flagKey)); + }); +} + +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; + 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); + }); +} + +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 new file mode 100644 index 00000000..ff3e4e7b --- /dev/null +++ b/packages/vue/test/index.test.ts @@ -0,0 +1,185 @@ +// @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, +} 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 () => { + const subscribers = new Set<() => void>(); + 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(() => currentFlag), + 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: ComputedRef; + onAct: () => void; + flag: ComputedRef; + maxItems: ComputedRef; + bannerText: ComputedRef; + }; + + const Consumer = defineComponent({ + setup() { + const { variant, onAct } = useAbby("hero-test"); + capturedVariantBeforeMount = variant.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}` + ); + }, + }); + + const Provider = defineComponent({ + setup() { + provideAbby(coreCompatibleClient); + return () => h(Consumer); + }, + }); + + const rootEl = document.createElement("div"); + const app = createApp(Provider); + 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 new file mode 100644 index 00000000..de113388 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "tsconfig/react-library.json", + "compilerOptions": { + "baseUrl": ".", + "lib": ["ESNext", "DOM"], + "types": ["vitest/globals"] + } +} diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts new file mode 100644 index 00000000..943ea7e3 --- /dev/null +++ b/packages/vue/vite.config.ts @@ -0,0 +1,22 @@ +/// + +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: hasInstalledVue + ? {} + : { + 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..25e1d078 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1108,6 +1108,34 @@ 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) + vue: + specifier: ^3.4.38 + version: 3.4.38(typescript@5.5.4) + packages: '@adobe/css-tools@4.2.0': @@ -6687,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==} @@ -13964,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==} @@ -21839,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 @@ -30910,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