feat(studio): prototype for telemetry entry point (#44720)

## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Some small styling brush ups and experimental for internal telemetry
tools.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Developer toolbar redesigned with compact event/flag lists, “Copy
JSON” per event, and a fixed draggable trigger that snaps and remembers
its position. Toolbar is now available in staging and local
environments.

* **Bug Fixes**
  * ConfigCat readiness wait ensures flags load correctly.
* Feature flag loading made resilient so one provider’s failure won’t
block the other.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Sean Oliver <882952+seanoliver@users.noreply.github.com>
This commit is contained in:
kemal.earthandSean Oliver authored and GitHub committed 2026-04-14 13:19:28 +01:00
1 parent 848d1a3754
commit 12989ba7fe
15 files changed
+682 -330

No files matched your search

@@ -1,6 +1,5 @@
import { LOCAL_STORAGE_KEYS, useParams } from 'common'
import dayjs from 'dayjs'
import { DevToolbarTrigger } from 'dev-tools'
import { AnimatePresence, motion } from 'framer-motion'
import { ChevronLeft } from 'lucide-react'
import Link from 'next/link'
@@ -224,7 +223,6 @@ export const LayoutHeader = ({
{customHeaderComponents && customHeaderComponents}
{IS_PLATFORM ? (
<>
<DevToolbarTrigger />
<FeedbackDropdown />
<div className="flex items-center gap-1 md:gap-2">
+2 -1
View File
@@ -36,7 +36,7 @@ import duration from 'dayjs/plugin/duration'
import relativeTime from 'dayjs/plugin/relativeTime'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import { DevToolbar, DevToolbarProvider } from 'dev-tools'
import { DevToolbar, DevToolbarProvider, DevToolbarTrigger } from 'dev-tools'
import Head from 'next/head'
import { NuqsAdapter } from 'nuqs/adapters/next/pages'
import { ErrorInfo, useCallback, type ComponentProps } from 'react'
@@ -191,6 +191,7 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) {
</CommandProvider>
</AiAssistantStateContextProvider>
<DevToolbar />
<DevToolbarTrigger />
</DevToolbarProvider>
</ThemeProvider>
</RouteValidationWrapper>
Binary file not shown.

After

Width:  |  Height:  |  Size: 244 B

+5 -1
View File
@@ -73,7 +73,11 @@ export async function getFlags(userEmail: string = '', customAttributes?: Record
if (!client) {
return []
} else if (userEmail) {
}
await client.waitForReady()
if (userEmail) {
return client.getAllValuesAsync(
new configcat.User(userEmail, undefined, undefined, _customAttributes)
)
+18 -8
View File
@@ -145,8 +145,8 @@ export const FeatureFlagProvider = ({
let flagStore: FeatureFlagContextType = { configcat: {}, posthog: {} }
// Run both async operations in parallel
const [flags, flagValues] = await Promise.all([
// Run both async operations in parallel — allSettled so a failure in one doesn't block the other
const [phResult, ccResult] = await Promise.allSettled([
loadPHFlags
? (async () => {
await ensureGroupContext()
@@ -163,7 +163,17 @@ export const FeatureFlagProvider = ({
: Promise.resolve([]),
])
const isLocalDev = process.env.NODE_ENV === 'development'
const flags = phResult.status === 'fulfilled' ? phResult.value : {}
if (phResult.status === 'rejected') {
console.warn('[FeatureFlags] PostHog flags failed', phResult.reason)
}
const flagValues = ccResult.status === 'fulfilled' ? ccResult.value : []
// Dev toolbar flag overrides are available in local dev and staging.
// Duplicated for tree-shaking — bundler must see literal process.env reference.
// Keep in sync: dev-tools/index.ts, DevToolbarContext.tsx, DevToolbar.tsx, DevToolbarTrigger.tsx
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
const isDevToolsEnabled = env === 'local' || env === 'staging'
const safeParse = (value: string | undefined): Record<string, boolean | number | string> => {
if (!value) return {}
@@ -176,8 +186,8 @@ export const FeatureFlagProvider = ({
// Process PostHog flags if loaded
if (Object.keys(flags).length > 0) {
// Apply local dev overrides for PostHog flags
if (isLocalDev) {
// Apply dev toolbar overrides for PostHog flags
if (isDevToolsEnabled) {
try {
const cookies = getCookies()
const phOverrides = safeParse(cookies['x-ph-flag-overrides'])
@@ -196,10 +206,10 @@ export const FeatureFlagProvider = ({
try {
const cookies = getCookies()
// Merge overrides: vercel-flag-overrides first, then x-cc-flag-overrides (local only)
// x-cc-flag-overrides takes precedence in local dev
// Merge overrides: vercel-flag-overrides first, then x-cc-flag-overrides (dev toolbar only)
// x-cc-flag-overrides takes precedence when dev tools are enabled
const vercelOverrides = safeParse(cookies['vercel-flag-overrides'])
const ccOverrides = isLocalDev ? safeParse(cookies['x-cc-flag-overrides']) : {}
const ccOverrides = isDevToolsEnabled ? safeParse(cookies['x-cc-flag-overrides']) : {}
overridesCookieValue = {
...vercelOverrides,
+63 -23
View File
@@ -23,7 +23,7 @@ vi.mock('common', async () => {
}
})
const originalEnv = process.env.NODE_ENV
const originalEnv = process.env.NEXT_PUBLIC_ENVIRONMENT
/**
* Helper to render the full component tree as used in production.
@@ -35,12 +35,15 @@ async function renderFullToolbar() {
const { DevToolbarProvider } = await import('./DevToolbarContext')
const { DevToolbarTrigger } = await import('./DevToolbarTrigger')
const { DevToolbar } = await import('./DevToolbar')
const { TooltipProvider } = await import('ui')
return render(
<DevToolbarProvider apiUrl="http://localhost:3000">
<DevToolbarTrigger />
<DevToolbar />
</DevToolbarProvider>
<TooltipProvider>
<DevToolbarProvider apiUrl="http://localhost:3000">
<DevToolbarTrigger />
<DevToolbar />
</DevToolbarProvider>
</TooltipProvider>
)
}
@@ -76,14 +79,18 @@ describe('DevToolbar', () => {
})
afterEach(() => {
process.env.NODE_ENV = originalEnv
if (originalEnv === undefined) {
delete process.env.NEXT_PUBLIC_ENVIRONMENT
} else {
process.env.NEXT_PUBLIC_ENVIRONMENT = originalEnv
}
vi.resetModules()
vi.restoreAllMocks()
})
describe('when not in local development', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production'
process.env.NEXT_PUBLIC_ENVIRONMENT = 'prod'
})
it('returns null and does not render anything', async () => {
@@ -104,7 +111,7 @@ describe('DevToolbar', () => {
describe('when in local development but not enabled', () => {
beforeEach(() => {
process.env.NODE_ENV = 'development'
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
})
it('does not render trigger when toolbar is not enabled', async () => {
@@ -126,7 +133,7 @@ describe('DevToolbar', () => {
describe('when in local development and enabled', () => {
beforeEach(() => {
process.env.NODE_ENV = 'development'
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
})
@@ -149,7 +156,7 @@ describe('DevToolbar', () => {
// Sheet should open with the title
await waitFor(() => {
expect(screen.getByText('Dev Telemetry')).toBeInTheDocument()
expect(screen.getByText('Dev Toolbar')).toBeInTheDocument()
})
})
@@ -166,8 +173,22 @@ describe('DevToolbar', () => {
expect(screen.getByRole('tab', { name: /Flags/i })).toBeInTheDocument()
})
})
})
it('shows "Local Only" badge in toolbar header', async () => {
describe('when in staging environment and enabled', () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_ENVIRONMENT = 'staging'
localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
})
it('renders the toolbar trigger', async () => {
vi.resetModules()
await renderFullToolbar()
const triggerButton = screen.getByRole('button')
expect(triggerButton).toBeInTheDocument()
})
it('shows server events notice in events tab', async () => {
vi.resetModules()
const user = userEvent.setup()
await renderFullToolbar()
@@ -176,14 +197,30 @@ describe('DevToolbar', () => {
await user.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('Local Only')).toBeInTheDocument()
expect(
screen.getByText(
'Server-side events are only visible when using the toolbar in local development'
)
).toBeInTheDocument()
})
})
it('does not connect to SSE in staging', async () => {
const EventSourceSpy = vi.fn()
vi.stubGlobal('EventSource', EventSourceSpy)
vi.resetModules()
await renderFullToolbar()
expect(EventSourceSpy).not.toHaveBeenCalled()
vi.unstubAllGlobals()
})
})
describe('window.devTelemetry function', () => {
beforeEach(() => {
process.env.NODE_ENV = 'development'
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
})
it('enables toolbar when called', async () => {
@@ -203,12 +240,15 @@ describe('DevToolbar', () => {
const { DevToolbarProvider } = await import('./DevToolbarContext')
const { DevToolbarTrigger } = await import('./DevToolbarTrigger')
const { DevToolbar } = await import('./DevToolbar')
const { TooltipProvider } = await import('ui')
rerender(
<DevToolbarProvider apiUrl="http://localhost:3000">
<DevToolbarTrigger />
<DevToolbar />
</DevToolbarProvider>
<TooltipProvider>
<DevToolbarProvider apiUrl="http://localhost:3000">
<DevToolbarTrigger />
<DevToolbar />
</DevToolbarProvider>
</TooltipProvider>
)
expect(localStorage.getItem('dev-telemetry-toolbar-enabled')).toBe('true')
@@ -217,7 +257,7 @@ describe('DevToolbar', () => {
describe('cleanup', () => {
it('removes window.devTelemetry on unmount', async () => {
process.env.NODE_ENV = 'development'
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
vi.resetModules()
const result = await renderFullToolbar()
@@ -232,7 +272,7 @@ describe('DevToolbar', () => {
describe('EventCard keyboard accessibility', () => {
beforeEach(() => {
process.env.NODE_ENV = 'development'
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
})
@@ -246,7 +286,7 @@ describe('DevToolbar', () => {
await user.click(triggerButton)
await waitFor(() => {
expect(screen.getByText('Dev Telemetry')).toBeInTheDocument()
expect(screen.getByText('Dev Toolbar')).toBeInTheDocument()
})
// Events tab should be active by default and show empty state
@@ -256,7 +296,7 @@ describe('DevToolbar', () => {
describe('Flag override UI', () => {
beforeEach(() => {
process.env.NODE_ENV = 'development'
process.env.NEXT_PUBLIC_ENVIRONMENT = 'local'
localStorage.setItem('dev-telemetry-toolbar-enabled', 'true')
})
@@ -277,8 +317,8 @@ describe('DevToolbar', () => {
await user.click(flagsTab)
await waitFor(() => {
expect(screen.getByRole('tab', { name: /PostHog/i })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: /ConfigCat/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /PostHog/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /ConfigCat/i })).toBeInTheDocument()
})
})
})
+307 -184
View File
@@ -1,40 +1,44 @@
'use client'
import { useFeatureFlags } from 'common'
import { Activity, ChevronDown, ChevronUp, Flag } from 'lucide-react'
import { Copy, EyeOff, Search, X } from 'lucide-react'
import Image from 'next/image'
import {
type ChangeEvent,
type Dispatch,
type SetStateAction,
useCallback,
useEffect,
useState,
type ChangeEvent,
type Dispatch,
type SetStateAction,
} from 'react'
import {
Badge,
Button,
Input_Shadcn_ as Input,
cn,
Input,
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
Switch,
Tabs_Shadcn_ as Tabs,
TabsContent_Shadcn_ as TabsContent,
TabsList_Shadcn_ as TabsList,
TabsTrigger_Shadcn_ as TabsTrigger,
cn,
Tooltip,
TooltipContent,
TooltipTrigger,
} from 'ui'
import { useDevToolbar } from './DevToolbarContext'
import type { DevTelemetryEvent } from './types'
import {
CC_ORIGINALS_KEY,
PH_ORIGINALS_KEY,
deleteCookie,
getCookie,
parseOverrideValue,
PH_ORIGINALS_KEY,
readOriginals,
safeJsonParse,
setCookie,
@@ -42,51 +46,88 @@ import {
writeOriginals,
} from './utils'
const IS_LOCAL_DEV = process.env.NODE_ENV === 'development'
// Duplicated for tree-shaking — bundler must see literal process.env reference.
// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbarTrigger.tsx, feature-flags.tsx
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
const IS_LOCAL_DEV = env === 'local'
function EventCard({ event }: { event: DevTelemetryEvent }) {
function EventRow({ event }: { event: DevTelemetryEvent }) {
const [isExpanded, setIsExpanded] = useState(false)
const handleToggle = () => setIsExpanded((prev) => !prev)
const time = new Date(event.timestamp).toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
const handleCopy = (e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard
.writeText(JSON.stringify({ name: event.eventName, properties: event.properties }, null, 2))
.catch((error) => console.warn('Copy failed', error))
}
return (
<div className="border rounded-md p-3 bg-surface-100">
<button
type="button"
aria-expanded={isExpanded}
aria-label={`${event.eventName} event details`}
className="flex items-start justify-between cursor-pointer gap-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm w-full text-left"
onClick={handleToggle}
>
<div className="flex flex-col gap-1 min-w-0 flex-1">
<div className="flex items-start gap-2 flex-wrap">
<Badge variant={event.source === 'client' ? 'default' : 'success'} className="shrink-0">
{event.source}
</Badge>
<Badge variant="secondary" className="shrink-0">
{event.eventType}
</Badge>
<span className="font-mono text-sm break-all">{event.eventName}</span>
</div>
{event.distinctId && (
<div
className="text-xs text-foreground-muted font-mono truncate"
title={event.distinctId}
<div className="group last:border-b-0">
<div className="relative flex items-center h-9 hover:bg-surface-100">
<button
type="button"
aria-expanded={isExpanded}
onClick={() => setIsExpanded((prev) => !prev)}
className="flex items-center flex-1 min-w-0 h-full px-6 gap-5 cursor-pointer"
>
<span className="flex items-center gap-2 shrink-0 w-16">
<span
className={cn(
'w-1.5 h-1.5 rounded-[2px] shrink-0',
event.source === 'client' ? 'bg-brand' : 'bg-foreground-lighter'
)}
/>
<span
className={cn(
'font-mono text-xs uppercase',
event.source === 'client' ? 'text-brand' : 'text-foreground-light'
)}
>
ID: {event.distinctId}
</div>
)}
</div>
<div className="flex items-center gap-2 text-foreground-muted shrink-0">
<span className="text-xs whitespace-nowrap">
{new Date(event.timestamp).toLocaleTimeString()}
{event.source}
</span>
</span>
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</div>
</button>
<span className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden">
<span className="font-mono text-xs text-foreground-lighter shrink-0 w-20">{time}</span>
<span className="font-mono text-xs text-foreground-lighter shrink-0 w-28 truncate uppercase text-left">
{event.eventType}
</span>
<span className="font-mono text-xs text-foreground truncate shrink-0">
{event.eventName}
</span>
{event.distinctId && (
<span className="font-mono text-xs text-foreground-muted truncate">
{event.distinctId}
</span>
)}
</span>
</button>
<div className="shrink-0 pr-6">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleCopy}
aria-label="Copy JSON"
className="p-1 rounded hover:bg-surface-200 text-foreground-muted hover:text-foreground-light"
>
<Copy className="w-3 h-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Copy JSON</TooltipContent>
</Tooltip>
</div>
</div>
{isExpanded && (
<pre className="mt-3 p-2 bg-surface-200 rounded text-xs overflow-x-auto max-h-[300px] overflow-y-auto">
<pre className="px-8 py-2 bg-surface-100 border-b text-xs font-mono overflow-x-auto max-h-[200px] overflow-y-auto text-foreground-light">
{JSON.stringify(event.properties, null, 2)}
</pre>
)}
@@ -94,7 +135,7 @@ function EventCard({ event }: { event: DevTelemetryEvent }) {
)
}
function FlagCard({
function FlagRow({
flagName,
currentValue,
originalValue,
@@ -110,30 +151,44 @@ function FlagCard({
const valueType = typeof originalValue
const isNull = originalValue === null
const inputProps = {
size: 'tiny' as const,
value: String(currentValue),
onChange: (event: ChangeEvent<HTMLInputElement>) => onToggle(event.target.value),
className: 'w-32',
}
return (
<div className={cn('border rounded-md p-3', isOverridden && 'border-warning bg-warning/5')}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span className="font-mono text-sm truncate">{flagName}</span>
{isOverridden && (
<Badge variant="warning" className="shrink-0">
Overridden
</Badge>
)}
{isNull && (
<Badge variant="secondary" className="shrink-0">
null
</Badge>
)}
<div className={cn('px-4 py-3 flex flex-col gap-0.5', isOverridden && 'bg-warning/5')}>
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col items-start gap-1">
<div className="flex gap-2 min-w-0 h-4">
<span
className={cn(
'font-mono text-xs truncate',
isOverridden ? 'text-warning' : 'text-foreground'
)}
>
{flagName}
</span>
{isOverridden && (
<Badge variant="warning" className="shrink-0">
Overridden
</Badge>
)}
{isNull && (
<Badge variant="secondary" className="shrink-0">
null
</Badge>
)}
</div>
<div className="text-xs text-foreground-muted uppercase font-mono">
Original:{' '}
<code className="text-foreground-lighter">{JSON.stringify(originalValue)}</code>
</div>
</div>
{isNull ? (
<Input value="null" disabled className="w-32 opacity-50" />
<Input size="tiny" value="null" disabled className="w-32 opacity-50" />
) : valueType === 'boolean' ? (
<Switch
checked={currentValue as boolean}
@@ -145,19 +200,12 @@ function FlagCard({
<Input {...inputProps} />
)}
</div>
{isOverridden && (
<div className="mt-2 text-xs text-foreground-muted">
Original:{' '}
<code className="bg-surface-200 px-1 rounded">{JSON.stringify(originalValue)}</code>
</div>
)}
</div>
)
}
export function DevToolbar() {
const { isEnabled, isOpen, setIsOpen, events, setEvents } = useDevToolbar()
const { isEnabled, isOpen, setIsOpen, events, setEvents, dismissToolbar } = useDevToolbar()
const [activeTab, setActiveTab] = useState<string>('events')
const [flagsSubTab, setFlagsSubTab] = useState<'posthog' | 'configcat'>('posthog')
const [eventFilter, setEventFilter] = useState<string>('')
@@ -218,6 +266,24 @@ export function DevToolbar() {
setCcFlagOriginals(readOriginals(CC_ORIGINALS_KEY))
}, [])
useEffect(() => {
const STYLE_ID = 'dev-toolbar-hide-native-devtools'
const existing = document.getElementById(STYLE_ID)
if (isOpen) {
if (!existing) {
const style = document.createElement('style')
style.id = STYLE_ID
style.textContent = `
.tsqd-open-btn, .tsqd-open-btn-container { display: none !important; }
`
document.head.appendChild(style)
}
} else {
existing?.remove()
}
}, [isOpen])
const updatePhOriginals = updateOriginals(PH_ORIGINALS_KEY, setPhFlagOriginals)
const updateCcOriginals = updateOriginals(CC_ORIGINALS_KEY, setCcFlagOriginals)
@@ -284,7 +350,7 @@ export function DevToolbar() {
const ccOverrideCount = Object.keys(ccFlagOverrides).length
const totalOverrideCount = phOverrideCount + ccOverrideCount
if (!IS_LOCAL_DEV || !isEnabled) return null
if (!IS_TOOLBAR_ENABLED || !isEnabled) return null
return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
@@ -292,146 +358,203 @@ export function DevToolbar() {
side="bottom"
size="lg"
className="flex flex-col p-0 gap-0 overflow-hidden"
showClose={false}
hasOverlay={false}
onOpenAutoFocus={(event) => event.preventDefault()}
>
<SheetHeader className="px-6 py-4 border-b shrink-0 space-y-0">
<div className="flex items-center gap-3">
<Activity className="w-5 h-5 text-brand-500" />
<SheetTitle className="text-lg font-semibold">Dev Telemetry</SheetTitle>
<Badge variant="secondary">Local Only</Badge>
</div>
<SheetDescription className="sr-only">
View telemetry events and feature flags for local development
</SheetDescription>
</SheetHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col flex-1 min-h-0 overflow-hidden"
>
<SheetHeader className="border-b shrink-0 space-y-0 p-0">
<SheetTitle className="sr-only">Dev Toolbar</SheetTitle>
<SheetDescription className="sr-only">
View telemetry events and feature flags for local development
</SheetDescription>
<div className="flex items-center px-6">
<Image
src="/img/logo-pixel-small-light.png"
alt="Dev Toolbar"
width={16}
height={16}
style={{
filter:
'brightness(0) saturate(100%) invert(72%) sepia(57%) saturate(431%) hue-rotate(108deg) brightness(95%) contrast(91%)',
}}
aria-hidden="true"
className="shrink-0 mr-4"
/>
<TabsList className="flex gap-x-4 rounded-none !border-none h-auto">
<TabsTrigger
value="events"
className="text-xs py-3 border-b-[1px] font-mono uppercase"
>
Events ({filteredEvents.length})
</TabsTrigger>
<TabsTrigger
value="flags"
className="text-xs py-3 border-b-[1px] font-mono uppercase"
>
Flags {totalOverrideCount > 0 && `(${totalOverrideCount})`}
</TabsTrigger>
</TabsList>
<div className="ml-auto flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="text"
icon={<EyeOff className="w-4 h-4" />}
onClick={dismissToolbar}
className="text-foreground-light hover:text-foreground p-1"
/>
</TooltipTrigger>
<TooltipContent side="top">Hide Dev Toolbar</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<SheetClose asChild>
<Button
type="text"
icon={<X className="w-4 h-4" />}
className="text-foreground-light hover:text-foreground p-1"
/>
</SheetClose>
</TooltipTrigger>
<TooltipContent side="top">Close</TooltipContent>
</Tooltip>
</div>
</div>
</SheetHeader>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden px-6 pt-4">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col min-h-0 overflow-hidden"
>
<TabsList className="shrink-0 mb-4">
<TabsTrigger value="events" className="flex items-center gap-2 px-4">
<Activity className="w-4 h-4" />
Events ({filteredEvents.length})
</TabsTrigger>
<TabsTrigger value="flags" className="flex items-center gap-2 px-4">
<Flag className="w-4 h-4" />
Flags {totalOverrideCount > 0 && `(${totalOverrideCount} overrides)`}
</TabsTrigger>
</TabsList>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{activeTab === 'events' && (
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
<div className="flex items-center gap-4 pb-4 shrink-0">
<div className="flex-1 overflow-hidden flex flex-col">
<div className="flex items-center justify-between border-b shrink-0 px-6 py-2">
<Input
size="tiny"
placeholder="Filter events..."
value={eventFilter}
onChange={(e) => setEventFilter(e.target.value)}
className="flex-1"
icon={<Search size={14} className="text-foreground-lighter" />}
className="flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 max-w-96"
/>
<Button type="outline" onClick={() => setEvents([])}>
Clear
<Button
type="default"
onClick={() => setEvents([])}
className="text-foreground-lighter hover:text-foreground"
>
Clear all
</Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto space-y-2 pb-6">
{!IS_LOCAL_DEV && (
<div className="px-6 py-2 text-xs text-foreground-muted border-b bg-surface-100">
Server-side events are only visible when using the toolbar in local development
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto pb-4">
{filteredEvents.length === 0 ? (
<div className="text-center text-foreground-muted py-8">
<div className="text-center text-foreground-lighter py-8 text-sm">
No events yet. Interact with the app to see telemetry events.
</div>
) : (
filteredEvents.map((event) => (
<EventCard key={`${event.source}-${event.id}`} event={event} />
))
<div className="overflow-hidden">
{filteredEvents.map((event) => (
<EventRow key={`${event.source}-${event.id}`} event={event} />
))}
</div>
)}
</div>
</div>
)}
{activeTab === 'flags' && (
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
{totalOverrideCount > 0 && (
<div className="flex items-center justify-between p-3 bg-warning/10 rounded-md mb-4 shrink-0">
<span className="text-sm text-warning">
{totalOverrideCount} flag(s) overridden
{phOverrideCount > 0 && ccOverrideCount > 0
? ` (${phOverrideCount} PostHog, ${ccOverrideCount} ConfigCat)`
: ''}
</span>
<Button type="outline" onClick={clearAllOverrides}>
Clear & Reload
</Button>
<div className="flex-1 min-h-0 overflow-hidden flex">
{/* Sidebar */}
<div className="w-44 border-r shrink-0 flex flex-col">
<nav className="flex flex-col p-3 gap-0.5">
{(
[
{ id: 'posthog', label: 'PostHog', count: phOverrideCount },
{ id: 'configcat', label: 'ConfigCat', count: ccOverrideCount },
] as const
).map(({ id, label, count }) => (
<button
key={id}
type="button"
onClick={() => setFlagsSubTab(id)}
className={cn(
'flex items-center justify-between px-3 py-1.5 rounded text-sm text-left uppercase font-mono tracking-wide',
flagsSubTab === id
? 'bg-surface-300 text-foreground'
: 'text-foreground-light hover:bg-surface-200'
)}
>
<span className="font-mono text-xs">{label}</span>
{count > 0 && (
<span className="text-xs text-foreground-lighter">{count}</span>
)}
</button>
))}
</nav>
{totalOverrideCount > 0 && (
<div className="mt-auto p-2 border-t">
<Button type="outline" size="tiny" block onClick={clearAllOverrides}>
Reset & Reload
</Button>
</div>
)}
</div>
{/* Flag list */}
<div className="flex-1 min-h-0 overflow-y-auto pb-6">
<div className="divide-y">
{flagsSubTab === 'posthog' &&
(Object.keys(posthogFlags).length === 0 ? (
<div className="text-center text-foreground-lighter py-8 text-sm">
No PostHog feature flags loaded yet.
</div>
) : (
Object.entries(posthogFlags).map(([flagName, flagValue]) => (
<FlagRow
key={flagName}
flagName={flagName}
currentValue={
phFlagOverrides[flagName] ?? phFlagOriginals[flagName] ?? flagValue
}
originalValue={phFlagOriginals[flagName] ?? flagValue}
isOverridden={flagName in phFlagOverrides}
onToggle={(value) => togglePhFlagOverride(flagName, value)}
/>
))
))}
{flagsSubTab === 'configcat' &&
(Object.keys(configcatFlags).length === 0 ? (
<div className="text-center text-foreground-lighter py-8 text-sm">
No ConfigCat feature flags loaded yet.
</div>
) : (
Object.entries(configcatFlags).map(([flagName, flagValue]) => (
<FlagRow
key={flagName}
flagName={flagName}
currentValue={
ccFlagOverrides[flagName] ?? ccFlagOriginals[flagName] ?? flagValue
}
originalValue={ccFlagOriginals[flagName] ?? flagValue}
isOverridden={flagName in ccFlagOverrides}
onToggle={(value) => toggleCcFlagOverride(flagName, value)}
/>
))
))}
</div>
)}
<Tabs
value={flagsSubTab}
onValueChange={(v) => setFlagsSubTab(v as 'posthog' | 'configcat')}
className="flex-1 flex flex-col min-h-0 overflow-hidden"
>
<TabsList className="shrink-0 mb-4">
<TabsTrigger value="posthog" className="px-4">
PostHog {phOverrideCount > 0 && `(${phOverrideCount})`}
</TabsTrigger>
<TabsTrigger value="configcat" className="px-4">
ConfigCat {ccOverrideCount > 0 && `(${ccOverrideCount})`}
</TabsTrigger>
</TabsList>
{flagsSubTab === 'posthog' && (
<div className="flex-1 min-h-0 overflow-y-auto pb-6">
<div className="space-y-4">
{Object.keys(posthogFlags).length === 0 ? (
<div className="text-center text-foreground-muted py-8">
No PostHog feature flags loaded yet.
</div>
) : (
Object.entries(posthogFlags).map(([flagName, flagValue]) => (
<FlagCard
key={flagName}
flagName={flagName}
currentValue={
phFlagOverrides[flagName] ?? phFlagOriginals[flagName] ?? flagValue
}
originalValue={phFlagOriginals[flagName] ?? flagValue}
isOverridden={flagName in phFlagOverrides}
onToggle={(value) => togglePhFlagOverride(flagName, value)}
/>
))
)}
</div>
</div>
)}
{flagsSubTab === 'configcat' && (
<div className="flex-1 min-h-0 overflow-y-auto pb-6">
<div className="space-y-4">
{Object.keys(configcatFlags).length === 0 ? (
<div className="text-center text-foreground-muted py-8">
No ConfigCat feature flags loaded yet.
</div>
) : (
Object.entries(configcatFlags).map(([flagName, flagValue]) => (
<FlagCard
key={flagName}
flagName={flagName}
currentValue={
ccFlagOverrides[flagName] ?? ccFlagOriginals[flagName] ?? flagValue
}
originalValue={ccFlagOriginals[flagName] ?? flagValue}
isOverridden={flagName in ccFlagOverrides}
onToggle={(value) => toggleCcFlagOverride(flagName, value)}
/>
))
)}
</div>
</div>
)}
</Tabs>
</div>
</div>
)}
</Tabs>
</div>
</div>
</Tabs>
</SheetContent>
</Sheet>
)
+10 -6
View File
@@ -1,14 +1,14 @@
'use client'
import { type ClientTelemetryEvent, ensurePlatformSuffix, posthogClient } from 'common'
import { ensurePlatformSuffix, posthogClient, type ClientTelemetryEvent } from 'common'
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react'
import type {
@@ -18,7 +18,11 @@ import type {
} from './types'
import { getCookie } from './utils'
const IS_LOCAL_DEV = process.env.NODE_ENV === 'development'
// Duplicated for tree-shaking — bundler must see literal process.env reference.
// Keep in sync: index.ts, DevToolbar.tsx, DevToolbarTrigger.tsx, feature-flags.tsx
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
const IS_LOCAL_DEV = env === 'local'
const MAX_EVENTS = 200
const STORAGE_KEY = 'dev-telemetry-toolbar-enabled'
@@ -56,7 +60,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
}, [])
useEffect(() => {
if (!IS_LOCAL_DEV) return
if (!IS_TOOLBAR_ENABLED) return
let stored: string | null = null
try {
@@ -105,7 +109,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
}, [appendEvent, isEnabled])
useEffect(() => {
if (!isEnabled || typeof EventSource === 'undefined') return
if (!IS_LOCAL_DEV || !isEnabled || typeof EventSource === 'undefined') return
let eventSource: EventSource | null = null
let isMounted = true
@@ -176,7 +180,7 @@ export function DevToolbarProvider({ children, apiUrl }: DevToolbarProviderProps
}
}, [apiUrl, appendEvent, isEnabled])
if (!IS_LOCAL_DEV) {
if (!IS_TOOLBAR_ENABLED) {
return <>{children}</>
}
+236 -91
View File
@@ -1,113 +1,258 @@
'use client'
import { Activity, EyeOff } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type { MouseEvent } from 'react'
import { Button, PopoverContent_Shadcn_, PopoverTrigger_Shadcn_, Popover_Shadcn_, cn } from 'ui'
import Image from 'next/image'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { CSSProperties, PointerEvent } from 'react'
import { Button, cn } from 'ui'
import { useDevToolbar } from './DevToolbarContext'
const IS_LOCAL_DEV = process.env.NODE_ENV === 'development'
// Duplicated for tree-shaking — bundler must see literal process.env reference.
// Keep in sync: index.ts, DevToolbarContext.tsx, DevToolbar.tsx, feature-flags.tsx
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
const IS_TOOLBAR_ENABLED = env === 'local' || env === 'staging'
const POSITION_STORAGE_KEY = 'dev-telemetry-toolbar-position'
const DRAG_THRESHOLD = 4
const MARGIN = 24
const BUTTON_SIZE = 40 // h-10 w-10
// Spring easing: slight overshoot then settle
const SNAP_TRANSITION =
'top 380ms cubic-bezier(0.34, 1.56, 0.64, 1), left 380ms cubic-bezier(0.34, 1.56, 0.64, 1)'
type SnapPosition =
| 'top-left'
| 'top-center'
| 'top-right'
| 'middle-left'
| 'middle-center'
| 'middle-right'
| 'bottom-left'
| 'bottom-center'
| 'bottom-right'
// All positions expressed as pixel top+left so transitions interpolate cleanly
function getSnapCoords(
position: SnapPosition,
vw: number,
vh: number
): { top: number; left: number } {
const [row, col] = position.split('-')
const top =
row === 'top'
? MARGIN
: row === 'bottom'
? vh - MARGIN - BUTTON_SIZE
: Math.round(vh / 2 - BUTTON_SIZE / 2)
const left =
col === 'left'
? MARGIN
: col === 'right'
? vw - MARGIN - BUTTON_SIZE
: Math.round(vw / 2 - BUTTON_SIZE / 2)
return { top, left }
}
function getNearestSnapPosition(cx: number, cy: number): SnapPosition {
const vw = window.innerWidth
const vh = window.innerHeight
const row = cy < vh / 3 ? 'top' : cy > (2 * vh) / 3 ? 'bottom' : 'middle'
const col = cx < vw / 3 ? 'left' : cx > (2 * vw) / 3 ? 'right' : 'center'
return `${row}-${col}` as SnapPosition
}
function readStoredPosition(): SnapPosition {
if (typeof window === 'undefined') return 'bottom-right'
return (localStorage.getItem(POSITION_STORAGE_KEY) as SnapPosition) ?? 'bottom-right'
}
export function DevToolbarTrigger() {
const { isEnabled, isOpen, setIsOpen, events, dismissToolbar } = useDevToolbar()
const [popoverOpen, setPopoverOpen] = useState(false)
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const { isEnabled, isOpen, setIsOpen, events } = useDevToolbar()
const [snapPosition, setSnapPosition] = useState<SnapPosition>('bottom-right')
const [hasHydrated, setHasHydrated] = useState(false)
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null)
// Holds the last drag pixel position for one RAF to prime the CSS transition
const [releasedAt, setReleasedAt] = useState<{ x: number; y: number } | null>(null)
const [viewport, setViewport] = useState(() => ({
w: typeof window !== 'undefined' ? window.innerWidth : 1920,
h: typeof window !== 'undefined' ? window.innerHeight : 1080,
}))
const dragRef = useRef<{
startPointerX: number
startPointerY: number
startButtonX: number
startButtonY: number
hasDragged: boolean
} | null>(null)
const wasDraggingRef = useRef(false)
// Restore persisted position after mount to avoid SSR hydration mismatch.
// hasHydrated is set via RAF so the correct position is painted before transitions are enabled,
// preventing the spring animation from firing on initial load.
useEffect(() => {
return () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current)
closeTimeoutRef.current = null
}
}
const stored = readStoredPosition()
if (stored !== 'bottom-right') setSnapPosition(stored)
const id = requestAnimationFrame(() => setHasHydrated(true))
return () => cancelAnimationFrame(id)
}, [])
if (!IS_LOCAL_DEV || !isEnabled) {
return null
}
// Keep snap coords accurate on resize
useEffect(() => {
const onResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight })
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
// Two-phase snap: hold last drag position for one frame (primes the transition),
// then clear it so the spring fires from that position to the snap target
useEffect(() => {
if (releasedAt === null) return
const id = requestAnimationFrame(() => setReleasedAt(null))
return () => cancelAnimationFrame(id)
}, [releasedAt])
const handlePointerDown = useCallback((e: PointerEvent<HTMLButtonElement>) => {
const rect = e.currentTarget.getBoundingClientRect()
dragRef.current = {
startPointerX: e.clientX,
startPointerY: e.clientY,
startButtonX: rect.left,
startButtonY: rect.top,
hasDragged: false,
}
wasDraggingRef.current = false
e.currentTarget.setPointerCapture(e.pointerId)
}, [])
const handlePointerMove = useCallback((e: PointerEvent<HTMLButtonElement>) => {
if (!dragRef.current) return
const dx = e.clientX - dragRef.current.startPointerX
const dy = e.clientY - dragRef.current.startPointerY
if (!dragRef.current.hasDragged && Math.hypot(dx, dy) < DRAG_THRESHOLD) return
dragRef.current.hasDragged = true
setDragPos({
x: dragRef.current.startButtonX + dx,
y: dragRef.current.startButtonY + dy,
})
}, [])
const handlePointerUp = useCallback((e: PointerEvent<HTMLButtonElement>) => {
if (!dragRef.current) return
const { hasDragged } = dragRef.current
dragRef.current = null
wasDraggingRef.current = hasDragged
if (!hasDragged) return
const rect = e.currentTarget.getBoundingClientRect()
const cx = rect.left + rect.width / 2
const cy = rect.top + rect.height / 2
const newPosition = getNearestSnapPosition(cx, cy)
setSnapPosition(newPosition)
localStorage.setItem(POSITION_STORAGE_KEY, newPosition)
// Phase 1: park at last drag position with transition primed
setReleasedAt({ x: rect.left, y: rect.top })
setDragPos(null)
}, [])
const handlePointerCancel = useCallback(() => {
dragRef.current = null
wasDraggingRef.current = false
setDragPos(null)
setReleasedAt(null)
}, [])
if (!IS_TOOLBAR_ENABLED || !isEnabled) return null
const eventCount = events.length
const isDragging = dragPos !== null
const snapCoords = getSnapCoords(snapPosition, viewport.w, viewport.h)
const FULL_TRANSITION = `${SNAP_TRANSITION}, opacity 200ms ease`
const containerStyle: CSSProperties =
dragPos !== null
? {
position: 'fixed',
zIndex: 50,
left: dragPos.x,
top: dragPos.y,
transition: 'none',
opacity: 1,
}
: releasedAt !== null
? // Phase 1: same pixel position as drag end, transition now defined
{
position: 'fixed',
zIndex: 50,
left: releasedAt.x,
top: releasedAt.y,
transition: FULL_TRANSITION,
opacity: isOpen ? 0 : 1,
pointerEvents: isOpen ? 'none' : undefined,
}
: // Phase 2: spring fires from releasedAt → snapCoords
{
position: 'fixed',
zIndex: 50,
...snapCoords,
transition: hasHydrated ? FULL_TRANSITION : 'none',
opacity: isOpen ? 0 : 1,
pointerEvents: isOpen ? 'none' : undefined,
}
const handleClick = () => {
setPopoverOpen(false)
if (wasDraggingRef.current) {
wasDraggingRef.current = false
return
}
setIsOpen(true)
}
const handleDismiss = () => {
setPopoverOpen(false)
dismissToolbar()
}
const handleMouseEnter = () => {
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current)
closeTimeoutRef.current = null
}
setPopoverOpen(true)
}
const handleMouseLeave = () => {
closeTimeoutRef.current = setTimeout(() => {
setPopoverOpen(false)
}, 100)
}
const handleTriggerMouseLeave = (event: MouseEvent<HTMLButtonElement>) => {
handleMouseLeave()
event.currentTarget.blur()
}
return (
<Popover_Shadcn_ open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger_Shadcn_ asChild>
<Button
type="text"
className={cn(
'relative rounded-full h-[32px] px-2',
'text-foreground-light hover:text-foreground',
'focus-visible:outline-0 focus-visible:outline-transparent focus-visible:outline-offset-0',
isOpen && 'text-foreground bg-surface-300'
)}
aria-label="Open dev toolbar"
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleTriggerMouseLeave}
>
<Activity className="w-4 h-4" />
{eventCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1',
'h-4 min-w-4 px-0.5',
'inline-flex items-center justify-center',
'rounded-full bg-destructive text-white',
'text-[10px] font-medium leading-none'
)}
>
{eventCount > 99 ? '99+' : eventCount}
</span>
)}
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_
side="left"
align="center"
sideOffset={4}
className="w-auto p-1"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onOpenAutoFocus={(event) => event.preventDefault()}
<div style={containerStyle}>
<Button
type="text"
className={cn(
'relative rounded-full h-10 w-10 p-0',
'bg-surface-100 border border-overlay shadow-md',
'text-foreground-light hover:text-foreground hover:bg-surface-200',
'focus-visible:outline-0 focus-visible:outline-transparent focus-visible:outline-offset-0',
'select-none touch-none',
isDragging ? 'cursor-grabbing' : 'cursor-pointer'
)}
aria-label="Open dev toolbar"
onClick={handleClick}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
title="Dev Toolbar"
>
<button
type="button"
className="flex items-center gap-2 px-2 py-1.5 text-sm rounded hover:bg-surface-200 text-foreground-light hover:text-foreground whitespace-nowrap"
onClick={handleDismiss}
>
<EyeOff className="w-4 h-4 shrink-0" />
<span>Hide</span>
</button>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
<Image
src="/img/logo-pixel-small-light.png"
alt="Dev Toolbar"
width={16}
height={16}
style={{
filter:
'brightness(0) saturate(100%) invert(72%) sepia(57%) saturate(431%) hue-rotate(108deg) brightness(95%) contrast(91%)',
}}
aria-hidden="true"
className="pointer-events-none"
/>
{eventCount > 0 && (
<span
className={cn(
'absolute -top-1 -right-1',
'h-4 min-w-4 px-0.5',
'inline-flex items-center justify-center',
'rounded-full bg-destructive text-foreground',
'text-[10px] font-medium leading-none'
)}
>
{eventCount > 99 ? '99+' : eventCount}
</span>
)}
</Button>
</div>
)
}
@@ -0,0 +1,8 @@
import React from 'react'
function MockImage(props: Record<string, unknown>) {
// eslint-disable-next-line @next/next/no-img-element, jsx-a11y/alt-text
return <img {...(props as React.ImgHTMLAttributes<HTMLImageElement>)} />
}
export default MockImage
+18 -14
View File
@@ -5,10 +5,14 @@ import * as DevToolbarContextModule from './DevToolbarContext'
import * as DevToolbarTriggerModule from './DevToolbarTrigger'
import type { DevTelemetryToolbarContextType } from './types'
// Tree-shaking pattern: conditionally export stubs in production
// The bundler replaces process.env.NODE_ENV at build time, making the
// ternary static. Combined with sideEffects: false, the implementation
// modules are eliminated from the production bundle.
// Tree-shaking pattern: conditionally export stubs outside local/staging.
// The bundler replaces NEXT_PUBLIC_ENVIRONMENT at build time, making the
// ternary static. In production builds (env === 'prod'), the implementation
// modules are eliminated from the bundle.
// Duplicated for tree-shaking — bundler must see literal process.env reference.
// Keep in sync: DevToolbarContext.tsx, DevToolbar.tsx, DevToolbarTrigger.tsx, feature-flags.tsx
const env = process.env.NEXT_PUBLIC_ENVIRONMENT
const isToolbarEnabled = env === 'local' || env === 'staging'
const noopContext: DevTelemetryToolbarContextType = {
isEnabled: false,
@@ -19,18 +23,18 @@ const noopContext: DevTelemetryToolbarContextType = {
dismissToolbar: () => {},
}
export const DevToolbarProvider =
process.env.NODE_ENV !== 'development'
? ({ children }: { children: ReactNode; apiUrl?: string }) => children
: DevToolbarContextModule.DevToolbarProvider
export const DevToolbarProvider = !isToolbarEnabled
? ({ children }: { children: ReactNode; apiUrl?: string }) => children
: DevToolbarContextModule.DevToolbarProvider
export const useDevToolbar =
process.env.NODE_ENV !== 'development' ? () => noopContext : DevToolbarContextModule.useDevToolbar
export const useDevToolbar = !isToolbarEnabled
? () => noopContext
: DevToolbarContextModule.useDevToolbar
export const DevToolbar =
process.env.NODE_ENV !== 'development' ? () => null : DevToolbarModule.DevToolbar
export const DevToolbar = !isToolbarEnabled ? () => null : DevToolbarModule.DevToolbar
export const DevToolbarTrigger =
process.env.NODE_ENV !== 'development' ? () => null : DevToolbarTriggerModule.DevToolbarTrigger
export const DevToolbarTrigger = !isToolbarEnabled
? () => null
: DevToolbarTriggerModule.DevToolbarTrigger
export type { DevTelemetryEvent, DevToolbarConfig } from './types'
+1
View File
@@ -33,6 +33,7 @@
"vitest": "catalog:"
},
"peerDependencies": {
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
}
+6
View File
@@ -1,6 +1,12 @@
import path from 'path'
import { defineConfig } from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
'next/image': path.resolve(__dirname, './__mocks__/next-image.tsx'),
},
},
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
+5
View File
@@ -1,6 +1,7 @@
/// <reference types="@testing-library/jest-dom" />
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
@@ -20,6 +21,10 @@ Object.defineProperty(window, 'matchMedia', {
vi.mock('next/navigation', () => require('next-router-mock/navigation'))
// jsdom doesn't implement pointer capture methods
Element.prototype.setPointerCapture = vi.fn()
Element.prototype.releasePointerCapture = vi.fn()
afterEach(() => {
cleanup()
})
+3
View File
@@ -2253,6 +2253,9 @@ importers:
lucide-react:
specifier: '*'
version: 0.511.0(react@18.3.1)
next:
specifier: 'catalog:'
version: 16.2.3(@babel/core@7.29.0(supports-color@8.1.1))(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.77.4)
react:
specifier: 'catalog:'
version: 18.3.1