diff --git a/.github/workflows/ux-label-mention.yml b/.github/workflows/ux-label-mention.yml
new file mode 100644
index 0000000..cb9f65c
--- /dev/null
+++ b/.github/workflows/ux-label-mention.yml
@@ -0,0 +1,16 @@
+name: Mention Teams
+
+on:
+ pull_request:
+ types:
+ - labeled
+
+jobs:
+ react-to-labels:
+ uses: configcat/.github/.github/workflows/ux-label-mention.yml@master
+ with:
+ id: ${{ github.event.pull_request.number }}
+ repo: ${{ github.repository }}
+ secrets:
+ gh_token: ${{ secrets.GITHUB_TOKEN }}
+ slack_webhook_url: ${{ secrets.TEXT_REVIEW_SLACK_WEBHOOK_URL }}
diff --git a/package-lock.json b/package-lock.json
index 6774cc6..76da4e1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "configcat-react",
- "version": "4.7.0",
+ "version": "4.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "configcat-react",
- "version": "4.7.0",
+ "version": "4.8.0",
"license": "MIT",
"dependencies": {
"configcat-common": "9.3.1",
diff --git a/package.json b/package.json
index 067502a..4f283b9 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "configcat-react",
- "version": "4.7.0",
+ "version": "4.8.0",
"scripts": {
"build": "npm run build:esm && npm run build:cjs",
"build:esm": "tsc -p tsconfig.build.esm.json && gulp esm",
diff --git a/src/FlagOverrides.test.tsx b/src/FlagOverrides.test.tsx
new file mode 100644
index 0000000..dd09bc8
--- /dev/null
+++ b/src/FlagOverrides.test.tsx
@@ -0,0 +1,152 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import React from "react";
+import { useFeatureFlag } from "./ConfigCatHooks";
+import ConfigCatProvider from "./ConfigCatProvider";
+import type { IQueryStringProvider, IReactAutoPollOptions } from ".";
+import { OverrideBehaviour, createFlagOverridesFromQueryParams } from ".";
+
+const sdkKey = "PKDVCLf-Hq-h-kCzMp-L7Q/psuH7BGHoUmdONrzzUOY7A";
+
+afterEach(cleanup);
+
+describe("Flag Overrides", () => {
+ it("Query string override should work - changes not watched", async () => {
+ const TestComponent = () => {
+ const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
+ return (
Feature flag value: {featureFlag}
);
+ };
+
+ const queryStringProvider = {
+ currentValue: "?cc-stringDefaultCat=OVERRIDE_CAT&stringDefaultCat=NON_OVERRIDE_CAT"
+ } satisfies IQueryStringProvider;
+
+ const options: IReactAutoPollOptions = {
+ flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, false, void 0, queryStringProvider)
+ };
+
+ const ui = ;
+
+ await render(ui);
+ await screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 });
+
+ cleanup();
+ queryStringProvider.currentValue = "?cc-stringDefaultCat=CHANGED_OVERRIDE_CAT";
+
+ await render(ui);
+ await screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 });
+ });
+
+ it("Query string override should work - changes watched", async () => {
+ const TestComponent = () => {
+ const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
+ return (Feature flag value: {featureFlag}
);
+ };
+
+ const queryStringProvider = {
+ currentValue: "?cc-stringDefaultCat=OVERRIDE_CAT"
+ } satisfies IQueryStringProvider;
+
+ const options: IReactAutoPollOptions = {
+ flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, true, void 0, queryStringProvider)
+ };
+
+ const ui = ;
+
+ await render(ui);
+ await screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 });
+
+ cleanup();
+ queryStringProvider.currentValue = "?cc-stringDefaultCat=CHANGED_OVERRIDE_CAT";
+
+ await render(ui);
+ await screen.findByText("Feature flag value: CHANGED_OVERRIDE_CAT", void 0, { timeout: 2000 });
+ });
+
+ it("Query string override should work - parsed query string", async () => {
+ const TestComponent = () => {
+ const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
+ return (Feature flag value: {featureFlag}
);
+ };
+
+ const queryStringProvider = {
+ currentValue: { "cc-stringDefaultCat": "OVERRIDE_CAT" as string | ReadonlyArray }
+ } satisfies IQueryStringProvider;
+
+ const options: IReactAutoPollOptions = {
+ flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, true, void 0, queryStringProvider)
+ };
+
+ const ui = ;
+
+ await render(ui);
+ await screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 });
+
+ cleanup();
+ queryStringProvider.currentValue = { "cc-stringDefaultCat": ["OVERRIDE_CAT", "CHANGED_OVERRIDE_CAT"] };
+
+ await render(ui);
+ await screen.findByText("Feature flag value: CHANGED_OVERRIDE_CAT", void 0, { timeout: 2000 });
+ });
+
+ it("Query string override should work - respects custom parameter name prefix", async () => {
+ const TestComponent = () => {
+ const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
+ return (Feature flag value: {featureFlag}
);
+ };
+
+ const queryStringProvider = {
+ currentValue: "?stringDefaultCat=OVERRIDE_CAT&cc-stringDefaultCat=NON_OVERRIDE_CAT"
+ } satisfies IQueryStringProvider;
+
+ const options: IReactAutoPollOptions = {
+ flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, void 0, "", queryStringProvider)
+ };
+
+ const ui = ;
+
+ await render(ui);
+ await screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 });
+ });
+
+ it("Query string override should work - respects force string value suffix", async () => {
+ const TestComponent = () => {
+ const { value: boolFeatureFlag } = useFeatureFlag("boolDefaultFalse", false);
+ const { value: stringFeatureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
+ return (Feature flag values: {boolFeatureFlag ? "true" : "false"} ({typeof boolFeatureFlag}), {stringFeatureFlag} ({typeof stringFeatureFlag})
);
+ };
+
+ const queryStringProvider = {
+ currentValue: "?stringDefaultCat;str=TRUE&boolDefaultFalse=TRUE"
+ } satisfies IQueryStringProvider;
+
+ const options: IReactAutoPollOptions = {
+ flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, void 0, "", queryStringProvider)
+ };
+
+ const ui = ;
+
+ await render(ui);
+ await screen.findByText("Feature flag values: true (boolean), TRUE (string)", void 0, { timeout: 2000 });
+ });
+
+ it("Query string override should work - handles query string edge cases", async () => {
+ const TestComponent = () => {
+ const { value: featureFlag } = useFeatureFlag("stringDefaultCat", "NOT_CAT");
+ return (Feature flag value: {featureFlag}
);
+ };
+
+ const queryStringProvider = {
+ currentValue: "?&some&=garbage&&cc-stringDefaultCat=OVERRIDE_CAT&=cc-stringDefaultCat&cc-stringDefaultCat"
+ } satisfies IQueryStringProvider;
+
+ const options: IReactAutoPollOptions = {
+ flagOverrides: createFlagOverridesFromQueryParams(OverrideBehaviour.LocalOverRemote, void 0, void 0, queryStringProvider)
+ };
+
+ const ui = ;
+
+ await render(ui);
+ await screen.findByText("Feature flag value:", void 0, { timeout: 2000 });
+ await expect(() => screen.findByText("Feature flag value: OVERRIDE_CAT", void 0, { timeout: 2000 })).rejects.toThrow();
+ });
+});
diff --git a/src/FlagOverrides.ts b/src/FlagOverrides.ts
new file mode 100644
index 0000000..30be838
--- /dev/null
+++ b/src/FlagOverrides.ts
@@ -0,0 +1,207 @@
+import { type FlagOverrides, OverrideBehaviour, type SettingValue, createFlagOverridesFromMap } from "configcat-common";
+
+const DEFAULT_PARAM_PREFIX = "cc-";
+const FORCE_STRING_VALUE_SUFFIX = ";str";
+
+export interface IQueryStringProvider {
+ readonly currentValue?: string | { [key: string]: string | ReadonlyArray };
+}
+
+class DefaultQueryStringProvider implements IQueryStringProvider {
+ get currentValue() { return window?.location.search; }
+}
+
+let defaultQueryStringProvider: DefaultQueryStringProvider | undefined;
+
+type SettingMap = { [name: string]: Setting };
+
+export class QueryParamsOverrideDataSource implements IOverrideDataSource {
+ private readonly watchChanges?: boolean;
+ private readonly paramPrefix: string;
+ private readonly queryStringProvider: IQueryStringProvider;
+ private queryString: string | undefined;
+ private settings: SettingMap;
+
+ constructor(watchChanges?: boolean, paramPrefix?: string, queryStringProvider?: IQueryStringProvider) {
+ this.watchChanges = watchChanges;
+ this.paramPrefix = paramPrefix ?? DEFAULT_PARAM_PREFIX;
+
+ queryStringProvider ??= defaultQueryStringProvider ??= new DefaultQueryStringProvider();
+ this.queryStringProvider = queryStringProvider;
+
+ const currentQueryStringOrParams = queryStringProvider.currentValue;
+ this.settings = extractSettings(currentQueryStringOrParams, this.paramPrefix);
+ this.queryString = getQueryString(currentQueryStringOrParams);
+ }
+
+ getOverrides(): Promise {
+ return Promise.resolve(this.getOverridesSync());
+ }
+
+ getOverridesSync(): SettingMap {
+ if (this.watchChanges) {
+ const currentQueryStringOrParams = this.queryStringProvider.currentValue;
+ const currentQueryString = getQueryString(currentQueryStringOrParams);
+ if (this.queryString !== currentQueryString) {
+ this.settings = extractSettings(currentQueryStringOrParams, this.paramPrefix);
+ this.queryString = currentQueryString;
+ }
+ }
+
+ return this.settings;
+ }
+}
+
+function getQueryString(queryStringOrParams: string | { [key: string]: string | ReadonlyArray } | undefined) {
+ if (queryStringOrParams == null) {
+ return "";
+ }
+
+ if (typeof queryStringOrParams === "string") {
+ return queryStringOrParams;
+ }
+
+ let queryString = "", separator = "?";
+
+ for (const key in queryStringOrParams) {
+ if (!Object.prototype.hasOwnProperty.call(queryStringOrParams, key)) continue;
+
+ const values = queryStringOrParams[key] as string | string[];
+ let value: string, length: number;
+
+ if (!Array.isArray(values)) value = values, length = 1;
+ else if (values.length) value = values[0], length = values.length;
+ else continue;
+
+ for (let i = 0; ;) {
+ queryString += separator + encodeURIComponent(key) + "=" + encodeURIComponent(value);
+ if (++i >= length) break;
+ separator = "&";
+ value = values[i];
+ }
+ }
+
+ return queryString;
+}
+
+function extractSettings(queryStringOrParams: string | { [key: string]: string | ReadonlyArray } | undefined, paramPrefix: string) {
+ const settings: SettingMap = {};
+
+ if (typeof queryStringOrParams === "string") {
+ extractSettingFromQueryString(queryStringOrParams, paramPrefix, settings);
+ }
+ else if (queryStringOrParams != null) {
+ extractSettingsFromQueryParams(queryStringOrParams, paramPrefix, settings);
+ }
+
+ return settings;
+}
+
+function extractSettingsFromQueryParams(queryParams: { [key: string]: string | ReadonlyArray } | undefined, paramPrefix: string, settings: SettingMap) {
+ for (const key in queryParams) {
+ if (!Object.prototype.hasOwnProperty.call(queryParams, key)) continue;
+
+ const values = queryParams[key] as string | string[];
+ let value: string, length: number;
+
+ if (!Array.isArray(values)) value = values, length = 1;
+ else if (values.length) value = values[0], length = values.length;
+ else continue;
+
+ for (let i = 0; ;) {
+ extractSettingFromQueryParam(key, value, paramPrefix, settings);
+ if (++i >= length) break;
+ value = values[i];
+ }
+ }
+}
+
+function extractSettingFromQueryString(queryString: string, paramPrefix: string, settings: SettingMap) {
+ if (!queryString
+ || queryString.lastIndexOf("?", 0) < 0) { // identical to `!queryString.startsWith("?")`
+ return;
+ }
+
+ const parts = queryString.substring(1).split("&");
+ for (let part of parts) {
+ part = part.replace(/\+/g, " ");
+ const index = part.indexOf("=");
+
+ const key = decodeURIComponent(index >= 0 ? part.substring(0, index) : part);
+ const value = index >= 0 ? decodeURIComponent(part.substring(index + 1)) : "";
+
+ extractSettingFromQueryParam(key, value, paramPrefix, settings);
+ }
+}
+
+function extractSettingFromQueryParam(key: string, value: string, paramPrefix: string, settings: SettingMap) {
+ if (!key
+ || key.length <= paramPrefix.length
+ || key.lastIndexOf(paramPrefix, 0) < 0) { // identical to `!key.startsWith(paramPrefix)`
+ return;
+ }
+
+ key = key.substring(paramPrefix.length);
+
+ const interpretValueAsString = key.length > FORCE_STRING_VALUE_SUFFIX.length
+ && key.indexOf(FORCE_STRING_VALUE_SUFFIX, key.length - FORCE_STRING_VALUE_SUFFIX.length) >= 0; // identical to `key.endsWith(strSuffix)`
+
+ if (interpretValueAsString) {
+ key = key.substring(0, key.length - FORCE_STRING_VALUE_SUFFIX.length);
+ }
+ else {
+ value = parseSettingValue(value) as unknown as string;
+ }
+
+ settings[key] = settingConstuctor.fromValue(value);
+}
+
+function parseSettingValue(value: string): NonNullable {
+ switch (value.toLowerCase()) {
+ case "false":
+ return false;
+ case "true":
+ return true;
+ default:
+ const number = parseFloatStrict(value);
+ return !isNaN(number) ? number : value;
+ }
+}
+
+function parseFloatStrict(value: string): number {
+ // NOTE: JS's float to string conversion is too forgiving, it converts whitespace string to 0 and accepts hex numbers.
+
+ if (!value.length || /^\s*$|^\s*0[^\d.e]/.test(value)) {
+ return NaN;
+ }
+
+ return +value;
+}
+
+// The following types and functions aren't part of configcat-common's public API,
+// so for now we need this hack to make things work.
+// TODO: move the flag override data source into the new unified JS SDK and
+// get rid of this workaround as soon as it's released.
+
+type IOverrideDataSource = FlagOverrides["dataSource"];
+
+type Setting = ReturnType[""];
+
+type FlagOverridesConstructor = {
+ new(dataSource: IOverrideDataSource, behaviour: OverrideBehaviour): FlagOverrides;
+}
+
+type SettingConstructor = {
+ fromValue(value: NonNullable): Setting;
+};
+
+const [flagOverridesConstructor, settingConstuctor] = (() => {
+ const dummyFlagOverrides = createFlagOverridesFromMap({ "$": 0 }, OverrideBehaviour.LocalOnly);
+ const dummySetting = dummyFlagOverrides.dataSource.getOverridesSync()["$"];
+ return [
+ (dummyFlagOverrides as any).constructor as FlagOverridesConstructor,
+ (dummySetting as any).constructor as SettingConstructor
+ ];
+})();
+
+export { flagOverridesConstructor };
diff --git a/src/index.ts b/src/index.ts
index cbf3414..7c425be 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,13 +1,36 @@
"use client";
-import type { IAutoPollOptions, IConfigCatLogger, ILazyLoadingOptions, IManualPollOptions } from "configcat-common";
+import type { FlagOverrides, IAutoPollOptions, IConfigCatLogger, ILazyLoadingOptions, IManualPollOptions, OverrideBehaviour } from "configcat-common";
import type { GetValueType, WithConfigCatClientProps } from "./ConfigCatHOC";
import withConfigCatClient from "./ConfigCatHOC";
import { useConfigCatClient, useFeatureFlag } from "./ConfigCatHooks";
import ConfigCatProvider from "./ConfigCatProvider";
+import type { IQueryStringProvider } from "./FlagOverrides";
+import { QueryParamsOverrideDataSource, flagOverridesConstructor } from "./FlagOverrides";
export { createConsoleLogger, createFlagOverridesFromMap } from "configcat-common";
+/**
+ * Creates an instance of `FlagOverrides` that uses query string parameters as data source.
+ * @param behaviour The override behaviour.
+ * Specifies whether the local values should override the remote values
+ * or local values should only be used when a remote value doesn't exist
+ * or the local values should be used only.
+ * @param watchChanges If set to `true`, the query string will be tracked for changes.
+ * @param paramPrefix The parameter name prefix used to indicate which query string parameters
+ * specify feature flag override values. Parameters whose name doesn't start with the
+ * prefix will be ignored. Defaults to `cc-`.
+ * @param queryStringProvider The provider object used to obtain the query string.
+ * Defaults to a provider that returns the value of `window.location.search`.
+ */
+export function createFlagOverridesFromQueryParams(behaviour: OverrideBehaviour,
+ watchChanges?: boolean, paramPrefix?: string, queryStringProvider?: IQueryStringProvider
+): FlagOverrides {
+ return new flagOverridesConstructor(new QueryParamsOverrideDataSource(watchChanges, paramPrefix, queryStringProvider), behaviour);
+}
+
+export type { IQueryStringProvider };
+
/** Options used to configure the ConfigCat SDK in the case of Auto Polling mode. */
export type IReactAutoPollOptions = IAutoPollOptions;
@@ -68,4 +91,3 @@ export { OverrideBehaviour } from "configcat-common";
export { ClientCacheState, RefreshResult } from "configcat-common";
export type { IProvidesHooks, HookEvents } from "configcat-common";
-