Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 21 additions & 8 deletions lib/core/flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Both storage steps are wrapped in `try`/`catch`, so a browser with `sessionStora

## Using flags from a wrapper bundle

A wrapper does not need its own query-string parser. Call `getFlags()` once during initialization — that parses the URL and persists it — then read flags wherever needed:
A wrapper does not need its own query-string parser. Call `getFlags()` once during initialization to parse the URL and persist every known flag for the session, then read flags wherever needed:

```js
import { getFlags, flagEnabled } from "@optable/web-sdk/lib/dist/core/flags";
Expand All @@ -101,6 +101,18 @@ function log(...args) {

Call it before anything that reads a flag. Addons that read flags internally — `setupAB` and `buildRTD` — call `getFlags()` themselves, so ordering only matters for a wrapper's own reads.

A bundle with flags of its own that don't belong in the shared key list can additionally call `persistFlagsFromURL()` with those keys. They follow the same URL rules (a bare key means `"1"`) and are persisted to `sessionStorage` for the bundle to read back directly — they do not appear in the typed `Flags` object:

```js
import { persistFlagsFromURL } from "@optable/web-sdk/lib/dist/core/flags";

persistFlagsFromURL(["optableMyBundleFlag"]);

sessionStorage.getItem("optableMyBundleFlag"); // "1" after ?optableMyBundleFlag
```

Because `flagEnabled()` only accepts known keys, a bundle reading such a key back must apply the same convention itself: treat `"0"` (and an empty value) as disabled, not just test truthiness — `?optableMyBundleFlag=0` stores the string `"0"`.

## Testing

`resetFlags()` clears the memoized result so the next `getFlags()` re-parses. It is intended for tests, which need to simulate successive page loads:
Expand All @@ -113,10 +125,11 @@ expect(flagEnabled("optableDebug")).toBe(true);

## API

| Export | Signature | Description |
| ------------- | ---------------------------------- | ---------------------------------------------------------------------------- |
| `getFlags` | `() => Flags` | Parsed flags for this page load. Memoized; persists URL flags on first call. |
| `flagEnabled` | `(key: FlagKey) => boolean` | True when a flag is present and not `"0"`. Use for on/off flags. |
| `resetFlags` | `() => void` | Clears the memoized result so the next `getFlags()` re-parses. |
| `FlagKey` | union of flag names | Type of a recognised flag key. |
| `Flags` | `Partial<Record<FlagKey, string>>` | Type of the parsed flag object. |
| Export | Signature | Description |
| --------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `getFlags` | `() => Flags` | Parsed flags for this page load. Memoized; persists URL flags on first call. |
| `flagEnabled` | `(key: FlagKey) => boolean` | True when a flag is present and not `"0"`. Use for on/off flags. |
| `resetFlags` | `() => void` | Clears the memoized result so the next `getFlags()` re-parses. |
| `persistFlagsFromURL` | `(keys: readonly string[]) => Record<string, string>` | Parses + persists the given bundle-specific keys from the URL (persist-only); returns the values read. |
| `FlagKey` | union of flag names | Type of a recognised flag key. |
| `Flags` | `Partial<Record<FlagKey, string>>` | Type of the parsed flag object. |
34 changes: 33 additions & 1 deletion lib/core/flags.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { flagEnabled, getFlags, resetFlags } from "./flags";
import { flagEnabled, getFlags, persistFlagsFromURL, resetFlags } from "./flags";

beforeEach(() => {
sessionStorage.clear();
Expand Down Expand Up @@ -150,6 +150,38 @@ describe("flagEnabled", () => {
});
});

describe("persistFlagsFromURL", () => {
it("persists the given keys, with a bare key meaning '1'", () => {
window.location = { search: "?optableResolveCustom&optableIncludeCustom=abc" } as Location;
persistFlagsFromURL(["optableResolveCustom", "optableIncludeCustom"]);
expect(sessionStorage.getItem("optableResolveCustom")).toBe("1");
expect(sessionStorage.getItem("optableIncludeCustom")).toBe("abc");
});

it("returns the values read from the URL", () => {
window.location = { search: "?optableResolveCustom=abc" } as Location;
expect(persistFlagsFromURL(["optableResolveCustom", "optableOther"])).toEqual({
optableResolveCustom: "abc",
});
});

it("leaves keys absent from the URL untouched", () => {
sessionStorage.setItem("optableResolveCustom", "kept");
window.location = { search: "" } as Location;
persistFlagsFromURL(["optableResolveCustom", "optableOther"]);
expect(sessionStorage.getItem("optableResolveCustom")).toBe("kept");
expect(sessionStorage.getItem("optableOther")).toBeNull();
});

it("does not throw when sessionStorage writes fail", () => {
window.location = { search: "?optableResolveCustom" } as Location;
(sessionStorage.setItem as jest.Mock).mockImplementationOnce(() => {
throw new Error("blocked");
});
expect(() => persistFlagsFromURL(["optableResolveCustom"])).not.toThrow();
});
});

describe("getFlags - newly recognized keys", () => {
it.each(["optableForceTokenize", "optableResolveId5", "optableResolveID5ID"] as const)(
"reads %s from the URL",
Expand Down
23 changes: 15 additions & 8 deletions lib/core/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,30 +16,37 @@ const FLAG_KEYS = [
export type FlagKey = (typeof FLAG_KEYS)[number];
export type Flags = Partial<Record<FlagKey, string>>;

function parseFlags(): Flags {
const flags: Flags = {};
// Reads the given keys from the URL query string (a bare key means "1") and
// persists them to sessionStorage for the rest of the tab session. Exported
// for wrapper bundles with keys of their own outside FLAG_KEYS.
export function persistFlagsFromURL(keys: readonly string[]): Record<string, string> {
const found: Record<string, string> = {};

try {
const params = new URLSearchParams(window.location.search);
for (const key of FLAG_KEYS) {
for (const key of keys) {
if (params.has(key)) {
flags[key] = params.get(key) || "1";
found[key] = params.get(key) || "1";
}
}
} catch {
// URL params unavailable
}

// Persist URL-supplied flags so a flag set once survives navigation within
// the session, rather than only applying to the page it was set on.
try {
for (const key of Object.keys(flags) as FlagKey[]) {
sessionStorage.setItem(key, flags[key] as string);
for (const key of Object.keys(found)) {
sessionStorage.setItem(key, found[key]);
}
} catch {
// sessionStorage unavailable
}

return found;
}

function parseFlags(): Flags {
const flags: Flags = persistFlagsFromURL(FLAG_KEYS);

try {
for (const key of FLAG_KEYS) {
if (!(key in flags)) {
Expand Down