mirror of
https://github.com/langgenius/dify.git
synced 2026-09-28 06:13:22 +08:00
fix(web): defer Amplitude SDK loading until analytics consent (#42857)
This commit is contained in:
@@ -21,10 +21,10 @@ export function AmplitudeProvider({
|
||||
return
|
||||
}
|
||||
|
||||
ensureAmplitudeInitialized({
|
||||
sessionReplaySampleRate,
|
||||
})
|
||||
setAmplitudeOptOut(false)
|
||||
void ensureAmplitudeInitialized({ sessionReplaySampleRate }).catch((error) => {
|
||||
console.error('Amplitude initialization failed:', error)
|
||||
})
|
||||
|
||||
return () => setAmplitudeOptOut(true)
|
||||
}, [active, consent, sessionReplaySampleRate])
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { render } from '@testing-library/react'
|
||||
import { act, render, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
|
||||
const mockConfig = vi.hoisted(() => ({
|
||||
@@ -22,6 +22,12 @@ vi.mock('@amplitude/analytics-browser', () => ({
|
||||
init: vi.fn(),
|
||||
add: vi.fn(),
|
||||
setOptOut: vi.fn(),
|
||||
track: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
setUserId: vi.fn(),
|
||||
Identify: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@amplitude/plugin-session-replay-browser', () => ({
|
||||
@@ -42,8 +48,9 @@ describe('AmplitudeProvider', () => {
|
||||
})
|
||||
|
||||
describe('Component', () => {
|
||||
it('initializes amplitude when enabled', () => {
|
||||
it('initializes amplitude when enabled', async () => {
|
||||
render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
|
||||
await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(amplitude.init).toHaveBeenCalledWith('test-api-key', expect.any(Object))
|
||||
expect(sessionReplayPlugin).toHaveBeenCalledWith({ sampleRate: 0.8 })
|
||||
@@ -51,11 +58,12 @@ describe('AmplitudeProvider', () => {
|
||||
expect(amplitude.setOptOut).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('does not re-initialize amplitude on remount', () => {
|
||||
it('does not re-initialize amplitude on remount', async () => {
|
||||
const { unmount } = render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
|
||||
|
||||
unmount()
|
||||
render(<AmplitudeProvider sessionReplaySampleRate={0.8} />)
|
||||
await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
|
||||
|
||||
expect(amplitude.init).toHaveBeenCalledTimes(1)
|
||||
expect(sessionReplayPlugin).toHaveBeenCalledTimes(1)
|
||||
@@ -83,8 +91,9 @@ describe('AmplitudeProvider', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('opts out on revoke and resumes without reinitializing', () => {
|
||||
it('opts out on revoke and resumes without reinitializing', async () => {
|
||||
const { rerender } = render(<AmplitudeProvider />)
|
||||
await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
|
||||
|
||||
mockConsent.value = 'denied'
|
||||
rerender(<AmplitudeProvider />)
|
||||
@@ -100,16 +109,37 @@ describe('AmplitudeProvider', () => {
|
||||
expect(amplitude.add).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('opts out when the analytics layout runtime unmounts', () => {
|
||||
it('opts out when the analytics layout runtime unmounts', async () => {
|
||||
const { unmount } = render(<AmplitudeProvider />)
|
||||
await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(amplitude.setOptOut).toHaveBeenLastCalledWith(true)
|
||||
})
|
||||
|
||||
it.each(['revoke', 'unmount'] as const)(
|
||||
'does not start tracking when %s happens during SDK loading',
|
||||
async (action) => {
|
||||
const { rerender, unmount } = render(<AmplitudeProvider />)
|
||||
if (action === 'revoke') {
|
||||
mockConsent.value = 'denied'
|
||||
rerender(<AmplitudeProvider />)
|
||||
} else {
|
||||
unmount()
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
await vi.dynamicImportSettled()
|
||||
})
|
||||
expect(amplitude.init).not.toHaveBeenCalled()
|
||||
expect(sessionReplayPlugin).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('pageNameEnrichmentPlugin logic works as expected', async () => {
|
||||
render(<AmplitudeProvider />)
|
||||
await waitFor(() => expect(amplitude.init).toHaveBeenCalledTimes(1))
|
||||
const plugin = vi.mocked(amplitude.add).mock.calls[0]?.[0] as
|
||||
| amplitude.Types.EnrichmentPlugin
|
||||
| undefined
|
||||
|
||||
@@ -18,6 +18,12 @@ vi.mock('@amplitude/analytics-browser', () => ({
|
||||
init: vi.fn(),
|
||||
add: vi.fn(),
|
||||
setOptOut: vi.fn(),
|
||||
track: vi.fn(),
|
||||
flush: vi.fn(),
|
||||
setUserId: vi.fn(),
|
||||
Identify: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@amplitude/plugin-session-replay-browser', () => ({
|
||||
@@ -29,13 +35,17 @@ describe('amplitude init helper', () => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
mockConfig.AMPLITUDE_API_KEY = 'test-api-key'
|
||||
;({ ensureAmplitudeInitialized } = await import('../init'))
|
||||
const init = await import('../init')
|
||||
ensureAmplitudeInitialized = init.ensureAmplitudeInitialized
|
||||
init.setAmplitudeOptOut(false)
|
||||
})
|
||||
|
||||
describe('ensureAmplitudeInitialized', () => {
|
||||
it('should initialize amplitude only once across repeated calls', () => {
|
||||
ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.8 })
|
||||
ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.2 })
|
||||
it('should initialize amplitude only once across concurrent calls', async () => {
|
||||
await Promise.all([
|
||||
ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.8 }),
|
||||
ensureAmplitudeInitialized({ sessionReplaySampleRate: 0.2 }),
|
||||
])
|
||||
|
||||
expect(amplitude.init).toHaveBeenCalledTimes(1)
|
||||
expect(sessionReplayPlugin).toHaveBeenCalledTimes(1)
|
||||
@@ -47,7 +57,7 @@ describe('amplitude init helper', () => {
|
||||
const { getIsAmplitudeInitialized } = await import('../init')
|
||||
|
||||
expect(getIsAmplitudeInitialized()).toBe(false)
|
||||
ensureAmplitudeInitialized()
|
||||
await ensureAmplitudeInitialized()
|
||||
|
||||
expect(getIsAmplitudeInitialized()).toBe(true)
|
||||
})
|
||||
@@ -57,16 +67,30 @@ describe('amplitude init helper', () => {
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = subscribeAmplitudeInitialization(listener)
|
||||
|
||||
ensureAmplitudeInitialized()
|
||||
await ensureAmplitudeInitialized()
|
||||
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('should skip initialization when amplitude is disabled', () => {
|
||||
it('allows a later initialization attempt after SDK setup fails', async () => {
|
||||
const { getIsAmplitudeInitialized } = await import('../init')
|
||||
vi.mocked(amplitude.init).mockImplementationOnce(() => {
|
||||
throw new Error('SDK setup failed')
|
||||
})
|
||||
|
||||
await expect(ensureAmplitudeInitialized()).rejects.toThrow('SDK setup failed')
|
||||
expect(getIsAmplitudeInitialized()).toBe(false)
|
||||
|
||||
await ensureAmplitudeInitialized()
|
||||
expect(getIsAmplitudeInitialized()).toBe(true)
|
||||
expect(amplitude.init).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should skip initialization when amplitude is disabled', async () => {
|
||||
mockConfig.AMPLITUDE_API_KEY = ''
|
||||
|
||||
ensureAmplitudeInitialized()
|
||||
await ensureAmplitudeInitialized()
|
||||
|
||||
expect(amplitude.init).not.toHaveBeenCalled()
|
||||
expect(sessionReplayPlugin).not.toHaveBeenCalled()
|
||||
@@ -81,7 +105,8 @@ describe('amplitude init helper', () => {
|
||||
setAmplitudeOptOut(true)
|
||||
expect(amplitude.setOptOut).not.toHaveBeenCalled()
|
||||
|
||||
ensureAmplitudeInitialized()
|
||||
setAmplitudeOptOut(false)
|
||||
await ensureAmplitudeInitialized()
|
||||
setAmplitudeOptOut(true)
|
||||
|
||||
expect(amplitude.setOptOut).toHaveBeenCalledWith(true)
|
||||
|
||||
@@ -28,16 +28,17 @@ vi.mock('@/app/components/base/analytics-consent/consent-store', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../init', () => ({
|
||||
getIsAmplitudeInitialized: () => mockState.initialized,
|
||||
}))
|
||||
|
||||
vi.mock('@amplitude/analytics-browser', () => ({
|
||||
track: (...args: unknown[]) => mockTrack(...args),
|
||||
flush: (...args: unknown[]) => mockFlush(...args),
|
||||
setUserId: (...args: unknown[]) => mockSetUserId(...args),
|
||||
identify: (...args: unknown[]) => mockIdentify(...args),
|
||||
reset: (...args: unknown[]) => mockReset(...args),
|
||||
Identify: MockIdentify,
|
||||
getAmplitudeClient: () =>
|
||||
mockState.initialized
|
||||
? {
|
||||
track: mockTrack,
|
||||
flush: mockFlush,
|
||||
setUserId: mockSetUserId,
|
||||
identify: mockIdentify,
|
||||
reset: mockReset,
|
||||
Identify: MockIdentify,
|
||||
}
|
||||
: undefined,
|
||||
}))
|
||||
|
||||
describe('amplitude utils', () => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
import { AMPLITUDE_API_KEY } from '@/config'
|
||||
|
||||
export type AmplitudeInitializationOptions = {
|
||||
@@ -7,6 +5,11 @@ export type AmplitudeInitializationOptions = {
|
||||
}
|
||||
|
||||
let isAmplitudeInitialized = false
|
||||
let amplitude: ReturnType<typeof import('./runtime').initializeAmplitudeSDK> | undefined
|
||||
let initialization: Promise<void> | undefined
|
||||
let shouldOptOut = true
|
||||
|
||||
export const getAmplitudeClient = () => (isAmplitudeInitialized ? amplitude : undefined)
|
||||
const initializationListeners = new Set<() => void>()
|
||||
|
||||
export const getIsAmplitudeInitialized = () => isAmplitudeInitialized
|
||||
@@ -20,77 +23,29 @@ const notifyAmplitudeInitialized = () => {
|
||||
initializationListeners.forEach((listener) => listener())
|
||||
}
|
||||
|
||||
// Map URL pathname to English page name for consistent Amplitude tracking
|
||||
const getEnglishPageName = (pathname: string): string => {
|
||||
// Remove leading slash and get the first segment
|
||||
const segments = pathname.replace(/^\//, '').split('/')
|
||||
const firstSegment = segments[0] || 'home'
|
||||
async function initializeAmplitude(sessionReplaySampleRate: number) {
|
||||
const { initializeAmplitudeSDK } = await import('./runtime')
|
||||
|
||||
const pageNameMap: Record<string, string> = {
|
||||
'': 'Home',
|
||||
apps: 'Studio',
|
||||
agents: 'Agents',
|
||||
datasets: 'Knowledge',
|
||||
explore: 'Explore',
|
||||
tools: 'Tools',
|
||||
account: 'Account',
|
||||
signin: 'Sign In',
|
||||
signup: 'Sign Up',
|
||||
}
|
||||
// Consent or the owning layout may have changed while the chunk was loading.
|
||||
if (shouldOptOut) return
|
||||
|
||||
return pageNameMap[firstSegment] || firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1)
|
||||
}
|
||||
|
||||
// Enrichment plugin to override page title with English name for page view events
|
||||
const createPageNameEnrichmentPlugin = (): amplitude.Types.EnrichmentPlugin => {
|
||||
return {
|
||||
name: 'page-name-enrichment',
|
||||
type: 'enrichment',
|
||||
setup: async () => undefined,
|
||||
execute: async (event: amplitude.Types.Event) => {
|
||||
// Only modify page view events
|
||||
if (event.event_type === '[Amplitude] Page Viewed' && event.event_properties) {
|
||||
/* v8 ignore next @preserve */
|
||||
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
|
||||
event.event_properties['[Amplitude] Page Title'] = getEnglishPageName(pathname)
|
||||
}
|
||||
return event
|
||||
},
|
||||
}
|
||||
amplitude = initializeAmplitudeSDK(AMPLITUDE_API_KEY, sessionReplaySampleRate)
|
||||
isAmplitudeInitialized = true
|
||||
notifyAmplitudeInitialized()
|
||||
}
|
||||
|
||||
export const ensureAmplitudeInitialized = ({
|
||||
sessionReplaySampleRate = 0.5,
|
||||
}: AmplitudeInitializationOptions = {}) => {
|
||||
if (!AMPLITUDE_API_KEY || isAmplitudeInitialized) return
|
||||
}: AmplitudeInitializationOptions = {}): Promise<void> => {
|
||||
if (!AMPLITUDE_API_KEY || isAmplitudeInitialized || shouldOptOut) return Promise.resolve()
|
||||
|
||||
isAmplitudeInitialized = true
|
||||
|
||||
try {
|
||||
amplitude.init(AMPLITUDE_API_KEY, {
|
||||
defaultTracking: {
|
||||
sessions: true,
|
||||
pageViews: true,
|
||||
formInteractions: true,
|
||||
fileDownloads: true,
|
||||
attribution: true,
|
||||
},
|
||||
})
|
||||
|
||||
amplitude.add(createPageNameEnrichmentPlugin())
|
||||
amplitude.add(
|
||||
sessionReplayPlugin({
|
||||
sampleRate: sessionReplaySampleRate,
|
||||
}),
|
||||
)
|
||||
notifyAmplitudeInitialized()
|
||||
} catch (error) {
|
||||
isAmplitudeInitialized = false
|
||||
throw error
|
||||
}
|
||||
initialization ??= initializeAmplitude(sessionReplaySampleRate).finally(() => {
|
||||
initialization = undefined
|
||||
})
|
||||
return initialization
|
||||
}
|
||||
|
||||
export const setAmplitudeOptOut = (optOut: boolean) => {
|
||||
if (!AMPLITUDE_API_KEY || !isAmplitudeInitialized) return
|
||||
amplitude.setOptOut(optOut)
|
||||
shouldOptOut = optOut
|
||||
getAmplitudeClient()?.setOptOut(optOut)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Types } from '@amplitude/analytics-browser'
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
|
||||
|
||||
// Map URL pathname to English page name for consistent Amplitude tracking
|
||||
const getEnglishPageName = (pathname: string): string => {
|
||||
// Remove leading slash and get the first segment
|
||||
const segments = pathname.replace(/^\//, '').split('/')
|
||||
const firstSegment = segments[0] || 'home'
|
||||
|
||||
const pageNameMap: Record<string, string> = {
|
||||
'': 'Home',
|
||||
apps: 'Studio',
|
||||
agents: 'Agents',
|
||||
datasets: 'Knowledge',
|
||||
explore: 'Explore',
|
||||
tools: 'Tools',
|
||||
account: 'Account',
|
||||
signin: 'Sign In',
|
||||
signup: 'Sign Up',
|
||||
}
|
||||
|
||||
return pageNameMap[firstSegment] || firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1)
|
||||
}
|
||||
|
||||
// Enrichment plugin to override page title with English name for page view events
|
||||
const createPageNameEnrichmentPlugin = (): Types.EnrichmentPlugin => {
|
||||
return {
|
||||
name: 'page-name-enrichment',
|
||||
type: 'enrichment',
|
||||
setup: async () => undefined,
|
||||
execute: async (event: Types.Event) => {
|
||||
// Only modify page view events
|
||||
if (event.event_type === '[Amplitude] Page Viewed' && event.event_properties) {
|
||||
/* v8 ignore next @preserve */
|
||||
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
|
||||
event.event_properties['[Amplitude] Page Title'] = getEnglishPageName(pathname)
|
||||
}
|
||||
return event
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function initializeAmplitudeSDK(apiKey: string, sessionReplaySampleRate: number) {
|
||||
amplitude.init(apiKey, {
|
||||
defaultTracking: {
|
||||
sessions: true,
|
||||
pageViews: true,
|
||||
formInteractions: true,
|
||||
fileDownloads: true,
|
||||
attribution: true,
|
||||
},
|
||||
})
|
||||
amplitude.add(createPageNameEnrichmentPlugin())
|
||||
amplitude.add(sessionReplayPlugin({ sampleRate: sessionReplaySampleRate }))
|
||||
amplitude.setOptOut(false)
|
||||
return {
|
||||
track: amplitude.track,
|
||||
flush: amplitude.flush,
|
||||
setUserId: amplitude.setUserId,
|
||||
Identify: amplitude.Identify,
|
||||
identify: amplitude.identify,
|
||||
reset: amplitude.reset,
|
||||
setOptOut: amplitude.setOptOut,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import * as amplitude from '@amplitude/analytics-browser'
|
||||
import type { Types } from '@amplitude/analytics-browser'
|
||||
import { getAnalyticsConsent } from '@/app/components/base/analytics-consent/consent-store'
|
||||
import { getIsAmplitudeInitialized } from './init'
|
||||
import { getAmplitudeClient } from './init'
|
||||
|
||||
const canUseAmplitude = () => getAnalyticsConsent() === 'granted' && getIsAmplitudeInitialized()
|
||||
const getConsentedClient = () =>
|
||||
getAnalyticsConsent() === 'granted' ? getAmplitudeClient() : undefined
|
||||
|
||||
/**
|
||||
* Track custom event
|
||||
@@ -12,15 +13,17 @@ const canUseAmplitude = () => getAnalyticsConsent() === 'granted' && getIsAmplit
|
||||
export const trackEvent = (
|
||||
eventName: string,
|
||||
eventProperties?: Record<string, unknown>,
|
||||
eventOptions?: amplitude.Types.EventOptions,
|
||||
eventOptions?: Types.EventOptions,
|
||||
) => {
|
||||
if (!canUseAmplitude()) return
|
||||
const amplitude = getConsentedClient()
|
||||
if (!amplitude) return
|
||||
if (eventOptions) return amplitude.track(eventName, eventProperties, eventOptions)
|
||||
return amplitude.track(eventName, eventProperties)
|
||||
}
|
||||
|
||||
export const flushEvents = () => {
|
||||
if (!canUseAmplitude()) return
|
||||
const amplitude = getConsentedClient()
|
||||
if (!amplitude) return
|
||||
return amplitude.flush()
|
||||
}
|
||||
|
||||
@@ -29,7 +32,8 @@ export const flushEvents = () => {
|
||||
* @param userId User ID
|
||||
*/
|
||||
export const setUserId = (userId: string) => {
|
||||
if (!canUseAmplitude()) return
|
||||
const amplitude = getConsentedClient()
|
||||
if (!amplitude) return
|
||||
amplitude.setUserId(userId)
|
||||
}
|
||||
|
||||
@@ -37,10 +41,9 @@ export const setUserId = (userId: string) => {
|
||||
* Set user properties
|
||||
* @param properties User properties
|
||||
*/
|
||||
export const setUserProperties = (
|
||||
properties: Record<string, amplitude.Types.ValidPropertyType>,
|
||||
) => {
|
||||
if (!canUseAmplitude()) return
|
||||
export const setUserProperties = (properties: Record<string, Types.ValidPropertyType>) => {
|
||||
const amplitude = getConsentedClient()
|
||||
if (!amplitude) return
|
||||
const identifyEvent = new amplitude.Identify()
|
||||
Object.entries(properties).forEach(([key, value]) => {
|
||||
identifyEvent.set(key, value)
|
||||
@@ -52,6 +55,7 @@ export const setUserProperties = (
|
||||
* Reset user (e.g., when user logs out)
|
||||
*/
|
||||
export const resetUser = () => {
|
||||
if (!canUseAmplitude()) return
|
||||
const amplitude = getConsentedClient()
|
||||
if (!amplitude) return
|
||||
amplitude.reset()
|
||||
}
|
||||
|
||||
@@ -69,4 +69,34 @@ node web/scripts/bundle-analysis.ts collect web "$(git rev-parse HEAD)" /tmp/cur
|
||||
node web/scripts/bundle-analysis.ts compare /tmp/base.json /tmp/current.json /tmp/report.md
|
||||
```
|
||||
|
||||
## Next.js / Turbopack analysis
|
||||
|
||||
The Web `analyze` script uses Next.js's built-in Turbopack analyzer:
|
||||
|
||||
```sh
|
||||
pnpm --dir web analyze --output
|
||||
```
|
||||
|
||||
Save `web/.next/diagnostics/analyze` before changing code, then collect another
|
||||
report with the same compiler version and environment. Omit `--output` to open
|
||||
the interactive analyzer and inspect route-specific client/server import chains.
|
||||
These reports are separate from the Vinext snapshots above.
|
||||
|
||||
If analysis panics inside `next-api/src/nft.rs`, move
|
||||
`web/.next/cache/turbopack` outside `.next` and retry before changing production
|
||||
configuration. A stale filesystem cache caused this failure locally with
|
||||
Next.js 16.3.6; rebuilding the cache let the unchanged configuration complete.
|
||||
|
||||
Check import chains before adding configuration. Next.js already optimizes
|
||||
imports from libraries such as `ahooks` and Heroicons. `serverExternalPackages`
|
||||
affects server bundling; it does not remove dependencies from browser bundles.
|
||||
|
||||
A lazy component can still have eager dependencies if shared utilities import
|
||||
its SDK statically. Console analytics keeps its initialization state and event
|
||||
helpers lightweight, loading Amplitude and session replay only after analytics
|
||||
is enabled and consent is granted. Keep SDK imports in shared helpers type-only
|
||||
and use the initialized client for events. When changing this boundary, verify
|
||||
revocation and unmount during loading, concurrent initialization, and failure
|
||||
recovery as well as entry static dependencies and total emitted bytes.
|
||||
|
||||
[Rolldown analyzer documentation]: https://rolldown.rs/builtin-plugins/bundle-analyzer
|
||||
|
||||
Reference in New Issue
Block a user