PostHog/posthog-js

useFeatureFlagEnabled hook: returns 'false' when provided with a string variable

Opened this issue · 3 comments

When providing a hard-coded string to the useFeatureFlagEnabled hook, it works as expected. However, when I pass that same string in as a prop to the component calling the hook, it returns false.

Repro

I have a component ProtectedRoute that handles redirecting users who are not permissioned to a route. I was trying to add an additional feature to this component where providing an optional featureFlag prop, corresponding to a PostHog feature flag, it would:

  1. Flag is loading (undefined): Render a loading screen
  2. Flag is true: Render its children as normal
  3. Flag is false: Redirect the user

I observed strange behavior where this hook would always return false despite the flag's evaluation, but if I simply replaced the passed prop with a hard-coded string, it would return the correct value.

Below is the relevant code:

type ProtectedRouteProps = RouteProps & {
  children: JSX.Element,
  path: string,
  featureFlag: string,
};

const ProtectedRoute = (props: ProtectedRouteProps) => {
  const { path, children, featureFlag, ...rest } = props;

  const { isAuthorized } = useContext(UserContext);
  const { loggedInUser } = useContext(UserQueryContext);
  const flagEnabled = useFeatureFlagEnabled(featureFlag);
  const featureEnabled = !featureFlag || flagEnabled;
  const { t } = useTranslation();

  if (featureEnabled === undefined) {
    return t('loading');
  }

  // If user does not have coach permissions, redirect to the member equivalent to this path
  if (loggedInUser && !isEmpty(loggedInUser) && !hasCoachPermissions(loggedInUser.role as Role) && isCoachProtectedRoute) {
    return (
      <Route
        {...rest}
      >
        <Redirect
              to={{
                pathname: path.replace(coachProtectedRouteRegex, '')
              }}
            />
      </Route>
    );
  }

  return (
    <Route
      {...rest}
      render={({ location }) =>
        isAuthorized && featureEnabled ? (
          children
        ) : (
          <Redirect
            to={{
              pathname: '/login',
              state: featureEnabled ? { routeTo: location } : undefined,
            }}
          />
        )
      }
    />
  );
};

Here's some screenshots of my debugging, showing that simply replacing featureFlag with the hard-coded string changes the behavior (only change in my code is the param swap on line 24)
image
image

Workaround

For now, I worked around this fairly easily by instead using useActiveFeatureFlags

...
  const activeFeatureFlags = useActiveFeatureFlags();
  const featureEnabled = !featureFlag || activeFeatureFlags?.includes(featureFlag || '');
...
if (activeFeatureFlags === undefined) {
    return t('loading');
  }

This appears to work consistently.

Please let me know if there's anything else I can provide to be more helpful here! Thank you!

cc @PostHog/team-feature-success

Hi @johnMorone, I'm an engineer on the feature success team and I'm taking a look at this issue.

Do you mind telling me a bit more about your setup? For context, I tried to reproduce the issue you were seeing by building an app like this

import { useActiveFeatureFlags, useFeatureFlagEnabled, usePostHog } from 'posthog-js/react'
import React, { ReactNode, useEffect, useState } from 'react'
import { PERSON_PROCESSING_MODE, cookieConsentGiven } from '@/src/posthog'

interface FeatureFlagComponentProps {
    flagName: string
    enabledContent: ReactNode
    disabledContent: ReactNode
}

const FeatureFlagComponent: React.FC<FeatureFlagComponentProps> = ({ flagName, enabledContent, disabledContent }) => {
    const isEnabled = useFeatureFlagEnabled(flagName) // similar setup to you, where the prop is passed in as a string

    return (
        <div>
            <h3>Feature Flag: {flagName}</h3>
            {isEnabled ? enabledContent : disabledContent}
        </div>
    )
}

export default function Home() {
    const posthog = usePostHog()
    const [isClient, setIsClient] = useState(false)
    const flags = useActiveFeatureFlags()

    const [time, setTime] = useState('')
    const consentGiven = cookieConsentGiven()

    useEffect(() => {
        setIsClient(true)
        const t = setInterval(() => {
            setTime(new Date().toISOString().split('T')[1].split('.')[0])
        }, 1000)

        return () => {
            clearInterval(t)
        }
    }, [])

    const randomID = () => Math.round(Math.random() * 10000)

    return (
        <>
            <p className="italic my-2 text-gray-500">The current time is {time}</p>

            <h2>Trigger posthog events</h2>
            <div className="flex items-center gap-2 flex-wrap">
                <button onClick={() => posthog.capture('Clicked button')}>Capture event</button>
                <button onClick={() => posthog.capture('user_subscribed')}>Subscribe to newsletter</button>
                <button onClick={() => posthog.capture('user_unsubscribed')}>Unsubscribe from newsletter</button>
                <button data-attr="autocapture-button">Autocapture buttons</button>
                <a className="Button" data-attr="autocapture-button" href="#">
                    <span>Autocapture a &gt; span</span>
                </a>
                <a href={'https://www.google.com'}>External link</a>
                {isClient && typeof window !== 'undefined' && process.env.NEXT_PUBLIC_CROSSDOMAIN && (
                    <a
                        className="Button"
                        href={
                            window.location.host === 'www.posthog.dev:3000'
                                ? 'https://app.posthog.dev:3000'
                                : 'https://www.posthog.dev:3000'
                        }
                    >
                        Change subdomain
                    </a>
                )}

                <button className="ph-no-capture">Ignore certain elements</button>

                <button
                    onClick={() =>
                        posthog?.setPersonProperties({
                            email: `user-${randomID()}@posthog.com`,
                        })
                    }
                >
                    Set user properties
                </button>

                <button onClick={() => posthog?.reset()}>Reset</button>
            </div>

            {isClient && (
                <>
                    {!consentGiven && (
                        <p className="border border-red-900 bg-red-200 rounded p-2">
                            <b>Consent not given!</b> Session recording, surveys, and autocapture are disabled.
                        </p>
                    )}

                    <h2 className="mt-4">PostHog info</h2>
                    <ul className="text-xs bg-gray-100 rounded border-2 border-gray-800 p-4 space-y-2">
                        <li className="font-mono">
                            Person Mode: <b>{PERSON_PROCESSING_MODE}</b>
                        </li>
                        <li className="font-mono">
                            DistinctID: <b>{posthog.get_distinct_id()}</b>
                        </li>
                        <li className="font-mono">
                            SessionID: <b>{posthog.get_session_id()}</b>
                        </li>

                        <li className="font-mono">
                            Active flags:
                            <pre className="text-xs">
                                <code>{JSON.stringify(flags, null, 2)}</code>
                            </pre>
                        </li>
                    </ul>

                    <h2 className="mt-4">PostHog config</h2>
                    <pre className="text-xs bg-gray-100 rounded border-2 border-gray-800 p-4">
                        <code>{JSON.stringify(posthog.config, null, 2)}</code>
                    </pre>
                    {/* New Feature Flag Component */}
                    <h2 className="mt-4">Feature Flag Example</h2>
                    <FeatureFlagComponent
                        flagName="half-of-people" // here's where i pass the props
                        enabledContent={<p>This content is shown when the feature flag is enabled.</p>}
                        disabledContent={<p>This content is shown when the feature flag is disabled.</p>}
                    />
                </>
            )}
        </>
    )
}

And I was never able to reproduce the behavior you described; the feature flag always correctly resolved (or was undefined before PostHog loaded).

Can you share please share more about:

  • how this component is being used
  • how you've configured your feature flags
  • anything else about your application that may be helpful (perhaps any other tricky state management happening under the hood).

Hi @dmarticus - thank you for taking a look into this and the excellent reproduction app - very much appreciated!

I've gone back and checked out the commit with the first approach I shared above that wasn't working last week and it... seems to work completely fine as expected today. I wonder if some cache layer/mechanism here could have contributed to the issue given this outcome.

How this component is being used

The component is used with React Router (v5) in a Switch, as a wrapper to React Router's Route component (which it renders). The featureFlag prop was always passed in hard-coded.

<Switch>
  <ProtectedRoute path={`${path}/home`}>
    <DashboardHome />
  </ProtectedRoute>
  ...
  <ProtectedRoute path={`${path}/schedule`}>
    <Schedule />
  </ProtectedRoute>
  <ProtectedRoute path={`${path}/documents/:tabId?/:resourceId?`}>
    <MyMorrow />
  </ProtectedRoute>
  <ProtectedRoute path={`/education/:contentId`} featureFlag='education-center'>
    <EducationContentPage />
  </ProtectedRoute>
  <ProtectedRoute path={`/education`} featureFlag='education-center'>
    <EducationContentDirectory />
  </ProtectedRoute>
  <Route>
    <Redirect to={{ pathname: `${path}/home` }} />
  </Route>
</Switch>

How you've configured your feature flags

Here is how the flag is configured in PostHog:
image
(Note: For most of my testing, I had turned the rollout for the second condition set to 0% to effectively disable the override to force the flag on everywhere when developing locally)

Anything else about your application that may be helpful

During my testing last week, I made sure to:

  1. "Disable cache" in my dev tools
    image
  2. Clear shared storage, local storage, and cookies for the site between tests.

We're calling identify on sign-up and login (and reset upon signing out). When I was testing, it was a mix of signing out and in with different accounts that would have different flag evaluations as well as hot-reloading (using vite) and manual refreshing of the page while logged in (so identify would not be called in this case).

I can't think of any other state management or application-specific bits that would be relevant here.

For now, I think I'll stick with the useActiveFeatureFlags approach as it has been working consistently. I couldn't find much documentation on it though. Are there known downsides to using this over useFeatureFlagEnabled? Off the top of my head, I can imagine if there are many hundreds of feature flags, the includes check in an array would not be very efficient if we're checking flags that way often.

I'd love to better help resolve this mystery if there's any other information I could provide or tests I could run. Given that I can no longer reproduce, my leading theory is some sort of cache behavior, but I'm not sure at which step or why hard-coding the feature-flag key would have always worked, and passing the prop would always return false unless it's a very unlikely coincidence across a few dozen tests.

Thank you again, and sorry I could not provide a more clear direction for what is going on here!