diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/dev-tools-indicator.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/dev-tools-indicator.tsx index 0d06b39eeea..b914bd82afa 100644 --- a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/dev-tools-indicator.tsx +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/dev-tools-indicator.tsx @@ -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 @@ -51,6 +53,9 @@ const DevToolsPopover = ({ }) => { // TODO: close when clicking outside + const isDevBuilding = useIsDevBuilding() + const isDevRendering = useIsDevRendering() + const [isPopoverOpen, setIsPopoverOpen] = useState(false) const togglePopover = () => setIsPopoverOpen((prev) => !prev) return ( @@ -58,6 +63,8 @@ const DevToolsPopover = ({ 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, +} diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/internal/next-logo.stories.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/internal/next-logo.stories.tsx index 810c5e46fca..55f4c3881c3 100644 --- a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/internal/next-logo.stories.tsx +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/internal/next-logo.stories.tsx @@ -17,23 +17,55 @@ type Story = StoryObj 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, }, } diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/internal/next-logo.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/internal/next-logo.tsx index 533b44315da..2953d5158ef 100644 --- a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/internal/next-logo.tsx +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/internal/next-logo.tsx @@ -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 (
+ + {/* Add issue count circle if issues exist */} {issueCount > 0 ? (
{ width="36" height="36" rx="18" - fill={issueCount > 0 ? '#CA2A30' : '#551A1E'} - /> - - 0 ? '#CA2A30' : '#2A2A2A'} /> + + + + + + + + + + + + + + + + + + + { - - - - - - - - - - -
diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/internal/use-sync-dev-render-indicator-internal.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/internal/use-sync-dev-render-indicator-internal.tsx new file mode 100644 index 00000000000..3102d061049 --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/internal/use-sync-dev-render-indicator-internal.tsx @@ -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 +} diff --git a/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/use-sync-dev-render-indicator.tsx b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/use-sync-dev-render-indicator.tsx new file mode 100644 index 00000000000..a02d117e271 --- /dev/null +++ b/packages/next/src/client/components/react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/use-sync-dev-render-indicator.tsx @@ -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 +} diff --git a/packages/next/src/client/components/use-reducer.ts b/packages/next/src/client/components/use-reducer.ts index 38906e8695e..3da5ab0a42e 100644 --- a/packages/next/src/client/components/use-reducer.ts +++ b/packages/next/src/client/components/use-reducer.ts @@ -1,13 +1,13 @@ import type { Dispatch } from 'react' -import React, { use } from 'react' -import { useCallback } from 'react' +import React, { use, useCallback } from 'react' +import { isThenable } from '../../shared/lib/is-thenable' +import type { AppRouterActionQueue } from '../../shared/lib/router/action-queue' import type { AppRouterState, ReducerActions, ReducerState, } from './router-reducer/router-reducer-types' -import type { AppRouterActionQueue } from '../../shared/lib/router/action-queue' -import { isThenable } from '../../shared/lib/is-thenable' +import { useSyncDevRenderIndicator } from './react-dev-overlay/_experimental/internal/components/Errors/dev-tools-indicator/use-sync-dev-render-indicator' export function useUnwrapState(state: ReducerState): AppRouterState { // reducer actions can be async, so sometimes we need to suspend until the state is resolved @@ -23,12 +23,15 @@ export function useReducer( actionQueue: AppRouterActionQueue ): [ReducerState, Dispatch] { const [state, setState] = React.useState(actionQueue.state) + const syncDevRenderIndicator = useSyncDevRenderIndicator() const dispatch = useCallback( (action: ReducerActions) => { - actionQueue.dispatch(action, setState) + syncDevRenderIndicator(() => { + actionQueue.dispatch(action, setState) + }) }, - [actionQueue] + [actionQueue, syncDevRenderIndicator] ) return [state, dispatch] diff --git a/packages/next/src/client/dev/dev-build-indicator/internal/dev-build-indicator.ts b/packages/next/src/client/dev/dev-build-indicator/internal/dev-build-indicator.ts index f8d69a64427..34796b1b1cd 100644 --- a/packages/next/src/client/dev/dev-build-indicator/internal/dev-build-indicator.ts +++ b/packages/next/src/client/dev/dev-build-indicator/internal/dev-build-indicator.ts @@ -1,6 +1,3 @@ -type VerticalPosition = 'top' | 'bottom' -type HorizonalPosition = 'left' | 'right' - const NOOP = () => {} export const devBuildIndicator = { @@ -9,191 +6,11 @@ export const devBuildIndicator = { /** Hides build indicator when Next.js finishes compiling. Requires initialize() first. */ hide: NOOP, /** Sets up the build indicator UI component. Call this before using show/hide. */ - initialize, -} - -function initialize(position = 'bottom-right') { - const shadowHost = document.createElement('div') - const [verticalProperty, horizontalProperty] = position.split('-', 2) as [ - VerticalPosition, - HorizonalPosition, - ] - shadowHost.id = '__next-build-indicator' - // Make sure container is fixed and on a high zIndex so it shows - shadowHost.style.position = 'fixed' - // Ensure container's position to be top or bottom (default) - shadowHost.style[verticalProperty] = '10px' - // Ensure container's position to be left or right (default) - shadowHost.style[horizontalProperty] = '20px' - shadowHost.style.width = '0' - shadowHost.style.height = '0' - shadowHost.style.zIndex = '99999' - document.body.appendChild(shadowHost) - - let shadowRoot - let prefix = '' - - if (shadowHost.attachShadow) { - shadowRoot = shadowHost.attachShadow({ mode: 'open' }) - } else { - // If attachShadow is undefined then the browser does not support - // the Shadow DOM, we need to prefix all the names so there - // will be no conflicts - shadowRoot = shadowHost - prefix = '__next-build-indicator-' - } - - // Container - const container = createContainer(prefix) - shadowRoot.appendChild(container) - - // CSS - const css = createCss(prefix, { horizontalProperty, verticalProperty }) - shadowRoot.appendChild(css) - - // State - let isVisible = false - let isBuilding = false - let timeoutId: null | ReturnType = null - - devBuildIndicator.show = () => { - timeoutId && clearTimeout(timeoutId) - isVisible = true - isBuilding = true - updateContainer() - } - - devBuildIndicator.hide = () => { - isBuilding = false - // Wait for the fade out transition to complete - timeoutId = setTimeout(() => { - isVisible = false - updateContainer() - }, 100) - updateContainer() - } - - function updateContainer() { - if (isBuilding) { - container.classList.add(`${prefix}building`) - } else { - container.classList.remove(`${prefix}building`) - } - - if (isVisible) { - container.classList.add(`${prefix}visible`) - } else { - container.classList.remove(`${prefix}visible`) - } - } -} - -function createContainer(prefix: string) { - const container = document.createElement('div') - container.id = `${prefix}container` - container.innerHTML = ` -
- - - - - - - - - - - -
- ` - - return container -} - -function createCss( - prefix: string, - { - horizontalProperty, - verticalProperty, - }: { horizontalProperty: string; verticalProperty: string } -) { - const css = document.createElement('style') - css.textContent = ` - #${prefix}container { - position: absolute; - ${verticalProperty}: 10px; - ${horizontalProperty}: 30px; - - border-radius: 3px; - background: #000; - color: #fff; - font: initial; - cursor: initial; - letter-spacing: initial; - text-shadow: initial; - text-transform: initial; - visibility: initial; - - padding: 7px 10px 8px 10px; - align-items: center; - box-shadow: 0 11px 40px 0 rgba(0, 0, 0, 0.25), 0 2px 10px 0 rgba(0, 0, 0, 0.12); - - display: none; - opacity: 0; - transition: opacity 0.1s ease, ${verticalProperty} 0.1s ease; - animation: ${prefix}fade-in 0.1s ease-in-out; - } - - #${prefix}container.${prefix}visible { - display: flex; - } - - #${prefix}container.${prefix}building { - ${verticalProperty}: 20px; - opacity: 1; - } - - #${prefix}icon-wrapper { - width: 16px; - height: 16px; - } - - #${prefix}icon-wrapper > svg { - width: 100%; - height: 100%; - } - - #${prefix}icon-group { - animation: ${prefix}strokedash 1s ease-in-out both infinite; - } - - @keyframes ${prefix}fade-in { - from { - ${verticalProperty}: 10px; - opacity: 0; - } - to { - ${verticalProperty}: 20px; - opacity: 1; - } - } - - @keyframes ${prefix}strokedash { - 0% { - stroke-dasharray: 0 226; - } - 80%, - 100% { - stroke-dasharray: 659 226; - } - } - ` - - return css + initialize: process.env.__NEXT_EXPERIMENTAL_NEW_DEV_OVERLAY + ? ( + require('./initialize-for-new-overlay') as typeof import('./initialize-for-new-overlay') + ).initializeForNewOverlay + : ( + require('./initialize-for-old-overlay') as typeof import('./initialize-for-old-overlay') + ).initializeForOldOverlay, } diff --git a/packages/next/src/client/dev/dev-build-indicator/internal/initialize-for-new-overlay.ts b/packages/next/src/client/dev/dev-build-indicator/internal/initialize-for-new-overlay.ts new file mode 100644 index 00000000000..f1a3aaaca77 --- /dev/null +++ b/packages/next/src/client/dev/dev-build-indicator/internal/initialize-for-new-overlay.ts @@ -0,0 +1,35 @@ +/* + * Singleton store to track whether the app is currently being built + * Used by the dev tools indicator of the new overlay to show build status + */ + +import { devBuildIndicator } from './dev-build-indicator' +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 + +export function useIsDevBuilding() { + return useSyncExternalStore(subscribe, getSnapshot) +} + +export function initializeForNewOverlay() { + devBuildIndicator.show = () => { + isVisible = true + listeners.forEach((listener) => listener()) + } + + devBuildIndicator.hide = () => { + isVisible = false + listeners.forEach((listener) => listener()) + } +} diff --git a/packages/next/src/client/dev/dev-build-indicator/internal/initialize-for-old-overlay.ts b/packages/next/src/client/dev/dev-build-indicator/internal/initialize-for-old-overlay.ts new file mode 100644 index 00000000000..cfe0a081696 --- /dev/null +++ b/packages/next/src/client/dev/dev-build-indicator/internal/initialize-for-old-overlay.ts @@ -0,0 +1,194 @@ +/* + * Initializes the build indicator for the old overlay + * This is a singleton store to track whether the app is currently being built + * Used by the dev tools indicator of the old overlay to show build status + */ + +import { devBuildIndicator } from './dev-build-indicator' + +type VerticalPosition = 'top' | 'bottom' +type HorizonalPosition = 'left' | 'right' + +export function initializeForOldOverlay(position = 'bottom-right') { + const shadowHost = document.createElement('div') + const [verticalProperty, horizontalProperty] = position.split('-', 2) as [ + VerticalPosition, + HorizonalPosition, + ] + shadowHost.id = '__next-build-indicator' + // Make sure container is fixed and on a high zIndex so it shows + shadowHost.style.position = 'fixed' + // Ensure container's position to be top or bottom (default) + shadowHost.style[verticalProperty] = '10px' + // Ensure container's position to be left or right (default) + shadowHost.style[horizontalProperty] = '20px' + shadowHost.style.width = '0' + shadowHost.style.height = '0' + shadowHost.style.zIndex = '99999' + document.body.appendChild(shadowHost) + + let shadowRoot + let prefix = '' + + if (shadowHost.attachShadow) { + shadowRoot = shadowHost.attachShadow({ mode: 'open' }) + } else { + // If attachShadow is undefined then the browser does not support + // the Shadow DOM, we need to prefix all the names so there + // will be no conflicts + shadowRoot = shadowHost + prefix = '__next-build-indicator-' + } + + // Container + const container = createContainer(prefix) + shadowRoot.appendChild(container) + + // CSS + const css = createCss(prefix, { horizontalProperty, verticalProperty }) + shadowRoot.appendChild(css) + + // State + let isVisible = false + let isBuilding = false + let timeoutId: null | ReturnType = null + + devBuildIndicator.show = () => { + timeoutId && clearTimeout(timeoutId) + isVisible = true + isBuilding = true + updateContainer() + } + + devBuildIndicator.hide = () => { + isBuilding = false + // Wait for the fade out transition to complete + timeoutId = setTimeout(() => { + isVisible = false + updateContainer() + }, 100) + updateContainer() + } + + function updateContainer() { + if (isBuilding) { + container.classList.add(`${prefix}building`) + } else { + container.classList.remove(`${prefix}building`) + } + + if (isVisible) { + container.classList.add(`${prefix}visible`) + } else { + container.classList.remove(`${prefix}visible`) + } + } +} +function createContainer(prefix: string) { + const container = document.createElement('div') + container.id = `${prefix}container` + container.innerHTML = ` +
+ + + + + + + + + + + +
+ ` + + return container +} +function createCss( + prefix: string, + { + horizontalProperty, + verticalProperty, + }: { horizontalProperty: string; verticalProperty: string } +) { + const css = document.createElement('style') + css.textContent = ` + #${prefix}container { + position: absolute; + ${verticalProperty}: 10px; + ${horizontalProperty}: 30px; + + border-radius: 3px; + background: #000; + color: #fff; + font: initial; + cursor: initial; + letter-spacing: initial; + text-shadow: initial; + text-transform: initial; + visibility: initial; + + padding: 7px 10px 8px 10px; + align-items: center; + box-shadow: 0 11px 40px 0 rgba(0, 0, 0, 0.25), 0 2px 10px 0 rgba(0, 0, 0, 0.12); + + display: none; + opacity: 0; + transition: opacity 0.1s ease, ${verticalProperty} 0.1s ease; + animation: ${prefix}fade-in 0.1s ease-in-out; + } + + #${prefix}container.${prefix}visible { + display: flex; + } + + #${prefix}container.${prefix}building { + ${verticalProperty}: 20px; + opacity: 1; + } + + #${prefix}icon-wrapper { + width: 16px; + height: 16px; + } + + #${prefix}icon-wrapper > svg { + width: 100%; + height: 100%; + } + + #${prefix}icon-group { + animation: ${prefix}strokedash 1s ease-in-out both infinite; + } + + @keyframes ${prefix}fade-in { + from { + ${verticalProperty}: 10px; + opacity: 0; + } + to { + ${verticalProperty}: 20px; + opacity: 1; + } + } + + @keyframes ${prefix}strokedash { + 0% { + stroke-dasharray: 0 226; + } + 80%, + 100% { + stroke-dasharray: 659 226; + } + } + ` + + return css +}