Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit e440bc3

Browse files
authored
fix(product-view): inline environment pills + hide the page under toolbar menus
The environment dropdown opened underneath the embedded page — the native WebContentsView paints above every renderer popover, so any menu over the page area was invisible. - environments are now inline toolbar pills: one click switches instantly (each environment keeps its own live view, so page state is preserved), nothing opens over the page in the hot path; "+" adds an environment and right-click on a pill removes it - new productViewObscuredStore: renderer popovers that can overlap the page (the pill context menu, alongside the existing command-menu case) hide the native view while open, with an unmount guard so a leaked acquire can't keep the page hidden Generated-By: PostHog Code Task-Id: 1120fcea-fc07-4c54-8dbb-50b7b712f9cb
1 parent 6dfa2b1 commit e440bc3

3 files changed

Lines changed: 111 additions & 55 deletions

File tree

‎packages/ui/src/features/product-view/ProductView.tsx‎

Lines changed: 81 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
ArrowClockwiseIcon,
33
ArrowLeftIcon,
44
ArrowRightIcon,
5-
CaretDownIcon,
65
CursorClickIcon,
76
GlobeIcon,
87
MonitorIcon,
@@ -13,11 +12,10 @@ import { useHostTRPC } from "@posthog/host-router/react";
1312
import {
1413
Badge,
1514
Button,
16-
DropdownMenu,
17-
DropdownMenuContent,
18-
DropdownMenuItem,
19-
DropdownMenuSeparator,
20-
DropdownMenuTrigger,
15+
ContextMenu,
16+
ContextMenuContent,
17+
ContextMenuItem,
18+
ContextMenuTrigger,
2119
Empty,
2220
EmptyDescription,
2321
EmptyHeader,
@@ -29,9 +27,10 @@ import type { ProductEnvironment } from "@posthog/shared";
2927
import { useProjects } from "@posthog/ui/features/projects/useProjects";
3028
import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities";
3129
import { useMutation } from "@tanstack/react-query";
32-
import { useEffect, useMemo, useState } from "react";
30+
import { useEffect, useMemo, useRef, useState } from "react";
3331
import { ElementDetailsPanel } from "./ElementDetailsPanel";
3432
import { ProductEnvironmentPicker } from "./ProductEnvironmentPicker";
33+
import { useProductViewObscuredStore } from "./productViewObscuredStore";
3534
import {
3635
useProductEnvironments,
3736
useRemoveProductEnvironment,
@@ -238,51 +237,26 @@ function ProductBrowser(props: {
238237
<CursorClickIcon size={14} />
239238
Inspect
240239
</Button>
241-
<DropdownMenu>
242-
<DropdownMenuTrigger
243-
render={
244-
<Button variant="outline" size="sm">
245-
{isLocalOrigin(environment.pageOrigin) ? (
246-
<MonitorIcon size={14} />
247-
) : (
248-
<GlobeIcon size={14} />
249-
)}
250-
{environment.label}
251-
<CaretDownIcon size={12} />
252-
</Button>
253-
}
240+
{/* One pill per environment: a click switches instantly (each
241+
environment keeps its own live view). Inline — a popover here
242+
would be hidden under the native page view. */}
243+
{environments.map((env) => (
244+
<EnvironmentPill
245+
key={env.id}
246+
environment={env}
247+
active={env.id === environment.id}
248+
onSelect={() => onSwitchEnvironment(env.id)}
249+
onRemove={() => removeEnvironment.mutate({ id: env.id })}
254250
/>
255-
<DropdownMenuContent align="end">
256-
{environments.map((env) => (
257-
<DropdownMenuItem
258-
key={env.id}
259-
onClick={() => onSwitchEnvironment(env.id)}
260-
>
261-
{isLocalOrigin(env.pageOrigin) ? (
262-
<MonitorIcon size={14} />
263-
) : (
264-
<GlobeIcon size={14} />
265-
)}
266-
{env.label}
267-
<span className="ml-auto pl-4 text-gray-11 text-xs">
268-
{env.pageOrigin.replace(/^https?:\/\//, "")}
269-
</span>
270-
</DropdownMenuItem>
271-
))}
272-
<DropdownMenuSeparator />
273-
<DropdownMenuItem onClick={onAddEnvironment}>
274-
<PlusIcon size={14} />
275-
Add environment…
276-
</DropdownMenuItem>
277-
<DropdownMenuItem
278-
variant="destructive"
279-
onClick={() => removeEnvironment.mutate({ id: environment.id })}
280-
>
281-
<TrashIcon size={14} />
282-
Remove this environment
283-
</DropdownMenuItem>
284-
</DropdownMenuContent>
285-
</DropdownMenu>
251+
))}
252+
<Button
253+
size="icon-sm"
254+
aria-label="Add environment"
255+
title="Add environment"
256+
onClick={onAddEnvironment}
257+
>
258+
<PlusIcon size={14} />
259+
</Button>
286260
<Badge
287261
variant="default"
288262
title="Analytics overlaid on this page come from this PostHog project"
@@ -306,3 +280,59 @@ function ProductBrowser(props: {
306280
</div>
307281
);
308282
}
283+
284+
/** Toolbar pill for one environment. Right-click for management actions;
285+
* the context menu hides the native view while open (z-order). */
286+
function EnvironmentPill(props: {
287+
environment: ProductEnvironment;
288+
active: boolean;
289+
onSelect: () => void;
290+
onRemove: () => void;
291+
}) {
292+
const { environment, active, onSelect, onRemove } = props;
293+
const acquire = useProductViewObscuredStore((s) => s.acquire);
294+
const release = useProductViewObscuredStore((s) => s.release);
295+
// Balance the counter if the pill unmounts while its menu is open (e.g.
296+
// "Remove environment" removes the pill itself) — a leaked acquire would
297+
// keep the page hidden forever.
298+
const holdingRef = useRef(false);
299+
useEffect(
300+
() => () => {
301+
if (holdingRef.current) release();
302+
},
303+
[release],
304+
);
305+
return (
306+
<ContextMenu
307+
onOpenChange={(open) => {
308+
holdingRef.current = open;
309+
if (open) acquire();
310+
else release();
311+
}}
312+
>
313+
<ContextMenuTrigger
314+
render={
315+
<Button
316+
variant={active ? "primary" : "outline"}
317+
size="sm"
318+
title={environment.pageOrigin}
319+
onClick={onSelect}
320+
>
321+
{isLocalOrigin(environment.pageOrigin) ? (
322+
<MonitorIcon size={14} />
323+
) : (
324+
<GlobeIcon size={14} />
325+
)}
326+
{environment.label}
327+
</Button>
328+
}
329+
/>
330+
<ContextMenuContent>
331+
<ContextMenuItem variant="destructive" onClick={onRemove}>
332+
<TrashIcon size={14} />
333+
Remove environment
334+
</ContextMenuItem>
335+
</ContextMenuContent>
336+
</ContextMenu>
337+
);
338+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { create } from "zustand";
2+
3+
/**
4+
* The embedded product page is a NATIVE view that paints above the renderer,
5+
* so any renderer popover that can overlap its rectangle (menus, pickers)
6+
* must hide the view while open. This counter is that cooperation point:
7+
* acquire() on open, release() on close; the slot hides the view while > 0.
8+
*/
9+
interface ProductViewObscuredState {
10+
count: number;
11+
acquire: () => void;
12+
release: () => void;
13+
}
14+
15+
export const useProductViewObscuredStore = create<ProductViewObscuredState>(
16+
(set) => ({
17+
count: 0,
18+
acquire: () => set((state) => ({ count: state.count + 1 })),
19+
release: () => set((state) => ({ count: Math.max(0, state.count - 1) })),
20+
}),
21+
);

‎packages/ui/src/features/product-view/useProductView.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useCommandMenuStore } from "@posthog/ui/shell/commandMenuStore";
33
import { useMutation, useQuery } from "@tanstack/react-query";
44
import { useSubscription } from "@trpc/tanstack-react-query";
55
import { useEffect, useRef, useState } from "react";
6+
import { useProductViewObscuredStore } from "./productViewObscuredStore";
67

78
export interface ProductViewPageState {
89
viewId: string;
@@ -60,6 +61,7 @@ export function useProductViewSlot(input: {
6061
const setBounds = useMutation(trpc.productView.setBounds.mutationOptions());
6162
const setVisible = useMutation(trpc.productView.setVisible.mutationOptions());
6263
const commandMenuOpen = useCommandMenuStore((s) => s.isOpen);
64+
const obscuredCount = useProductViewObscuredStore((s) => s.count);
6365

6466
const openMutate = open.mutateAsync;
6567
const setBoundsMutate = setBounds.mutate;
@@ -124,12 +126,15 @@ export function useProductViewSlot(input: {
124126
setVisibleMutate,
125127
]);
126128

127-
// The command menu is a renderer overlay and cannot paint over the native
128-
// view — hide the view while the menu is open.
129+
// Renderer overlays (command menu, toolbar menus) cannot paint over the
130+
// native view — hide the view while any of them is open.
129131
useEffect(() => {
130132
if (!openedRef.current) return;
131-
setVisibleMutate({ viewId, visible: !commandMenuOpen });
132-
}, [commandMenuOpen, viewId, setVisibleMutate]);
133+
setVisibleMutate({
134+
viewId,
135+
visible: !commandMenuOpen && obscuredCount === 0,
136+
});
137+
}, [commandMenuOpen, obscuredCount, viewId, setVisibleMutate]);
133138

134139
return slotRef;
135140
}

0 commit comments

Comments
 (0)