diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index 230bccaf..c9c13870 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/browser-sdk", - "version": "1.4.1", + "version": "1.4.2", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { diff --git a/packages/browser-sdk/src/httpClient.ts b/packages/browser-sdk/src/httpClient.ts index 14f584c3..bf9f207a 100644 --- a/packages/browser-sdk/src/httpClient.ts +++ b/packages/browser-sdk/src/httpClient.ts @@ -51,8 +51,13 @@ export class HttpClient { params.set(SDK_VERSION_HEADER_NAME, this.sdkVersion); params.set("publishableKey", this.publishableKey); - const url = this.getUrl(path); - url.search = params.toString(); + // Do not assign `url.search` directly: some React Native URL implementations + // expose `search` as getter-only and throw at runtime on assignment. + const query = params.toString(); + const pathWithQuery = query + ? `${path}${path.includes("?") ? "&" : "?"}${query}` + : path; + const url = this.getUrl(pathWithQuery); if (timeoutMs === undefined) { return fetch(url, this.fetchOptions); diff --git a/packages/browser-sdk/test/httpClient.test.ts b/packages/browser-sdk/test/httpClient.test.ts index a885d99d..9389b5ec 100644 --- a/packages/browser-sdk/test/httpClient.test.ts +++ b/packages/browser-sdk/test/httpClient.test.ts @@ -63,4 +63,37 @@ describe("sets `credentials`", () => { expect.objectContaining({ credentials: "include" }), ); }); + + test("does not require a writable `URL.search` property", async () => { + const OriginalURL = global.URL; + + class ReadonlySearchURL extends OriginalURL { + get search() { + return super.search; + } + + set search(_value: string) { + throw new TypeError( + "Cannot assign to property 'search' which has only a getter", + ); + } + } + + global.URL = ReadonlySearchURL as unknown as typeof URL; + + try { + const client = new HttpClient("publishableKey"); + await client.get({ + path: "/test", + params: new URLSearchParams({ hello: "world" }), + }); + + const [firstArg] = vi.mocked(global.fetch).mock.calls[0]!; + const url = new OriginalURL(String(firstArg)); + expect(url.searchParams.get("hello")).toBe("world"); + expect(url.searchParams.get("publishableKey")).toBe("publishableKey"); + } finally { + global.URL = OriginalURL; + } + }); }); diff --git a/packages/react-native-sdk/dev/bare-rn/.eslintrc.js b/packages/react-native-sdk/dev/bare-rn/.eslintrc.js new file mode 100644 index 00000000..187894b6 --- /dev/null +++ b/packages/react-native-sdk/dev/bare-rn/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: '@react-native', +}; diff --git a/packages/react-native-sdk/dev/bare-rn/.gitignore b/packages/react-native-sdk/dev/bare-rn/.gitignore new file mode 100644 index 00000000..beacfa68 --- /dev/null +++ b/packages/react-native-sdk/dev/bare-rn/.gitignore @@ -0,0 +1,76 @@ +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +**/.xcode.env.local + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ +*.keystore +!debug.keystore +.kotlin/ + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/ + +**/fastlane/report.xml +**/fastlane/Preview.html +**/fastlane/screenshots +**/fastlane/test_output + +# Bundle artifact +*.jsbundle + +# Ruby / CocoaPods +**/Pods/ +/vendor/bundle/ +.bundle/ + +# Temporary files created by Metro to check the health of the file watcher +.metro-health-check* + +# testing +/coverage + +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions diff --git a/packages/react-native-sdk/dev/bare-rn/.prettierrc.js b/packages/react-native-sdk/dev/bare-rn/.prettierrc.js new file mode 100644 index 00000000..06860c8d --- /dev/null +++ b/packages/react-native-sdk/dev/bare-rn/.prettierrc.js @@ -0,0 +1,5 @@ +module.exports = { + arrowParens: 'avoid', + singleQuote: true, + trailingComma: 'all', +}; diff --git a/packages/react-native-sdk/dev/bare-rn/.watchmanconfig b/packages/react-native-sdk/dev/bare-rn/.watchmanconfig new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/packages/react-native-sdk/dev/bare-rn/.watchmanconfig @@ -0,0 +1 @@ +{} diff --git a/packages/react-native-sdk/dev/bare-rn/App.tsx b/packages/react-native-sdk/dev/bare-rn/App.tsx new file mode 100644 index 00000000..83c98352 --- /dev/null +++ b/packages/react-native-sdk/dev/bare-rn/App.tsx @@ -0,0 +1,242 @@ +import React, { useMemo } from 'react'; +import { + Button, + ScrollView, + StatusBar, + StyleSheet, + Text, + View, +} from 'react-native'; +import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; + +import { + ReflagProvider, + useClient, + useFlag, + useIsLoading, +} from '@reflag/react-native-sdk'; + +const publishableKey = 'pub_prod_vxuM5hSZOnhzvAfiOnZ9rj'; +const isConfigured = publishableKey.length > 0; + +type CheckStatus = 'pass' | 'warn' | 'fail'; + +interface RuntimeCheck { + label: string; + status: CheckStatus; + details: string; +} + +function runRuntimeChecks(): RuntimeCheck[] { + const checks: RuntimeCheck[] = []; + + const hasURL = typeof URL !== 'undefined'; + checks.push({ + label: 'global URL', + status: hasURL ? 'pass' : 'fail', + details: hasURL ? 'available' : 'missing', + }); + + const hasURLSearchParams = typeof URLSearchParams !== 'undefined'; + checks.push({ + label: 'global URLSearchParams', + status: hasURLSearchParams ? 'pass' : 'fail', + details: hasURLSearchParams ? 'available' : 'missing', + }); + + if (hasURL) { + const descriptor = Object.getOwnPropertyDescriptor(URL.prototype, 'search'); + checks.push({ + label: 'URL.search setter', + status: descriptor?.set ? 'pass' : 'warn', + details: descriptor?.set + ? 'setter present' + : 'getter-only URL.search (older RN behavior)', + }); + } + + try { + const url = new URL('/probe', 'https://front.reflag.com'); + url.searchParams.set('check', 'ok'); + const href = url.toString(); + checks.push({ + label: 'URL + searchParams behavior', + status: href.includes('check=ok') ? 'pass' : 'fail', + details: href, + }); + } catch (error) { + checks.push({ + label: 'URL + searchParams behavior', + status: 'fail', + details: String(error), + }); + } + + checks.push({ + label: 'global EventSource (auto feedback only)', + status: typeof EventSource !== 'undefined' ? 'pass' : 'warn', + details: + typeof EventSource !== 'undefined' + ? 'available' + : 'missing: keep feedback.enableAutoFeedback=false', + }); + + return checks; +} + +function RuntimeChecksCard() { + const checks = useMemo(() => runRuntimeChecks(), []); + + return ( + + Runtime Checks + {checks.map(check => ( + + + {check.status.toUpperCase()} + + + {check.label} + {check.details} + + + ))} + + ); +} + +function FlagCard() { + const client = useClient(); + const isLoading = useIsLoading(); + const { isEnabled, track } = useFlag('bare-rn-demo'); + + return ( + + Flag: bare-rn-demo + + Status: {isLoading ? 'loading' : isEnabled ? 'enabled' : 'disabled'} + + +