Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vue-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tryabby/vue": minor
---

Add the Vue integration with typed reactive composables, a request-local provider, React-parity helpers, cookie consent controls, devtools support and Nuxt usage guidance.
1 change: 1 addition & 0 deletions apps/docs/pages/integrations/_meta.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"nextjs": "Next.js",
"react": "React",
"vue": "Vue",
"remix": "Remix",
"svelte": "Svelte",
"angular": "Angular",
Expand Down
46 changes: 46 additions & 0 deletions apps/docs/pages/integrations/vue.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Vue

Use `@tryabby/vue` with Vue 3.3 or later. It builds on Abby core and provides typed, reactive A/B tests, feature flags and remote config.

```sh
pnpm add @tryabby/vue
```

```ts
// abby.ts (client-only app)
import { createAbby } from "@tryabby/vue";

export const abby = createAbby({
projectId: "YOUR_PROJECT_ID",
environments: ["production"],
currentEnvironment: "production",
tests: { checkout: { variants: ["original", "fresh"] } },
flags: ["premium"],
remoteConfig: { headline: "String" },
});
```

Wrap the app with `abby.AbbyProvider`, then call the composables in a descendant's setup function:

```vue
<script setup lang="ts">
import { abby } from "./abby";
const { variant, onAct } = abby.useAbby("checkout");
const premium = abby.useFeatureFlag("premium");
const headline = abby.useRemoteConfig("headline");
</script>

<template>
<h1>{{ headline }}</h1>
<button v-if="variant" type="button" @click="onAct">{{ variant }}</button>
<p v-if="premium">Premium is enabled</p>
</template>
```

Values are readonly computed refs. Vue unwraps them in templates; use `.value` in TypeScript. A/B variants are pending (`undefined`) through SSR and initial hydration, then resolve after mount. Names, variant unions, complete lookup objects and remote config return types are inferred from your configuration.

The provider fetches once on mount unless `initialData` was supplied. It accepts an SSR cookie header through `cookies` and typed targeting properties through `userProperties`. Direct getters, list composables, reset helpers, consent controls and the existing devtools are also supported.

For SSR, create a fresh SDK instance for each request and pass matching initial data to hydration. A Nuxt plugin can create this instance and expose it as `$abby`; do not export a shared SDK singleton from a server module.

See the [Vue package guide](https://github.com/tryabby/abby/tree/main/packages/vue#readme) for the complete API, provider setup, cookie behavior, devtools and a Nuxt plugin example.
1 change: 1 addition & 0 deletions packages/vue/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
142 changes: 142 additions & 0 deletions packages/vue/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# @tryabby/vue

Typed Vue 3 composables for Abby A/B tests, feature flags and remote configuration.

```sh
pnpm add @tryabby/vue
```

Create an instance with the same configuration used by the other Abby integrations:

```ts
// abby.ts — a client-only app can export this instance.
import { createAbby } from "@tryabby/vue";

export const abby = createAbby({
projectId: "YOUR_PROJECT_ID",
environments: ["production"],
currentEnvironment: "production",
tests: { checkout: { variants: ["original", "fresh"] } },
flags: ["premium"],
remoteConfig: { headline: "String", price: "Number", theme: "JSON" },
user: { role: { type: "string" } },
});
```

Wrap the application in that instance's provider:

```vue
<script setup lang="ts">
import { abby } from "./abby";
const { AbbyProvider } = abby;
</script>

<template><AbbyProvider><RouterView /></AbbyProvider></template>
```

Consume reactive values inside a descendant's `setup()`:

```vue
<script setup lang="ts">
import { abby } from "./abby";
const { variant, onAct } = abby.useAbby("checkout");
const premium = abby.useFeatureFlag("premium");
const headline = abby.useRemoteConfig("headline");
</script>

<template>
<h1>{{ headline }}</h1>
<button v-if="variant" type="button" @click="onAct">{{ variant }}</button>
<p v-if="premium">Premium feature enabled</p>
</template>
```

Composables return readonly computed refs: use `.value` in TypeScript; Vue unwraps them in templates. Names accept a string, ref or getter. Variant values remain `undefined` during SSR and initial hydration, then resolve after the provider mounts and loads data. A view event is sent when the selected test/variant changes; `onAct()` sends a conversion for the current selection. Both use core's telemetry behavior, including its localhost suppression.

`useAbby("checkout", { original: "Buy", fresh: "Try now" })` maps every variant to a typed value. Missing lookup keys and unknown names are TypeScript errors.

## API

| Member | Result / behavior |
| --- | --- |
| `useAbby(name, lookup?)` | `{ variant: ComputedRef<VariantOrLookupValue \| undefined>, onAct }` |
| `useFeatureFlag(name)` | `ComputedRef<boolean>` |
| `useRemoteConfig(name)` | A computed string, number or JSON object, inferred from config |
| `useFeatureFlags()` / `useRemoteConfigVariables()` | Computed arrays of `{ name, value }` |
| `getFeatureFlagValue(name)` / `getRemoteConfig(name)` | Nonreactive values outside component setup |
| `getABTestValue(name, lookup?)` | Current variant or mapped value; retains the assignment within this instance |
| `getVariants(name)` | The configured readonly variant tuple |
| `getABResetFunction(name)` | Removes the saved assignment; takes effect on the next page load |
| `updateUserProperties(properties)` | Validates a partial user update and refreshes targeting |
| `enableCookies()` / `disableCookies()` | Changes assignment consent; revocation removes assignment cookies |
| `loadProjectData()` | Explicit refresh, with concurrent fetches shared |
| `__abby__` | The underlying core instance, also used by devtools |
| `withDevtools(factory, props)` | Vue component that creates and destroys the existing devtools |

The provider accepts `initialData?: AbbyDataResponse`, `cookies?: string` (an SSR request's cookie header), and typed `userProperties`. With initial data it makes no initial network request. Otherwise it fetches centrally on mount. Configured defaults/fallbacks cover failed requests and absent flags/config variables. Updates to `initialData` or `userProperties` propagate to descendants. Provider teardown removes its subscription; unmounted consumers stop emitting events.

Cookie names and expiry follow core's existing conventions. Assignments stay stable in memory if the browser cannot persist cookies. Set `cookies: { disableByDefault: true }` to start without assignment cookies, then call `enableCookies()` after consent. This setting governs storage; it does not disable core's event transport. `disableCookies()` records the consent preference and removes assignment cookies. Flag/config overrides keep core semantics.

## SSR and Nuxt

**Create a fresh instance per app and per server request.** Do not share a mutable SDK instance in a server module. Reusing one instance across SSR apps raises an error instead of sharing user state. The input config can be shared; each instance copies the mutable test/consent configuration.

Pass the same fetched `initialData` to server rendering and hydration. Feature flags and remote config can render on the server; A/B content remains pending until client mount, avoiding a randomly selected hydration mismatch. Pass the request cookie header only to the server's provider when using existing assignments/overrides. Do not serialize an entire request cookie header into public HTML.

A Nuxt plugin is a suitable place to create the request-local instance:

For the typed server fetch below, also install core as a direct dependency: `pnpm add @tryabby/core`.

```ts
// plugins/abby.ts
import { createAbby } from "@tryabby/vue";
import config from "~/abby.config";

export default defineNuxtPlugin(() => ({
provide: { abby: createAbby(config) },
}));
```

```vue
<!-- app.vue -->
<script setup lang="ts">
import type { AbbyDataResponse } from "@tryabby/core";
const { $abby } = useNuxtApp();
const { AbbyProvider } = $abby;
const { data } = await useAsyncData("abby-project", () =>
$fetch<AbbyDataResponse>(
"https://www.tryabby.com/api/v2/data/YOUR_PROJECT_ID?environment=production"
)
);
const cookies = import.meta.server ? useRequestHeaders(["cookie"]).cookie : undefined;
</script>

<template>
<AbbyProvider :initial-data="data ?? undefined" :cookies="cookies">
<NuxtPage />
</AbbyProvider>
</template>
```

Descendants call `useNuxtApp().$abby.useFeatureFlag(...)`, and so on. This is a Nuxt usage recipe, not an auto-installed Nuxt module.

## Devtools

```ts
import Devtools from "@tryabby/devtools";
const AbbyDevtools = abby.withDevtools(Devtools, { defaultShow: true });
```

Render `<AbbyDevtools />` once. It initializes on mount in development and cleans up on unmount. `dangerouslyForceShow: true` explicitly enables it in other environments; do not expose it to production users by accident. The devtools package is optional and is not bundled with this SDK.

## Development and local demo

From the repository root, the test command builds core and this package before running the runtime and type checks:

```sh
pnpm --filter @tryabby/vue test
pnpm --filter @tryabby/vue demo:build
node packages/vue/demo/serve.mjs
```

Open `http://127.0.0.1:8766`. The demo uses the built package with a labelled local fixture, and shows PING/ACT events received by its local server. It does not contact the hosted Abby backend. Use “Target admin user” before manually toggling the flag, since a manual override takes precedence over targeting.
1 change: 1 addition & 0 deletions packages/vue/demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
build/
5 changes: 5 additions & 0 deletions packages/vue/demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Abby Vue integration demo</title></head>
<body><div id="app"></div><script type="module" src="/main.ts"></script></body>
</html>
154 changes: 154 additions & 0 deletions packages/vue/demo/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { AbbyDataResponse } from "@tryabby/core";
import { createAbby } from "@tryabby/vue";
import {
createApp,
defineComponent,
h,
onMounted,
onUnmounted,
ref,
} from "vue";
import "./style.css";

const fixture: AbbyDataResponse = {
tests: [{ name: "checkout", weights: [1, 0] }],
flags: [
{
name: "premium",
value: false,
ruleSet: [
{
propertyName: "role",
propertyType: "string",
operator: "eq",
value: "admin",
thenValue: true,
},
],
},
],
remoteConfig: [
{ name: "headline", value: "A small change. A clear result." },
],
};
const sdk = createAbby({
projectId: "vue-local-demo",
currentEnvironment: "demo",
environments: ["demo"],
apiUrl: "/",
tests: { checkout: { variants: ["original", "fresh"] } },
flags: ["premium"],
remoteConfig: { headline: "String" },
user: { role: { type: "string" } },
});
const Demo = defineComponent({
setup() {
const { variant, onAct } = sdk.useAbby("checkout");
const premium = sdk.useFeatureFlag("premium");
const headline = sdk.useRemoteConfig("headline");
const views = ref(0);
const acts = ref(0);
const targeting = ref("visitor");
const consent = ref(!sdk.__abby__.getConfig().cookies?.disableByDefault);
let timer: ReturnType<typeof setInterval>;
onMounted(() => {
const read = async () => {
const events = (await fetch("/events").then((response) =>
response.json()
)) as { type: number }[];
views.value = events.filter((event) => event.type === 0).length;
acts.value = events.filter((event) => event.type === 1).length;
};
void read();
timer = setInterval(() => {
void read();
}, 300);
});
onUnmounted(() => clearInterval(timer));
const button = (text: string, action: () => void) =>
h("button", { type: "button", onClick: action }, text);
return () =>
h("main", [
h("p", { class: "eyebrow" }, "ABBY / VUE 3"),
h("h1", "Vue integration, live."),
h(
"p",
{ class: "intro" },
"A/B testing, feature flags and remote config — reacting together."
),
h(
"p",
{ class: "fixture" },
"Local deterministic fixture · actual built SDK · no hosted Abby connection"
),
h("section", { class: "preview", "aria-label": "Live preview" }, [
h("div", { class: "topline" }, [
h("span", "CHECKOUT PREVIEW"),
h(
"span",
{ class: "pill" },
`Variant: ${variant.value ?? "pending"}`
),
]),
h("h2", headline.value),
h(
"p",
`Premium feature: ${premium.value ? "enabled" : "disabled"} · User role: ${targeting.value}`
),
h(
"button",
{ type: "button", class: `cta ${variant.value}`, onClick: onAct },
"Record conversion"
),
]),
h("section", { class: "controls", "aria-label": "SDK controls" }, [
button("Switch variant", () =>
sdk.__abby__.updateLocalVariant(
"checkout",
variant.value === "original" ? "fresh" : "original"
)
),
button("Toggle feature flag", () =>
sdk.__abby__.updateFlag("premium", !premium.value)
),
button("Change remote title", () =>
sdk.__abby__.updateRemoteConfig(
"headline",
"Updated remotely. Rendered immediately."
)
),
button("Target admin user", () => {
sdk.updateUserProperties({ role: "admin" });
targeting.value = "admin";
}),
button(
consent.value ? "Revoke cookie consent" : "Enable cookie consent",
() => {
consent.value = !consent.value;
if (consent.value) sdk.enableCookies();
else sdk.disableCookies();
}
),
]),
h("footer", [
h("div", [
h("strong", String(views.value)),
h("span", "View events"),
]),
h("div", [
h("strong", String(acts.value)),
h("span", "Conversion events"),
]),
h("div", [
h("strong", consent.value ? "On" : "Off"),
h("span", "Assignment cookies"),
]),
h("p", "Events shown here were received by this local demo server."),
]),
]);
},
});
createApp({
render: () =>
h(sdk.AbbyProvider, { initialData: fixture }, { default: () => h(Demo) }),
}).mount("#app");
Loading