From 370b7587b026a1513039042dd13b57f847844a16 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 3 Sep 2026 21:29:48 +0200 Subject: [PATCH 1/6] Send SDK evaluation context as canonical JSON --- .changeset/context-json-sdk-transport.md | 6 +++ packages/browser-sdk/src/context.ts | 57 ++++++++++++++++++++++-- packages/browser-sdk/src/flag/flags.ts | 8 ++-- packages/browser-sdk/src/index.ts | 1 + packages/browser-sdk/src/sse.ts | 34 +------------- packages/browser-sdk/test/client.test.ts | 21 ++++++--- packages/browser-sdk/test/flags.test.ts | 25 ++++++++--- packages/node-sdk/src/client.ts | 8 ++-- packages/node-sdk/src/utils.ts | 23 ++++++++++ packages/node-sdk/test/client.test.ts | 38 ++++++++++++++-- packages/node-sdk/test/utils.test.ts | 16 +++++++ 11 files changed, 178 insertions(+), 59 deletions(-) create mode 100644 .changeset/context-json-sdk-transport.md diff --git a/.changeset/context-json-sdk-transport.md b/.changeset/context-json-sdk-transport.md new file mode 100644 index 000000000..ecb1f1def --- /dev/null +++ b/.changeset/context-json-sdk-transport.md @@ -0,0 +1,6 @@ +--- +"@reflag/node-sdk": minor +"@reflag/browser-sdk": minor +--- + +Send remote evaluation context as canonical `contextJson`, preserving array-valued context attributes. Browser SDK context types now accept JSON-compatible array and object values. diff --git a/packages/browser-sdk/src/context.ts b/packages/browser-sdk/src/context.ts index e4e18f3f4..a0867f4f8 100644 --- a/packages/browser-sdk/src/context.ts +++ b/packages/browser-sdk/src/context.ts @@ -1,3 +1,52 @@ +export type ContextValue = + | string + | number + | boolean + | null + | undefined + | ContextValue[] + | { [key: string]: ContextValue }; + +/** + * Serialize context with recursively sorted object keys. Array order is preserved. + */ +function canonicalJSONStringify(value: unknown): string { + const serialized = JSON.stringify(value, (_key, nestedValue: unknown) => { + if ( + nestedValue === null || + typeof nestedValue !== "object" || + Array.isArray(nestedValue) + ) { + return nestedValue; + } + + return Object.fromEntries( + Object.entries(nestedValue).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ); + }); + if (serialized === undefined) + throw new Error("value must be JSON serializable"); + return serialized; +} + +export function canonicalContextJSONStringify( + context: ReflagContext | undefined, +): string | undefined { + if (!context) return undefined; + const nonEmptyContext = Object.fromEntries( + Object.entries(context).filter( + ([, attributes]) => + attributes && + Object.values(attributes).some((value) => value !== undefined), + ), + ); + return Object.keys(nonEmptyContext).length + ? canonicalJSONStringify(nonEmptyContext) + : undefined; +} + /** * Context is a set of key-value pairs. * This is used to determine if feature targeting matches and to track events. @@ -17,7 +66,7 @@ export interface CompanyContext { /** * Other company attributes */ - [key: string]: string | number | undefined; + [key: string]: ContextValue; } /** @@ -44,7 +93,7 @@ export interface UserContext { /** * Other user attributes */ - [key: string]: string | number | undefined; + [key: string]: ContextValue; } /** @@ -67,7 +116,7 @@ export interface ReflagContext { /** * Context which is not related to a user or a company. */ - other?: Record; + other?: Record; } /** @@ -79,5 +128,5 @@ export interface ReflagDeprecatedContext extends ReflagContext { * Context which is not related to a user or a company. * @deprecated Use `other` instead, this property will be removed in the next major version */ - otherContext?: Record; + otherContext?: Record; } diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index dacdb099c..1bdb207ff 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -2,7 +2,7 @@ import { deepEqual } from "fast-equals"; import type { BulkEvent } from "../bulkQueue"; import { FLAG_EVENTS_PER_MIN, FLAGS_EXPIRE_MS } from "../config"; -import { ReflagContext } from "../context"; +import { canonicalContextJSONStringify, ReflagContext } from "../context"; import { HttpClient } from "../httpClient"; import { Logger, loggerWithPrefix } from "../logger"; import RateLimiter from "../rateLimiter"; @@ -1005,8 +1005,10 @@ export class FlagsClient { } private fetchParams() { - const flattenedContext = flattenJSON({ context: this.context }); - const params = new URLSearchParams(flattenedContext); + const contextJson = canonicalContextJSONStringify(this.context); + const params = new URLSearchParams( + contextJson ? { contextJson } : undefined, + ); // publishableKey should be part of the cache key params.append("publishableKey", this.httpClient.publishableKey); diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index 095fa8237..b27a34185 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -10,6 +10,7 @@ export type { export { ReflagClient } from "./client"; export type { CompanyContext, + ContextValue, ReflagContext, ReflagDeprecatedContext, UserContext, diff --git a/packages/browser-sdk/src/sse.ts b/packages/browser-sdk/src/sse.ts index 3ce3e267a..9a6c4e905 100644 --- a/packages/browser-sdk/src/sse.ts +++ b/packages/browser-sdk/src/sse.ts @@ -1,5 +1,5 @@ import { SDK_VERSION_HEADER_NAME } from "./config"; -import { ReflagContext } from "./context"; +import { canonicalContextJSONStringify, ReflagContext } from "./context"; import { Logger, loggerWithPrefix } from "./logger"; export type EventSourceLike = { @@ -17,36 +17,6 @@ export type PubSubMessage = { [key: string]: any; }; -function withoutUndefinedValues( - attributes: Record | undefined, -) { - if (!attributes) return undefined; - - const cleaned: Record = {}; - for (const [key, value] of Object.entries(attributes)) { - if (value !== undefined) { - cleaned[key] = value; - } - } - - return Object.keys(cleaned).length > 0 ? cleaned : undefined; -} - -function serializeContext(context: ReflagContext | undefined) { - if (!context) return undefined; - - const payload: Record> = {}; - const user = withoutUndefinedValues(context.user); - const company = withoutUndefinedValues(context.company); - const other = withoutUndefinedValues(context.other); - - if (user) payload.user = user; - if (company) payload.company = company; - if (other) payload.other = other; - - return Object.keys(payload).length > 0 ? JSON.stringify(payload) : undefined; -} - export class AblySSEChannel { private isOpen = false; private eventSource: EventSourceLike | null = null; @@ -165,7 +135,7 @@ export class AblySSEChannel { if (this.sdkVersion) { url.searchParams.append(SDK_VERSION_HEADER_NAME, this.sdkVersion); } - const serializedContext = serializeContext(this.context); + const serializedContext = canonicalContextJSONStringify(this.context); if (serializedContext) { url.searchParams.append("context", serializedContext); } diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index 1c3a66e72..9b9467e11 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -131,9 +131,10 @@ describe("ReflagClient", () => { ({ request }) => { requests.push("flags"); const url = new URL(request.url); - expect(url.searchParams.get("context.user.siteCentricOptIn")).toBe( - "true", - ); + expect( + JSON.parse(url.searchParams.get("contextJson") ?? "{}").user + .siteCentricOptIn, + ).toBe("true"); return HttpResponse.json({ success: true, @@ -459,9 +460,12 @@ describe("ReflagClient", () => { http.get( "https://front.reflag.com/features/evaluated", ({ request }) => { - const userId = new URL(request.url).searchParams.get( - "context.user.id", + const contextJson = new URL(request.url).searchParams.get( + "contextJson", ); + const userId = contextJson + ? JSON.parse(contextJson).user?.id + : undefined; if (userId === "user1") { return previousContextResponse.promise.then((response) => { previousContextSettled(); @@ -873,9 +877,12 @@ describe("ReflagClient", () => { http.get( "https://front.reflag.com/features/evaluated", ({ request }) => { - const userId = new URL(request.url).searchParams.get( - "context.user.id", + const contextJson = new URL(request.url).searchParams.get( + "contextJson", ); + const userId = contextJson + ? JSON.parse(contextJson).user?.id + : undefined; return userId === "user1" ? previousContextRefresh.promise : currentContextRefresh.promise; diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index 3dde13932..37a23b942 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -158,9 +158,8 @@ describe("FlagsClient", () => { const paramsObj = Object.fromEntries(new URLSearchParams(params)); expect(paramsObj).toEqual({ "reflag-sdk-version": "browser-sdk/" + version, - "context.user.id": "123", - "context.company.id": "456", - "context.other.eventId": "big-conference1", + contextJson: + '{"company":{"id":"456"},"other":{"eventId":"big-conference1"},"user":{"id":"123"}}', publishableKey: "pk", }); @@ -168,6 +167,21 @@ describe("FlagsClient", () => { expect(timeoutMs).toEqual(5000); }); + test("sends array context as canonical JSON", async () => { + const { newFlagsClient, httpClient } = flagsClientFactory(); + const flagsClient = newFlagsClient({ + user: { id: "123", roles: ["admin", "editor"] }, + other: { z: { second: 2, first: 1 }, a: true }, + }); + + await flagsClient.initialize(); + + const { params } = vi.mocked(httpClient.get).mock.calls[0][0]; + expect(new URLSearchParams(params).get("contextJson")).toBe( + '{"company":{"id":"456"},"other":{"a":true,"z":{"first":1,"second":2}},"user":{"id":"123","roles":["admin","editor"]}}', + ); + }); + test("uses waitForVersion when refreshing flags for a pushed version", async () => { const { newFlagsClient, httpClient } = flagsClientFactory(); const flagsClient = newFlagsClient(); @@ -189,9 +203,8 @@ describe("FlagsClient", () => { expect(path).toEqual("/features/evaluated"); expect(paramsObj).toMatchObject({ - "context.user.id": "123", - "context.company.id": "456", - "context.other.eventId": "big-conference1", + contextJson: + '{"company":{"id":"456"},"other":{"eventId":"big-conference1"},"user":{"id":"123"}}', publishableKey: "pk", waitForVersion: "22", }); diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 2f5eb205b..13fe791df 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -60,6 +60,7 @@ import { } from "./types"; import { applyLogLevel, + canonicalJSONStringify, decorateLogger, hashObject, hashString, @@ -1679,9 +1680,10 @@ export class ReflagClient { checkContextWithTracking(contextWithTracking); - const params = new URLSearchParams( - Object.keys(context).length ? flattenJSON({ context }) : undefined, - ); + const params = new URLSearchParams(); + if (Object.keys(context).length) { + params.set("contextJson", canonicalJSONStringify(context)); + } if (key) { params.append("key", key); diff --git a/packages/node-sdk/src/utils.ts b/packages/node-sdk/src/utils.ts index d934a364c..f30f3be96 100644 --- a/packages/node-sdk/src/utils.ts +++ b/packages/node-sdk/src/utils.ts @@ -148,6 +148,29 @@ function updateSha1Hash(hash: Hash, value: any) { } } +/** + * Serialize JSON with object keys sorted recursively. Array order is preserved. + */ +export function canonicalJSONStringify(value: unknown): string { + const serialized = JSON.stringify(value, (_key, nestedValue: unknown) => { + if ( + nestedValue === null || + typeof nestedValue !== "object" || + Array.isArray(nestedValue) + ) { + return nestedValue; + } + + return Object.fromEntries( + Object.entries(nestedValue).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ); + }); + ok(serialized !== undefined, "value must be JSON serializable"); + return serialized; +} + /** Hash an object using SHA1. * * @param obj - The object to hash. diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 9130478b1..5aae5810f 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -3174,12 +3174,31 @@ describe("getFlagsRemote", () => { expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1", + `https://api.example.com/features/evaluated?${new URLSearchParams({ + contextJson: + '{"company":{"id":"u1"},"other":{"custom":"context","key":"value"},"user":{"id":"c1"}}', + })}`, expectedHeaders, API_TIMEOUT_MS, ); }); + it("sends array context as canonical JSON", async () => { + await client.getFlagsRemote(undefined, undefined, { + other: { + z: { second: 2, first: 1 }, + roles: ["admin", "editor"], + a: true, + }, + }); + + const requestUrl = new URL(httpClient.get.mock.calls[0][0]); + expect(requestUrl.searchParams.get("contextJson")).toBe( + '{"other":{"a":true,"roles":["admin","editor"],"z":{"first":1,"second":2}}}', + ); + expect(requestUrl.searchParams.has("context.other.roles.0")).toBe(false); + }); + it("should not try to append the context if it's empty", async () => { await client.getFlagsRemote(); @@ -3246,7 +3265,11 @@ describe("getFlagRemote", () => { expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated?context.other.custom=context&context.other.key=value&context.user.id=c1&context.company.id=u1&key=flag1", + `https://api.example.com/features/evaluated?${new URLSearchParams({ + contextJson: + '{"company":{"id":"u1"},"other":{"custom":"context","key":"value"},"user":{"id":"c1"}}', + key: "flag1", + })}`, expectedHeaders, API_TIMEOUT_MS, ); @@ -3566,7 +3589,10 @@ describe("BoundReflagClient", () => { expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated?context.user.id=user123&context.user.age=1&context.user.name=John&context.company.id=company123&context.company.employees=100&context.company.name=Acme+Inc.&context.other.custom=context&context.other.key=value", + `https://api.example.com/features/evaluated?${new URLSearchParams({ + contextJson: + '{"company":{"employees":100,"id":"company123","name":"Acme Inc."},"other":{"custom":"context","key":"value"},"user":{"age":1,"id":"user123","name":"John"}}', + })}`, expectedHeaders, API_TIMEOUT_MS, ); @@ -3591,7 +3617,11 @@ describe("BoundReflagClient", () => { expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated?context.user.id=user123&context.user.age=1&context.user.name=John&context.company.id=company123&context.company.employees=100&context.company.name=Acme+Inc.&context.other.custom=context&context.other.key=value&key=flag1", + `https://api.example.com/features/evaluated?${new URLSearchParams({ + contextJson: + '{"company":{"employees":100,"id":"company123","name":"Acme Inc."},"other":{"custom":"context","key":"value"},"user":{"age":1,"id":"user123","name":"John"}}', + key: "flag1", + })}`, expectedHeaders, API_TIMEOUT_MS, ); diff --git a/packages/node-sdk/test/utils.test.ts b/packages/node-sdk/test/utils.test.ts index 12fa1bf4a..e103b5862 100644 --- a/packages/node-sdk/test/utils.test.ts +++ b/packages/node-sdk/test/utils.test.ts @@ -3,6 +3,7 @@ import { createHash } from "crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + canonicalJSONStringify, decorateLogger, hashObject, isObject, @@ -124,6 +125,21 @@ describe("mergeSkipUndefined", () => { }); }); +describe("canonicalJSONStringify", () => { + it("sorts object keys recursively while preserving array order", () => { + expect( + canonicalJSONStringify({ + z: { second: 2, first: 1 }, + values: [{ b: true, a: false }, "second"], + a: 1, + omitted: undefined, + }), + ).toBe( + '{"a":1,"values":[{"a":false,"b":true},"second"],"z":{"first":1,"second":2}}', + ); + }); +}); + describe("hashObject", () => { it("should throw if the given value is not an object", () => { expect(() => hashObject(null as any)).toThrowError( From 820ed04c29dde1891cc2a6ec77a0fb91e3270352 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 4 Sep 2026 08:07:20 +0200 Subject: [PATCH 2/6] Tighten browser context value types --- packages/browser-sdk/src/context.ts | 7 ++++--- packages/browser-sdk/src/flag/flags.ts | 15 --------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/packages/browser-sdk/src/context.ts b/packages/browser-sdk/src/context.ts index a0867f4f8..a13333400 100644 --- a/packages/browser-sdk/src/context.ts +++ b/packages/browser-sdk/src/context.ts @@ -1,12 +1,13 @@ -export type ContextValue = +type DefinedContextValue = | string | number | boolean | null - | undefined - | ContextValue[] + | DefinedContextValue[] | { [key: string]: ContextValue }; +export type ContextValue = DefinedContextValue | undefined; + /** * Serialize context with recursively sorted object keys. Array order is preserved. */ diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 1bdb207ff..7eccfb193 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -195,21 +195,6 @@ export function validateFlagsResponse( }; } -export function flattenJSON(obj: Record): Record { - const result: Record = {}; - for (const key in obj) { - if (typeof obj[key] === "object") { - const flat = flattenJSON(obj[key]); - for (const flatKey in flat) { - result[`${key}.${flatKey}`] = flat[flatKey]; - } - } else if (typeof obj[key] !== "undefined") { - result[key] = obj[key]; - } - } - return result; -} - /** * Event representing checking the flag evaluation result */ From 6bdab3d549daf6d45b8e961771b7ef646e494b1b Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 4 Sep 2026 08:10:22 +0200 Subject: [PATCH 3/6] Keep browser flattenJSON compatibility export --- packages/browser-sdk/src/flag/flags.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 7eccfb193..1bdb207ff 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -195,6 +195,21 @@ export function validateFlagsResponse( }; } +export function flattenJSON(obj: Record): Record { + const result: Record = {}; + for (const key in obj) { + if (typeof obj[key] === "object") { + const flat = flattenJSON(obj[key]); + for (const flatKey in flat) { + result[`${key}.${flatKey}`] = flat[flatKey]; + } + } else if (typeof obj[key] !== "undefined") { + result[key] = obj[key]; + } + } + return result; +} + /** * Event representing checking the flag evaluation result */ From dc0a22423477e0e1e77acf927e01bfe4c49b860e Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 4 Sep 2026 10:13:47 +0200 Subject: [PATCH 4/6] Harden canonical context serialization --- .changeset/context-json-sdk-transport.md | 3 +- packages/browser-sdk/src/context.ts | 80 +++++++++++++++++------ packages/browser-sdk/test/context.test.ts | 38 +++++++++++ packages/node-sdk/src/client.ts | 7 +- packages/node-sdk/src/utils.ts | 78 ++++++++++++++++++---- packages/node-sdk/test/client.test.ts | 7 +- packages/node-sdk/test/utils.test.ts | 32 +++++++++ packages/react-sdk/src/index.tsx | 29 ++++++-- packages/react-sdk/test/usage.test.tsx | 32 ++++++++- 9 files changed, 260 insertions(+), 46 deletions(-) create mode 100644 packages/browser-sdk/test/context.test.ts diff --git a/.changeset/context-json-sdk-transport.md b/.changeset/context-json-sdk-transport.md index ecb1f1def..81d7e1483 100644 --- a/.changeset/context-json-sdk-transport.md +++ b/.changeset/context-json-sdk-transport.md @@ -1,6 +1,7 @@ --- "@reflag/node-sdk": minor "@reflag/browser-sdk": minor +"@reflag/react-sdk": minor --- -Send remote evaluation context as canonical `contextJson`, preserving array-valued context attributes. Browser SDK context types now accept JSON-compatible array and object values. +Send remote evaluation context as canonical `contextJson`, preserving array-valued context attributes. Browser and React SDK context types now accept JSON-compatible array and object values. diff --git a/packages/browser-sdk/src/context.ts b/packages/browser-sdk/src/context.ts index a13333400..195f659d3 100644 --- a/packages/browser-sdk/src/context.ts +++ b/packages/browser-sdk/src/context.ts @@ -12,39 +12,77 @@ export type ContextValue = DefinedContextValue | undefined; * Serialize context with recursively sorted object keys. Array order is preserved. */ function canonicalJSONStringify(value: unknown): string { - const serialized = JSON.stringify(value, (_key, nestedValue: unknown) => { + let serialized: string | undefined; + try { + serialized = JSON.stringify(value, (_key, nestedValue: unknown) => { + if (typeof nestedValue === "bigint") return String(nestedValue); + if ( + nestedValue === null || + typeof nestedValue !== "object" || + Array.isArray(nestedValue) + ) { + return nestedValue; + } + + return Object.fromEntries( + Object.entries(nestedValue).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ); + }); + } catch { + throw new Error("value must be JSON serializable"); + } + if (serialized === undefined) { + throw new Error("value must be JSON serializable"); + } + return serialized; +} + +function pruneUndefinedObjectValues( + value: object, + ancestors: WeakSet, +): Record { + if (ancestors.has(value)) { + throw new Error("value must be JSON serializable"); + } + ancestors.add(value); + + const entries = Object.entries(value).flatMap(([key, nestedValue]) => { + if (nestedValue === undefined) return []; if ( - nestedValue === null || - typeof nestedValue !== "object" || - Array.isArray(nestedValue) + nestedValue !== null && + typeof nestedValue === "object" && + !Array.isArray(nestedValue) ) { - return nestedValue; + const originalKeys = Object.keys(nestedValue); + const pruned = pruneUndefinedObjectValues(nestedValue, ancestors); + if (originalKeys.length > 0 && Object.keys(pruned).length === 0) + return []; + return [[key, pruned] as const]; } - - return Object.fromEntries( - Object.entries(nestedValue).sort(([left], [right]) => - left < right ? -1 : left > right ? 1 : 0, - ), - ); + return [[key, nestedValue] as const]; }); - if (serialized === undefined) - throw new Error("value must be JSON serializable"); - return serialized; + + ancestors.delete(value); + return Object.fromEntries(entries); } export function canonicalContextJSONStringify( context: ReflagContext | undefined, ): string | undefined { if (!context) return undefined; - const nonEmptyContext = Object.fromEntries( - Object.entries(context).filter( - ([, attributes]) => - attributes && - Object.values(attributes).some((value) => value !== undefined), + const pruned = Object.fromEntries( + Object.entries(pruneUndefinedObjectValues(context, new WeakSet())).filter( + ([, section]) => + !section || + typeof section !== "object" || + Array.isArray(section) || + Object.keys(section).length > 0, ), ); - return Object.keys(nonEmptyContext).length - ? canonicalJSONStringify(nonEmptyContext) + return Object.keys(pruned).length + ? canonicalJSONStringify(pruned) : undefined; } diff --git a/packages/browser-sdk/test/context.test.ts b/packages/browser-sdk/test/context.test.ts new file mode 100644 index 000000000..53918d632 --- /dev/null +++ b/packages/browser-sdk/test/context.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; + +import { canonicalContextJSONStringify } from "../src/context"; + +describe("canonicalContextJSONStringify", () => { + it("sorts recursively, preserves arrays, and prunes undefined-only objects", () => { + expect( + canonicalContextJSONStringify({ + user: { id: undefined }, + other: { + z: 2, + dropped: { value: undefined }, + explicitEmpty: {}, + values: [{ value: undefined }, "second"], + a: 1, + }, + }), + ).toBe('{"other":{"a":1,"explicitEmpty":{},"values":[{},"second"],"z":2}}'); + }); + + it("omits an effectively empty context", () => { + expect( + canonicalContextJSONStringify({ user: { id: undefined } }), + ).toBeUndefined(); + }); + + it("normalizes bigint and reports circular values consistently", () => { + expect( + canonicalContextJSONStringify({ other: { value: 42n } } as any), + ).toBe('{"other":{"value":"42"}}'); + + const circular: Record = {}; + circular.self = circular; + expect(() => + canonicalContextJSONStringify({ other: circular } as any), + ).toThrow("value must be JSON serializable"); + }); +}); diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 13fe791df..c046b2c21 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -60,7 +60,7 @@ import { } from "./types"; import { applyLogLevel, - canonicalJSONStringify, + canonicalContextJSONStringify, decorateLogger, hashObject, hashString, @@ -1681,8 +1681,9 @@ export class ReflagClient { checkContextWithTracking(contextWithTracking); const params = new URLSearchParams(); - if (Object.keys(context).length) { - params.set("contextJson", canonicalJSONStringify(context)); + const contextJson = canonicalContextJSONStringify(context); + if (contextJson) { + params.set("contextJson", contextJson); } if (key) { diff --git a/packages/node-sdk/src/utils.ts b/packages/node-sdk/src/utils.ts index f30f3be96..820f3eda9 100644 --- a/packages/node-sdk/src/utils.ts +++ b/packages/node-sdk/src/utils.ts @@ -152,23 +152,75 @@ function updateSha1Hash(hash: Hash, value: any) { * Serialize JSON with object keys sorted recursively. Array order is preserved. */ export function canonicalJSONStringify(value: unknown): string { - const serialized = JSON.stringify(value, (_key, nestedValue: unknown) => { + let serialized: string | undefined; + try { + serialized = JSON.stringify(value, (_key, nestedValue: unknown) => { + if (typeof nestedValue === "bigint") return String(nestedValue); + if ( + nestedValue === null || + typeof nestedValue !== "object" || + Array.isArray(nestedValue) + ) { + return nestedValue; + } + + return Object.fromEntries( + Object.entries(nestedValue).sort(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ); + }); + } catch { + ok(false, "value must be JSON serializable"); + } + ok(serialized !== undefined, "value must be JSON serializable"); + return serialized; +} + +function pruneUndefinedObjectValues( + value: object, + ancestors: WeakSet, +): Record { + ok(!ancestors.has(value), "value must be JSON serializable"); + ancestors.add(value); + + const entries = Object.entries(value).flatMap(([key, nestedValue]) => { + if (nestedValue === undefined) return []; if ( - nestedValue === null || - typeof nestedValue !== "object" || - Array.isArray(nestedValue) + nestedValue !== null && + typeof nestedValue === "object" && + !Array.isArray(nestedValue) ) { - return nestedValue; + const originalKeys = Object.keys(nestedValue); + const pruned = pruneUndefinedObjectValues(nestedValue, ancestors); + if (originalKeys.length > 0 && Object.keys(pruned).length === 0) + return []; + return [[key, pruned] as const]; } - - return Object.fromEntries( - Object.entries(nestedValue).sort(([left], [right]) => - left < right ? -1 : left > right ? 1 : 0, - ), - ); + return [[key, nestedValue] as const]; }); - ok(serialized !== undefined, "value must be JSON serializable"); - return serialized; + + ancestors.delete(value); + return Object.fromEntries(entries); +} + +export function canonicalContextJSONStringify( + context: object | undefined, +): string | undefined { + if (!context) return undefined; + + const pruned = Object.fromEntries( + Object.entries(pruneUndefinedObjectValues(context, new WeakSet())).filter( + ([, section]) => + !section || + typeof section !== "object" || + Array.isArray(section) || + Object.keys(section).length > 0, + ), + ); + return Object.keys(pruned).length + ? canonicalJSONStringify(pruned) + : undefined; } /** Hash an object using SHA1. diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 5aae5810f..ddcebd756 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -3199,8 +3199,11 @@ describe("getFlagsRemote", () => { expect(requestUrl.searchParams.has("context.other.roles.0")).toBe(false); }); - it("should not try to append the context if it's empty", async () => { - await client.getFlagsRemote(); + it.each([ + ["absent", undefined], + ["effectively empty", { other: { nested: { value: undefined } } }], + ])("does not append %s context", async (_case, additionalContext) => { + await client.getFlagsRemote(undefined, undefined, additionalContext); expect(httpClient.get).toHaveBeenCalledTimes(1); diff --git a/packages/node-sdk/test/utils.test.ts b/packages/node-sdk/test/utils.test.ts index e103b5862..b0fb95d1f 100644 --- a/packages/node-sdk/test/utils.test.ts +++ b/packages/node-sdk/test/utils.test.ts @@ -3,6 +3,7 @@ import { createHash } from "crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + canonicalContextJSONStringify, canonicalJSONStringify, decorateLogger, hashObject, @@ -138,6 +139,37 @@ describe("canonicalJSONStringify", () => { '{"a":1,"values":[{"a":false,"b":true},"second"],"z":{"first":1,"second":2}}', ); }); + + it("normalizes bigint and reports circular values consistently", () => { + expect(canonicalJSONStringify({ value: 42n })).toBe('{"value":"42"}'); + + const circular: Record = {}; + circular.self = circular; + expect(() => canonicalJSONStringify(circular)).toThrow( + "validation failed: value must be JSON serializable", + ); + }); +}); + +describe("canonicalContextJSONStringify", () => { + it("deeply prunes undefined-only objects but preserves explicit empties", () => { + expect( + canonicalContextJSONStringify({ + user: { id: undefined }, + other: { + dropped: { value: undefined }, + explicitEmpty: {}, + values: [{ value: undefined }], + }, + }), + ).toBe('{"other":{"explicitEmpty":{},"values":[{}]}}'); + }); + + it("omits an effectively empty context", () => { + expect( + canonicalContextJSONStringify({ user: { id: undefined } }), + ).toBeUndefined(); + }); }); describe("hashObject", () => { diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 8272516d9..b3dcbbc6f 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -214,10 +214,29 @@ const reflagClientBootstrappedStates = new WeakMap< BrowserBootstrappedState >(); +function contextValueEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) || Array.isArray(b)) { + return ( + Array.isArray(a) && + Array.isArray(b) && + a.length === b.length && + a.every((value, index) => contextValueEqual(value, b[index])) + ); + } + if (!a || !b || typeof a !== "object" || typeof b !== "object") { + return false; + } + return contextPartEqual( + a as Record, + b as Record, + ); +} + function contextPartEqual( - a?: Record, - b?: Record, -) { + a?: Record, + b?: Record, +): boolean { if (a === b) return true; if (!a || !b) return !a && !b; @@ -226,7 +245,9 @@ function contextPartEqual( if (aKeys.length !== bKeys.length) return false; return aKeys.every( - (key) => Object.prototype.hasOwnProperty.call(b, key) && a[key] === b[key], + (key) => + Object.prototype.hasOwnProperty.call(b, key) && + contextValueEqual(a[key], b[key]), ); } diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index b30a5f76b..fe0ce835d 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -313,6 +313,28 @@ describe("", () => { expect(ReflagClient.prototype.stop).not.toHaveBeenCalledOnce(); }); + test("does not reset context for structurally equal nested values", async () => { + const setContext = vi.spyOn(ReflagClient.prototype, "setContext"); + const provider = () => + getProvider({ + context: { + user: { + id: "nested-user", + roles: ["admin", "editor"], + settings: { notifications: { enabled: true } }, + }, + }, + }); + + const { rerender } = render(provider()); + await waitFor(() => expect(setContext).toHaveBeenCalledTimes(1)); + + rerender(provider()); + await act(async () => undefined); + + expect(setContext).toHaveBeenCalledTimes(1); + }); + test("handles context changes", async () => { const { queryByTestId, rerender } = render( getProvider({ @@ -702,9 +724,15 @@ describe("useUpdateUser", () => { server.use( http.get(/\/features\/evaluated$/, ({ request }) => { - const siteCentricOptIn = new URL(request.url).searchParams.get( - "context.user.siteCentricOptIn", + const contextJson = new URL(request.url).searchParams.get( + "contextJson", ); + const context = contextJson + ? (JSON.parse(contextJson) as { + user?: { siteCentricOptIn?: string }; + }) + : undefined; + const siteCentricOptIn = context?.user?.siteCentricOptIn ?? null; seenOptInValues.push(siteCentricOptIn); return HttpResponse.json({ From 13983e31e1fcd027c868701c523bc4dc781159f4 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 4 Sep 2026 10:22:09 +0200 Subject: [PATCH 5/6] Document defined context values --- packages/browser-sdk/src/context.ts | 4 +++- packages/browser-sdk/src/index.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/browser-sdk/src/context.ts b/packages/browser-sdk/src/context.ts index 195f659d3..dad447cfc 100644 --- a/packages/browser-sdk/src/context.ts +++ b/packages/browser-sdk/src/context.ts @@ -1,4 +1,5 @@ -type DefinedContextValue = +/** A JSON-compatible context value that is not `undefined`. */ +export type DefinedContextValue = | string | number | boolean @@ -6,6 +7,7 @@ type DefinedContextValue = | DefinedContextValue[] | { [key: string]: ContextValue }; +/** A context value. Object properties set to `undefined` are omitted. */ export type ContextValue = DefinedContextValue | undefined; /** diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index b27a34185..34555b0e2 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -11,6 +11,7 @@ export { ReflagClient } from "./client"; export type { CompanyContext, ContextValue, + DefinedContextValue, ReflagContext, ReflagDeprecatedContext, UserContext, From 7d0470d8a3ab7521f376fb970d0481096418b945 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 4 Sep 2026 10:28:40 +0200 Subject: [PATCH 6/6] Omit empty remote evaluation query --- packages/node-sdk/src/client.ts | 3 ++- packages/node-sdk/test/client.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index c046b2c21..293ee46dc 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -1690,8 +1690,9 @@ export class ReflagClient { params.append("key", key); } + const query = params.toString(); const res = await this.get( - `features/evaluated?${params}`, + `features/evaluated${query ? `?${query}` : ""}`, ); if (key) { diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index ddcebd756..5c9207a03 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -3208,7 +3208,7 @@ describe("getFlagsRemote", () => { expect(httpClient.get).toHaveBeenCalledTimes(1); expect(httpClient.get).toHaveBeenCalledWith( - "https://api.example.com/features/evaluated?", + "https://api.example.com/features/evaluated", expectedHeaders, API_TIMEOUT_MS, );