From 4f802c8cc96bf916dd6f51e92f86965b08a2cd4c Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 3 Oct 2025 18:25:32 +0200 Subject: [PATCH 1/2] v0.0.13: Remove useConfig dependency from client hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes client hooks work outside of Payload Admin UI context by: - Adding FeatureFlagOptions parameter to all hooks for configuration - Using window.location.origin as default serverURL when in browser - Removing @payloadcms/ui dependency from client hooks - Allowing custom serverURL, apiPath, and collectionSlug configuration This fixes the webpack error "_payloadcms_ui__WEBPACK_IMPORTED_MODULE_1__.b() is undefined" when using the hooks in frontend applications. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package.json | 2 +- src/exports/client.ts | 1 + src/hooks/client.ts | 60 +++++++++++++++++++++++++++++++------------ 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/package.json b/package.json index 0fdad0e..d7f4714 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-feature-flags", - "version": "0.0.12", + "version": "0.0.13", "description": "Feature flags plugin for Payload CMS - manage feature toggles, A/B tests, and gradual rollouts", "license": "MIT", "type": "module", diff --git a/src/exports/client.ts b/src/exports/client.ts index b3cbd5f..f4e109d 100644 --- a/src/exports/client.ts +++ b/src/exports/client.ts @@ -7,4 +7,5 @@ export { useRolloutCheck, withFeatureFlag, type FeatureFlag, + type FeatureFlagOptions, } from '../hooks/client.js' diff --git a/src/hooks/client.ts b/src/hooks/client.ts index da0b323..7e762a3 100644 --- a/src/hooks/client.ts +++ b/src/hooks/client.ts @@ -1,6 +1,5 @@ 'use client' import React, { useCallback, useEffect, useState } from 'react' -import { useConfig } from '@payloadcms/ui' export interface FeatureFlag { name: string @@ -14,18 +13,36 @@ export interface FeatureFlag { metadata?: any } +export interface FeatureFlagOptions { + serverURL?: string + apiPath?: string + collectionSlug?: string +} + +// Helper to get config from options or defaults +function getConfig(options?: FeatureFlagOptions) { + const serverURL = options?.serverURL || + (typeof window !== 'undefined' ? window.location.origin : '') || + '' + const apiPath = options?.apiPath || '/api' + const collectionSlug = options?.collectionSlug || 'feature-flags' + + return { serverURL, apiPath, collectionSlug } +} + /** * Hook to fetch all active feature flags from the API */ export function useFeatureFlags( - initialFlags: Partial[] + initialFlags: Partial[], + options?: FeatureFlagOptions ): { flags: Partial[] loading: boolean error: string | null refetch: () => Promise } { - const { config } = useConfig() + const { serverURL, apiPath, collectionSlug } = getConfig(options) const [flags, setFlags] = useState[]>(initialFlags) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -41,7 +58,7 @@ export function useFeatureFlags( ? `?where[name][in]=${names.join(',')}&limit=1000` : '?limit=1000' - const response = await fetch(`${config.serverURL}${config.routes.api}/feature-flags${query}`) + const response = await fetch(`${serverURL}${apiPath}/${collectionSlug}${query}`) if (!response.ok) { throw new Error(`Failed to fetch feature flags: ${response.statusText}`) @@ -77,7 +94,7 @@ export function useFeatureFlags( } finally { setLoading(false) } - }, [config.serverURL, config.routes.api, initialFlags]) + }, [serverURL, apiPath, collectionSlug, initialFlags]) useEffect(() => { void fetchFlags() @@ -89,13 +106,16 @@ export function useFeatureFlags( /** * Hook to check if a specific feature flag is enabled */ -export function useFeatureFlag(flagName: string): { +export function useFeatureFlag( + flagName: string, + options?: FeatureFlagOptions +): { isEnabled: boolean flag: Partial | null loading: boolean error: string | null } { - const { flags, loading, error } = useFeatureFlags([{ name: flagName }]) + const { flags, loading, error } = useFeatureFlags([{ name: flagName }], options) const flag = flags.find(f => f.name === flagName) || null const isEnabled = flag?.enabled || false @@ -106,13 +126,16 @@ export function useFeatureFlag(flagName: string): { /** * Hook to fetch a specific feature flag from the API */ -export function useSpecificFeatureFlag(flagName: string): { +export function useSpecificFeatureFlag( + flagName: string, + options?: FeatureFlagOptions +): { flag: FeatureFlag | null loading: boolean error: string | null refetch: () => Promise } { - const { config } = useConfig() + const { serverURL, apiPath, collectionSlug } = getConfig(options) const [flag, setFlag] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -124,7 +147,7 @@ export function useSpecificFeatureFlag(flagName: string): { // Use Payload's native collection API with query filter const response = await fetch( - `${config.serverURL}${config.routes.api}/feature-flags?where[name][equals]=${flagName}&limit=1` + `${serverURL}${apiPath}/${collectionSlug}?where[name][equals]=${flagName}&limit=1` ) if (!response.ok) { @@ -153,7 +176,7 @@ export function useSpecificFeatureFlag(flagName: string): { } finally { setLoading(false) } - }, [config.serverURL, config.routes.api, flagName]) + }, [serverURL, apiPath, collectionSlug, flagName]) useEffect(() => { void fetchFlag() @@ -167,14 +190,15 @@ export function useSpecificFeatureFlag(flagName: string): { */ export function useVariantSelection( flagName: string, - userId: string + userId: string, + options?: FeatureFlagOptions ): { variant: string | null flag: FeatureFlag | null loading: boolean error: string | null } { - const { flag, loading, error } = useSpecificFeatureFlag(flagName) + const { flag, loading, error } = useSpecificFeatureFlag(flagName, options) const variant = flag?.enabled && flag.variants ? selectVariantForUser(userId, flag.variants) @@ -188,14 +212,15 @@ export function useVariantSelection( */ export function useRolloutCheck( flagName: string, - userId: string + userId: string, + options?: FeatureFlagOptions ): { isInRollout: boolean flag: FeatureFlag | null loading: boolean error: string | null } { - const { flag, loading, error } = useSpecificFeatureFlag(flagName) + const { flag, loading, error } = useSpecificFeatureFlag(flagName, options) const isInRollout = flag?.enabled ? checkUserInRollout(userId, flag.rolloutPercentage || 100) @@ -253,13 +278,14 @@ function checkUserInRollout(userId: string, percentage: number): boolean { */ export function withFeatureFlag

>( flagName: string, - FallbackComponent?: React.ComponentType

+ FallbackComponent?: React.ComponentType

, + options?: FeatureFlagOptions ) { return function FeatureFlagWrapper( WrappedComponent: React.ComponentType

): React.ComponentType

{ return function WithFeatureFlagComponent(props: P): React.ReactElement | null { - const { isEnabled, loading } = useFeatureFlag(flagName) + const { isEnabled, loading } = useFeatureFlag(flagName, options) if (loading) { return null // or a loading spinner From 9bb5f4ecc8dfd670745d5b7d84bb95598b87b06a Mon Sep 17 00:00:00 2001 From: Bas van den Aakster Date: Fri, 3 Oct 2025 18:30:58 +0200 Subject: [PATCH 2/2] v0.0.14: Improve SSR support and fix race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses critical issues identified in code review: 1. Server-Side Environment Handling: - Add warning when serverURL is not provided in SSR/SSG environments - Falls back to relative URLs with console warning - Prevents silent failures in server-side rendering 2. Race Condition Fix: - Use useRef for initialFlags to prevent re-creating fetchFlags on every render - Removes initialFlags from useCallback dependencies - Prevents excessive re-renders and potential infinite loops These improvements ensure better stability and reliability in both client-side and server-side environments. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- package.json | 2 +- src/hooks/client.ts | 30 ++++++++++++++++++++++++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index d7f4714..778c1ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@xtr-dev/payload-feature-flags", - "version": "0.0.13", + "version": "0.0.14", "description": "Feature flags plugin for Payload CMS - manage feature toggles, A/B tests, and gradual rollouts", "license": "MIT", "type": "module", diff --git a/src/hooks/client.ts b/src/hooks/client.ts index 7e762a3..1ea345c 100644 --- a/src/hooks/client.ts +++ b/src/hooks/client.ts @@ -1,5 +1,5 @@ 'use client' -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState, useRef } from 'react' export interface FeatureFlag { name: string @@ -21,9 +21,19 @@ export interface FeatureFlagOptions { // Helper to get config from options or defaults function getConfig(options?: FeatureFlagOptions) { + // In server-side environments, serverURL must be explicitly provided const serverURL = options?.serverURL || - (typeof window !== 'undefined' ? window.location.origin : '') || - '' + (typeof window !== 'undefined' ? window.location.origin : undefined) + + if (!serverURL) { + console.warn( + 'FeatureFlags: serverURL must be provided when using hooks in server-side environment. ' + + 'Falling back to relative URL which may not work correctly.' + ) + // Use relative URL as fallback - will work if API is on same domain + return { serverURL: '', apiPath: options?.apiPath || '/api', collectionSlug: options?.collectionSlug || 'feature-flags' } + } + const apiPath = options?.apiPath || '/api' const collectionSlug = options?.collectionSlug || 'feature-flags' @@ -47,13 +57,21 @@ export function useFeatureFlags( const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + // Use ref to store initialFlags to avoid re-creating fetchFlags on every render + const initialFlagsRef = useRef(initialFlags) + + // Update ref when initialFlags changes (but won't trigger re-fetch) + useEffect(() => { + initialFlagsRef.current = initialFlags + }, [initialFlags]) + const fetchFlags = useCallback(async () => { try { setLoading(true) setError(null) // Use Payload's native collection API - const names = initialFlags.map(f => f.name).filter(Boolean) + const names = initialFlagsRef.current.map(f => f.name).filter(Boolean) const query = names.length > 0 ? `?where[name][in]=${names.join(',')}&limit=1000` : '?limit=1000' @@ -81,7 +99,7 @@ export function useFeatureFlags( } // Sort flags based on the order of names in initialFlags - const sortedFlags = initialFlags.map(initialFlag => { + const sortedFlags = initialFlagsRef.current.map(initialFlag => { const fetchedFlag = fetchedFlagsMap.get(initialFlag.name!) // Use fetched flag if available, otherwise keep the initial flag return fetchedFlag || initialFlag @@ -94,7 +112,7 @@ export function useFeatureFlags( } finally { setLoading(false) } - }, [serverURL, apiPath, collectionSlug, initialFlags]) + }, [serverURL, apiPath, collectionSlug]) // Remove initialFlags from dependencies useEffect(() => { void fetchFlags()