From 6423f5106662c74c74cbd631ae93fe7dc9444b59 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 4 May 2026 23:22:24 +0100 Subject: [PATCH 1/3] feat: add Vue integration package --- packages/vue/package.json | 41 ++++ packages/vue/src/StorageService.ts | 67 +++++ packages/vue/src/index.ts | 339 ++++++++++++++++++++++++++ packages/vue/tests/createAbby.test.ts | 142 +++++++++++ packages/vue/tsconfig.json | 11 + packages/vue/tsup.config.ts | 10 + packages/vue/vite.config.ts | 20 ++ pnpm-lock.yaml | 90 +++++++ 8 files changed, 720 insertions(+) create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/StorageService.ts create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/tests/createAbby.test.ts create mode 100644 packages/vue/tsconfig.json create mode 100644 packages/vue/tsup.config.ts create mode 100644 packages/vue/vite.config.ts diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..66d7634b --- /dev/null +++ b/packages/vue/package.json @@ -0,0 +1,41 @@ +{ + "name": "@tryabby/vue", + "version": "0.0.1", + "description": "Vue integration for Abby feature flags, A/B tests, 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/", + "dev": "pnpm run build --watch", + "test": "vitest" + }, + "homepage": "https://docs.tryabby.com", + "keywords": ["abby", "vue", "feature-flags", "ab-testing"], + "author": "", + "license": "ISC", + "peerDependencies": { + "vue": "^3.4.0" + }, + "dependencies": { + "@tryabby/core": "workspace:*", + "js-cookie": "^3.0.5" + }, + "devDependencies": { + "@types/js-cookie": "^3.0.3", + "tsconfig": "workspace:*", + "tsup": "^6.5.0", + "typescript": "5.5.4", + "vite": "5.4.0", + "vitest": "2.0.5", + "vue": "3.4.24" + } +} diff --git a/packages/vue/src/StorageService.ts b/packages/vue/src/StorageService.ts new file mode 100644 index 00000000..e9aba8e7 --- /dev/null +++ b/packages/vue/src/StorageService.ts @@ -0,0 +1,67 @@ +import { + type IStorageService, + type StorageServiceOptions, + getABStorageKey, + getFFStorageKey, + getRCStorageKey, +} from "@tryabby/core"; +import Cookie from "js-cookie"; + +const DEFAULT_COOKIE_AGE = 365; + +class ABStorageService implements IStorageService { + get(projectId: string, testName: string): string | null { + return Cookie.get(getABStorageKey(projectId, testName)) ?? null; + } + + set( + projectId: string, + testName: string, + value: string, + options?: StorageServiceOptions + ): void { + Cookie.set(getABStorageKey(projectId, testName), value, { + expires: options?.expiresInDays ?? DEFAULT_COOKIE_AGE, + }); + } + + remove(projectId: string, testName: string): void { + Cookie.remove(getABStorageKey(projectId, testName)); + } +} + +class FFStorageService implements IStorageService { + get(projectId: string, flagName: string): string | null { + return Cookie.get(getFFStorageKey(projectId, flagName)) ?? null; + } + + set(projectId: string, flagName: string, value: string): void { + Cookie.set(getFFStorageKey(projectId, flagName), value, { + expires: DEFAULT_COOKIE_AGE, + }); + } + + remove(projectId: string, flagName: string): void { + Cookie.remove(getFFStorageKey(projectId, flagName)); + } +} + +class RCStorageService implements IStorageService { + get(projectId: string, key: string): string | null { + return Cookie.get(getRCStorageKey(projectId, key)) ?? null; + } + + set(projectId: string, key: string, value: string): void { + Cookie.set(getRCStorageKey(projectId, key), value, { + expires: DEFAULT_COOKIE_AGE, + }); + } + + remove(projectId: string, key: string): void { + Cookie.remove(getRCStorageKey(projectId, key)); + } +} + +export const TestStorageService = new ABStorageService(); +export const FlagStorageService = new FFStorageService(); +export const RemoteConfigStorageService = new RCStorageService(); diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..70fe0a5e --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,339 @@ +import { + type ABConfig, + Abby, + type AbbyConfig, + AbbyEventType, + type AbbyDataResponse, + HttpService, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, + type ValidatorType, +} from "@tryabby/core"; +import type { Infer } from "@tryabby/core/validation"; +import { + computed, + defineComponent, + h, + hasInjectionContext, + inject, + onMounted, + onUnmounted, + provide, + ref, + type ComputedRef, + type InjectionKey, + type PropType, + type Ref, +} from "vue"; +import { + FlagStorageService, + RemoteConfigStorageService, + TestStorageService, +} from "./StorageService"; + +export { type ABConfig, type AbbyConfig, defineConfig } from "@tryabby/core"; + +export type ABTestReturnValue = Lookup extends undefined + ? TestVariant + : TestVariant extends keyof Lookup + ? Lookup[TestVariant] + : never; + +export function createAbby< + const FlagName extends string, + const TestName extends string, + const Tests extends Record, + const RemoteConfig extends Record, + const RemoteConfigName extends Extract, + const User extends Record = Record< + string, + ValidatorType + >, +>( + config: AbbyConfig< + FlagName, + Tests, + string[], + RemoteConfigName, + RemoteConfig, + User + > +) { + const abby = new Abby< + FlagName, + TestName, + Tests, + RemoteConfig, + RemoteConfigName, + string[], + User + >( + config, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return TestStorageService.get(config.projectId, key); + }, + set: (key: string, value: string, options) => { + if (typeof window === "undefined" || config.cookies?.disableByDefault) + return; + TestStorageService.set(config.projectId, key, value, options); + }, + }, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return FlagStorageService.get(config.projectId, key); + }, + set: (key: string, value: string) => { + if (typeof window === "undefined") return; + FlagStorageService.set(config.projectId, key, value); + }, + }, + { + get: (key: string) => { + if (typeof window === "undefined") return null; + return RemoteConfigStorageService.get(config.projectId, key); + }, + set: (key: string, value: string) => { + if (typeof window === "undefined") return; + RemoteConfigStorageService.set(config.projectId, key, value); + }, + } + ); + + type AbbyProjectData = ReturnType; + + const abbyData = ref(abby.getProjectData()) as Ref; + const AbbyDataKey: InjectionKey> = Symbol("AbbyData"); + + const useAbbyData = () => + hasInjectionContext() ? inject(AbbyDataKey, abbyData) : abbyData; + + const notify = ( + name: N, + selectedVariant: string + ) => { + if (!name || !selectedVariant) return; + HttpService.sendData({ + url: config.apiUrl, + type: AbbyEventType.PING, + data: { + projectId: config.projectId, + selectedVariant, + testName: name as string, + }, + }); + }; + + const useAbby = < + K extends keyof Tests, + TestVariant extends Tests[K]["variants"][number], + LookupValue, + const Lookup extends + | Record + | undefined = undefined, + >( + name: K, + lookupObject?: Lookup + ): { + variant: ComputedRef>; + onAct: () => void; + } => { + const data = useAbbyData(); + const selectedVariant = computed( + () => { + data.value; + return abby.getTestVariant(name) as TestVariant; + } + ); + + notify(name, abby.getTestVariant(name)); + + const variant = computed(() => { + const currentVariant = selectedVariant.value; + if (lookupObject) { + return lookupObject[currentVariant]; + } + return currentVariant; + }) as ComputedRef>; + + const onAct = () => { + if (!selectedVariant.value) return; + HttpService.sendData({ + url: config.apiUrl, + type: AbbyEventType.ACT, + data: { + projectId: config.projectId, + selectedVariant: selectedVariant.value, + testName: name as string, + }, + }); + }; + + return { variant, onAct }; + }; + + const useFeatureFlag = (name: FlagName): ComputedRef => { + const data = useAbbyData(); + return computed(() => { + data.value; + return abby.getFeatureFlag(name); + }); + }; + + const getFeatureFlagValue = (name: FlagName) => abby.getFeatureFlag(name); + + const useRemoteConfig = < + T extends RemoteConfigName, + Config extends RemoteConfig[T], + >( + remoteConfigName: T + ): ComputedRef> => { + const data = useAbbyData(); + return computed( + () => { + data.value; + return abby.getRemoteConfig(remoteConfigName); + } + ); + }; + + const getRemoteConfig = < + T extends RemoteConfigName, + Config extends RemoteConfig[T], + >( + remoteConfigName: T + ): RemoteConfigValueStringToType => { + return abby.getRemoteConfig(remoteConfigName); + }; + + const getABTestValue = < + K extends keyof Tests, + TestVariant extends Tests[K]["variants"][number], + LookupValue, + const Lookup extends + | Record + | undefined = undefined, + >( + testName: K, + lookupObject?: Lookup + ): ABTestReturnValue => { + const variant = abby.getTestVariant(testName); + if (lookupObject === undefined) { + return variant as any; + } + return lookupObject[variant as TestVariant] as any; + }; + + const getABResetFunction = (name: K) => { + return () => { + TestStorageService.remove(config.projectId, name as string); + }; + }; + + const getVariants = (name: K) => { + return abby.getVariants(name); + }; + + const useFeatureFlags = () => { + const data = useAbbyData(); + return computed(() => { + data.value; + return abby.getFeatureFlags(); + }); + }; + + const useRemoteConfigVariables = () => { + const data = useAbbyData(); + return computed( + () => { + data.value; + return abby.getRemoteConfigVariables(); + } + ); + }; + + const updateUserProperties = ( + user: Partial<{ + -readonly [K in keyof User]: Infer; + }> + ) => { + abby.updateUserProperties(user); + }; + + const AbbyProvider = defineComponent({ + name: "AbbyProvider", + props: { + initialData: { + type: Object as PropType, + required: false, + }, + }, + setup(props, { slots }) { + if (props.initialData) { + abbyData.value = abby.init(props.initialData) as AbbyProjectData; + } + + provide(AbbyDataKey, abbyData); + + const unsubscribe = abby.subscribe((newData) => { + abbyData.value = newData as AbbyProjectData; + }); + + onMounted(() => { + if (props.initialData) return; + abby.loadProjectData().then((data) => { + if (data) abbyData.value = data as AbbyProjectData; + }); + }); + + onUnmounted(() => { + unsubscribe(); + }); + + return () => slots.default?.(); + }, + }); + + const withDevtools = ( + factory: { + create: (props: Record) => () => void; + }, + props: Record & { dangerouslyForceShow?: boolean } = {} + ) => + defineComponent({ + name: "AbbyDevtools", + setup() { + let destroy: (() => void) | undefined; + onMounted(() => { + if ( + !props.dangerouslyForceShow && + process.env.NODE_ENV !== "development" + ) { + return; + } + destroy = factory.create({ ...props, abby }); + }); + onUnmounted(() => destroy?.()); + return () => h("span", { style: "display: none;" }); + }, + }); + + return { + useAbby, + AbbyProvider, + useFeatureFlag, + getFeatureFlagValue, + useRemoteConfig, + getRemoteConfig, + getABTestValue, + __abby__: abby, + getABResetFunction, + getVariants, + useFeatureFlags, + useRemoteConfigVariables, + updateUserProperties, + withDevtools, + }; +} diff --git a/packages/vue/tests/createAbby.test.ts b/packages/vue/tests/createAbby.test.ts new file mode 100644 index 00000000..8dc3410d --- /dev/null +++ b/packages/vue/tests/createAbby.test.ts @@ -0,0 +1,142 @@ +import { AbbyEventType, HttpService } from "@tryabby/core"; +import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; +import { createAbby } from "../src"; +import { TestStorageService } from "../src/StorageService"; + +describe("createAbby Vue integration", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns a reactive A/B test variant and onAct handler", () => { + const { useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + headline: { variants: ["A", "B"] as const }, + }, + }); + + const { variant, onAct } = useAbby("headline"); + + expect(["A", "B"]).toContain(variant.value); + expect(onAct).toBeDefined(); + }); + + it("uses persisted A/B test values", () => { + const getSpy = vi.spyOn(TestStorageService, "get"); + const setSpy = vi.spyOn(TestStorageService, "set"); + getSpy.mockReturnValue("B"); + + const { useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + headline: { variants: ["A", "B"] as const }, + }, + }); + + const { variant } = useAbby("headline"); + + expect(getSpy).toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + expect(variant.value).toBe("B"); + }); + + it("maps variants through a lookup object", () => { + vi.spyOn(TestStorageService, "get").mockReturnValue("B"); + const { useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + headline: { variants: ["A", "B"] as const }, + }, + }); + + const { variant } = useAbby("headline", { + A: "Control", + B: "Treatment", + }); + + expect(variant.value).toBe("Treatment"); + expectTypeOf(variant.value).toEqualTypeOf<"Control" | "Treatment">(); + }); + + it("returns reactive feature flags with local values", () => { + const { useFeatureFlag, getFeatureFlagValue } = createAbby({ + environments: [], + currentEnvironment: "production", + projectId: "123", + flags: ["beta"], + settings: { + flags: { + defaultValue: true, + }, + }, + }); + + expect(useFeatureFlag("beta").value).toBe(false); + expect(getFeatureFlagValue("beta")).toBe(false); + }); + + it("returns reactive remote config values", () => { + const { useRemoteConfig, getRemoteConfig } = createAbby({ + environments: ["production"], + currentEnvironment: "production", + projectId: "123", + remoteConfig: { + theme: "String", + }, + settings: { + remoteConfig: { + defaultValues: { + String: "dark", + }, + }, + }, + }); + + expect(useRemoteConfig("theme").value).toBe("dark"); + expect(getRemoteConfig("theme")).toBe("dark"); + }); + + it("sends ping and act events", () => { + const spy = vi.spyOn(HttpService, "sendData"); + const { useAbby } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + checkout: { variants: ["A", "B"] as const }, + }, + }); + + const { onAct } = useAbby("checkout"); + onAct(); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ type: AbbyEventType.PING }) + ); + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ type: AbbyEventType.ACT }) + ); + }); + + it("exposes sync helper functions", () => { + const { getVariants, getABTestValue, getABResetFunction } = createAbby({ + environments: [""], + currentEnvironment: "", + projectId: "123", + tests: { + color: { variants: ["red", "blue"] as const }, + }, + }); + + expect(getVariants("color")).toEqual(["red", "blue"]); + expect(["red", "blue"]).toContain(getABTestValue("color")); + expect(getABResetFunction("color")).toEqual(expect.any(Function)); + }); +}); diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..94971bb5 --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ES2020", + "types": ["vitest/globals"] + }, + "include": ["src", "tests", "vite.config.ts", "tsup.config.ts"] +} diff --git a/packages/vue/tsup.config.ts b/packages/vue/tsup.config.ts new file mode 100644 index 00000000..685d4095 --- /dev/null +++ b/packages/vue/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + dts: true, + clean: true, + format: ["cjs", "esm"], + sourcemap: true, + treeshake: true, + external: ["vue"], +}); diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts new file mode 100644 index 00000000..17d64c3d --- /dev/null +++ b/packages/vue/vite.config.ts @@ -0,0 +1,20 @@ +/// + +import { defineConfig } from "vite"; +import { resolve } from "node:path"; + +export default defineConfig({ + resolve: { + alias: { + "@tryabby/core/validation": resolve( + __dirname, + "../core/src/validation/index.ts" + ), + "@tryabby/core": resolve(__dirname, "../core/src/index.ts"), + }, + }, + test: { + globals: true, + environment: "jsdom", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69fa896e..2979ef36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1108,6 +1108,37 @@ importers: packages/tsconfig: {} + packages/vue: + dependencies: + '@tryabby/core': + specifier: workspace:* + version: link:../core + js-cookie: + specifier: ^3.0.5 + version: 3.0.5 + devDependencies: + '@types/js-cookie': + specifier: ^3.0.3 + version: 3.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.24 + version: 3.4.24(typescript@5.5.4) + packages: '@adobe/css-tools@4.2.0': @@ -6693,6 +6724,12 @@ packages: '@vue/compiler-dom@3.4.24': resolution: {integrity: sha512-4XgABML/4cNndVsQndG6BbGN7+EoisDwi3oXNovqL/4jdNhwvP8/rfRMTb6FxkxIxUUtg6AI1/qZvwfSjxJiWA==} + '@vue/compiler-sfc@3.4.24': + resolution: {integrity: sha512-nRAlJUK02FTWfA2nuvNBAqsDZuERGFgxZ8sGH62XgFSvMxO2URblzulExsmj4gFZ8e+VAyDooU9oAoXfEDNxTA==} + + '@vue/compiler-ssr@3.4.24': + resolution: {integrity: sha512-ZsAtr4fhaUFnVcDqwW3bYCSDwq+9Gk69q2r/7dAHDrOMw41kylaMgOP4zRnn6GIEJkQznKgrMOGPMFnLB52RbQ==} + '@vue/language-core@1.8.27': resolution: {integrity: sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==} peerDependencies: @@ -6701,6 +6738,20 @@ packages: typescript: optional: true + '@vue/reactivity@3.4.24': + resolution: {integrity: sha512-nup3fSYg4i4LtNvu9slF/HF/0dkMQYfepUdORBcMSsankzRPzE7ypAFurpwyRBfU1i7Dn1kcwpYsE1wETSh91g==} + + '@vue/runtime-core@3.4.24': + resolution: {integrity: sha512-c7iMfj6cJMeAG3s5yOn9Rc5D9e2/wIuaozmGf/ICGCY3KV5H7mbTVdvEkd4ZshTq7RUZqj2k7LMJWVx+EBiY1g==} + + '@vue/runtime-dom@3.4.24': + resolution: {integrity: sha512-uXKzuh/Emfad2Y7Qm0ABsLZZV6H3mAJ5ZVqmAOlrNQRf+T5mxpPGZBfec1hkP41t6h6FwF6RSGCs/gd8WbuySQ==} + + '@vue/server-renderer@3.4.24': + resolution: {integrity: sha512-H+DLK4sQF6sRgzKyofmlEVBIV/9KrQU6HIV7nt6yIwSGGKvSwlV8pqJlebUKLpbXaNHugdSfAbP6YmXF69lxow==} + peerDependencies: + vue: 3.4.24 + '@vue/shared@3.4.24': resolution: {integrity: sha512-BW4tajrJBM9AGAknnyEw5tO2xTmnqgup0VTnDAMcxYmqOX0RG0b9aSUGAbEKolD91tdwpA6oCwbltoJoNzpItw==} @@ -7796,6 +7847,9 @@ packages: csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + csv-generate@3.4.3: resolution: {integrity: sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw==} @@ -13973,6 +14027,14 @@ packages: peerDependencies: typescript: '*' + vue@3.4.24: + resolution: {integrity: sha512-NPdx7dLGyHmKHGRRU5bMRYVE+rechR+KDU5R2tSTNG36PuMwbfAJ+amEvOAw7BPfZp5sQulNELSLm5YUkau+Sg==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -21852,6 +21914,10 @@ snapshots: '@vue/compiler-core': 3.4.24 '@vue/shared': 3.4.24 + '@vue/compiler-sfc@3.4.24': {} + + '@vue/compiler-ssr@3.4.24': {} + '@vue/language-core@1.8.27(typescript@5.5.4)': dependencies: '@volar/language-core': 1.11.1 @@ -21866,6 +21932,18 @@ snapshots: optionalDependencies: typescript: 5.5.4 + '@vue/reactivity@3.4.24': {} + + '@vue/runtime-core@3.4.24': {} + + '@vue/runtime-dom@3.4.24': {} + + '@vue/server-renderer@3.4.24(vue@3.4.24(typescript@5.5.4))': + dependencies: + '@vue/compiler-ssr': 3.4.24 + '@vue/shared': 3.4.24 + vue: 3.4.24(typescript@5.5.4) + '@vue/shared@3.4.24': {} '@web3-storage/multipart-parser@1.0.0': {} @@ -23018,6 +23096,8 @@ snapshots: csstype@3.1.2: {} + csstype@3.2.3: {} + csv-generate@3.4.3: {} csv-parse@4.16.3: {} @@ -30917,6 +30997,16 @@ snapshots: semver: 7.6.0 typescript: 5.5.4 + vue@3.4.24(typescript@5.5.4): + dependencies: + '@vue/compiler-dom': 3.4.24 + '@vue/compiler-sfc': 3.4.24 + '@vue/runtime-dom': 3.4.24 + '@vue/server-renderer': 3.4.24(vue@3.4.24(typescript@5.5.4)) + '@vue/shared': 3.4.24 + optionalDependencies: + typescript: 5.5.4 + w3c-keyname@2.2.8: {} w3c-xmlserializer@4.0.0: From a7d12fbf47a314a29138d23697ad4e2945d6a98d Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 5 May 2026 11:27:08 +0100 Subject: [PATCH 2/3] fix: address Vue integration review feedback --- packages/vue/src/index.ts | 40 +++++++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 70fe0a5e..90738080 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -20,6 +20,7 @@ import { onUnmounted, provide, ref, + watch, type ComputedRef, type InjectionKey, type PropType, @@ -82,21 +83,25 @@ export function createAbby< }, { get: (key: string) => { - if (typeof window === "undefined") return null; + if (typeof window === "undefined" || config.cookies?.disableByDefault) + return null; return FlagStorageService.get(config.projectId, key); }, set: (key: string, value: string) => { - if (typeof window === "undefined") return; + if (typeof window === "undefined" || config.cookies?.disableByDefault) + return; FlagStorageService.set(config.projectId, key, value); }, }, { get: (key: string) => { - if (typeof window === "undefined") return null; + if (typeof window === "undefined" || config.cookies?.disableByDefault) + return null; return RemoteConfigStorageService.get(config.projectId, key); }, set: (key: string, value: string) => { - if (typeof window === "undefined") return; + if (typeof window === "undefined" || config.cookies?.disableByDefault) + return; RemoteConfigStorageService.set(config.projectId, key, value); }, } @@ -148,7 +153,16 @@ export function createAbby< } ); - notify(name, abby.getTestVariant(name)); + let lastSentVariant: string | null = null; + watch( + selectedVariant, + (currentVariant) => { + if (!currentVariant || currentVariant === lastSentVariant) return; + lastSentVariant = currentVariant; + notify(name, currentVariant); + }, + { immediate: !hasInjectionContext() } + ); const variant = computed(() => { const currentVariant = selectedVariant.value; @@ -277,19 +291,21 @@ export function createAbby< provide(AbbyDataKey, abbyData); - const unsubscribe = abby.subscribe((newData) => { - abbyData.value = newData as AbbyProjectData; - }); + let unsubscribe: (() => void) | undefined; - onMounted(() => { - if (props.initialData) return; - abby.loadProjectData().then((data) => { + onMounted(async () => { + if (!props.initialData) { + const data = await abby.loadProjectData(); if (data) abbyData.value = data as AbbyProjectData; + } + + unsubscribe = abby.subscribe((newData) => { + abbyData.value = newData as AbbyProjectData; }); }); onUnmounted(() => { - unsubscribe(); + unsubscribe?.(); }); return () => slots.default?.(); From 7560164ffe6447bbdf835bc790fe8b2f1ba1d26d Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 5 May 2026 13:34:18 +0100 Subject: [PATCH 3/3] fix: honor cookie opt-out for AB storage --- packages/vue/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 90738080..dd968718 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -72,7 +72,8 @@ export function createAbby< config, { get: (key: string) => { - if (typeof window === "undefined") return null; + if (typeof window === "undefined" || config.cookies?.disableByDefault) + return null; return TestStorageService.get(config.projectId, key); }, set: (key: string, value: string, options) => {