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
1 change: 1 addition & 0 deletions packages/vue/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
108 changes: 108 additions & 0 deletions packages/vue/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# `@tryabby/vue`

Vue 3 integration for [Abby](https://docs.tryabby.com) - A/B testing, feature flags and remote config.

Mirrors `@tryabby/react`: the same `createAbby` factory, the same type-level
quality, and the same runtime behaviour (including rendering an empty variant
on the first pass so SSR output never mismatches the server).

## Installation

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

`vue@^3` is a peer dependency.

## Usage

```ts
import { createAbby } from "@tryabby/vue";

export const {
AbbyProvider,
useAbby,
useFeatureFlag,
useFeatureFlags,
useRemoteConfig,
useRemoteConfigVariables,
getABTestValue,
getFeatureFlagValue,
getRemoteConfig,
getABResetFunction,
getVariants,
updateUserProperties,
} = createAbby({
projectId: "YOUR_PROJECT_ID",
environments: ["production"],
currentEnvironment: "production",
tests: {
footer: { variants: ["old", "new"] },
},
flags: ["newCheckout"],
remoteConfig: {
welcomeMessage: "String",
},
});
```

Wrap your app once (preferably with `initialData` fetched on the server):

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

<template>
<AbbyProvider :initialData="initialData">
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<App />
</AbbyProvider>
</template>
```

Use the composables in any descendant component (all reactive `computed` refs):

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

const { variant, onAct } = useAbby("footer");
const newCheckout = useFeatureFlag("newCheckout");
const welcomeMessage = useRemoteConfig("welcomeMessage");
</script>

<template>
<footer v-if="variant === 'new'" @click="onAct">new footer: {{ welcomeMessage }}</footer>
<footer v-else @click="onAct">old footer</footer>
<CheckoutV2 v-if="newCheckout" />
</template>
```

Plain (non-reactive) getters - `getABTestValue`, `getFeatureFlagValue`,
`getRemoteConfig`, `getVariants`, `getABResetFunction` - work anywhere,
including outside components.

## SSR / Nuxt notes

- `variant` starts as `""` on the very first render (the server never runs
`onMounted`, so no variant is selected yet). Without a lookup object that
empty string is the value; with a lookup object lacking a `""` key the
value is `undefined` until the client resolves. Guard with `v-if="variant"`.
- One `createAbby()` result holds mutable project data on its core instance.
On servers handling concurrent requests (Nuxt SSR), call `createAbby()`
per request (e.g. inside a request-scoped plugin) instead of sharing one
module-level instance, so request A passing `initialData` can never leak
into request B that omits it. SPA / client-only usage can share one
module-level instance (same as `@tryabby/react`).

## API parity with `@tryabby/react`

| React | Vue |
| --- | --- |
| `AbbyProvider` (`initialData` prop) | `AbbyProvider` (`initialData` prop) |
| `useAbby(name, lookup?)` ? `{ variant, onAct }` | `useAbby(name, lookup?)` ? `{ variant: ComputedRef, onAct }` |
| `useFeatureFlag(name)` | `useFeatureFlag(name)` ? `ComputedRef<boolean>` |
| `useFeatureFlags()` | `useFeatureFlags()` ? `ComputedRef<Array<...>>` |
| `useRemoteConfig(name)` | `useRemoteConfig(name)` ? `ComputedRef<...>` |
| `useRemoteConfigVariables()` | `useRemoteConfigVariables()` ? `ComputedRef<Array<...>>` |
| `withDevtools(factory, props)` ? hook | `withDevtools(factory, props)` ? component |
Binary file added packages/vue/demo/browser-demo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
63 changes: 63 additions & 0 deletions packages/vue/demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>@tryabby/vue demo (issue #68)</title>
<style>body{font-family:system-ui,sans-serif;max-width:640px;margin:40px auto;padding:0 16px} .card{border:1px solid #ddd;border-radius:8px;padding:16px;margin:12px 0} button{padding:8px 16px;cursor:pointer}</style>
<script type="importmap">
{"imports": {
"vue": "/packages/vue/node_modules/vue/dist/vue.esm-bundler.js",
"@tryabby/vue": "/packages/vue/dist/index.mjs",
"@tryabby/core": "/packages/core/dist/index.mjs",
"js-cookie": "/packages/vue/node_modules/js-cookie/dist/js.cookie.mjs",
"@vue/runtime-dom": "/node_modules/.pnpm/@[email protected]/node_modules/@vue/runtime-dom/dist/runtime-dom.esm-bundler.js",
"@vue/runtime-core": "/node_modules/.pnpm/@[email protected]/node_modules/@vue/runtime-core/dist/runtime-core.esm-bundler.js",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"@vue/shared": "/node_modules/.pnpm/@[email protected]/node_modules/@vue/shared/dist/shared.esm-bundler.js",
"@vue/reactivity": "/node_modules/.pnpm/@[email protected]/node_modules/@vue/reactivity/dist/reactivity.esm-bundler.js",
"@vue/compiler-dom": "/node_modules/.pnpm/@[email protected]/node_modules/@vue/compiler-dom/dist/compiler-dom.esm-bundler.js",
"@vue/compiler-core": "/node_modules/.pnpm/@[email protected]/node_modules/@vue/compiler-core/dist/compiler-core.esm-bundler.js"
}}
</script>
</head>
<body>
<h1>@tryabby/vue demo</h1>
<div id="app">loading…</div>
<script>window.process = window.process || { env: { NODE_ENV: 'development' } };</script>
<script type="module">
import { createApp, h } from 'vue';
import { createAbby } from '@tryabby/vue';

const { AbbyProvider, useAbby, useFeatureFlag, useRemoteConfig } = createAbby({
environments: [''],
currentEnvironment: '',
projectId: 'demo',
tests: { footer: { variants: ['OldFooter', 'NewFooter'] } },
flags: ['newHeader'],
remoteConfig: { welcomeText: 'String' },
});

// deterministic: weights [0,1] always select NewFooter
const initialData = {
tests: [{ name: 'footer', weights: [0, 1] }],
flags: [{ name: 'newHeader', value: true }],
remoteConfig: [{ name: 'welcomeText', value: 'Hello from Abby Vue!' }],
};

const Demo = {
setup() {
const { variant, onAct } = useAbby('footer');
const header = useFeatureFlag('newHeader');
const welcome = useRemoteConfig('welcomeText');
return () => h('div', [
h('div', { class: 'card' }, ['useAbby("footer") → variant: ', h('b', String(variant.value))]),
h('div', { class: 'card' }, ['useFeatureFlag("newHeader") → ', h('b', String(header.value))]),
h('div', { class: 'card' }, ['useRemoteConfig("welcomeText") → ', h('b', String(welcome.value))]),
h('button', { onClick: onAct }, 'onAct() (sends ACT ping)'),
]);
},
};

createApp({ render: () => h(AbbyProvider, { initialData }, { default: () => h(Demo) }) }).mount('#app');
</script>
</body>
</html>
109 changes: 109 additions & 0 deletions packages/vue/demo/ssr-demo.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// packages/vue/demo/ssr-demo.cjs — plain Node CJS SSR demo for @tryabby/vue (issue #68).
// No TypeScript, no browser, no ESM import maps. Run from abby repo root:
// node packages/vue/demo/ssr-demo.cjs
// Requires the BUILT packages/vue/dist/index.js (CJS) + vue CJS + @vue/server-renderer.
const path = require("path");
const fs = require("fs");

// 1) Vue CJS: require('vue') resolves to vue/dist/vue.cjs.js via its package.json.
// Fallback to the explicit path inside packages/vue/node_modules.
let vue;
try {
vue = require("vue");
} catch (e) {
vue = require(
path.join(__dirname, "..", "node_modules", "vue", "dist", "vue.cjs.js")
);
}
const { createSSRApp, h } = vue;

// 2) Server renderer (CJS)
let serverRenderer;
try {
serverRenderer = require("@vue/server-renderer");
} catch (e) {
serverRenderer = require(
path.join(
__dirname,
"..",
"node_modules",
"@vue",
"server-renderer",
"dist",
"server-renderer.cjs.js"
)
);
}
const { renderToString } = serverRenderer;

// 3) Built @tryabby/vue CJS bundle
const { createAbby } = require("../dist/index.js");

// 4) Same demo config as the browser demo (demo/index.html)
const { AbbyProvider, useAbby, useFeatureFlag, useRemoteConfig } = createAbby({
environments: [""],
currentEnvironment: "",
projectId: "demo",
tests: { footer: { variants: ["OldFooter", "NewFooter"] } },
flags: ["newHeader"],
remoteConfig: { welcomeText: "String" },
});

// Deterministic: weights [0,1] always select NewFooter on the client.
// NOTE: useAbby renders "" on the server by design (SSR mismatch guard;
// onMounted never runs during SSR). Flags + remoteConfig DO render on SSR.
const initialData = {
tests: [{ name: "footer", weights: [0, 1] }],
flags: [{ name: "newHeader", value: true }],
remoteConfig: [{ name: "welcomeText", value: "Hello from Abby Vue!" }],
};

// Child component with h() only (NO templates)
const Demo = {
setup() {
const { variant, onAct } = useAbby("footer");
const header = useFeatureFlag("newHeader");
const welcome = useRemoteConfig("welcomeText");
void onAct;
return () =>
h("div", [
h("div", { class: "card" }, [
'useAbby("footer") -> variant: ',
h("b", { id: "variant" }, String(variant.value)),
]),
h("div", { class: "card" }, [
'useFeatureFlag("newHeader") -> ',
h("b", { id: "flag" }, String(header.value)),
]),
h("div", { class: "card" }, [
'useRemoteConfig("welcomeText") -> ',
h("b", { id: "welcome" }, String(welcome.value)),
]),
]);
},
};

async function main() {
const app = createSSRApp({
render: () =>
h(AbbyProvider, { initialData }, { default: () => h(Demo) }),
});
const html = await renderToString(app);
console.log(html);
const doc =
"<!DOCTYPE html>\n" +
'<html lang="en">\n<head>\n<meta charset="utf-8" />\n' +
"<title>@tryabby/vue SSR demo (issue #68)</title>\n" +
"<style>body{font-family:system-ui,sans-serif;max-width:640px;margin:40px auto;padding:0 16px} .card{border:1px solid #ddd;border-radius:8px;padding:16px;margin:12px 0}</style>\n" +
"</head>\n<body>\n<h1>@tryabby/vue SSR demo</h1>\n" +
'<div id="app">' +
html +
"</div>\n</body>\n</html>\n";
fs.writeFileSync(path.join(__dirname, "ssr-output.html"), doc, "utf8");
console.log("[ssr-demo] wrote " + path.join(__dirname, "ssr-output.html"));
}

main().catch((err) => {
console.error("[ssr-demo] FAILED:", err && err.stack ? err.stack : err);
process.exit(1);
});
12 changes: 12 additions & 0 deletions packages/vue/demo/ssr-output.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>@tryabby/vue SSR demo (issue #68)</title>
<style>body{font-family:system-ui,sans-serif;max-width:640px;margin:40px auto;padding:0 16px} .card{border:1px solid #ddd;border-radius:8px;padding:16px;margin:12px 0}</style>
</head>
<body>
<h1>@tryabby/vue SSR demo</h1>
<div id="app"><!--[--><div><div class="card">useAbby(&quot;footer&quot;) -&gt; variant: <b id="variant"></b></div><div class="card">useFeatureFlag(&quot;newHeader&quot;) -&gt; <b id="flag">true</b></div><div class="card">useRemoteConfig(&quot;welcomeText&quot;) -&gt; <b id="welcome">Hello from Abby Vue!</b></div></div><!--]--></div>
</body>
</html>
49 changes: 49 additions & 0 deletions packages/vue/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"name": "@tryabby/vue",
"version": "7.0.1",
"description": "Vue 3 integration for Abby (A/B testing, feature flags and remote config)",
"main": "dist/index.js",
"files": ["dist"],
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsup src/",
"dev": "pnpm run build --watch",
"test": "vitest"
},
"homepage": "https://docs.tryabby.com",
"keywords": ["abby", "vue", "ab-testing", "feature-flags", "remote-config"],
"author": "",
"license": "ISC",
"devDependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@tryabby/devtools": "workspace:*",
"@types/js-cookie": "^3.0.3",
"@vue/server-renderer": "^3.4.0",
"@vue/test-utils": "^2.5.0",
"js-cookie": "^3.0.1",
"jsdom": "^20.0.3",
"msw": "^0.49.1",
"node-fetch": "^3.3.0",
"tsconfig": "workspace:*",
"tsup": "^6.5.0",
"typescript": "5.5.4",
"vite": "5.4.0",
"vitest": "2.0.5",
"vue": "^3.4.0"
},
"peerDependencies": {
"vue": "^3.0.0"
},
"dependencies": {
"@tryabby/core": "workspace:*",
"js-cookie": "^3.0.1"
}
}
Loading