diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx
index d85c2e2314..b284e75bea 100644
--- a/apps/app/src/app/(app)/[orgId]/layout.tsx
+++ b/apps/app/src/app/(app)/[orgId]/layout.tsx
@@ -15,7 +15,7 @@ import type { OrganizationFromMe } from '@/types';
import { auth } from '@/utils/auth';
import { GetObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@/lib/s3-presigner';
-import { OrganizationIdentifier } from '@trycompai/analytics';
+import { OrganizationIdentifier, ServerFeatureFlagsProvider } from '@trycompai/analytics';
import { db, Role } from '@db/server';
import dynamic from 'next/dynamic';
import { cookies, headers } from 'next/headers';
@@ -146,21 +146,21 @@ export default async function Layout({
// Check feature flags for menu items. Security (penetration tests) is
// always enabled now — the nav rail entry is gated solely by the
// `pentest:read` permission downstream, matching `security/layout.tsx`.
- let isQuestionnaireEnabled = false;
- let isTrustNdaEnabled = false;
- let isWebAutomationsEnabled = false;
+ // The full map is also provided to the client via ServerFeatureFlagsProvider
+ // so `useFeatureFlag` keeps working when posthog-js is blocked client-side.
+ const featureFlags = session?.user?.id
+ ? await getFeatureFlags(session.user.id, {
+ groups: { organization: organization.id },
+ })
+ : {};
+ const isQuestionnaireEnabled = featureFlags['ai-vendor-questionnaire'] === true;
+ const isTrustNdaEnabled =
+ featureFlags['is-trust-nda-enabled'] === true ||
+ featureFlags['is-trust-nda-enabled'] === 'true';
+ const isWebAutomationsEnabled =
+ featureFlags['is-web-automations-enabled'] === true ||
+ featureFlags['is-web-automations-enabled'] === 'true';
const isSecurityEnabled = true;
- if (session?.user?.id) {
- const flags = await getFeatureFlags(session.user.id, {
- groups: { organization: organization.id },
- });
- isQuestionnaireEnabled = flags['ai-vendor-questionnaire'] === true;
- isTrustNdaEnabled =
- flags['is-trust-nda-enabled'] === true || flags['is-trust-nda-enabled'] === 'true';
- isWebAutomationsEnabled =
- flags['is-web-automations-enabled'] === true ||
- flags['is-web-automations-enabled'] === 'true';
- }
// Check auditor role
const hasAuditorRole = roles.includes(Role.auditor);
@@ -192,25 +192,27 @@ export default async function Layout({
initialToken={publicAccessToken || undefined}
>
-
- {children}
-
+
+
+ {children}
+
+
);
diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/OverviewTabs.test.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/OverviewTabs.test.tsx
new file mode 100644
index 0000000000..0114a7739d
--- /dev/null
+++ b/apps/app/src/app/(app)/[orgId]/overview/components/OverviewTabs.test.tsx
@@ -0,0 +1,142 @@
+import { render, renderHook, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+// The live posthog-js value is controlled per-test. `undefined` simulates a
+// client whose /ingest/flags request is blocked (ad blocker, privacy browser,
+// corporate proxy) — flags never load, so the hook never resolves.
+const { useFeatureFlagEnabledMock } = vi.hoisted(() => ({
+ useFeatureFlagEnabledMock: vi.fn<(flag: string) => boolean | undefined>(),
+}));
+
+vi.mock('posthog-js/react', () => ({
+ useFeatureFlagEnabled: (flag: string) => useFeatureFlagEnabledMock(flag),
+ usePostHog: () => null,
+ PostHogProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+vi.mock('next/navigation', () => ({
+ useParams: () => ({ orgId: 'org_test123' }),
+ usePathname: () => '/org_test123/overview',
+}));
+
+vi.mock('@/hooks/use-findings-api', () => ({
+ useOrganizationFindings: () => ({ data: undefined }),
+}));
+
+vi.mock('@db', () => ({
+ FindingStatus: { open: 'open' },
+}));
+
+import { ServerFeatureFlagsProvider, useFeatureFlag } from '@trycompai/analytics';
+import { OverviewTabs } from './OverviewTabs';
+
+describe('useFeatureFlag server fallback', () => {
+ it('returns false when flags never load and no server flags are provided', () => {
+ useFeatureFlagEnabledMock.mockReturnValue(undefined);
+
+ const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'));
+
+ expect(result.current).toBe(false);
+ });
+
+ it('falls back to the server-evaluated value when flags never load', () => {
+ useFeatureFlagEnabledMock.mockReturnValue(undefined);
+
+ const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ expect(result.current).toBe(true);
+ });
+
+ it('treats multivariate (string) server values as enabled', () => {
+ useFeatureFlagEnabledMock.mockReturnValue(undefined);
+
+ const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ expect(result.current).toBe(true);
+ });
+
+ it('stays false when both live and server values are disabled', () => {
+ useFeatureFlagEnabledMock.mockReturnValue(false);
+
+ const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ expect(result.current).toBe(false);
+ });
+
+ it('prefers an enabled server value over a stale persisted live=false', () => {
+ // posthog-js serves flags persisted from an older session even when the
+ // network is blocked — those can predate the admin toggle. The fresher
+ // server-side evaluation must win for enable rollouts.
+ useFeatureFlagEnabledMock.mockReturnValue(false);
+
+ const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ });
+
+ expect(result.current).toBe(true);
+ });
+
+ it('returns true from the live value alone, without a provider', () => {
+ useFeatureFlagEnabledMock.mockReturnValue(true);
+
+ const { result } = renderHook(() => useFeatureFlag('is-timeline-enabled'));
+
+ expect(result.current).toBe(true);
+ });
+});
+
+describe('OverviewTabs timeline gating', () => {
+ it('shows the Timeline tab via server flags when the client cannot load flags', () => {
+ useFeatureFlagEnabledMock.mockReturnValue(undefined);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('Timeline')).toBeInTheDocument();
+ });
+
+ it('hides the Timeline tab when the flag is off everywhere', () => {
+ useFeatureFlagEnabledMock.mockReturnValue(undefined);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByText('Timeline')).not.toBeInTheDocument();
+ });
+
+ it('shows the Timeline tab from the live client flag without server flags', () => {
+ useFeatureFlagEnabledMock.mockReturnValue(true);
+
+ render();
+
+ expect(screen.getByText('Timeline')).toBeInTheDocument();
+ });
+});
diff --git a/packages/analytics/src/components/server-feature-flags-provider.tsx b/packages/analytics/src/components/server-feature-flags-provider.tsx
new file mode 100644
index 0000000000..f203628ce9
--- /dev/null
+++ b/packages/analytics/src/components/server-feature-flags-provider.tsx
@@ -0,0 +1,31 @@
+'use client';
+
+import { createContext, useContext } from 'react';
+
+export type ServerFeatureFlags = Record;
+
+const ServerFeatureFlagsContext = createContext(null);
+
+/**
+ * Provides feature flags evaluated server-side (posthog-node) as a fallback
+ * for `useFeatureFlag`. The client-side posthog-js flags request is routinely
+ * blocked by ad blockers / privacy browsers / corporate proxies — without this
+ * fallback, flag-gated UI silently never renders for those users.
+ */
+export function ServerFeatureFlagsProvider({
+ flags,
+ children,
+}: {
+ flags: ServerFeatureFlags;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useServerFeatureFlags(): ServerFeatureFlags | null {
+ return useContext(ServerFeatureFlagsContext);
+}
diff --git a/packages/analytics/src/hooks/use-feature-flag.ts b/packages/analytics/src/hooks/use-feature-flag.ts
index 6f6e29e714..2f8fe7a6c1 100644
--- a/packages/analytics/src/hooks/use-feature-flag.ts
+++ b/packages/analytics/src/hooks/use-feature-flag.ts
@@ -1,14 +1,29 @@
'use client';
import { useFeatureFlagEnabled } from 'posthog-js/react';
+import { useServerFeatureFlags } from '../components/server-feature-flags-provider';
/**
* Returns whether a feature flag is enabled for the current user/group.
- * Thin wrapper around posthog-js's reactive hook. Returns false until flags
- * finish loading — callers should treat the flag as the source of truth, and
- * create + toggle the flag via the admin UI (or PostHog) to enable features
- * locally.
+ *
+ * The flag is on when EITHER source says so:
+ * - the live posthog-js value (fresh while the browser can reach PostHog), or
+ * - flags evaluated server-side and passed down via ServerFeatureFlagsProvider.
+ *
+ * The OR matters: when the client's flags request is blocked (ad blockers,
+ * privacy browsers, corporate proxies) the live value is `undefined` forever —
+ * or worse, a stale `false` persisted from an old session — and only the
+ * server-evaluated value can turn the feature on. Mirrors PostHog "enabled"
+ * semantics: `true` or any non-empty variant string counts as enabled.
*/
export function useFeatureFlag(flagKey: string): boolean {
- return useFeatureFlagEnabled(flagKey) === true;
+ const liveValue = useFeatureFlagEnabled(flagKey);
+ const serverFlags = useServerFeatureFlags();
+ const serverValue = serverFlags?.[flagKey];
+
+ return (
+ liveValue === true ||
+ serverValue === true ||
+ (typeof serverValue === 'string' && serverValue.length > 0)
+ );
}
diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts
index 45754873df..cc0ab9862d 100644
--- a/packages/analytics/src/index.ts
+++ b/packages/analytics/src/index.ts
@@ -27,4 +27,5 @@ export const Analytics = {
export * from './components/page-view';
export * from './components/provider';
export * from './components/organization-identifier';
+export * from './components/server-feature-flags-provider';
export * from './hooks/use-feature-flag';