From b39e391a5e29dbb041df1c13db2d8b5013087667 Mon Sep 17 00:00:00 2001 From: liuhongming Date: Mon, 2 Nov 2026 06:45:12 +0800 Subject: [PATCH 1/3] feat(vue): add reactive core-backed integration with isolated providers --- .changeset/kind-vue-signals.md | 5 + packages/vue/.gitignore | 1 + packages/vue/README.md | 79 ++++++ packages/vue/examples/index.html | 11 + packages/vue/examples/main.ts | 120 +++++++++ packages/vue/package.json | 38 +++ packages/vue/src/index.ts | 216 ++++++++++++++++ packages/vue/src/tests/integration.test.ts | 271 +++++++++++++++++++++ packages/vue/src/tests/public-types.ts | 32 +++ packages/vue/src/tests/ssr.test.ts | 50 ++++ packages/vue/tsconfig.json | 13 + packages/vue/vite.config.ts | 9 + pnpm-lock.yaml | 194 +++++++++++++++ 13 files changed, 1039 insertions(+) create mode 100644 .changeset/kind-vue-signals.md create mode 100644 packages/vue/.gitignore create mode 100644 packages/vue/README.md create mode 100644 packages/vue/examples/index.html create mode 100644 packages/vue/examples/main.ts create mode 100644 packages/vue/package.json create mode 100644 packages/vue/src/index.ts create mode 100644 packages/vue/src/tests/integration.test.ts create mode 100644 packages/vue/src/tests/public-types.ts create mode 100644 packages/vue/src/tests/ssr.test.ts create mode 100644 packages/vue/tsconfig.json create mode 100644 packages/vue/vite.config.ts diff --git a/.changeset/kind-vue-signals.md b/.changeset/kind-vue-signals.md new file mode 100644 index 00000000..a7dff99d --- /dev/null +++ b/.changeset/kind-vue-signals.md @@ -0,0 +1,5 @@ +--- +"@tryabby/vue": minor +--- + +Add a typed Vue integration backed by core with reactive composables, isolated providers, SSR-safe variant loading, cookie persistence and lifecycle cleanup. diff --git a/packages/vue/.gitignore b/packages/vue/.gitignore new file mode 100644 index 00000000..1521c8b7 --- /dev/null +++ b/packages/vue/.gitignore @@ -0,0 +1 @@ +dist diff --git a/packages/vue/README.md b/packages/vue/README.md new file mode 100644 index 00000000..0193e930 --- /dev/null +++ b/packages/vue/README.md @@ -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: + + + + +Inside a descendant's setup: + + const { variant, onAct } = useAbby("heading"); + const banner = useFeatureFlag("banner"); // ComputedRef + const title = useRemoteConfig("title"); // ComputedRef + const limit = useRemoteConfig("limit"); // ComputedRef + const ready = useAbbyStatus(); // ComputedRef + 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. diff --git a/packages/vue/examples/index.html b/packages/vue/examples/index.html new file mode 100644 index 00000000..43b5140e --- /dev/null +++ b/packages/vue/examples/index.html @@ -0,0 +1,11 @@ + + + + +Abby Vue + +
+ + diff --git a/packages/vue/examples/main.ts b/packages/vue/examples/main.ts new file mode 100644 index 00000000..db104904 --- /dev/null +++ b/packages/vue/examples/main.ts @@ -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"); diff --git a/packages/vue/package.json b/packages/vue/package.json new file mode 100644 index 00000000..1aaeef9a --- /dev/null +++ b/packages/vue/package.json @@ -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": "tsup src/index.ts --dts --format cjs,esm --clean", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "license": "ISC", + "dependencies": { + "@tryabby/core": "workspace:*", + "js-cookie": "^3.0.1" + }, + "peerDependencies": { + "vue": "^3.3.0" + }, + "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" + } +} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts new file mode 100644 index 00000000..85a73f44 --- /dev/null +++ b/packages/vue/src/index.ts @@ -0,0 +1,216 @@ +import { + type ABConfig, + Abby, + type AbbyConfig, + type AbbyDataResponse, + AbbyEventType, + HttpService, + type RemoteConfigValueString, + type RemoteConfigValueStringToType, + type StorageServiceOptions, + getABStorageKey, + getFFStorageKey, + getRCStorageKey, +} from "@tryabby/core"; +import Cookie from "js-cookie"; +import { + type ComputedRef, + type InjectionKey, + type MaybeRefOrGetter, + type PropType, + computed, + defineComponent, + inject, + onMounted, + onScopeDispose, + provide, + shallowRef, + toValue, + watch, +} from "vue"; + +export { type ABConfig, type AbbyConfig, defineConfig } from "@tryabby/core"; + +export type ABTestReturnValue = Lookup extends undefined + ? Variant + : Variant extends keyof Lookup + ? Lookup[Variant] + : never; + +export function createAbby< + const FlagName extends string, + const TestName extends string, + const Tests extends Record, + const RemoteConfig extends Record, + const RemoteConfigName extends Extract, +>( + config: AbbyConfig +) { + type Client = Abby; + type Context = { + client: Client; + revision: { value: number }; + ready: ComputedRef; + }; + const key: InjectionKey = Symbol("abby"); + + const storage = (makeKey: (project: string, name: string) => string) => ({ + get: (name: string) => + typeof document === "undefined" + ? null + : (Cookie.get(makeKey(config.projectId, name)) ?? null), + set: (name: string, value: string, options?: StorageServiceOptions) => { + if (typeof document === "undefined") return; + Cookie.set(makeKey(config.projectId, name), value, { + expires: options?.expiresInDays ?? config.cookies?.expiresInDays ?? 365, + sameSite: "lax", + }); + }, + }); + + const AbbyProvider = defineComponent({ + name: "AbbyProvider", + props: { + initialData: Object as PropType, + }, + emits: ["error"], + setup(props, { slots, emit }) { + // Core adds weights and cookie-consent state to its config; do not share that across requests. + const localConfig = { + ...config, + tests: Object.fromEntries( + (Object.entries(config.tests ?? {}) as Array<[string, ABConfig]>).map( + ([name, test]) => [name, { ...test, variants: [...test.variants] }] + ) + ) as unknown as Tests, + flags: config.flags ? [...config.flags] : undefined, + remoteConfig: config.remoteConfig + ? { ...config.remoteConfig } + : undefined, + cookies: config.cookies ? { ...config.cookies } : undefined, + }; + const client = new Abby< + FlagName, + TestName, + Tests, + RemoteConfig, + RemoteConfigName + >( + localConfig, + storage(getABStorageKey), + storage(getFFStorageKey), + storage(getRCStorageKey) + ); + const revision = shallowRef(0); + const mounted = shallowRef(false); + const loaded = shallowRef(Boolean(props.initialData)); + if (props.initialData) client.init(props.initialData); + if (typeof document !== "undefined") + client.setLocalOverrides(document.cookie); + const unsubscribe = client.subscribe(() => { + revision.value += 1; + }); + onScopeDispose(unsubscribe); + provide(key, { + client, + revision, + ready: computed(() => mounted.value && loaded.value), + }); + onMounted(async () => { + mounted.value = true; + if (props.initialData) return; + try { + await client.loadProjectData(); + loaded.value = true; + } catch (error) { + emit("error", error); + } + }); + return () => slots.default?.(); + }, + }); + + const context = () => { + const value = inject(key, null); + if (!value) + throw new Error( + "Abby composables must be used inside this createAbby's AbbyProvider." + ); + return value; + }; + + const useAbby = < + Name extends keyof Tests, + const Lookup extends + | Record + | undefined = undefined, + >( + name: MaybeRefOrGetter, + lookup?: Lookup + ) => { + const state = context(); + const selected = computed(() => { + state.revision.value; + return state.ready.value + ? state.client.getTestVariant(toValue(name)) + : undefined; + }); + const notify = (type: AbbyEventType) => { + if (selected.value === undefined) return; + return HttpService.sendData({ + url: config.apiUrl, + type, + data: { + projectId: config.projectId, + testName: String(toValue(name)), + selectedVariant: selected.value, + }, + }); + }; + watch( + [() => toValue(name), selected], + () => { + notify(AbbyEventType.PING); + }, + { immediate: true, flush: "post" } + ); + const variant = computed(() => { + const value = selected.value; + if (value === undefined) return undefined; + return (lookup ? lookup[value] : value) as ABTestReturnValue< + Lookup, + Tests[Name]["variants"][number] + >; + }); + return { variant, onAct: () => notify(AbbyEventType.ACT) }; + }; + + const useFeatureFlag = (name: MaybeRefOrGetter) => { + const state = context(); + return computed(() => { + state.revision.value; + return state.client.getFeatureFlag(toValue(name)); + }); + }; + + const useRemoteConfig = ( + name: MaybeRefOrGetter + ) => { + const state = context(); + return computed(() => { + state.revision.value; + return state.client.getRemoteConfig( + toValue(name) + ) as RemoteConfigValueStringToType; + }); + }; + + return { + AbbyProvider, + useAbby, + useFeatureFlag, + useRemoteConfig, + useAbbyClient: () => context().client, + useAbbyStatus: () => context().ready, + }; +} diff --git a/packages/vue/src/tests/integration.test.ts b/packages/vue/src/tests/integration.test.ts new file mode 100644 index 00000000..fe1ca396 --- /dev/null +++ b/packages/vue/src/tests/integration.test.ts @@ -0,0 +1,271 @@ +import { + Abby, + AbbyEventType, + HttpService, + getABStorageKey, +} from "@tryabby/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createApp, + createSSRApp, + defineComponent, + h, + nextTick, + ref, +} from "vue"; +import { renderToString } from "vue/server-renderer"; +import { createAbby } from "../index"; + +const make = () => + createAbby({ + projectId: "vue-tests", + apiUrl: "http://localhost/", + currentEnvironment: "production", + environments: ["production"], + tests: { + heading: { variants: ["a", "b"] }, + button: { variants: ["a", "b"] }, + }, + flags: ["banner"], + remoteConfig: { title: "String", limit: "Number", options: "JSON" }, + cookies: { disableByDefault: true }, + }); +const data = () => ({ + tests: [ + { name: "heading", weights: [1, 0] }, + { name: "button", weights: [0, 1] }, + ], + flags: [{ name: "banner", value: true }], + remoteConfig: [ + { name: "title", value: "Hello" }, + { name: "limit", value: 7 }, + { name: "options", value: { enabled: true } }, + ], +}); +const cleanup: Array<() => void> = []; +function mount( + api: ReturnType, + use: () => T, + initialData: ReturnType | null = data() +) { + let value!: T; + const child = defineComponent({ + setup() { + value = use(); + return () => h("span"); + }, + }); + const root = document.createElement("div"); + document.body.append(root); + const app = createApp({ + render: () => + h( + api.AbbyProvider, + { initialData: initialData ?? undefined }, + { default: () => h(child) } + ), + }); + app.mount(root); + const unmount = () => { + app.unmount(); + root.remove(); + }; + cleanup.push(unmount); + return { + get value() { + return value; + }, + unmount, + }; +} +beforeEach(() => { + for (const cookie of document.cookie.split(";")) { + document.cookie = `${cookie.split("=")[0]}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`; + } +}); +afterEach(() => { + cleanup.splice(0).forEach((dispose) => dispose()); + vi.restoreAllMocks(); +}); + +describe("Vue integration with actual core", () => { + it("hydrates server markup before exposing the client variant", async () => { + const api = make(); + const child = defineComponent({ + setup() { + const { variant } = api.useAbby("heading"); + return () => h("span", variant.value ?? "pending"); + }, + }); + const component = { + render: () => + h( + api.AbbyProvider, + { initialData: data() }, + { default: () => h(child) } + ), + }; + const root = document.createElement("div"); + root.innerHTML = await renderToString(createSSRApp(component)); + expect(root.textContent).toBe("pending"); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const app = createSSRApp(component); + app.mount(root); + cleanup.push(() => app.unmount()); + await nextTick(); + expect(root.textContent).toBe("a"); + expect(error).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + }); + + it("uses core defaults if the project fetch fails", async () => { + vi.spyOn(HttpService, "getProjectData").mockResolvedValue(null); + const api = make(); + const mounted = mount( + api, + () => ({ + ready: api.useAbbyStatus(), + test: api.useAbby("heading"), + flag: api.useFeatureFlag("banner"), + }), + null + ); + await vi.waitFor(() => expect(mounted.value.ready.value).toBe(true)); + expect(mounted.value.test.variant.value).toBe("a"); + expect(mounted.value.flag.value).toBe(false); + }); + + it("reacts to variants, flags and typed remote configuration", async () => { + const api = make(); + const mounted = mount(api, () => ({ + test: api.useAbby("heading"), + flag: api.useFeatureFlag("banner"), + title: api.useRemoteConfig("title"), + limit: api.useRemoteConfig("limit"), + options: api.useRemoteConfig("options"), + client: api.useAbbyClient(), + })); + await nextTick(); + expect(mounted.value.test.variant.value).toBe("a"); + expect(mounted.value.flag.value).toBe(true); + expect(mounted.value.limit.value).toBe(7); + expect(mounted.value.options.value).toEqual({ enabled: true }); + mounted.value.client.init({ + ...data(), + tests: [ + { name: "heading", weights: [0, 1] }, + { name: "button", weights: [0, 1] }, + ], + }); + mounted.value.client.updateFlag("banner", false); + mounted.value.client.updateRemoteConfig("title", "Updated"); + await nextTick(); + expect(mounted.value.test.variant.value).toBe("b"); + expect(mounted.value.flag.value).toBe(false); + expect(mounted.value.title.value).toBe("Updated"); + }); + + it("tracks current names and raw variants, not lookup values", async () => { + const send = vi + .spyOn(HttpService, "sendData") + .mockImplementation(() => undefined); + const api = make(); + const name = ref<"heading" | "button">("heading"); + const mounted = mount(api, () => + api.useAbby(name, { a: "Alpha", b: "Beta" }) + ); + await nextTick(); + expect(mounted.value.variant.value).toBe("Alpha"); + mounted.value.onAct(); + expect(send).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: AbbyEventType.ACT, + data: expect.objectContaining({ + testName: "heading", + selectedVariant: "a", + }), + }) + ); + name.value = "button"; + await nextTick(); + expect(mounted.value.variant.value).toBe("Beta"); + mounted.value.onAct(); + expect(send).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: AbbyEventType.ACT, + data: expect.objectContaining({ + testName: "button", + selectedVariant: "b", + }), + }) + ); + expect( + send.mock.calls.some(([event]) => event.type === AbbyEventType.PING) + ).toBe(true); + }); + + it("unsubscribes exactly once when its provider is unmounted", () => { + const unsubscribe = vi.fn(); + const subscribe = vi + .spyOn(Abby.prototype, "subscribe") + .mockReturnValue(unsubscribe); + const api = make(); + const mounted = mount(api, () => api.useFeatureFlag("banner")); + expect(subscribe).toHaveBeenCalledTimes(1); + mounted.unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + cleanup.pop(); + }); + + it("isolates clients and mutable config between providers", async () => { + const api = make(); + const first = mount(api, () => ({ + client: api.useAbbyClient(), + flag: api.useFeatureFlag("banner"), + })); + const second = mount(api, () => ({ + client: api.useAbbyClient(), + flag: api.useFeatureFlag("banner"), + })); + first.value.client.updateFlag("banner", false); + await nextTick(); + expect(first.value.flag.value).toBe(false); + expect(second.value.flag.value).toBe(true); + expect(first.value.client).not.toBe(second.value.client); + expect(first.value.client.getConfig().tests).not.toBe( + second.value.client.getConfig().tests + ); + }); + + it("loads data once without initial data", async () => { + const fetchData = vi + .spyOn(HttpService, "getProjectData") + .mockResolvedValue(data()); + const api = make(); + const mounted = mount( + api, + () => ({ ready: api.useAbbyStatus(), test: api.useAbby("heading") }), + null + ); + await vi.waitFor(() => expect(mounted.value.ready.value).toBe(true)); + expect(fetchData).toHaveBeenCalledTimes(1); + }); + + it("honors consent and project-scoped persisted variants", async () => { + document.cookie = `${getABStorageKey("vue-tests", "heading")}=b;path=/`; + const api = make(); + const mounted = mount(api, () => api.useAbby("heading")); + await nextTick(); + expect(mounted.value.variant.value).toBe("b"); + expect(document.cookie).not.toContain( + `${getABStorageKey("vue-tests", "button")}=` + ); + }); + + it("rejects use outside the matching provider", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + expect(() => make().useFeatureFlag("banner")).toThrow("AbbyProvider"); + warn.mockRestore(); + }); +}); diff --git a/packages/vue/src/tests/public-types.ts b/packages/vue/src/tests/public-types.ts new file mode 100644 index 00000000..d1b6e5d2 --- /dev/null +++ b/packages/vue/src/tests/public-types.ts @@ -0,0 +1,32 @@ +import type { ComputedRef } from "vue"; +import { createAbby } from "../index"; + +const api = createAbby({ + projectId: "types", + environments: ["production"], + currentEnvironment: "production", + tests: { heading: { variants: ["a", "b"] } }, + flags: ["banner"], + remoteConfig: { title: "String", limit: "Number", options: "JSON" }, +}); + +export function verifyPublicTypes() { + const variant: ComputedRef<"a" | "b" | undefined> = + api.useAbby("heading").variant; + const mapped: ComputedRef<1 | 2 | undefined> = api.useAbby("heading", { + a: 1, + b: 2, + }).variant; + const flag: ComputedRef = api.useFeatureFlag("banner"); + const title: ComputedRef = api.useRemoteConfig("title"); + const limit: ComputedRef = api.useRemoteConfig("limit"); + // @ts-expect-error Unknown flag names are rejected. + api.useFeatureFlag("missing"); + // @ts-expect-error Unknown test names are rejected. + api.useAbby("missing"); + // @ts-expect-error Lookup objects must cover all configured variants. + api.useAbby("heading", { a: 1 }); + // @ts-expect-error Number remote configs are not strings. + const wrong: ComputedRef = api.useRemoteConfig("limit"); + return { variant, mapped, flag, title, limit, wrong }; +} diff --git a/packages/vue/src/tests/ssr.test.ts b/packages/vue/src/tests/ssr.test.ts new file mode 100644 index 00000000..f4b6308d --- /dev/null +++ b/packages/vue/src/tests/ssr.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node +import { HttpService } from "@tryabby/core"; +import { describe, expect, it, vi } from "vitest"; +import { createSSRApp, defineComponent, h } from "vue"; +import { renderToString } from "vue/server-renderer"; +import { createAbby } from "../index"; + +describe("server rendering", () => { + it("isolates requests, avoids browser storage/fetch and renders hydration-safe variants", async () => { + const config = { + projectId: "ssr", + currentEnvironment: "production", + environments: ["production"], + tests: { heading: { variants: ["a", "b"] } }, + flags: ["banner"], + }; + const api = createAbby(config); + const fetch = vi.spyOn(HttpService, "getProjectData"); + const child = defineComponent({ + setup() { + const { variant } = api.useAbby("heading"); + const flag = api.useFeatureFlag("banner"); + return () => h("p", [variant.value ?? "pending", String(flag.value)]); + }, + }); + const render = (value: boolean) => + renderToString( + createSSRApp({ + render: () => + h( + api.AbbyProvider, + { + initialData: { + tests: [{ name: "heading", weights: [0, 1] }], + flags: [{ name: "banner", value }], + remoteConfig: [], + }, + }, + { default: () => h(child) } + ), + }) + ); + expect(await render(true)).toContain("pendingtrue"); + expect(await render(false)).toContain("pendingfalse"); + expect(await render(true)).toContain("pendingtrue"); + expect(config.tests.heading).not.toHaveProperty("weights"); + expect(fetch).not.toHaveBeenCalled(); + fetch.mockRestore(); + }); +}); diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json new file mode 100644 index 00000000..4c117e0a --- /dev/null +++ b/packages/vue/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2020", "DOM"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "examples/**/*.ts"] +} diff --git a/packages/vue/vite.config.ts b/packages/vue/vite.config.ts new file mode 100644 index 00000000..a6faa3db --- /dev/null +++ b/packages/vue/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + pool: "forks", + poolOptions: { forks: { singleFork: true } }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69fa896e..d0ab38c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1108,6 +1108,37 @@ 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 + tsup: + specifier: ^6.5.0 + version: 6.7.0(postcss@8.5.32)(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.13 + version: 3.5.13(typescript@5.5.4) + packages: '@adobe/css-tools@4.2.0': @@ -14389,6 +14420,72 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + postcss@8.5.32: + resolution: + integrity: sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g== + engines: + node: ^10 || ^12 || >=14 + nanoid@3.3.20: + resolution: + integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ== + engines: + node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 + hasBin: true + picocolors@1.1.1: + resolution: + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + source-map-js@1.2.2: + resolution: + integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw== + engines: + node: ">=0.10.0" + vue@3.5.13: + resolution: + integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ== + peerDependencies: + typescript: "*" + peerDependenciesMeta: + typescript: + optional: true + "@vue/compiler-dom@3.5.13": + resolution: + integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA== + "@vue/compiler-core@3.5.13": + resolution: + integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q== + "@vue/shared@3.5.13": + resolution: + integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ== + "@vue/compiler-sfc@3.5.13": + resolution: + integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ== + "@vue/compiler-ssr@3.5.13": + resolution: + integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA== + magic-string@0.30.21: + resolution: + integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + "@jridgewell/sourcemap-codec@1.6.0": + resolution: + integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw== + "@vue/runtime-dom@3.5.13": + resolution: + integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog== + "@vue/reactivity@3.5.13": + resolution: + integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg== + "@vue/runtime-core@3.5.13": + resolution: + integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw== + csstype@3.2.3: + resolution: + integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== + "@vue/server-renderer@3.5.13": + resolution: + integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA== + peerDependencies: + vue: 3.5.13 + snapshots: '@adobe/css-tools@4.2.0': {} @@ -31399,3 +31496,100 @@ snapshots: react: 18.2.0 zwitch@2.0.4: {} + + tsup@6.7.0(postcss@8.5.32)(ts-node@10.9.1(@types/node@22.1.0)(typescript@5.5.4))(typescript@5.5.4): + dependencies: + bundle-require: 4.0.1(esbuild@0.17.19) + cac: 6.7.14 + chokidar: 3.6.0 + debug: 4.3.5 + esbuild: 0.17.19 + execa: 5.1.1 + globby: 11.1.0 + joycon: 3.1.1 + postcss-load-config: 3.1.4(postcss@8.5.32)(ts-node@10.9.1(@types/node@22.1.0)(typescript@5.5.4)) + resolve-from: 5.0.0 + rollup: 3.29.4 + source-map: 0.8.0-beta.0 + sucrase: 3.32.0 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.32 + typescript: 5.5.4 + transitivePeerDependencies: + - supports-color + - ts-node + postcss-load-config@3.1.4(postcss@8.5.32)(ts-node@10.9.1(@types/node@22.1.0)(typescript@5.5.4)): + dependencies: + lilconfig: 2.1.0 + yaml: 1.10.2 + optionalDependencies: + postcss: 8.5.32 + ts-node: 10.9.1(@types/node@22.1.0)(typescript@5.5.4) + postcss@8.5.32: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + nanoid@3.3.20: {} + picocolors@1.1.1: {} + source-map-js@1.2.2: {} + vue@3.5.13(typescript@5.5.4): + dependencies: + "@vue/compiler-dom": 3.5.13 + "@vue/compiler-sfc": 3.5.13 + "@vue/runtime-dom": 3.5.13 + "@vue/server-renderer": 3.5.13(vue@3.5.13(typescript@5.5.4)) + "@vue/shared": 3.5.13 + optionalDependencies: + typescript: 5.5.4 + "@vue/compiler-dom@3.5.13": + dependencies: + "@vue/compiler-core": 3.5.13 + "@vue/shared": 3.5.13 + "@vue/compiler-core@3.5.13": + dependencies: + "@babel/parser": 7.25.3 + "@vue/shared": 3.5.13 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.0 + "@vue/shared@3.5.13": {} + "@vue/compiler-sfc@3.5.13": + dependencies: + "@babel/parser": 7.25.3 + "@vue/compiler-core": 3.5.13 + "@vue/compiler-dom": 3.5.13 + "@vue/compiler-ssr": 3.5.13 + "@vue/shared": 3.5.13 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.32 + source-map-js: 1.2.0 + "@vue/compiler-ssr@3.5.13": + dependencies: + "@vue/compiler-dom": 3.5.13 + "@vue/shared": 3.5.13 + magic-string@0.30.21: + dependencies: + "@jridgewell/sourcemap-codec": 1.6.0 + "@jridgewell/sourcemap-codec@1.6.0": {} + "@vue/runtime-dom@3.5.13": + dependencies: + "@vue/reactivity": 3.5.13 + "@vue/runtime-core": 3.5.13 + "@vue/shared": 3.5.13 + csstype: 3.2.3 + "@vue/reactivity@3.5.13": + dependencies: + "@vue/shared": 3.5.13 + "@vue/runtime-core@3.5.13": + dependencies: + "@vue/reactivity": 3.5.13 + "@vue/shared": 3.5.13 + csstype@3.2.3: {} + "@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.5.4))": + dependencies: + "@vue/compiler-ssr": 3.5.13 + "@vue/shared": 3.5.13 + vue: 3.5.13(typescript@5.5.4) From 880e4850beaf12381001eb5be6d8482625913252 Mon Sep 17 00:00:00 2001 From: liuhongming Date: Mon, 2 Nov 2026 07:14:48 +0800 Subject: [PATCH 2/3] fix(vue): defer cookie overrides during hydration and build test prerequisites --- .changeset/kind-vue-signals.md | 3 ++ packages/core/src/index.ts | 8 ++++-- packages/vue/package.json | 4 +-- packages/vue/src/index.ts | 7 +++-- packages/vue/src/tests/integration.test.ts | 32 ++++++++++++++++++++++ 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/.changeset/kind-vue-signals.md b/.changeset/kind-vue-signals.md index a7dff99d..7dbcc54c 100644 --- a/.changeset/kind-vue-signals.md +++ b/.changeset/kind-vue-signals.md @@ -1,5 +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. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e47e6657..d4814d5a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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); } diff --git a/packages/vue/package.json b/packages/vue/package.json index 1aaeef9a..3f630b49 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -15,8 +15,8 @@ }, "scripts": { "build": "tsup src/index.ts --dts --format cjs,esm --clean", - "test": "vitest run", - "typecheck": "tsc --noEmit" + "test": "pnpm --filter @tryabby/core build && vitest run", + "typecheck": "pnpm --filter @tryabby/core build && tsc --noEmit" }, "license": "ISC", "dependencies": { diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 85a73f44..66839928 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -104,9 +104,7 @@ export function createAbby< const revision = shallowRef(0); const mounted = shallowRef(false); const loaded = shallowRef(Boolean(props.initialData)); - if (props.initialData) client.init(props.initialData); - if (typeof document !== "undefined") - client.setLocalOverrides(document.cookie); + if (props.initialData) client.init(props.initialData, false); const unsubscribe = client.subscribe(() => { revision.value += 1; }); @@ -118,6 +116,9 @@ export function createAbby< }); onMounted(async () => { mounted.value = true; + if (typeof document !== "undefined") + client.setLocalOverrides(document.cookie); + revision.value += 1; if (props.initialData) return; try { await client.loadProjectData(); diff --git a/packages/vue/src/tests/integration.test.ts b/packages/vue/src/tests/integration.test.ts index fe1ca396..84ed12ef 100644 --- a/packages/vue/src/tests/integration.test.ts +++ b/packages/vue/src/tests/integration.test.ts @@ -3,6 +3,7 @@ import { AbbyEventType, HttpService, getABStorageKey, + getFFStorageKey, } from "@tryabby/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -89,6 +90,37 @@ afterEach(() => { }); describe("Vue integration with actual core", () => { + it("defers browser flag overrides until initial-data hydration completes", async () => { + const api = make(); + const child = defineComponent({ + setup() { + const flag = api.useFeatureFlag("banner"); + return () => h("span", String(flag.value)); + }, + }); + const component = { + render: () => + h( + api.AbbyProvider, + { initialData: data() }, + { default: () => h(child) } + ), + }; + const root = document.createElement("div"); + root.innerHTML = await renderToString(createSSRApp(component)); + expect(root.textContent).toBe("true"); + document.cookie = `${getFFStorageKey("vue-tests", "banner")}=false;path=/`; + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const app = createSSRApp(component); + app.mount(root); + cleanup.push(() => app.unmount()); + await nextTick(); + expect(root.textContent).toBe("false"); + expect(error).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + }); + it("hydrates server markup before exposing the client variant", async () => { const api = make(); const child = defineComponent({ From 92df2c89200152c2094d4430c3ddf420c72b4192 Mon Sep 17 00:00:00 2001 From: liuhongming Date: Mon, 2 Nov 2026 07:44:16 +0800 Subject: [PATCH 3/3] fix(vue): tolerate malformed cookies and build core before packaging --- packages/core/src/helpers.ts | 6 +++++- packages/core/tests/cookies.test.ts | 14 ++++++++++++++ packages/vue/package.json | 2 +- packages/vue/src/index.ts | 8 ++++++-- packages/vue/src/tests/integration.test.ts | 11 +++++++++++ 5 files changed, 37 insertions(+), 4 deletions(-) create mode 100644 packages/core/tests/cookies.test.ts diff --git a/packages/core/src/helpers.ts b/packages/core/src/helpers.ts index c0b7f49a..59f9de84 100644 --- a/packages/core/src/helpers.ts +++ b/packages/core/src/helpers.ts @@ -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; diff --git a/packages/core/tests/cookies.test.ts b/packages/core/tests/cookies.test.ts new file mode 100644 index 00000000..bf13bf87 --- /dev/null +++ b/packages/core/tests/cookies.test.ts @@ -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", + }); + }); +}); diff --git a/packages/vue/package.json b/packages/vue/package.json index 3f630b49..0556c756 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -14,7 +14,7 @@ } }, "scripts": { - "build": "tsup src/index.ts --dts --format cjs,esm --clean", + "build": "pnpm --filter @tryabby/core build && tsup src/index.ts --dts --format cjs,esm --clean", "test": "pnpm --filter @tryabby/core build && vitest run", "typecheck": "pnpm --filter @tryabby/core build && tsc --noEmit" }, diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index 66839928..279517b2 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -116,8 +116,12 @@ export function createAbby< }); onMounted(async () => { mounted.value = true; - if (typeof document !== "undefined") - client.setLocalOverrides(document.cookie); + try { + if (typeof document !== "undefined") + client.setLocalOverrides(document.cookie); + } catch (error) { + emit("error", error); + } revision.value += 1; if (props.initialData) return; try { diff --git a/packages/vue/src/tests/integration.test.ts b/packages/vue/src/tests/integration.test.ts index 84ed12ef..ca50f56a 100644 --- a/packages/vue/src/tests/integration.test.ts +++ b/packages/vue/src/tests/integration.test.ts @@ -90,6 +90,17 @@ afterEach(() => { }); describe("Vue integration with actual core", () => { + it("loads project data despite malformed unrelated cookies", async () => { + document.cookie = "broken=%E0%A4%A;path=/"; + const fetchData = vi + .spyOn(HttpService, "getProjectData") + .mockResolvedValue(data()); + const api = make(); + const mounted = mount(api, () => api.useAbbyStatus(), null); + await vi.waitFor(() => expect(mounted.value.value).toBe(true)); + expect(fetchData).toHaveBeenCalledTimes(1); + }); + it("defers browser flag overrides until initial-data hydration completes", async () => { const api = make(); const child = defineComponent({