From 484cc989964d595bdb0dfdd5602481ef92b8d25f Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Fri, 3 Jul 2026 11:11:07 -0300 Subject: [PATCH 1/2] feat(openfeature): add web provider (@posthog/openfeature-web-provider) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Official PostHog provider for the OpenFeature web SDK (@openfeature/web-sdk), backed by posthog-js. Split out from the combined providers PR per review (marandaneto) — the server provider (@posthog/openfeature-node-provider) ships in #3994. The browser model is single-user and synchronous: posthog-js owns the user identity and holds flags in memory, so evaluation is synchronous and the static evaluation context is reconciled into the SDK on initialize()/onContextChange(). Resolves booleans from `enabled`, strings from the multivariate variant, numbers from the parsed variant, and objects from the JSON payload; missing flag -> FLAG_NOT_FOUND, wrong type -> TYPE_MISMATCH. Not published yet at the workflow level beyond being wired into the release matrix and carrying a changeset; publishing still requires the standard merge + NPM Release approval flow. Generated-By: PostHog Code Task-Id: 46a3f8c9-fbcd-460e-9457-fca583955e5a --- .changeset/openfeature-web-provider.md | 5 + .eslintrc.cjs | 1 + .github/workflows/release.yml | 1 + AGENTS.md | 1 + packages/openfeature-web-provider/.prettierrc | 7 + packages/openfeature-web-provider/README.md | 13 + .../openfeature-web-provider/jest.config.mjs | 12 + .../openfeature-web-provider/package.json | 71 ++++++ .../openfeature-web-provider/rslib.config.ts | 18 ++ .../src/__tests__/provider.spec.ts | 225 ++++++++++++++++++ .../openfeature-web-provider/src/index.ts | 8 + .../openfeature-web-provider/src/mapping.ts | 150 ++++++++++++ .../openfeature-web-provider/src/provider.ts | 151 ++++++++++++ .../tsconfig.build.json | 8 + .../openfeature-web-provider/tsconfig.json | 12 + pnpm-lock.yaml | 44 ++++ 16 files changed, 727 insertions(+) create mode 100644 .changeset/openfeature-web-provider.md create mode 100644 packages/openfeature-web-provider/.prettierrc create mode 100644 packages/openfeature-web-provider/README.md create mode 100644 packages/openfeature-web-provider/jest.config.mjs create mode 100644 packages/openfeature-web-provider/package.json create mode 100644 packages/openfeature-web-provider/rslib.config.ts create mode 100644 packages/openfeature-web-provider/src/__tests__/provider.spec.ts create mode 100644 packages/openfeature-web-provider/src/index.ts create mode 100644 packages/openfeature-web-provider/src/mapping.ts create mode 100644 packages/openfeature-web-provider/src/provider.ts create mode 100644 packages/openfeature-web-provider/tsconfig.build.json create mode 100644 packages/openfeature-web-provider/tsconfig.json diff --git a/.changeset/openfeature-web-provider.md b/.changeset/openfeature-web-provider.md new file mode 100644 index 0000000000..8afff7b750 --- /dev/null +++ b/.changeset/openfeature-web-provider.md @@ -0,0 +1,5 @@ +--- +'@posthog/openfeature-web-provider': minor +--- + +Initial release of the official PostHog provider for the OpenFeature web SDK, backed by `posthog-js`. diff --git a/.eslintrc.cjs b/.eslintrc.cjs index e0e49d042b..f28f05a0a2 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -71,6 +71,7 @@ module.exports = { 'packages/mcp/**', 'packages/nextjs-config/**', 'packages/nuxt/**', + 'packages/openfeature-web-provider/**', 'packages/react-native/**', 'packages/node/**', 'packages/web/**', diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8da508b0fb..2f3a85b6d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -268,6 +268,7 @@ jobs: - name: '@posthog/next' - name: '@posthog/nextjs-config' - name: '@posthog/nuxt' + - name: '@posthog/openfeature-web-provider' - name: '@posthog/plugin-utils' - name: '@posthog/rollup-plugin' - name: '@posthog/types' diff --git a/AGENTS.md b/AGENTS.md index 1a5290d05a..4969b54dcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,7 @@ The repository contains the following SDK packages in `./packages/`: | `nuxt/` | `@posthog/nuxt` | Nuxt framework module | | `next/` | `@posthog/next` | Next.js framework module | | `nextjs-config/` | `@posthog/nextjs-config` | Next.js configuration helper | +| `openfeature-web-provider/` | `@posthog/openfeature-web-provider` | OpenFeature web provider (posthog-js) | | `plugin-utils/` | `@posthog/plugin-utils` | Shared CLI and sourcemap utilities for plugins | | `types/` | `@posthog/types` | TypeScript type definitions for the SDK | diff --git a/packages/openfeature-web-provider/.prettierrc b/packages/openfeature-web-provider/.prettierrc new file mode 100644 index 0000000000..5b7d09a033 --- /dev/null +++ b/packages/openfeature-web-provider/.prettierrc @@ -0,0 +1,7 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "printWidth": 120 +} diff --git a/packages/openfeature-web-provider/README.md b/packages/openfeature-web-provider/README.md new file mode 100644 index 0000000000..719754f033 --- /dev/null +++ b/packages/openfeature-web-provider/README.md @@ -0,0 +1,13 @@ +# @posthog/openfeature-web-provider + +Official [PostHog](https://posthog.com) provider for the [OpenFeature](https://openfeature.dev) **web** +SDK ([`@openfeature/web-sdk`](https://openfeature.dev/docs/reference/technologies/client/web)), backed by +[`posthog-js`](https://posthog.com/docs/libraries/js). + +For the server, use [`@posthog/openfeature-node-provider`](../openfeature-node-provider). + +## Documentation + +Installation and usage instructions live in the PostHog docs, so they stay in one place and don't drift: + +**https://posthog.com/docs/feature-flags/installation/openfeature-js** diff --git a/packages/openfeature-web-provider/jest.config.mjs b/packages/openfeature-web-provider/jest.config.mjs new file mode 100644 index 0000000000..0446edfe76 --- /dev/null +++ b/packages/openfeature-web-provider/jest.config.mjs @@ -0,0 +1,12 @@ +import { createDefaultPreset } from 'ts-jest' + +const tsJestTransformCfg = createDefaultPreset().transform + +/** @type {import("jest").Config} **/ +export default { + testEnvironment: 'node', + testPathIgnorePatterns: ['/node_modules/', '/dist/'], + transform: { + ...tsJestTransformCfg, + }, +} diff --git a/packages/openfeature-web-provider/package.json b/packages/openfeature-web-provider/package.json new file mode 100644 index 0000000000..cb286a9ef6 --- /dev/null +++ b/packages/openfeature-web-provider/package.json @@ -0,0 +1,71 @@ +{ + "name": "@posthog/openfeature-web-provider", + "version": "0.0.0", + "bugs": { + "url": "https://github.com/PostHog/posthog-js/issues" + }, + "description": "Official PostHog provider for the OpenFeature web SDK (browser), backed by posthog-js", + "repository": { + "type": "git", + "url": "git+https://github.com/PostHog/posthog-js.git", + "directory": "packages/openfeature-web-provider" + }, + "homepage": "https://github.com/PostHog/posthog-js/tree/main/packages/openfeature-web-provider#readme", + "author": { + "name": "PostHog", + "email": "engineering@posthog.com", + "url": "https://posthog.com" + }, + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + } + }, + "scripts": { + "clean": "rimraf dist", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "build": "rslib build", + "dev": "rslib build --watch", + "test:unit": "jest", + "prepublishOnly": "pnpm lint && pnpm test:unit && pnpm build", + "package": "pnpm pack --out $PACKAGE_DEST/%s.tgz" + }, + "files": [ + "dist/" + ], + "keywords": [ + "posthog", + "openfeature", + "feature-flags", + "browser", + "web" + ], + "peerDependencies": { + "@openfeature/core": "^1.11.0", + "@openfeature/web-sdk": "^1.7.0", + "posthog-js": ">=1.336.0" + }, + "devDependencies": { + "@openfeature/core": "^1.11.0", + "@openfeature/web-sdk": "^1.9.0", + "@posthog-tooling/tsconfig-base": "workspace:*", + "@rslib/core": "catalog:", + "@types/jest": "catalog:", + "@types/node": "^22.15.23", + "jest": "catalog:", + "posthog-js": "workspace:*", + "ts-jest": "catalog:" + } +} diff --git a/packages/openfeature-web-provider/rslib.config.ts b/packages/openfeature-web-provider/rslib.config.ts new file mode 100644 index 0000000000..6a71533546 --- /dev/null +++ b/packages/openfeature-web-provider/rslib.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from '@rslib/core' + +export default defineConfig({ + lib: [ + { format: 'esm', syntax: 'es2023', dts: true, bundle: false }, + { format: 'cjs', syntax: 'es2023', dts: true, bundle: false }, + ], + output: { + // Web provider: targets the browser runtime. + target: 'web', + }, + source: { + entry: { + index: ['src/**/*', '!src/__tests__/**/*', '!src/**/*.spec.ts'], + }, + tsconfigPath: './tsconfig.build.json', + }, +}) diff --git a/packages/openfeature-web-provider/src/__tests__/provider.spec.ts b/packages/openfeature-web-provider/src/__tests__/provider.spec.ts new file mode 100644 index 0000000000..42c136fda4 --- /dev/null +++ b/packages/openfeature-web-provider/src/__tests__/provider.spec.ts @@ -0,0 +1,225 @@ +import { ErrorCode, OpenFeature, StandardResolutionReasons, type ResolutionDetails } from '@openfeature/web-sdk' +import type { PostHog } from 'posthog-js' + +import { PostHogWebProvider } from '../provider' + +type FlagResult = { + key: string + enabled: boolean + variant?: string + payload?: unknown +} + +function makeClient( + result: FlagResult | undefined, + { alreadyLoaded = false }: { alreadyLoaded?: boolean } = {} +): { + client: PostHog + getFeatureFlagResult: jest.Mock + reloadFeatureFlags: jest.Mock + onFeatureFlags: jest.Mock + setPersonPropertiesForFlags: jest.Mock + group: jest.Mock +} { + let callback: ((flags: string[], variants: Record) => void) | undefined + + const onFeatureFlags = jest.fn((cb: (flags: string[], variants: Record) => void) => { + callback = cb + // posthog-js fires synchronously on subscribe when flags are already loaded. + if (alreadyLoaded) { + cb([], {}) + } + return () => { + callback = undefined + } + }) + // Simulate an async reload that notifies subscribers on completion. + const reloadFeatureFlags = jest.fn(() => { + queueMicrotask(() => callback?.([], {})) + }) + const getFeatureFlagResult = jest.fn().mockReturnValue(result) + const setPersonPropertiesForFlags = jest.fn() + const group = jest.fn() + + return { + client: { + getFeatureFlagResult, + reloadFeatureFlags, + onFeatureFlags, + setPersonPropertiesForFlags, + group, + } as unknown as PostHog, + getFeatureFlagResult, + reloadFeatureFlags, + onFeatureFlags, + setPersonPropertiesForFlags, + group, + } +} + +type Resolve = (provider: PostHogWebProvider) => ResolutionDetails + +describe('PostHogWebProvider', () => { + it('identifies as a client provider', () => { + const { client } = makeClient(undefined) + const provider = new PostHogWebProvider(client) + expect(provider.metadata.name).toBe('PostHogWebProvider') + expect(provider.runsOn).toBe('client') + }) + + describe('synchronous resolution', () => { + it.each<[string, FlagResult, Resolve, Partial>]>([ + [ + 'boolean enabled → true / TARGETING_MATCH', + { key: 'flag', enabled: true }, + (p) => p.resolveBooleanEvaluation('flag', false), + { value: true, reason: StandardResolutionReasons.TARGETING_MATCH }, + ], + [ + 'boolean disabled → false / DEFAULT', + { key: 'flag', enabled: false }, + (p) => p.resolveBooleanEvaluation('flag', true), + { value: false, reason: StandardResolutionReasons.DEFAULT }, + ], + [ + 'string → multivariate variant', + { key: 'flag', enabled: true, variant: 'control' }, + (p) => p.resolveStringEvaluation('flag', 'x'), + { value: 'control', variant: 'control' }, + ], + [ + 'number → parsed variant', + { key: 'flag', enabled: true, variant: '7' }, + (p) => p.resolveNumberEvaluation('flag', 0), + { value: 7 }, + ], + [ + 'object → JSON payload', + { key: 'flag', enabled: true, payload: { a: 1 } }, + (p) => p.resolveObjectEvaluation('flag', {}), + { value: { a: 1 } }, + ], + ])('resolves %s', (_name, result, resolve, expected) => { + const { client } = makeClient(result) + expect(resolve(new PostHogWebProvider(client))).toMatchObject(expected) + }) + + it.each<[string, FlagResult | undefined, Resolve, ErrorCode]>([ + [ + 'string from a boolean flag (no variant)', + { key: 'flag', enabled: true }, + (p) => p.resolveStringEvaluation('flag', 'x'), + ErrorCode.TYPE_MISMATCH, + ], + [ + 'missing flag (client returns undefined)', + undefined, + (p) => p.resolveBooleanEvaluation('missing', false), + ErrorCode.FLAG_NOT_FOUND, + ], + ])('throws on %s', (_name, result, resolve, code) => { + const { client } = makeClient(result) + expect(() => resolve(new PostHogWebProvider(client))).toThrow(expect.objectContaining({ code })) + }) + + it('passes send_event through to the client', () => { + const { client, getFeatureFlagResult } = makeClient({ key: 'flag', enabled: true }) + new PostHogWebProvider(client, { sendFeatureFlagEvents: false }).resolveBooleanEvaluation('flag', false) + expect(getFeatureFlagResult).toHaveBeenCalledWith('flag', { send_event: false }) + }) + + it('defaults send_event to true', () => { + const { client, getFeatureFlagResult } = makeClient({ key: 'flag', enabled: true }) + new PostHogWebProvider(client).resolveBooleanEvaluation('flag', false) + expect(getFeatureFlagResult).toHaveBeenCalledWith('flag', { send_event: true }) + }) + }) + + describe('initialize / reconciliation', () => { + it('reloads flags on initialize and resolves once loaded', async () => { + const { client, reloadFeatureFlags } = makeClient({ key: 'flag', enabled: true }) + const provider = new PostHogWebProvider(client) + await provider.initialize() + expect(reloadFeatureFlags).toHaveBeenCalledTimes(1) + }) + + it('resolves even when flags were already loaded (ignores the immediate fire)', async () => { + const { client, reloadFeatureFlags } = makeClient({ key: 'flag', enabled: true }, { alreadyLoaded: true }) + const provider = new PostHogWebProvider(client) + await expect(provider.initialize()).resolves.toBeUndefined() + expect(reloadFeatureFlags).toHaveBeenCalledTimes(1) + }) + + it('resolves on timeout when the flags callback never fires', async () => { + // onFeatureFlags never invokes its callback and reloadFeatureFlags is a no-op, + // so only the reloadTimeoutMs safety net can settle initialize(). + const client = { + getFeatureFlagResult: jest.fn(), + reloadFeatureFlags: jest.fn(), + onFeatureFlags: jest.fn(() => () => {}), + setPersonPropertiesForFlags: jest.fn(), + group: jest.fn(), + } as unknown as PostHog + const provider = new PostHogWebProvider(client, { reloadTimeoutMs: 20 }) + await expect(provider.initialize()).resolves.toBeUndefined() + }) + + it('reconciles person properties and groups from the context on change', async () => { + const { client, setPersonPropertiesForFlags, group } = makeClient({ key: 'flag', enabled: true }) + const provider = new PostHogWebProvider(client) + await provider.onContextChange( + {}, + { + targetingKey: 'user_1', + plan: 'enterprise', + groups: { organization: 'acme' }, + groupProperties: { organization: { tier: 'gold' } }, + } + ) + // reload suppressed on the property write — a single trailing reload is awaited instead + expect(setPersonPropertiesForFlags).toHaveBeenCalledWith({ plan: 'enterprise' }, false) + expect(group).toHaveBeenCalledWith('organization', 'acme', { tier: 'gold' }) + }) + + it('does not touch person properties when none are provided', async () => { + const { client, setPersonPropertiesForFlags } = makeClient({ key: 'flag', enabled: true }) + const provider = new PostHogWebProvider(client) + await provider.onContextChange({}, { targetingKey: 'user_1' }) + expect(setPersonPropertiesForFlags).not.toHaveBeenCalled() + }) + + it('calls group without properties when groupProperties are absent', async () => { + const { client, group } = makeClient({ key: 'flag', enabled: true }) + const provider = new PostHogWebProvider(client) + await provider.onContextChange({}, { groups: { organization: 'acme' } }) + expect(group).toHaveBeenCalledWith('organization', 'acme', undefined) + }) + }) + + describe('end-to-end through the OpenFeature client', () => { + afterEach(async () => { + await OpenFeature.close() + }) + + it('resolves values synchronously through the real client', async () => { + const { client } = makeClient({ key: 'flag', enabled: true, variant: 'control', payload: { a: 1 } }) + await OpenFeature.setProviderAndWait(new PostHogWebProvider(client)) + const ofClient = OpenFeature.getClient() + + expect(ofClient.getBooleanValue('flag', false)).toBe(true) + expect(ofClient.getStringValue('flag', 'x')).toBe('control') + expect(ofClient.getObjectDetails('flag', {}).value).toEqual({ a: 1 }) + }) + + it('returns the default value with an error code on a type mismatch', async () => { + const { client } = makeClient({ key: 'flag', enabled: true }) + await OpenFeature.setProviderAndWait(new PostHogWebProvider(client)) + const ofClient = OpenFeature.getClient() + + const details = ofClient.getStringDetails('flag', 'fallback') + expect(details.value).toBe('fallback') + expect(details.errorCode).toBe(ErrorCode.TYPE_MISMATCH) + expect(details.reason).toBe(StandardResolutionReasons.ERROR) + }) + }) +}) diff --git a/packages/openfeature-web-provider/src/index.ts b/packages/openfeature-web-provider/src/index.ts new file mode 100644 index 0000000000..c44238d652 --- /dev/null +++ b/packages/openfeature-web-provider/src/index.ts @@ -0,0 +1,8 @@ +/** + * Official PostHog provider for the OpenFeature **web** SDK + * (`@openfeature/web-sdk`), backed by `posthog-js`. + * + * import { PostHogWebProvider } from '@posthog/openfeature-web-provider' + */ +export { PostHogWebProvider, type PostHogWebProviderOptions } from './provider' +export { GROUPS_KEY, GROUP_PROPERTIES_KEY, type PostHogFlagResult, type SplitContext } from './mapping' diff --git a/packages/openfeature-web-provider/src/mapping.ts b/packages/openfeature-web-provider/src/mapping.ts new file mode 100644 index 0000000000..62c3e2c4ed --- /dev/null +++ b/packages/openfeature-web-provider/src/mapping.ts @@ -0,0 +1,150 @@ +/** + * Mapping between PostHog feature flag results and OpenFeature resolution + * details for the **web** provider. + * + * `posthog-js`'s `getFeatureFlagResult` returns `{ key, enabled, variant?, + * payload? }`, and this module turns that into the OpenFeature + * `ResolutionDetails` shape (and the reserved-attribute context split). + * + * Everything is imported from `@openfeature/core` (a peer dependency shared by + * the web SDK) so the error classes thrown here are the same identities the + * active SDK catches. + */ +import { + FlagNotFoundError, + StandardResolutionReasons, + TypeMismatchError, + type EvaluationContext, + type JsonValue, + type ResolutionDetails, + type ResolutionReason, +} from '@openfeature/core' + +/** + * The minimal flag-result shape returned by `posthog-js`'s + * `getFeatureFlagResult`. The client's result structurally satisfies this, so + * the SDK does not need to be imported here. + */ +export interface PostHogFlagResult { + readonly key: string + readonly enabled: boolean + readonly variant?: string + readonly payload?: unknown +} + +/** + * Reserved evaluation-context attribute keys. Every other attribute (besides + * the standard `targetingKey`) is forwarded to PostHog as a person property. + */ +export const GROUPS_KEY = 'groups' +export const GROUP_PROPERTIES_KEY = 'groupProperties' + +/** PostHog evaluation inputs derived from an OpenFeature evaluation context. */ +export interface SplitContext { + personProperties: Record + groups: Record + groupProperties: Record> +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Split an OpenFeature evaluation context into PostHog's evaluation inputs: + * - reserved `groups` -> PostHog `groups` + * - reserved `groupProperties` -> PostHog `groupProperties` + * - every other attribute -> PostHog `personProperties` + * + * `targetingKey` is consumed separately and never becomes a person property. + */ +export function splitContext(context?: EvaluationContext): SplitContext { + if (!context) { + return { personProperties: {}, groups: {}, groupProperties: {} } + } + const { + targetingKey: _targetingKey, + [GROUPS_KEY]: rawGroups, + [GROUP_PROPERTIES_KEY]: rawGroupProperties, + ...rest + } = context + return { + personProperties: rest, + groups: isRecord(rawGroups) ? (rawGroups as Record) : {}, + groupProperties: isRecord(rawGroupProperties) + ? (rawGroupProperties as Record>) + : {}, + } +} + +/** + * Map PostHog's enabled state to an OpenFeature reason. PostHog's JS + * `FeatureFlagResult` carries no free-text reason (unlike the Python client), so + * an enabled flag means a targeting condition matched and a disabled one falls + * back to the default rollout. + */ +function reasonFor(result: PostHogFlagResult): ResolutionReason { + return result.enabled ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.DEFAULT +} + +/** + * A `undefined` result means the flag does not exist (or was archived) — + * `getFeatureFlagResult` returns a populated result with `enabled: false` for a + * flag that exists but did not match. Surface the former as the OpenFeature + * `FLAG_NOT_FOUND` error so callers get their default value. + */ +function ensureResolved(result: PostHogFlagResult | undefined, flagKey: string): PostHogFlagResult { + if (result == null) { + throw new FlagNotFoundError(`Flag '${flagKey}' not found.`) + } + return result +} + +export function resolveBooleanDetails( + result: PostHogFlagResult | undefined, + flagKey: string +): ResolutionDetails { + const resolved = ensureResolved(result, flagKey) + return { value: resolved.enabled, variant: resolved.variant, reason: reasonFor(resolved) } +} + +export function resolveStringDetails( + result: PostHogFlagResult | undefined, + flagKey: string +): ResolutionDetails { + const resolved = ensureResolved(result, flagKey) + if (resolved.variant === undefined) { + // A boolean flag has no string variant. Surface a type mismatch so the + // caller gets its default value (per the OpenFeature spec) rather than a + // surprising "true"/"false" string. + throw new TypeMismatchError(`Flag '${flagKey}' has no string variant (boolean flag).`) + } + return { value: resolved.variant, variant: resolved.variant, reason: reasonFor(resolved) } +} + +export function resolveNumberDetails( + result: PostHogFlagResult | undefined, + flagKey: string +): ResolutionDetails { + const resolved = ensureResolved(result, flagKey) + if (resolved.variant === undefined) { + throw new TypeMismatchError(`Flag '${flagKey}' has no variant to parse as a number.`) + } + const value = Number(resolved.variant) + if (!Number.isFinite(value)) { + throw new TypeMismatchError(`Flag '${flagKey}' variant '${resolved.variant}' is not a valid number.`) + } + return { value, variant: resolved.variant, reason: reasonFor(resolved) } +} + +export function resolveObjectDetails( + result: PostHogFlagResult | undefined, + flagKey: string +): ResolutionDetails { + const resolved = ensureResolved(result, flagKey) + const payload = resolved.payload + if (typeof payload !== 'object' || payload === null) { + throw new TypeMismatchError(`Flag '${flagKey}' has no object/JSON payload.`) + } + return { value: payload as T, variant: resolved.variant, reason: reasonFor(resolved) } +} diff --git a/packages/openfeature-web-provider/src/provider.ts b/packages/openfeature-web-provider/src/provider.ts new file mode 100644 index 0000000000..4e870d0590 --- /dev/null +++ b/packages/openfeature-web-provider/src/provider.ts @@ -0,0 +1,151 @@ +/** + * Official PostHog provider for the OpenFeature **web** SDK + * (`@openfeature/web-sdk`), backed by a configured `posthog-js` client. + * + * The browser model is single-user and synchronous: `posthog-js` owns the user + * identity and keeps flags in memory, so evaluation is synchronous and the + * static evaluation context is reconciled into the SDK whenever it changes + * (the OpenFeature `onContextChange` contract). + */ +import type { EvaluationContext, JsonValue, Provider, ResolutionDetails } from '@openfeature/web-sdk' +import type { PostHog } from 'posthog-js' + +import { + resolveBooleanDetails, + resolveNumberDetails, + resolveObjectDetails, + resolveStringDetails, + splitContext, +} from './mapping' + +export interface PostHogWebProviderOptions { + /** + * Forwarded to `getFeatureFlagResult` to control `$feature_flag_called` + * capture. Defaults to `true` so PostHog flag analytics (and experiments) + * keep working. + */ + sendFeatureFlagEvents?: boolean + /** + * Maximum time in milliseconds that `initialize()` / `onContextChange()` will + * wait for `posthog-js` to (re)load flags before resolving anyway. This is a + * safety net: if the SDK never fires its flags callback after a reload (e.g. + * `posthog.init()` was never called, or a network request fails silently), + * the OpenFeature client would otherwise stay stuck in NOT_READY forever. On + * timeout the provider becomes ready and serves whatever flags are cached. + * Defaults to 5000. + */ + reloadTimeoutMs?: number +} + +/** + * OpenFeature web provider backed by a configured `posthog-js` client. + * + * The caller owns the PostHog client lifecycle (init it, and manage the user + * identity via `posthog.identify()` as usual). This provider does **not** call + * `identify()` — `targetingKey` is therefore not used to switch users, since in + * the browser the host app owns identity. The rest of the evaluation context is + * reconciled into the SDK so it influences flag evaluation: + * - reserved `groups` -> `posthog.group(type, key)` + * - reserved `groupProperties` -> `posthog.group(type, key, properties)` + * - every other attribute -> `posthog.setPersonPropertiesForFlags(...)` + * + * Note that `group()` and `setPersonPropertiesForFlags()` persist on the client + * and may emit a `$groupidentify` event — the standard `posthog-js` behaviour. + * + * Flag-type mapping mirrors the server provider (boolean -> `enabled`, string + * -> `variant`, number -> parsed `variant`, object -> `payload`). + */ +export class PostHogWebProvider implements Provider { + public readonly runsOn = 'client' + public readonly metadata = { name: 'PostHogWebProvider' } as const + + private readonly _client: PostHog + private readonly _sendFeatureFlagEvents: boolean + private readonly _reloadTimeoutMs: number + + constructor(client: PostHog, options: PostHogWebProviderOptions = {}) { + this._client = client + this._sendFeatureFlagEvents = options.sendFeatureFlagEvents ?? true + this._reloadTimeoutMs = options.reloadTimeoutMs ?? 5000 + } + + async initialize(context?: EvaluationContext): Promise { + await this._reconcile(context) + } + + async onContextChange(_oldContext: EvaluationContext, newContext: EvaluationContext): Promise { + await this._reconcile(newContext) + } + + resolveBooleanEvaluation(flagKey: string, _defaultValue: boolean): ResolutionDetails { + return resolveBooleanDetails(this._evaluate(flagKey), flagKey) + } + + resolveStringEvaluation(flagKey: string, _defaultValue: string): ResolutionDetails { + return resolveStringDetails(this._evaluate(flagKey), flagKey) + } + + resolveNumberEvaluation(flagKey: string, _defaultValue: number): ResolutionDetails { + return resolveNumberDetails(this._evaluate(flagKey), flagKey) + } + + resolveObjectEvaluation(flagKey: string, _defaultValue: T): ResolutionDetails { + return resolveObjectDetails(this._evaluate(flagKey), flagKey) + } + + private _evaluate(flagKey: string): ReturnType { + return this._client.getFeatureFlagResult(flagKey, { send_event: this._sendFeatureFlagEvents }) + } + + /** + * Reconcile the evaluation context into `posthog-js` and wait for flags to + * (re)load. Person properties and groups are applied with their own reloads + * suppressed where possible; a single trailing `reloadFeatureFlags()` (which + * `posthog-js` debounces with any others) is then awaited so OpenFeature + * treats reconciliation as complete only once fresh flags are available. + */ + private async _reconcile(context?: EvaluationContext): Promise { + const { personProperties, groups, groupProperties } = splitContext(context) + + if (Object.keys(personProperties).length > 0) { + this._client.setPersonPropertiesForFlags(personProperties, false) + } + for (const [groupType, groupKey] of Object.entries(groups)) { + this._client.group(groupType, groupKey, groupProperties[groupType]) + } + + await this._reloadFlags() + } + + private _reloadFlags(): Promise { + return new Promise((resolve) => { + let settled = false + let subscribed = false + + const finish = (): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + unsubscribe() + resolve() + } + + // `onFeatureFlags` fires synchronously on subscribe if flags are already + // loaded; ignore that immediate call (it happens before `subscribed` is + // set) and resolve only on the callback that follows our reload request. + const unsubscribe = this._client.onFeatureFlags(() => { + if (subscribed) { + finish() + } + }) + subscribed = true + // Safety net: resolve anyway if posthog-js never delivers the callback + // (uninitialised SDK, silent network failure, ...) so the OpenFeature + // client can't get stuck NOT_READY forever. + const timer = setTimeout(finish, this._reloadTimeoutMs) + this._client.reloadFeatureFlags() + }) + } +} diff --git a/packages/openfeature-web-provider/tsconfig.build.json b/packages/openfeature-web-provider/tsconfig.build.json new file mode 100644 index 0000000000..fcd4c2a7c6 --- /dev/null +++ b/packages/openfeature-web-provider/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src" + }, + "include": ["./src/**/*"], + "exclude": ["./src/__tests__/**/*", "./src/**/*.spec.ts"] +} diff --git a/packages/openfeature-web-provider/tsconfig.json b/packages/openfeature-web-provider/tsconfig.json new file mode 100644 index 0000000000..996c948cc4 --- /dev/null +++ b/packages/openfeature-web-provider/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@posthog-tooling/tsconfig-base", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "skipLibCheck": true, + "lib": ["ES2023", "DOM"] + }, + "include": ["./src/**/*", "rslib.config.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ddf6a6d6a..5fcd0fe037 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -787,6 +787,36 @@ importers: specifier: ^4.1.2 version: 4.1.3(@parcel/watcher@2.5.1)(@types/node@20.19.9)(@vercel/functions@3.4.3(@aws-sdk/credential-provider-web-identity@3.972.29))(@vue/compiler-sfc@3.5.22)(db0@0.3.4)(eslint@9.37.0(jiti@2.6.1))(ioredis@5.8.1)(lightningcss@1.27.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.53.3)(terser@5.44.1)(typescript@5.8.2)(vite@7.3.1(@types/node@20.19.9)(jiti@2.6.1)(lightningcss@1.27.0)(terser@5.44.1)(yaml@2.8.0))(yaml@2.8.0) + packages/openfeature-web-provider: + devDependencies: + '@openfeature/core': + specifier: ^1.11.0 + version: 1.11.0 + '@openfeature/web-sdk': + specifier: ^1.9.0 + version: 1.9.0(@openfeature/core@1.11.0) + '@posthog-tooling/tsconfig-base': + specifier: workspace:* + version: link:../../tooling/tsconfig-base + '@rslib/core': + specifier: 'catalog:' + version: 0.10.6(@microsoft/api-extractor@7.55.1(@types/node@22.16.5))(typescript@5.8.2) + '@types/jest': + specifier: 'catalog:' + version: 29.5.14 + '@types/node': + specifier: ^22.15.23 + version: 22.16.5 + jest: + specifier: 'catalog:' + version: 29.7.0(@types/node@22.16.5)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@22.16.5)(typescript@5.8.2)) + posthog-js: + specifier: workspace:* + version: link:../browser + ts-jest: + specifier: 'catalog:' + version: 29.4.11(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@22.16.5)(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@22.16.5)(typescript@5.8.2)))(typescript@5.8.2) + packages/plugin-utils: dependencies: cross-spawn: @@ -4376,6 +4406,14 @@ packages: peerDependencies: zod: ^4.0.0 + '@openfeature/core@1.11.0': + resolution: {integrity: sha512-P0u3/ht/oZCQT89fOed+laLk0kZR529a825cS02uPDglxXbE97irWYpDAeRGGVETIzKfuy+H2g8c3Ccv/tXJNQ==} + + '@openfeature/web-sdk@1.9.0': + resolution: {integrity: sha512-FCrNfqvE/thHVfCNU0KKx1SD7rk+1wE2UaR5B5OPZl917QJv6AsKRwaI3N+SVgwXWI07GgtXP6hNlTVb49PGhg==} + peerDependencies: + '@openfeature/core': ^1.11.0 + '@opentelemetry/api-logs@0.200.0': resolution: {integrity: sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q==} engines: {node: '>=8.0.0'} @@ -20263,6 +20301,12 @@ snapshots: - utf-8-validate - ws + '@openfeature/core@1.11.0': {} + + '@openfeature/web-sdk@1.9.0(@openfeature/core@1.11.0)': + dependencies: + '@openfeature/core': 1.11.0 + '@opentelemetry/api-logs@0.200.0': dependencies: '@opentelemetry/api': 1.9.0 From 85f34db3de943ced090cdc6adfc74ae8054142e0 Mon Sep 17 00:00:00 2001 From: "Gustavo H. Strassburger" Date: Fri, 3 Jul 2026 11:56:11 -0300 Subject: [PATCH 2/2] fix(openfeature-web): address review feedback (haacked, Greptile, manoel) Provider/mapping fixes (haacked): - Disabled or unmatched flags now resolve to the caller's default with reason=DEFAULT instead of throwing TypeMismatchError (which set reason=ERROR and fired error hooks on every ordinary disabled-flag read). Threaded the default value through resolveString/Number/Object. - onContextChange now deep-compares old vs new context and skips reconciliation (group()/reloadFeatureFlags()) when nothing changed. - Added an engines block. - Restructured _reloadFlags so the cleanup closure no longer references timer/unsubscribe before their declarations (temporal-dead-zone fragility). Tests: - Added disabled-flag resolution cases (reason=DEFAULT, no errorCode) and the missing number/object TypeMismatchError branches to the parameterised tables. - Converted the reload-timeout test to fake timers (Greptile) so it's deterministic and instant instead of waiting on real wall-clock time. Repo wiring (manoel): - Listed the package in the root README and CHANGELOG package lists and the PR template's "Libraries affected" checklist. - Added examples/example-openfeature-web (Vite) demonstrating the provider. Generated-By: PostHog Code Task-Id: 46a3f8c9-fbcd-460e-9457-fca583955e5a --- .github/pull_request_template.md | 1 + CHANGELOG.md | 1 + README.md | 1 + examples/example-openfeature-web/.gitignore | 6 ++ examples/example-openfeature-web/.npmrc | 2 + examples/example-openfeature-web/README.md | 32 +++++++ examples/example-openfeature-web/index.html | 14 +++ examples/example-openfeature-web/package.json | 21 +++++ .../pnpm-workspace.yaml | 12 +++ examples/example-openfeature-web/src/main.ts | 44 ++++++++++ .../example-openfeature-web/src/vite-env.d.ts | 1 + .../example-openfeature-web/tsconfig.json | 16 ++++ .../openfeature-web-provider/package.json | 3 + .../src/__tests__/provider.spec.ts | 86 ++++++++++++++++--- .../openfeature-web-provider/src/mapping.ts | 26 ++++-- .../openfeature-web-provider/src/provider.ts | 57 +++++++++--- 16 files changed, 296 insertions(+), 27 deletions(-) create mode 100644 examples/example-openfeature-web/.gitignore create mode 100644 examples/example-openfeature-web/.npmrc create mode 100644 examples/example-openfeature-web/README.md create mode 100644 examples/example-openfeature-web/index.html create mode 100644 examples/example-openfeature-web/package.json create mode 100644 examples/example-openfeature-web/pnpm-workspace.yaml create mode 100644 examples/example-openfeature-web/src/main.ts create mode 100644 examples/example-openfeature-web/src/vite-env.d.ts create mode 100644 examples/example-openfeature-web/tsconfig.json diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 833fbdbc2b..dab205c532 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -24,6 +24,7 @@ - [ ] @posthog/next - [ ] @posthog/nextjs-config - [ ] @posthog/nuxt +- [ ] @posthog/openfeature-web-provider - [ ] @posthog/rollup-plugin - [ ] @posthog/webpack-plugin - [ ] @posthog/types diff --git a/CHANGELOG.md b/CHANGELOG.md index e26d4d9f09..1acdfc3baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,4 +11,5 @@ PostHog JS is a mono-repo. The changelogs are in the individual packages - [@posthog/convex](./packages/convex/CHANGELOG.md) - [@posthog/next](./packages/next/CHANGELOG.md) - [@posthog/nextjs-config](./packages/nextjs-config/CHANGELOG.md) +- [@posthog/openfeature-web-provider](./packages/openfeature-web-provider/CHANGELOG.md) - [@posthog/plugin-utils](./packages/plugin-utils/CHANGELOG.md) diff --git a/README.md b/README.md index 420c25b2f9..7408defd22 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Please see the main [PostHog docs](https://posthog.com/docs). - [@posthog/convex](./packages/convex/README.md) - [@posthog/nextjs-config](./packages/nextjs-config/README.md) - [@posthog/nuxt](./packages/nuxt/README.md) +- [@posthog/openfeature-web-provider](./packages/openfeature-web-provider/README.md) - [@posthog/plugin-utils](./packages/plugin-utils/README.md) - [@posthog/rollup-plugin](./packages/rollup-plugin/README.md) - [@posthog/types](./packages/types/README.md) diff --git a/examples/example-openfeature-web/.gitignore b/examples/example-openfeature-web/.gitignore new file mode 100644 index 0000000000..5b9f6ac26a --- /dev/null +++ b/examples/example-openfeature-web/.gitignore @@ -0,0 +1,6 @@ +/node_modules +/dist +.DS_Store +.env +.env.local +*.log diff --git a/examples/example-openfeature-web/.npmrc b/examples/example-openfeature-web/.npmrc new file mode 100644 index 0000000000..c0f538dcf3 --- /dev/null +++ b/examples/example-openfeature-web/.npmrc @@ -0,0 +1,2 @@ +# Minimum age (in days) before a package version can be installed +min-release-age=7 diff --git a/examples/example-openfeature-web/README.md b/examples/example-openfeature-web/README.md new file mode 100644 index 0000000000..4919aaa001 --- /dev/null +++ b/examples/example-openfeature-web/README.md @@ -0,0 +1,32 @@ +# example-openfeature-web + +Minimal browser example of the **[`@posthog/openfeature-web-provider`](../../packages/openfeature-web-provider)** — evaluating PostHog feature flags through the standard [OpenFeature](https://openfeature.dev) web SDK, backed by `posthog-js`. + +The server equivalent lives in [`example-openfeature-node`](../example-openfeature-node) (see `@posthog/openfeature-node-provider`). + +## What it shows + +- Initializing `posthog-js` (you own the client lifecycle and user identity). +- Registering `PostHogWebProvider` with `OpenFeature.setProviderAndWait(...)`. +- Synchronous flag evaluation via the vendor-neutral OpenFeature client + (`getBooleanValue` / `getStringValue` / `getObjectValue`). + +See [`src/main.ts`](./src/main.ts). + +## Run it + +These examples install workspace packages as tarballs (see [`../README.md`](../README.md)). + +1. From the repo root, build the tarballs: + ```bash + pnpm package:watch + ``` +2. In this folder, install and start: + ```bash + pnpm install + pnpm start + ``` +3. Open the printed local URL. Set `VITE_POSTHOG_KEY` / `VITE_POSTHOG_HOST` in a + `.env` file (or edit `src/main.ts`) and use real flag keys from your project. + +> The lockfile is generated on first `pnpm install` per the examples tarball workflow. diff --git a/examples/example-openfeature-web/index.html b/examples/example-openfeature-web/index.html new file mode 100644 index 0000000000..faed740e86 --- /dev/null +++ b/examples/example-openfeature-web/index.html @@ -0,0 +1,14 @@ + + + + + + PostHog OpenFeature web provider example + + +

PostHog + OpenFeature (web)

+

Flags evaluated through the standard OpenFeature API, backed by posthog-js:

+
Evaluating flags…
+ + + diff --git a/examples/example-openfeature-web/package.json b/examples/example-openfeature-web/package.json new file mode 100644 index 0000000000..d022ac6517 --- /dev/null +++ b/examples/example-openfeature-web/package.json @@ -0,0 +1,21 @@ +{ + "name": "example-openfeature-web", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.15.0", + "type": "module", + "scripts": { + "start": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@openfeature/web-sdk": "^1.9.0", + "@posthog/openfeature-web-provider": "*", + "posthog-js": "*" + }, + "devDependencies": { + "typescript": "^5.8.2", + "vite": "^6.2.0" + } +} diff --git a/examples/example-openfeature-web/pnpm-workspace.yaml b/examples/example-openfeature-web/pnpm-workspace.yaml new file mode 100644 index 0000000000..50d3e5defb --- /dev/null +++ b/examples/example-openfeature-web/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +packages: + - . + +overrides: + node-forge: 1.3.2 + +nodeLinker: hoisted +pnpmfile: ../.pnpmfile.cjs +minimumReleaseAge: 10080 + +blockExoticSubdeps: true +trustPolicy: no-downgrade diff --git a/examples/example-openfeature-web/src/main.ts b/examples/example-openfeature-web/src/main.ts new file mode 100644 index 0000000000..413726a29e --- /dev/null +++ b/examples/example-openfeature-web/src/main.ts @@ -0,0 +1,44 @@ +import { OpenFeature } from '@openfeature/web-sdk' +import { PostHogWebProvider } from '@posthog/openfeature-web-provider' +import posthog from 'posthog-js' + +// Configure via a local .env file (VITE_POSTHOG_KEY / VITE_POSTHOG_HOST) or edit inline. +const PROJECT_API_KEY = import.meta.env.VITE_POSTHOG_KEY ?? '' +const API_HOST = import.meta.env.VITE_POSTHOG_HOST ?? 'https://us.i.posthog.com' + +async function main(): Promise { + // 1. You own the posthog-js lifecycle. Manage user identity as usual + // (posthog.identify(...)); the provider never calls identify(). + posthog.init(PROJECT_API_KEY, { api_host: API_HOST }) + + // 2. Register the PostHog provider with OpenFeature. setProviderAndWait + // resolves once the provider's initial flag load has settled. + await OpenFeature.setProviderAndWait(new PostHogWebProvider(posthog)) + const client = OpenFeature.getClient() + + // 3. Evaluate flags through the vendor-neutral OpenFeature API. Web evaluation + // is synchronous. Swap these keys for real flags in your project. + const result = { + 'my-boolean-flag': client.getBooleanValue('my-boolean-flag', false), + 'my-multivariate-flag': client.getStringValue('my-multivariate-flag', 'control'), + 'my-payload-flag': client.getObjectValue('my-payload-flag', {}), + } + + render(result) + + // Optional: pass extra evaluation context (person/group properties). The + // provider reconciles it into posthog-js and reloads flags before returning. + // await OpenFeature.setContext({ plan: 'enterprise', groups: { organization: 'acme' } }) +} + +function render(result: Record): void { + const el = document.getElementById('app') + if (el) { + el.textContent = JSON.stringify(result, null, 2) + } +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error('OpenFeature example failed:', err) +}) diff --git a/examples/example-openfeature-web/src/vite-env.d.ts b/examples/example-openfeature-web/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/examples/example-openfeature-web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/example-openfeature-web/tsconfig.json b/examples/example-openfeature-web/tsconfig.json new file mode 100644 index 0000000000..d08b0948ab --- /dev/null +++ b/examples/example-openfeature-web/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2020", + "lib": ["dom", "dom.iterable", "esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/packages/openfeature-web-provider/package.json b/packages/openfeature-web-provider/package.json index cb286a9ef6..5f13046279 100644 --- a/packages/openfeature-web-provider/package.json +++ b/packages/openfeature-web-provider/package.json @@ -42,6 +42,9 @@ "prepublishOnly": "pnpm lint && pnpm test:unit && pnpm build", "package": "pnpm pack --out $PACKAGE_DEST/%s.tgz" }, + "engines": { + "node": "^20.20.0 || >=22.22.0" + }, "files": [ "dist/" ], diff --git a/packages/openfeature-web-provider/src/__tests__/provider.spec.ts b/packages/openfeature-web-provider/src/__tests__/provider.spec.ts index 42c136fda4..916fd10764 100644 --- a/packages/openfeature-web-provider/src/__tests__/provider.spec.ts +++ b/packages/openfeature-web-provider/src/__tests__/provider.spec.ts @@ -99,6 +99,24 @@ describe('PostHogWebProvider', () => { (p) => p.resolveObjectEvaluation('flag', {}), { value: { a: 1 } }, ], + [ + 'disabled flag as string → default / DEFAULT', + { key: 'flag', enabled: false }, + (p) => p.resolveStringEvaluation('flag', 'fallback'), + { value: 'fallback', reason: StandardResolutionReasons.DEFAULT }, + ], + [ + 'disabled flag as number → default / DEFAULT', + { key: 'flag', enabled: false }, + (p) => p.resolveNumberEvaluation('flag', 42), + { value: 42, reason: StandardResolutionReasons.DEFAULT }, + ], + [ + 'disabled flag as object → default / DEFAULT', + { key: 'flag', enabled: false }, + (p) => p.resolveObjectEvaluation('flag', { fallback: true }), + { value: { fallback: true }, reason: StandardResolutionReasons.DEFAULT }, + ], ])('resolves %s', (_name, result, resolve, expected) => { const { client } = makeClient(result) expect(resolve(new PostHogWebProvider(client))).toMatchObject(expected) @@ -106,11 +124,35 @@ describe('PostHogWebProvider', () => { it.each<[string, FlagResult | undefined, Resolve, ErrorCode]>([ [ - 'string from a boolean flag (no variant)', + 'string from an enabled boolean flag (no variant)', { key: 'flag', enabled: true }, (p) => p.resolveStringEvaluation('flag', 'x'), ErrorCode.TYPE_MISMATCH, ], + [ + 'number from an enabled boolean flag (no variant)', + { key: 'flag', enabled: true }, + (p) => p.resolveNumberEvaluation('flag', 0), + ErrorCode.TYPE_MISMATCH, + ], + [ + 'number from a non-numeric variant', + { key: 'flag', enabled: true, variant: 'not-a-number' }, + (p) => p.resolveNumberEvaluation('flag', 0), + ErrorCode.TYPE_MISMATCH, + ], + [ + 'object from an enabled flag with no payload', + { key: 'flag', enabled: true, variant: 'x' }, + (p) => p.resolveObjectEvaluation('flag', {}), + ErrorCode.TYPE_MISMATCH, + ], + [ + 'object from a non-object payload', + { key: 'flag', enabled: true, variant: 'x', payload: 'not-an-object' }, + (p) => p.resolveObjectEvaluation('flag', {}), + ErrorCode.TYPE_MISMATCH, + ], [ 'missing flag (client returns undefined)', undefined, @@ -152,16 +194,24 @@ describe('PostHogWebProvider', () => { it('resolves on timeout when the flags callback never fires', async () => { // onFeatureFlags never invokes its callback and reloadFeatureFlags is a no-op, - // so only the reloadTimeoutMs safety net can settle initialize(). - const client = { - getFeatureFlagResult: jest.fn(), - reloadFeatureFlags: jest.fn(), - onFeatureFlags: jest.fn(() => () => {}), - setPersonPropertiesForFlags: jest.fn(), - group: jest.fn(), - } as unknown as PostHog - const provider = new PostHogWebProvider(client, { reloadTimeoutMs: 20 }) - await expect(provider.initialize()).resolves.toBeUndefined() + // so only the reloadTimeoutMs safety net can settle initialize(). Fake timers + // keep this deterministic and instant rather than waiting on real wall-clock. + jest.useFakeTimers() + try { + const client = { + getFeatureFlagResult: jest.fn(), + reloadFeatureFlags: jest.fn(), + onFeatureFlags: jest.fn(() => () => {}), + setPersonPropertiesForFlags: jest.fn(), + group: jest.fn(), + } as unknown as PostHog + const provider = new PostHogWebProvider(client, { reloadTimeoutMs: 20 }) + const pending = provider.initialize() + await jest.runAllTimersAsync() + await expect(pending).resolves.toBeUndefined() + } finally { + jest.useRealTimers() + } }) it('reconciles person properties and groups from the context on change', async () => { @@ -194,6 +244,20 @@ describe('PostHogWebProvider', () => { await provider.onContextChange({}, { groups: { organization: 'acme' } }) expect(group).toHaveBeenCalledWith('organization', 'acme', undefined) }) + + it('skips reconciliation when the context is deeply unchanged', async () => { + const { client, reloadFeatureFlags, setPersonPropertiesForFlags, group } = makeClient({ + key: 'flag', + enabled: true, + }) + const provider = new PostHogWebProvider(client) + const ctx = { targetingKey: 'user_1', plan: 'pro', groups: { organization: 'acme' } } + // Deeply-equal but distinct object instances (as a re-render would produce). + await provider.onContextChange(ctx, { ...ctx, groups: { ...ctx.groups } }) + expect(reloadFeatureFlags).not.toHaveBeenCalled() + expect(setPersonPropertiesForFlags).not.toHaveBeenCalled() + expect(group).not.toHaveBeenCalled() + }) }) describe('end-to-end through the OpenFeature client', () => { diff --git a/packages/openfeature-web-provider/src/mapping.ts b/packages/openfeature-web-provider/src/mapping.ts index 62c3e2c4ed..391cfc700a 100644 --- a/packages/openfeature-web-provider/src/mapping.ts +++ b/packages/openfeature-web-provider/src/mapping.ts @@ -110,13 +110,19 @@ export function resolveBooleanDetails( export function resolveStringDetails( result: PostHogFlagResult | undefined, - flagKey: string + flagKey: string, + defaultValue: string ): ResolutionDetails { const resolved = ensureResolved(result, flagKey) if (resolved.variant === undefined) { - // A boolean flag has no string variant. Surface a type mismatch so the - // caller gets its default value (per the OpenFeature spec) rather than a - // surprising "true"/"false" string. + if (!resolved.enabled) { + // A disabled or unmatched flag has no variant. Resolve to the caller's + // default (per the OpenFeature spec) rather than throwing — a throw would + // set reason=ERROR and fire every registered error hook on an ordinary + // disabled-flag read. + return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT } + } + // An enabled boolean flag has no string variant: a genuine type mismatch. throw new TypeMismatchError(`Flag '${flagKey}' has no string variant (boolean flag).`) } return { value: resolved.variant, variant: resolved.variant, reason: reasonFor(resolved) } @@ -124,10 +130,14 @@ export function resolveStringDetails( export function resolveNumberDetails( result: PostHogFlagResult | undefined, - flagKey: string + flagKey: string, + defaultValue: number ): ResolutionDetails { const resolved = ensureResolved(result, flagKey) if (resolved.variant === undefined) { + if (!resolved.enabled) { + return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT } + } throw new TypeMismatchError(`Flag '${flagKey}' has no variant to parse as a number.`) } const value = Number(resolved.variant) @@ -139,11 +149,15 @@ export function resolveNumberDetails( export function resolveObjectDetails( result: PostHogFlagResult | undefined, - flagKey: string + flagKey: string, + defaultValue: T ): ResolutionDetails { const resolved = ensureResolved(result, flagKey) const payload = resolved.payload if (typeof payload !== 'object' || payload === null) { + if (!resolved.enabled) { + return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT } + } throw new TypeMismatchError(`Flag '${flagKey}' has no object/JSON payload.`) } return { value: payload as T, variant: resolved.variant, reason: reasonFor(resolved) } diff --git a/packages/openfeature-web-provider/src/provider.ts b/packages/openfeature-web-provider/src/provider.ts index 4e870d0590..38e44e9925 100644 --- a/packages/openfeature-web-provider/src/provider.ts +++ b/packages/openfeature-web-provider/src/provider.ts @@ -73,7 +73,14 @@ export class PostHogWebProvider implements Provider { await this._reconcile(context) } - async onContextChange(_oldContext: EvaluationContext, newContext: EvaluationContext): Promise { + async onContextChange(oldContext: EvaluationContext, newContext: EvaluationContext): Promise { + // The web SDK runs this handler on every setContext() call with no equality + // check, so a host that re-sets an equivalent context (e.g. a React + // integration passing a fresh object each render) would otherwise trigger a + // $groupidentify and a flag reload every time. Skip when nothing changed. + if (deepEqual(oldContext, newContext)) { + return + } await this._reconcile(newContext) } @@ -81,16 +88,16 @@ export class PostHogWebProvider implements Provider { return resolveBooleanDetails(this._evaluate(flagKey), flagKey) } - resolveStringEvaluation(flagKey: string, _defaultValue: string): ResolutionDetails { - return resolveStringDetails(this._evaluate(flagKey), flagKey) + resolveStringEvaluation(flagKey: string, defaultValue: string): ResolutionDetails { + return resolveStringDetails(this._evaluate(flagKey), flagKey, defaultValue) } - resolveNumberEvaluation(flagKey: string, _defaultValue: number): ResolutionDetails { - return resolveNumberDetails(this._evaluate(flagKey), flagKey) + resolveNumberEvaluation(flagKey: string, defaultValue: number): ResolutionDetails { + return resolveNumberDetails(this._evaluate(flagKey), flagKey, defaultValue) } - resolveObjectEvaluation(flagKey: string, _defaultValue: T): ResolutionDetails { - return resolveObjectDetails(this._evaluate(flagKey), flagKey) + resolveObjectEvaluation(flagKey: string, defaultValue: T): ResolutionDetails { + return resolveObjectDetails(this._evaluate(flagKey), flagKey, defaultValue) } private _evaluate(flagKey: string): ReturnType { @@ -121,14 +128,16 @@ export class PostHogWebProvider implements Provider { return new Promise((resolve) => { let settled = false let subscribed = false + // Cleanups are collected as they're created so `finish` never has to + // reference `unsubscribe`/`timer` before they're declared below. + const cleanups: Array<() => void> = [] const finish = (): void => { if (settled) { return } settled = true - clearTimeout(timer) - unsubscribe() + cleanups.forEach((fn) => fn()) resolve() } @@ -140,12 +149,40 @@ export class PostHogWebProvider implements Provider { finish() } }) - subscribed = true // Safety net: resolve anyway if posthog-js never delivers the callback // (uninitialised SDK, silent network failure, ...) so the OpenFeature // client can't get stuck NOT_READY forever. const timer = setTimeout(finish, this._reloadTimeoutMs) + cleanups.push(unsubscribe, () => clearTimeout(timer)) + subscribed = true this._client.reloadFeatureFlags() }) } } + +/** + * Deep structural equality for evaluation contexts (which are JSON-like, with + * possibly-nested `groupProperties`). Used to skip redundant reconciliation + * when the host re-sets an equivalent context. + */ +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) { + return true + } + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime() + } + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) { + return false + } + const aKeys = Object.keys(a as Record) + const bKeys = Object.keys(b as Record) + if (aKeys.length !== bKeys.length) { + return false + } + return aKeys.every( + (key) => + Object.prototype.hasOwnProperty.call(b, key) && + deepEqual((a as Record)[key], (b as Record)[key]) + ) +}