Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { ReadyRuntimeError } from '../../../helpers/get-error-by-type'
import { Toast } from '../../Toast'
import React, { useState } from 'react'
import { NextLogo } from './internal/next-logo'
import { useIsDevBuilding } from '../../../../../../../dev/dev-build-indicator/internal/initialize-for-new-overlay'
import { useIsDevRendering } from './internal/dev-render-indicator'

// TODO: test a11y
// TODO: add E2E tests to cover different scenarios
Expand Down Expand Up @@ -51,13 +53,18 @@ const DevToolsPopover = ({
}) => {
// TODO: close when clicking outside

const isDevBuilding = useIsDevBuilding()
const isDevRendering = useIsDevRendering()

const [isPopoverOpen, setIsPopoverOpen] = useState(false)
const togglePopover = () => setIsPopoverOpen((prev) => !prev)
return (
<Toast style={{ boxShadow: 'none' }}>
<NextLogo
issueCount={issueCount}
onClick={togglePopover}
isDevBuilding={isDevBuilding}
isDevRendering={isDevRendering}
Comment on lines 66 to 67

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as far as the logo is concerned it's only ever rendering or building, so I'm wondering if this should be combined into a single enum state? e.g. IDLE | BUILDING | RENDERING

@gaojude gaojude Jan 13, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking it through, passing two separate props makes sense because:

  1. Four Possible States: Technically, there are four states (building on/off × rendering on/off). Since dev building and rendering are independent, the Logo component needs to decide how to handle conflicting states (e.g., both building and rendering happening simultaneously). I want that logic to be delegated to Logo instead of preprocessing it beforehand.

  2. Component Behavior: The Logo is already a “smart” component—it includes logic like a 200ms debounce. This makes it more than just a dummy presentational component. Hence, adding a bit more logic to handle multiple states is more consistent with its current responsibilities. (The reason we pass rendering and building props rather than having Logo manage these internally via hooks is to allow flexibility for overrides, such as in Storybook.)

aria-haspopup="true"
aria-expanded={isPopoverOpen}
aria-controls="dev-tools-popover"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Singleton store to track whether the app is currently being rendered
* Used by the dev tools indicator to show render status
*/

import { useSyncExternalStore } from 'react'

let isVisible = false
let listeners: Array<() => void> = []

const subscribe = (listener: () => void) => {
listeners.push(listener)
return () => {
listeners = listeners.filter((l) => l !== listener)
}
}

const getSnapshot = () => isVisible

const show = () => {
isVisible = true
listeners.forEach((listener) => listener())
}

const hide = () => {
isVisible = false
listeners.forEach((listener) => listener())
}

export function useIsDevRendering() {
return useSyncExternalStore(subscribe, getSnapshot)
}

export const devRenderIndicator = {
show,
hide,
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,55 @@ type Story = StoryObj<typeof NextLogo>
export const NoIssues: Story = {
args: {
issueCount: 0,
isDevBuilding: false,
isDevRendering: false,
},
}

export const SingleIssue: Story = {
args: {
issueCount: 1,
isDevBuilding: false,
isDevRendering: false,
},
}

export const MultipleIssues: Story = {
args: {
issueCount: 5,
isDevBuilding: false,
isDevRendering: false,
},
}

export const ManyIssues: Story = {
args: {
issueCount: 99,
isDevBuilding: false,
isDevRendering: false,
},
}

export const Building: Story = {
args: {
issueCount: 0,
isDevBuilding: true,
isDevRendering: false,
},
}

export const BuildingWithError: Story = {
args: {
issueCount: 1,
isDevBuilding: true,
isDevRendering: false,
},
}

export const Rendering: Story = {
args: {
issueCount: 0,
isDevBuilding: false,
isDevRendering: true,
},
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,84 @@
import { useEffect, useState } from 'react'
import { noop as css } from '../../../../../../internal/helpers/noop-template'

interface Props extends React.ComponentProps<'svg'> {
issueCount: number
onClick: () => void
isDevBuilding: boolean
isDevRendering: boolean
}

export const NextLogo = ({ issueCount, onClick, ...props }: Props) => {
// TODO: animate it based on build status..
// TODO: add red dot when there are errors + change color of the logo
export const NextLogo = ({
issueCount,
onClick,
isDevBuilding,
isDevRendering,
...props
}: Props) => {
const [isLoading, setIsLoading] = useState(false)

// Only shows the loading state after a 200ms delay when building or rendering,
// to avoid flashing the loading state for quick updates
useEffect(() => {
if (isDevBuilding || isDevRendering) {
const timeout = setTimeout(() => {
setIsLoading(true)
}, 200)
return () => clearTimeout(timeout)
} else {
setIsLoading(false)
}
}, [isDevBuilding, isDevRendering])

return (
<div onClick={onClick} style={{ position: 'relative' }}>
<style>
{css`
.path0 {
animation: draw0 2s ease-in-out infinite;
}

.paused {
stroke-dashoffset: 0;
}

@keyframes draw0 {
0% {
stroke-dashoffset: -29.6;
}
50% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 29.6;
}
}

.path1 {
animation: draw1 2s ease-out infinite;
animation-delay: 0.5s;
}

@keyframes draw1 {
0% {
stroke-dashoffset: -11.6;
}
25% {
stroke-dashoffset: 0;
}
50% {
stroke-dashoffset: 0;
}
75% {
stroke-dashoffset: 11.6;
}
100% {
stroke-dashoffset: 11.6;
}
}
`}
</style>

{/* Add issue count circle if issues exist */}
{issueCount > 0 ? (
<div
Expand Down Expand Up @@ -87,32 +158,81 @@ export const NextLogo = ({ issueCount, onClick, ...props }: Props) => {
width="36"
height="36"
rx="18"
fill={issueCount > 0 ? '#CA2A30' : '#551A1E'}
/>
<path
d="
M30.2854 31.6696
L16.3951 13.7771
H13.7779
V26.2163
H15.8717
V16.4359
L28.6419 32.9356
C29.2182 32.5499 29.7672 32.1267 30.2854 31.6696
"
fill="url(#paint1_linear_1457_6023)"
/>
<rect
x="24.3213"
y="13.7771"
width="2.0741"
height="12.4444"
fill="url(#paint2_linear_1457_6023)"
fill={issueCount > 0 ? '#CA2A30' : '#2A2A2A'}
/>

<g transform="translate(12, 12)">
<path
className={isLoading ? 'path0' : 'paused'}
d="M13.3 15.2 L2.34 1 V12.6"
fill="none"
stroke="url(#paint0_linear_1357_10853)"
strokeWidth="1.86"
mask="url(#mask0)"
strokeDasharray="29.6"
strokeDashoffset="29.6"
/>
<path
className={isLoading ? 'path1' : 'paused'}
d="M11.825 1.5 V13.1"
strokeWidth="1.86"
stroke="url(#paint1_linear_1357_10853)"
strokeDasharray="11.6"
strokeDashoffset="11.6"
/>
</g>
</g>
</g>

<defs>
<linearGradient
id="paint0_linear_1357_10853"
x1="9.95555"
y1="11.1226"
x2="15.4778"
y2="17.9671"
gradientUnits="userSpaceOnUse"
>
<stop
stopColor={isDevBuilding ? 'rgba(255,255,255,0.7)' : 'white'}
/>
<stop
offset="0.604072"
stopColor={isDevBuilding ? 'rgba(255,255,255,0.7)' : 'white'}
stopOpacity="0"
/>
<stop
offset="1"
stopColor={isDevBuilding ? 'rgba(255,255,255,0.7)' : 'white'}
stopOpacity="0"
/>
</linearGradient>
<linearGradient
id="paint1_linear_1357_10853"
x1="11.8222"
y1="1.40039"
x2="11.791"
y2="9.62542"
gradientUnits="userSpaceOnUse"
>
<stop
stopColor={isDevBuilding ? 'rgba(255,255,255,0.7)' : 'white'}
/>
<stop
offset="1"
stopColor={isDevBuilding ? 'rgba(255,255,255,0.7)' : 'white'}
stopOpacity="0"
/>
</linearGradient>
<mask id="mask0">
<rect
width="100%"
height="100%"
fill={isDevBuilding ? 'rgba(255,255,255,0.7)' : 'white'}
/>
<rect width="5" height="1.5" fill="black" />
</mask>

<filter
id="filter0_bdddi_1457_6023"
x="-2"
Expand Down Expand Up @@ -268,31 +388,6 @@ export const NextLogo = ({ issueCount, onClick, ...props }: Props) => {
<stop offset="0.0914784" stopColor="white" stopOpacity="0.463159" />
<stop offset="0.405428" stopColor="white" stopOpacity="0.4" />
</radialGradient>

<linearGradient
id="paint1_linear_1457_6023"
x1="16.6174"
y1="24.5796"
x2="22.7532"
y2="32.1846"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" />
<stop offset="0.604072" stopColor="white" stopOpacity="0" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>

<linearGradient
id="paint2_linear_1457_6023"
x1="25.3583"
y1="13.7771"
x2="25.3235"
y2="22.9160"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="white" />
<stop offset="1" stopColor="white" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { useEffect, useTransition } from 'react'
import { devRenderIndicator } from './dev-render-indicator'

export const useSyncDevRenderIndicatorInternal = () => {
const [isPending, startTransition] = useTransition()

useEffect(() => {
if (isPending) {
devRenderIndicator.show()
} else {
devRenderIndicator.hide()
}
}, [isPending])

return startTransition
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const NOOP = (fn: () => void) => fn()

/**
* Returns a transition function that can be used to wrap router actions.
* This allows us to tap into the transition state of the router as an
* approximation of React render time.
*/
export const useSyncDevRenderIndicator = () => {
let syncDevRenderIndicator = NOOP

if (process.env.NODE_ENV === 'development') {
const { useSyncDevRenderIndicatorInternal } =
require('./internal/use-sync-dev-render-indicator-internal') as typeof import('./internal/use-sync-dev-render-indicator-internal')

// eslint-disable-next-line react-hooks/rules-of-hooks
syncDevRenderIndicator = useSyncDevRenderIndicatorInternal()
}

return syncDevRenderIndicator
}
Loading