Update CBS Mobile App codebase
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import { useMemo, type ReactElement, type ReactNode } from 'react';
|
||||
|
||||
import type { FeatureKey } from '../constants/featureKeys';
|
||||
import { featureService, isFeatureEnabled } from './featureService';
|
||||
import { useFeatureVersion } from './useFeature';
|
||||
|
||||
type GateMode = 'all' | 'any';
|
||||
|
||||
/**
|
||||
* Resolves a set of keys against the current config, re-evaluating whenever a
|
||||
* source layer changes. An empty key list resolves to `false` (fail closed).
|
||||
*/
|
||||
const useGateResult = (keys: readonly FeatureKey[], mode: GateMode): boolean => {
|
||||
const version = useFeatureVersion();
|
||||
const signature = keys.join('|');
|
||||
|
||||
return useMemo(() => {
|
||||
if (keys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return mode === 'any'
|
||||
? keys.some((key) => isFeatureEnabled(key))
|
||||
: keys.every((key) => isFeatureEnabled(key));
|
||||
// `signature` stands in for `keys` so an inline array literal does not thrash the memo.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signature, mode, version]);
|
||||
};
|
||||
|
||||
type FeatureGateProps = {
|
||||
/** Feature(s) guarding this subtree. */
|
||||
feature: FeatureKey | readonly FeatureKey[];
|
||||
/**
|
||||
* How to combine multiple keys. `all` (default) requires every key; `any`
|
||||
* requires at least one. Irrelevant for a single key.
|
||||
*/
|
||||
mode?: GateMode;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders its children only while the feature is enabled, and renders **nothing**
|
||||
* otherwise.
|
||||
*
|
||||
* <FeatureGate feature={FeatureKey.PAYMENTS_BENEFICIARIES_ADD}>
|
||||
* <AddBeneficiaryButton />
|
||||
* </FeatureGate>
|
||||
*
|
||||
* There is deliberately no `fallback` / placeholder prop. A disabled feature must
|
||||
* leave no trace in the UI — no greyed-out control, no "coming soon" copy, no
|
||||
* hidden-but-present node — so the only two outcomes are the children or `null`.
|
||||
*
|
||||
* This hides UI; it does not neuter a handler some other path still holds. Guard
|
||||
* the handler with `useFeatureAction` / `guardFeature`, and the screen with
|
||||
* `FeatureRoute`.
|
||||
*/
|
||||
export const FeatureGate = ({
|
||||
feature,
|
||||
mode = 'all',
|
||||
children,
|
||||
}: FeatureGateProps): ReactElement | null => {
|
||||
const keys = useMemo(
|
||||
() => (Array.isArray(feature) ? (feature as FeatureKey[]) : [feature as FeatureKey]),
|
||||
[feature],
|
||||
);
|
||||
|
||||
return useGateResult(keys, mode) ? <>{children}</> : null;
|
||||
};
|
||||
|
||||
type DynamicFeatureGateProps = {
|
||||
/**
|
||||
* Feature reference as a raw string — from JSON-driven UI or a remote payload.
|
||||
* An unknown string hides the subtree; omitting the prop leaves it visible, so
|
||||
* untagged components render exactly as before.
|
||||
*/
|
||||
feature?: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* String-keyed gate for JSON-driven UI, where the feature name arrives as data
|
||||
* rather than as a compile-time constant:
|
||||
*
|
||||
* { "type": "button", "label": "Create User", "feature": "agent.customers.create" }
|
||||
*
|
||||
* Prefer `FeatureGate` with a `FeatureKey` wherever the key is known at compile
|
||||
* time; this exists only for genuinely dynamic content.
|
||||
*/
|
||||
export const DynamicFeatureGate = ({
|
||||
feature,
|
||||
children,
|
||||
}: DynamicFeatureGateProps): ReactElement | null => {
|
||||
const version = useFeatureVersion();
|
||||
|
||||
const enabled = useMemo(
|
||||
() => feature === undefined || featureService.isEnabledKey(feature),
|
||||
// `version` is the signal that the service's mutable state changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[feature, version],
|
||||
);
|
||||
|
||||
return enabled ? <>{children}</> : null;
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
|
||||
import type { FeatureKey } from '../constants/featureKeys';
|
||||
import { featureService, isFeatureEnabled } from './featureService';
|
||||
import { FeatureSource, type FeatureOverrides } from './types';
|
||||
import { useFeatureVersion } from './useFeature';
|
||||
|
||||
type FeatureContextValue = {
|
||||
/** Imperative check, for callbacks that should not subscribe to re-renders. */
|
||||
isEnabled: (key: FeatureKey) => boolean;
|
||||
/** False until remote/tenant sources have been resolved (or skipped). */
|
||||
ready: boolean;
|
||||
/** Bumps whenever any source layer changes. */
|
||||
version: number;
|
||||
};
|
||||
|
||||
const FeatureContext = createContext<FeatureContextValue | undefined>(undefined);
|
||||
|
||||
type FeatureProviderProps = PropsWithChildren<{
|
||||
/**
|
||||
* Loads the remote layer (Firebase Remote Config, config API, …). Resolving to
|
||||
* `undefined` leaves the layer untouched. Failures are swallowed so the app
|
||||
* always falls back to the compiled-in defaults rather than blocking startup.
|
||||
*/
|
||||
loadRemoteOverrides?: () => Promise<FeatureOverrides | undefined>;
|
||||
/** Loads per-tenant/role overrides once the session is known. */
|
||||
loadTenantOverrides?: () => Promise<FeatureOverrides | undefined>;
|
||||
/**
|
||||
* Applies configuration that the one-loader-per-layer slots above cannot
|
||||
* express, and runs *before* them.
|
||||
*
|
||||
* Two cases need it. The CBS config endpoint returns the org-wide and
|
||||
* role-scoped switches in a single response, so one fetch has to write two
|
||||
* layers; and the persisted local overrides have to be read back into the user
|
||||
* layer, which is not a network load at all. Both own their own layer writes,
|
||||
* so this returns nothing.
|
||||
*
|
||||
* Failures are swallowed here too — a feature layer that cannot be loaded must
|
||||
* degrade to the layers beneath it, never block the app from starting.
|
||||
*/
|
||||
hydrate?: () => Promise<void>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Wires the feature service into the React tree.
|
||||
*
|
||||
* The hooks work without this provider — they subscribe to the service directly
|
||||
* — so mounting it is only required for two things: resolving the async source
|
||||
* layers at startup, and exposing `ready` for callers that want to hold UI back
|
||||
* until remote config has landed.
|
||||
*
|
||||
* In development it also validates the static config and logs any key that is
|
||||
* declared without config, or whose dot path implies a parent that does not
|
||||
* exist (which would silently make it un-gateable by that parent).
|
||||
*/
|
||||
export const FeatureProvider = ({
|
||||
children,
|
||||
loadRemoteOverrides,
|
||||
loadTenantOverrides,
|
||||
hydrate,
|
||||
}: FeatureProviderProps) => {
|
||||
const version = useFeatureVersion();
|
||||
const [ready, setReady] = useState(!loadRemoteOverrides && !loadTenantOverrides && !hydrate);
|
||||
|
||||
useEffect(() => {
|
||||
if (!__DEV__) {
|
||||
return;
|
||||
}
|
||||
|
||||
const issues = featureService.validate();
|
||||
|
||||
if (issues.length > 0) {
|
||||
console.warn(
|
||||
`[features] ${issues.length} configuration issue(s):\n` +
|
||||
issues.map((issue) => ` • [${issue.problem}] ${issue.detail}`).join('\n'),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
if (hydrate) {
|
||||
try {
|
||||
await hydrate();
|
||||
} catch (error) {
|
||||
if (__DEV__) {
|
||||
console.warn('[features] Hydration failed; using defaults.', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const layers: Array<[FeatureSource, (() => Promise<FeatureOverrides | undefined>) | undefined]> =
|
||||
[
|
||||
[FeatureSource.Remote, loadRemoteOverrides],
|
||||
[FeatureSource.Tenant, loadTenantOverrides],
|
||||
];
|
||||
|
||||
for (const [source, loader] of layers) {
|
||||
if (!loader) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const overrides = await loader();
|
||||
|
||||
if (!cancelled && overrides) {
|
||||
featureService.setSource(source, overrides);
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) {
|
||||
console.warn(`[features] Failed to load "${source}" overrides; using defaults.`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setReady(true);
|
||||
}
|
||||
};
|
||||
|
||||
// `run` swallows its own failures, so there is nothing to catch here.
|
||||
run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadRemoteOverrides, loadTenantOverrides, hydrate]);
|
||||
|
||||
const value = useMemo<FeatureContextValue>(
|
||||
() => ({ isEnabled: isFeatureEnabled, ready, version }),
|
||||
[ready, version],
|
||||
);
|
||||
|
||||
return <FeatureContext.Provider value={value}>{children}</FeatureContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Access to the feature context. Throws when used outside the provider, which
|
||||
* catches wiring mistakes early — most components should use `useFeature`
|
||||
* instead, which needs no provider.
|
||||
*/
|
||||
export const useFeatureContext = (): FeatureContextValue => {
|
||||
const context = useContext(FeatureContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useFeatureContext must be used within a <FeatureProvider>.');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { FeatureKey } from '../constants/featureKeys';
|
||||
|
||||
/**
|
||||
* The features the shipped admin switchboard may touch.
|
||||
*
|
||||
* The dev screen lists all 182 keys; this list is deliberately much shorter,
|
||||
* because the two screens answer different questions. The dev screen is for
|
||||
* "does gating work at all", and every key belongs there. This one is for a
|
||||
* member of staff shutting a product down in the field, so it holds only the
|
||||
* switches that make sense as an operational decision — product areas and the
|
||||
* transactional actions inside them.
|
||||
*
|
||||
* Two kinds of key are kept off it on purpose:
|
||||
*
|
||||
* - **Anything under `auth`.** Turning off sign-in or registration from inside
|
||||
* the app locks the handset out of the app, including out of this screen. That
|
||||
* belongs to remote config, where it can be undone from the server.
|
||||
* - **`profile.featureFlags` itself**, for the same reason: a switchboard that
|
||||
* can switch itself off is a one-way door.
|
||||
*
|
||||
* Every entry is a real product decision, and each is grouped as it should
|
||||
* appear in the UI.
|
||||
*/
|
||||
export const ADMIN_TOGGLEABLE_FEATURES: ReadonlyArray<{
|
||||
title: string;
|
||||
keys: readonly FeatureKey[];
|
||||
}> = [
|
||||
{
|
||||
title: 'Payments',
|
||||
keys: [
|
||||
FeatureKey.PAYMENTS,
|
||||
FeatureKey.PAYMENTS_TRANSFER,
|
||||
FeatureKey.PAYMENTS_BENEFICIARIES_ADD,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Cards',
|
||||
keys: [FeatureKey.CARDS, FeatureKey.CARDS_CONTROLS_FREEZE, FeatureKey.CARDS_APPLY_CREDIT],
|
||||
},
|
||||
{
|
||||
title: 'Lending & cover',
|
||||
keys: [FeatureKey.LOANS, FeatureKey.LOANS_APPLY, FeatureKey.INSURANCE, FeatureKey.INSURANCE_BUY],
|
||||
},
|
||||
{
|
||||
title: 'Deposits & services',
|
||||
keys: [
|
||||
FeatureKey.SERVICES,
|
||||
FeatureKey.SERVICES_DEPOSITS,
|
||||
FeatureKey.SERVICES_DEPOSITS_OPEN_FD,
|
||||
FeatureKey.SERVICES_DEPOSITS_OPEN_RD,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Home & discovery',
|
||||
keys: [
|
||||
FeatureKey.HOME_ACCOUNTS_MMID,
|
||||
FeatureKey.SEARCH_GLOBAL,
|
||||
FeatureKey.NOTIFICATIONS_LIST,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Field agent app',
|
||||
keys: [FeatureKey.AGENT, FeatureKey.AGENT_COLLECTIONS],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,291 @@
|
||||
import { FeatureKey } from '../constants/featureKeys';
|
||||
import type { FeatureDefinition, FeatureOverrides } from './types';
|
||||
|
||||
/**
|
||||
* Default feature configuration — the single source of truth compiled into the
|
||||
* bundle.
|
||||
*
|
||||
* This is a flat `Record<FeatureKey, …>` rather than a nested object on purpose:
|
||||
* the `Record` makes TypeScript reject the file if any declared key is missing
|
||||
* config, which a nested object literal cannot guarantee. The hierarchy is not
|
||||
* lost — it lives in the dot-notation keys themselves (`services.cheque.hub` is
|
||||
* a child of `services.cheque`), and the service derives parents from that path.
|
||||
*
|
||||
* Rules:
|
||||
* - Disabling a parent disables every descendant, whatever their own flag says.
|
||||
* e.g. `services.cheque = false` hides the cheque hub, book request, stop
|
||||
* payment, tracking and positive pay in one edit.
|
||||
* - Entries are grouped and indented to read like the tree they represent.
|
||||
*/
|
||||
export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
// ---------------------------------------------------------------- Auth ----
|
||||
[FeatureKey.AUTH]: { enabled: true, description: 'Authentication module' },
|
||||
[FeatureKey.AUTH_LOGIN]: { enabled: true },
|
||||
[FeatureKey.AUTH_LOGIN_USER_ROLE]: { enabled: true, description: 'Customer login tab' },
|
||||
[FeatureKey.AUTH_LOGIN_AGENT_ROLE]: { enabled: true, description: 'Agent login tab' },
|
||||
[FeatureKey.AUTH_LOGIN_BIOMETRIC]: { enabled: true },
|
||||
[FeatureKey.AUTH_LOGIN_FORGOT_PASSWORD]: { enabled: true },
|
||||
[FeatureKey.AUTH_REGISTER]: {
|
||||
enabled: true,
|
||||
description: 'Device registration: lookup, OTP, MPIN setup',
|
||||
},
|
||||
[FeatureKey.AUTH_LOGIN_CREATE_ACCOUNT]: { enabled: true },
|
||||
[FeatureKey.AUTH_LOGOUT]: { enabled: true },
|
||||
|
||||
// ---------------------------------------------------------------- Home ----
|
||||
[FeatureKey.HOME]: { enabled: true, description: 'Home tab' },
|
||||
[FeatureKey.HOME_ACCOUNTS]: { enabled: true },
|
||||
[FeatureKey.HOME_ACCOUNTS_CAROUSEL]: { enabled: true },
|
||||
[FeatureKey.HOME_ACCOUNTS_BALANCE_TOGGLE]: { enabled: true },
|
||||
[FeatureKey.HOME_ACCOUNTS_MINI_STATEMENT]: { enabled: true },
|
||||
[FeatureKey.HOME_ACCOUNTS_DETAILS]: { enabled: true },
|
||||
[FeatureKey.HOME_ACCOUNTS_EPASSBOOK]: { enabled: true },
|
||||
[FeatureKey.HOME_ACCOUNTS_REKYC]: { enabled: true },
|
||||
[FeatureKey.HOME_ACCOUNTS_VIDEO_KYC]: { enabled: true },
|
||||
[FeatureKey.HOME_ACCOUNTS_MMID]: {
|
||||
enabled: true,
|
||||
description: 'Accounts and MMID, for receiving money by IMPS',
|
||||
},
|
||||
|
||||
[FeatureKey.HOME_QUICK_ACTIONS]: { enabled: true },
|
||||
[FeatureKey.HOME_QUICK_ACTIONS_TRANSFER]: { enabled: true },
|
||||
[FeatureKey.HOME_QUICK_ACTIONS_BILL_PAY]: { enabled: true },
|
||||
[FeatureKey.HOME_QUICK_ACTIONS_AIRTIME]: { enabled: true },
|
||||
[FeatureKey.HOME_QUICK_ACTIONS_LOANS]: { enabled: false, reason: 'Destination module is off' },
|
||||
[FeatureKey.HOME_QUICK_ACTIONS_CARDS]: { enabled: false, reason: 'Destination module is off' },
|
||||
[FeatureKey.HOME_QUICK_ACTIONS_STATS]: { enabled: true },
|
||||
|
||||
[FeatureKey.HOME_SERVICES]: { enabled: true },
|
||||
[FeatureKey.HOME_SERVICES_TRANSFER]: { enabled: true },
|
||||
[FeatureKey.HOME_SERVICES_SELF_TRANSFER]: { enabled: true },
|
||||
[FeatureKey.HOME_SERVICES_BILL_PAY]: { enabled: true },
|
||||
[FeatureKey.HOME_SERVICES_COLLECT_REQUEST]: { enabled: true },
|
||||
[FeatureKey.HOME_SERVICES_MANAGE_PAYEE]: { enabled: true },
|
||||
[FeatureKey.HOME_SERVICES_SUPPORT]: { enabled: true },
|
||||
[FeatureKey.HOME_SERVICES_INSURANCE]: { enabled: false, reason: 'Destination module is off' },
|
||||
[FeatureKey.HOME_SERVICES_DEPOSITS]: { enabled: true },
|
||||
[FeatureKey.HOME_SERVICES_GOLD_LOAN]: { enabled: false, reason: 'Destination module is off' },
|
||||
|
||||
[FeatureKey.HOME_INSIGHTS]: { enabled: true },
|
||||
[FeatureKey.HOME_PROMO_BANNER]: { enabled: true },
|
||||
|
||||
// -------------------------------------------------------------- Search ----
|
||||
[FeatureKey.SEARCH]: { enabled: true },
|
||||
[FeatureKey.SEARCH_GLOBAL]: { enabled: true },
|
||||
|
||||
// ------------------------------------------------------- Notifications ----
|
||||
[FeatureKey.NOTIFICATIONS]: { enabled: true },
|
||||
[FeatureKey.NOTIFICATIONS_LIST]: { enabled: true },
|
||||
[FeatureKey.NOTIFICATIONS_MARK_READ]: { enabled: true },
|
||||
|
||||
// ------------------------------------------------------------ Payments ----
|
||||
[FeatureKey.PAYMENTS]: { enabled: true, description: 'Payments tab' },
|
||||
[FeatureKey.PAYMENTS_HUB]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_BENEFICIARIES]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_BENEFICIARIES_LIST]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_BENEFICIARIES_ADD]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_BENEFICIARIES_DELETE]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_TRANSFER]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_IMPS]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_NEFT]: { enabled: false, reason: 'Target transfer form offers IMPS only' },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_RTGS]: { enabled: false, reason: 'Target transfer form offers IMPS only' },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_INTERNAL]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_SUBMIT]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_MOBILE]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_ACCOUNT]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_TRANSFER_OWN]: { enabled: true },
|
||||
|
||||
[FeatureKey.PAYMENTS_MMID]: {
|
||||
enabled: true,
|
||||
description: 'The MMIDs on this profile, for receiving over IMPS',
|
||||
},
|
||||
[FeatureKey.PAYMENTS_MMID_CANCEL]: { enabled: true },
|
||||
[FeatureKey.PAYMENTS_MMID_GENERATE]: { enabled: true },
|
||||
|
||||
[FeatureKey.PAYMENTS_UPI]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.PAYMENTS_UPI_SCAN]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
|
||||
// --------------------------------------------------------------- Cards ----
|
||||
[FeatureKey.CARDS]: { enabled: false, description: 'Cards tab', reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_OVERVIEW]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_TRANSACTIONS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_CONTROLS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_CONTROLS_FREEZE]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_CONTROLS_ONLINE]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_CONTROLS_INTERNATIONAL]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_CONTROLS_CONTACTLESS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_CONTROLS_ATM]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_CONTROLS_LIMITS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_CONTROLS_BLOCK]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_APPLY_CREDIT]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.CARDS_REQUEST_PHYSICAL]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
|
||||
// ------------------------------------------------------------ Services ----
|
||||
[FeatureKey.SERVICES]: { enabled: true, description: 'Services / hub tab' },
|
||||
[FeatureKey.SERVICES_HUB]: { enabled: true },
|
||||
|
||||
[FeatureKey.SERVICES_DEPOSITS]: { enabled: true },
|
||||
[FeatureKey.SERVICES_DEPOSITS_OVERVIEW]: { enabled: true },
|
||||
[FeatureKey.SERVICES_DEPOSITS_DETAILS]: { enabled: true },
|
||||
// A customer may look at the deposits they hold, but cannot start a new
|
||||
// one from the app. Both the route and every control that reaches it are
|
||||
// gated on these, so switching them on is all it takes to offer it.
|
||||
[FeatureKey.SERVICES_DEPOSITS_OPEN_FD]: { enabled: false, reason: 'Viewing deposits is offered; opening one is not' },
|
||||
[FeatureKey.SERVICES_DEPOSITS_OPEN_RD]: { enabled: false, reason: 'Viewing deposits is offered; opening one is not' },
|
||||
[FeatureKey.SERVICES_DEPOSITS_CALCULATOR]: { enabled: true },
|
||||
|
||||
[FeatureKey.SERVICES_CHEQUE]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CHEQUE_HUB]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CHEQUE_BOOK_REQUEST]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CHEQUE_TRACKING]: { enabled: true },
|
||||
[FeatureKey.SERVICES_CHEQUE_POSITIVE_PAY]: { enabled: true },
|
||||
|
||||
[FeatureKey.SERVICES_CERTIFICATES]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_HUB]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_LIST]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_BALANCE]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_INTEREST]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_SHARE]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_HYPOTHECATION]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_INSURANCE]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_TAX]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.SERVICES_CERTIFICATES_LOAN]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
|
||||
[FeatureKey.SERVICES_REQUESTS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.SERVICES_REQUESTS_HUB]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
|
||||
[FeatureKey.SERVICES_STANDING_INSTRUCTIONS]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
|
||||
[FeatureKey.SERVICES_FORM_15G15H]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
|
||||
[FeatureKey.SERVICES_NOMINATION]: { enabled: false, reason: 'Only entry point was the service-requests hub' },
|
||||
[FeatureKey.SERVICES_BRANCH_LOCATOR]: { enabled: true },
|
||||
|
||||
[FeatureKey.SERVICES_AWARENESS]: { enabled: true },
|
||||
[FeatureKey.SERVICES_AWARENESS_LIST]: { enabled: true },
|
||||
[FeatureKey.SERVICES_AWARENESS_DETAIL]: { enabled: true },
|
||||
|
||||
[FeatureKey.SERVICES_PROTECTION]: { enabled: true },
|
||||
[FeatureKey.SERVICES_PROTECTION_HUB]: { enabled: true },
|
||||
[FeatureKey.SERVICES_PROTECTION_SCHEME_DETAIL]: { enabled: true },
|
||||
[FeatureKey.SERVICES_PROTECTION_ENROLLMENT]: { enabled: true },
|
||||
[FeatureKey.SERVICES_PROTECTION_DEAF]: { enabled: true },
|
||||
[FeatureKey.SERVICES_PROTECTION_DEAF_CLAIM]: { enabled: true },
|
||||
[FeatureKey.SERVICES_PROTECTION_DICGC]: { enabled: true },
|
||||
|
||||
// Not built yet — these two only raise a "coming soon" toast today, so they
|
||||
// are off by default rather than advertising something that does not exist.
|
||||
[FeatureKey.SERVICES_SUPPORT_CHAT]: { enabled: true },
|
||||
[FeatureKey.SERVICES_HELP_CENTER]: { enabled: true },
|
||||
|
||||
[FeatureKey.SERVICES_BALANCE_ENQUIRY]: {
|
||||
enabled: false,
|
||||
reason: 'No CBS endpoint returns a balance',
|
||||
},
|
||||
[FeatureKey.SERVICES_FATE_OF_CHEQUE]: { enabled: true },
|
||||
[FeatureKey.SERVICES_TPIN_CHANGE]: { enabled: true },
|
||||
// Service-desk entry points to screens the home and profile stacks already
|
||||
// own. Switching one off takes the tile away, not the screen.
|
||||
[FeatureKey.SERVICES_MINI_STATEMENT]: { enabled: true },
|
||||
[FeatureKey.SERVICES_MPIN_CHANGE]: { enabled: true },
|
||||
|
||||
// --------------------------------------------------------------- Loans ----
|
||||
[FeatureKey.LOANS]: { enabled: false, description: 'Loans module', reason: 'Not in the target screen set' },
|
||||
[FeatureKey.LOANS_DASHBOARD]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.LOANS_TYPES]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.LOANS_ELIGIBILITY]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.LOANS_CALCULATOR]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.LOANS_APPLY]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.LOANS_TRACKING]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.LOANS_ACTIVE_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.LOANS_EMI_PAYMENT]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.LOANS_FORECLOSURE]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.LOANS_HISTORY]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.LOANS_DOCUMENTS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
|
||||
// ----------------------------------------------------------- Insurance ----
|
||||
[FeatureKey.INSURANCE]: { enabled: false, description: 'Insurance module', reason: 'Not in the target screen set' },
|
||||
[FeatureKey.INSURANCE_DASHBOARD]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.INSURANCE_BUY]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.INSURANCE_POLICIES]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.INSURANCE_POLICY_DETAILS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.INSURANCE_RENEWALS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.INSURANCE_CLAIMS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.INSURANCE_DOCUMENTS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.INSURANCE_CALCULATOR]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.INSURANCE_COMPARISON]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
[FeatureKey.INSURANCE_NOTIFICATIONS]: { enabled: false, reason: 'Not in the target screen set' },
|
||||
|
||||
// ------------------------------------------------------------- Profile ----
|
||||
[FeatureKey.PROFILE]: { enabled: true, description: 'Profile tab' },
|
||||
[FeatureKey.PROFILE_MAIN]: { enabled: true },
|
||||
[FeatureKey.PROFILE_EDIT]: { enabled: true },
|
||||
[FeatureKey.PROFILE_ACCOUNTS]: { enabled: true },
|
||||
[FeatureKey.PROFILE_ACCOUNTS_ADD]: { enabled: true },
|
||||
[FeatureKey.PROFILE_BENEFICIARIES_SHORTCUT]: { enabled: true },
|
||||
[FeatureKey.PROFILE_STATEMENTS_SHORTCUT]: { enabled: true },
|
||||
|
||||
[FeatureKey.PROFILE_SETTINGS]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SETTINGS_LANGUAGE]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SETTINGS_NOTIFICATIONS]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SETTINGS_PRIVACY]: { enabled: true },
|
||||
|
||||
[FeatureKey.PROFILE_SECURITY]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_CHANGE_PIN]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_CHANGE_PASSWORD]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_BIOMETRIC]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_TWO_FACTOR]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_DEVICES]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_LOGOUT_ALL]: { enabled: true },
|
||||
[FeatureKey.PROFILE_SECURITY_HIDE_BALANCE]: { enabled: true },
|
||||
|
||||
[FeatureKey.PROFILE_SUPPORT]: { enabled: true },
|
||||
[FeatureKey.PROFILE_LEGAL]: { enabled: true },
|
||||
[FeatureKey.PROFILE_ABOUT]: { enabled: true },
|
||||
[FeatureKey.PROFILE_FEATURE_FLAGS]: {
|
||||
// Off by default, on purpose. Enable it through remote config for the
|
||||
// tenants whose staff need it; a switchboard shipped on by default would
|
||||
// be reachable by every customer.
|
||||
enabled: false,
|
||||
description: 'Operational feature switchboard (admin)',
|
||||
},
|
||||
|
||||
// --------------------------------------------------------------- Agent ----
|
||||
[FeatureKey.AGENT]: { enabled: true, description: 'Field-agent application' },
|
||||
|
||||
[FeatureKey.AGENT_DASHBOARD]: { enabled: true },
|
||||
[FeatureKey.AGENT_DASHBOARD_MAIN]: { enabled: true },
|
||||
[FeatureKey.AGENT_DASHBOARD_ROUTE]: { enabled: true },
|
||||
|
||||
[FeatureKey.AGENT_CUSTOMERS]: { enabled: true },
|
||||
[FeatureKey.AGENT_CUSTOMERS_LIST]: { enabled: true },
|
||||
[FeatureKey.AGENT_CUSTOMERS_DETAIL]: { enabled: true },
|
||||
[FeatureKey.AGENT_CUSTOMERS_CREATE]: { enabled: true },
|
||||
[FeatureKey.AGENT_CUSTOMERS_EDIT]: { enabled: true },
|
||||
[FeatureKey.AGENT_CUSTOMERS_DELETE]: { enabled: true },
|
||||
[FeatureKey.AGENT_CUSTOMERS_INSTALLMENT_HISTORY]: { enabled: true },
|
||||
|
||||
[FeatureKey.AGENT_COLLECTIONS]: { enabled: true },
|
||||
[FeatureKey.AGENT_COLLECTIONS_DAILY]: { enabled: true },
|
||||
[FeatureKey.AGENT_COLLECTIONS_HISTORY]: { enabled: true },
|
||||
[FeatureKey.AGENT_COLLECTIONS_RECEIPT]: { enabled: true },
|
||||
|
||||
[FeatureKey.AGENT_LOANS]: { enabled: true },
|
||||
[FeatureKey.AGENT_LOANS_LIST]: { enabled: true },
|
||||
[FeatureKey.AGENT_LOANS_CREATE_REQUEST]: { enabled: true },
|
||||
[FeatureKey.AGENT_LOANS_ACCOUNT_OPENING]: { enabled: true },
|
||||
[FeatureKey.AGENT_LOANS_COMMISSION]: { enabled: true },
|
||||
|
||||
[FeatureKey.AGENT_PROFILE]: { enabled: true },
|
||||
[FeatureKey.AGENT_PROFILE_MAIN]: { enabled: true },
|
||||
[FeatureKey.AGENT_PROFILE_SMS_HISTORY]: { enabled: true },
|
||||
[FeatureKey.AGENT_PROFILE_CHANGE_PASSWORD]: { enabled: true },
|
||||
};
|
||||
|
||||
/**
|
||||
* Build-time / environment overrides, applied on top of the defaults.
|
||||
*
|
||||
* Keep this keyed off `env` (or `__DEV__`) rather than editing the defaults, so
|
||||
* a environment-specific switch never leaks into production config.
|
||||
*/
|
||||
export const ENVIRONMENT_FEATURE_OVERRIDES: FeatureOverrides = {};
|
||||
@@ -0,0 +1,124 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import { toFeatureKey, type FeatureKey } from '../constants/featureKeys';
|
||||
import { featureService } from './featureService';
|
||||
import { FeatureSource, type FeatureOverrides } from './types';
|
||||
|
||||
const STORAGE_KEY = 'bankcbs.featureOverrides';
|
||||
|
||||
/**
|
||||
* Device-local manual overrides — the `user` layer.
|
||||
*
|
||||
* Held in AsyncStorage rather than the Keychain: these are switches someone set
|
||||
* on this handset, not a secret, and they must be readable before sign-in so the
|
||||
* app renders the same way on a cold start as it did when they were set.
|
||||
*
|
||||
* They deliberately survive logout. Redux is wiped on sign-out (so no customer
|
||||
* data leaks between sessions) but a tester's overrides are configuration, not
|
||||
* session data, and losing them on every sign-out would make them useless.
|
||||
* `featureRemoteConfig` handles the tenant layer, which *is* session-scoped.
|
||||
*
|
||||
* A kill from the bank still wins over anything set here — see
|
||||
* `AUTHORITATIVE_FEATURE_SOURCES`.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Drops anything that is not a currently declared key.
|
||||
*
|
||||
* Necessary because stored overrides outlive the build that wrote them: a key
|
||||
* that was renamed or removed in an app update would otherwise sit in the layer
|
||||
* forever, matching nothing and impossible to find in the toggle UI.
|
||||
*/
|
||||
const narrowToKnownKeys = (raw: unknown): FeatureOverrides => {
|
||||
if (typeof raw !== 'object' || raw === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: FeatureOverrides = {};
|
||||
|
||||
for (const [candidate, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
const key = toFeatureKey(candidate);
|
||||
|
||||
if (key && typeof value === 'boolean') {
|
||||
result[key] = value;
|
||||
} else if (__DEV__) {
|
||||
console.warn(`[features] Dropping stored override "${candidate}" — not a declared key.`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const persist = async (overrides: FeatureOverrides) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(overrides));
|
||||
} catch (error) {
|
||||
// A failed write must not take the toggle with it: the in-memory layer is
|
||||
// already updated, so the switch works for this session and simply does not
|
||||
// survive a restart.
|
||||
if (__DEV__) {
|
||||
console.warn('[features] Could not persist local overrides.', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const featureOverrides = {
|
||||
/**
|
||||
* Loads stored overrides into the user layer. Resolves even on failure — a
|
||||
* corrupt or unreadable store must fall back to the compiled-in defaults
|
||||
* rather than block startup.
|
||||
*/
|
||||
async restore(): Promise<FeatureOverrides> {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (!stored) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const overrides = narrowToKnownKeys(JSON.parse(stored));
|
||||
featureService.setSource(FeatureSource.User, overrides);
|
||||
|
||||
return overrides;
|
||||
} catch (error) {
|
||||
if (__DEV__) {
|
||||
console.warn('[features] Could not read local overrides; using defaults.', error);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
/** Current local overrides. */
|
||||
current(): FeatureOverrides {
|
||||
return featureService.getSource(FeatureSource.User);
|
||||
},
|
||||
|
||||
/** Pins one feature on or off locally, and persists it. */
|
||||
async set(key: FeatureKey, value: boolean): Promise<void> {
|
||||
featureService.patchSource(FeatureSource.User, key, value);
|
||||
await persist(this.current());
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes a local pin so the feature follows the layers below it again.
|
||||
* Distinct from `set(key, false)` — that pins it off, this stops overriding.
|
||||
*/
|
||||
async clear(key: FeatureKey): Promise<void> {
|
||||
featureService.patchSource(FeatureSource.User, key, undefined);
|
||||
await persist(this.current());
|
||||
},
|
||||
|
||||
/** Drops every local override. The "reset to defaults" action. */
|
||||
async resetAll(): Promise<void> {
|
||||
featureService.setSource(FeatureSource.User, {});
|
||||
|
||||
try {
|
||||
await AsyncStorage.removeItem(STORAGE_KEY);
|
||||
} catch (error) {
|
||||
if (__DEV__) {
|
||||
console.warn('[features] Could not clear stored overrides.', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
import { toFeatureKey } from '../constants/featureKeys';
|
||||
import { apiClient } from '../services/api/apiClient';
|
||||
import { endpoints } from '../services/api/endpoints';
|
||||
import { featureService } from './featureService';
|
||||
import { FeatureSource, type FeatureOverrides } from './types';
|
||||
import type { FeatureConfigResult } from '../types/cbs';
|
||||
|
||||
const CACHE_KEY = 'bankcbs.featureConfig.lastKnown';
|
||||
|
||||
/**
|
||||
* Server-driven feature switches — the `remote` and `tenant` layers.
|
||||
*
|
||||
* Two things here are load-bearing rather than incidental:
|
||||
*
|
||||
* **The last successful payload is cached and re-applied on launch.** Without it
|
||||
* a handset that starts offline would fall back to the flags compiled into the
|
||||
* bundle — which is exactly how a feature the bank killed during an incident
|
||||
* comes *back on* for anyone who restarts the app on a bad connection. Caching
|
||||
* the instruction is what makes the kill switch survive being offline.
|
||||
*
|
||||
* **Unknown keys are dropped, not trusted.** The server may legitimately
|
||||
* publish keys for a newer app release; this build must ignore those rather than
|
||||
* store strings that match nothing. Combined with fail-closed resolution, a
|
||||
* malformed payload can only ever hide features, never reveal them.
|
||||
*/
|
||||
|
||||
interface CachedConfig {
|
||||
version: number;
|
||||
overrides: FeatureOverrides;
|
||||
roleOverrides: FeatureOverrides;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
/** Narrows a server payload to declared keys, discarding anything else. */
|
||||
const narrow = (raw: Record<string, boolean> | undefined, label: string): FeatureOverrides => {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: FeatureOverrides = {};
|
||||
const unknown: string[] = [];
|
||||
|
||||
for (const [candidate, value] of Object.entries(raw)) {
|
||||
const key = toFeatureKey(candidate);
|
||||
|
||||
if (key && typeof value === 'boolean') {
|
||||
result[key] = value;
|
||||
} else {
|
||||
unknown.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (unknown.length > 0 && __DEV__) {
|
||||
console.warn(
|
||||
`[features] Ignored ${unknown.length} unrecognised key(s) in ${label}: ${unknown.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const readCache = async (): Promise<CachedConfig | null> => {
|
||||
try {
|
||||
const stored = await AsyncStorage.getItem(CACHE_KEY);
|
||||
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(stored) as CachedConfig;
|
||||
|
||||
return {
|
||||
version: typeof parsed.version === 'number' ? parsed.version : 0,
|
||||
// Re-narrow on read: the cache may have been written by an older build
|
||||
// whose key set has since changed.
|
||||
overrides: narrow(parsed.overrides as Record<string, boolean>, 'cached config'),
|
||||
roleOverrides: narrow(parsed.roleOverrides as Record<string, boolean>, 'cached role config'),
|
||||
fetchedAt: typeof parsed.fetchedAt === 'number' ? parsed.fetchedAt : 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeCache = async (config: CachedConfig) => {
|
||||
try {
|
||||
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(config));
|
||||
} catch (error) {
|
||||
if (__DEV__) {
|
||||
console.warn('[features] Could not cache feature config.', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fixture used while `useMockServer` is on, since `/features/config` has no
|
||||
* backend behind it yet.
|
||||
*
|
||||
* Deliberately empty. A fixture that switched features off would make mock mode
|
||||
* differ from a fresh install for reasons nobody could find — to exercise a kill
|
||||
* switch locally, use the dev toggle screen, or add an entry here temporarily:
|
||||
*
|
||||
* overrides: { 'loans.apply': false }
|
||||
*/
|
||||
const MOCK_CONFIG: FeatureConfigResult = {
|
||||
version: 0,
|
||||
overrides: {},
|
||||
};
|
||||
|
||||
const fetchConfig = async (): Promise<FeatureConfigResult> => {
|
||||
const response = await apiClient.get<FeatureConfigResult>(endpoints.features.config);
|
||||
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const featureRemoteConfig = {
|
||||
/**
|
||||
* Applies the cached configuration, if any. Call before the first fetch so a
|
||||
* cold start on a dead connection still honours the bank's last instruction
|
||||
* instead of reverting to bundle defaults.
|
||||
*/
|
||||
async applyCached(): Promise<boolean> {
|
||||
const cached = await readCache();
|
||||
|
||||
if (!cached) {
|
||||
return false;
|
||||
}
|
||||
|
||||
featureService.setSource(FeatureSource.Remote, cached.overrides);
|
||||
featureService.setSource(FeatureSource.Tenant, cached.roleOverrides);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetches the live configuration and applies it to the remote and tenant
|
||||
* layers.
|
||||
*
|
||||
* Returns `false` when nothing could be applied, having left the existing
|
||||
* layers untouched — a failed refresh must never widen access, so the last
|
||||
* known configuration stays in force rather than falling back to defaults.
|
||||
*
|
||||
* Safe to call unauthenticated: the response then carries no `roleOverrides`
|
||||
* and the tenant layer is left as it is.
|
||||
*/
|
||||
async refresh({ useMock }: { useMock: boolean }): Promise<boolean> {
|
||||
try {
|
||||
const result = useMock ? MOCK_CONFIG : await fetchConfig();
|
||||
|
||||
const overrides = narrow(result.overrides, 'feature config');
|
||||
const hasRoleScope = result.roleOverrides !== undefined;
|
||||
const roleOverrides = narrow(result.roleOverrides, 'role feature config');
|
||||
|
||||
featureService.setSource(FeatureSource.Remote, overrides);
|
||||
|
||||
// Only touch the tenant layer when the response actually carried a role
|
||||
// scope. An unauthenticated fetch omits it, and clearing the layer then
|
||||
// would drop role rules that a previous authenticated fetch had applied.
|
||||
if (hasRoleScope) {
|
||||
featureService.setSource(FeatureSource.Tenant, roleOverrides);
|
||||
}
|
||||
|
||||
await writeCache({
|
||||
version: result.version ?? 0,
|
||||
overrides,
|
||||
roleOverrides: hasRoleScope
|
||||
? roleOverrides
|
||||
: featureService.getSource(FeatureSource.Tenant),
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
|
||||
if (__DEV__) {
|
||||
const killed = Object.entries(overrides).filter(([, on]) => !on).length;
|
||||
console.log(
|
||||
`[features] Applied remote config v${result.version ?? 0} — ` +
|
||||
`${Object.keys(overrides).length} override(s), ${killed} disabled.`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (__DEV__) {
|
||||
console.warn('[features] Feature config refresh failed; keeping current config.', error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clears the role-scoped layer on sign-out. The org-wide layer stays: it
|
||||
* applies to the sign-in screen too, and it is not session data.
|
||||
*/
|
||||
clearRoleScope() {
|
||||
featureService.setSource(FeatureSource.Tenant, {});
|
||||
},
|
||||
|
||||
/**
|
||||
* Forgets the signed-out customer's entitlements for good, keeping the bank's
|
||||
* org-wide instruction.
|
||||
*
|
||||
* `clearRoleScope` only drops the in-memory layer. The cache on disk still
|
||||
* held `roleOverrides`, and `applyCached` re-applies both layers on the next
|
||||
* cold start — so one customer's role scope came back and sat over whoever
|
||||
* launched the app next. That is the leak this closes.
|
||||
*
|
||||
* The org-wide half is rewritten rather than deleted, deliberately. It is the
|
||||
* bank's kill switch, and it is cached precisely so a handset that starts
|
||||
* offline still honours a feature the bank killed during an incident instead
|
||||
* of reverting to whatever the bundle was built with. Dropping the file would
|
||||
* turn a sign-out into a way to switch those features back on.
|
||||
*/
|
||||
async forgetRoleScope() {
|
||||
featureService.setSource(FeatureSource.Tenant, {});
|
||||
|
||||
const cached = await readCache();
|
||||
|
||||
if (!cached || Object.keys(cached.roleOverrides).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await writeCache({ ...cached, roleOverrides: {} });
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,262 @@
|
||||
import { FeatureKey, getFeatureAncestors, getParentFeatureKey, toFeatureKey } from '../constants/featureKeys';
|
||||
import {
|
||||
featureService,
|
||||
filterEnabled,
|
||||
guardFeature,
|
||||
isFeatureEnabled,
|
||||
isFeatureKeyEnabled,
|
||||
} from './featureService';
|
||||
import { FeatureSource } from './types';
|
||||
|
||||
describe('feature keys', () => {
|
||||
it('derives the parent from the dot path', () => {
|
||||
expect(getParentFeatureKey(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe(
|
||||
FeatureKey.SERVICES_CHEQUE,
|
||||
);
|
||||
expect(getParentFeatureKey(FeatureKey.SERVICES_CHEQUE)).toBe(FeatureKey.SERVICES);
|
||||
});
|
||||
|
||||
it('returns undefined for a root key', () => {
|
||||
expect(getParentFeatureKey(FeatureKey.SERVICES)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('lists ancestors nearest-first', () => {
|
||||
expect(getFeatureAncestors(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toEqual([
|
||||
FeatureKey.SERVICES_CHEQUE,
|
||||
FeatureKey.SERVICES,
|
||||
]);
|
||||
});
|
||||
|
||||
it('narrows only strings that are declared keys', () => {
|
||||
expect(toFeatureKey('services.cheque')).toBe(FeatureKey.SERVICES_CHEQUE);
|
||||
expect(toFeatureKey('services.nope')).toBeUndefined();
|
||||
expect(toFeatureKey(undefined)).toBeUndefined();
|
||||
expect(toFeatureKey('')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('featureService', () => {
|
||||
afterEach(() => {
|
||||
featureService.resetSources();
|
||||
});
|
||||
|
||||
it('has a valid static configuration', () => {
|
||||
expect(featureService.validate()).toEqual([]);
|
||||
});
|
||||
|
||||
it('enables a feature whose config and ancestors are all on', () => {
|
||||
expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe(true);
|
||||
});
|
||||
|
||||
it('disables every descendant when a parent is turned off', () => {
|
||||
featureService.setSource(FeatureSource.Remote, { [FeatureKey.SERVICES_CHEQUE]: false });
|
||||
|
||||
expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE)).toBe(false);
|
||||
expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_HUB)).toBe(false);
|
||||
expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe(false);
|
||||
// Siblings under a different parent are untouched.
|
||||
expect(isFeatureEnabled(FeatureKey.SERVICES_PROTECTION_DEAF)).toBe(true);
|
||||
});
|
||||
|
||||
it('cannot re-enable a child while its parent is off', () => {
|
||||
featureService.setSource(FeatureSource.Remote, {
|
||||
[FeatureKey.SERVICES_CHEQUE]: false,
|
||||
[FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT]: true,
|
||||
});
|
||||
|
||||
expect(isFeatureEnabled(FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT)).toBe(false);
|
||||
});
|
||||
|
||||
it('disables a single child without touching its parent or siblings', () => {
|
||||
featureService.setSource(FeatureSource.Remote, {
|
||||
[FeatureKey.AGENT_CUSTOMERS_CREATE]: false,
|
||||
[FeatureKey.AGENT_CUSTOMERS_DELETE]: false,
|
||||
});
|
||||
|
||||
expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS)).toBe(true);
|
||||
expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_LIST)).toBe(true);
|
||||
expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_EDIT)).toBe(true);
|
||||
expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_CREATE)).toBe(false);
|
||||
expect(isFeatureEnabled(FeatureKey.AGENT_CUSTOMERS_DELETE)).toBe(false);
|
||||
});
|
||||
|
||||
it('fails closed on an unknown key', () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(isFeatureEnabled('totally.made.up' as FeatureKey)).toBe(false);
|
||||
expect(isFeatureKeyEnabled('totally.made.up')).toBe(false);
|
||||
expect(isFeatureKeyEnabled(undefined)).toBe(false);
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
describe('source priority', () => {
|
||||
it('lets a higher-priority source win', () => {
|
||||
// Nothing authoritative has an opinion here, so ordinary precedence
|
||||
// applies and the user layer — the highest — decides.
|
||||
featureService.setSource(FeatureSource.Environment, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
|
||||
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false);
|
||||
|
||||
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true });
|
||||
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(true);
|
||||
});
|
||||
|
||||
// The kill switch. A device-local override must not be able to re-enable
|
||||
// something the bank has shut down, even though the user layer outranks the
|
||||
// remote one for every other purpose.
|
||||
it('does not let a local override lift a remote kill', () => {
|
||||
featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
|
||||
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true });
|
||||
|
||||
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false);
|
||||
expect(featureService.explain(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toMatchObject({
|
||||
enabled: false,
|
||||
killedBy: FeatureSource.Remote,
|
||||
locallyToggleable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let a local override lift a tenant kill', () => {
|
||||
featureService.setSource(FeatureSource.Tenant, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
|
||||
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true });
|
||||
|
||||
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false);
|
||||
});
|
||||
|
||||
// The asymmetry is deliberate: switching a feature off locally is always
|
||||
// safe, so an authoritative `true` stays a preference rather than a mandate.
|
||||
it('still lets a local override switch off a remotely enabled feature', () => {
|
||||
featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true });
|
||||
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
|
||||
|
||||
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false);
|
||||
expect(featureService.explain(FeatureKey.PAYMENTS_TRANSFER_IMPS).locallyToggleable).toBe(true);
|
||||
});
|
||||
|
||||
it('reports a feature held down by a disabled ancestor as not locally toggleable', () => {
|
||||
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER]: false });
|
||||
|
||||
const resolution = featureService.explain(FeatureKey.PAYMENTS_TRANSFER_IMPS);
|
||||
|
||||
expect(resolution.enabled).toBe(false);
|
||||
// Its own flag is untouched — it is the parent that is off, so flipping
|
||||
// this row would achieve nothing and the UI must say so.
|
||||
expect(resolution.ownFlag).toBe(true);
|
||||
expect(resolution.blockedBy).toBe(FeatureKey.PAYMENTS_TRANSFER);
|
||||
expect(resolution.locallyToggleable).toBe(false);
|
||||
});
|
||||
|
||||
it('patches a single key without discarding the rest of the layer', () => {
|
||||
featureService.setSource(FeatureSource.User, {
|
||||
[FeatureKey.PAYMENTS_TRANSFER_IMPS]: false,
|
||||
[FeatureKey.NOTIFICATIONS]: false,
|
||||
});
|
||||
|
||||
featureService.patchSource(FeatureSource.User, FeatureKey.PAYMENTS_TRANSFER_IMPS, undefined);
|
||||
|
||||
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(true);
|
||||
// The toggle screens patch one row per tap; losing the others would make
|
||||
// every switch reset the whole layer.
|
||||
expect(isFeatureEnabled(FeatureKey.NOTIFICATIONS)).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back to the next source down when a layer is cleared', () => {
|
||||
featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
|
||||
featureService.setSource(FeatureSource.User, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: true });
|
||||
|
||||
featureService.clearSource(FeatureSource.User);
|
||||
|
||||
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns to the compiled defaults after reset', () => {
|
||||
featureService.setSource(FeatureSource.Remote, { [FeatureKey.PAYMENTS_TRANSFER_IMPS]: false });
|
||||
featureService.resetSources();
|
||||
|
||||
expect(isFeatureEnabled(FeatureKey.PAYMENTS_TRANSFER_IMPS)).toBe(true);
|
||||
});
|
||||
|
||||
it('refuses to replace the default layer', () => {
|
||||
expect(() => featureService.setSource(FeatureSource.Default, {})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it('notifies subscribers when config changes', () => {
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = featureService.subscribe(listener);
|
||||
|
||||
featureService.setSource(FeatureSource.Remote, { [FeatureKey.NOTIFICATIONS]: false });
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
featureService.setSource(FeatureSource.Remote, { [FeatureKey.NOTIFICATIONS]: true });
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invalidates cached results when a source changes', () => {
|
||||
expect(isFeatureEnabled(FeatureKey.NOTIFICATIONS_LIST)).toBe(true);
|
||||
|
||||
featureService.setSource(FeatureSource.Remote, { [FeatureKey.NOTIFICATIONS]: false });
|
||||
|
||||
expect(isFeatureEnabled(FeatureKey.NOTIFICATIONS_LIST)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('guardFeature', () => {
|
||||
afterEach(() => {
|
||||
featureService.resetSources();
|
||||
});
|
||||
|
||||
it('runs the action while the feature is enabled', () => {
|
||||
const action = jest.fn(() => 'ran');
|
||||
const guarded = guardFeature(FeatureKey.AGENT_CUSTOMERS_DELETE, action);
|
||||
|
||||
expect(guarded()).toBe('ran');
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not run the action — or fire its API call — when disabled', () => {
|
||||
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const action = jest.fn();
|
||||
const guarded = guardFeature(FeatureKey.AGENT_CUSTOMERS_DELETE, action);
|
||||
|
||||
featureService.setSource(FeatureSource.Remote, {
|
||||
[FeatureKey.AGENT_CUSTOMERS_DELETE]: false,
|
||||
});
|
||||
|
||||
expect(guarded()).toBeUndefined();
|
||||
expect(action).not.toHaveBeenCalled();
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterEnabled', () => {
|
||||
afterEach(() => {
|
||||
featureService.resetSources();
|
||||
});
|
||||
|
||||
const items = [
|
||||
{ id: 'protection', feature: FeatureKey.SERVICES_PROTECTION_HUB },
|
||||
{ id: 'cheque', feature: FeatureKey.SERVICES_CHEQUE_HUB },
|
||||
{ id: 'untagged' },
|
||||
];
|
||||
|
||||
it('keeps enabled and untagged items', () => {
|
||||
expect(filterEnabled(items).map((item) => item.id)).toEqual([
|
||||
'protection',
|
||||
'cheque',
|
||||
'untagged',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops items whose feature is disabled', () => {
|
||||
featureService.setSource(FeatureSource.Remote, { [FeatureKey.SERVICES_CHEQUE]: false });
|
||||
|
||||
expect(filterEnabled(items).map((item) => item.id)).toEqual(['protection', 'untagged']);
|
||||
});
|
||||
|
||||
it('drops items whose feature string is not a declared key', () => {
|
||||
expect(filterEnabled([{ id: 'bogus', feature: 'not.a.key' }])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,415 @@
|
||||
import {
|
||||
ALL_FEATURE_KEYS,
|
||||
FeatureKey,
|
||||
getFeatureAncestors,
|
||||
getParentFeatureKey,
|
||||
toFeatureKey,
|
||||
} from '../constants/featureKeys';
|
||||
import { DEFAULT_FEATURE_CONFIG, ENVIRONMENT_FEATURE_OVERRIDES } from './featureConfig';
|
||||
import {
|
||||
AUTHORITATIVE_FEATURE_SOURCES,
|
||||
FEATURE_SOURCE_PRIORITY,
|
||||
FeatureSource,
|
||||
type FeatureConfig,
|
||||
type FeatureConfigIssue,
|
||||
type FeatureOverrides,
|
||||
type FeatureResolution,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Central feature-flag engine.
|
||||
*
|
||||
* Single instance for the whole app (exported as `featureService`). It owns:
|
||||
* - the registry built from the static config,
|
||||
* - the stack of override layers (environment → remote → tenant → user),
|
||||
* - a resolution cache, invalidated whenever any layer changes,
|
||||
* - a listener set so React can re-render on remote-config updates.
|
||||
*
|
||||
* Resolution is *fail closed*: an unknown key, or a key whose config is missing,
|
||||
* resolves to `false`. In a banking app an unrecognised flag must hide the
|
||||
* feature, never reveal it.
|
||||
*/
|
||||
class FeatureService {
|
||||
private readonly registry = new Map<FeatureKey, FeatureConfig>();
|
||||
|
||||
private readonly layers = new Map<FeatureSource, FeatureOverrides>();
|
||||
|
||||
private readonly cache = new Map<FeatureKey, boolean>();
|
||||
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
/** Bumped on every config change; used as the `useSyncExternalStore` snapshot. */
|
||||
private version = 0;
|
||||
|
||||
constructor() {
|
||||
this.buildRegistry();
|
||||
this.layers.set(FeatureSource.Environment, { ...ENVIRONMENT_FEATURE_OVERRIDES });
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- registry ---
|
||||
|
||||
private buildRegistry() {
|
||||
this.registry.clear();
|
||||
|
||||
for (const key of ALL_FEATURE_KEYS) {
|
||||
const definition = DEFAULT_FEATURE_CONFIG[key];
|
||||
|
||||
this.registry.set(key, {
|
||||
key,
|
||||
enabled: definition.enabled,
|
||||
parent: getParentFeatureKey(key),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the configuration is internally consistent. Every declared key must
|
||||
* have config, and every non-root key must have a declared parent (otherwise a
|
||||
* typo like `loans.aply.submit` would silently become a root feature that no
|
||||
* parent can ever switch off).
|
||||
*
|
||||
* Called from `FeatureProvider` in development.
|
||||
*/
|
||||
validate(): FeatureConfigIssue[] {
|
||||
const issues: FeatureConfigIssue[] = [];
|
||||
|
||||
for (const key of ALL_FEATURE_KEYS) {
|
||||
if (!(key in DEFAULT_FEATURE_CONFIG)) {
|
||||
issues.push({
|
||||
key,
|
||||
problem: 'missing-config',
|
||||
detail: `"${key}" is declared in FeatureKey but absent from DEFAULT_FEATURE_CONFIG.`,
|
||||
});
|
||||
}
|
||||
|
||||
const lastDot = key.lastIndexOf('.');
|
||||
|
||||
if (lastDot !== -1 && !toFeatureKey(key.slice(0, lastDot))) {
|
||||
issues.push({
|
||||
key,
|
||||
problem: 'missing-parent',
|
||||
detail: `"${key}" implies a parent "${key.slice(
|
||||
0,
|
||||
lastDot,
|
||||
)}" which is not a declared FeatureKey.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const configured of Object.keys(DEFAULT_FEATURE_CONFIG)) {
|
||||
if (!toFeatureKey(configured)) {
|
||||
issues.push({
|
||||
key: configured,
|
||||
problem: 'unknown-key',
|
||||
detail: `DEFAULT_FEATURE_CONFIG has "${configured}" which is not a FeatureKey.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- resolution ---
|
||||
|
||||
/**
|
||||
* The feature's own flag, before considering ancestors: the value from the
|
||||
* highest-priority source that defines it, falling back to the static config.
|
||||
*/
|
||||
private resolveOwnFlag(key: FeatureKey): boolean {
|
||||
return this.resolveOwnFlagWithSource(key).value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The own flag plus the layer that decided it. Two phases, and the order
|
||||
* between them is the security-relevant part:
|
||||
*
|
||||
* 1. A kill from an authoritative layer wins outright, whatever sits above it
|
||||
* in the priority list. A device-local toggle must not be able to re-enable
|
||||
* a feature the bank has shut down.
|
||||
* 2. Otherwise the highest-priority layer that defines the key wins, falling
|
||||
* back to the static config compiled into the bundle.
|
||||
*/
|
||||
private resolveOwnFlagWithSource(key: FeatureKey): {
|
||||
value: boolean;
|
||||
source: FeatureSource;
|
||||
killedBy?: FeatureSource;
|
||||
} {
|
||||
for (const source of AUTHORITATIVE_FEATURE_SOURCES) {
|
||||
if (this.layers.get(source)?.[key] === false) {
|
||||
return { value: false, source, killedBy: source };
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = FEATURE_SOURCE_PRIORITY.length - 1; i >= 0; i -= 1) {
|
||||
const source = FEATURE_SOURCE_PRIORITY[i];
|
||||
|
||||
if (!source || source === FeatureSource.Default) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const override = this.layers.get(source)?.[key];
|
||||
|
||||
if (typeof override === 'boolean') {
|
||||
return { value: override, source };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
value: this.registry.get(key)?.enabled ?? false,
|
||||
source: FeatureSource.Default,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a feature is usable: it must exist, its own flag must be on, and
|
||||
* every ancestor must also resolve to enabled.
|
||||
*/
|
||||
isEnabled(key: FeatureKey): boolean {
|
||||
const cached = this.cache.get(key);
|
||||
|
||||
if (typeof cached === 'boolean') {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let enabled: boolean;
|
||||
|
||||
if (!this.registry.has(key)) {
|
||||
if (__DEV__) {
|
||||
console.warn(
|
||||
`[features] Unknown feature key "${key}". Resolving to disabled. ` +
|
||||
'Add it to FeatureKey and DEFAULT_FEATURE_CONFIG.',
|
||||
);
|
||||
}
|
||||
enabled = false;
|
||||
} else if (!this.resolveOwnFlag(key)) {
|
||||
enabled = false;
|
||||
} else {
|
||||
// Walking ancestors nearest-first lets the recursive call hit the cache.
|
||||
enabled = getFeatureAncestors(key).every((ancestor) => this.isEnabled(ancestor));
|
||||
}
|
||||
|
||||
this.cache.set(key, enabled);
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** True only when every supplied key is enabled. */
|
||||
areAllEnabled(keys: readonly FeatureKey[]): boolean {
|
||||
return keys.every((key) => this.isEnabled(key));
|
||||
}
|
||||
|
||||
/** True when at least one supplied key is enabled. */
|
||||
isAnyEnabled(keys: readonly FeatureKey[]): boolean {
|
||||
return keys.some((key) => this.isEnabled(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves an untrusted string (JSON UI, deep link, remote payload) and
|
||||
* reports whether it maps to an enabled feature. Unknown strings → false.
|
||||
*/
|
||||
isEnabledKey(value: string | null | undefined): boolean {
|
||||
const key = toFeatureKey(value);
|
||||
|
||||
return key ? this.isEnabled(key) : false;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- sources ---
|
||||
|
||||
/**
|
||||
* Replaces one layer's overrides wholesale and notifies subscribers.
|
||||
* This is the seam for Firebase Remote Config, an API-driven config service,
|
||||
* or per-tenant/role rules — no UI component changes when a layer is added.
|
||||
*/
|
||||
setSource(source: FeatureSource, overrides: FeatureOverrides) {
|
||||
if (source === FeatureSource.Default) {
|
||||
throw new Error('[features] The default layer is static and cannot be replaced.');
|
||||
}
|
||||
|
||||
this.layers.set(source, { ...overrides });
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or clears a *single* key within one layer, leaving the rest of that
|
||||
* layer alone. This is what the toggle screens use: `setSource` would discard
|
||||
* every other override in the layer on each tap.
|
||||
*
|
||||
* Passing `undefined` removes the key, so the feature falls back to whatever
|
||||
* the layers below it say rather than being pinned to a value.
|
||||
*/
|
||||
patchSource(source: FeatureSource, key: FeatureKey, value: boolean | undefined) {
|
||||
if (source === FeatureSource.Default) {
|
||||
throw new Error('[features] The default layer is static and cannot be patched.');
|
||||
}
|
||||
|
||||
const next: FeatureOverrides = { ...this.layers.get(source) };
|
||||
|
||||
if (value === undefined) {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
|
||||
this.layers.set(source, next);
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
/** One layer's overrides, for the toggle screens and for persisting them. */
|
||||
getSource(source: FeatureSource): FeatureOverrides {
|
||||
return { ...this.layers.get(source) };
|
||||
}
|
||||
|
||||
/** Drops a layer entirely (e.g. on logout, clear the user/tenant layers). */
|
||||
clearSource(source: FeatureSource) {
|
||||
if (this.layers.delete(source)) {
|
||||
this.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes every non-default layer. Used on logout and in tests. */
|
||||
resetSources() {
|
||||
this.layers.clear();
|
||||
this.layers.set(FeatureSource.Environment, { ...ENVIRONMENT_FEATURE_OVERRIDES });
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
private invalidate() {
|
||||
this.cache.clear();
|
||||
this.version += 1;
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ reactivity ---
|
||||
|
||||
/** Subscribes to config changes. Returns an unsubscribe function. */
|
||||
subscribe = (listener: () => void): (() => void) => {
|
||||
this.listeners.add(listener);
|
||||
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
/** Monotonic version, stable between changes — safe for useSyncExternalStore. */
|
||||
getVersion = (): number => this.version;
|
||||
|
||||
// ----------------------------------------------------------- diagnostics ---
|
||||
|
||||
/** Full resolved state. Development/debugging aid only. */
|
||||
getSnapshot(): Record<FeatureKey, boolean> {
|
||||
const snapshot = {} as Record<FeatureKey, boolean>;
|
||||
|
||||
for (const key of ALL_FEATURE_KEYS) {
|
||||
snapshot[key] = this.isEnabled(key);
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Resolved config for one feature, including where its parent sits. */
|
||||
getConfig(key: FeatureKey): FeatureConfig | undefined {
|
||||
return this.registry.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Why one feature resolved the way it did.
|
||||
*
|
||||
* The toggle screens need this rather than a bare boolean: a row that is off
|
||||
* because the bank killed it, and a row that is off because its parent is off,
|
||||
* both look identical as a `false` — and neither can be fixed by flipping that
|
||||
* row. Showing the reason is what stops someone tapping a locked toggle and
|
||||
* concluding the app is broken.
|
||||
*/
|
||||
explain(key: FeatureKey): FeatureResolution {
|
||||
const { value: ownFlag, source, killedBy } = this.resolveOwnFlagWithSource(key);
|
||||
const blockedBy = ownFlag
|
||||
? getFeatureAncestors(key).find((ancestor) => !this.isEnabled(ancestor))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
key,
|
||||
enabled: this.isEnabled(key),
|
||||
ownFlag,
|
||||
decidedBy: source,
|
||||
...(killedBy ? { killedBy } : {}),
|
||||
...(blockedBy ? { blockedBy } : {}),
|
||||
// A killed feature cannot be lifted locally, and neither can one whose
|
||||
// parent is off — the ancestor has to be fixed first.
|
||||
locallyToggleable: !killedBy && !blockedBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const featureService = new FeatureService();
|
||||
|
||||
/**
|
||||
* Canonical feature check used across the app.
|
||||
*
|
||||
* Automatically accounts for the feature's own flag, every ancestor, whether the
|
||||
* key exists at all, and the active source layers.
|
||||
*
|
||||
* if (isFeatureEnabled(FeatureKey.PAYMENTS_BENEFICIARIES_ADD)) { … }
|
||||
*/
|
||||
export const isFeatureEnabled = (key: FeatureKey): boolean => featureService.isEnabled(key);
|
||||
|
||||
/** Every key enabled? */
|
||||
export const areAllFeaturesEnabled = (keys: readonly FeatureKey[]): boolean =>
|
||||
featureService.areAllEnabled(keys);
|
||||
|
||||
/** Any key enabled? */
|
||||
export const isAnyFeatureEnabled = (keys: readonly FeatureKey[]): boolean =>
|
||||
featureService.isAnyEnabled(keys);
|
||||
|
||||
/**
|
||||
* Check for a feature reference that arrived as a plain string — JSON-driven UI,
|
||||
* deep links, remote payloads. Unknown strings resolve to `false`.
|
||||
*/
|
||||
export const isFeatureKeyEnabled = (value: string | null | undefined): boolean =>
|
||||
featureService.isEnabledKey(value);
|
||||
|
||||
/**
|
||||
* Wraps an action so it cannot run while its feature is disabled. Use for any
|
||||
* handler that mutates state or calls an API, so a disabled feature can never
|
||||
* fire a request even if some UI path reaches it.
|
||||
*
|
||||
* const deleteUser = guardFeature(FeatureKey.USERS_DELETE, async (id) => { … });
|
||||
*/
|
||||
export function guardFeature<TArgs extends unknown[], TResult>(
|
||||
key: FeatureKey,
|
||||
action: (...args: TArgs) => TResult,
|
||||
): (...args: TArgs) => TResult | undefined {
|
||||
return (...args: TArgs) => {
|
||||
if (!isFeatureEnabled(key)) {
|
||||
if (__DEV__) {
|
||||
console.warn(`[features] Blocked action for disabled feature "${key}".`);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return action(...args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Throwing variant, for service/thunk layers where silently doing nothing would
|
||||
* hide a bug. Prefer `guardFeature` in UI handlers.
|
||||
*/
|
||||
export function assertFeatureEnabled(key: FeatureKey): void {
|
||||
if (!isFeatureEnabled(key)) {
|
||||
throw new Error(`[features] Feature "${key}" is disabled.`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters any list of feature-tagged items down to the enabled ones. This is the
|
||||
* single helper behind gated service tiles, menu rows, search entries and
|
||||
* JSON-driven component arrays.
|
||||
*
|
||||
* Items without a `feature` field are always kept — tagging is opt-in.
|
||||
*/
|
||||
export function filterEnabled<T extends { feature?: FeatureKey | string }>(
|
||||
items: readonly T[],
|
||||
): T[] {
|
||||
return items.filter((item) => item.feature === undefined || isFeatureKeyEnabled(item.feature));
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { env } from '../config/env';
|
||||
import { featureOverrides } from './featureOverrides';
|
||||
import { featureRemoteConfig } from './featureRemoteConfig';
|
||||
|
||||
/**
|
||||
* Brings every feature layer up before the app renders. Passed to
|
||||
* `FeatureProvider`'s `hydrate` slot, which holds `ready` until it resolves.
|
||||
*
|
||||
* The order matters:
|
||||
*
|
||||
* 1. **Local overrides** — read from storage first. Cheap, and it means a
|
||||
* tester's switches are in force even if the network never answers.
|
||||
* 2. **Cached server config** — the bank's last known instruction. Applied
|
||||
* before the fetch so a cold start offline still honours a kill switch
|
||||
* rather than reverting to whatever the bundle was built with.
|
||||
* 3. **Live fetch** — replaces the cache when it succeeds, and is a no-op when
|
||||
* it does not, leaving step 2 in force.
|
||||
*
|
||||
* The awaits are sequential on purpose. Running them together would let the
|
||||
* fetch land before the cache and then be overwritten by it.
|
||||
*
|
||||
* This resolves the org-wide scope only; role-scoped switches need a session and
|
||||
* are refreshed at sign-in (see `useAppBootstrap`).
|
||||
*/
|
||||
export const hydrateFeatures = async (): Promise<void> => {
|
||||
await featureOverrides.restore();
|
||||
await featureRemoteConfig.applyCached();
|
||||
await featureRemoteConfig.refresh({ useMock: env.useMockServer });
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Centralised feature management.
|
||||
*
|
||||
* Import everything feature-related from here:
|
||||
*
|
||||
* import { FeatureGate, useFeature, isFeatureEnabled } from '../../features';
|
||||
* import { FeatureKey } from '../../constants/featureKeys';
|
||||
*/
|
||||
export { FeatureGate, DynamicFeatureGate } from './FeatureGate';
|
||||
export { FeatureProvider, useFeatureContext } from './FeatureProvider';
|
||||
export {
|
||||
areAllFeaturesEnabled,
|
||||
assertFeatureEnabled,
|
||||
featureService,
|
||||
filterEnabled,
|
||||
guardFeature,
|
||||
isAnyFeatureEnabled,
|
||||
isFeatureEnabled,
|
||||
isFeatureKeyEnabled,
|
||||
} from './featureService';
|
||||
export {
|
||||
useAllFeatures,
|
||||
useAnyFeature,
|
||||
useEnabledItems,
|
||||
useFeature,
|
||||
useFeatureAction,
|
||||
useFeatures,
|
||||
useFeatureVersion,
|
||||
} from './useFeature';
|
||||
export { DEFAULT_FEATURE_CONFIG, ENVIRONMENT_FEATURE_OVERRIDES } from './featureConfig';
|
||||
export { featureOverrides } from './featureOverrides';
|
||||
export { featureRemoteConfig } from './featureRemoteConfig';
|
||||
export { hydrateFeatures } from './hydrateFeatures';
|
||||
export { ADMIN_TOGGLEABLE_FEATURES } from './adminFeatures';
|
||||
export {
|
||||
AUTHORITATIVE_FEATURE_SOURCES,
|
||||
FEATURE_SOURCE_PRIORITY,
|
||||
FeatureSource,
|
||||
type FeatureConfig,
|
||||
type FeatureConfigIssue,
|
||||
type FeatureDefinition,
|
||||
type FeatureOverrides,
|
||||
type FeatureResolution,
|
||||
type FeatureTagged,
|
||||
} from './types';
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { FeatureKey } from '../constants/featureKeys';
|
||||
|
||||
/**
|
||||
* How a single feature is declared in the static configuration.
|
||||
*/
|
||||
export type FeatureDefinition = {
|
||||
/** When false the feature and everything beneath it is hidden. */
|
||||
enabled: boolean;
|
||||
/** Why this feature exists / what it gates. Documentation only. */
|
||||
description?: string;
|
||||
/**
|
||||
* Why this feature ships off. Documentation only, and only meaningful
|
||||
* alongside `enabled: false` — it is the note a reviewer needs to tell a
|
||||
* deliberate scope decision from an accidental one.
|
||||
*/
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A fully resolved feature entry as held by the registry. `parent` is derived
|
||||
* from the key's dot path rather than hand-written, so the hierarchy can never
|
||||
* drift from the key naming.
|
||||
*/
|
||||
export type FeatureConfig = {
|
||||
key: FeatureKey;
|
||||
enabled: boolean;
|
||||
parent?: FeatureKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Configuration layers, lowest priority first. A value supplied by a later
|
||||
* source overrides the same key from an earlier one:
|
||||
*
|
||||
* default → environment → remote → tenant → user
|
||||
*
|
||||
* `default` is the static config compiled into the bundle. The remaining layers
|
||||
* are empty until something registers them, which is what lets us add Firebase
|
||||
* Remote Config, an API-driven config or per-tenant/role rules later without
|
||||
* touching a single UI component.
|
||||
*/
|
||||
export enum FeatureSource {
|
||||
Default = 'default',
|
||||
Environment = 'environment',
|
||||
Remote = 'remote',
|
||||
Tenant = 'tenant',
|
||||
User = 'user',
|
||||
}
|
||||
|
||||
/** Source precedence, lowest → highest. */
|
||||
export const FEATURE_SOURCE_PRIORITY: readonly FeatureSource[] = [
|
||||
FeatureSource.Default,
|
||||
FeatureSource.Environment,
|
||||
FeatureSource.Remote,
|
||||
FeatureSource.Tenant,
|
||||
FeatureSource.User,
|
||||
];
|
||||
|
||||
/**
|
||||
* Layers whose `false` is a **kill switch**: once one of these turns a feature
|
||||
* off, no higher-priority layer may turn it back on.
|
||||
*
|
||||
* Priority alone is the wrong tool here. `user` sits above `remote` in the list
|
||||
* above — deliberately, so a local override can enable something for testing —
|
||||
* but that same ordering would let a device-local toggle re-enable a feature the
|
||||
* bank had shut down for a live incident. So a kill is resolved *before*
|
||||
* precedence, and precedence then decides only among features nobody has killed.
|
||||
*
|
||||
* Note the asymmetry: an authoritative `true` is only a preference and a local
|
||||
* layer may still switch it off. Turning a feature *off* locally is always safe;
|
||||
* turning one *on* against the bank's instruction is not.
|
||||
*/
|
||||
export const AUTHORITATIVE_FEATURE_SOURCES: readonly FeatureSource[] = [
|
||||
FeatureSource.Remote,
|
||||
FeatureSource.Tenant,
|
||||
];
|
||||
|
||||
/**
|
||||
* Why one feature resolved the way it did. Drives the toggle screens, which
|
||||
* must show *why* a row is off — a key held down by a remote kill or by a
|
||||
* disabled parent cannot be fixed by flipping the row itself.
|
||||
*/
|
||||
export type FeatureResolution = {
|
||||
key: FeatureKey;
|
||||
/** Final answer, ancestors included. This is what `isEnabled` returns. */
|
||||
enabled: boolean;
|
||||
/** The feature's own flag, before ancestors are considered. */
|
||||
ownFlag: boolean;
|
||||
/** Layer that decided the own flag. */
|
||||
decidedBy: FeatureSource;
|
||||
/** Set when an authoritative layer forced it off. No local override lifts this. */
|
||||
killedBy?: FeatureSource;
|
||||
/** Nearest ancestor that is off, when the own flag is on but the feature is not. */
|
||||
blockedBy?: FeatureKey;
|
||||
/** False when a local toggle cannot change the outcome (killed, or parent off). */
|
||||
locallyToggleable: boolean;
|
||||
};
|
||||
|
||||
/** A sparse set of overrides contributed by one source. */
|
||||
export type FeatureOverrides = Partial<Record<FeatureKey, boolean>>;
|
||||
|
||||
/**
|
||||
* Anything carrying an optional feature reference — used by JSON-driven UI and
|
||||
* by static option arrays (service tiles, menu rows, search entries).
|
||||
*
|
||||
* The field is a plain string so it can come straight from JSON; it is narrowed
|
||||
* through `toFeatureKey` before use, and an unknown value hides the item.
|
||||
*/
|
||||
export type FeatureTagged = {
|
||||
feature?: FeatureKey | string;
|
||||
};
|
||||
|
||||
/** Result of validating the configuration against the key enum. */
|
||||
export type FeatureConfigIssue = {
|
||||
key: string;
|
||||
problem: 'missing-config' | 'unknown-key' | 'missing-parent';
|
||||
detail: string;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||
|
||||
import type { FeatureKey } from '../constants/featureKeys';
|
||||
import { featureService, isFeatureEnabled } from './featureService';
|
||||
|
||||
/**
|
||||
* Subscribes the calling component to feature-config changes.
|
||||
*
|
||||
* The store snapshot is the service's version counter, which only changes when a
|
||||
* source layer is replaced. That keeps `useSyncExternalStore` stable (no render
|
||||
* loops) while still re-rendering everything the moment remote config lands.
|
||||
*/
|
||||
export const useFeatureVersion = (): number =>
|
||||
useSyncExternalStore(
|
||||
featureService.subscribe,
|
||||
featureService.getVersion,
|
||||
featureService.getVersion,
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether a single feature is enabled, accounting for its ancestors.
|
||||
*
|
||||
* const canAddBeneficiary = useFeature(FeatureKey.PAYMENTS_BENEFICIARIES_ADD);
|
||||
* return <>{canAddBeneficiary && <AddBeneficiaryButton />}</>;
|
||||
*/
|
||||
export const useFeature = (key: FeatureKey): boolean => {
|
||||
const version = useFeatureVersion();
|
||||
|
||||
// `version` looks unused to the linter, but `isFeatureEnabled` reads mutable
|
||||
// service state — the version is exactly what tells us that state changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return useMemo(() => isFeatureEnabled(key), [key, version]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves several features at once, keyed by feature key. Cheaper and tidier
|
||||
* than calling `useFeature` repeatedly in a screen with many gated controls.
|
||||
*
|
||||
* const flags = useFeatures([FeatureKey.CARDS_CONTROLS_FREEZE, FeatureKey.CARDS_CONTROLS_LIMITS]);
|
||||
* flags[FeatureKey.CARDS_CONTROLS_FREEZE] // boolean
|
||||
*/
|
||||
export const useFeatures = <T extends readonly FeatureKey[]>(
|
||||
keys: T,
|
||||
): Record<T[number], boolean> => {
|
||||
const version = useFeatureVersion();
|
||||
// Key the memo on the contents, so an inline array literal does not thrash it.
|
||||
const signature = keys.join('|');
|
||||
|
||||
return useMemo(() => {
|
||||
const result = {} as Record<T[number], boolean>;
|
||||
|
||||
for (const key of keys) {
|
||||
result[key as T[number]] = isFeatureEnabled(key);
|
||||
}
|
||||
|
||||
return result;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [signature, version]);
|
||||
};
|
||||
|
||||
/** True only when every listed feature is enabled. */
|
||||
export const useAllFeatures = (keys: readonly FeatureKey[]): boolean => {
|
||||
const version = useFeatureVersion();
|
||||
const signature = keys.join('|');
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return useMemo(() => keys.every((key) => isFeatureEnabled(key)), [signature, version]);
|
||||
};
|
||||
|
||||
/** True when at least one listed feature is enabled. */
|
||||
export const useAnyFeature = (keys: readonly FeatureKey[]): boolean => {
|
||||
const version = useFeatureVersion();
|
||||
const signature = keys.join('|');
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
return useMemo(() => keys.some((key) => isFeatureEnabled(key)), [signature, version]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a handler so it is inert while its feature is disabled — the callback
|
||||
* still exists (keeping call sites simple) but does nothing and fires no API
|
||||
* call. Pair with `FeatureGate` so the control is not rendered either.
|
||||
*
|
||||
* const onDelete = useFeatureAction(FeatureKey.PAYMENTS_BENEFICIARIES_DELETE, () =>
|
||||
* dispatch(deleteBeneficiary(id)),
|
||||
* );
|
||||
*/
|
||||
export const useFeatureAction = <TArgs extends unknown[]>(
|
||||
key: FeatureKey,
|
||||
action: (...args: TArgs) => void,
|
||||
): ((...args: TArgs) => void) => {
|
||||
const enabled = useFeature(key);
|
||||
|
||||
return useCallback(
|
||||
(...args: TArgs) => {
|
||||
if (!enabled) {
|
||||
if (__DEV__) {
|
||||
console.warn(`[features] Blocked action for disabled feature "${key}".`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
action(...args);
|
||||
},
|
||||
[enabled, key, action],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters a list of feature-tagged items down to the enabled ones, recomputing
|
||||
* when config changes. Use for service grids, menus and JSON-driven UI arrays.
|
||||
*/
|
||||
export const useEnabledItems = <T extends { feature?: FeatureKey | string }>(
|
||||
items: readonly T[],
|
||||
): T[] => {
|
||||
const version = useFeatureVersion();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
(item) =>
|
||||
item.feature === undefined ||
|
||||
(typeof item.feature === 'string' && featureService.isEnabledKey(item.feature)),
|
||||
),
|
||||
// `version` is the signal that the service's mutable state changed.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[items, version],
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user