import type { ReactNode } from 'react'; import React, { useContext } from 'react'; import type { RouteProps } from 'react-router-dom'; import { Route } from 'react-router-dom'; import type { Harmony, SlotRegistry } from '@teambit/harmony'; import { Slot } from '@teambit/harmony'; import { LaneCompare, type LaneCompareProps as DefaultLaneCompareProps } from '@teambit/lanes.ui.compare.lane-compare'; import type { UiUI } from '@teambit/ui'; import { UIRuntime, UIAspect } from '@teambit/ui'; import type { NavigationSlot, RouteSlot } from '@teambit/ui-foundation.ui.react-router.slot-router'; import { NotFoundPage } from '@teambit/design.ui.pages.not-found'; import type { ScopeUI } from '@teambit/scope'; import { ScopeAspect, ScopeContext } from '@teambit/scope'; import type { WorkspaceUI } from '@teambit/workspace'; import { WorkspaceAspect } from '@teambit/workspace'; import type { ComponentUI } from '@teambit/component'; import { ComponentAspect, useIdFromLocation, ComponentID } from '@teambit/component'; import type { TabItem } from '@teambit/component.ui.component-compare.models.component-compare-props'; import { flatten } from 'lodash'; import type { MenuWidget, MenuWidgetSlot } from '@teambit/ui-foundation.ui.menu'; import type { LaneOverviewLine, LaneOverviewLineSlot } from '@teambit/lanes.ui.lane-overview'; import { LaneOverview } from '@teambit/lanes.ui.lane-overview'; import type { LanesNavPlugin, LanesOrderedNavigationSlot } from '@teambit/lanes.ui.menus.lanes-overview-menu'; import { LanesOverviewMenu } from '@teambit/lanes.ui.menus.lanes-overview-menu'; import { useQuery } from '@teambit/ui-foundation.ui.react-router.use-query'; import { UseLaneMenu } from '@teambit/lanes.ui.menus.use-lanes-menu'; import type { LanesHost } from '@teambit/lanes.ui.models.lanes-model'; import { LanesModel } from '@teambit/lanes.ui.models.lanes-model'; import type { IgnoreDerivingFromUrl, UseLanes } from '@teambit/lanes.hooks.use-lanes'; import { LanesProvider, useLanes } from '@teambit/lanes.hooks.use-lanes'; import { LaneSwitcher } from '@teambit/lanes.ui.navigation.lane-switcher'; import { useViewedLaneFromUrl } from '@teambit/lanes.hooks.use-viewed-lane-from-url'; import type { ComponentCompareUI } from '@teambit/component-compare'; import { ComponentCompareAspect } from '@teambit/component-compare'; import { LaneComparePage } from '@teambit/lanes.ui.compare.lane-compare-page'; import { ScopeIcon } from '@teambit/scope.ui.scope-icon'; import type { CommandBarUI } from '@teambit/command-bar'; import { CommandBarAspect } from '@teambit/command-bar'; import { LanesAspect } from './lanes.aspect'; import styles from './lanes.ui.module.scss'; export type LaneCompareProps = Partial; export type LaneProviderIgnoreSlot = SlotRegistry; /** * Stable, module-scope tabs list. Previously this array (and the `React.createElement(...)` for each * tab's element) was constructed inside `getLaneCompare`, which means every render produced a new * array and new tab-element references. Those flowed into `` → `resolvedTabs` * (useMemo keyed on input ref) → new value → new `allTabs` prop on every `InlineComponentCompare`, * defeating React.memo and forcing all 10 component panels (and their nested tabs) to re-render on * every parent re-render — including every `setViewMode` click. Hoisting the array to module scope * gives a single stable reference that survives any number of parent renders. */ /** * Slot signature for `LanesUI.registerCompareTab`. Kept for back-compat — the canonical inline * compare-tab list is now owned by `ComponentCompareUI` (so single-component compare and * lane-compare share one source). Tabs registered here are merged on top of that list. */ export type LaneCompareTabSlot = SlotRegistry; export function useComponentFilters() { const idFromLocation = useIdFromLocation(undefined, true); const { lanesModel, loading } = useLanes(); const laneFromUrl = useViewedLaneFromUrl(); const laneComponentId = idFromLocation && !laneFromUrl?.isDefault() ? (lanesModel?.resolveComponentFromUrl(idFromLocation, laneFromUrl) ?? null) : null; if (laneComponentId === null || loading) { return { loading: true, }; } return { loading: false, log: { head: laneComponentId.version, }, }; } export function useLaneComponentIdFromUrl(): ComponentID | undefined | null { const idFromLocation = useIdFromLocation(undefined, true); const { lanesModel, loading } = useLanes(); const laneFromUrl = useViewedLaneFromUrl(); const query = useQuery(); const componentVersion = query.get('version'); if (!idFromLocation) return null; const compIdFromLocation = ComponentID.tryFromString( `${idFromLocation}${componentVersion ? `@${componentVersion}` : ''}` ); if (compIdFromLocation) return compIdFromLocation; if (loading) return undefined; const lanesComp = (lanesModel?.resolveComponentFromUrl(idFromLocation, laneFromUrl) as any | undefined) ?? null; if (componentVersion) { return lanesComp?.changeVersion(componentVersion); } return lanesComp; } export function useComponentId() { return useLaneComponentIdFromUrl()?.toString(); } export class LanesUI { static dependencies = [ UIAspect, ComponentAspect, WorkspaceAspect, ScopeAspect, ComponentCompareAspect, CommandBarAspect, ]; static runtime = UIRuntime; static slots = [ Slot.withType(), Slot.withType(), Slot.withType(), Slot.withType(), Slot.withType(), Slot.withType(), ]; constructor( private componentUI: ComponentUI, private componentCompareUI: ComponentCompareUI, private routeSlot: RouteSlot, private navSlot: LanesOrderedNavigationSlot, private menuWidgetSlot: MenuWidgetSlot, /** * overview line slot to add new lines beneath the overview section */ private overviewSlot: LaneOverviewLineSlot, private laneProviderIgnoreSlot: LaneProviderIgnoreSlot, /** * compare-tab slot. Aspects register their inline-compare tab via `registerCompareTab(...)`; * the registered tabs are merged with the local fallback list, deduped by `id`, and sorted * by `order` before being passed to ``. */ private compareTabSlot: LaneCompareTabSlot, private workspace?: WorkspaceUI, private scope?: ScopeUI ) { this.hostAspect = workspace || scope; this.lanesHost = workspace ? 'workspace' : 'scope'; this.host = workspace ? WorkspaceAspect.id : ScopeAspect.id; } private readonly lanesHost: LanesHost; private readonly hostAspect?: WorkspaceUI | ScopeUI; private readonly host: string; private registerHostAspectRoutes() { if (!this.hostAspect) return; this.hostAspect.registerRoutes(this.getLaneRoutes()); this.hostAspect.registerMenuRoutes(this.getMenuRoutes()); } getLaneRoutes() { return [ { path: LanesModel.lanesPrefix, children: ( <> } /> } /> ), }, ]; } overrideComputeLaneUrl( fn: () => { prefix: string; path: string; getLaneIdFromPathname: typeof LanesModel.getLaneIdFromPathname; getLaneUrl: typeof LanesModel.getLaneUrl; getLaneComponentUrl: typeof LanesModel.getLaneComponentUrl; } ) { const { prefix, path, getLaneComponentUrl, getLaneIdFromPathname, getLaneUrl } = fn(); LanesModel.lanesPrefix = prefix; LanesModel.lanePath = path; LanesModel.getLaneComponentUrl = getLaneComponentUrl; LanesModel.getLaneUrl = getLaneUrl; LanesModel.getLaneIdFromPathname = getLaneIdFromPathname; } getLaneComponent() { return this.componentUI.getComponentUI(this.host, { componentId: useComponentId, useComponentFilters, }); } getLaneComponentMenu() { return this.componentUI.getMenu(this.host, { componentId: useComponentId, useComponentFilters, }); } getLaneOverview() { return ( ); } getLanesComparePage() { return ; } getMenuRoutes() { return [ { path: LanesModel.lanesPrefix, children: ( ), }, ]; } getLanesOverviewMenu() { return ; } registerMenuWidget(...menuItems: MenuWidget[]) { this.menuWidgetSlot.register(menuItems); return this; } registerLaneProviderIgnoreSlot(ignoreFn: IgnoreDerivingFromUrl) { this.laneProviderIgnoreSlot.register(ignoreFn); return this; } private registerLanesRoutes() { this.registerNavigation([ // { // props: { // href: '.', // exact: true, // children: 'README', // }, // order: 1, // hide: () => { // const { lanesModel } = useLanes(); // return !lanesModel?.viewedLane?.readmeComponent; // }, // }, { props: { href: '.', exact: true, children: 'Overview', }, order: 1, }, { props: { href: '~compare', children: 'Compare', }, order: 2, hide: () => { const { lanesModel } = useLanes(); return !lanesModel?.viewedLane || lanesModel?.lanes.length < 2; }, }, ]); } private registerRoutes() { this.registerHostAspectRoutes(); this.registerLanesRoutes(); } getLanesSwitcher() { const mainIcon = () => { const scope = useContext(ScopeContext); return ( ); }; const LanesSwitcher = ( ); return LanesSwitcher; } getLanesProvider() { return LanesProvider; } getUseLanes(): UseLanes { return useLanes; } private registerLanesDropdown() { const LanesSwitcher = this.getLanesSwitcher(); this.hostAspect?.registerSidebarLink({ component: function Gallery() { return LanesSwitcher; }, weight: 1000, }); } private renderContext = ({ children }: { children: ReactNode }) => { const ignoreFns = this.laneProviderIgnoreSlot.values(); return {children}; }; registerRoute(route: RouteProps) { this.routeSlot.register(route); return this; } /** * register a new line beneath the lane overview section. */ registerOverviewLine(...lines: LaneOverviewLine[]) { this.overviewSlot.register(lines); return this; } registerNavigation(routes: LanesNavPlugin[]) { this.navSlot.register(routes); } /** * Register an inline compare tab. Aspects own their compare-tab entry and call this in their * own UI provider (e.g. the docs aspect registers `inline-docs` so it can pass its real * `titleBadges` / `overviewOptions` slots). Tabs are merged with the lanes-side fallback list, * deduped by `id`, and sorted by `order` at render time. */ registerCompareTab(tab: TabItem | TabItem[]) { this.compareTabSlot.register(tab); return this; } // memoize the resolved tab list — same identity across renders unless a new registration lands. // The base list is owned by component-compare (so the single-component compare page and // lane-compare share one source). We additionally merge in anything registered on this aspect's // legacy `compareTabSlot` for back-compat. private _resolvedCompareTabs?: TabItem[]; private _resolvedCompareTabsKey?: string; private resolveCompareTabs(): TabItem[] { const fromComponentCompare = this.componentCompareUI.resolveCompareTabs(); const slotEntries = this.compareTabSlot.toArray(); const localRegistered = flatten( slotEntries.map(([, value]) => value).filter(Boolean) as Array ); const cacheKey = `${fromComponentCompare.map((t) => `${t.id}:${t.order ?? 0}`).join(',')}|${localRegistered .map((t) => `${t.id}:${t.order ?? 0}`) .join(',')}`; if (this._resolvedCompareTabs && this._resolvedCompareTabsKey === cacheKey) { return this._resolvedCompareTabs; } const localIds = new Set(localRegistered.map((t) => t.id)); // local registrations take precedence over the component-compare list. const merged = [...localRegistered, ...fromComponentCompare.filter((t) => !localIds.has(t.id))].sort( (a, b) => (a.order ?? 0) - (b.order ?? 0) ); this._resolvedCompareTabs = merged; this._resolvedCompareTabsKey = cacheKey; return merged; } getLaneCompare = (props: LaneCompareProps) => { if (!props.base || !props.compare) return null; return ( ); }; static async provider( [uiUi, componentUI, workspaceUi, scopeUi, componentCompareUI, commandBarUI]: [ UiUI, ComponentUI, WorkspaceUI, ScopeUI, ComponentCompareUI, CommandBarUI, ], _, [routeSlot, overviewSlot, navSlot, menuWidgetSlot, laneProviderIgnoreSlot, compareTabSlot]: [ RouteSlot, LaneOverviewLineSlot, LanesOrderedNavigationSlot, MenuWidgetSlot, LaneProviderIgnoreSlot, LaneCompareTabSlot, ], harmony: Harmony ) { const { config } = harmony; const host = String(config.get('teambit.harmony/bit')); let workspace: WorkspaceUI | undefined; let scope: ScopeUI | undefined; if (host === WorkspaceAspect.id) { workspace = workspaceUi; } if (host === ScopeAspect.id) { scope = scopeUi; } const lanesUi = new LanesUI( componentUI, componentCompareUI, routeSlot, navSlot, menuWidgetSlot, overviewSlot, laneProviderIgnoreSlot, compareTabSlot, workspace, scope ); if (uiUi) uiUi.registerRenderHooks({ reactContext: lanesUi.renderContext }); lanesUi.registerRoutes(); lanesUi.registerMenuWidget(() => { const { lanesModel } = useLanes(); if (!lanesModel?.viewedLane) return null; const { viewedLane, currentLane } = lanesModel; return ( ); }); lanesUi.registerLanesDropdown(); if (workspace) { lanesUi.registerMenuWidget(commandBarUI.CommandBarButton); } return lanesUi; } } export default LanesUI; LanesAspect.addRuntime(LanesUI);