Repository navigation
feat(vue): add typed composables with app and provider isolation #211
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
CarsonCCHsieh
wants to merge
3
commits into
tryabby:main
Choose a base branch
from
CarsonCCHsieh:feat/vue-integration
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| --- | ||
| "@tryabby/vue": minor | ||
| --- | ||
|
|
||
| Add a Vue 3.5 integration with typed reactive feature flags, remote configuration, | ||
| A/B test composables, per-app/provider isolation, SSR-safe initialization, | ||
| consent-aware persistence, and lifecycle cleanup. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| dist/ | ||
| demo-dist/ |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,200 @@ | ||
| # @tryabby/vue | ||
|
|
||
| Typed Vue composables for Abby feature flags, remote configuration, and A/B tests. This workspace implementation uses `@tryabby/core` and requires **Vue 3.5 or newer**. It does not require a single-file-component compiler. | ||
|
|
||
| ## Install on a Vue application | ||
|
|
||
| ```ts | ||
| // abby.ts | ||
| import { createAbby } from "@tryabby/vue"; | ||
|
|
||
| export const sdk = createAbby({ | ||
| projectId: "your-public-project-id", | ||
| environments: ["production"], | ||
| currentEnvironment: "production", | ||
| flags: ["showDetails"], | ||
| tests: { welcome: { variants: ["calm", "bright"] } }, | ||
| remoteConfig: { headline: "String", previewCount: "Number", appearance: "JSON" }, | ||
| cookies: { disableByDefault: true }, | ||
| }); | ||
| ``` | ||
|
|
||
| ```ts | ||
| // main.ts | ||
| import { createApp } from "vue"; | ||
| import App from "./App.vue"; | ||
| import { sdk } from "./abby"; | ||
|
|
||
| const app = createApp(App); | ||
| app.use(sdk.AbbyPlugin); // Loads public project data on the client after mount. | ||
| app.mount("#app"); | ||
| ``` | ||
|
|
||
| If the host already has public project data, replace the preceding `main.ts` example with the following. Install the plugin once, with `{ initialData }`, before mounting; calling `app.use` again after installation does not replace its options. The payload has the `AbbyDataResponse` shape from `@tryabby/core`: | ||
|
|
||
| ```ts | ||
| // main.ts (alternative to the preceding example) | ||
| import { createApp } from "vue"; | ||
| import App from "./App.vue"; | ||
| import { sdk } from "./abby"; | ||
|
|
||
| const initialData = { | ||
| tests: [{ name: "welcome", weights: [1, 0] }], | ||
| flags: [{ name: "showDetails", value: true }], | ||
| remoteConfig: [ | ||
| { name: "headline", value: "Welcome back" }, | ||
| { name: "previewCount", value: 3 }, | ||
| { name: "appearance", value: { tone: "mint" } }, | ||
| ], | ||
| }; | ||
| const app = createApp(App); | ||
| app.use(sdk.AbbyPlugin, { initialData }); // Skips the automatic data request. | ||
| // Equivalent plugin form: app.use(sdk, { initialData }). | ||
| app.mount("#app"); | ||
| ``` | ||
|
|
||
| An isolated subtree can instead render `sdk.AbbyProvider` with an optional `initialData` prop. Composables in its descendants use that provider's separate core. Create the component using `h(sdk.AbbyProvider, { initialData }, { default: () => h(Child) })`, or bind the returned component in a Vue template. Do not call a descendant composable in the parent's setup before the provider exists. | ||
|
|
||
| ## Reactive component values | ||
|
|
||
| Call composables synchronously in a component's `setup()` or `<script setup>`, beneath the installed plugin or provider: | ||
|
|
||
| ```vue | ||
| <script setup lang="ts"> | ||
| import { sdk } from "./abby"; | ||
|
|
||
| const showDetails = sdk.useFeatureFlag("showDetails"); // ComputedRef<boolean> | ||
| const headline = sdk.useRemoteConfig("headline"); // ComputedRef<string> | ||
| const previewCount = sdk.useRemoteConfig("previewCount"); // ComputedRef<number> | ||
| const appearance = sdk.useRemoteConfig("appearance"); // ComputedRef<Record<string, unknown>> | ||
| const { variant, onAct } = sdk.useAbby("welcome"); | ||
| // variant: ComputedRef<"calm" | "bright" | undefined> | ||
| </script> | ||
|
|
||
| <template> | ||
| <h1>{{ headline }}</h1> | ||
| <p v-if="showDetails">Showing {{ previewCount }} items.</p> | ||
| <button :disabled="variant === undefined" @click="onAct"> | ||
| {{ variant ?? "Loading experience" }} | ||
| </button> | ||
| </template> | ||
| ``` | ||
|
|
||
| Vue templates unwrap refs. In JavaScript, use `.value` inside a render function or computed expression; copying `.value` into a plain variable loses reactivity. A lookup maps each literal variant to another typed value: | ||
|
|
||
| ```ts | ||
| const { variant: buttonText, onAct } = sdk.useAbby("welcome", { | ||
| calm: "Take a look", | ||
| bright: "Explore now", | ||
| }); | ||
| ``` | ||
|
|
||
| `useFeatureFlags()` and `useRemoteConfigVariables()` return computed lists. `useAbby()` emits PING when a mounted selection becomes available or changes; `onAct()` sends ACT for the current raw variant, including when a lookup is used. Neither sends during SSR or before mount. The real SDK uses the core's telemetry transport; the local demo intercepts it separately. | ||
|
|
||
| ## Imperative access and updates | ||
|
|
||
| Getters return snapshots, not refs. They resolve the nearest runtime in setup. Outside injection context, pass the **installed Vue app** explicitly; there is no implicit global client: | ||
|
|
||
| ```ts | ||
| // In setup: | ||
| const client = sdk.useAbbyClient(); | ||
| const choices = sdk.getVariants("welcome"); | ||
| const resetWelcome = sdk.getABResetFunction("welcome"); | ||
|
|
||
| // Safe to use the captured client in a later component event handler: | ||
| const toggle = () => client.updateFlag("showDetails", !client.getFeatureFlag("showDetails")); | ||
|
|
||
| // Outside setup, after plugin installation: | ||
| sdk.getFeatureFlagValue("showDetails", app); | ||
| sdk.getRemoteConfig("headline", app); | ||
| sdk.getABTestValue("welcome", undefined, app); // undefined until client mount | ||
| sdk.getABTestValue("welcome", { calm: "A", bright: "B" }, app); | ||
| sdk.getVariants("welcome", app); | ||
| sdk.getABResetFunction("welcome", app)(); | ||
| sdk.useAbbyClient(app); | ||
| ``` | ||
|
|
||
| `updateUserProperties(properties, app?)` accepts properties declared by the core config's `user` validators and recalculates rules. `enableCookies(app?)`, `disableCookies(app?)`, and `loadProjectData(app?)` also resolve an injected runtime or the explicit app. Prefer the SDK cookie helpers over direct core consent calls so Vue snapshots and storage cleanup stay coordinated. | ||
|
|
||
| The exposed core supports `init`, `updateFlag`, `updateRemoteConfig`, and other existing core methods; notifications update mounted composables. `updateLocalVariant` is a **development override** in core, not a production experiment-assignment API. `withDevtools(factory, options?)` accepts a factory with `create({ abby, ...options })` returning an optional cleanup function; it does not add a devtools UI dependency. It shows only in development unless `dangerouslyForceShow` is enabled. | ||
|
|
||
| ## SSR, Nuxt, cookies, and cleanup | ||
|
|
||
| - Every app installation and every provider owns a distinct core and cloned configuration/data. Never place a mutable per-request core or user properties in a server singleton. | ||
| - A/B variants are `undefined` during SSR and the initial hydration render, then become available after client mount. Render the same placeholder on both sides. Feature flags and remote configuration use the supplied initial data or configured defaults. | ||
| - SSR does not automatically fetch, access browser storage, or send events. Without initial data, the client loads project data after mount. With initial data, give server and client the same public payload. | ||
| - Variant persistence uses project-scoped cookies when enabled. Configure consent for your application; `disableCookies` clears this runtime's known values and records disabled consent. Reset helpers clear the selected test's stored assignment; they do not promise a different next variant. No HttpOnly cookie can be read by this client SDK. | ||
| - Cookie-based `Number` remote-config overrides accept finite integers only. Decimal and exponent strings are ignored before reaching core's legacy integer parser, which would otherwise truncate them. This cookie-override restriction does not apply to numeric values supplied in `initialData` or project-data responses. | ||
| - Component/effect-scope subscriptions stop on disposal. Provider unmount and `app.unmount()` dispose their owned runtimes; devtools cleanup runs on unmount. Do not keep using a disposed runtime. | ||
|
|
||
| This is a **Nuxt plugin integration example**, not a shipped Nuxt module or a claim that a Nuxt application test suite has run: | ||
|
|
||
| ```ts | ||
| // plugins/abby.ts — runs once for each Nuxt app/request, not at module scope. | ||
| import { defineNuxtPlugin, useState } from "#app"; | ||
| import type { AbbyDataResponse } from "@tryabby/core"; | ||
| import { createAbby } from "@tryabby/vue"; | ||
|
|
||
| export default defineNuxtPlugin((nuxtApp) => { | ||
| const sdk = createAbby({ | ||
| projectId: "your-public-project-id", | ||
| environments: ["production"], | ||
| currentEnvironment: "production", | ||
| flags: ["showDetails"], | ||
| tests: { welcome: { variants: ["calm", "bright"] } }, | ||
| remoteConfig: { headline: "String" }, | ||
| cookies: { disableByDefault: true }, | ||
| }); | ||
|
|
||
| // A host-controlled server loader may populate this with public Abby data. | ||
| // useState serializes it for hydration: never put secrets/user profiles here. | ||
| const initialData = useState<AbbyDataResponse | null>("abby-public-data", () => null); | ||
| nuxtApp.vueApp.use(sdk.AbbyPlugin, initialData.value | ||
| ? { initialData: initialData.value } | ||
| : {}); // No payload: client fetches after mount; server does not fetch. | ||
| return { provide: { abby: sdk } }; | ||
| }); | ||
| ``` | ||
|
|
||
| In a Nuxt component's setup, obtain `const { $abby } = useNuxtApp()` and call `$abby.useFeatureFlag(...)`, `$abby.useAbby(...)`, or the other composables. Server-provided flags are presentation configuration, not authorization: enforce privileged access on your server. | ||
|
|
||
| ## Run the local demonstration | ||
|
|
||
| The [public synthetic-data playground](https://microusada.com/demos/abby-vue/) | ||
| and its [26-second browser-captured walkthrough](https://microusada.com/demos/abby-vue/walkthrough.mp4) | ||
| show the same demonstration. The video is a captioned sequence of actual browser | ||
| captures, not a recording of a live Abby project or a claim about network performance. | ||
|
|
||
| From the repository root, with workspace dependencies already installed: | ||
|
|
||
| ```sh | ||
| pnpm --filter @tryabby/core build | ||
| pnpm --filter @tryabby/vue demo | ||
| ``` | ||
|
|
||
| Open the local URL printed by Vite. The demo imports `src/index.ts` and uses real Vue `createApp`/`h`, without a Vue SFC plugin. `index.html` is also the Vite production entry; a local static build can be made with `pnpm --filter @tryabby/vue exec vite build`. That builds the demo, while `pnpm --filter @tryabby/vue build` builds the SDK package. | ||
|
|
||
| The demo contains only synthetic initial data. It intercepts the same `@tryabby/core` `HttpService.sendData` used by the SDK, stores events in memory, and disables project loading before plugin installation. Its page CSP additionally blocks connection requests, including Vite's development HMR WebSocket. App files still load from your local HTTP server; no Abby API, telemetry, fonts, CDN, account, or key is required. This instrumentation is demonstration-only and is **not installed by the SDK**. | ||
|
|
||
| Suggested manual checks, rather than a claim that they have already run: | ||
|
|
||
| 1. Toggle the flag and change string, number, and JSON configuration; verify the actual preview DOM updates. | ||
| 2. Switch the synthetic A/B weights; verify the variant label changes and a local PING appears. | ||
| 3. Click “Try this experience”; verify an ACT with the selected raw variant appears. | ||
| 4. Unmount and remount the panel; check that the app remains reactive without accumulating obsolete subscriptions. | ||
| 5. Inspect the Network panel: local assets only, no external API or telemetry. The project-data attempt counter should remain zero. | ||
|
|
||
| ## Development checks | ||
|
|
||
| ```sh | ||
| pnpm --filter @tryabby/vue typecheck | ||
| pnpm --filter @tryabby/vue test | ||
| pnpm --filter @tryabby/vue build | ||
| pnpm --filter @tryabby/core exec vitest run | ||
| pnpm --filter @tryabby/react exec vitest run | ||
| pnpm --filter @tryabby/svelte exec vitest run | ||
| ``` | ||
|
|
||
| These are reproduction commands, not a declaration that every command or framework application has passed in every environment. The Vue package includes runtime, SSR-isolation/lifecycle, and TypeScript contract checks; consult the actual test output for the tested scope. | ||
|
|
||
| This integration and its demonstration are original, AI-assisted contributions. The contributor is responsible for reviewing and validating the final change before upstream submission. No implementation from competing pull requests was copied. Repository licensing applies; inclusion of this package does not guarantee bounty acceptance or payout eligibility. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import type { AbbyDataResponse } from "@tryabby/core"; | ||
|
|
||
| /** Synthetic public configuration. No project account, user data or API key. */ | ||
| export function createInitialData( | ||
| selected: "calm" | "bright" = "calm" | ||
| ): AbbyDataResponse { | ||
| return { | ||
| tests: [ | ||
| { name: "welcome", weights: selected === "calm" ? [1, 0] : [0, 1] }, | ||
| ], | ||
| flags: [{ name: "showDetails", value: true }], | ||
| remoteConfig: [ | ||
| { name: "headline", value: "A little change. A live result." }, | ||
| { name: "previewCount", value: 3 }, | ||
| { name: "appearance", value: { tone: "peach", rounded: true } }, | ||
| ], | ||
| }; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.