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

Add a typed Vue integration backed by core with reactive composables, isolated providers, SSR-safe variant loading, cookie persistence and lifecycle cleanup.

Allow integrations to defer browser cookie overrides during initial-data hydration while retaining the default core initialization behavior.
6 changes: 5 additions & 1 deletion packages/core/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ export function parseCookies(cookieHeader: string) {
// biome-ignore lint/suspicious/noImplicitAnyLet:>
for (let m; (m = cookieRegexp.exec(cookieHeader)); ) {
const [, cookieName, cookieValue] = m;
cookies[decodeURIComponent(cookieName)] = decodeURIComponent(cookieValue);
try {
cookies[decodeURIComponent(cookieName)] = decodeURIComponent(cookieValue);
} catch {
// An unrelated malformed cookie must not disable all local overrides.
}
}

return cookies;
Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,17 @@ export class Abby<
* @param data
* @returns
*/
init(data: AbbyDataResponse) {
init(data: AbbyDataResponse, applyLocalOverrides = true) {
this.log("init()", data);

this.#data = this.responseToLocalData(data);
this.notifyListeners();

if (typeof window !== "undefined" && typeof document !== "undefined") {
if (
applyLocalOverrides &&
typeof window !== "undefined" &&
typeof document !== "undefined"
) {
this.setLocalOverrides(document.cookie);
}

Expand Down
14 changes: 14 additions & 0 deletions packages/core/tests/cookies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { parseCookies } from "../src/helpers";

describe("cookie parsing", () => {
it("skips malformed names and values without discarding valid neighbors", () => {
expect(
parseCookies(
"bad%=value; valid=hello%20world; broken=%E0%A4%A; final=yes"
)
).toEqual({
valid: "hello world",
final: "yes",
});
});
});
1 change: 1 addition & 0 deletions packages/vue/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
dist
79 changes: 79 additions & 0 deletions packages/vue/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# @tryabby/vue

Vue 3 composables backed by @tryabby/core.

import { createAbby } from "@tryabby/vue";
export const {
AbbyProvider, useAbby, useFeatureFlag, useRemoteConfig,
useAbbyClient, useAbbyStatus,
} = createAbby({
projectId: "your-project",
environments: ["production"],
currentEnvironment: "production",
tests: { heading: { variants: ["short", "long"] } },
flags: ["banner"],
remoteConfig: { title: "String", limit: "Number" },
});

Wrap the consuming component tree in the returned provider:

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

Inside a descendant's setup:

const { variant, onAct } = useAbby("heading");
const banner = useFeatureFlag("banner"); // ComputedRef<boolean>
const title = useRemoteConfig("title"); // ComputedRef<string>
const limit = useRemoteConfig("limit"); // ComputedRef<number>
const ready = useAbbyStatus(); // ComputedRef<boolean>
const client = useAbbyClient(); // typed core instance
const { variant: heading } = useAbby("heading", {
short: "Welcome",
long: "Welcome to the application",
});

Names accept a plain value, a Vue ref, or a getter. Changing a name changes the
selected value and the test name used by onAct. Variant names, feature flags
and remote config names are inferred from configuration; incomplete lookup
objects and unknown names are TypeScript errors.

The variant is undefined until the provider is mounted and data loading has
completed. This gives server rendering and the initial hydration render the
same placeholder. Use a loading branch in templates. Flags and remote configs
use core's configured defaults while loading. The core transport falls back
to defaults on fetch failure; unexpected provider loading errors are emitted
through the provider's error event.

An optional initialData provider prop accepts an AbbyDataResponse, skips the
initial HTTP request, and can be populated by a Nuxt useAsyncData call. Pass the
same response during server rendering and hydration. No Nuxt module is required
or supplied. Each provider owns its core instance and mutable configuration:
module-level createAbby calls do not share user state between SSR requests.

The provider subscribes once and unsubscribes on scope disposal. Cookie-backed
variant persistence uses core's project-specific storage keys and respects
cookies.disableByDefault and expiry options. The typed core instance exposes
the existing consent, user-property and local override APIs. Development-only
core behavior remains development-only. PING and ACT use the same transport as
the existing integrations, including its localhost suppression.

## Validation

From the workspace root:

pnpm --filter @tryabby/core build
pnpm --filter @tryabby/vue typecheck
pnpm --filter @tryabby/vue test
pnpm --filter @tryabby/vue build
pnpm --filter @tryabby/vue exec vite --host 127.0.0.1 --port 5181

Open /examples/ for the local reactive demo. It uses synthetic initial data;
it does not require a real Abby project or credentials. Tests cover the actual
core, Vue component lifecycle, events, reactive names, cookie consent,
provider isolation, SSR, and compile-time public types. No optional Vue
devtools panel or Nuxt plugin is included.
11 changes: 11 additions & 0 deletions packages/vue/examples/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Abby Vue</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#f5f7f5;color:#19241f;font:16px/1.5 system-ui}main{max-width:720px;margin:32px auto;padding:0 24px}header{display:flex;gap:12px;align-items:center;border-bottom:1px solid #cad4ce;padding-bottom:20px}header img{width:40px;height:40px}h1{font-size:26px;margin:0}section{padding:22px 0;border-bottom:1px solid #cad4ce}h2{font-size:17px;margin:0 0 12px}label{display:block;margin:10px 0}fieldset{border:0;padding:0;display:flex;gap:24px;margin:14px 0}fieldset label{margin:0}input[type=text],input[type=number]{display:block;padding:8px;width:100%;max-width:380px;border:1px solid #9fac9f;border-radius:4px;font:inherit}output{display:block;font-size:21px;font-weight:600;color:#1e634a}button{padding:8px 16px;border:1px solid #8d9d93;border-radius:4px;background:#fff;font:inherit;cursor:pointer}.flag{color:#5a3077}footer{margin-top:18px;color:#4c6256;font-size:14px}@media(max-width:480px){main{margin:20px auto;padding:0 18px}h1{font-size:24px}}
</style>
<main id="app"></main>
<script type="module" src="./main.ts"></script>
</html>
120 changes: 120 additions & 0 deletions packages/vue/examples/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { createApp, defineComponent, h } from "vue";
import icon from "../../../apps/web/public/favicon.png?url";
import { createAbby } from "../src/index";

const api = createAbby({
projectId: "vue-local-demo",
currentEnvironment: "development",
environments: ["development"],
tests: { heading: { variants: ["a", "b"] } },
flags: ["banner"],
remoteConfig: { title: "String", limit: "Number" },
cookies: { disableByDefault: true },
});
const initialData = {
tests: [{ name: "heading", weights: [1, 0] }],
flags: [{ name: "banner", value: true }],
remoteConfig: [
{ name: "title", value: "Welcome" },
{ name: "limit", value: 7 },
],
};
const Demo = defineComponent({
setup() {
const { variant, onAct } = api.useAbby("heading", {
a: "Alpha",
b: "Beta",
});
const flag = api.useFeatureFlag("banner");
const title = api.useRemoteConfig("title");
const limit = api.useRemoteConfig("limit");
const ready = api.useAbbyStatus();
const client = api.useAbbyClient();
return () => [
h("header", [h("img", { src: icon, alt: "Abby" }), h("h1", "Abby Vue")]),
h("section", [
h("h2", "Heading variant"),
h("output", { "data-testid": "variant" }, variant.value ?? ""),
h(
"fieldset",
{ "aria-label": "Heading variant" },
["a", "b"].map((value) =>
h("label", [
h("input", {
type: "radio",
name: "variant",
value,
checked: client.getTestVariant("heading") === value,
onChange: () =>
client.updateLocalVariant("heading", value as "a" | "b"),
}),
` ${value.toUpperCase()}`,
])
)
),
h("button", { onClick: onAct }, "ACT"),
]),
h("section", [
h("h2", "Feature flag"),
h("label", [
h("input", {
type: "checkbox",
checked: flag.value,
"aria-label": "Banner",
onChange: (event: Event) =>
client.updateFlag(
"banner",
(event.target as HTMLInputElement).checked
),
}),
" Banner",
]),
h(
"output",
{ class: "flag", "data-testid": "flag" },
flag.value ? "Enabled" : "Disabled"
),
]),
h("section", [
h("h2", "Remote configuration"),
h("label", [
"Title",
h("input", {
type: "text",
value: title.value,
"aria-label": "Title",
onInput: (event: Event) =>
client.updateRemoteConfig(
"title",
(event.target as HTMLInputElement).value
),
}),
]),
h("output", { "data-testid": "title" }, title.value),
h("label", [
"Limit",
h("input", {
type: "number",
value: limit.value,
"aria-label": "Limit",
onInput: (event: Event) =>
client.updateRemoteConfig(
"limit",
Number((event.target as HTMLInputElement).value)
),
}),
]),
h("output", { "data-testid": "limit" }, String(limit.value)),
]),
h(
"footer",
{ "data-testid": "ready" },
ready.value ? "Ready" : "Loading"
),
];
},
});
createApp({
render: () =>
h(api.AbbyProvider, { initialData }, { default: () => h(Demo) }),
}).mount("#app");
38 changes: 38 additions & 0 deletions packages/vue/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"name": "@tryabby/vue",
"version": "0.0.0",
"description": "Reactive Vue integration for Abby",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": ["dist"],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "pnpm --filter @tryabby/core build && tsup src/index.ts --dts --format cjs,esm --clean",
"test": "pnpm --filter @tryabby/core build && vitest run",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
"typecheck": "pnpm --filter @tryabby/core build && tsc --noEmit"
},
"license": "ISC",
"dependencies": {
"@tryabby/core": "workspace:*",
"js-cookie": "^3.0.1"
},
"peerDependencies": {
"vue": "^3.3.0"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
},
"devDependencies": {
"@types/js-cookie": "^3.0.3",
"jsdom": "^20.0.3",
"tsup": "^6.5.0",
"typescript": "5.5.4",
"vite": "5.4.0",
"vitest": "2.0.5",
"vue": "3.5.13"
}
}
Loading