("/api/abby")
+ );
+ if (error.value || !data.value) {
+ throw createError({ statusCode: 503, statusMessage: "Abby data unavailable" });
+ }
+ return { provide: { abby, abbyInitialData: data.value } };
+});
+```
+
+`/api/abby` is an endpoint supplied by your application, not by this package.
+It must return the core `AbbyDataResponse` shape. Only return data suitable for
+the browser; keep any service credentials in the server endpoint. Choose the
+error policy appropriate for your application rather than passing missing data
+to the provider unintentionally.
+
+```vue
+
+
+
+
+
+
+
+
+```
+
+Call the Abby composables in a descendant page or component's setup, where the
+provider is available. They must not be called in the plugin or in `app.vue`
+before its provider is rendered. Nuxt infers the injected factory's types:
+
+```vue
+
+
+
+
+ {{ title }}
+ {{ variant }}
+
+```
+
+The variant still waits until client mount, as described above. Cookie overrides
+and user targeting need the same server/client handling as other SSR consumers.
+This is a plugin recipe, not a Nuxt module or global Abby auto-imports.
+
+## Other helpers
+
+The factory also provides `useFeatureFlags()` and `useRemoteConfigVariables()`
+as computed lists, plus `getFeatureFlagValue`, `getRemoteConfig`,
+`getABTestValue`, `getVariants`, `getABResetFunction` and
+`updateUserProperties`. These non-composable helpers retain configured key and
+value types. `__abby__` exposes the underlying core instance for explicit
+reloads and existing core tooling. `defineConfig` is re-exported from core.
+
+Test cookies use the same project-prefixed names as the other integrations and
+honor `cookies.disableByDefault` and `cookies.expiresInDays`. Saved consent is
+read before initializing or refreshing variant cookies. No browser storage is
+accessed on the server.
+
+With `cookies.disableByDefault: true`, removing saved consent disables cookie
+writes when a provider from the same factory remounts. A new grant through
+`__abby__.enableCookies()` saves consent and enables persistence again. Removing
+consent does not erase existing variant cookies, and external cookie changes
+are not watched while the provider stays mounted.
+
+### Devtools
+
+```ts
+import devtools from "@tryabby/devtools";
+
+const AbbyDevtools = abby.withDevtools(devtools, { position: "bottom-right" });
+// Render in the application's template.
+```
+
+Install `@tryabby/devtools` separately if using it. Options are inferred from
+the supplied factory, so this integration does not require loading the devtools
+bundle otherwise. The component creates devtools on mount in development and
+calls their cleanup function on unmount. It renders nothing during SSR.
+`dangerouslyForceShow: true` explicitly enables it outside development.
+
+## Local verification and example
+
+From the repository root, with Node 22 and pnpm 9.9.0:
+
+```sh
+pnpm --filter @tryabby/vue... install --frozen-lockfile
+pnpm --filter @tryabby/core build
+pnpm --filter @tryabby/vue test
+pnpm --filter @tryabby/vue build
+pnpm --filter @tryabby/vue example
+```
+
+`test` runs runtime tests followed by `tsc --noEmit`, including positive
+inference assertions and rejected invalid names, lookups and writes. Tests
+cover SSR, hydration, targeting, cookie consent, late consumers, cleanup,
+stale requests and devtools lifecycle.
+
+Open `http://127.0.0.1:4178`. The example consumes the built package and serves
+synthetic project data from its local Vite middleware. **Change server data**
+loads alternate flags/config/weights. **Record conversion**, then **Read
+events**, shows the server's received exposure/conversion payloads. This makes
+no calls to an Abby account and uses no customer data.
diff --git a/packages/vue/example/index.html b/packages/vue/example/index.html
new file mode 100644
index 00000000..84341673
--- /dev/null
+++ b/packages/vue/example/index.html
@@ -0,0 +1,37 @@
+
+
+
+
+
+ Abby · Vue integration
+
+
+
+
+ Abby for Vue
+ A local example of feature flags, remote configuration and A/B conversion events.
+ All data is synthetic. Requests stay on this local server.
+
+ Enable JavaScript to run the Vue example.
+
+
+
diff --git a/packages/vue/example/main.ts b/packages/vue/example/main.ts
new file mode 100644
index 00000000..61994ca1
--- /dev/null
+++ b/packages/vue/example/main.ts
@@ -0,0 +1,91 @@
+import { createApp, defineComponent, h, ref } from "vue";
+import { createAbby } from "../dist/index.mjs";
+
+const abby = createAbby({
+ projectId: "vue-example",
+ apiUrl: `${window.location.origin}/`,
+ environments: ["local"],
+ currentEnvironment: "local",
+ flags: ["banner"],
+ tests: { checkout: { variants: ["classic", "compact"] } },
+ remoteConfig: { title: "String" },
+ cookies: { disableByDefault: true },
+});
+
+const Example = defineComponent({
+ setup() {
+ const banner = abby.useFeatureFlag("banner");
+ const title = abby.useRemoteConfig("title");
+ const { variant, onAct } = abby.useAbby("checkout");
+ const busy = ref(false);
+ const message = ref("");
+ const events = ref("No events read yet.");
+ async function changeData() {
+ busy.value = true;
+ try {
+ const response = await fetch("/fixture/toggle", { method: "POST" });
+ if (!response.ok) throw new Error("Could not update the fixture");
+ await abby.__abby__.loadProjectData();
+ message.value = "Loaded the updated fixture.";
+ } catch (error) {
+ message.value = String(error);
+ } finally {
+ busy.value = false;
+ }
+ }
+ async function readEvents() {
+ try {
+ const response = await fetch("/fixture/events");
+ if (!response.ok) throw new Error("Could not read events");
+ events.value = JSON.stringify(await response.json(), null, 2);
+ } catch (error) {
+ message.value = String(error);
+ }
+ }
+ const row = (label: string, value: unknown) =>
+ h("tr", [
+ h("th", { scope: "row" }, h("code", label)),
+ h("td", String(value)),
+ ]);
+ return () => [
+ h("table", [
+ h("tbody", [
+ row('useFeatureFlag("banner")', banner.value),
+ row('useRemoteConfig("title")', title.value),
+ row('useAbby("checkout").variant', variant.value ?? "Loading…"),
+ ]),
+ ]),
+ h("div", { class: "actions" }, [
+ h(
+ "button",
+ { type: "button", disabled: busy.value, onClick: changeData },
+ busy.value ? "Loading fixture…" : "Change server data"
+ ),
+ h(
+ "button",
+ {
+ type: "button",
+ disabled: variant.value === undefined,
+ onClick: () => {
+ onAct();
+ message.value =
+ "Conversion requested. Read events to verify receipt.";
+ },
+ },
+ "Record conversion"
+ ),
+ h("button", { type: "button", onClick: readEvents }, "Read events"),
+ ]),
+ h("p", { id: "message", role: "status" }, message.value),
+ h("h2", "Server event log"),
+ h(
+ "p",
+ "Type 0 is an exposure. Type 1 is a conversion. Read events after recording a conversion to see the server receipt."
+ ),
+ h("pre", { "data-testid": "events" }, events.value),
+ ];
+ },
+});
+createApp({ render: () => h(abby.AbbyProvider, {}, () => h(Example)) }).mount(
+ "#app"
+);
diff --git a/packages/vue/example/vite.config.ts b/packages/vue/example/vite.config.ts
new file mode 100644
index 00000000..cf0ee79a
--- /dev/null
+++ b/packages/vue/example/vite.config.ts
@@ -0,0 +1,71 @@
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ define: {
+ __VUE_OPTIONS_API__: true,
+ __VUE_PROD_DEVTOOLS__: false,
+ __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false,
+ },
+ root: fileURLToPath(new URL(".", import.meta.url)),
+ server: { host: "127.0.0.1", port: 4178, strictPort: true },
+ plugins: [
+ {
+ name: "local-abby-fixture",
+ configureServer(server) {
+ let alternate = false;
+ const events: unknown[] = [];
+ server.middlewares.use((request, response, next) => {
+ const pathname = new URL(request.url ?? "/", "http://127.0.0.1")
+ .pathname;
+ const reply = (data: unknown) => {
+ response.setHeader("Content-Type", "application/json");
+ response.end(JSON.stringify(data));
+ };
+ if (
+ pathname === "/api/v2/data/vue-example" &&
+ request.method === "GET"
+ ) {
+ reply({
+ tests: [
+ { name: "checkout", weights: alternate ? [1, 0] : [0, 1] },
+ ],
+ flags: [{ name: "banner", value: !alternate }],
+ remoteConfig: [
+ {
+ name: "title",
+ value: alternate ? "Welcome back" : "Welcome",
+ },
+ ],
+ });
+ } else if (
+ pathname === "/fixture/toggle" &&
+ request.method === "POST"
+ ) {
+ alternate = !alternate;
+ reply({ alternate });
+ } else if (
+ pathname === "/fixture/events" &&
+ request.method === "GET"
+ ) {
+ reply(events);
+ } else if (pathname === "/api/data" && request.method === "POST") {
+ let body = "";
+ request.on("data", (chunk) => {
+ body += chunk;
+ });
+ request.on("end", () => {
+ try {
+ events.push(JSON.parse(body));
+ reply({ recorded: true });
+ } catch {
+ response.statusCode = 400;
+ reply({ error: "Invalid event JSON" });
+ }
+ });
+ } else next();
+ });
+ },
+ },
+ ],
+});
diff --git a/packages/vue/package.json b/packages/vue/package.json
new file mode 100644
index 00000000..7c54ca85
--- /dev/null
+++ b/packages/vue/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "@tryabby/vue",
+ "version": "0.0.0",
+ "description": "Vue 3 composables for Abby",
+ "main": "dist/index.js",
+ "module": "dist/index.mjs",
+ "types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.js"
+ }
+ },
+ "files": ["dist"],
+ "sideEffects": false,
+ "license": "AGPL-3.0-only",
+ "scripts": {
+ "build": "tsup src/index.ts",
+ "dev": "pnpm build --watch",
+ "example": "vite --config example/vite.config.ts",
+ "test": "vitest run && pnpm typecheck",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@tryabby/core": "workspace:*",
+ "js-cookie": "^3.0.1"
+ },
+ "peerDependencies": {
+ "vue": "^3.3.0"
+ },
+ "devDependencies": {
+ "@types/js-cookie": "^3.0.3",
+ "jsdom": "^20.0.3",
+ "tsconfig": "workspace:*",
+ "tsup": "^6.5.0",
+ "typescript": "5.5.4",
+ "vite": "5.4.0",
+ "vitest": "2.0.5",
+ "vue": "3.5.12"
+ }
+}
diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts
new file mode 100644
index 00000000..0979cbac
--- /dev/null
+++ b/packages/vue/src/index.ts
@@ -0,0 +1,303 @@
+import {
+ ABBY_WINDOW_KEY,
+ type ABConfig,
+ Abby,
+ type AbbyConfig,
+ type AbbyDataResponse,
+ AbbyEventType,
+ HttpService,
+ type RemoteConfigValueString,
+ type RemoteConfigValueStringToType,
+ type ValidatorType,
+} from "@tryabby/core";
+import {
+ type DefineComponent,
+ type InjectionKey,
+ type PropType,
+ computed,
+ defineComponent,
+ inject,
+ onBeforeUnmount,
+ onMounted,
+ provide,
+ shallowRef,
+ watch,
+} from "vue";
+import {
+ createStorage,
+ getABStorageKey,
+ getFFStorageKey,
+ getRCStorageKey,
+} from "./storage";
+
+export { type ABConfig, type AbbyConfig, defineConfig } from "@tryabby/core";
+
+export type ABTestReturnValue = Lookup extends undefined
+ ? Variant
+ : Variant extends keyof Lookup
+ ? Lookup[Variant]
+ : 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
+ >,
+>(
+ abbyConfig: AbbyConfig<
+ FlagName,
+ Tests,
+ string[],
+ RemoteConfigName,
+ RemoteConfig,
+ User
+ >
+) {
+ const requiresCookieConsent = abbyConfig.cookies?.disableByDefault === true;
+ // Core assigns weights and cookie preferences in place. A shared config
+ // must not carry those mutations into another server request.
+ const config = {
+ ...abbyConfig,
+ cookies: abbyConfig.cookies ? { ...abbyConfig.cookies } : undefined,
+ tests: abbyConfig.tests
+ ? (Object.fromEntries(
+ Object.entries(abbyConfig.tests as Record).map(
+ ([name, test]) => [name, { ...test, variants: [...test.variants] }]
+ )
+ ) as unknown as Tests)
+ : undefined,
+ };
+ const testStorage = createStorage(config.projectId, getABStorageKey);
+ const abby = new Abby<
+ FlagName,
+ TestName,
+ Tests,
+ RemoteConfig,
+ RemoteConfigName,
+ string[],
+ User
+ >(
+ config,
+ testStorage,
+ createStorage(config.projectId, getFFStorageKey),
+ createStorage(config.projectId, getRCStorageKey)
+ );
+ type ProjectData = ReturnType;
+ const createContext = () => ({
+ data: shallowRef(),
+ mounted: shallowRef(false),
+ });
+ const contextKey: InjectionKey> =
+ Symbol("Abby");
+
+ function useContext() {
+ const context = inject(contextKey, null);
+ if (!context) {
+ throw new Error(
+ "Abby composables must be used inside the AbbyProvider returned by the same createAbby call."
+ );
+ }
+ return context;
+ }
+
+ const AbbyProvider = defineComponent({
+ name: "AbbyProvider",
+ props: {
+ initialData: Object as PropType,
+ },
+ setup(props, { slots }) {
+ const context = createContext();
+ // Read saved consent before init notifies subscribers and selects tests.
+ if (typeof document !== "undefined") {
+ const consent = testStorage.get("$_abcc_$");
+ if (consent !== null) {
+ abby.setLocalOverrides(
+ `${getABStorageKey(config.projectId, "$_abcc_$")}=${encodeURIComponent(consent)}`
+ );
+ } else if (requiresCookieConsent) {
+ // A reused core instance may still hold the previous consent grant.
+ abby.disableCookies();
+ }
+ abby.setLocalOverrides(document.cookie);
+ }
+ if (props.initialData) context.data.value = abby.init(props.initialData);
+ provide(contextKey, context);
+ let unsubscribe: (() => void) | undefined;
+ onMounted(() => {
+ context.mounted.value = true;
+ unsubscribe = abby.subscribe(() => {
+ context.data.value = abby.getProjectData();
+ });
+ if (props.initialData) {
+ // Child mount hooks run first and may update targeting before we subscribe.
+ context.data.value = abby.getProjectData();
+ return;
+ }
+ // Select the first variant only after the server's weights arrive.
+ // Fetch without init so a retired provider cannot overwrite a new one.
+ const load =
+ typeof window !== "undefined" &&
+ ABBY_WINDOW_KEY in window &&
+ window[ABBY_WINDOW_KEY] != null
+ ? Promise.resolve(window[ABBY_WINDOW_KEY] as AbbyDataResponse)
+ : HttpService.getProjectData({
+ projectId: config.projectId,
+ environment: config.currentEnvironment,
+ url: config.apiUrl,
+ fetch: config.fetch,
+ __experimentalCdnUrl: config.__experimentalCdnUrl
+ ? `${config.__experimentalCdnUrl}/${config.projectId}/${config.currentEnvironment}`
+ : undefined,
+ });
+ void load.then((data) => {
+ if (context.mounted.value) {
+ context.data.value = data ? abby.init(data) : abby.getProjectData();
+ }
+ });
+ });
+ onBeforeUnmount(() => {
+ context.mounted.value = false;
+ unsubscribe?.();
+ });
+ return () => slots.default?.();
+ },
+ }) as DefineComponent<{ initialData?: AbbyDataResponse }>;
+
+ function useAbby<
+ K extends keyof Tests,
+ Variant extends Tests[K]["variants"][number],
+ const Lookup extends Record | undefined = undefined,
+ >(name: K, lookup?: Lookup) {
+ const context = useContext();
+ const mounted = shallowRef(false);
+ onMounted(() => {
+ mounted.value = true;
+ });
+ onBeforeUnmount(() => {
+ mounted.value = false;
+ });
+ const selected = computed(() =>
+ context.mounted.value && mounted.value
+ ? context.data.value?.tests[name as unknown as TestName]
+ ?.selectedVariant
+ : undefined
+ );
+ function send(type: AbbyEventType) {
+ if (!selected.value) return;
+ HttpService.sendData({
+ url: config.apiUrl,
+ type,
+ data: {
+ projectId: config.projectId,
+ testName: String(name),
+ selectedVariant: selected.value,
+ },
+ });
+ }
+ watch(selected, (variant) => {
+ if (variant) send(AbbyEventType.PING);
+ });
+ return {
+ // undefined is explicit during SSR and the first client render.
+ variant: computed | undefined>(() => {
+ const variant = selected.value as Variant | undefined;
+ if (variant === undefined) return undefined;
+ return (lookup ? lookup[variant] : variant) as ABTestReturnValue<
+ Lookup,
+ Variant
+ >;
+ }),
+ onAct: () => send(AbbyEventType.ACT),
+ };
+ }
+
+ function useFeatureFlag(name: FlagName) {
+ const context = useContext();
+ return computed(() => {
+ context.data.value;
+ return abby.getFeatureFlag(name);
+ });
+ }
+
+ function useRemoteConfig(name: K) {
+ const context = useContext();
+ return computed>(() => {
+ context.data.value;
+ return abby.getRemoteConfig(name);
+ });
+ }
+
+ function useFeatureFlags() {
+ const context = useContext();
+ return computed(() => {
+ context.data.value;
+ return abby.getFeatureFlags();
+ });
+ }
+
+ function useRemoteConfigVariables() {
+ const context = useContext();
+ return computed(() => {
+ context.data.value;
+ return abby.getRemoteConfigVariables();
+ });
+ }
+
+ function getABTestValue<
+ K extends keyof Tests,
+ Variant extends Tests[K]["variants"][number],
+ const Lookup extends Record | undefined = undefined,
+ >(name: K, lookup?: Lookup): ABTestReturnValue {
+ const variant = abby.getTestVariant(name) as Variant;
+ return (lookup ? lookup[variant] : variant) as ABTestReturnValue<
+ Lookup,
+ Variant
+ >;
+ }
+
+ function withDevtools }>(
+ factory: { create(props: Props): () => void },
+ props: Omit & { dangerouslyForceShow?: boolean }
+ ): DefineComponent {
+ return defineComponent({
+ name: "AbbyDevtools",
+ setup() {
+ let destroy: (() => void) | undefined;
+ onMounted(() => {
+ if (
+ !props.dangerouslyForceShow &&
+ process.env.NODE_ENV !== "development"
+ )
+ return;
+ const { dangerouslyForceShow: _force, ...options } = props;
+ destroy = factory.create({ ...options, abby } as unknown as Props);
+ });
+ onBeforeUnmount(() => destroy?.());
+ return () => null;
+ },
+ }) as DefineComponent;
+ }
+
+ return {
+ AbbyProvider,
+ useAbby,
+ useFeatureFlag,
+ useRemoteConfig,
+ useFeatureFlags,
+ useRemoteConfigVariables,
+ getABTestValue,
+ withDevtools,
+ getFeatureFlagValue: abby.getFeatureFlag.bind(abby),
+ getRemoteConfig: abby.getRemoteConfig.bind(abby),
+ getVariants: abby.getVariants.bind(abby),
+ getABResetFunction: (name: keyof Tests) => () =>
+ testStorage.remove(String(name)),
+ updateUserProperties: abby.updateUserProperties.bind(abby),
+ __abby__: abby,
+ };
+}
diff --git a/packages/vue/src/storage.ts b/packages/vue/src/storage.ts
new file mode 100644
index 00000000..7dc0e85e
--- /dev/null
+++ b/packages/vue/src/storage.ts
@@ -0,0 +1,32 @@
+import {
+ type StorageServiceOptions,
+ getABStorageKey,
+ getFFStorageKey,
+ getRCStorageKey,
+} from "@tryabby/core";
+import Cookies from "js-cookie";
+
+export function createStorage(
+ projectId: string,
+ storageKey: typeof getABStorageKey
+) {
+ return {
+ get(key: string) {
+ if (typeof document === "undefined") return null;
+ return Cookies.get(storageKey(projectId, key)) ?? null;
+ },
+ set(key: string, value: string, options?: StorageServiceOptions) {
+ if (typeof document === "undefined") return;
+ Cookies.set(storageKey(projectId, key), value, {
+ expires: options?.expiresInDays ?? 365,
+ });
+ },
+ remove(key: string) {
+ if (typeof document !== "undefined") {
+ Cookies.remove(storageKey(projectId, key));
+ }
+ },
+ };
+}
+
+export { getABStorageKey, getFFStorageKey, getRCStorageKey };
diff --git a/packages/vue/tests/composables.test.ts b/packages/vue/tests/composables.test.ts
new file mode 100644
index 00000000..ec87e858
--- /dev/null
+++ b/packages/vue/tests/composables.test.ts
@@ -0,0 +1,588 @@
+import {
+ type AbbyDataResponse,
+ AbbyEventType,
+ HttpService,
+} from "@tryabby/core";
+import Cookies from "js-cookie";
+import {
+ type App,
+ createApp,
+ createSSRApp,
+ defineComponent,
+ h,
+ nextTick,
+ onMounted,
+ shallowRef,
+} from "vue";
+import { renderToString } from "vue/server-renderer";
+import { createAbby } from "../src";
+
+const initialData: AbbyDataResponse = {
+ tests: [{ name: "checkout", weights: [0, 1] }],
+ flags: [{ name: "banner", value: true }],
+ remoteConfig: [
+ { name: "title", value: "Welcome" },
+ { name: "limit", value: 3 },
+ ],
+};
+function configure(
+ cookies: { disableByDefault?: boolean } = { disableByDefault: true }
+) {
+ return createAbby({
+ projectId: "vue-tests",
+ apiUrl: "http://127.0.0.1:9876/",
+ currentEnvironment: "test",
+ environments: ["test"],
+ tests: { checkout: { variants: ["old", "new"] } },
+ flags: ["banner", "missing"],
+ remoteConfig: { title: "String", limit: "Number", missing: "String" },
+ cookies,
+ settings: {
+ flags: { fallbackValues: { missing: true } },
+ remoteConfig: { defaultValues: { String: "Default" } },
+ },
+ user: { customer: { type: "boolean" } },
+ });
+}
+type Integration = ReturnType;
+const apps: App[] = [];
+function mount(abby: Integration, setup: () => T, data?: AbbyDataResponse) {
+ let result!: T;
+ const child = defineComponent({
+ setup() {
+ result = setup();
+ return () => h("span", "consumer");
+ },
+ });
+ const app = createApp({
+ render: () => h(abby.AbbyProvider, { initialData: data }, () => h(child)),
+ });
+ apps.push(app);
+ const element = document.createElement("div");
+ app.mount(element);
+ return { result, app, element };
+}
+
+beforeEach(() => {
+ vi.spyOn(HttpService, "sendData").mockImplementation(() => undefined);
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockRejectedValue(new Error("Unexpected network request"))
+ );
+});
+afterEach(() => {
+ for (const app of apps.splice(0)) app.unmount();
+ for (const cookie of document.cookie.split(";")) {
+ document.cookie = `${cookie.split("=")[0].trim()}=; Max-Age=0; path=/`;
+ }
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ vi.unstubAllEnvs();
+});
+
+it("returns a weighted variant, lookup value, flags and typed config", async () => {
+ const abby = configure();
+ const { result } = mount(
+ abby,
+ () => ({
+ test: abby.useAbby("checkout"),
+ lookup: abby.useAbby("checkout", { old: 1, new: 2 }),
+ flag: abby.useFeatureFlag("banner"),
+ title: abby.useRemoteConfig("title"),
+ limit: abby.useRemoteConfig("limit"),
+ }),
+ initialData
+ );
+ await nextTick();
+ expect(result.test.variant.value).toBe("new");
+ expect(result.lookup.variant.value).toBe(2);
+ expect(result.flag.value).toBe(true);
+ expect(result.title.value).toBe("Welcome");
+ expect(result.limit.value).toBe(3);
+ expect(fetch).not.toHaveBeenCalled();
+});
+
+it("hydrates server data without a variant or text mismatch", async () => {
+ function createPage() {
+ const abby = configure();
+ const child = defineComponent({
+ setup() {
+ const flag = abby.useFeatureFlag("banner");
+ const title = abby.useRemoteConfig("title");
+ const { variant } = abby.useAbby("checkout");
+ return () =>
+ h("p", `${flag.value}|${title.value}|${variant.value ?? "pending"}`);
+ },
+ });
+ return createSSRApp({
+ render: () => h(abby.AbbyProvider, { initialData }, () => h(child)),
+ });
+ }
+ const element = document.createElement("div");
+ element.innerHTML = await renderToString(createPage());
+ expect(element.textContent).toBe("true|Welcome|pending");
+ const app = createPage();
+ const warning = vi.fn();
+ app.config.warnHandler = warning;
+ apps.push(app);
+ app.mount(element);
+ await nextTick();
+ expect(element.textContent).toBe("true|Welcome|new");
+ expect(warning).not.toHaveBeenCalled();
+ expect(fetch).not.toHaveBeenCalled();
+});
+
+it("tracks each rendered variant and its conversion", async () => {
+ const abby = configure();
+ const { result } = mount(abby, () => abby.useAbby("checkout"), initialData);
+ await nextTick();
+ expect(HttpService.sendData).toHaveBeenCalledOnce();
+ expect(HttpService.sendData).toHaveBeenCalledWith({
+ url: "http://127.0.0.1:9876/",
+ type: AbbyEventType.PING,
+ data: {
+ projectId: "vue-tests",
+ testName: "checkout",
+ selectedVariant: "new",
+ },
+ });
+ result.onAct();
+ expect(HttpService.sendData).toHaveBeenLastCalledWith(
+ expect.objectContaining({ type: AbbyEventType.ACT })
+ );
+});
+
+it("updates every composable after core subscription events", async () => {
+ vi.stubEnv("NODE_ENV", "development");
+ const abby = configure();
+ const { result } = mount(
+ abby,
+ () => ({
+ test: abby.useAbby("checkout"),
+ flag: abby.useFeatureFlag("banner"),
+ title: abby.useRemoteConfig("title"),
+ flags: abby.useFeatureFlags(),
+ variables: abby.useRemoteConfigVariables(),
+ }),
+ initialData
+ );
+ await nextTick();
+ abby.__abby__.updateLocalVariant("checkout", "old");
+ abby.__abby__.updateFlag("banner", false);
+ abby.__abby__.updateRemoteConfig("title", "Changed");
+ await nextTick();
+ expect(result.test.variant.value).toBe("old");
+ expect(result.flag.value).toBe(false);
+ expect(result.title.value).toBe("Changed");
+ expect(result.flags.value).toContainEqual({ name: "banner", value: false });
+ expect(result.variables.value).toContainEqual({
+ name: "title",
+ value: "Changed",
+ });
+ expect(HttpService.sendData).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ type: AbbyEventType.PING,
+ data: expect.objectContaining({ selectedVariant: "old" }),
+ })
+ );
+});
+
+it("uses core fallbacks when the API omits a configured key", () => {
+ const abby = configure();
+ const { result } = mount(
+ abby,
+ () => ({
+ flag: abby.useFeatureFlag("missing"),
+ remote: abby.useRemoteConfig("missing"),
+ }),
+ initialData
+ );
+ expect(result.flag.value).toBe(true);
+ expect(result.remote.value).toBe("Default");
+});
+
+it("fetches once on mount, waits for weights, and updates the initial view", async () => {
+ let resolve!: (value: Response) => void;
+ vi.mocked(fetch).mockImplementation(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ })
+ );
+ const abby = configure();
+ const { result } = mount(abby, () => ({
+ test: abby.useAbby("checkout"),
+ flag: abby.useFeatureFlag("banner"),
+ }));
+ expect(result.test.variant.value).toBeUndefined();
+ result.test.onAct();
+ expect(HttpService.sendData).not.toHaveBeenCalled();
+ expect(result.flag.value).toBe(false);
+ expect(fetch).toHaveBeenCalledOnce();
+ expect(fetch).toHaveBeenCalledWith(
+ "http://127.0.0.1:9876/api/v2/data/vue-tests?environment=test"
+ );
+ resolve(new Response(JSON.stringify(initialData)));
+ await vi.waitFor(() => expect(result.test.variant.value).toBe("new"));
+ expect(result.flag.value).toBe(true);
+});
+
+it("keeps defaults when the project request fails", async () => {
+ vi.mocked(fetch).mockResolvedValue(
+ new Response("Unavailable", { status: 503 })
+ );
+ const abby = configure();
+ const { result } = mount(abby, () => ({
+ test: abby.useAbby("checkout"),
+ title: abby.useRemoteConfig("title"),
+ }));
+ await vi.waitFor(() => expect(result.test.variant.value).toBe("old"));
+ expect(result.title.value).toBe("Default");
+});
+
+it("unsubscribes and stops tracking when the provider unmounts", async () => {
+ const abby = configure();
+ const subscribe = abby.__abby__.subscribe.bind(abby.__abby__);
+ const removed = vi.fn();
+ vi.spyOn(abby.__abby__, "subscribe").mockImplementation((listener) => {
+ const unsubscribe = subscribe(listener);
+ return () => {
+ removed();
+ unsubscribe();
+ };
+ });
+ const { result, app } = mount(
+ abby,
+ () => abby.useAbby("checkout"),
+ initialData
+ );
+ await nextTick();
+ app.unmount();
+ apps.splice(apps.indexOf(app), 1);
+ vi.mocked(HttpService.sendData).mockClear();
+ abby.__abby__.updateFlag("banner", false);
+ result.onAct();
+ await nextTick();
+ expect(removed).toHaveBeenCalledOnce();
+ expect(HttpService.sendData).not.toHaveBeenCalled();
+});
+
+it("ignores an outstanding load after unmount", async () => {
+ let resolve!: (value: Response) => void;
+ vi.mocked(fetch).mockImplementation(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ })
+ );
+ const abby = configure();
+ const { result, app } = mount(abby, () => abby.useAbby("checkout"));
+ app.unmount();
+ apps.splice(apps.indexOf(app), 1);
+ resolve(new Response(JSON.stringify(initialData)));
+ await new Promise((done) => setTimeout(done, 0));
+ expect(result.variant.value).toBeUndefined();
+ expect(HttpService.sendData).not.toHaveBeenCalled();
+});
+
+it("does not let a retired provider's request overwrite its replacement", async () => {
+ let resolve!: (value: Response) => void;
+ vi.mocked(fetch).mockImplementation(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ })
+ );
+ const abby = configure();
+ const first = mount(abby, () => abby.useRemoteConfig("title"));
+ first.app.unmount();
+ apps.splice(apps.indexOf(first.app), 1);
+ const second = mount(abby, () => abby.useRemoteConfig("title"), initialData);
+ expect(second.result.value).toBe("Welcome");
+ resolve(
+ new Response(
+ JSON.stringify({
+ ...initialData,
+ remoteConfig: [{ name: "title", value: "Stale" }],
+ })
+ )
+ );
+ await new Promise((done) => setTimeout(done, 0));
+ expect(second.result.value).toBe("Welcome");
+ expect(abby.getRemoteConfig("title")).toBe("Welcome");
+});
+
+it("tracks late consumers and stops conversions after only the consumer unmounts", async () => {
+ const abby = configure();
+ const visible = shallowRef(false);
+ let onAct!: () => void;
+ const child = defineComponent({
+ setup() {
+ onAct = abby.useAbby("checkout").onAct;
+ return () => h("p", "Checkout");
+ },
+ });
+ const app = createApp({
+ render: () =>
+ h(abby.AbbyProvider, { initialData }, () =>
+ visible.value ? h(child) : null
+ ),
+ });
+ apps.push(app);
+ app.mount(document.createElement("div"));
+ await nextTick();
+ expect(HttpService.sendData).not.toHaveBeenCalled();
+ visible.value = true;
+ await nextTick();
+ expect(HttpService.sendData).toHaveBeenCalledOnce();
+ expect(HttpService.sendData).toHaveBeenLastCalledWith(
+ expect.objectContaining({ type: AbbyEventType.PING })
+ );
+ onAct();
+ expect(HttpService.sendData).toHaveBeenCalledTimes(2);
+ visible.value = false;
+ await nextTick();
+ onAct();
+ expect(HttpService.sendData).toHaveBeenCalledTimes(2);
+});
+
+it.each(["useAbby", "useFeatureFlag", "useRemoteConfig"] as const)(
+ "explains a missing provider for %s",
+ (method) => {
+ const abby = configure();
+ const error = vi.fn();
+ const app = createApp({
+ setup: () => {
+ if (method === "useAbby") abby.useAbby("checkout");
+ if (method === "useFeatureFlag") abby.useFeatureFlag("banner");
+ if (method === "useRemoteConfig") abby.useRemoteConfig("title");
+ return () => null;
+ },
+ render: () => null,
+ });
+ app.config.errorHandler = error;
+ apps.push(app);
+ app.mount(document.createElement("div"));
+ expect(error.mock.calls[0][0].message).toContain("inside the AbbyProvider");
+ }
+);
+
+it("does not accept the provider from a different factory", () => {
+ const first = configure();
+ const second = configure();
+ const error = vi.fn();
+ const child = defineComponent({
+ setup() {
+ second.useFeatureFlag("banner");
+ },
+ render: () => null,
+ });
+ const app = createApp({
+ render: () => h(first.AbbyProvider, { initialData }, () => h(child)),
+ });
+ app.config.errorHandler = error;
+ apps.push(app);
+ app.mount(document.createElement("div"));
+ expect(error.mock.calls[0][0].message).toContain("same createAbby");
+});
+
+it("persists the variant in the same cookie format as other integrations", async () => {
+ const abby = createAbby({
+ projectId: "cookies",
+ currentEnvironment: "test",
+ environments: ["test"],
+ tests: { checkout: { variants: ["old", "new"] } },
+ });
+ // The provider helper is deliberately narrow; this instance only differs in config.
+ const app = createApp({
+ render: () => h(abby.AbbyProvider, { initialData }, () => h("div")),
+ });
+ apps.push(app);
+ app.mount(document.createElement("div"));
+ expect(document.cookie).toContain("__abby__ab__cookies_checkout=new");
+ expect(abby.getABTestValue("checkout")).toBe("new");
+ abby.getABResetFunction("checkout")();
+ expect(document.cookie).not.toContain("__abby__ab__cookies_checkout=");
+});
+
+it.each([false, true])(
+ "reads saved refusal before cookie writes (existing variant: %s)",
+ async (existing) => {
+ if (existing) document.cookie = "__abby__ab__consent_checkout=old; path=/";
+ document.cookie = "__abby__ab__consent_$_abcc_$=false; path=/";
+ const writeCookie = vi.spyOn(Cookies, "set");
+ let resolve!: (value: Response) => void;
+ vi.mocked(fetch).mockImplementation(
+ () =>
+ new Promise((done) => {
+ resolve = done;
+ })
+ );
+ const abby = createAbby({
+ projectId: "consent",
+ currentEnvironment: "test",
+ environments: ["test"],
+ tests: { checkout: { variants: ["old", "new"] } },
+ });
+ let value!: ReturnType;
+ const child = defineComponent({
+ setup() {
+ value = abby.useAbby("checkout");
+ return () => null;
+ },
+ });
+ const app = createApp({
+ render: () => h(abby.AbbyProvider, {}, () => h(child)),
+ });
+ apps.push(app);
+ app.mount(document.createElement("div"));
+ expect(writeCookie).not.toHaveBeenCalled();
+ resolve(new Response(JSON.stringify(initialData)));
+ await vi.waitFor(() =>
+ expect(value.variant.value).toBe(existing ? "old" : "new")
+ );
+ expect(writeCookie).not.toHaveBeenCalled();
+ if (!existing)
+ expect(document.cookie).not.toContain("__abby__ab__consent_checkout=");
+ }
+);
+
+it.each([false, true])(
+ "restores opt-in after consent is removed on remount (retained variant: %s)",
+ async (retainVariant) => {
+ const consentKey = "__abby__ab__vue-tests_$_abcc_$";
+ const variantKey = "__abby__ab__vue-tests_checkout";
+ Cookies.set(consentKey, "true");
+ const abby = configure();
+ const first = mount(abby, () => abby.useAbby("checkout"), initialData);
+ await nextTick();
+ expect(Cookies.get(variantKey)).toBe("new");
+ first.app.unmount();
+
+ Cookies.remove(consentKey);
+ if (!retainVariant) Cookies.remove(variantKey);
+ const writeCookie = vi.spyOn(Cookies, "set");
+ const second = mount(abby, () => abby.useAbby("checkout"), initialData);
+ await nextTick();
+ expect(second.result.variant.value).toBe("new");
+ expect(writeCookie).not.toHaveBeenCalled();
+ expect(abby.__abby__.getConfig().cookies?.disableByDefault).toBe(true);
+ expect(Cookies.get(consentKey)).toBeUndefined();
+ expect(Cookies.get(variantKey)).toBe(retainVariant ? "new" : undefined);
+
+ // An explicit new grant must work in this provider and survive remounting.
+ abby.__abby__.enableCookies();
+ expect(Cookies.get(consentKey)).toBe("true");
+ expect(Cookies.get(variantKey)).toBe("new");
+ second.app.unmount();
+ Cookies.remove(variantKey);
+ mount(abby, () => abby.useAbby("checkout"), initialData);
+ await nextTick();
+ expect(Cookies.get(variantKey)).toBe("new");
+ expect(abby.__abby__.getConfig().cookies?.disableByDefault).toBe(false);
+ }
+);
+
+it("honors a saved refusal when remounting a previously consenting factory", async () => {
+ const consentKey = "__abby__ab__vue-tests_$_abcc_$";
+ Cookies.set(consentKey, "true");
+ const abby = configure();
+ const first = mount(abby, () => abby.useAbby("checkout"), initialData);
+ first.app.unmount();
+ Cookies.set(consentKey, "false");
+ const writeCookie = vi.spyOn(Cookies, "set");
+ mount(abby, () => abby.useAbby("checkout"), initialData);
+ await nextTick();
+ expect(writeCookie).not.toHaveBeenCalled();
+ expect(abby.__abby__.getConfig().cookies?.disableByDefault).toBe(true);
+});
+
+it.each([{}, { disableByDefault: false }])(
+ "preserves default-allow and explicit disabling on remount (%j)",
+ async (cookies) => {
+ const variantKey = "__abby__ab__vue-tests_checkout";
+ const abby = configure(cookies);
+ const first = mount(abby, () => abby.useAbby("checkout"), initialData);
+ expect(Cookies.get(variantKey)).toBe("new");
+ first.app.unmount();
+ Cookies.remove(variantKey);
+ const second = mount(abby, () => abby.useAbby("checkout"), initialData);
+ expect(Cookies.get(variantKey)).toBe("new");
+
+ abby.__abby__.disableCookies();
+ second.app.unmount();
+ Cookies.remove(variantKey);
+ const writeCookie = vi.spyOn(Cookies, "set");
+ mount(abby, () => abby.useAbby("checkout"), initialData);
+ await nextTick();
+ expect(writeCookie).not.toHaveBeenCalled();
+ expect(Cookies.get(variantKey)).toBeUndefined();
+ expect(abby.__abby__.getConfig().cookies?.disableByDefault).toBe(true);
+ }
+);
+
+it("respects disabled cookies and applies typed user targeting updates", async () => {
+ const abby = configure();
+ const data: AbbyDataResponse = {
+ ...initialData,
+ flags: [
+ {
+ name: "banner",
+ value: false,
+ ruleSet: [
+ {
+ propertyName: "customer",
+ propertyType: "boolean",
+ operator: "eq",
+ value: true,
+ thenValue: true,
+ },
+ ],
+ },
+ ],
+ };
+ const { result } = mount(abby, () => abby.useFeatureFlag("banner"), data);
+ expect(result.value).toBe(false);
+ abby.updateUserProperties({ customer: true });
+ await nextTick();
+ expect(result.value).toBe(true);
+ expect(document.cookie).not.toContain("__abby__ab__vue-tests");
+});
+
+it("observes targeting updates from a child's mount hook", async () => {
+ const abby = configure();
+ const data: AbbyDataResponse = {
+ ...initialData,
+ flags: [
+ {
+ name: "banner",
+ value: false,
+ ruleSet: [
+ {
+ propertyName: "customer",
+ propertyType: "boolean",
+ operator: "eq",
+ value: true,
+ thenValue: true,
+ },
+ ],
+ },
+ ],
+ };
+ const child = defineComponent({
+ setup() {
+ const flag = abby.useFeatureFlag("banner");
+ onMounted(() => abby.updateUserProperties({ customer: true }));
+ return () => h("p", String(flag.value));
+ },
+ });
+ const app = createApp({
+ render: () => h(abby.AbbyProvider, { initialData: data }, () => h(child)),
+ });
+ apps.push(app);
+ const element = document.createElement("div");
+ app.mount(element);
+ await nextTick();
+ expect(abby.getFeatureFlagValue("banner")).toBe(true);
+ expect(element.textContent).toBe("true");
+});
diff --git a/packages/vue/tests/devtools.test.ts b/packages/vue/tests/devtools.test.ts
new file mode 100644
index 00000000..f6466cde
--- /dev/null
+++ b/packages/vue/tests/devtools.test.ts
@@ -0,0 +1,58 @@
+import { createApp, createSSRApp, h } from "vue";
+import { renderToString } from "vue/server-renderer";
+import { createAbby } from "../src";
+
+afterEach(() => {
+ vi.unstubAllEnvs();
+});
+
+it.each([
+ { environment: "development", force: false, expected: 1 },
+ { environment: "production", force: false, expected: 0 },
+ { environment: "production", force: true, expected: 1 },
+])(
+ "mounts and disposes devtools in $environment (force: $force)",
+ ({ environment, force, expected }) => {
+ vi.stubEnv("NODE_ENV", environment);
+ const abby = createAbby({
+ projectId: "tools",
+ environments: [],
+ currentEnvironment: "test",
+ });
+ const destroy = vi.fn();
+ const factory = {
+ create: vi.fn(
+ (_props: { abby: typeof abby.__abby__; defaultShow?: boolean }) =>
+ destroy
+ ),
+ };
+ const Devtools = abby.withDevtools(factory, {
+ defaultShow: true,
+ dangerouslyForceShow: force,
+ });
+ const app = createApp({ render: () => h(Devtools) });
+ app.mount(document.createElement("div"));
+ expect(factory.create).toHaveBeenCalledTimes(expected);
+ if (expected)
+ expect(factory.create).toHaveBeenCalledWith({
+ abby: abby.__abby__,
+ defaultShow: true,
+ });
+ app.unmount();
+ expect(destroy).toHaveBeenCalledTimes(expected);
+ }
+);
+
+it("never constructs devtools during SSR, even when forced", async () => {
+ const abby = createAbby({
+ projectId: "tools",
+ environments: [],
+ currentEnvironment: "test",
+ });
+ const factory = {
+ create: vi.fn((_props: { abby: typeof abby.__abby__ }) => vi.fn()),
+ };
+ const Devtools = abby.withDevtools(factory, { dangerouslyForceShow: true });
+ await renderToString(createSSRApp({ render: () => h(Devtools) }));
+ expect(factory.create).not.toHaveBeenCalled();
+});
diff --git a/packages/vue/tests/inference.types.ts b/packages/vue/tests/inference.types.ts
new file mode 100644
index 00000000..bb6ca0dd
--- /dev/null
+++ b/packages/vue/tests/inference.types.ts
@@ -0,0 +1,68 @@
+import { expectTypeOf } from "vitest";
+import type { ComputedRef } from "vue";
+import { createAbby } from "../src";
+
+// Compiled by tsc, not executed as component setup.
+function checkTypes() {
+ const abby = createAbby({
+ projectId: "types",
+ environments: ["test"],
+ currentEnvironment: "test",
+ flags: ["banner", "checkout"],
+ tests: { checkout: { variants: ["old", "new"] } },
+ remoteConfig: { title: "String", limit: "Number", options: "JSON" },
+ user: { age: { type: "number" }, name: { type: "string", optional: true } },
+ });
+ expectTypeOf(abby.useFeatureFlag("banner")).toEqualTypeOf<
+ ComputedRef
+ >();
+ expectTypeOf(abby.useRemoteConfig("title")).toEqualTypeOf<
+ ComputedRef
+ >();
+ expectTypeOf(abby.useRemoteConfig("limit")).toEqualTypeOf<
+ ComputedRef
+ >();
+ expectTypeOf(abby.useRemoteConfig("options")).toEqualTypeOf<
+ ComputedRef>
+ >();
+ expectTypeOf(abby.useAbby("checkout").variant).toEqualTypeOf<
+ ComputedRef<"old" | "new" | undefined>
+ >();
+ expectTypeOf(
+ abby.useAbby("checkout", { old: 1, new: 2 }).variant
+ ).toEqualTypeOf>();
+ expectTypeOf(
+ abby.getABTestValue("checkout", { old: "A", new: "B" })
+ ).toEqualTypeOf<"A" | "B">();
+ expectTypeOf(abby.getRemoteConfig("limit")).toEqualTypeOf();
+ expectTypeOf(abby.getVariants("checkout")).toEqualTypeOf<
+ readonly ["old", "new"]
+ >();
+ abby.updateUserProperties({ age: 30, name: undefined });
+ const devtools = {
+ create:
+ (_props: {
+ abby: typeof abby.__abby__;
+ position?: "top-left" | "bottom-right";
+ }) =>
+ () => {},
+ };
+ abby.withDevtools(devtools, { position: "top-left" });
+ // @ts-expect-error devtools options are inferred from the supplied factory
+ abby.withDevtools(devtools, { position: "unknown" });
+ // @ts-expect-error flag names come from configuration
+ abby.useFeatureFlag("unknown");
+ // @ts-expect-error remote config names come from configuration
+ abby.useRemoteConfig("unknown");
+ // @ts-expect-error test names come from configuration
+ abby.useAbby("unknown");
+ // @ts-expect-error all variants must have lookup entries
+ abby.useAbby("checkout", { old: 1 });
+ // @ts-expect-error returned refs are read-only
+ abby.useFeatureFlag("banner").value = true;
+ // @ts-expect-error targeting keeps validator types
+ abby.updateUserProperties({ age: "30" });
+ // @ts-expect-error helpers retain configured keys
+ abby.getRemoteConfig("unknown");
+}
+void checkTypes;
diff --git a/packages/vue/tests/ssr.test.ts b/packages/vue/tests/ssr.test.ts
new file mode 100644
index 00000000..4ce3a62e
--- /dev/null
+++ b/packages/vue/tests/ssr.test.ts
@@ -0,0 +1,79 @@
+// @vitest-environment node
+import { type AbbyDataResponse, HttpService } from "@tryabby/core";
+import { createSSRApp, defineComponent, h } from "vue";
+import { renderToString } from "vue/server-renderer";
+import { createAbby } from "../src";
+
+const config = {
+ projectId: "ssr",
+ currentEnvironment: "test",
+ environments: ["test"],
+ tests: { checkout: { variants: ["old", "new"] } },
+ flags: ["banner"],
+ remoteConfig: { title: "String" as const },
+};
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+it("renders initial flags and config without browser APIs, subscriptions or tracking", async () => {
+ const abby = createAbby(config);
+ const subscribe = vi.spyOn(abby.__abby__, "subscribe");
+ const load = vi.spyOn(abby.__abby__, "loadProjectData");
+ const send = vi.spyOn(HttpService, "sendData");
+ const child = defineComponent({
+ setup() {
+ const flag = abby.useFeatureFlag("banner");
+ const title = abby.useRemoteConfig("title");
+ const test = abby.useAbby("checkout");
+ test.onAct();
+ return () =>
+ h(
+ "div",
+ `${flag.value}|${title.value}|${test.variant.value ?? "pending"}`
+ );
+ },
+ });
+ const initialData: AbbyDataResponse = {
+ flags: [{ name: "banner", value: true }],
+ tests: [{ name: "checkout", weights: [0, 1] }],
+ remoteConfig: [{ name: "title", value: "Server title" }],
+ };
+ const html = await renderToString(
+ createSSRApp({
+ render: () => h(abby.AbbyProvider, { initialData }, () => h(child)),
+ })
+ );
+ expect(html).toContain("true|Server title|pending");
+ expect(subscribe).not.toHaveBeenCalled();
+ expect(load).not.toHaveBeenCalled();
+ expect(send).not.toHaveBeenCalled();
+});
+
+it("isolates simultaneous requests even when they reuse a configuration object", async () => {
+ async function render(title: string) {
+ const abby = createAbby(config);
+ const child = defineComponent({
+ async setup() {
+ const value = abby.useRemoteConfig("title");
+ await Promise.resolve();
+ return () => h("p", value.value);
+ },
+ });
+ const initialData: AbbyDataResponse = {
+ tests: [{ name: "checkout", weights: title === "A" ? [0, 1] : [1, 0] }],
+ flags: [],
+ remoteConfig: [{ name: "title", value: title }],
+ };
+ return renderToString(
+ createSSRApp({
+ render: () => h(abby.AbbyProvider, { initialData }, () => h(child)),
+ })
+ );
+ }
+ const [first, second] = await Promise.all([render("A"), render("B")]);
+ expect(first).toContain("A
");
+ expect(second).toContain("B
");
+ expect(config.tests.checkout).not.toHaveProperty("weights");
+ expect(config.tests.checkout).not.toHaveProperty("selectedVariant");
+});
diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json
new file mode 100644
index 00000000..fceb723f
--- /dev/null
+++ b/packages/vue/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "tsconfig/base.json",
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "lib": ["ESNext", "DOM"],
+ "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..bf89044f
--- /dev/null
+++ b/packages/vue/tsup.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ dts: true,
+ clean: true,
+ format: ["cjs", "esm"],
+ sourcemap: true,
+ treeshake: true,
+});
diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts
new file mode 100644
index 00000000..86ed19a1
--- /dev/null
+++ b/packages/vue/vite.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: "jsdom",
+ exclude: ["**/node_modules/**", "**/dist/**", "**/*.types.ts"],
+ },
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 69fa896e..8ad36e63 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -666,6 +666,10 @@ importers:
version: 2.0.5(@types/node@20.3.1)(jsdom@20.0.3)(less@4.2.0)(sass@1.77.8)(terser@5.31.1)
packages/core:
+ dependencies:
+ zod:
+ specifier: ^3.19.1
+ version: 3.21.4
devDependencies:
'@vitejs/plugin-react':
specifier: 4.2.1
@@ -691,9 +695,6 @@ 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)
- zod:
- specifier: ^3.19.1
- version: 3.21.4
packages/devtools:
dependencies:
@@ -1108,6 +1109,40 @@ importers:
packages/tsconfig: {}
+ packages/vue:
+ dependencies:
+ '@tryabby/core':
+ specifier: workspace:*
+ version: link:../core
+ js-cookie:
+ specifier: ^3.0.1
+ version: 3.0.5
+ devDependencies:
+ '@types/js-cookie':
+ specifier: ^3.0.3
+ version: 3.0.3
+ 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.5.12
+ version: 3.5.12(typescript@5.5.4)
+
packages:
'@adobe/css-tools@4.2.0':
@@ -14389,6 +14424,88 @@ packages:
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution:
+ integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==
+
+ '@vue/compiler-core@3.5.12':
+ resolution:
+ integrity: sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==
+
+ '@vue/compiler-dom@3.5.12':
+ resolution:
+ integrity: sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==
+
+ '@vue/compiler-sfc@3.5.12':
+ resolution:
+ integrity: sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==
+
+ '@vue/compiler-ssr@3.5.12':
+ resolution:
+ integrity: sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==
+
+ '@vue/reactivity@3.5.12':
+ resolution:
+ integrity: sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==
+
+ '@vue/runtime-core@3.5.12':
+ resolution:
+ integrity: sha512-hrMUYV6tpocr3TL3Ad8DqxOdpDe4zuQY4HPY3X/VRh+L2myQO8MFXPAMarIOSGNu0bFAjh1yBkMPXZBqCk62Uw==
+
+ '@vue/runtime-dom@3.5.12':
+ resolution:
+ integrity: sha512-q8VFxR9A2MRfBr6/55Q3umyoN7ya836FzRXajPB6/Vvuv0zOPL+qltd9rIMzG/DbRLAIlREmnLsplEF/kotXKA==
+
+ '@vue/server-renderer@3.5.12':
+ resolution:
+ integrity: sha512-I3QoeDDeEPZm8yR28JtY+rk880Oqmj43hreIBVTicisFTx/Dl7JpG72g/X7YF8hnQD3IFhkky5i2bPonwrTVPg==
+ peerDependencies:
+ vue: 3.5.12
+
+ '@vue/shared@3.5.12':
+ resolution:
+ integrity: sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==
+
+ csstype@3.2.3:
+ resolution:
+ integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
+
+ magic-string@0.30.21:
+ resolution:
+ integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
+
+ nanoid@3.3.19:
+ resolution:
+ integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==
+ engines:
+ node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1
+ hasBin: true
+
+ picocolors@1.1.1:
+ resolution:
+ integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
+
+ postcss@8.5.28:
+ resolution:
+ integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==
+ engines:
+ node: ^10 || ^12 || >=14
+
+ source-map-js@1.2.1:
+ resolution:
+ integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
+ engines:
+ node: '>=0.10.0'
+
+ vue@3.5.12:
+ resolution:
+ integrity: sha512-CLVZtXtn2ItBIi/zHZ0Sg1Xkb7+PU32bJJ8Bmy7ts3jxXTcbfsEfBivFYYWz1Hur+lalqGAh65Coin0r+HRUfg==
+ peerDependencies:
+ typescript: '*'
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+
snapshots:
'@adobe/css-tools@4.2.0': {}
@@ -31399,3 +31516,87 @@ snapshots:
react: 18.2.0
zwitch@2.0.4: {}
+
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@vue/compiler-core@3.5.12':
+ dependencies:
+ '@babel/parser': 7.25.3
+ '@vue/shared': 3.5.12
+ entities: 4.5.0
+ estree-walker: 2.0.2
+ source-map-js: 1.2.0
+
+ '@vue/compiler-dom@3.5.12':
+ dependencies:
+ '@vue/compiler-core': 3.5.12
+ '@vue/shared': 3.5.12
+
+ '@vue/compiler-sfc@3.5.12':
+ dependencies:
+ '@babel/parser': 7.25.3
+ '@vue/compiler-core': 3.5.12
+ '@vue/compiler-dom': 3.5.12
+ '@vue/compiler-ssr': 3.5.12
+ '@vue/shared': 3.5.12
+ estree-walker: 2.0.2
+ magic-string: 0.30.21
+ postcss: 8.5.28
+ source-map-js: 1.2.0
+
+ '@vue/compiler-ssr@3.5.12':
+ dependencies:
+ '@vue/compiler-dom': 3.5.12
+ '@vue/shared': 3.5.12
+
+ '@vue/reactivity@3.5.12':
+ dependencies:
+ '@vue/shared': 3.5.12
+
+ '@vue/runtime-core@3.5.12':
+ dependencies:
+ '@vue/reactivity': 3.5.12
+ '@vue/shared': 3.5.12
+
+ '@vue/runtime-dom@3.5.12':
+ dependencies:
+ '@vue/reactivity': 3.5.12
+ '@vue/runtime-core': 3.5.12
+ '@vue/shared': 3.5.12
+ csstype: 3.2.3
+
+ '@vue/server-renderer@3.5.12(vue@3.5.12(typescript@5.5.4))':
+ dependencies:
+ '@vue/compiler-ssr': 3.5.12
+ '@vue/shared': 3.5.12
+ vue: 3.5.12(typescript@5.5.4)
+
+ '@vue/shared@3.5.12': {}
+
+ csstype@3.2.3: {}
+
+ magic-string@0.30.21:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ nanoid@3.3.19: {}
+
+ picocolors@1.1.1: {}
+
+ postcss@8.5.28:
+ dependencies:
+ nanoid: 3.3.19
+ picocolors: 1.1.1
+ source-map-js: 1.2.1
+
+ source-map-js@1.2.1: {}
+
+ vue@3.5.12(typescript@5.5.4):
+ dependencies:
+ '@vue/compiler-dom': 3.5.12
+ '@vue/compiler-sfc': 3.5.12
+ '@vue/runtime-dom': 3.5.12
+ '@vue/server-renderer': 3.5.12(vue@3.5.12(typescript@5.5.4))
+ '@vue/shared': 3.5.12
+ optionalDependencies:
+ typescript: 5.5.4