+
+
+```
+
+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
+
+`` 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.
+
+For Nuxt, create the factory inside the per-request Nuxt plugin and provide its
+return value through the application. Render its `AbbyProvider` around the
+consumers, and transfer initial data using Nuxt's normal SSR data facilities.
+This package does not install a Nuxt module or global 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.
+
+### 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.
");
+ 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
From 106164871ad6fa558af6e3cca9c6f634f77004f1 Mon Sep 17 00:00:00 2001
From: Gavin-Yau <2695188238@qq.com>
Date: Sat, 26 Sep 2026 23:12:00 +0800
Subject: [PATCH 2/4] Refresh Vue data after child mount hooks
---
packages/vue/src/index.ts | 6 +++-
packages/vue/tests/composables.test.ts | 39 ++++++++++++++++++++++++++
2 files changed, 44 insertions(+), 1 deletion(-)
diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts
index 5cf16794..f65716fe 100644
--- a/packages/vue/src/index.ts
+++ b/packages/vue/src/index.ts
@@ -129,7 +129,11 @@ export function createAbby<
unsubscribe = abby.subscribe(() => {
context.data.value = abby.getProjectData();
});
- if (props.initialData) return;
+ 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 =
diff --git a/packages/vue/tests/composables.test.ts b/packages/vue/tests/composables.test.ts
index 85eb20de..77650e29 100644
--- a/packages/vue/tests/composables.test.ts
+++ b/packages/vue/tests/composables.test.ts
@@ -11,6 +11,7 @@ import {
defineComponent,
h,
nextTick,
+ onMounted,
shallowRef,
} from "vue";
import { renderToString } from "vue/server-renderer";
@@ -471,3 +472,41 @@ it("respects disabled cookies and applies typed user targeting updates", async (
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");
+});
From 8566ea7835f665e267fa66495441fc5e4214e434 Mon Sep 17 00:00:00 2001
From: Gavin-Yau <2695188238@qq.com>
Date: Sat, 26 Sep 2026 23:39:37 +0800
Subject: [PATCH 3/4] fix(vue): restore opt-in cookies on provider remount
---
packages/vue/README.md | 6 ++
packages/vue/src/index.ts | 4 ++
packages/vue/tests/composables.test.ts | 80 +++++++++++++++++++++++++-
3 files changed, 88 insertions(+), 2 deletions(-)
diff --git a/packages/vue/README.md b/packages/vue/README.md
index 548655f4..9fee82fd 100644
--- a/packages/vue/README.md
+++ b/packages/vue/README.md
@@ -116,6 +116,12 @@ 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
diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts
index f65716fe..0979cbac 100644
--- a/packages/vue/src/index.ts
+++ b/packages/vue/src/index.ts
@@ -58,6 +58,7 @@ export function createAbby<
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 = {
@@ -118,6 +119,9 @@ export function createAbby<
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);
}
diff --git a/packages/vue/tests/composables.test.ts b/packages/vue/tests/composables.test.ts
index 77650e29..ec87e858 100644
--- a/packages/vue/tests/composables.test.ts
+++ b/packages/vue/tests/composables.test.ts
@@ -25,7 +25,9 @@ const initialData: AbbyDataResponse = {
{ name: "limit", value: 3 },
],
};
-function configure() {
+function configure(
+ cookies: { disableByDefault?: boolean } = { disableByDefault: true }
+) {
return createAbby({
projectId: "vue-tests",
apiUrl: "http://127.0.0.1:9876/",
@@ -34,7 +36,7 @@ function configure() {
tests: { checkout: { variants: ["old", "new"] } },
flags: ["banner", "missing"],
remoteConfig: { title: "String", limit: "Number", missing: "String" },
- cookies: { disableByDefault: true },
+ cookies,
settings: {
flags: { fallbackValues: { missing: true } },
remoteConfig: { defaultValues: { String: "Default" } },
@@ -445,6 +447,80 @@ it.each([false, true])(
}
);
+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 = {
From 54372c5de288f7a411197e0050ed9ec56909dac3 Mon Sep 17 00:00:00 2001
From: Qiu Guanzong <283098231+QIU-Guanzong@users.noreply.github.com>
Date: Sun, 27 Sep 2026 14:39:04 +0800
Subject: [PATCH 4/4] docs(vue): document verified Nuxt SSR integration
---
packages/vue/README.md | 78 +++++++++++++++++++++++++++++++++++++++---
1 file changed, 74 insertions(+), 4 deletions(-)
diff --git a/packages/vue/README.md b/packages/vue/README.md
index 9fee82fd..4cdd7289 100644
--- a/packages/vue/README.md
+++ b/packages/vue/README.md
@@ -97,10 +97,80 @@ 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.
-For Nuxt, create the factory inside the per-request Nuxt plugin and provide its
-return value through the application. Render its `AbbyProvider` around the
-consumers, and transfer initial data using Nuxt's normal SSR data facilities.
-This package does not install a Nuxt module or global auto-imports.
+### 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("/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 }}
+
+
+```
+
+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