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
2 changes: 1 addition & 1 deletion packages/browser-sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@reflag/browser-sdk",
"version": "1.4.1",
"version": "1.4.2",
"packageManager": "yarn@4.1.1",
"license": "MIT",
"repository": {
Expand Down
9 changes: 7 additions & 2 deletions packages/browser-sdk/src/httpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
33 changes: 33 additions & 0 deletions packages/browser-sdk/test/httpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
});
4 changes: 4 additions & 0 deletions packages/react-native-sdk/dev/bare-rn/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
module.exports = {
root: true,
extends: '@react-native',
};
76 changes: 76 additions & 0 deletions packages/react-native-sdk/dev/bare-rn/.gitignore
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions packages/react-native-sdk/dev/bare-rn/.prettierrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
arrowParens: 'avoid',
singleQuote: true,
trailingComma: 'all',
};
1 change: 1 addition & 0 deletions packages/react-native-sdk/dev/bare-rn/.watchmanconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
242 changes: 242 additions & 0 deletions packages/react-native-sdk/dev/bare-rn/App.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View style={styles.card}>
<Text style={styles.cardTitle}>Runtime Checks</Text>
{checks.map(check => (
<View key={check.label} style={styles.checkRow}>
<Text
style={[
styles.checkStatus,
check.status === 'pass'
? styles.statusPass
: check.status === 'warn'
? styles.statusWarn
: styles.statusFail,
]}
>
{check.status.toUpperCase()}
</Text>
<View style={styles.checkBody}>
<Text style={styles.checkLabel}>{check.label}</Text>
<Text style={styles.checkDetails}>{check.details}</Text>
</View>
</View>
))}
</View>
);
}

function FlagCard() {
const client = useClient();
const isLoading = useIsLoading();
const { isEnabled, track } = useFlag('bare-rn-demo');

return (
<View style={styles.card}>
<Text style={styles.cardTitle}>Flag: bare-rn-demo</Text>
<Text style={styles.cardBody}>
Status: {isLoading ? 'loading' : isEnabled ? 'enabled' : 'disabled'}
</Text>
<View style={styles.buttonRow}>
<Button title="Track usage" onPress={() => void track()} />
<Button title="Refresh" onPress={() => void client.refresh()} />
</View>
</View>
);
}

export default function App() {
return (
<SafeAreaProvider>
<StatusBar barStyle="light-content" />
<SafeAreaView style={styles.container}>
<ReflagProvider
publishableKey={publishableKey || 'demo'}
offline={!isConfigured}
fallbackFlags={['bare-rn-demo']}
context={{
user: { id: 'bare-rn-user', name: 'Bare RN User' },
other: { platform: 'react-native-bare' },
}}
>
<View style={styles.header}>
<Text style={styles.title}>Reflag Bare RN Smoke App</Text>
<Text style={styles.subtitle}>
{isConfigured
? 'Connected to Reflag'
: 'Offline mode (set publishableKey in App.tsx to fetch real flags)'}
</Text>
</View>
<ScrollView contentContainerStyle={styles.scrollContent}>
<RuntimeChecksCard />
<FlagCard />
</ScrollView>
</ReflagProvider>
</SafeAreaView>
</SafeAreaProvider>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0f172a',
},
scrollContent: {
padding: 20,
gap: 16,
},
header: {
paddingHorizontal: 20,
paddingTop: 20,
gap: 8,
},
title: {
fontSize: 24,
fontWeight: '600',
color: '#f8fafc',
},
subtitle: {
fontSize: 14,
color: '#94a3b8',
},
card: {
backgroundColor: '#111827',
borderRadius: 12,
padding: 16,
gap: 12,
borderWidth: 1,
borderColor: '#1f2937',
},
cardTitle: {
fontSize: 16,
fontWeight: '600',
color: '#e2e8f0',
},
cardBody: {
fontSize: 14,
color: '#cbd5f5',
},
buttonRow: {
gap: 12,
},
checkRow: {
flexDirection: 'row',
gap: 12,
alignItems: 'flex-start',
},
checkStatus: {
fontSize: 12,
fontWeight: '700',
minWidth: 42,
},
statusPass: {
color: '#22c55e',
},
statusWarn: {
color: '#f59e0b',
},
statusFail: {
color: '#ef4444',
},
checkBody: {
flex: 1,
gap: 2,
},
checkLabel: {
fontSize: 13,
color: '#e2e8f0',
},
checkDetails: {
fontSize: 12,
color: '#94a3b8',
},
});
Loading