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
10 changes: 10 additions & 0 deletions .changeset/vue-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@tryabby/core": patch
"@tryabby/vue": minor
---

Add Vue 3 composables for A/B tests, feature flags and remote configuration,
with typed lookups, server rendering, lifecycle cleanup and devtools support.

Declare zod as a core dependency so published TypeScript declarations resolve
in applications that install an integration without core's development tools.
4 changes: 3 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
"tsup": "^6.5.0",
"typescript": "5.5.4",
"vite": "5.4.0",
"vitest": "2.0.5",
"vitest": "2.0.5"
},
"dependencies": {
"zod": "^3.19.1"
}
}
1 change: 1 addition & 0 deletions packages/vue/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist/
231 changes: 231 additions & 0 deletions packages/vue/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
# @tryabby/vue

Vue 3 composables for Abby A/B tests, feature flags and remote configuration.
Uses `@tryabby/core` for data loading, targeting, defaults and variant selection.

## Configure

```ts
// abby.ts (a browser-only application)
import { createAbby } from "@tryabby/vue";

export const {
AbbyProvider,
useAbby,
useFeatureFlag,
useRemoteConfig,
} = createAbby({
projectId: "your-project-id",
environments: ["development", "production"],
currentEnvironment: "development",
tests: { checkout: { variants: ["classic", "compact"] } },
flags: ["banner"],
remoteConfig: { title: "String", limit: "Number" },
});
```

Wrap the components that consume these composables in the provider from the
**same factory call**:

```vue
<!-- App.vue -->
<script setup lang="ts">
import { AbbyProvider } from "./abby";
import Checkout from "./Checkout.vue";
</script>

<template>
<AbbyProvider><Checkout /></AbbyProvider>
</template>
```

```vue
<!-- Checkout.vue -->
<script setup lang="ts">
import { useAbby, useFeatureFlag, useRemoteConfig } from "./abby";

const { variant, onAct } = useAbby("checkout");
const banner = useFeatureFlag("banner");
const title = useRemoteConfig("title");
const limit = useRemoteConfig("limit");
</script>

<template>
<p v-if="banner">{{ title }} — up to {{ limit }} items</p>
<button v-if="variant" type="button" @click="onAct">
{{ variant === "compact" ? "Buy now" : "Continue to checkout" }}
</button>
</template>
```

The composables return read-only computed refs. Vue unwraps them in templates;
use `.value` in JavaScript. Call composables synchronously in component setup.
Their names are fixed for that component instance. Remount the consumer if its
configured key changes.

`variant.value` is `undefined` during SSR and before the initial client data has
loaded. It then narrows to the configured variants. An optional complete lookup
maps variants while preserving value types:

```ts
const { variant } = useAbby("checkout", { classic: "Continue", compact: "Buy now" });
// ComputedRef<"Continue" | "Buy now" | undefined>
```

Each mounted consumer reports an exposure when its selected variant changes.
`onAct()` reports a conversion for its current variant. Neither sends events
during SSR, before a variant exists, or after that consumer unmounts. Core's
existing localhost tracking suppression still applies.

## Initial data and SSR

`<AbbyProvider :initial-data="data">` accepts the core `AbbyDataResponse` shape
(`tests`, `flags`, `remoteConfig`) and skips its automatic fetch. Without initial
data, fetching begins on mount. The provider supports the same `apiUrl`, custom
`fetch`, environment and window bootstrap data as core. Network failures use
core's defaults. Core subscriptions update the refs and are removed on unmount;
an old provider's pending response cannot initialize its replacement.

**Create a new factory for each server request.** A factory owns mutable core
state, targeting properties and overrides. Do not export a server singleton or
share one between requests. Reusing an immutable configuration object to create
separate factories is supported. Use one provider per factory.

Pass the same initial data to server rendering and client hydration. Flags and
remote config render from that data; the variant remains absent until the client
mounts. Include an appropriate placeholder where the test will appear. Cookie
overrides and user targeting must also agree between server and client if they
affect server-rendered flags/config.

### Nuxt 4

Create the factory inside a universal Nuxt plugin: Nuxt runs it for each server
request and again for the client application. `useAsyncData` transfers only the
project data in the hydration payload; the factory and its mutable core state
stay in the current application. Do not give this plugin a `.client` suffix if
flags or remote configuration should render on the server.

```ts
// app/plugins/abby.ts
import type { AbbyDataResponse } from "@tryabby/core";
import { createAbby } from "@tryabby/vue";

export default defineNuxtPlugin(async () => {
const abby = createAbby({
projectId: "your-project-id",
environments: ["production"],
currentEnvironment: "production",
tests: { checkout: { variants: ["classic", "compact"] } },
flags: ["banner"],
remoteConfig: { title: "String", limit: "Number" },
});
const { data, error } = await useAsyncData("abby-initial", () =>
$fetch<AbbyDataResponse>("/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
<!-- app/app.vue -->
<script setup lang="ts">
const { $abby, $abbyInitialData } = useNuxtApp();
const { AbbyProvider } = $abby;
</script>

<template>
<AbbyProvider :initial-data="$abbyInitialData">
<NuxtPage />
</AbbyProvider>
</template>
```

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
<!-- app/pages/index.vue -->
<script setup lang="ts">
const { $abby } = useNuxtApp();
const banner = $abby.useFeatureFlag("banner");
const title = $abby.useRemoteConfig("title");
const { variant, onAct } = $abby.useAbby("checkout");
</script>

<template>
<p v-if="banner">{{ title }}</p>
<button v-if="variant" type="button" @click="onAct">{{ variant }}</button>
</template>
```

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 <AbbyDevtools /> 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.
37 changes: 37 additions & 0 deletions packages/vue/example/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Abby · Vue integration</title>
<link rel="icon" href="data:," />
<style>
:root { color-scheme: light; font-family: system-ui, sans-serif; color: #19312d; background: #fafbf7; }
body { max-width: 760px; margin: 56px auto; padding: 0 24px; }
h1 { font-size: clamp(28px, 5vw, 40px); letter-spacing: -.04em; margin-bottom: 12px; }
p { line-height: 1.6; }
table { width: 100%; border-collapse: collapse; margin: 28px 0; text-align: left; }
th, td { border-bottom: 1px solid #c8d0c8; padding: 16px 8px; }
th { width: 55%; }
code { overflow-wrap: anywhere; font-size: .9em; }
button { font: inherit; cursor: pointer; padding: 11px 16px; color: inherit; background: white; border: 1px solid #677a72; border-radius: 4px; }
button:hover { background: #eaf1e9; }
button:active { background: #dce8da; }
button:focus-visible { outline: 3px solid #186b55; outline-offset: 3px; }
button:disabled { cursor: wait; opacity: .6; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; }
#message { min-height: 28px; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #eef2eb; padding: 20px; }
small { color: #4c6159; }
@media (max-width: 480px) { body { margin-top: 24px; padding: 0 16px; } th, td { padding: 12px 4px; } th { width: 65%; } }
</style>
</head>
<body>
<h1>Abby for Vue</h1>
<p>A local example of feature flags, remote configuration and A/B conversion events.</p>
<small>All data is synthetic. Requests stay on this local server.</small>
<div id="app"></div>
<noscript><p>Enable JavaScript to run the Vue example.</p></noscript>
<script type="module" src="/main.ts"></script>
</body>
</html>
91 changes: 91 additions & 0 deletions packages/vue/example/main.ts
Original file line number Diff line number Diff line change
@@ -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"
);
Loading