next.js/apps/bundle-analyzer/components/import-chain.tsx
import-chain.tsx539 lines18.1 KB
'use client'

import {
  ArrowUp,
  ChevronLeft,
  ChevronRight,
  Box,
  File,
  PanelTop,
  SquareFunction,
  Server,
  Globe,
  MessageCircleQuestion,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import type {
  AnalyzeData,
  ModuleIndex,
  ModulesData,
  SourceIndex,
} from '@/lib/analyze-data'
import { splitIdent } from '@/lib/utils'
import clsx from 'clsx'
import { Button } from '@/components/ui/button'

interface ImportChainProps {
  startFileId: number
  analyzeData: AnalyzeData
  modulesData: ModulesData
  depthMap: Map<ModuleIndex, number>
  environmentFilter: 'client' | 'server'
}

interface ChainLevel {
  moduleIndex: ModuleIndex
  sourceIndex: SourceIndex | undefined
  path: string
  depth: number
  fullPath?: string
  templateArgs?: string
  layer?: string
  moduleType?: string
  treeShaking?: string
  selectedIndex: number
  totalCount: number
  // Info about this level's relationship to parent (undefined for root)
  info?: DependentInfo
}

interface DependentInfo {
  moduleIndex: number
  sourceIndex: number | undefined
  ident: string
  isAsync: boolean
  depth: number
}

type PathPart = {
  segment: string
  isCommon: boolean
  isLastCommon: boolean
  isPackageName: boolean
  isInfrastructure: boolean
}

function spitPathSegments(path: string): string[] {
  return Array.from(path.matchAll(/(.+?(?:\/|$))/g)).map(([i]) => i)
}

function getPathParts(
  currentPath: string,
  previousPath: string | null
): PathPart[] {
  const currentSegments = spitPathSegments(currentPath)

  let commonCount = 0
  if (previousPath) {
    const previousSegments = spitPathSegments(previousPath)

    const minLength = Math.min(currentSegments.length, previousSegments.length)

    for (let i = 0; i < minLength; i++) {
      if (currentSegments[i] === previousSegments[i]) {
        commonCount++
      } else {
        break
      }
    }
  }

  let infrastructureCount = 0
  let packageNameCount = 0
  let nodeModulesIndex = currentSegments.lastIndexOf('node_modules/')
  if (nodeModulesIndex === -1) {
    nodeModulesIndex = currentSegments.length
  } else {
    infrastructureCount = nodeModulesIndex + 1
    if (currentSegments[nodeModulesIndex + 1]?.startsWith('@')) {
      packageNameCount = 2
    } else {
      packageNameCount = 1
    }
  }

  return currentSegments.map((segment, i) => ({
    segment,
    isCommon: i < commonCount,
    isLastCommon: i === commonCount - 1,
    isInfrastructure: i < infrastructureCount,
    isPackageName:
      i >= infrastructureCount && i < infrastructureCount + packageNameCount,
  }))
}

function getTitle(level: ChainLevel) {
  const parts = []
  if (level.fullPath) parts.push(`Full Path: ${level.fullPath}`)
  else parts.push(`Path: ${level.path}`)
  if (level.layer) parts.push(`Layer: ${level.layer}`)
  if (level.moduleType) parts.push(`Module Type: ${level.moduleType}`)
  if (level.treeShaking) parts.push(`Tree Shaking: ${level.treeShaking}`)
  if (level.templateArgs) parts.push(`Template Args: ${level.templateArgs}`)
  return parts.join('\n')
}

export function ImportChain({
  startFileId,
  analyzeData,
  modulesData,
  depthMap,
  environmentFilter,
}: ImportChainProps) {
  // Filter to include only the current route
  const [currentRouteOnly, setCurrentRouteOnly] = useState(true)

  // Track which dependent is selected at each level
  const [selectedIndices, setSelectedIndices] = useState<number[]>([])

  // Helper function to get module indices from source path
  const getModuleIndicesFromSourceIndex = (sourceIndex: number) => {
    const path = analyzeData.getFullSourcePath(sourceIndex)
    return modulesData.getModuleIndiciesFromPath(path)
  }

  // Helper function to get source index from module path
  const getSourceIndexFromModuleIndex = (moduleIndex: number) => {
    const module = modulesData.module(moduleIndex)
    if (!module) return undefined

    // Search through all sources to find one with matching path
    const modulePath = module.path
    for (let i = 0; i < analyzeData.sourceCount(); i++) {
      if (analyzeData.getFullSourcePath(i) === modulePath) {
        return i
      }
    }
    return undefined
  }

  // Build the import chain based on current selections
  const chain = useMemo(() => {
    const result: ChainLevel[] = []
    const visitedModules = new Set<number>()

    const startPath = analyzeData.getFullSourcePath(startFileId)
    if (!startPath) return result

    // Get all module indices for the starting source
    const startModuleIndices = getModuleIndicesFromSourceIndex(
      startFileId
    ).filter((moduleIndex) => {
      if (currentRouteOnly && !depthMap.has(moduleIndex)) {
        return false
      }
      let module = modulesData.module(moduleIndex)
      let layer = splitIdent(module?.ident || '').layer
      if (layer) {
        if (environmentFilter === 'client' && /ssr|rsc|route|api/.test(layer)) {
          return false
        }
        if (environmentFilter === 'server' && /client/.test(layer)) {
          return false
        }
      }
      return true
    })
    if (startModuleIndices.length === 0) return result

    // Get the selected index for the start modules (default to 0)
    const selectedStartIdx = selectedIndices[0] ?? 0
    const actualStartIdx = Math.min(
      selectedStartIdx,
      startModuleIndices.length - 1
    )
    const startModuleIndex = startModuleIndices[actualStartIdx]
    const startIdent = modulesData.module(startModuleIndex)?.ident ?? ''

    result.push({
      moduleIndex: startModuleIndex,
      sourceIndex: startFileId,
      path: startPath,
      ...splitIdent(startIdent),
      depth: depthMap.get(startModuleIndex) ?? Infinity,
      selectedIndex: actualStartIdx,
      totalCount: startModuleIndices.length,
    })

    visitedModules.add(startModuleIndex)

    // Build chain by following selected dependents
    let levelIndex = 1
    let currentModuleIndex = startModuleIndex

    while (true) {
      // Get dependents at the module level (sync and async)
      const dependentModuleIndices = [
        ...modulesData
          .moduleDependents(currentModuleIndex)
          .map((index: number) => ({
            index,
            async: false,
            depth: depthMap.get(index) ?? Infinity,
          })),
        ...modulesData
          .asyncModuleDependents(currentModuleIndex)
          .map((index: number) => ({
            index,
            async: true,
            depth: depthMap.get(index) ?? Infinity,
          })),
      ]

      // Filter out dependents that would create a cycle
      const validDependents = dependentModuleIndices.filter(
        ({ index, depth }) =>
          !visitedModules.has(index) && (isFinite(depth) || !currentRouteOnly)
      )

      if (validDependents.length === 0) {
        // No more dependents or all would create cycles
        break
      }

      // Build info for each dependent
      const dependentsInfo: DependentInfo[] = validDependents.map(
        ({ index: moduleIndex, async: isAsync, depth }) => {
          const sourceIndex = getSourceIndexFromModuleIndex(moduleIndex)
          let ident = modulesData.module(moduleIndex)?.ident || ''
          return {
            moduleIndex,
            sourceIndex,
            ident,
            isAsync,
            depth,
          }
        }
      )

      // Sort: sync first, async second, then by source presence, then by depth
      dependentsInfo.sort((a, b) => {
        // Sort by depth (smallest first)
        if (a.depth !== b.depth) {
          return a.depth - b.depth
        }
        // Sort by ident length (shortest first)
        if (a.ident.length !== b.ident.length) {
          return a.ident.length - b.ident.length
        }
        // Sort by ident
        return a.ident.localeCompare(b.ident)
      })

      // Get the selected index for this level (default to 0)
      const selectedIdx = selectedIndices[levelIndex] ?? 0
      const actualIdx = Math.min(selectedIdx, dependentsInfo.length - 1)

      const selectedDepInfo = dependentsInfo[actualIdx]
      const selectedDepModule = modulesData.module(selectedDepInfo.moduleIndex)

      if (!selectedDepModule || selectedDepModule.ident == null) break

      result.push({
        moduleIndex: selectedDepInfo.moduleIndex,
        sourceIndex: selectedDepInfo.sourceIndex,
        path: selectedDepModule.path,
        depth: depthMap.get(selectedDepInfo.moduleIndex) ?? Infinity,
        ...splitIdent(selectedDepModule.ident),
        selectedIndex: actualIdx,
        totalCount: dependentsInfo.length,
        info: selectedDepInfo,
      })

      visitedModules.add(selectedDepInfo.moduleIndex)
      currentModuleIndex = selectedDepInfo.moduleIndex
      levelIndex++

      // Safety check to prevent infinite loops
      if (levelIndex > 100) break
    }

    return result
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [
    startFileId,
    analyzeData,
    modulesData,
    selectedIndices,
    currentRouteOnly,
    depthMap,
    environmentFilter,
  ])

  const handlePrevious = (levelIndex: number) => {
    setSelectedIndices((prev) => {
      const newIndices = [...prev]
      const currentIdx = newIndices[levelIndex] ?? 0
      const level = chain[levelIndex]
      newIndices[levelIndex] =
        currentIdx > 0 ? currentIdx - 1 : level.totalCount - 1
      return newIndices.slice(0, levelIndex + 1)
    })
  }

  const handleNext = (levelIndex: number) => {
    setSelectedIndices((prev) => {
      const newIndices = [...prev]
      const currentIdx = newIndices[levelIndex] ?? 0
      const level = chain[levelIndex]
      newIndices[levelIndex] =
        currentIdx < level.totalCount - 1 ? currentIdx + 1 : 0
      return newIndices.slice(0, levelIndex + 1)
    })
  }

  const startPath = analyzeData.getFullSourcePath(startFileId)
  if (!startPath) return null

  return (
    <div className="space-y-2">
      <div className="flex items-center space-x-2">
        <h3 className="text-xs font-semibold text-foreground flex-1">
          Import Chain
        </h3>
        <label
          className="inline-flex items-center space-x-2 text-xs cursor-pointer"
          title="Only include dependent modules that are part of the current route's bundle"
        >
          <span>Current route only</span>
          <input
            type="checkbox"
            className="form-checkbox h-4 w-4 text-primary"
            checked={currentRouteOnly}
            onChange={() => setCurrentRouteOnly((prev) => !prev)}
          />
        </label>
      </div>
      <div className="space-y-0">
        {chain.map((level, index) => {
          const previousPath = index > 0 ? chain[index - 1].path : null
          const parts = getPathParts(level.path, previousPath)

          const flags =
            level.sourceIndex !== undefined
              ? analyzeData.getSourceFlags(level.sourceIndex)
              : undefined

          // Get the current item's info from the level itself
          const currentItemInfo = level.info

          return (
            <div key={`${level.path}-${index}`}>
              {currentItemInfo?.isAsync && <div className="h-8" />}
              <div className="flex items-center justify-center gap-2 py-1">
                {currentItemInfo?.isAsync && (
                  <span className="text-xs text-muted-foreground italic">
                    (async)
                  </span>
                )}
                {index > 0 ? (
                  <ArrowUp className="w-4 h-4 text-muted-foreground" />
                ) : undefined}
                {level.totalCount > 1 && (
                  <div className="flex items-center gap-1 flex-none">
                    <button
                      type="button"
                      onClick={() => handlePrevious(index)}
                      className="p-0.5 hover:bg-accent rounded transition-colors cursor-pointer"
                      title="Previous dependent"
                    >
                      <ChevronLeft className="w-3 h-3" />
                    </button>
                    <span className="text-muted-foreground text-xs min-w-[3ch] text-center">
                      {level.selectedIndex + 1}/{level.totalCount}
                    </span>
                    <button
                      type="button"
                      onClick={() => handleNext(index)}
                      className="p-0.5 hover:bg-accent rounded transition-colors cursor-pointer"
                      title="Next dependent"
                    >
                      <ChevronRight className="w-3 h-3" />
                    </button>
                  </div>
                )}
              </div>
              {currentItemInfo?.isAsync && <div className="h-8" />}
              <div className="flex items-center gap-2">
                <div className="flex flex-col gap-1 items-center">
                  {!level.layer ? (
                    <div title="Unknown">
                      <MessageCircleQuestion className="w-3 h-3 text-gray-500" />
                    </div>
                  ) : /app/.test(level.layer || '') ? (
                    <div title="App Router">
                      <Box className="w-3 h-3 text-green-500" />
                    </div>
                  ) : (
                    <div title="Pages Router">
                      <File className="w-3 h-3 text-purple-500" />
                    </div>
                  )}
                </div>

                <div className="flex-1 border border-border rounded px-2 py-1 bg-background">
                  <span
                    className="font-mono text-xs text-foreground text-center block break-words"
                    title={getTitle(level)}
                  >
                    {parts.map(
                      (
                        {
                          segment,
                          isCommon,
                          isLastCommon,
                          isInfrastructure,
                          isPackageName,
                        },
                        i
                      ) => (
                        <span
                          key={i}
                          className={clsx(
                            segment.length > 20 && 'break-all',
                            isCommon &&
                              !isLastCommon &&
                              !isPackageName &&
                              !isInfrastructure &&
                              'text-muted-foreground/80',
                            !isCommon && !isInfrastructure && 'font-bold',
                            isInfrastructure && 'text-muted-foreground/50',
                            isPackageName && 'text-orange-500'
                          )}
                        >
                          {segment}
                          <wbr />
                        </span>
                      )
                    )}
                  </span>
                </div>

                {/* Show icons for current item if we have flag info or no source */}
                <div className="flex flex-col gap-1 items-center">
                  {/client/.test(level.layer || '') &&
                    (flags?.client ? (
                      <div title="Included on client-side of this route">
                        <PanelTop className="w-3 h-3 text-green-500" />
                      </div>
                    ) : (
                      <div title="On client layer, but not included on client-side of this route (might be optimized away)">
                        <PanelTop className="w-3 h-3 text-gray-500" />
                      </div>
                    ))}
                  {/ssr/.test(level.layer || '') &&
                    (flags?.server ? (
                      <div title="Included on server-side of this route for server-side rendering">
                        <Globe className="w-3 h-3 text-blue-500" />
                      </div>
                    ) : (
                      <div title="On server-side rendering layer, but not included on server-side of this route (might be optimized away)">
                        <Globe className="w-3 h-3 text-gray-500" />
                      </div>
                    ))}
                  {/rsc/.test(level.layer || '') &&
                    (flags?.server ? (
                      <div title="Included on server-side of this route as Server Component">
                        <Server className="w-3 h-3 text-orange-500" />
                      </div>
                    ) : (
                      <div title="On Server Component layer, but not included on server-side of this route (might be optimized away)">
                        <Server className="w-3 h-3 text-gray-500" />
                      </div>
                    ))}
                  {/route|api/.test(level.layer || '') &&
                    (flags?.server ? (
                      <div title="Included on server-side of this route for API routes">
                        <SquareFunction className="w-3 h-3 text-red-500" />
                      </div>
                    ) : (
                      <div title="On API route layer, but not included on server-side of this route (might be optimized away)">
                        <SquareFunction className="w-3 h-3 text-gray-500" />
                      </div>
                    ))}
                </div>
              </div>

              <div className="block text-center">
                {level.treeShaking && (
                  <div className="text-xs text-foreground text-center">
                    {level.treeShaking === 'locals'
                      ? '(local declarations only)'
                      : level.treeShaking === 'module evaluation'
                        ? '(only the module evaluation part of the module)'
                        : `(${level.treeShaking})`}
                  </div>
                )}
              </div>
            </div>
          )
        })}
        {chain.length === 0 && (
          <p className="text-muted-foreground italic text-xs">
            No dependents found.{' '}
            {currentRouteOnly ? (
              <Button
                variant="link"
                className="inline p-0 text-xs h-auto"
                onClick={() => setCurrentRouteOnly(false)}
              >
                Show all routes.
              </Button>
            ) : null}
          </p>
        )}
      </div>
    </div>
  )
}
Quest for Codev2.0.0
/
SIGN IN