Skip to content

Commit b16f895

Browse files
Added URL overrides for Admin Labs flags (#30501)
ref https://linear.app/ghost/issue/BER-3920/ Allowed unreleased Admin features to be tested on live sites without changing server-side Labs settings. Overrides are read from the labs query parameter, persisted for the browser session, and synchronized with Ember during cold loads so both Admin implementations use the same flag state.
1 parent 602c0d9 commit b16f895

15 files changed

Lines changed: 285 additions & 10 deletions

File tree

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { useBrowseConfig } from '../api/config';
2+
import { useFeatureFlagOverrides } from '../providers/feature-flag-overrides-context';
23

34
/**
4-
* Returns whether a Labs flag is explicitly enabled. Only boolean `true`
5-
* counts — `false` while config is loading, missing, or failed.
6-
* Avoids refetching stale config when a feature-gated component mounts.
5+
* Returns whether a Labs flag is explicitly enabled by config or the current
6+
* session's URL overrides. Only boolean `true` config values count. Avoids
7+
* refetching stale config when a feature-gated component mounts.
78
*/
89
export const useFeatureFlag = (flag: string): boolean => {
910
const { data: config } = useBrowseConfig({ refetchOnMount: false });
10-
return config?.config.labs?.[flag] === true;
11+
const { enabledFlags } = useFeatureFlagOverrides();
12+
13+
return config?.config.labs?.[flag] === true || enabledFlags.includes(flag);
1114
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createContext, useContext } from 'react';
2+
3+
interface FeatureFlagOverridesContextValue {
4+
enabledFlags: string[];
5+
}
6+
7+
export const FeatureFlagOverridesContext = createContext<FeatureFlagOverridesContextValue>({
8+
enabledFlags: [],
9+
});
10+
11+
export const useFeatureFlagOverrides = () => useContext(FeatureFlagOverridesContext);

apps/admin-x-framework/src/providers/framework-provider.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export interface FrameworkProviderProps {
4444
onUpdate: (dataType: string, response: unknown) => void;
4545
onInvalidate: (dataType: string) => void;
4646
onDelete: (dataType: string, id: string) => void;
47+
// Called after URL overrides are synced to sessionStorage. May return cleanup work.
48+
onFeatureFlagOverridesChange?: () => void | (() => void);
4749

4850
// Optional QueryClient override. Defaults to the shared window-level
4951
// singleton; test harnesses pass a fresh client per render for isolation.

apps/admin-x-framework/src/providers/router-provider.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
import { useFramework } from './framework-provider';
1313
import { NavigationStackProvider } from './navigation-stack-provider';
1414
import { ErrorPage } from '@tryghost/shade/primitives';
15+
import { syncFeatureFlagOverrides } from '../utils/feature-flag-overrides';
16+
import { FeatureFlagOverridesContext } from './feature-flag-overrides-context';
1517

1618
/**
1719
* This provider uses React Router to provide a router context to React apps
@@ -33,6 +35,23 @@ export interface RouterProviderProps {
3335
children?: React.ReactNode;
3436
}
3537

38+
function FeatureFlagOverridesRouteProvider({ children }: { children: React.ReactNode }) {
39+
const { search } = useLocation();
40+
const { onFeatureFlagOverridesChange } = useFramework();
41+
const enabledFlags = useMemo(() => syncFeatureFlagOverrides(search), [search]);
42+
const value = useMemo(() => ({ enabledFlags }), [enabledFlags]);
43+
44+
useEffect(() => {
45+
return onFeatureFlagOverridesChange?.();
46+
}, [enabledFlags, onFeatureFlagOverridesChange]);
47+
48+
return (
49+
<FeatureFlagOverridesContext.Provider value={value}>
50+
{children}
51+
</FeatureFlagOverridesContext.Provider>
52+
);
53+
}
54+
3655
// Store scroll positions globally
3756
const scrollPositions = new Map<string, number>();
3857

@@ -100,7 +119,11 @@ export function RouterProvider({ routes, prefix, errorElement, children }: Route
100119
// Create a root route that wraps all routes with NavigationStackProvider
101120
// and any additional children (providers) so they have access to routing
102121
const rootRoute: RouteObject = {
103-
element: <NavigationStackProvider>{children}</NavigationStackProvider>,
122+
element: (
123+
<FeatureFlagOverridesRouteProvider>
124+
<NavigationStackProvider>{children}</NavigationStackProvider>
125+
</FeatureFlagOverridesRouteProvider>
126+
),
104127
hydrateFallbackElement: <></>,
105128
children: routes.map((route) => ({
106129
...route,
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const LABS_QUERY_PARAM = 'labs';
2+
const LABS_STORAGE_KEY = 'ghost-admin:labs-overrides';
3+
4+
const getUrlFeatureFlags = (searchParams: URLSearchParams): string[] => {
5+
return searchParams
6+
.getAll(LABS_QUERY_PARAM)
7+
.flatMap((value) => value.split(','))
8+
.filter(Boolean);
9+
};
10+
11+
export const getStoredFeatureFlagOverrides = (): string[] => {
12+
try {
13+
const storedFlags: unknown = JSON.parse(sessionStorage.getItem(LABS_STORAGE_KEY) ?? '[]');
14+
15+
if (!Array.isArray(storedFlags)) {
16+
return [];
17+
}
18+
19+
return storedFlags.filter((flag): flag is string => typeof flag === 'string');
20+
} catch {
21+
return [];
22+
}
23+
};
24+
25+
export const syncFeatureFlagOverrides = (search: string): string[] => {
26+
const searchParams = new URLSearchParams(search);
27+
28+
if (!searchParams.has(LABS_QUERY_PARAM)) {
29+
return getStoredFeatureFlagOverrides();
30+
}
31+
32+
const flags = getUrlFeatureFlags(searchParams);
33+
34+
try {
35+
if (flags.length > 0) {
36+
sessionStorage.setItem(LABS_STORAGE_KEY, JSON.stringify(flags));
37+
} else {
38+
sessionStorage.removeItem(LABS_STORAGE_KEY);
39+
}
40+
} catch {
41+
// Storage can be unavailable in restricted browser environments. The URL
42+
// override still applies to the current React render in that case.
43+
}
44+
45+
return flags;
46+
};

apps/admin-x-framework/test/unit/hooks/use-feature-flag.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@ import { useFeatureFlag } from '../../../src/hooks/use-feature-flag';
44
vi.mock('../../../src/api/config', () => ({
55
useBrowseConfig: vi.fn(),
66
}));
7+
vi.mock('../../../src/providers/feature-flag-overrides-context', () => ({
8+
useFeatureFlagOverrides: vi.fn(),
9+
}));
710

811
import { useBrowseConfig } from '../../../src/api/config';
12+
import { useFeatureFlagOverrides } from '../../../src/providers/feature-flag-overrides-context';
913

1014
const mockUseBrowseConfig = useBrowseConfig as any;
15+
const mockUseFeatureFlagOverrides = vi.mocked(useFeatureFlagOverrides);
1116

1217
const withLabs = (labs: Record<string, unknown>) => ({
1318
data: { config: { labs } },
@@ -16,6 +21,7 @@ const withLabs = (labs: Record<string, unknown>) => ({
1621
describe('useFeatureFlag', () => {
1722
beforeEach(() => {
1823
vi.clearAllMocks();
24+
mockUseFeatureFlagOverrides.mockReturnValue({ enabledFlags: [] });
1925
});
2026

2127
it('returns true when the flag is explicitly true', () => {
@@ -65,4 +71,22 @@ describe('useFeatureFlag', () => {
6571

6672
expect(result.current).toBe(false);
6773
});
74+
75+
it('enables a flag when the session override enables it', () => {
76+
mockUseBrowseConfig.mockReturnValue(withLabs({ myFlag: false }));
77+
mockUseFeatureFlagOverrides.mockReturnValue({ enabledFlags: ['myFlag'] });
78+
79+
const { result } = renderHook(() => useFeatureFlag('myFlag'));
80+
81+
expect(result.current).toBe(true);
82+
});
83+
84+
it('returns false when the session override does not enable the flag', () => {
85+
mockUseBrowseConfig.mockReturnValue(withLabs({ myFlag: false }));
86+
mockUseFeatureFlagOverrides.mockReturnValue({ enabledFlags: ['otherFlag'] });
87+
88+
const { result } = renderHook(() => useFeatureFlag('myFlag'));
89+
90+
expect(result.current).toBe(false);
91+
});
6892
});

apps/admin-x-framework/test/unit/providers/router-provider.test.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,35 @@
11
import { StrictMode } from 'react';
22
import { render, waitFor } from '@testing-library/react';
3-
import { Navigate } from '../../../src/providers/router-provider';
3+
import { Navigate, RouterProvider } from '../../../src/providers/router-provider';
44
import { TestWrapper } from '../../../src/test/test-utils';
55

6+
describe('feature flag overrides', () => {
7+
beforeEach(() => {
8+
sessionStorage.clear();
9+
window.location.hash = '';
10+
});
11+
12+
afterEach(() => {
13+
sessionStorage.clear();
14+
window.location.hash = '';
15+
});
16+
17+
it('notifies the host after storing URL overrides', async () => {
18+
window.location.hash = '#/?labs=testFlag';
19+
const onFeatureFlagOverridesChange = vi.fn(() => {
20+
expect(sessionStorage.getItem('ghost-admin:labs-overrides')).toBe('["testFlag"]');
21+
});
22+
23+
render(
24+
<TestWrapper frameworkProps={{ onFeatureFlagOverridesChange }}>
25+
<RouterProvider prefix="/" routes={[{ path: '/', element: <div>Home</div> }]} />
26+
</TestWrapper>,
27+
);
28+
29+
await waitFor(() => expect(onFeatureFlagOverridesChange).toHaveBeenCalled());
30+
});
31+
});
32+
633
describe('Navigate', () => {
734
it('performs cross-app navigation once after mounting in Strict Mode', async () => {
835
const externalNavigate = vi.fn();
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { beforeEach, describe, expect, it } from 'vitest';
2+
import {
3+
getStoredFeatureFlagOverrides,
4+
syncFeatureFlagOverrides,
5+
} from '../../../src/utils/feature-flag-overrides';
6+
7+
describe('feature flag overrides', () => {
8+
beforeEach(() => {
9+
sessionStorage.clear();
10+
});
11+
12+
it('stores flags listed in the labs query parameter', () => {
13+
expect(syncFeatureFlagOverrides('?labs=testFlag,secondFlag')).toEqual([
14+
'testFlag',
15+
'secondFlag',
16+
]);
17+
expect(getStoredFeatureFlagOverrides()).toEqual(['testFlag', 'secondFlag']);
18+
});
19+
20+
it('supports repeated labs query parameters', () => {
21+
expect(syncFeatureFlagOverrides('?labs=testFlag&labs=secondFlag')).toEqual([
22+
'testFlag',
23+
'secondFlag',
24+
]);
25+
});
26+
27+
it('uses stored overrides when the URL has no labs parameter', () => {
28+
syncFeatureFlagOverrides('?labs=testFlag');
29+
30+
expect(syncFeatureFlagOverrides('?page=2')).toEqual(['testFlag']);
31+
});
32+
33+
it('replaces stored overrides when the URL specifies new flags', () => {
34+
syncFeatureFlagOverrides('?labs=testFlag');
35+
36+
expect(syncFeatureFlagOverrides('?labs=secondFlag')).toEqual(['secondFlag']);
37+
expect(getStoredFeatureFlagOverrides()).toEqual(['secondFlag']);
38+
});
39+
40+
it('clears stored overrides for an empty labs parameter', () => {
41+
syncFeatureFlagOverrides('?labs=testFlag');
42+
43+
expect(syncFeatureFlagOverrides('?labs=')).toEqual([]);
44+
expect(getStoredFeatureFlagOverrides()).toEqual([]);
45+
});
46+
47+
it('ignores malformed stored overrides', () => {
48+
sessionStorage.setItem('ghost-admin:labs-overrides', '{invalid');
49+
50+
expect(getStoredFeatureFlagOverrides()).toEqual([]);
51+
});
52+
});

apps/admin/src/ember-bridge/ember-bridge.test.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ function createMockStateBridge(sidebarVisible = true) {
4444
onUpdate: vi.fn(),
4545
onInvalidate: vi.fn(),
4646
onDelete: vi.fn(),
47+
refreshFeatureFlagOverrides: vi.fn(),
4748
isFeatureEnabled: vi.fn().mockReturnValue(false),
4849
on,
4950
off,
@@ -638,6 +639,22 @@ describe('theme bridge helpers', () => {
638639
});
639640

640641
describe('emberMutationHandlers', () => {
642+
test('waits for Ember to load before refreshing feature flag overrides', async () => {
643+
vi.useFakeTimers();
644+
const { emberMutationHandlers } = await import('./ember-bridge');
645+
646+
const stopWaiting = emberMutationHandlers.onFeatureFlagOverridesChange();
647+
const mock = createMockStateBridge();
648+
window.EmberBridge = { state: mock.stateBridge };
649+
650+
act(() => {
651+
vi.advanceTimersByTime(100);
652+
});
653+
654+
expect(mock.stateBridge.refreshFeatureFlagOverrides).toHaveBeenCalledTimes(1);
655+
stopWaiting();
656+
});
657+
641658
test('resolves the bridge at call time, not import time', async () => {
642659
// Import first, install the bridge after: forwarding must still work.
643660
const { emberMutationHandlers } = await import('./ember-bridge');

apps/admin/src/ember-bridge/ember-bridge.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export interface StateBridge {
2323
onUpdate: (dataType: string, response: unknown) => void;
2424
onInvalidate: (dataType: string) => void;
2525
onDelete: (dataType: string, id: string) => void;
26+
refreshFeatureFlagOverrides?: () => void;
2627
isFeatureEnabled?: (name: string) => boolean | undefined;
2728
preloadAdminThemeStylesheet?: () => Promise<void>;
2829
applyAdminThemePreference?: (mode: AdminThemeMode) => Promise<void> | void;
@@ -315,11 +316,15 @@ export function applyEmberAdminThemePreference(mode: AdminThemeMode): boolean {
315316
}
316317

317318
/**
318-
* React -> Ember mutation sync handlers for the FrameworkProvider. Each
319-
* forwards a successful React mutation to Ember's store sync and no-ops when
320-
* the bridge is absent (standalone React).
319+
* React -> Ember handlers for the FrameworkProvider. Feature flag overrides
320+
* wait for Ember to load; mutation handlers no-op when the bridge is absent.
321321
*/
322322
export const emberMutationHandlers = {
323+
onFeatureFlagOverridesChange: (): (() => void) => {
324+
return waitForStateBridge((stateBridge) => {
325+
stateBridge.refreshFeatureFlagOverrides?.();
326+
});
327+
},
323328
onUpdate: (dataType: string, response: unknown): void => {
324329
window.EmberBridge?.state.onUpdate(dataType, response);
325330
},

0 commit comments

Comments
 (0)