chore(web): upgrade Next.js to 16.3 (#39962)

This commit is contained in:
yyh
2026-08-04 04:46:44 +00:00
committed by GitHub
parent 2d2cc7cab1
commit be413650ba
6 changed files with 87 additions and 646 deletions
@@ -1,257 +0,0 @@
/// <reference types="vite/client" />
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import * as React from 'react'
type IconComponent = React.ComponentType<Record<string, unknown>>
type IconModule = { default: IconComponent }
type IconEntry = {
name: string
category: string
path: string
Component: IconComponent
}
const iconModules: Record<string, IconModule> = import.meta.glob('./src/**/*.tsx', { eager: true })
const iconEntries: IconEntry[] = Object.entries(iconModules)
.filter(([key]) => !key.endsWith('.stories.tsx') && !key.endsWith('.spec.tsx'))
.map(([key, mod]) => {
const Component = mod.default
if (!Component) return null
const relativePath = key.replace(/^\.\/src\//, '')
const path = `app/components/base/icons/src/${relativePath}`
const parts = relativePath.split('/')
const fileName = parts.pop() || ''
const category = parts.length ? parts.join('/') : '(root)'
const name = Component.displayName || fileName.replace(/\.tsx$/, '')
return {
name,
category,
path,
Component,
}
})
.filter(Boolean) as IconEntry[]
const sortedEntries = [...iconEntries].sort((a, b) => {
if (a.category === b.category) return a.name.localeCompare(b.name)
return a.category.localeCompare(b.category)
})
const filterEntries = (entries: IconEntry[], query: string) => {
const normalized = query.trim().toLowerCase()
if (!normalized) return entries
return entries.filter(
(entry) =>
entry.name.toLowerCase().includes(normalized) ||
entry.path.toLowerCase().includes(normalized) ||
entry.category.toLowerCase().includes(normalized),
)
}
const groupByCategory = (entries: IconEntry[]) =>
entries.reduce(
(acc, entry) => {
if (!acc[entry.category]) acc[entry.category] = []
acc[entry.category]!.push(entry)
return acc
},
{} as Record<string, IconEntry[]>,
)
const containerStyle: React.CSSProperties = {
padding: 24,
display: 'flex',
flexDirection: 'column',
gap: 24,
}
const headerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 8,
}
const controlsStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
}
const searchInputStyle: React.CSSProperties = {
padding: '8px 12px',
minWidth: 280,
borderRadius: 6,
border: '1px solid #d0d0d5',
}
const toggleButtonStyle: React.CSSProperties = {
padding: '8px 12px',
borderRadius: 6,
border: '1px solid #d0d0d5',
background: '#fff',
cursor: 'pointer',
}
const emptyTextStyle: React.CSSProperties = { color: '#5f5f66' }
const sectionStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 12,
}
const gridStyle: React.CSSProperties = {
display: 'grid',
gap: 12,
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
}
const cardStyle: React.CSSProperties = {
border: '1px solid #e1e1e8',
borderRadius: 8,
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 8,
minHeight: 140,
}
const previewBaseStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: 48,
borderRadius: 6,
}
const nameButtonBaseStyle: React.CSSProperties = {
display: 'inline-flex',
padding: 0,
border: 'none',
background: 'transparent',
font: 'inherit',
cursor: 'pointer',
textAlign: 'left',
fontWeight: 600,
}
const PREVIEW_SIZE = 40
const IconGalleryStory = () => {
const [query, setQuery] = React.useState('')
const [copiedPath, setCopiedPath] = React.useState<string | null>(null)
const [previewTheme, setPreviewTheme] = React.useState<'light' | 'dark'>('light')
const filtered = React.useMemo(() => filterEntries(sortedEntries, query), [query])
const grouped = React.useMemo(() => groupByCategory(filtered), [filtered])
const categoryOrder = React.useMemo(
() => Object.keys(grouped).sort((a, b) => a.localeCompare(b)),
[grouped],
)
React.useEffect(() => {
if (!copiedPath) return undefined
const timerId = window.setTimeout(() => {
setCopiedPath(null)
}, 1200)
return () => window.clearTimeout(timerId)
}, [copiedPath])
const handleCopy = React.useCallback((text: string) => {
navigator.clipboard
?.writeText(text)
.then(() => {
setCopiedPath(text)
})
.catch((err) => {
console.error('Failed to copy icon path:', err)
})
}, [])
return (
<div style={containerStyle}>
<header style={headerStyle}>
<h1 style={{ margin: 0 }}>Icon Gallery</h1>
<p style={{ margin: 0, color: '#5f5f66' }}>
Browse all icon components sourced from <code>app/components/base/icons/src</code>. Use
the search bar to filter by name or path.
</p>
<div style={controlsStyle}>
<input
style={searchInputStyle}
placeholder="Search icons"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<span style={{ color: '#5f5f66' }}>{filtered.length} icons</span>
<button
type="button"
onClick={() => setPreviewTheme((prev) => (prev === 'light' ? 'dark' : 'light'))}
style={toggleButtonStyle}
>
Toggle {previewTheme === 'light' ? 'dark' : 'light'} preview
</button>
</div>
</header>
{categoryOrder.length === 0 && (
<p style={emptyTextStyle}>No icons match the current filter.</p>
)}
{categoryOrder.map((category) => (
<section key={category} style={sectionStyle}>
<h2 style={{ margin: 0, fontSize: 18 }}>{category}</h2>
<div style={gridStyle}>
{grouped[category]!.map((entry) => (
<div key={entry.path} style={cardStyle}>
<div
style={{
...previewBaseStyle,
background: previewTheme === 'dark' ? '#1f2024' : '#fff',
}}
>
<entry.Component style={{ width: PREVIEW_SIZE, height: PREVIEW_SIZE }} />
</div>
<button
type="button"
onClick={() => handleCopy(entry.path)}
style={{
...nameButtonBaseStyle,
color: copiedPath === entry.path ? '#00754a' : '#24262c',
}}
>
{copiedPath === entry.path ? 'Copied!' : entry.name}
</button>
</div>
))}
</div>
</section>
))}
</div>
)
}
const meta: Meta<typeof IconGalleryStory> = {
title: 'Base/Icons/Icon Gallery',
component: IconGalleryStory,
parameters: {
layout: 'fullscreen',
},
}
export default meta
type Story = StoryObj<typeof IconGalleryStory>
export const All: Story = {
render: () => <IconGalleryStory />,
}
@@ -1,23 +1,11 @@
import { screen } from '@testing-library/react'
import { render, screen } from '@testing-library/react'
import { MediaType } from '@/hooks/use-breakpoints'
import { renderWithConsoleQuery as render } from '@/test/console/query-data'
import Explore from '../index'
const mockReplace = vi.fn()
const mockPush = vi.fn()
type MediaTypeValue = (typeof MediaType)[keyof typeof MediaType]
let mockMediaType: MediaTypeValue = MediaType.pc
vi.mock('@/next/navigation', () => ({
usePathname: () => '/explore',
useRouter: () => ({
replace: mockReplace,
push: mockPush,
}),
useSelectedLayoutSegments: () => ['apps'],
}))
vi.mock('@/hooks/use-breakpoints', () => ({
default: () => mockMediaType,
MediaType: {
@@ -27,9 +15,12 @@ vi.mock('@/hooks/use-breakpoints', () => ({
},
}))
vi.mock('@/app/components/explore/sidebar', () => ({
default: () => <aside aria-label="explore.sidebar.title" />,
}))
describe('Explore', () => {
beforeEach(() => {
vi.clearAllMocks()
mockMediaType = MediaType.pc
})
@@ -51,7 +42,9 @@ describe('Explore', () => {
</Explore>,
)
expect(screen.queryByText('explore.sidebar.title')).not.toBeInTheDocument()
expect(
screen.queryByRole('complementary', { name: 'explore.sidebar.title' }),
).not.toBeInTheDocument()
})
it('should keep the legacy explore sidebar on mobile', () => {
@@ -63,31 +56,9 @@ describe('Explore', () => {
</Explore>,
)
expect(screen.getByRole('link', { name: 'explore.sidebar.title' })).toBeInTheDocument()
})
})
describe('Effects', () => {
it('should not redirect at component level', () => {
render(
<Explore>
<div>child</div>
</Explore>,
)
expect(mockReplace).not.toHaveBeenCalled()
})
it('should not redirect on mobile', () => {
mockMediaType = MediaType.mobile
render(
<Explore>
<div>child</div>
</Explore>,
)
expect(mockReplace).not.toHaveBeenCalled()
expect(
screen.getByRole('complementary', { name: 'explore.sidebar.title' }),
).toBeInTheDocument()
})
})
})
+5
View File
@@ -19,6 +19,11 @@ const nextConfig: NextConfig = {
bundler: 'turbopack',
}),
},
experimental: {
// TODO: Remove when the `typescript` package can point to TypeScript 7.
// Next.js resolves that package, while compiler-API consumers still require TypeScript 6.
useTypeScriptCli: false,
},
productionBrowserSourceMaps: false, // enable browser source map generation during the production build
// Configure pageExtensions to include md and mdx
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],