diff --git a/components/hooks/use-lane-components/use-lane-components.tsx b/components/hooks/use-lane-components/use-lane-components.tsx index fb17c6741506..e1fa4e080970 100644 --- a/components/hooks/use-lane-components/use-lane-components.tsx +++ b/components/hooks/use-lane-components/use-lane-components.tsx @@ -1,5 +1,4 @@ -import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; -import { gql } from '@apollo/client'; +import { gql, useQuery } from '@apollo/client'; import { ComponentID, ComponentModel, componentOverviewFields } from '@teambit/component'; import type { LaneId } from '@teambit/lane-id'; import { ComponentDescriptor } from '@teambit/component-descriptor'; @@ -46,19 +45,32 @@ export type UseLaneComponentsResult = { loading?: boolean; }; -export function useLaneComponents(laneId?: LaneId): UseLaneComponentsResult { +export type UseLaneComponentsOptions = { + skip?: boolean; +}; + +export function useLaneComponents(laneId?: LaneId, options: UseLaneComponentsOptions = {}): UseLaneComponentsResult { + const shouldSkip = options.skip || !laneId; // @ts-ignore - remove once graphql versions are aligned (see #8753) - const { data, loading } = useDataQuery(GET_LANE_COMPONENTS, { - variables: { ids: [laneId?.toString()], skipList: laneId?.isDefault() }, - skip: !laneId, + const { data, loading } = useQuery(GET_LANE_COMPONENTS, { + variables: { ids: [laneId?.toString()], skipList: laneId?.isDefault() ?? false }, + skip: shouldSkip, + fetchPolicy: 'cache-and-network', + nextFetchPolicy: 'cache-first', + returnPartialData: true, }); const rawComps = data?.lanes.list && data?.lanes.list.length > 0 ? data?.lanes.list[0] : data?.lanes.default; - const components = rawComps?.components?.map((component) => { - const componentModel = ComponentModel.from({ ...component, host: data.getHost.id }); - return componentModel; - }); + // returnPartialData can deliver lane components before the separate getHost + // field arrives - treat that as still loading instead of dereferencing undefined + const hostId = data?.getHost?.id; + const components = hostId + ? rawComps?.components?.map((component) => { + const componentModel = ComponentModel.from({ ...component, host: hostId }); + return componentModel; + }) + : undefined; const componentDescriptors: ComponentDescriptor[] = compact( rawComps?.components?.map((rawComponent) => { diff --git a/components/hooks/use-lanes/use-lanes.tsx b/components/hooks/use-lanes/use-lanes.tsx index 7fc7b723489a..484746979ed8 100644 --- a/components/hooks/use-lanes/use-lanes.tsx +++ b/components/hooks/use-lanes/use-lanes.tsx @@ -1,13 +1,18 @@ import { useMemo, useCallback } from 'react'; -import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; import type { LaneModel, LanesQuery } from '@teambit/lanes.ui.models.lanes-model'; import { LanesModel } from '@teambit/lanes.ui.models.lanes-model'; -import { gql } from '@apollo/client'; +import { gql, useQuery } from '@apollo/client'; import type { LaneId } from '@teambit/lane-id'; import { isEqual } from 'lodash'; import type { LanesContextModel } from './lanes-context'; import { useLanesContext } from './lanes-context'; +type LanesQueryCompat = Omit & { + lanes?: Omit, 'viewedLane'> & { + viewedLane?: NonNullable['list']; + }; +}; + const GET_LANES = gql` query Lanes( $extensionId: String @@ -173,7 +178,7 @@ const useRootLanes: UseRootLanes = (viewedLaneId, skip, options = {}, scope) => const { ids, offset, limit, sort } = options; // @ts-ignore - remove once graphql versions are aligned (see #8753) - const { data, fetchMore, loading } = useDataQuery(GET_LANES, { + const { data, fetchMore, loading } = useQuery(GET_LANES, { variables: { laneIds: ids, offset, @@ -185,11 +190,14 @@ const useRootLanes: UseRootLanes = (viewedLaneId, skip, options = {}, scope) => skip, errorPolicy: 'all', fetchPolicy: 'cache-and-network', + nextFetchPolicy: 'cache-first', + returnPartialData: true, + notifyOnNetworkStatusChange: false, }); const lanesModel = useMemo(() => { if (!loading && !!data) { - const newLanesModel = LanesModel.from({ data, scope }); + const newLanesModel = LanesModel.from({ data: data as unknown as LanesQuery, scope }); return newLanesModel; } return undefined; @@ -219,7 +227,7 @@ const useRootLanes: UseRootLanes = (viewedLaneId, skip, options = {}, scope) => const loadingMore = networkStatus === 3; if (!loadingMore && moreData.lanes) { - const newLanesModel = LanesModel.from({ data: moreData }); + const newLanesModel = LanesModel.from({ data: moreData as unknown as LanesQuery }); return { lanesModel: newLanesModel, loading: loadingMore, @@ -262,7 +270,7 @@ const useRootLanes: UseRootLanes = (viewedLaneId, skip, options = {}, scope) => export const useSearchLanes: SearchLanes = (search, skip) => { // @ts-ignore - remove once graphql versions are aligned (see #8753) - const { data: searchData, loading: loadingSearch } = useDataQuery(GET_LANES, { + const { data: searchData, loading: loadingSearch } = useQuery(GET_LANES, { variables: { search, skipViewedLane: true, @@ -280,7 +288,9 @@ export const useSearchLanes: SearchLanes = (search, skip) => { loading: loadingSearch, lanesModel: searchData && - LanesModel.from({ data: { lanes: { ...searchData.lanes, current: undefined, default: undefined } } }), + LanesModel.from({ + data: { lanes: { ...searchData.lanes, current: undefined, default: undefined } } as unknown as LanesQuery, + }), }; }; diff --git a/components/ui/component-drawer/component-drawer.tsx b/components/ui/component-drawer/component-drawer.tsx index b384774e9f62..24b5f0b6242f 100644 --- a/components/ui/component-drawer/component-drawer.tsx +++ b/components/ui/component-drawer/component-drawer.tsx @@ -155,7 +155,8 @@ function ComponentsDrawerContent({ const emptyDrawer = {emptyMessage}; - const loading = loadingComponents || loadingLanesModel || !lanes || !components; + const hasComponents = Array.isArray(components); + const loading = loadingComponents || !hasComponents || (components.length === 0 && loadingLanesModel); if (loading) return ; diff --git a/components/ui/side-bar/component-tree/component-view/component-view.tsx b/components/ui/side-bar/component-tree/component-view/component-view.tsx index 63773335f690..08d09b16f058 100644 --- a/components/ui/side-bar/component-tree/component-view/component-view.tsx +++ b/components/ui/side-bar/component-tree/component-view/component-view.tsx @@ -9,7 +9,7 @@ import classNames from 'classnames'; import { ComponentID } from '@teambit/component-id'; import type { ComponentModel } from '@teambit/component'; import { ComponentUrl } from '@teambit/component.modules.component-url'; -import React, { useCallback, useContext, useEffect, useState } from 'react'; +import React, { useCallback, useContext } from 'react'; import { Tooltip } from '@teambit/design.ui.tooltip'; import { TreeContext } from '@teambit/base-ui.graph.tree.tree-context'; import { indentClass } from '@teambit/base-ui.graph.tree.indent'; @@ -33,14 +33,9 @@ export function ComponentView(props: ComponentViewProps) { const { onSelect } = useContext(TreeContext); const lanesContextModel = useContext(LanesContext); const lanesModel = lanesContextModel?.lanesModel; - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - return () => { - setMounted(false); - }; - }, []); + const hasWindow = typeof window !== 'undefined'; + const locationOrigin = hasWindow ? window.location.origin : undefined; + const isLocalhost = hasWindow ? window.location.host.startsWith('localhost') : false; const handleClick = useCallback( (event: React.MouseEvent) => { @@ -65,11 +60,11 @@ export function ComponentView(props: ComponentViewProps) { ? undefined : (isEnvOnCurrentLane && lanesModel?.viewedLane?.id && - mounted && - `${window.location.origin}${LanesModel.getLaneComponentUrl(envId, lanesModel.viewedLane?.id, undefined, lanesModel.viewedLane)}`) || + locationOrigin && + `${locationOrigin}${LanesModel.getLaneComponentUrl(envId, lanesModel.viewedLane?.id, undefined, lanesModel.viewedLane)}`) || ComponentUrl.toUrl(envId, { includeVersion: true, - useLocationOrigin: mounted ? window.location.host.startsWith('localhost') : false, + useLocationOrigin: isLocalhost, }); const envTooltip = envId ? ( @@ -99,7 +94,7 @@ export function ComponentView(props: ComponentViewProps) { const viewingMainCompOnLane = React.useMemo(() => { if (isMissingCompOrEnvId) return false; return ( - !component.status?.isNew && + component.status?.isNew === false && !lanesModel?.viewedLane?.id.isDefault() && lanesModel?.isComponentOnMainButNotOnLane(component.id as any, undefined, lanesModel?.viewedLane?.id) ); diff --git a/components/ui/version-dropdown/version-dropdown-placeholder.tsx b/components/ui/version-dropdown/version-dropdown-placeholder.tsx index 8f3baf739ee7..077b13427cef 100644 --- a/components/ui/version-dropdown/version-dropdown-placeholder.tsx +++ b/components/ui/version-dropdown/version-dropdown-placeholder.tsx @@ -47,7 +47,7 @@ export function SimpleVersion({ {formattedVersion} - {hasMoreVersions && } + {hasMoreVersions !== false && } ); } @@ -95,7 +95,7 @@ export function DetailedVersion({ - {hasMoreVersions && } + {hasMoreVersions !== false && } ); } diff --git a/components/ui/version-dropdown/version-dropdown.tsx b/components/ui/version-dropdown/version-dropdown.tsx index 6b8f677561fb..9aad45c87af8 100644 --- a/components/ui/version-dropdown/version-dropdown.tsx +++ b/components/ui/version-dropdown/version-dropdown.tsx @@ -24,6 +24,7 @@ export type UseComponentDropdownVersionsResult = { }; export type UseComponentDropdownVersionsProps = { skip?: boolean; + fetchLogs?: boolean; }; export type UseComponentDropdownVersions = ( props?: UseComponentDropdownVersionsProps @@ -79,8 +80,13 @@ function _VersionDropdown({ ...rest }: VersionDropdownProps) { const [key, setKey] = useState(0); - const singleVersion = !hasMoreVersions; + const singleVersion = hasMoreVersions === false; const [open, setOpen] = useState(false); + const [prefetchRequested, setPrefetchRequested] = useState(false); + + const requestPrefetch = useCallback(() => { + setPrefetchRequested(true); + }, []); useEffect(() => { if (loading && open) { @@ -89,6 +95,7 @@ function _VersionDropdown({ }, [loading]); const handlePlaceholderClicked = (e: React.MouseEvent) => { + requestPrefetch(); if (loading) return; if (e.target === e.currentTarget) { setOpen((o) => !o); @@ -128,36 +135,62 @@ function _VersionDropdown({ } return ( -
+
setOpen(false)} - onChange={(_e, _open) => _open && setKey((x) => x + 1)} // to reset menu to initial state when toggling + onChange={(_e, _open) => { + if (_open) { + requestPrefetch(); + setKey((x) => x + 1); + } + }} // to reset menu to initial state when toggling PlaceholderComponent={({ children, ...other }) => ( -
+
{children}
)} placeholder={PlaceholderComponent} > - setOpen(false)} - open={open} - /> + {/* the menu is not mounted until the user shows intent (hover/focus/press/open). versions + load lazily, and mounting the menu eagerly would render its rows into the dom for every + card in a workspace grid before anyone asked for them. */} + {prefetchRequested || open ? ( + setOpen(false)} + open={open} + /> + ) : null}
); @@ -175,6 +208,7 @@ type VersionMenuProps = { loading?: boolean; getActiveTabIndex?: GetActiveTabIndex; open?: boolean; + shouldPrefetchVersions?: boolean; onVersionClicked?: () => void; } & HTMLAttributes; @@ -211,11 +245,20 @@ function _VersionMenu({ getActiveTabIndex = defaultActiveTabIndex, loading: loadingFromProps, open, + shouldPrefetchVersions, onVersionClicked, ...rest }: VersionMenuProps) { - const { snaps, tags, loading: loadingVersions } = useVersions?.() || {}; - const loading = loadingFromProps || loadingVersions; + const shouldFetchVersions = !!(open || shouldPrefetchVersions); + const { + snaps, + tags, + loading: loadingVersions, + } = useVersions?.({ + skip: !shouldFetchVersions, + fetchLogs: shouldFetchVersions, + }) || {}; + const loading = loadingFromProps || (shouldFetchVersions && loadingVersions); const tabs = useMemo( () => diff --git a/components/ui/version-dropdown_1/index.ts b/components/ui/version-dropdown_1/index.ts deleted file mode 100644 index d21ba2af8847..000000000000 --- a/components/ui/version-dropdown_1/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { SimpleVersion, DetailedVersion } from './version-dropdown-placeholder'; -export type { VersionProps } from './version-dropdown-placeholder'; -export { VersionDropdown } from './version-dropdown'; -export type { DropdownComponentVersion, GetActiveTabIndex } from './version-dropdown'; diff --git a/components/ui/version-dropdown_1/lane-info/index.ts b/components/ui/version-dropdown_1/lane-info/index.ts deleted file mode 100644 index 29c77c7d104c..000000000000 --- a/components/ui/version-dropdown_1/lane-info/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { LaneInfo } from './lane-info'; -export type { LaneInfoProps } from './lane-info'; diff --git a/components/ui/version-dropdown_1/lane-info/lane-info.module.scss b/components/ui/version-dropdown_1/lane-info/lane-info.module.scss deleted file mode 100644 index 3c4d9ae0901e..000000000000 --- a/components/ui/version-dropdown_1/lane-info/lane-info.module.scss +++ /dev/null @@ -1,27 +0,0 @@ -.versionRow { - display: flex; - justify-content: space-between; - align-items: center; - height: 40px; - padding: 0 24px; - - .versionTimestamp { - margin-right: 2px; - } - .versionUserAvatar { - padding: 0px 8px; - } - .laneIcon { - padding: 0px 4px; - } - .version { - width: 60%; - display: flex; - flex-direction: row; - align-content: space-between; - align-items: center; - } - .versionName { - padding: 0px 8px; - } -} diff --git a/components/ui/version-dropdown_1/lane-info/lane-info.tsx b/components/ui/version-dropdown_1/lane-info/lane-info.tsx deleted file mode 100644 index 4f7586253987..000000000000 --- a/components/ui/version-dropdown_1/lane-info/lane-info.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { LaneModel } from '@teambit/lanes.ui.models.lanes-model'; -import { LanesModel } from '@teambit/lanes.ui.models.lanes-model'; -import React from 'react'; -import { MenuLinkItem } from '@teambit/design.ui.surfaces.menu.link-item'; -import { Icon } from '@teambit/evangelist.elements.icon'; -import styles from './lane-info.module.scss'; - -export type LaneInfoProps = LaneModel & { currentLane?: LaneModel }; - -export function LaneInfo({ id, currentLane }: LaneInfoProps) { - const isCurrent = currentLane && id.isEqual(currentLane.id); - - return ( -
- - - - {id.toString()} - - -
- ); -} diff --git a/components/ui/version-dropdown_1/version-dropdown-placeholder.module.scss b/components/ui/version-dropdown_1/version-dropdown-placeholder.module.scss deleted file mode 100644 index 5f87a85dd842..000000000000 --- a/components/ui/version-dropdown_1/version-dropdown-placeholder.module.scss +++ /dev/null @@ -1,49 +0,0 @@ -.simple, -.detailed { - cursor: pointer; - border: 1px solid var(--bit-border-color, #babec9); - border-radius: 6px; - transition: background-color 300ms ease-in-out; - height: 30px; - display: flex; - align-items: center; - box-sizing: border-box; - user-select: none; - flex-wrap: nowrap; - gap: 8px; - padding: 8px; - line-height: 1.2em; - - &:hover { - background-color: var(--bit-bg-heavy); - } - - &.disabled { - cursor: default; - background-color: #ededed; - > span { - display: none; - } - } -} -.versionName { - flex-grow: 1; - min-width: fit-content; -} - -.commitMessage { - flex-grow: 1; - max-width: 200px; -} - -.versionUserAvatar { - flex: none; - margin-right: 5px; -} - -.loader { - color: var(--bit-bg-dent, #f6f6f6); - > span { - padding: 4px; - } -} diff --git a/components/ui/version-dropdown_1/version-dropdown-placeholder.tsx b/components/ui/version-dropdown_1/version-dropdown-placeholder.tsx deleted file mode 100644 index 0472310e6017..000000000000 --- a/components/ui/version-dropdown_1/version-dropdown-placeholder.tsx +++ /dev/null @@ -1,109 +0,0 @@ -import type { HTMLAttributes } from 'react'; -import React from 'react'; -import { Ellipsis } from '@teambit/design.ui.styles.ellipsis'; -import classNames from 'classnames'; -import * as semver from 'semver'; -import { Icon } from '@teambit/evangelist.elements.icon'; -import { TimeAgo } from '@teambit/design.ui.time-ago'; -import { UserAvatar } from '@teambit/design.ui.avatar'; -import { WordSkeleton } from '@teambit/base-ui.loaders.skeleton'; -import type { DropdownComponentVersion } from './version-dropdown'; - -import styles from './version-dropdown-placeholder.module.scss'; - -export type VersionProps = { - currentVersion?: string; - isTag?: (version?: string) => boolean; - disabled?: boolean; - hasMoreVersions?: boolean; - showFullVersion?: boolean; - loading?: boolean; - useCurrentVersionLog?: (props: { skip?: boolean; version?: string }) => DropdownComponentVersion | undefined; -} & HTMLAttributes; - -export function SimpleVersion({ - currentVersion, - className, - disabled, - hasMoreVersions, - isTag = (version) => semver.valid(version) !== null, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - useCurrentVersionLog, - showFullVersion, - loading, - ...rest -}: VersionProps) { - if (loading) return ; - const formattedVersion = showFullVersion || isTag(currentVersion) ? currentVersion : currentVersion?.slice(0, 6); - - return ( -
- - {formattedVersion} - - {hasMoreVersions && } -
- ); -} - -export function DetailedVersion({ - currentVersion, - className, - disabled, - hasMoreVersions, - isTag = (version) => semver.valid(version) !== null, - loading, - useCurrentVersionLog, - showFullVersion, - ...rest -}: VersionProps) { - const currentVersionLog = useCurrentVersionLog?.({ skip: loading, version: currentVersion }); - const { displayName, message, username, email, date: _date, profileImage } = currentVersionLog || {}; - const author = React.useMemo(() => { - return { - displayName: displayName ?? '', - email, - name: username ?? '', - profileImage, - }; - }, [displayName, email, username, profileImage]); - const formattedVersion = showFullVersion || isTag(currentVersion) ? currentVersion : currentVersion?.slice(0, 6); - - const date = _date ? new Date(+_date) : undefined; - const timestamp = React.useMemo(() => (date ? new Date(+date).toString() : new Date().toString()), [date]); - if (loading) return ; - - return ( -
- - - {formattedVersion} - - {commitMessage(message, rest.onClick)} - - - - {hasMoreVersions && } -
- ); -} - -function commitMessage(message?: string, onClick?: React.MouseEventHandler | undefined) { - if (!message || message === '') - return ( - - No commit message - - ); - return ( - - {message} - - ); -} diff --git a/components/ui/version-dropdown_1/version-dropdown.composition.tsx b/components/ui/version-dropdown_1/version-dropdown.composition.tsx deleted file mode 100644 index e6185e683322..000000000000 --- a/components/ui/version-dropdown_1/version-dropdown.composition.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { ThemeCompositions } from '@teambit/documenter.theme.theme-compositions'; -import { VersionDropdown } from './version-dropdown'; - -const style = { display: 'flex', justifyContent: 'center', alignContent: 'center' }; - -export const VersionDropdownWithOneVersion = () => { - return ( - - ({ - tags: [{ version: '0.1' }], - })} - currentVersion="0.1" - /> - - ); -}; - -export const VersionDropdownWithMultipleVersions = () => { - const versions = ['0.3', '0.2', '0.1'].map((version) => ({ version })); - - return ( - - - ({ - tags: [{ version: '0.1' }], - })} - currentVersion={versions[0].version} - /> - - - ); -}; diff --git a/components/ui/version-dropdown_1/version-dropdown.docs.tsx b/components/ui/version-dropdown_1/version-dropdown.docs.tsx deleted file mode 100644 index 7ffd3c1603a9..000000000000 --- a/components/ui/version-dropdown_1/version-dropdown.docs.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { MemoryRouter } from 'react-router-dom'; -import { Section } from '@teambit/documenter.ui.section'; -import { ThemeCompositions } from '@teambit/documenter.theme.theme-compositions'; -import { Separator } from '@teambit/documenter.ui.separator'; -import { VersionDropdown } from './version-dropdown'; - -export default function Overview() { - return ( - - <> -
- The version-dropdown displays the latest version of the viewed component.
- If previous versions are available, the component will display a list of them, when clicked.
- This allows the user to navigate to previous versions, and explore them. -
- - -
- ); -} - -Overview.abstract = 'The version-dropdown lists the latest and previous versions of the viewed component.'; - -Overview.labels = ['react', 'typescript', 'version', 'dropdown']; - -const style = { display: 'flex', justifyContent: 'center', alignContent: 'center' }; - -Overview.examples = [ - { - scope: { - VersionDropdown, - style, - }, - title: 'Version Dropdown', - description: 'Using the Version Dropdown component with one verion', - code: ` - () => { - return ( -
- -
- ); - } - `, - }, - { - scope: { - VersionDropdown, - style, - MemoryRouter, - }, - title: 'Version Dropdown with multiple versions', - description: 'Using the Version Dropdown component with more than one version', - code: ` - () => { - const versions = ['0.3', '0.2', '0.1']; - return ( -
- - - -
- ); - } - `, - }, -]; diff --git a/components/ui/version-dropdown_1/version-dropdown.module.scss b/components/ui/version-dropdown_1/version-dropdown.module.scss deleted file mode 100644 index a2f88168279d..000000000000 --- a/components/ui/version-dropdown_1/version-dropdown.module.scss +++ /dev/null @@ -1,124 +0,0 @@ -@import '@teambit/ui-foundation.ui.constants.z-indexes/z-indexes.module.scss'; - -.versionDropdown { - height: 100%; - - > div { - height: 100%; - display: flex; - align-items: center; - } - .menu { - padding: 0; - right: 0; - // top: 43px; - font-size: var(--bit-p-xs); - border-radius: 6px; - z-index: $modal-z-index; - } -} - -.title { - padding: 16px; - padding-bottom: 12px; - border-bottom: 1px solid var(--bit-border-color-lightest, #ededed); -} - -.titleContainer { - margin-bottom: 2px; -} - -.versionContainerRoot { - max-height: 240px; - overflow-y: scroll; - padding-bottom: 8px; - position: relative; -} - -.versionRow { - display: flex; - justify-content: space-between; - align-items: center; - height: 40px; - padding: 0 16px; - - &.localVersion { - border-bottom: 1px solid var(--bit-border-color-lightest, #ededed); - } - .versionTimestamp { - margin-right: 2px; - } - .versionUserAvatar { - padding: 0px 8px; - } - .laneIcon { - padding: 0px 4px; - } - .version { - width: 60%; - display: flex; - flex-direction: row; - align-content: space-between; - align-items: center; - } - .versionName { - padding: 0px 8px; - min-width: fit-content; - } -} - -.withVersions { - cursor: pointer; - > div { - margin-right: 5px; - } - > span { - display: unset; - } - &:hover { - background-color: var(--bit-bg-heavy); - } - [data-open='true'] & { - transition: - color 300ms, - background-color 300ms ease-in-out; - background-color: var(--bit-bg-color, #ffffff); - color: var(--bit-accent-color, #6c5ce7); - &:hover { - background-color: var(--bit-bg-color, #ffffff); - } - } -} - -.tabs { - display: flex; - padding: 0 24px; - line-height: 14px; - border-bottom: 1px solid var(--bit-border-color-lightest, #ededed); - overflow-x: auto; - margin-top: 8px; - - .tab { - font-weight: bold; - padding-top: 4px; - font-size: 12px; - } -} - -.loading { - color: var(--bit-bg-dent, #f6f6f6); -} - -.loader { - color: var(--bit-bg-dent, #f6f6f6); - > div { - padding: 8px 0px; - } -} - -.versionMenuContainer { - display: initial; - &.hide { - display: none; - } -} diff --git a/components/ui/version-dropdown_1/version-dropdown.spec.tsx b/components/ui/version-dropdown_1/version-dropdown.spec.tsx deleted file mode 100644 index 4f67e26029ac..000000000000 --- a/components/ui/version-dropdown_1/version-dropdown.spec.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { expect } from 'chai'; -import { VersionDropdownWithOneVersion, VersionDropdownWithMultipleVersions } from './version-dropdown.composition'; - -describe('version dropdown tests', () => { - /** - * https://github.com/jsdom/jsdom/issues/1695 - * scrollIntoView is not implemented in jsdom - * */ - beforeEach(() => { - Element.prototype.scrollIntoView = jest.fn(); - }); - it('should render one version', () => { - const { getByText } = render(); - const textVersion = getByText(/^0.1$/); - expect(textVersion).to.exist; - }); - it('should not return multiple versions when mounted (lazy loading)', () => { - render(); - const textVersionOne = screen.queryByText(/^0.1$/); - const textVersionTwo = screen.queryByText(/^0.2$/); - const textVersionThree = screen.getAllByText(/^0.3$/); - - expect(textVersionOne).to.be.null; - expect(textVersionTwo).to.be.null; - expect(textVersionThree).to.have.lengthOf.at.least(1); - }); -}); diff --git a/components/ui/version-dropdown_1/version-dropdown.tsx b/components/ui/version-dropdown_1/version-dropdown.tsx deleted file mode 100644 index afb5e49b54e0..000000000000 --- a/components/ui/version-dropdown_1/version-dropdown.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import React, { useState } from 'react'; -import { MenuLinkItem } from '@teambit/design.ui.surfaces.menu.link-item'; -import { Dropdown } from '@teambit/evangelist.surfaces.dropdown'; -import { Tab } from '@teambit/ui-foundation.ui.use-box.tab'; -import type { LegacyComponentLog } from '@teambit/legacy-component-log'; -import { UserAvatar } from '@teambit/design.ui.avatar'; -import { LineSkeleton } from '@teambit/base-ui.loaders.skeleton'; -import type { LaneModel } from '@teambit/lanes.ui.models.lanes-model'; -import classNames from 'classnames'; -import styles from './version-dropdown.module.scss'; -import { VersionInfo } from './version-info'; -import { LaneInfo } from './lane-info'; -import type { VersionProps } from './version-dropdown-placeholder'; -import { SimpleVersion } from './version-dropdown-placeholder'; - -export const LOCAL_VERSION = 'workspace'; - -export type DropdownComponentVersion = Partial & { version: string }; - -export type UseComponentDropdownVersionsResult = { - tags?: DropdownComponentVersion[]; - snaps?: DropdownComponentVersion[]; - loading?: boolean; -}; -export type UseComponentDropdownVersionsProps = { - skip?: boolean; -}; -export type UseComponentDropdownVersions = ( - props?: UseComponentDropdownVersionsProps -) => UseComponentDropdownVersionsResult; -export type GetActiveTabIndex = ( - currentVersion?: string, - tabs?: Array, - tags?: DropdownComponentVersion[], - snaps?: DropdownComponentVersion[], - currentLane?: LaneModel -) => number; -export type VersionDropdownProps = { - localVersion?: boolean; - latestVersion?: string; - currentVersion: string; - useCurrentVersionLog?: (props?: { skip?: boolean; version?: string }) => DropdownComponentVersion | undefined; - hasMoreVersions?: boolean; - loading?: boolean; - useComponentVersions?: UseComponentDropdownVersions; - currentLane?: LaneModel; - lanes?: LaneModel[]; - getActiveTabIndex?: GetActiveTabIndex; - overrideVersionHref?: (version: string) => string; - placeholderClassName?: string; - dropdownClassName?: string; - menuClassName?: string; - showVersionDetails?: boolean; - disabled?: boolean; - PlaceholderComponent?: React.ComponentType; -} & React.HTMLAttributes; - -export const VersionDropdown = React.memo(_VersionDropdown); -const VersionMenu = React.memo(_VersionMenu); -function _VersionDropdown({ - currentVersion, - latestVersion, - localVersion, - useCurrentVersionLog, - hasMoreVersions, - loading, - overrideVersionHref, - className, - placeholderClassName, - getActiveTabIndex, - dropdownClassName, - menuClassName, - showVersionDetails = true, - disabled, - PlaceholderComponent: _PlaceholderComponent, - currentLane, - useComponentVersions, - lanes, - ...rest -}: VersionDropdownProps) { - const [key, setKey] = useState(0); - const singleVersion = !hasMoreVersions; - const [open, setOpen] = useState(false); - - React.useEffect(() => { - if (loading && open) { - setOpen(false); - } - }, [loading]); - - const handlePlaceholderClicked = (e: React.MouseEvent) => { - if (loading) return; - if (e.target === e.currentTarget) { - setOpen((o) => !o); - } - }; - - const defaultPlaceholder = ( - - ); - - const PlaceholderComponent = _PlaceholderComponent ? ( - <_PlaceholderComponent - useCurrentVersionLog={useCurrentVersionLog} - disabled={disabled} - className={placeholderClassName} - currentVersion={currentVersion} - onClick={handlePlaceholderClicked} - hasMoreVersions={hasMoreVersions} - loading={loading} - showFullVersion={currentVersion === 'workspace'} - /> - ) : ( - defaultPlaceholder - ); - - if (disabled || (singleVersion && !loading)) { - return
{PlaceholderComponent}
; - } - - return ( -
- setOpen(false)} - onChange={(_e, _open) => _open && setKey((x) => x + 1)} // to reset menu to initial state when toggling - PlaceholderComponent={({ children, ...other }) => ( -
- {children} -
- )} - placeholder={PlaceholderComponent} - > - -
-
- ); -} - -type VersionMenuProps = { - localVersion?: boolean; - currentVersion?: string; - latestVersion?: string; - useVersions?: UseComponentDropdownVersions; - currentLane?: LaneModel; - lanes?: LaneModel[]; - overrideVersionHref?: (version: string) => string; - showVersionDetails?: boolean; - loading?: boolean; - getActiveTabIndex?: GetActiveTabIndex; - open?: boolean; -} & React.HTMLAttributes; - -export type VersionMenuTab = - | { - name: 'SNAP'; - payload: DropdownComponentVersion[]; - } - | { - name: 'LANE'; - payload: LaneModel[]; - } - | { - name: 'TAG'; - payload: DropdownComponentVersion[]; - }; - -const defaultActiveTabIndex: GetActiveTabIndex = (currentVersion, tabs = [], tags, snaps) => { - if ((snaps || []).some((snap) => snap.version === currentVersion)) - return tabs.findIndex((tab) => tab.name === 'SNAP'); - return 0; -}; - -const VERSION_TAB_NAMES = ['TAG', 'SNAP', 'LANE'] as const; -function _VersionMenu({ - currentVersion, - localVersion, - latestVersion, - overrideVersionHref, - showVersionDetails, - useVersions, - currentLane, - lanes, - getActiveTabIndex = defaultActiveTabIndex, - loading: loadingFromProps, - open, - ...rest -}: VersionMenuProps) { - const { snaps, tags, loading: loadingVersions } = useVersions?.() || {}; - const loading = loadingFromProps || loadingVersions; - - const tabs = React.useMemo( - () => - VERSION_TAB_NAMES.map((name) => { - switch (name) { - case 'SNAP': - return { name, payload: snaps || [] }; - case 'LANE': - return { name, payload: lanes || [] }; - default: - return { name, payload: tags || [] }; - } - }).filter((tab) => tab.payload.length > 0), - [snaps?.length, tags?.length, lanes?.length, loading] - ); - - const [activeTabIndex, setActiveTab] = React.useState( - getActiveTabIndex(currentVersion, tabs, tags, snaps, currentLane) - ); - - const activeTab = React.useMemo( - () => (activeTabIndex !== undefined ? tabs[activeTabIndex] : undefined), - [activeTabIndex, tabs] - ); - - React.useEffect(() => { - if (!currentLane) return; - if (tabs.length === 0) return; - const _activeTabIndex = getActiveTabIndex(currentVersion, tabs, tags, snaps, currentLane); - if (_activeTabIndex !== activeTabIndex) setActiveTab(_activeTabIndex); - }, [currentLane, tabs.length, tags?.length, snaps?.length, currentVersion, loading]); - - const multipleTabs = tabs.length > 1; - const message = multipleTabs - ? 'Switch to view tags, snaps, or lanes' - : `Switch between ${tabs[0]?.name.toLocaleLowerCase()}s`; - - const showTab = activeTabIndex !== undefined && tabs[activeTabIndex]?.payload.length > 0; - - const _rowRenderer = React.useCallback( - function VersionRowRenderer({ index }) { - const { name, payload = [] } = activeTab || {}; - const item = payload[index]; - if (!item) return null; - if (name === 'LANE') { - const lane = item as LaneModel; - return ; - } - const version = item as DropdownComponentVersion; - return ( - - ); - }, - [activeTab, currentVersion, latestVersion, showVersionDetails, currentLane?.id.toString(), showTab] - ); - - const rowRenderer = React.useMemo( - () => (showTab && activeTab ? _rowRenderer : () => null), - [showTab, activeTab, _rowRenderer] - ); - - const ActiveTab = React.useMemo(() => { - return activeTab?.payload.map((payload, index) => { - return rowRenderer({ index }); - }); - }, [activeTab]); - - return ( -
-
- {loading && } - {!loading &&
{message}
} - {!loading && localVersion && ( - -
- - {LOCAL_VERSION} -
-
- )} -
-
- {multipleTabs && - tabs.map(({ name }, index) => { - return ( - setActiveTab(index)} - > - {name} - - ); - })} -
-
{ActiveTab}
-
- ); -} diff --git a/components/ui/version-dropdown_1/version-info/index.ts b/components/ui/version-dropdown_1/version-info/index.ts deleted file mode 100644 index 4c24385f6dbf..000000000000 --- a/components/ui/version-dropdown_1/version-info/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { VersionInfo } from './version-info'; -export type { VersionInfoProps } from './version-info'; diff --git a/components/ui/version-dropdown_1/version-info/version-info.module.scss b/components/ui/version-dropdown_1/version-info/version-info.module.scss deleted file mode 100644 index d0d9593f3dd4..000000000000 --- a/components/ui/version-dropdown_1/version-info/version-info.module.scss +++ /dev/null @@ -1,50 +0,0 @@ -.versionRow { - display: flex; - justify-content: space-between; - align-items: center; - height: 40px; - padding: 0 16px; - line-height: 1.2em; -} - -.versionTimestamp { - min-width: fit-content; - margin-right: 2px; - text-align: right; -} - -.versionUserAvatar { - flex: none; - padding: 0px 8px; -} - -.laneIcon { - padding: 0px 4px; -} - -.version { - max-width: 70%; - display: flex; - flex-direction: row; - align-items: center; - justify-content: flex-start; - overflow: hidden; -} - -.versionName { - padding: 0px 8px; - flex-grow: 1; - min-width: fit-content; -} - -.commitMessage { - flex-grow: 1; - padding: 0px 8px; -} - -.emptyMessage { - font-style: italic; - color: var(--bit-text-color-light, #6c707c); - padding: 0px 8px; - font-size: (var-bit-p-xs, 14px); -} diff --git a/components/ui/version-dropdown_1/version-info/version-info.tsx b/components/ui/version-dropdown_1/version-info/version-info.tsx deleted file mode 100644 index 33134fc27548..000000000000 --- a/components/ui/version-dropdown_1/version-info/version-info.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { MenuLinkItem } from '@teambit/design.ui.surfaces.menu.link-item'; -import { TimeAgo } from '@teambit/design.ui.time-ago'; -import { VersionLabel } from '@teambit/component.ui.version-label'; -import React, { useMemo, useRef, useEffect } from 'react'; -import { UserAvatar } from '@teambit/design.ui.avatar'; -import { Ellipsis } from '@teambit/design.ui.styles.ellipsis'; -import classNames from 'classnames'; - -import type { DropdownComponentVersion } from '../version-dropdown'; -import styles from './version-info.module.scss'; - -export type VersionInfoProps = DropdownComponentVersion & { - currentVersion?: string; - latestVersion?: string; - overrideVersionHref?: (version: string) => string; - showDetails?: boolean; -}; - -export const VersionInfo = React.memo(React.forwardRef(_VersionInfo)); -function _VersionInfo( - { - version, - currentVersion, - latestVersion, - date, - username, - displayName, - email, - overrideVersionHref, - showDetails, - message, - tag, - profileImage, - }: VersionInfoProps, - ref?: React.ForwardedRef -) { - const isCurrent = version === currentVersion; - const author = useMemo(() => { - return { - displayName: displayName ?? '', - email, - name: username ?? '', - profileImage, - }; - }, [displayName, email, username, profileImage]); - - const timestamp = useMemo(() => (date ? new Date(parseInt(date)).toString() : new Date().toString()), [date]); - const currentVersionRef = useRef(null); - - useEffect(() => { - if (isCurrent) { - currentVersionRef.current?.scrollIntoView({ block: 'nearest' }); - } - }, [isCurrent]); - - const href = overrideVersionHref ? overrideVersionHref(version) : `?version=${version}`; - - const formattedVersion = useMemo(() => { - return tag ? version : version.slice(0, 6); - }, [tag, version]); - - const isLatest = version === latestVersion; - - return ( -
- -
- - {formattedVersion} - {isLatest && } - -
- - - -
-
- ); -} - -function CommitMessage({ message, showDetails }: { message?: string; showDetails?: boolean }) { - if (!showDetails) return null; - if (!message || message === '') return No commit message; - return {message}; -} diff --git a/components/ui/workspace-component-card/workspace-component-card.module.scss b/components/ui/workspace-component-card/workspace-component-card.module.scss index ea53adc04940..57f54e1a71b8 100644 --- a/components/ui/workspace-component-card/workspace-component-card.module.scss +++ b/components/ui/workspace-component-card/workspace-component-card.module.scss @@ -1,6 +1,6 @@ .cardWrapper { position: relative; - min-width: 300px; + min-width: 0; } .loadPreview { diff --git a/components/ui/workspace-component-card/workspace-component-card.tsx b/components/ui/workspace-component-card/workspace-component-card.tsx index 6097615c7b65..12b808633f5b 100644 --- a/components/ui/workspace-component-card/workspace-component-card.tsx +++ b/components/ui/workspace-component-card/workspace-component-card.tsx @@ -1,10 +1,9 @@ -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import type { ComponentDescriptor } from '@teambit/component-descriptor'; import classNames from 'classnames'; import type { ScopeID } from '@teambit/scopes.scope-id'; import { ComponentCard, type ComponentCardPluginType, type PluginProps } from '@teambit/explorer.ui.component-card'; import type { ComponentModel } from '@teambit/component'; -import { LoadPreview } from '@teambit/workspace.ui.load-preview'; import styles from './workspace-component-card.module.scss'; export type WorkspaceComponentCardProps = { @@ -17,7 +16,6 @@ export type WorkspaceComponentCardProps = { id: ScopeID; }; className?: string; - shouldShowPreviewState?: boolean; } & React.HTMLAttributes; export function WorkspaceComponentCard({ @@ -26,61 +24,13 @@ export function WorkspaceComponentCard({ scope, plugins, className, - shouldShowPreviewState: shouldShowPreviewStateFromProps, ...rest }: WorkspaceComponentCardProps) { - const [shouldShowPreviewState, togglePreview] = React.useState(Boolean(shouldShowPreviewStateFromProps)); - const prevServerUrlRef = useRef(component.server?.url); - - useEffect(() => { - const currentServerUrl = component.server?.url; - if (prevServerUrlRef.current !== currentServerUrl && shouldShowPreviewState) { - togglePreview(false); - setTimeout(() => togglePreview(true), 50); - } - prevServerUrlRef.current = currentServerUrl; - }, [component.server?.url]); - - useEffect(() => { - togglePreview(Boolean(shouldShowPreviewStateFromProps)); - }, [shouldShowPreviewStateFromProps]); - - const showPreview = (e: React.MouseEvent) => { - e.stopPropagation(); - if (!shouldShowPreviewState) { - togglePreview(true); - } - }; - - const loadPreviewBtnVisible = - component.compositions.length > 0 && component?.buildStatus !== 'pending' && !shouldShowPreviewState; - - const updatedPlugins = React.useMemo(() => { - return plugins?.map((plugin) => { - if (plugin.preview) { - const Preview = plugin.preview; - return { - ...plugin, - preview: function PreviewWrapper(props) { - const serverKey = component.server?.url || 'no-server'; - return ( -
- -
- ); - }, - }; - } - return plugin; - }); - }, [shouldShowPreviewState, component.compositions.length, component.server?.url]); - if (component.deprecation?.isDeprecate) return null; return (
- {loadPreviewBtnVisible && } - +
); } diff --git a/scopes/cloud/hooks/use-cloud-scopes/use-cloud-scopes.ts b/scopes/cloud/hooks/use-cloud-scopes/use-cloud-scopes.ts index b129609c21bb..f1d10a72309a 100644 --- a/scopes/cloud/hooks/use-cloud-scopes/use-cloud-scopes.ts +++ b/scopes/cloud/hooks/use-cloud-scopes/use-cloud-scopes.ts @@ -1,5 +1,4 @@ -import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; -import { gql } from '@apollo/client'; +import { gql, useQuery } from '@apollo/client'; import { ScopeDescriptor } from '@teambit/scopes.scope-descriptor'; import { ScopeID } from '@teambit/scopes.scope-id'; @@ -15,8 +14,16 @@ export const GET_CLOUD_SCOPES_QUERY = gql` } `; +type CloudScopeQueryResult = { + id: string; + icon?: string; + backgroundIconColor?: string; + stripColor?: string; + displayName?: string; +}; + export function useCloudScopes(ids?: string[]): { cloudScopes?: ScopeDescriptor[] } { - const { data } = useDataQuery<{ getCloudScopes?: (ScopeDescriptor & { id: string })[] }>(GET_CLOUD_SCOPES_QUERY, { + const { data } = useQuery<{ getCloudScopes?: CloudScopeQueryResult[] }>(GET_CLOUD_SCOPES_QUERY, { variables: { ids, }, @@ -26,7 +33,7 @@ export function useCloudScopes(ids?: string[]): { cloudScopes?: ScopeDescriptor[ cloudScopes: (data?.getCloudScopes || []).map((scope) => { const scopeId = ScopeID.fromString(scope.id); const scopeDescriptorObj = { - ...scope, + displayName: scope.displayName, id: scopeId.toObject(), scopeStyle: { icon: scope.icon, diff --git a/scopes/cloud/hooks/use-current-user/use-current-user.ts b/scopes/cloud/hooks/use-current-user/use-current-user.ts index 4ff48d08d5f9..a9ea6db3f95f 100644 --- a/scopes/cloud/hooks/use-current-user/use-current-user.ts +++ b/scopes/cloud/hooks/use-current-user/use-current-user.ts @@ -1,6 +1,5 @@ import React from 'react'; -import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; -import { useMutation, gql } from '@apollo/client'; +import { gql, useQuery, useApolloClient } from '@apollo/client'; import type { CloudUser } from '@teambit/cloud.models.cloud-user'; export const SET_REDIRECT_URL_MUTATION = gql` @@ -27,22 +26,22 @@ export function useCurrentUser(): { isLoggedIn?: boolean; loading?: boolean; } { - const [setRedirectUrl] = useMutation(SET_REDIRECT_URL_MUTATION); + const client = useApolloClient(); // read the href during render rather than inside the dependency array: a dependency array is // evaluated on every render, including the server-side one, where `window` does not exist. the // effect body itself never runs on the server, so only the dependency needed guarding. const redirectUrl = typeof window === 'undefined' ? undefined : window.location.href; + // Fire-and-forget: don't block UI rendering. This just sets an in-memory URL on the server. React.useEffect(() => { if (!redirectUrl) return; - setRedirectUrl({ variables: { redirectUrl } }).catch((error) => { - // eslint-disable-next-line no-console - console.error('Error setting redirect URL:', error); - }); + client + .mutate({ mutation: SET_REDIRECT_URL_MUTATION, variables: { redirectUrl }, fetchPolicy: 'no-cache' }) + .catch(() => {}); }, [redirectUrl]); - const { data, loading } = useDataQuery(CURRENT_USER_QUERY, { + const { data, loading } = useQuery(CURRENT_USER_QUERY, { fetchPolicy: 'cache-first', }); diff --git a/scopes/cloud/ui/user-bar/use-dev-server-connection-status.ts b/scopes/cloud/ui/user-bar/use-dev-server-connection-status.ts new file mode 100644 index 000000000000..41edc7e81008 --- /dev/null +++ b/scopes/cloud/ui/user-bar/use-dev-server-connection-status.ts @@ -0,0 +1,446 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +const CONNECTION_STATUS_EVENT = 'bit-dev-server-connection-status'; +const ONLINE_POLL_MS = 10000; +const OFFLINE_POLL_MS = 2500; +const REQUEST_TIMEOUT_MS = 2500; +const RECOVERY_FADE_MS = 1500; +const PREVIEW_OFFLINE_DELAY_MS = 15000; +const OFFLINE_DEBOUNCE_MS = 1200; +const OFFLINE_GUARD_AFTER_RECOVERY_MS = 3000; + +type ConnectionMode = 'online' | 'offline' | 'recovering'; +type PreviewMode = 'online' | 'loading' | 'offline'; +type ConnectionReason = 'network' | 'browser-offline' | 'preview'; + +type ConnectionEventDetail = { + online?: boolean; + reason?: ConnectionReason; + previewKey?: string; + previewSnapshot?: { + presenceKeys: string[]; + readyKeys: string[]; + compilingKeys: string[]; + }; +}; + +export function useDevServerConnectionStatus() { + const [mode, setMode] = useState('online'); + const [reason, setReason] = useState('network'); + const [previewMode, setPreviewMode] = useState('loading'); + const modeRef = useRef('online'); + const inFlightRef = useRef(false); + const recoveryTimerRef = useRef(undefined); + const previewOfflineTimerRef = useRef(undefined); + const offlineTimerRef = useRef(undefined); + const pendingOfflineReasonRef = useRef('network'); + const ignoreOfflineUntilRef = useRef(0); + const mainFailureCountRef = useRef(0); + const healthCheckGenerationRef = useRef(0); + const previewPresenceKeysRef = useRef(new Set()); + const previewReadyKeysRef = useRef(new Set()); + const previewCompilingKeysRef = useRef(new Set()); + const previewWentOnlineOnceRef = useRef(false); + const previewSnapshotSeenRef = useRef(false); + const previewBootTimerRef = useRef(undefined); + const previewOnlineSettleTimerRef = useRef(undefined); + + const applyMode = useCallback((next: ConnectionMode) => { + modeRef.current = next; + setMode(next); + }, []); + + const clearRecoveryTimer = useCallback(() => { + if (!recoveryTimerRef.current) return; + window.clearTimeout(recoveryTimerRef.current); + recoveryTimerRef.current = undefined; + }, []); + + const clearPreviewOfflineTimer = useCallback(() => { + if (!previewOfflineTimerRef.current) return; + window.clearTimeout(previewOfflineTimerRef.current); + previewOfflineTimerRef.current = undefined; + }, []); + + const clearOfflineTimer = useCallback(() => { + if (!offlineTimerRef.current) return; + window.clearTimeout(offlineTimerRef.current); + offlineTimerRef.current = undefined; + }, []); + + const clearPreviewBootTimer = useCallback(() => { + if (!previewBootTimerRef.current) return; + window.clearTimeout(previewBootTimerRef.current); + previewBootTimerRef.current = undefined; + }, []); + + const clearPreviewOnlineSettleTimer = useCallback(() => { + if (!previewOnlineSettleTimerRef.current) return; + window.clearTimeout(previewOnlineSettleTimerRef.current); + previewOnlineSettleTimerRef.current = undefined; + }, []); + + const pingDevServer = useCallback(async () => { + if (typeof window === 'undefined') return true; + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const result = await fetch('/graphql', { + method: 'POST', + cache: 'no-store', + signal: controller.signal, + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + query: 'query __BitHealth { __typename }', + }), + }).catch(() => undefined); + + return !!result?.ok; + } finally { + window.clearTimeout(timeout); + } + }, []); + + const applyOfflineNow = useCallback( + (nextReason: ConnectionReason) => { + clearRecoveryTimer(); + clearOfflineTimer(); + setReason(nextReason); + applyMode('offline'); + }, + [applyMode, clearOfflineTimer, clearRecoveryTimer] + ); + + const markOnline = useCallback(() => { + mainFailureCountRef.current = 0; + clearOfflineTimer(); + if (modeRef.current === 'online' || modeRef.current === 'recovering') return; + clearRecoveryTimer(); + ignoreOfflineUntilRef.current = Date.now() + OFFLINE_GUARD_AFTER_RECOVERY_MS; + applyMode('recovering'); + recoveryTimerRef.current = window.setTimeout(() => { + applyMode('online'); + }, RECOVERY_FADE_MS); + }, [applyMode, clearOfflineTimer, clearRecoveryTimer]); + + const markOffline = useCallback( + (nextReason: ConnectionReason, immediate = false) => { + if (nextReason === 'browser-offline' || immediate) { + applyOfflineNow(nextReason); + return; + } + if (Date.now() < ignoreOfflineUntilRef.current) { + return; + } + + pendingOfflineReasonRef.current = nextReason; + if (offlineTimerRef.current) return; + + // Ignore transient transport blips (startup websocket churn, subscription reconnects). + // We only transition to offline if a debounced, direct local health check still fails. + // The generation counter discards a ping that resolves after a newer check started + // (or after teardown bumped it) - a stale failure must not flip a recovered + // connection back offline. + const scheduleHealthCheck = (delay: number) => { + offlineTimerRef.current = window.setTimeout(async () => { + offlineTimerRef.current = undefined; + if (!window.navigator.onLine) { + applyOfflineNow('browser-offline'); + return; + } + const generation = ++healthCheckGenerationRef.current; + const ok = await pingDevServer(); + if (generation !== healthCheckGenerationRef.current) return; + if (ok) { + mainFailureCountRef.current = 0; + markOnline(); + return; + } + mainFailureCountRef.current += 1; + if (mainFailureCountRef.current < 2) { + // confirm quickly instead of waiting for the next regular poll cycle - + // otherwise a real outage keeps showing "online" for many extra seconds + scheduleHealthCheck(OFFLINE_DEBOUNCE_MS); + return; + } + applyOfflineNow(pendingOfflineReasonRef.current); + }, delay); + }; + scheduleHealthCheck(OFFLINE_DEBOUNCE_MS); + }, + [applyOfflineNow, markOnline, pingDevServer] + ); + + const evaluatePreviewMode = useCallback(() => { + const presence = previewPresenceKeysRef.current; + const ready = previewReadyKeysRef.current; + const compiling = previewCompilingKeysRef.current; + + if (presence.size === 0) { + clearPreviewOfflineTimer(); + clearPreviewOnlineSettleTimer(); + previewWentOnlineOnceRef.current = false; + setPreviewMode('online'); + return; + } + + if (compiling.size > 0) { + clearPreviewOfflineTimer(); + clearPreviewOnlineSettleTimer(); + setPreviewMode('loading'); + return; + } + + let allReady = true; + for (const previewKey of presence) { + if (!ready.has(previewKey)) { + allReady = false; + break; + } + } + + if (allReady) { + clearPreviewOfflineTimer(); + if (previewOnlineSettleTimerRef.current) return; + previewOnlineSettleTimerRef.current = window.setTimeout(() => { + previewOnlineSettleTimerRef.current = undefined; + previewWentOnlineOnceRef.current = true; + setPreviewMode('online'); + }, 220); + return; + } + + clearPreviewOnlineSettleTimer(); + setPreviewMode('loading'); + if (!previewWentOnlineOnceRef.current) { + clearPreviewOfflineTimer(); + return; + } + if (previewOfflineTimerRef.current) return; + + previewOfflineTimerRef.current = window.setTimeout(() => { + previewOfflineTimerRef.current = undefined; + const currentPresence = previewPresenceKeysRef.current; + const currentReady = previewReadyKeysRef.current; + if (currentPresence.size === 0) { + previewWentOnlineOnceRef.current = false; + setPreviewMode('online'); + return; + } + for (const previewKey of currentPresence) { + if (!currentReady.has(previewKey)) { + setPreviewMode('offline'); + return; + } + } + previewWentOnlineOnceRef.current = true; + setPreviewMode('online'); + }, PREVIEW_OFFLINE_DELAY_MS); + }, [clearPreviewOfflineTimer, clearPreviewOnlineSettleTimer]); + + const markPreviewSignal = useCallback( + (online: boolean, previewKey?: string) => { + if (previewSnapshotSeenRef.current) { + // Once workspace snapshot mode is active, keyed snapshot data is the source of truth. + // Ignore legacy per-preview/unkeyed fallback events to prevent stale loading regressions. + return; + } + if (previewKey) { + // Snapshot is authoritative for keyed previews (presence + readiness). + // Ignore per-iframe online/offline signals to avoid stale loading states. + return; + } + + // Backward-compatibility path for legacy/unkeyed preview events. + if (online) { + clearPreviewOfflineTimer(); + previewWentOnlineOnceRef.current = true; + setPreviewMode('online'); + return; + } + + setPreviewMode((current) => (current === 'offline' ? current : 'loading')); + clearPreviewOfflineTimer(); + previewOfflineTimerRef.current = window.setTimeout(() => { + setPreviewMode('offline'); + }, PREVIEW_OFFLINE_DELAY_MS); + }, + [clearPreviewOfflineTimer] + ); + + const applyPreviewSnapshot = useCallback( + (snapshot?: { presenceKeys: string[]; readyKeys: string[]; compilingKeys: string[] }) => { + if (!snapshot) return; + previewSnapshotSeenRef.current = true; + clearPreviewBootTimer(); + previewPresenceKeysRef.current = new Set(snapshot.presenceKeys || []); + previewReadyKeysRef.current = new Set(snapshot.readyKeys || []); + previewCompilingKeysRef.current = new Set(snapshot.compilingKeys || []); + evaluatePreviewMode(); + }, + [clearPreviewBootTimer, evaluatePreviewMode] + ); + + const runHealthCheck = useCallback(async () => { + if (typeof window === 'undefined') return; + if (inFlightRef.current) return; + + inFlightRef.current = true; + + try { + clearOfflineTimer(); + if (!window.navigator.onLine) { + applyOfflineNow('browser-offline'); + return; + } + + const ok = await pingDevServer(); + if (!ok) { + markOffline('network'); + return; + } + + mainFailureCountRef.current = 0; + markOnline(); + } finally { + inFlightRef.current = false; + } + }, [applyOfflineNow, clearOfflineTimer, markOffline, markOnline, pingDevServer]); + + useEffect(() => { + if (typeof window === 'undefined') return undefined; + + const handleStatusEvent = (event: Event) => { + const { detail } = event as CustomEvent; + if (detail?.reason === 'preview') { + if (detail?.previewSnapshot) { + applyPreviewSnapshot(detail.previewSnapshot); + return; + } + markPreviewSignal(detail?.online === true, detail?.previewKey); + return; + } + if (detail?.online === true) { + markOnline(); + return; + } + if (detail?.online === false) { + markOffline(detail.reason || 'network'); + } + }; + + const onOffline = () => markOffline('browser-offline', true); + const onOnline = () => { + void runHealthCheck(); + }; + + window.addEventListener(CONNECTION_STATUS_EVENT, handleStatusEvent as EventListener); + window.addEventListener('offline', onOffline); + window.addEventListener('online', onOnline); + + const bootSnapshot = (window as any).__BIT_PREVIEW_STATUS__ as + | { presenceKeys: string[]; readyKeys: string[]; compilingKeys: string[] } + | undefined; + if (bootSnapshot) { + applyPreviewSnapshot(bootSnapshot); + } else { + previewBootTimerRef.current = window.setTimeout(() => { + previewBootTimerRef.current = undefined; + if (previewSnapshotSeenRef.current) return; + if (previewPresenceKeysRef.current.size === 0) { + setPreviewMode('online'); + } + }, 2500); + } + + return () => { + window.removeEventListener(CONNECTION_STATUS_EVENT, handleStatusEvent as EventListener); + window.removeEventListener('offline', onOffline); + window.removeEventListener('online', onOnline); + clearOfflineTimer(); + clearRecoveryTimer(); + healthCheckGenerationRef.current += 1; + clearPreviewOfflineTimer(); + clearPreviewBootTimer(); + clearPreviewOnlineSettleTimer(); + }; + }, [ + clearOfflineTimer, + clearRecoveryTimer, + clearPreviewOfflineTimer, + clearPreviewBootTimer, + clearPreviewOnlineSettleTimer, + markOffline, + markOnline, + applyPreviewSnapshot, + markPreviewSignal, + runHealthCheck, + ]); + + useEffect(() => { + if (typeof window === 'undefined') return undefined; + + void runHealthCheck(); + const interval = window.setInterval( + () => { + if (!document.hidden || modeRef.current !== 'online') { + void runHealthCheck(); + } + }, + modeRef.current === 'online' ? ONLINE_POLL_MS : OFFLINE_POLL_MS + ); + + return () => window.clearInterval(interval); + }, [runHealthCheck, mode]); + + const isDevOffline = mode === 'offline'; + const isDevRecovering = mode === 'recovering'; + const showPreviewIndicator = mode === 'online' && previewMode !== 'online'; + const showIndicator = true; + + const indicatorLabel = isDevOffline + ? 'Offline' + : isDevRecovering + ? 'Reconnecting' + : previewMode === 'offline' + ? 'Previews offline' + : previewMode === 'loading' + ? 'Previews loading' + : 'Online'; + + const indicatorTone: 'offline' | 'recovering' | 'preview-loading' | 'preview-offline' | 'online' = isDevOffline + ? 'offline' + : isDevRecovering + ? 'recovering' + : previewMode === 'offline' + ? 'preview-offline' + : previewMode === 'loading' + ? 'preview-loading' + : 'online'; + + const message = isDevOffline + ? reason === 'browser-offline' + ? 'Offline mode: browser has no network connection.' + : 'Offline mode: waiting for dev server to respond.' + : isDevRecovering + ? 'Reconnecting and validating dev server health.' + : previewMode === 'offline' + ? 'Preview dev servers are offline. Main UI is still connected.' + : showPreviewIndicator + ? 'Preview dev servers are still compiling.' + : 'Workspace UI and dev server are connected.'; + + return { + mode, + previewMode, + showIndicator, + isOffline: isDevOffline, + indicatorLabel, + indicatorTone, + shouldFade: false, + message, + runHealthCheck, + }; +} diff --git a/scopes/cloud/ui/user-bar/user-bar.module.scss b/scopes/cloud/ui/user-bar/user-bar.module.scss index 0ecc4e57af85..c1e45ae4a57a 100644 --- a/scopes/cloud/ui/user-bar/user-bar.module.scss +++ b/scopes/cloud/ui/user-bar/user-bar.module.scss @@ -4,10 +4,75 @@ color: var(--on-surface-neutral-low-color, #9598a1); } +.userBarAnchor { + display: inline-flex; + align-items: center; + gap: 8px; + position: relative; + z-index: $modal-z-index + 6; +} + .currentUser { cursor: pointer; } +.devServerStatus { + border: 1px solid transparent; + border-radius: 999px; + padding: 4px 10px; + font-size: 12px; + font-weight: 600; + line-height: 1; + transition: + opacity 220ms ease, + transform 220ms ease; +} + +.devServerStatusOnline { + background: #edf9f0; + border-color: #b8e8c1; + color: #126129; + opacity: 1; + transform: translateY(0); +} + +.devServerStatusOffline { + background: #fff1f1; + border-color: #ffcbcb; + color: #ab1a1a; + opacity: 1; + transform: translateY(0); +} + +.devServerStatusRecovering { + background: #edf9f0; + border-color: #b8e8c1; + color: #126129; + opacity: 1; + transform: translateY(0); +} + +.devServerStatusPreviewLoading { + background: #fff8e7; + border-color: #f4d898; + color: #8a5a00; + opacity: 1; + transform: translateY(0); +} + +.devServerStatusPreviewOffline { + background: #fff4ec; + border-color: #ffc9a1; + color: #9d3f00; + opacity: 1; + transform: translateY(0); +} + +.fadeOut { + opacity: 0; + transform: translateY(-3px); +} + :export { menuZIndex: #{$modal-z-index + 1}; } diff --git a/scopes/cloud/ui/user-bar/user-bar.tsx b/scopes/cloud/ui/user-bar/user-bar.tsx index c5f78e08ae6d..2faf37df1f1f 100644 --- a/scopes/cloud/ui/user-bar/user-bar.tsx +++ b/scopes/cloud/ui/user-bar/user-bar.tsx @@ -1,7 +1,9 @@ import React from 'react'; +import classNames from 'classnames'; import { UserAvatar } from '@teambit/design.ui.avatar'; import { CircleSkeleton } from '@teambit/base-ui.loaders.skeleton'; import { useNavigate } from '@teambit/design.ui.navigation.link'; +import { Tooltip } from '@teambit/design.ui.tooltip'; import { Login } from '@teambit/cloud.ui.login'; import { CurrentUser } from '@teambit/cloud.ui.current-user'; import { useCurrentUser } from '@teambit/cloud.hooks.use-current-user'; @@ -11,6 +13,7 @@ import { Menu } from '@teambit/design.controls.menu'; import { useWorkspaceMode } from '@teambit/workspace.ui.use-workspace-mode'; import type { UserBarSection } from './section'; import type { UserBarItem } from './item'; +import { useDevServerConnectionStatus } from './use-dev-server-connection-status'; import styles from './user-bar.module.scss'; @@ -30,6 +33,8 @@ export function UserBar({ sections = [], items = [] }: UserBarProps) { const { isMinimal } = useWorkspaceMode(); const { currentUser, loginUrl, loading, isLoggedIn } = useCurrentUser(); const { logout, loading: loadingLoggingOut, loggedOut } = useLogout(); + const { showIndicator, indicatorLabel, indicatorTone, shouldFade, message, runHealthCheck } = + useDevServerConnectionStatus(); const navigate = useNavigate(); @@ -118,8 +123,33 @@ export function UserBar({ sections = [], items = [] }: UserBarProps) { return navigate(e.value.link); }} menuButton={ -
- +
+ {showIndicator && ( + + + + )} +
+ +
} items={allItems} diff --git a/scopes/compilation/bundler/bundler.main.runtime.ts b/scopes/compilation/bundler/bundler.main.runtime.ts index 46102c4d7c1c..bfcaf20f93f5 100644 --- a/scopes/compilation/bundler/bundler.main.runtime.ts +++ b/scopes/compilation/bundler/bundler.main.runtime.ts @@ -1,3 +1,4 @@ +import { flatten } from 'lodash'; import type { PubsubMain } from '@teambit/pubsub'; import { PubsubAspect } from '@teambit/pubsub'; import { MainRuntime } from '@teambit/cli'; @@ -16,6 +17,7 @@ import type { ComponentServer } from './component-server'; import { NewDevServersCreatedEvent } from './events'; import type { BundlerContext } from './bundler-context'; import { devServerSchema } from './dev-server.graphql'; +import type { DevServerFailure, DevServerRunOnceResult } from './dev-server.service'; import { DevServerService } from './dev-server.service'; import { BundlerService } from './bundler.service'; import type { DevServer } from './dev-server'; @@ -39,6 +41,11 @@ export class BundlerMain { */ private _componentServers: ComponentServer[] = []; + /** + * envs that failed to create a dev server on the last `devServer()` call. + */ + private _devServerFailures: DevServerFailure[] = []; + constructor( readonly config: BundlerConfig, /** @@ -90,9 +97,12 @@ export class BundlerMain { async devServer(components: Component[], opts: { configureProxy?: boolean } = {}): Promise { const envRuntime = await this.envs.createEnvironment(components); - const servers: ComponentServer[] = await envRuntime.runOnce(this.devService, { + // a failing env no longer rejects the whole batch - it comes back in `failures` instead, so the + // envs that did build still get their servers. see `DevServerService.runOnce`. + const { servers, failures }: DevServerRunOnceResult = await envRuntime.runOnce(this.devService, { dedicatedEnvDevServers: this.config.dedicatedEnvDevServers, }); + this._devServerFailures = failures; if (opts.configureProxy) { this.pubsub.pub(BundlerAspect.id, new NewDevServersCreatedEvent(servers, Date.now(), this.graphql, true)); } @@ -101,6 +111,22 @@ export class BundlerMain { return servers; } + /** + * envs that failed to create a dev server on the last `devServer()` call, with the error that + * caused it. their components have no preview - everything else is unaffected. + */ + getDevServerFailures(): DevServerFailure[] { + return this._devServerFailures; + } + + /** + * ids of all envs left without a preview dev server, including the envs that were deduped into a + * failing env. useful for telling the user (or later, the UI) which components have no preview. + */ + getEnvIdsWithoutDevServer(): string[] { + return flatten(this._devServerFailures.map((failure) => [failure.envId, ...failure.relatedEnvIds])); + } + /** * get a dev server instance containing a component. * @param component diff --git a/scopes/compilation/bundler/component-server.ts b/scopes/compilation/bundler/component-server.ts index 16360e2cee62..9a3b9519b954 100644 --- a/scopes/compilation/bundler/component-server.ts +++ b/scopes/compilation/bundler/component-server.ts @@ -37,6 +37,7 @@ export class ComponentServer { hostname: string | undefined; private _server?: Server; private _isRestarting: boolean = false; + isCompiling: boolean = true; get server() { return this._server; diff --git a/scopes/compilation/bundler/dev-server.graphql.ts b/scopes/compilation/bundler/dev-server.graphql.ts index f4c25a5d143d..03eedafc46d4 100644 --- a/scopes/compilation/bundler/dev-server.graphql.ts +++ b/scopes/compilation/bundler/dev-server.graphql.ts @@ -2,7 +2,11 @@ import type { Component } from '@teambit/component'; import type { GraphqlMain, Schema } from '@teambit/graphql'; import { gql } from '@apollo/client'; import type { BundlerMain } from './bundler.main.runtime'; -import { ComponentServerStartedEvent } from './events'; +import { ComponentServerCompilationChangedEvent, ComponentServerStartedEvent } from './events'; + +function getContextRootPath(context: unknown): string | undefined { + return (context as { rootPath?: string } | undefined)?.rootPath; +} export function devServerSchema(bundler: BundlerMain, graphql: GraphqlMain): Schema { return { @@ -17,10 +21,23 @@ export function devServerSchema(bundler: BundlerMain, graphql: GraphqlMain): Sch url: String host: String basePath: String + isCompiling: Boolean + } + + type ComponentServerCompilationStatus { + env: String! + affectedEnvs: [String!] + url: String + host: String + basePath: String + isCompiling: Boolean! + errorCount: Int! + warningCount: Int! } type Subscription { componentServerStarted(id: String): [ComponentServer!]! + componentServerCompilationChanged(id: String): ComponentServerCompilationStatus } `, resolvers: { @@ -49,6 +66,9 @@ export function devServerSchema(bundler: BundlerMain, graphql: GraphqlMain): Sch id: `server-${componentServer.context.envRuntime.id}`, env: componentServer.context.envRuntime.id, url: componentServer.url, + host: componentServer.hostname, + basePath: getContextRootPath(componentServer.context), + isCompiling: !!componentServer.isCompiling, }; }, }, @@ -67,10 +87,34 @@ export function devServerSchema(bundler: BundlerMain, graphql: GraphqlMain): Sch id: `server-${server.context.envRuntime.id}`, env: server.context.envRuntime.id, url: server.url, + host: server.hostname, + basePath: getContextRootPath(server.context), + isCompiling: !!server.isCompiling, }, ]; }, }, + componentServerCompilationChanged: { + subscribe: () => graphql.pubsub.asyncIterator([ComponentServerCompilationChangedEvent]), + resolve: (payload, { id }) => { + const status = payload?.componentServerCompilation; + if (!status?.env) return null; + const affected = Array.isArray(status.affectedEnvs) ? status.affectedEnvs : []; + // an env deduplicated under another env's server publishes with that owner as + // status.env - subscribers filtering by the deduped env id match via affectedEnvs + if (id && status.env !== id && !affected.includes(id)) return null; + return { + env: status.env, + affectedEnvs: Array.isArray(status.affectedEnvs) ? status.affectedEnvs : [], + url: status.url, + host: status.host, + basePath: status.basePath, + isCompiling: !!status.isCompiling, + errorCount: Number(status.errorCount || 0), + warningCount: Number(status.warningCount || 0), + }; + }, + }, }, }, }; diff --git a/scopes/compilation/bundler/dev-server.service.ts b/scopes/compilation/bundler/dev-server.service.ts index 0534818a5c5f..336a8aac4904 100644 --- a/scopes/compilation/bundler/dev-server.service.ts +++ b/scopes/compilation/bundler/dev-server.service.ts @@ -8,7 +8,7 @@ import type { } from '@teambit/envs'; import type { PubsubMain } from '@teambit/pubsub'; import chalk from 'chalk'; -import { flatten } from 'lodash'; +import { compact, flatten } from 'lodash'; import type { DependencyResolverMain } from '@teambit/dependency-resolver'; import highlight from 'cli-highlight'; import { sep } from 'path'; @@ -22,6 +22,36 @@ import { getEntry } from './get-entry'; export type DevServerServiceOptions = { dedicatedEnvDevServers?: string[] }; +/** + * an env whose dev server could not be created. the other envs are unaffected - only the + * components of this env (and of the envs deduped into it) are left without a preview. + */ +export type DevServerFailure = { + /** + * id of the env that owns the (failed) dev server. + */ + envId: string; + + /** + * ids of the envs that were grouped into `envId` and therefore lost their preview with it. + */ + relatedEnvIds: string[]; + + error: Error; +}; + +export type DevServerRunOnceResult = { + /** + * servers that were created successfully. + */ + servers: ComponentServer[]; + + /** + * envs that failed to produce a server. empty when everything went well. + */ + failures: DevServerFailure[]; +}; + type DevServiceTransformationMap = ServiceTransformationMap & { /** * Required for `bit start` @@ -130,22 +160,78 @@ export class DevServerService implements EnvService { + ): Promise { const groupedEnvs = await dedupEnvs(contexts, this.dependencyResolver, dedicatedEnvDevServers); + const failures: DevServerFailure[] = []; // TODO: (gilad) - change this back to promise all once we make the preview pre-bundle to run before that loop const servers = await pMapSeries(Object.entries(groupedEnvs), async ([id, contextList]) => { - const mainContext = contextList.find((context) => context.envDefinition.id === id) || contextList[0]; - const additionalContexts = contextList.filter((context) => context.envDefinition.id !== id); + // one group failing used to reject the whole batch: groups handled before it had their server + // thrown away and groups after it were never attempted. keep a failure local to its group. + return this.createServerForGroup(id, contextList, failures); + }); - const devServerContext = await this.buildContext(mainContext, additionalContexts); - const devServer: DevServer = await devServerContext.envRuntime.env.getDevServer(devServerContext); - const transformedDevServer: DevServer = this.transformDevServer(devServer, { envId: id }); + return { servers: compact(servers), failures }; + } - return new ComponentServer(this.pubsub, devServerContext, [3300, 3400], transformedDevServer); - }); + /** + * create the dev server that serves a group of deduped envs. + * + * building the context bundles the group's preview runtime (`getEntry` -> pre-bundle), and that + * bundle contains the preview aspect of *every* env in the group - so one env with an import it + * cannot resolve fails the bundle for all of them. when the bundler names the envs that broke, + * drop them from the group and build again, so the envs that are fine still get a preview. envs + * that had to be dropped are recorded in `failures` and end up without a preview server at all. + */ + private async createServerForGroup( + groupId: string, + contextList: ExecutionContext[], + failures: DevServerFailure[] + ): Promise { + // `buildContext` mutates the contexts it gets (it merges the group's components into the main + // context), so keep the pristine lists around to be able to build again with a subset. + const pristineComponents = new Map(contextList.map((context) => [context, context.components])); + let remaining = contextList; + + while (remaining.length) { + const mainContext = remaining.find((context) => context.envDefinition.id === groupId) || remaining[0]; + const additionalContexts = remaining.filter((context) => context !== mainContext); + // the group is keyed by the env that owns the dev server, which is not necessarily one of the + // envs in it (it can be the env they all delegate their dev server to). keep that key unless + // the env it points at is one of the envs we had to drop. + const nothingDropped = remaining.length === contextList.length; + const envId = nothingDropped || mainContext.envDefinition.id === groupId ? groupId : mainContext.envDefinition.id; + try { + const devServerContext = await this.buildContext(mainContext, additionalContexts); + const devServer: DevServer = await devServerContext.envRuntime.env.getDevServer(devServerContext); + const transformedDevServer: DevServer = this.transformDevServer(devServer, { envId }); - return servers; + return new ComponentServer(this.pubsub, devServerContext, [3300, 3400], transformedDevServer); + } catch (error: any) { + const broken = findFailingContexts(error, remaining); + // nothing to isolate - either the bundler did not name an env of this group, or every env in + // it is broken. either way the whole group is out. + if (!broken.length || broken.length === remaining.length) { + failures.push({ + envId: mainContext.envDefinition.id, + relatedEnvIds: additionalContexts.map((context) => context.envDefinition.id), + error, + }); + return undefined; + } + broken.forEach((context) => { + failures.push({ envId: context.envDefinition.id, relatedEnvIds: [], error }); + }); + remaining = remaining.filter((context) => !broken.includes(context)); + // the failed attempt left the merged component list on the contexts - undo it, otherwise the + // next attempt would pull the envs we just dropped back in through the main context. + pristineComponents.forEach((components, context) => { + context.components = components; + }); + } + } + + return undefined; } mergeContext() {} @@ -196,3 +282,17 @@ export class DevServerService implements EnvService transformFn(updatedDevServer, { envId }), devServer); } } + +/** + * the envs of `contexts` that the bundler blamed for a failure. rspack reports every unresolved + * module as an `ERROR in ` line, and an env's preview aspect is bundled from its capsule - + * a directory named after the env id with the `/` flattened to `_`. + */ +function findFailingContexts(error: Error, contexts: ExecutionContext[]): ExecutionContext[] { + const failingModules = (error?.message || '').split('\n').filter((line) => line.startsWith('ERROR in ')); + if (!failingModules.length) return []; + return contexts.filter((context) => { + const capsuleDirName = context.envDefinition.id.replace(/\//g, '_'); + return failingModules.some((line) => line.includes(capsuleDirName)); + }); +} diff --git a/scopes/compilation/bundler/events/components-server-started-event.ts b/scopes/compilation/bundler/events/components-server-started-event.ts index 253422e3667b..38ff17b35cc6 100644 --- a/scopes/compilation/bundler/events/components-server-started-event.ts +++ b/scopes/compilation/bundler/events/components-server-started-event.ts @@ -6,6 +6,7 @@ import type { ComponentServer } from '../component-server'; import type { ExecutionContext } from '@teambit/envs'; export const ComponentServerStartedEvent = 'ComponentServerStartedEvent'; +export const ComponentServerCompilationChangedEvent = 'ComponentServerCompilationChangedEvent'; class ComponentsServerStartedEventData { constructor( diff --git a/scopes/compilation/bundler/index.ts b/scopes/compilation/bundler/index.ts index ce69c03d384d..9e652689a888 100644 --- a/scopes/compilation/bundler/index.ts +++ b/scopes/compilation/bundler/index.ts @@ -21,6 +21,7 @@ export type { EntryAssets, } from './bundler'; export type { BundlerMain } from './bundler.main.runtime'; +export type { DevServerFailure, DevServerRunOnceResult } from './dev-server.service'; export type { ComponentDir } from './get-entry'; export { ComponentServer } from './component-server'; export * from './events'; diff --git a/scopes/component/component/ui/component.module.scss b/scopes/component/component/ui/component.module.scss index 14c23ea29b0a..3eec34f23618 100644 --- a/scopes/component/component/ui/component.module.scss +++ b/scopes/component/component/ui/component.module.scss @@ -5,3 +5,228 @@ $topbarHeight: 64px; display: flex; flex-direction: column; } + +// --- Error state (offline / waiting for dev server) --- + +.bootShellViewport { + min-height: calc(100vh - #{$topbarHeight} - 26px); + width: min(100%, 1240px); + margin: 0 auto; + padding: 18px clamp(16px, 4.5vw, 64px) 26px; + display: flex; + flex-direction: column; + gap: 14px; + box-sizing: border-box; +} + +.bootShell { + width: 100%; +} + +.bootStatusRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 44px; + border: 1px solid var(--border-medium-color, #dbe0ea); + background: var(--surface-color, #fff); + border-radius: 10px; + padding: 8px 12px; +} + +.bootStatusRowError { + border-color: #ffd2d2; + background: #fff8f8; +} + +.bootStatusCopy { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.bootStatusBadge { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 3px 10px; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.bootStatusBadgeError { + color: #ab1a1a; + border: 1px solid #ffc3c3; + background: #ffecec; +} + +.bootStatusText { + font-size: 12px; + color: var(--on-surface-medium-color, #6c707c); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.bootShellTitle { + margin: 0 0 8px; + font-size: 20px; + line-height: 1.3; +} + +.bootShellText { + margin: 0; + max-width: 760px; + color: var(--on-surface-medium-color, #6c707c); +} + +.bootShellAction { + margin-top: 14px; + border: 1px solid var(--border-medium-color, #d6d9e2); + border-radius: 8px; + height: 32px; + min-width: 96px; + padding: 0 12px; + font-size: 12px; + font-weight: 600; + color: var(--on-surface-medium-color, #4f5561); + background: var(--surface-color, #fff); + cursor: pointer; +} + +// --- Skeleton loading state --- + +@keyframes skeleton-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} + +@mixin shimmer { + border-radius: 6px; + background: linear-gradient(90deg, #f0f0f0 25%, #e4e4e4 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: skeleton-shimmer 1.2s ease-in-out infinite; +} + +.skeletonViewport { + display: flex; + flex-direction: column; + height: 100%; +} + +.skeletonTopBar { + display: flex; + align-items: center; + justify-content: space-between; + height: 48px; + padding: 0 16px; + border-bottom: 1px solid var(--border-light-color, #edeff4); + flex-shrink: 0; +} + +.skeletonNavTabs { + display: flex; + align-items: center; + gap: 6px; +} + +.skeletonNavRight { + display: flex; + align-items: center; + gap: 8px; +} + +.skeletonTab { + height: 26px; + border-radius: 4px; + @include shimmer; +} + +.skeletonContent { + flex: 1; + padding: 24px clamp(16px, 4vw, 48px); + display: flex; + flex-direction: column; + gap: 20px; + max-width: 1200px; + width: 100%; + box-sizing: border-box; +} + +.skeletonHero { + width: 100%; + height: clamp(200px, 34vh, 340px); + border-radius: 8px; + @include shimmer; +} + +.skeletonSection { + display: flex; + flex-direction: column; + gap: 10px; +} + +.skeletonBar { + height: 12px; + border-radius: 6px; + @include shimmer; +} + +.skeletonContentGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; + margin-top: 6px; +} + +.skeletonCardWide { + height: 120px; + border-radius: 8px; + @include shimmer; +} + +// --- Responsive --- + +@media (max-width: 1024px) { + .bootShellViewport { + min-height: calc(100vh - #{$topbarHeight} - 10px); + } + + .bootStatusRow { + align-items: flex-start; + flex-direction: column; + } + + .bootStatusCopy { + width: 100%; + } + + .bootStatusText { + white-space: normal; + } + + .skeletonContentGrid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 640px) { + .skeletonNavRight { + display: none; + } + + .skeletonHero { + height: 180px; + } + + .skeletonCardWide { + height: 100px; + } +} diff --git a/scopes/component/component/ui/component.tsx b/scopes/component/component/ui/component.tsx index d1ed41ff0a6e..fb0497aea9b2 100644 --- a/scopes/component/component/ui/component.tsx +++ b/scopes/component/component/ui/component.tsx @@ -64,18 +64,8 @@ export function Component({ const host = componentVersion ? 'teambit.scope/scope' : hostFromProps; const useComponentOptions = { - logFilters: { - ...componentFiltersFromProps, - ...(componentFiltersFromProps.loading - ? {} - : { - log: { - // @todo - enable this when we have lazy loading of logs - // limit: 3, - ...componentFiltersFromProps.log, - }, - }), - }, + logFilters: componentFiltersFromProps, + skip: !!componentFiltersFromProps.loading, customUseComponent: useComponent, }; @@ -94,8 +84,56 @@ export function Component({ const before = useMemo(() => pageItems.filter((x) => x.type === 'before').map((x) => x.content), [pageItems]); const after = useMemo(() => pageItems.filter((x) => x.type === 'after').map((x) => x.content), [pageItems]); - if (error) return error?.renderError(); - if (!component) return
; + if (error?.code === 404) return error?.renderError(); + if (error) { + return ( +
+
+
+ Offline + + Component view is temporarily unavailable. Waiting for the dev server to respond. + +
+ +
+
+

Waiting for component data

+

+ Waiting for the dev server to respond. Your page will recover automatically. +

+
+
+ ); + } + + if (!component) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); + } return ( diff --git a/scopes/component/component/ui/menu/menu.tsx b/scopes/component/component/ui/menu/menu.tsx index 22825c34b1eb..cc1088c8337e 100644 --- a/scopes/component/component/ui/menu/menu.tsx +++ b/scopes/component/component/ui/menu/menu.tsx @@ -1,9 +1,10 @@ import type { ReactNode } from 'react'; import React, { useMemo } from 'react'; import { Routes, Route } from 'react-router-dom'; +import { gql, useQuery as useApolloQuery } from '@apollo/client'; import classnames from 'classnames'; -import { useQuery } from '@teambit/ui-foundation.ui.react-router.use-query'; -import { compact, flatten, groupBy, isFunction, orderBy } from 'lodash'; +import { useQuery as useRouterQuery } from '@teambit/ui-foundation.ui.react-router.use-query'; +import { flatten, groupBy, isFunction, orderBy } from 'lodash'; import * as semver from 'semver'; import type { SlotRegistry } from '@teambit/harmony'; import type { DropdownComponentVersion, GetActiveTabIndex } from '@teambit/component.ui.version-dropdown'; @@ -21,6 +22,7 @@ import type { LegacyComponentLog } from '@teambit/legacy-component-log'; import { useWorkspaceMode } from '@teambit/workspace.ui.use-workspace-mode'; import type { UseComponentType, Filters } from '../use-component'; import { useComponent as useComponentQuery } from '../use-component'; +import { useComponentLogs } from '../use-component-logs'; import { CollapsibleMenuNav } from './menu-nav'; import type { OrderedNavigationSlot, @@ -132,7 +134,7 @@ export function ComponentMenu({ [pinnedWidgetSlot] ); const componentFilters = useComponentFilters?.() || {}; - const query = useQuery(); + const query = useRouterQuery(); const componentVersion = query.get('version'); const host = componentVersion ? 'teambit.scope/scope' : hostFromProps; @@ -206,6 +208,7 @@ export type UseComponentVersionsProps = { skip?: boolean; id?: string; initialLoad?: boolean; + fetchLogs?: boolean; }; export type UseComponentVersionProps = { skip?: boolean; @@ -216,6 +219,7 @@ export type UseComponentVersion = (props?: UseComponentVersionProps) => Dropdown export type UseComponentVersionsResult = { tags?: DropdownComponentVersion[]; snaps?: DropdownComponentVersion[]; + hasMoreVersions?: boolean; id?: ComponentID; packageName?: string; latest?: string; @@ -223,6 +227,27 @@ export type UseComponentVersionsResult = { loading?: boolean; }; +const GET_COMPONENT_VERSION_METADATA = gql` + query ComponentVersionMetadata($extensionId: String!, $id: String!) { + getHost(id: $extensionId) { + id + get(id: $id) { + id { + scope + name + version + } + packageName + latest + buildStatus + tags { + version + } + } + } + } +`; + export function defaultLoadVersions( host: string, componentId?: string, @@ -232,28 +257,97 @@ export function defaultLoadVersions( ): UseComponentVersions { return React.useCallback( (_props) => { - const { skip, initialLoad } = _props || {}; - const fetchOptions = { - logFilters: { - ...componentFilters, - log: { - ...componentFilters.log, - limit: initialLoad ? 3 : undefined, + const { skip, initialLoad, fetchLogs = false } = _props || {}; + const shouldFetchLogs = fetchLogs && !loadingFromProps && !skip && !componentFilters.loading; + const shouldSkip = loadingFromProps || skip || !!componentFilters.loading || !componentId; + + const { data: componentMetadataData, loading: loadingComponentMetadata } = useApolloQuery( + GET_COMPONENT_VERSION_METADATA, + { + variables: { + extensionId: host, + id: componentId, }, - }, - skip: loadingFromProps || skip, + skip: shouldSkip, + fetchPolicy: 'cache-and-network', + nextFetchPolicy: 'cache-first', + errorPolicy: 'all', + returnPartialData: true, + notifyOnNetworkStatusChange: false, + context: { skipBatch: true }, + } + ); + + const componentFromMetadata = componentMetadataData?.getHost?.get; + + const fallbackQueryResult = useComponentQuery(host, componentId, { + skip: shouldSkip || !useComponent, customUseComponent: useComponent, - }; - const { - component, - loading: loadingComponent, - componentLogs = {}, - } = useComponentQuery(host, componentId, fetchOptions); - const logs = componentLogs?.logs; - const loading = React.useMemo( - () => loadingComponent || loadingFromProps || componentLogs.loading, - [loadingComponent, loadingFromProps, componentLogs.loading] + logFilters: componentFilters, + }); + + const component = useMemo(() => { + if (useComponent) return fallbackQueryResult.component; + if (!componentFromMetadata?.id) return undefined; + + const id = ComponentID.fromObject(componentFromMetadata.id); + const tagsMap = (componentFromMetadata.tags || []).map((tag) => ({ + version: { + version: tag.version, + }, + hash: tag.version, + })); + + return { + id, + packageName: componentFromMetadata.packageName, + latest: componentFromMetadata.latest, + version: id.version || 'new', + buildStatus: componentFromMetadata.buildStatus, + tags: { + toArray: () => tagsMap, + }, + }; + }, [useComponent, fallbackQueryResult.component, componentFromMetadata]); + + const loadingComponent = useComponent ? fallbackQueryResult.loading : loadingComponentMetadata; + + const logsFilter = shouldFetchLogs + ? { + ...componentFilters, + log: { + ...componentFilters.log, + limit: initialLoad ? 3 : undefined, + }, + } + : undefined; + + const { componentLogs = {}, loading: loadingLogs } = useComponentLogs( + componentId || '', + host, + logsFilter, + !shouldFetchLogs || shouldSkip ); + const logs = componentLogs?.logs; + + const tagVersionsFromComponent = useMemo(() => { + if (!component?.tags?.toArray) return []; + return (component.tags.toArray() || []) + .slice() + .reverse() + .map((tag) => { + const version = tag.version.version; + return { version, tag: version } as DropdownComponentVersion; + }); + }, [ + component?.id?.toString(), + // content-sensitive: cache-and-network can replace the tag list with a + // different set of the same length + component?.tags + ?.toArray?.() + ?.map((tag) => tag.version.version) + .join(), + ]); const snaps = useMemo(() => { return (logs || []).filter((log) => !log.tag).map((snap) => ({ ...snap, version: snap.hash })); @@ -266,30 +360,55 @@ export function defaultLoadVersions( .forEach((tag) => { tagLookup.set(tag?.tag as string, tag); }); - return compact( - (component?.tags?.toArray() || []).reverse().map((tag) => tagLookup.get(tag.version.version)) - ).map((tag) => ({ ...tag, version: tag.tag as string })); - }, [logs]); + return tagVersionsFromComponent.map((tagVersion) => { + const logTag = tagLookup.get(tagVersion.tag as string); + return logTag ? ({ ...logTag, version: logTag.tag as string } as DropdownComponentVersion) : tagVersion; + }); + }, [logs, tagVersionsFromComponent, component?.id?.toString?.()]); + + const loading = React.useMemo(() => { + const logsLoadingWithoutAnyData = !!loadingLogs && tags.length === 0 && snaps.length === 0; + return loadingComponent || !!loadingFromProps || (shouldFetchLogs && logsLoadingWithoutAnyData); + }, [loadingComponent, loadingFromProps, loadingLogs, tags.length, snaps.length, shouldFetchLogs]); + + const hasMoreVersions = React.useMemo(() => { + if (shouldFetchLogs) { + return snaps.length + tags.length > 1; + } + if (tagVersionsFromComponent.length > 1) return true; + if (tagVersionsFromComponent.length === 1) return false; + if (!component?.id?.version || component?.version === 'new') return false; + return undefined; + }, [ + shouldFetchLogs, + snaps.length, + tags.length, + tagVersionsFromComponent.length, + component?.id?.version, + component?.version, + ]); return { loading, id: component?.id, packageName: component?.packageName, - latestVersion: component?.latest, + latest: component?.latest, currentVersion: component?.version, snaps, tags, + hasMoreVersions, buildStatus: component?.buildStatus, }; }, - [componentId, loadingFromProps, componentFilters, host] + [componentId, loadingFromProps, componentFilters, host, useComponent] ); } export const defaultLoadCurrentVersion: (props: VersionRelatedDropdownsProps) => UseComponentVersion = (props) => { return (_props) => { const { skip, version: _version } = _props || {}; - const { snaps, tags, currentVersion, loading } = props.useComponent?.({ skip, id: props.componentId }) ?? {}; + const { snaps, tags, currentVersion, loading } = + props.useComponent?.({ skip, id: props.componentId, fetchLogs: true }) ?? {}; const version = _version ?? currentVersion; const isTag = React.useMemo(() => semver.valid(version), [loading, version]); if (isTag) { @@ -318,9 +437,10 @@ export function VersionRelatedDropdowns(props: VersionRelatedDropdownsProps) { tags, snaps, latest, + hasMoreVersions, packageName, currentVersion: _currentVersion, - } = props.useComponent?.({ initialLoad: true }) || {}; + } = props.useComponent?.({ initialLoad: true, fetchLogs: false }) || {}; const location = useLocation(); const { lanesModel } = useLanes(); const lanes = id ? lanesModel?.getLanesByComponentId(id as any)?.filter((lane) => !lane.id.isDefault()) || [] : []; @@ -329,12 +449,12 @@ export function VersionRelatedDropdowns(props: VersionRelatedDropdownsProps) { const isWorkspace = host === 'teambit.workspace/workspace'; - const isNew = tags?.length === 0 && snaps?.length === 0; + const isNew = hasMoreVersions === false && tags?.length === 0 && snaps?.length === 0; const localVersion = isWorkspace && !isNew && (!viewedLane || lanesModel?.isViewingCurrentLane()); const currentVersion = - isWorkspace && !isNew && !location?.search.includes('version') ? 'workspace' : (_currentVersion ?? ''); + isWorkspace && !isNew && !location?.search.includes('version') ? 'workspace' : (_currentVersion ?? 'new'); const authToken = props.authToken; @@ -366,7 +486,7 @@ export function VersionRelatedDropdowns(props: VersionRelatedDropdownsProps) { lanes={lanes} loading={loading} useComponentVersions={props.useComponent} - hasMoreVersions={!isNew} + hasMoreVersions={hasMoreVersions} useCurrentVersionLog={loadVersion} localVersion={localVersion} currentVersion={currentVersion} diff --git a/scopes/component/component/ui/use-component-logs.ts b/scopes/component/component/ui/use-component-logs.ts index a1b6d15c4e35..bb69b0a89cd4 100644 --- a/scopes/component/component/ui/use-component-logs.ts +++ b/scopes/component/component/ui/use-component-logs.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import type { LegacyComponentLog } from '@teambit/legacy-component-log'; -import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; +import { useQuery } from '@apollo/client'; import type { ComponentLogsResult, Filters } from './use-component.model'; import { GET_COMPONENT_WITH_LOGS } from './use-component.fragments'; import { ComponentError } from './component-error'; @@ -16,11 +16,15 @@ export function useComponentLogs( ): ComponentLogsResult { const { variables, skip } = useComponentLogsInit(componentId, host, filters, skipFromProps); - const { data, error, loading } = useDataQuery(GET_COMPONENT_WITH_LOGS, { + const { data, error, loading } = useQuery(GET_COMPONENT_WITH_LOGS, { variables, skip, errorPolicy: 'all', - context, + fetchPolicy: 'cache-and-network', + nextFetchPolicy: 'cache-first', + returnPartialData: true, + notifyOnNetworkStatusChange: false, + context: { skipBatch: true, ...context }, }); const rawComponent = data?.getHost?.get; diff --git a/scopes/component/component/ui/use-component-query.ts b/scopes/component/component/ui/use-component-query.ts index 0ea8d3b79165..c40eda7c879f 100644 --- a/scopes/component/component/ui/use-component-query.ts +++ b/scopes/component/component/ui/use-component-query.ts @@ -1,5 +1,5 @@ import { useMemo, useRef } from 'react'; -import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query'; +import { useQuery } from '@apollo/client'; import { ComponentID } from '@teambit/component-id'; import { ComponentDescriptor } from '@teambit/component-descriptor'; import { ComponentModel } from './component-model'; @@ -28,18 +28,24 @@ export function useComponentQuery( extensionId: host, }; - const { data, error, loading } = useDataQuery(GET_COMPONENT, { + // Use useQuery directly to avoid global loader side effects from useDataQuery. + // cache-and-network gives instant cache hydration, then reconciles with current server data. + const { data, error, loading } = useQuery(GET_COMPONENT, { variables, skip, errorPolicy: 'all', - context, + fetchPolicy: 'cache-and-network', + nextFetchPolicy: 'cache-first', + returnPartialData: true, + notifyOnNetworkStatusChange: false, + context: { skipBatch: true, ...context }, }); // Only fetch logs when a log filter is explicitly provided — most callers (lane-compare, // bulk component panels) never look at history, so the per-component logs query was firing // for nothing. Pages that need the history panel pass `filters: { log: {...} }` and // `useComponentLogs` runs as before. - const wantsLogs = !!filters?.log; + const wantsLogs = !!filters?.log && !filters?.loading; const { loading: loadingLogs, componentLogs: { logs } = {} } = useComponentLogs( componentId, host, @@ -66,12 +72,12 @@ export function useComponentQuery( const component = useMemo( () => (rawComponent ? ComponentModel.from({ ...rawComponent, host, logs }) : undefined), - [id?.toString(), logs] + [id?.toString(), logs, rawComponent, host] ); const componentDescriptor = useMemo(() => { const aspectList = { - entries: rawComponent?.aspects.map((aspectObject) => { + entries: (rawComponent?.aspects || []).map((aspectObject) => { return { ...aspectObject, aspectId: aspectObject.id, @@ -81,7 +87,7 @@ export function useComponentQuery( }; return id ? ComponentDescriptor.fromObject({ id: id.toString(), aspectList }) : undefined; - }, [id?.toString()]); + }, [id?.toString(), rawComponent?.aspects]); return useMemo(() => { return { @@ -94,5 +100,5 @@ export function useComponentQuery( error: componentError || undefined, loading, }; - }, [host, component, componentDescriptor, componentError]); + }, [component, componentDescriptor, componentError, loading, loadingLogs, logs]); } diff --git a/scopes/harmony/graphql/graphql.ui.runtime.tsx b/scopes/harmony/graphql/graphql.ui.runtime.tsx index 020450f04864..973a76e64ad7 100644 --- a/scopes/harmony/graphql/graphql.ui.runtime.tsx +++ b/scopes/harmony/graphql/graphql.ui.runtime.tsx @@ -2,21 +2,76 @@ import type { ReactNode } from 'react'; import React from 'react'; import { UIRuntime } from '@teambit/ui'; import { BatchHttpLink } from '@apollo/client/link/batch-http'; -import { InMemoryCache, ApolloClient, ApolloLink, HttpLink } from '@apollo/client'; -import type { DefaultOptions, NormalizedCacheObject, Operation } from '@apollo/client'; +import { InMemoryCache, ApolloClient, ApolloLink, HttpLink, Observable } from '@apollo/client'; +import type { NormalizedCacheObject, Operation } from '@apollo/client'; import { WebSocketLink } from '@apollo/client/link/ws'; import { onError } from '@apollo/client/link/error'; +import { RetryLink } from '@apollo/client/link/retry'; import { getMainDefinition } from '@apollo/client/utilities'; import type { OperationDefinitionNode } from 'graphql'; import crossFetch from 'cross-fetch'; +import { persistCache, LocalStorageWrapper } from 'apollo3-cache-persist'; + import { createSplitLink } from './create-link'; import { GraphQLProvider } from './graphql-provider'; import { GraphqlAspect } from './graphql.aspect'; import { GraphqlRenderPlugins } from './render-lifecycle'; import { logError } from './logging'; +const CONNECTION_STATUS_EVENT = 'bit-dev-server-connection-status'; + +function reportConnectionStatus(online: boolean, reason?: 'network' | 'preview') { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(CONNECTION_STATUS_EVENT, { + detail: { online, reason, timestamp: Date.now() }, + }) + ); +} + +function sanitizeRestoredApolloCache(cacheData: NormalizedCacheObject) { + let clearedServerField = 0; + let clearedPreviewUrl = 0; + let clearedCompilingFlag = 0; + + // Auth state must never survive a session boundary through the persisted cache: + // a stale getCurrentUser can show the previous account after logout. Evict it so + // the user lookup always revalidates over the network while the rest stays warm. + const rootQuery = cacheData.ROOT_QUERY as Record | undefined; + if (rootQuery) { + for (const key of Object.keys(rootQuery)) { + if (key === 'getCurrentUser' || key.startsWith('getCurrentUser(')) delete rootQuery[key]; + } + } + + for (const key of Object.keys(cacheData)) { + const entry = cacheData[key] as Record | undefined; + if (!entry || typeof entry !== 'object') continue; + + // `Component.server` is runtime-volatile by nature. + // Keeping stale server blocks (url/isCompiling) across process restarts causes + // false online states and stale preview readiness during cache hydration. + if (entry.server && typeof entry.server === 'object') { + delete entry.server; + clearedServerField += 1; + } + + if (entry.url && typeof entry.url === 'string' && entry.url.startsWith('/preview/')) { + entry.url = null; + clearedPreviewUrl += 1; + } + + if (typeof entry.isCompiling === 'boolean') { + delete entry.isCompiling; + clearedCompilingFlag += 1; + } + } + + return { clearedServerField, clearedPreviewUrl, clearedCompilingFlag }; +} + /** * Type of gql client. * Used to abstract Apollo client, so consumers could import the type from graphql.ui, and not have to depend on @apollo/client directly @@ -48,26 +103,64 @@ export type GraphQLConfig = { export class GraphqlUI { constructor(readonly config: GraphQLConfig = {}) {} - createClient(uri: string, { state, subscriptionUri, host }: ClientOptions = {}) { - const defaultOptions: DefaultOptions | undefined = - host === 'teambit.workspace/workspace' - ? { - query: { - fetchPolicy: 'network-only', - }, - watchQuery: { - fetchPolicy: 'network-only', - }, - mutate: { - fetchPolicy: 'network-only', - }, + async createClient(uri: string, { state, subscriptionUri, host }: ClientOptions = {}) { + const cache = this.createCache({ state }); + + // Persist Apollo cache to localStorage for instant workspace reloads. + // On refresh, data renders from cache immediately while network refreshes in background. + if (typeof window !== 'undefined') { + try { + const workspaceKeyRaw = + (window as Window & { __BIT_WORKSPACE_CACHE_KEY__?: string }).__BIT_WORKSPACE_CACHE_KEY__ || + host || + 'default'; + const workspaceKey = String(workspaceKeyRaw) + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .slice(0, 80); + const originKey = window.location.host.replace(/[^a-z0-9_-]+/gi, '_'); + const t0 = performance.now(); + await persistCache({ + // in a build capsule, apollo3-cache-persist resolves its own @apollo/client peer + // instance (a second pnpm copy with a different peer hash), so our InMemoryCache is not + // assignable to *its* ApolloCache declaration - the types are identical but nominally + // distinct. runtime-wise it is the same object shape either way. + cache: cache as unknown as Parameters[0]['cache'], + storage: new LocalStorageWrapper(window.localStorage), + key: `apollo-cache-${originKey}-${workspaceKey}`, + maxSize: 1048576 * 5, // 5MB + debounce: 1000, + }); + const cacheData = cache.extract(); + const cacheEntries = Object.keys(cacheData).length; + // oxlint-disable-next-line no-console + console.log(`[apollo-cache] restored ${cacheEntries} entries in ${(performance.now() - t0).toFixed(0)}ms`); + + // Clear volatile preview-server state from restored cache. + // We keep stable metadata (names/compositions/etc.) for instant render, but force + // runtime preview readiness/compilation state to come from fresh network/session events. + if (cacheEntries > 0) { + const { clearedServerField, clearedPreviewUrl, clearedCompilingFlag } = + sanitizeRestoredApolloCache(cacheData); + const clearedTotal = clearedServerField + clearedPreviewUrl + clearedCompilingFlag; + if (clearedTotal > 0) { + cache.restore(cacheData); + // oxlint-disable-next-line no-console + console.log( + `[apollo-cache] sanitized volatile fields (server=${clearedServerField}, previewUrl=${clearedPreviewUrl}, isCompiling=${clearedCompilingFlag})` + ); } - : undefined; + } + } catch { + // localStorage may be full or unavailable — continue without persistence + } + } + const client = new ApolloClient({ link: this.createLink(uri, { subscriptionUri }), - cache: this.createCache({ state }), - defaultOptions, + cache, }); + reportConnectionStatus(true, 'network'); return client; } @@ -135,6 +228,27 @@ export class GraphqlUI { return op.getContext().batch === true; }; + private createConnectionReporterLink() { + return new ApolloLink((operation, forward) => { + if (!forward) return null; + const observable = forward(operation); + return new Observable((observer) => { + const subscription = observable.subscribe({ + next: (result) => { + reportConnectionStatus(true, 'network'); + observer.next(result); + }, + error: (error) => observer.error(error), + complete: () => observer.complete(), + }); + + return () => { + subscription?.unsubscribe?.(); + }; + }); + }); + } + private createLink(uri: string, { subscriptionUri }: { subscriptionUri?: string } = {}) { const httpLink = new HttpLink({ credentials: 'include', uri }); const batchHttpLink = new BatchHttpLink({ @@ -150,7 +264,28 @@ export class GraphqlUI { : undefined; const hybridLink = subsLink ? createSplitLink(httpOrBatchLink, subsLink) : httpOrBatchLink; - return ApolloLink.from([onError(logError), hybridLink]); + // Retry transient network failures (dev server restarts, brief disconnections). + // Only retries queries/subscriptions — mutations are not retried (not idempotent). + const retryLink = new RetryLink({ + delay: { initial: 300, max: 5000, jitter: true }, + attempts: { + max: 5, + retryIf: (error, operation) => { + const def = getMainDefinition(operation.query) as OperationDefinitionNode; + if (def.kind === 'OperationDefinition' && def.operation === 'mutation') return false; + return !!error; + }, + }, + }); + + const errorLogger = onError((error) => { + logError(error); + if (error.networkError) reportConnectionStatus(false, 'network'); + }); + + const connectionReporter = this.createConnectionReporterLink(); + + return ApolloLink.from([retryLink, errorLogger, connectionReporter, hybridLink]); } getProvider = ({ client, children }: { client: GraphQLClient; children: ReactNode }) => { diff --git a/scopes/harmony/graphql/render-lifecycle.tsx b/scopes/harmony/graphql/render-lifecycle.tsx index cf047ae38fe4..45befba527ce 100644 --- a/scopes/harmony/graphql/render-lifecycle.tsx +++ b/scopes/harmony/graphql/render-lifecycle.tsx @@ -71,19 +71,18 @@ export class GraphqlRenderPlugins implements SSR.RenderPlugin | undefined = undefined; - browserInit = ({ state }: { state?: NormalizedCacheObject } = {}, { host }: { host?: string } = {}) => { + browserInit = async ({ state }: { state?: NormalizedCacheObject } = {}, { host }: { host?: string } = {}) => { const { location } = window; const isInsecure = location.protocol === 'http:'; const wsUrl = `${isInsecure ? 'ws:' : 'wss:'}//${location.host}/subscriptions`; - const client = this.graphqlUI.createClient('/graphql', { state, subscriptionUri: wsUrl, host }); + const client = await this.graphqlUI.createClient('/graphql', { state, subscriptionUri: wsUrl, host }); this._client = client; return { client }; }; getClient() { - if (!this._client) return this.browserInit().client; return this._client; } diff --git a/scopes/harmony/pubsub/pubsub.preview.runtime.ts b/scopes/harmony/pubsub/pubsub.preview.runtime.ts index d680d713d75b..42b75cbe4185 100644 --- a/scopes/harmony/pubsub/pubsub.preview.runtime.ts +++ b/scopes/harmony/pubsub/pubsub.preview.runtime.ts @@ -47,6 +47,24 @@ export class PubsubPreview { } } + /** + * A pooled grid thumbnail realm (hash carries `thumbnail=true`, set by preview-canvas.ts). Its + * host creates iframes with raw DOM and never answers the penpal handshake, so connecting would + * only burn ten 300ms-timeout retries during boot. Size reaches the pool as a plain postMessage + * (see preview.preview.runtime) and errors via the injected error-reporting script - neither + * needs this connection. The pool never re-points a realm at a non-thumbnail hash, so skipping + * the connection for the realm's lifetime is safe. + */ + private isThumbnailRealm() { + try { + if (!isBrowser) return false; + const [, query = ''] = (window.location.hash || '').slice(1).split('?'); + return new URLSearchParams(query).get('thumbnail') === 'true'; + } catch { + return false; + } + } + private connectToParentPubSub = (retries = 10): Promise => { if (retries <= 0) throw new PubSubNoParentError(); @@ -73,7 +91,7 @@ export class PubsubPreview { static async provider(): Promise { const pubsubPreview = new PubsubPreview(); - if (pubsubPreview.inIframe()) { + if (pubsubPreview.inIframe() && !pubsubPreview.isThumbnailRealm()) { pubsubPreview.connectToParentPubSub().catch((err) => { // parent window is not required to accept connections if (err instanceof PubSubNoParentError) return; diff --git a/scopes/lanes/lanes/lanes.graphql.ts b/scopes/lanes/lanes/lanes.graphql.ts index 5c087c72dfad..fbb95db2dc2b 100644 --- a/scopes/lanes/lanes/lanes.graphql.ts +++ b/scopes/lanes/lanes/lanes.graphql.ts @@ -8,6 +8,26 @@ import { flatten, slice } from 'lodash'; import type { LaneComponentDiffStatus, LaneDiffStatus, LaneDiffStatusOptions, LanesMain } from './lanes.main.runtime'; export function lanesSchema(lanesMainRuntime: LanesMain): Schema { + type LaneResolverCacheState = { + laneComponentIdsByLane: Map>; + laneComponentsByLane: Map>; + laneReadmeByLane: Map>; + }; + + const getLaneResolverCacheState = (context: any): LaneResolverCacheState => { + if (!context.__lanesResolverCacheState) { + context.__lanesResolverCacheState = { + laneComponentIdsByLane: new Map>(), + laneComponentsByLane: new Map>(), + laneReadmeByLane: new Map>(), + }; + } + + return context.__lanesResolverCacheState as LaneResolverCacheState; + }; + + const getLaneCacheKey = (lane: LaneData) => lane.id.toString(); + return { typeDefs: gql` type FileDiff { @@ -293,12 +313,28 @@ export function lanesSchema(lanesMainRuntime: LanesMain): Schema { }, Lane: { id: (lane: LaneData) => lane.id.toObject(), - laneComponentIds: async (lane: LaneData) => { - const componentIds = await lanesMainRuntime.getLaneComponentIds(lane); + laneComponentIds: async (lane: LaneData, _args, context) => { + const cache = getLaneResolverCacheState(context); + const laneKey = getLaneCacheKey(lane); + let componentIdsPromise = cache.laneComponentIdsByLane.get(laneKey); + if (!componentIdsPromise) { + componentIdsPromise = lanesMainRuntime.getLaneComponentIds(lane); + cache.laneComponentIdsByLane.set(laneKey, componentIdsPromise); + } + + const componentIds = await componentIdsPromise; return componentIds.map((componentId) => componentId.toObject()); }, - components: async (lane: LaneData) => { - const laneComponents = await lanesMainRuntime.getLaneComponentModels(lane); + components: async (lane: LaneData, _args, context) => { + const cache = getLaneResolverCacheState(context); + const laneKey = getLaneCacheKey(lane); + let laneComponentsPromise = cache.laneComponentsByLane.get(laneKey); + if (!laneComponentsPromise) { + laneComponentsPromise = lanesMainRuntime.getLaneComponentModels(lane); + cache.laneComponentsByLane.set(laneKey, laneComponentsPromise); + } + + const laneComponents = await laneComponentsPromise; return laneComponents; }, updateDependentIds: async (lane: LaneData) => { @@ -308,8 +344,16 @@ export function lanesSchema(lanesMainRuntime: LanesMain): Schema { updateDependentComponents: async (lane: LaneData) => { return lanesMainRuntime.getLaneUpdateDependentComponents(lane); }, - readmeComponent: async (lane: LaneData) => { - const laneReadmeComponent = await lanesMainRuntime.getLaneReadmeComponent(lane); + readmeComponent: async (lane: LaneData, _args, context) => { + const cache = getLaneResolverCacheState(context); + const laneKey = getLaneCacheKey(lane); + let laneReadmePromise = cache.laneReadmeByLane.get(laneKey); + if (!laneReadmePromise) { + laneReadmePromise = lanesMainRuntime.getLaneReadmeComponent(lane); + cache.laneReadmeByLane.set(laneKey, laneReadmePromise); + } + + const laneReadmeComponent = await laneReadmePromise; return laneReadmeComponent; }, createdAt: async (lane: LaneData) => { diff --git a/scopes/preview/preview/generate-link.ts b/scopes/preview/preview/generate-link.ts index 62eb778c4332..09480be9085a 100644 --- a/scopes/preview/preview/generate-link.ts +++ b/scopes/preview/preview/generate-link.ts @@ -1,5 +1,5 @@ import type { ComponentMap } from '@teambit/component'; -import { join, relative } from 'path'; +import { join } from 'path'; import { outputFileSync } from 'fs-extra'; import normalizePath from 'normalize-path'; import objectHash from 'object-hash'; @@ -61,32 +61,23 @@ export function generateLink( return { envId, varName, resolveFrom }; }); const moduleImports = getModuleImports(moduleLinks, tempPackageDir); - const acceptedDependencies = useSource - ? Array.from( - new Set([ - ...componentLinks.flatMap((link) => - link.modules.map((module) => toWebpackRequestId(module.resolveFrom, workspacePath)) - ), - ...(moduleImports.tempFilePath ? [toWebpackRequestId(moduleImports.tempFilePath, workspacePath)] : []), - ]) - ) - : []; + // The link file is the graph parent of every component module it imports, which makes + // it the hot boundary for updates react-refresh can't take (a module whose exports are + // not all React components — e.g. a composition exporting a live-controls config — + // is not a refresh boundary, so its update bubbles up; unhandled, it reaches the entry + // and forces a full page reload). The boundary must be a no-argument self-accept: + // listing the imported modules explicitly makes the bundler resolve every listed file + // into a dependency of this entry — hundreds of composition/docs modules get pulled + // into the initial build, which bloats it and breaks lazy per-component loading + // (webpack-served envs visibly regressed). Self-accept adds no dependencies: a + // bubbling child disposes and re-executes this module, and the dispose data below + // marks the re-run so the bootstrap re-initializes and notifies the preview UI. const sourceModeBootstrap = ` -function __bitActivePreviewName() { - try { - const { hash } = window.location; - if (!hash) return null; - const [, query = ""] = hash.slice(1).split("?"); - const params = new URLSearchParams(query); - return params.get("preview"); - } catch { - return null; - } -} - let __bitInitialized = false; async function __bitMaybeInitialize(force = false, shouldNotify = false) { + // a deferred thumbnail link stays uninitialized until a hashchange asks for its preview + if (__bitThumbnailDefer()) return; if (__bitInitialized && !force) return; __bitInitialized = true; // Always call initializeModules() so linkModules runs for every preview @@ -97,9 +88,11 @@ async function __bitMaybeInitialize(force = false, shouldNotify = false) { await initializeModules(); if (shouldNotify) { // Only the active preview dispatches the update event so unrelated previews - // don't cause extra rerenders during HMR. + // don't cause extra rerenders during HMR. A realm whose hash names no preview + // is showing the default one (e.g. overview pages) — it must notify too, or + // docs edits never re-render there. const activePreview = __bitActivePreviewName(); - if (activePreview === ${JSON.stringify(prefix)}) { + if (activePreview === ${JSON.stringify(prefix)} || activePreview === null) { window.dispatchEvent( new CustomEvent('bit-preview-modules-updated', { detail: { previewName: ${JSON.stringify(prefix)} }, @@ -109,20 +102,20 @@ async function __bitMaybeInitialize(force = false, shouldNotify = false) { } } -const __bitHot = - import.meta.webpackHot - || (typeof module !== 'undefined' && module.hot) - || undefined; - -if (__bitHot) { - __bitHot.accept(${JSON.stringify(acceptedDependencies)}, () => { - __bitInitialized = false; - void __bitMaybeInitialize(true, true); - }); - __bitHot.dispose(() => { - __bitInitialized = false; +// The literal \`import.meta.webpackHot\` member expression is required: bundlers wire +// hot acceptance by static analysis of exactly that form; an alias is invisible to it +// and updates bubble past this file into a full reload. +if (import.meta.webpackHot) { + import.meta.webpackHot.accept(); + import.meta.webpackHot.dispose((data) => { + data.bitHmrRerun = true; }); } +if (import.meta.webpackHot && import.meta.webpackHot.data && import.meta.webpackHot.data.bitHmrRerun) { + // this evaluation is an HMR re-execution after a child module updated — + // re-link the fresh modules and tell the preview UI to re-render. + void __bitMaybeInitialize(true, true); +} // Defer source-mode initialization until after webpack marks the current entry // chunk as loaded. Otherwise modules placed in the current entry chunk can be @@ -138,9 +131,50 @@ window.addEventListener('hashchange', () => { const runtimeBootstrap = useSource ? sourceModeBootstrap : ` -(async function initializeModulesOnLoad() { +let __bitInitialized = false; +async function __bitInitializeOnce(force = false, shouldNotify = false) { + if (__bitInitialized && !force) return; + __bitInitialized = true; await initializeModules(); -})(); + if (shouldNotify) { + // Only the active preview dispatches the update event so unrelated previews + // don't cause extra rerenders during HMR. A realm whose hash names no preview + // is showing the default one (e.g. overview pages) — it must notify too, or + // docs edits never re-render there. + const activePreview = __bitActivePreviewName(); + if (activePreview === ${JSON.stringify(prefix)} || activePreview === null) { + window.dispatchEvent( + new CustomEvent('bit-preview-modules-updated', { + detail: { previewName: ${JSON.stringify(prefix)} }, + }) + ); + } + } +} + +// Literal \`import.meta.webpackHot\` member expressions on purpose — an alias breaks +// the bundler's static hot-accept analysis. No-argument self-accept on purpose too — +// see the acceptedDependencies note in generate-link.ts: listing modules would pull +// them all into this entry's build. +if (import.meta.webpackHot) { + import.meta.webpackHot.accept(); + import.meta.webpackHot.dispose((data) => { + data.bitHmrRerun = true; + }); +} +if (import.meta.webpackHot && import.meta.webpackHot.data && import.meta.webpackHot.data.bitHmrRerun) { + // HMR re-execution after a child module updated — re-link and re-render. + void __bitInitializeOnce(true, true); +} + +if (__bitThumbnailDefer()) { + // deferred: initialize only if a later hash actually asks for this preview + window.addEventListener('hashchange', () => { + if (!__bitThumbnailDefer()) void __bitInitializeOnce(); + }); +} else { + void __bitInitializeOnce(); +} `; const contents = `import { linkModules } from '${normalizePath(join(previewDistDir, 'preview-modules.js'))}'; @@ -164,11 +198,44 @@ function __bitActiveComponentId() { } } -const __bitActiveId = __bitActiveComponentId(); +function __bitActivePreviewName() { + try { + const { hash } = window.location; + if (!hash) return null; + const [, query = ""] = hash.slice(1).split("?"); + const params = new URLSearchParams(query); + return params.get("preview"); + } catch { + return null; + } +} + +// A pooled grid "thumbnail" realm (see preview-canvas.ts) renders exactly one preview type, yet +// every preview's link file evaluates its env template module at boot: the overview template is a +// full docs app, measured at ~250ms of a ~500ms realm boot on a grid that only ever renders +// compositions. When the hash carries thumbnail=true, a link whose preview is not the active one +// defers all module evaluation until a hashchange actually asks for it. Without the marker +// nothing defers, so regular preview pages load exactly what they loaded before. +function __bitThumbnailDefer() { + try { + const [, query = ""] = (window.location.hash || "").slice(1).split("?"); + const params = new URLSearchParams(query); + if (params.get("thumbnail") !== "true") return false; + const active = params.get("preview"); + if (!active) return false; + return active !== ${JSON.stringify(prefix)}; + } catch { + return false; + } +} function __bitShouldSurfaceFor(componentId) { - if (!__bitActiveId) return false; - const act = __bitNormalizeId(__bitActiveId); + // resolved per call, not captured at realm boot: a pooled iframe is re-pointed at + // other components by changing only its hash, and a boot-time snapshot would keep + // selecting the first component's modules for same-fullName collisions. + const activeId = __bitActiveComponentId(); + if (!activeId) return false; + const act = __bitNormalizeId(activeId); const cmp = __bitNormalizeId(componentId); if (!act || !cmp) return false; if (act === cmp) return true; @@ -190,6 +257,7 @@ function __bitSurfaceToOverlay(err, componentId) { ${moduleImports.statement} async function initializeModules() { +const {${moduleLinks.map((m) => m.varName).join(', ')}} = await __bitLoadMainModules(); ${getComponentImports(componentLinks)} linkModules('${prefix}', { modulesMap: { @@ -247,21 +315,6 @@ function getEnvVarName(envId: string) { return varName; } -function toWebpackRequestId(filePath: string, workspacePath?: string): string { - if (!workspacePath) return filePath; - const normalizedWorkspacePath = normalizePath(workspacePath); - const normalizedFilePath = normalizePath(filePath); - if (normalizedFilePath === normalizedWorkspacePath) return '.'; - if ( - normalizedFilePath.startsWith(`${normalizedWorkspacePath}/`) || - normalizedFilePath.startsWith(`${normalizedWorkspacePath}\\`) - ) { - const relPath = normalizePath(relative(workspacePath, filePath)); - return relPath.startsWith('.') ? relPath : `./${relPath}`; - } - return filePath; -} - function getModuleImports( moduleLinks: ModuleLink[] = [], tempPackageDir?: string @@ -277,9 +330,19 @@ function getModuleImports( .join('\n'); outputFileSync(tempFilePath, tempFileContents); return { - statement: `import {${moduleLinks.map((moduleLink) => moduleLink.varName).join(', ')}} from "${normalizePath( - tempFilePath - )}";`, + // A lazy loader instead of a static import: the env template modules (the docs app among them) + // must not evaluate while a thumbnail realm's link is deferred. A true async import also keeps + // their code out of the *initial* chunk graph, which is what makes it matter: the docs-app chunk + // alone measured 5.4 MB on the wire, downloaded by every realm that would never run it. Each + // preview's temp file must form its OWN async chunk - a shared webpackChunkName merged the + // compositions mounter with the overview docs template, and initializing compositions dragged + // the whole docs app back in. A grid thumbnail now neither evaluates nor fetches it; a real + // preview page pays one extra request the first time its template initializes. + statement: `let __bitMainModulesNs; +async function __bitLoadMainModules() { + __bitMainModulesNs ??= await import("${normalizePath(tempFilePath)}"); + return __bitMainModulesNs; +}`, tempFilePath: normalizePath(tempFilePath), }; } @@ -303,8 +366,16 @@ function getComponentImports(componentLinks: ComponentLink[] = []): string { } } else { - // Don't import non-active modules at all - ${module.varName} = { default: function Placeholder() { return null; } }; + // Not the component this iframe was opened for. Keep it out of the initial load, but + // hand back a loader instead of a null-rendering placeholder: a realm that renders + // several previews at once (a workspace grid batching cards into one iframe) needs to + // pull these in on demand, and normalizeEntries already calls functions. The active + // component's path above is untouched, so a single-preview iframe loads exactly what it + // loaded before. + ${module.varName} = () => import("${module.resolveFrom}").catch((err) => { + __bitSurfaceToOverlay(err, "${link.componentIdString}"); + return { default: function ErrorFallback() { return null; }, __loadError: err }; + }); }`; }); }) diff --git a/scopes/preview/preview/preview.main.runtime.ts b/scopes/preview/preview/preview.main.runtime.ts index 96f943e93444..227cddb7d0ef 100644 --- a/scopes/preview/preview/preview.main.runtime.ts +++ b/scopes/preview/preview/preview.main.runtime.ts @@ -1027,6 +1027,7 @@ export class PreviewMain { updater(executionRef); const previews = this.previewSlot.values(); await this.updateLinkFiles(previews, executionRef.currentComponents, executionRef.executionCtx); + return noopResult; }; @@ -1170,7 +1171,6 @@ export class PreviewMain { dependencyResolver, express ); - cli.register(new GeneratePreviewCmd(preview), new ServePreviewCmd(preview)); if (workspace) diff --git a/scopes/preview/preview/preview.preview.runtime.tsx b/scopes/preview/preview/preview.preview.runtime.tsx index 7abc84b8b85f..a5d26fe4e14f 100644 --- a/scopes/preview/preview/preview.preview.runtime.tsx +++ b/scopes/preview/preview/preview.preview.runtime.tsx @@ -64,6 +64,34 @@ export class PreviewPreview { } private isDev = false; + private sizeObserver?: ResizeObserver; + private sizePublishRaf?: number; + private sizePublishDebounced?: ReturnType; + + private cleanupSizeObserver() { + if (this.sizeObserver) { + this.sizeObserver.disconnect(); + this.sizeObserver = undefined; + } + if (this.sizePublishRaf) { + window.cancelAnimationFrame(this.sizePublishRaf); + this.sizePublishRaf = undefined; + } + if (this.sizePublishDebounced) { + this.sizePublishDebounced.cancel(); + this.sizePublishDebounced = undefined; + } + } + + /** + * A pooled grid thumbnail (hash carries `thumbnail=true`, set by preview-canvas.ts). Thumbnail + * realms render with `onlyOverview=true`, which drops included previews at render time - so + * their link files stay deferred (never evaluated, see generate-link.ts) and readiness must not + * wait for modules that will never load. Regular preview pages never carry the marker. + */ + private isThumbnail() { + return this.getParam(this.getQuery(), 'thumbnail') === 'true'; + } private isReady() { const { previewName } = this.getLocation(); @@ -72,12 +100,29 @@ export class PreviewPreview { if (!PREVIEW_MODULES.has(name)) return false; const preview = this.getPreview(name); if (!preview) return false; - const includedReady = preview.include?.every((included) => PREVIEW_MODULES.has(included)) ?? true; + const includedReady = + this.isThumbnail() || (preview.include?.every((included) => PREVIEW_MODULES.has(included)) ?? true); if (!includedReady) return false; return true; } + /** + * A host that recycles preview iframes - a workspace grid reusing a small pool of them while + * scrolling, rather than mounting one per card - re-points an iframe at another component by + * changing its hash. Only the fragment changes, so the document is not reloaded and the realm + * keeps everything it already parsed: re-rendering costs a render, not a bootstrap. + */ + private listenToHashChanges(rootExt?: string) { + if (typeof window === 'undefined' || this.hashListenerAttached) return; + this.hashListenerAttached = true; + window.addEventListener('hashchange', () => { + void this.render(rootExt); + }); + } + + private hashListenerAttached = false; + private _setupPromise?: Promise; setup = () => { if (this.isReady()) return Promise.resolve(); @@ -94,9 +139,19 @@ export class PreviewPreview { /** * render the preview. */ + private renderSeq = 0; + render = async (rootExt?: string) => { + // a pooled iframe can be re-pointed while a previous render is still awaiting modules; + // without a sequence guard the older render can commit last and win over the newer hash + const seq = ++this.renderSeq; + // preview registration is asynchronous (link files initialize their modules through a dynamic + // import), so rendering must wait for the active preview - and its includes - to have + // registered. setup() resolves immediately once ready, so steady-state renders pay nothing. + await this.setup(); + if (seq !== this.renderSeq) return undefined; // fit content always. - window.document.body.style.width = 'fit-content'; + window.document.body.style.width = 'auto'; const { previewName, componentId, envId } = this.getLocation(); const name = previewName || this.getDefault(); @@ -107,15 +162,21 @@ export class PreviewPreview { throw new PreviewNotFound(previewName); } - const includesAll = await Promise.all( - (preview.include || []).map(async (inclPreviewName) => { - const includedPreview = this.getPreview(inclPreviewName); - if (!includedPreview) return undefined; + this.listenToHashChanges(rootExt); - const inclPreviewModule = await this.getPreviewModule(inclPreviewName, componentId); - return includedPreview.selectPreviewModel?.(componentId.fullName, inclPreviewModule); - }) - ); + // thumbnails discard includes below (they render with onlyOverview), so don't resolve the + // included previews' modules at all - their link files are deferred and must stay that way + const includesAll = this.isThumbnail() + ? [] + : await Promise.all( + (preview.include || []).map(async (inclPreviewName) => { + const includedPreview = this.getPreview(inclPreviewName); + if (!includedPreview) return undefined; + + const inclPreviewModule = await this.getPreviewModule(inclPreviewName, componentId); + return includedPreview.selectPreviewModel?.(componentId.fullName, inclPreviewModule); + }) + ); const query = this.getQuery(); const onlyOverview = this.getParam(query, 'onlyOverview'); @@ -135,9 +196,11 @@ export class PreviewPreview { return module; }); - // during build / tag, the component is isolated, so all aspects are relevant, and do not require filtering - const componentAspects = this.isDev ? await this.getComponentAspects(componentId.toString()) : undefined; + // Aspect filtering is not needed in dev mode — all providers in the bundle are safe to use, + // and the GQL call to fetch aspects blocks rendering of every preview iframe. + const componentAspects = undefined; const previewModule = await this.getPreviewModule(name, componentId); + if (seq !== this.renderSeq) return undefined; const render = preview.render( componentId, envId || '', @@ -168,48 +231,76 @@ export class PreviewPreview { setViewport() { const query = this.getQuery(); const viewPort = this.getParam(query, 'viewport'); + const body = window.document.body; + if (!viewPort) { - window.document.body.style.width = '100%'; + body.style.width = '100%'; + body.style.maxWidth = ''; return; } - window.document.body.style.maxWidth = `${viewPort}px`; + body.style.width = 'auto'; + body.style.maxWidth = `${viewPort}px`; } reportSize() { if (!window?.parent || !window?.document) return; - // In Preview, the is always exactly as tall as the iframe viewport, not as tall as the content. - // by measuring the scrollHeight of the root element, we can get the height of the content. + this.cleanupSizeObserver(); + // In Preview, the can stay viewport-sized while the actual content extends beyond it. + // Avoid style mutations in ResizeObserver callbacks (which can create ResizeObserver loop errors). const measure = () => { const root = window.document.getElementById('root') ?? window.document.documentElement; - const prevHeight = root.style.height; - // set height auto to make the browser calculate the natural block formatting height - // scrollHeight of an element that has height: 100% reports the viewport height, not the content height - root.style.height = 'auto'; - const height = root.scrollHeight; - const width = root.scrollWidth; - // restore the previous height so nothing visually changes within the same tick - root.style.height = prevHeight; + const docEl = window.document.documentElement; + const body = window.document.body; + const rootRect = root.getBoundingClientRect(); + const height = Math.max( + root.scrollHeight, + docEl.scrollHeight, + body?.scrollHeight || 0, + Math.ceil(rootRect.height) + ); + const width = Math.max(root.scrollWidth, docEl.scrollWidth, body?.scrollWidth || 0, Math.ceil(rootRect.width)); return { width, height }; }; const publish = () => { const { width, height } = measure(); this.pubsub.pub(PreviewAspect.id, new SizeEvent({ width, height })); + // A pooled thumbnail host (preview-canvas.ts) creates its iframes with raw DOM and never + // opens a penpal connection, so the pubsub event above cannot reach it. Send the size as a + // plain postMessage too - the pool uses it as the "this card actually rendered" signal. + if (this.isThumbnail() && window.parent && window.parent !== window) { + window.parent.postMessage({ type: SizeEvent.TYPE, data: { width, height } }, '*'); + } }; // publish right away so the parent gets the first real size as soon as possible publish(); // publish dimension changes when the content size actually changes - const debounced = debounce(publish, 100); - const resizedObserver = new ResizeObserver(debounced); - resizedObserver.observe(window.document.documentElement); + this.sizePublishDebounced = debounce(publish, 100); + const schedulePublish = () => { + if (this.sizePublishRaf) { + window.cancelAnimationFrame(this.sizePublishRaf); + } + this.sizePublishRaf = window.requestAnimationFrame(() => { + this.sizePublishRaf = undefined; + this.sizePublishDebounced?.(); + }); + }; + this.sizeObserver = new ResizeObserver(schedulePublish); + this.sizeObserver.observe(window.document.documentElement); const root = window.document.getElementById('root'); - if (root) resizedObserver.observe(root); + if (root) this.sizeObserver.observe(root); + if (window.document.body) this.sizeObserver.observe(window.document.body); } async getPreviewModule(previewName: string, id: ComponentID): Promise { const compShortId = id.fullName; - const relevantModel = PREVIEW_MODULES.get(previewName); + let relevantModel = PREVIEW_MODULES.get(previewName); + if (!relevantModel) { + // an include can be requested before its link finished registering - wait once, then fail + await this.setup(); + relevantModel = PREVIEW_MODULES.get(previewName); + } if (!relevantModel) throw new Error(`[preview.preview] missing preview "${previewName}"`); if (relevantModel.componentMap[compShortId]) return relevantModel; @@ -410,10 +501,8 @@ export class PreviewPreview { preview.rerenderOnPreviewModulesUpdated(); }); - window.addEventListener('hashchange', () => { - // eslint-disable-next-line @typescript-eslint/no-floating-promises - preview.render(); - }); + // hash changes are handled by listenToHashChanges(), attached by the first render(); + // a second listener here made every hash change render twice. return preview; } diff --git a/scopes/preview/preview/preview.start-plugin.tsx b/scopes/preview/preview/preview.start-plugin.tsx index 9781697eb9ce..87ced402aa94 100644 --- a/scopes/preview/preview/preview.start-plugin.tsx +++ b/scopes/preview/preview/preview.start-plugin.tsx @@ -1,7 +1,8 @@ import { flatten } from 'lodash'; -import type { BundlerMain, ComponentServer } from '@teambit/bundler'; +import type { BundlerMain, ComponentServer, DevServerFailure } from '@teambit/bundler'; import { BundlerAspect, + ComponentServerCompilationChangedEvent, ComponentServerStartedEvent, ComponentsServerStartedEvent, NewDevServersCreatedEvent, @@ -10,13 +11,13 @@ import type { PubsubMain } from '@teambit/pubsub'; import type { ProxyEntry, StartPlugin, StartPluginOptions, UiMain } from '@teambit/ui'; import type { Workspace } from '@teambit/workspace'; import { SubscribeToEvents } from '@teambit/preview.cli.dev-server-events-listener'; -import { SubscribeToWebpackEvents } from '@teambit/preview.cli.webpack-events-listener'; import { CompilationInitiator } from '@teambit/compiler'; import type { Logger } from '@teambit/logger'; import type { WatcherMain } from '@teambit/watcher'; import { CheckTypes } from '@teambit/watcher'; import type { GraphqlMain } from '@teambit/graphql'; import chalk from 'chalk'; +import { bulletSymbol } from '@teambit/cli'; type ServerState = { isCompiling?: boolean; @@ -31,12 +32,47 @@ type ServerState = { type ServerStateMap = Record; +function getContextRootPath(context: unknown): string | undefined { + return (context as { rootPath?: string } | undefined)?.rootPath; +} + export class PreviewStartPlugin implements StartPlugin { previewServers: ComponentServer[] = []; serversState: ServerStateMap = {}; serversMap: Record = {}; private pendingServers: Map = new Map(); + private bootstrapActive = false; + private lastBootstrapText = ''; private pendingPostInstallPublish = new Set(); + private bootstrapFailureReported = false; + + private cacheServerLookup(server: ComponentServer) { + const lookupKeys = new Set([ + server.context.envRuntime.id, + server.context.id, + ...(server.context.relatedContexts || []), + ]); + lookupKeys.forEach((key) => { + if (!key) return; + this.serversMap[key] = server; + }); + } + + private resolveServerByEventId(eventId: string): ComponentServer | undefined { + const direct = this.serversMap[eventId] || this.pendingServers.get(eventId); + if (direct) return direct; + + return Object.values(this.serversMap).find((server) => { + if (!server) return false; + if (server.context.envRuntime.id === eventId) return true; + if (server.context.id === eventId) return true; + return !!server.context.relatedContexts?.includes(eventId); + }); + } + + private resolveEnvRuntimeId(eventId: string): string { + return this.resolveServerByEventId(eventId)?.context.envRuntime.id || eventId; + } constructor( private workspace: Workspace, @@ -59,7 +95,7 @@ export class PreviewStartPlugin implements StartPlugin { async onComponentServerStarted(componentServer: ComponentServer) { const startedEnvId = componentServer.context.envRuntime.id; - this.serversMap[startedEnvId] = componentServer; + this.cacheServerLookup(componentServer); const wasPending = this.pendingServers.has(startedEnvId); this.pendingServers.delete(startedEnvId); @@ -78,9 +114,14 @@ export class PreviewStartPlugin implements StartPlugin { const uiServer = this.ui.getUIServer(); if (uiServer) { uiServer.addComponentServerProxy(componentServer); + const isCompilationDone = !!this.serversState[startedEnvId]?.isCompilationDone; + // Ordering race guard: + // compile "done" can arrive before component-server-started. + // In that case we must keep proxy active, otherwise HMR sockets stay closed forever. + uiServer.setComponentServerProxyActive(startedEnvId, isCompilationDone); if (wasPending) { - if (this.serversState[startedEnvId]?.isCompilationDone) { + if (isCompilationDone) { await this.publishServerStarted(componentServer); } else { this.serversState[startedEnvId] = { @@ -101,16 +142,43 @@ export class PreviewStartPlugin implements StartPlugin { }); } + private async publishCompilationStatus( + eventId: string, + isCompiling: boolean, + results?: { errors?: Error[]; warnings?: Error[] } + ) { + const server = this.resolveServerByEventId(eventId); + const env = server?.context.envRuntime.id || eventId; + const affectedEnvs = new Set([env, eventId]); + if (server?.context?.id) affectedEnvs.add(server.context.id); + for (const relatedEnv of server?.context?.relatedContexts || []) { + if (relatedEnv) affectedEnvs.add(relatedEnv); + } + await this.graphql.pubsub.publish(ComponentServerCompilationChangedEvent, { + componentServerCompilation: { + env, + affectedEnvs: Array.from(affectedEnvs), + url: server?.url, + host: server?.hostname, + basePath: getContextRootPath(server?.context), + isCompiling, + errorCount: results?.errors?.length || 0, + warningCount: results?.warnings?.length || 0, + }, + }); + } + async onNewDevServersCreated(servers: ComponentServer[]) { for (const server of servers) { const envId = server.context.envRuntime.id; if (this.serversMap[envId]) { continue; } + this.cacheServerLookup(server); this.pendingServers.set(envId, server); this.serversState[envId] = { - isCompiling: false, + isCompiling: true, isReady: false, isStarted: false, isCompilationDone: false, @@ -124,40 +192,126 @@ export class PreviewStartPlugin implements StartPlugin { } async initiate(options: StartPluginOptions) { - this.listenToDevServers(options.showInternalUrls); - const components = await this.workspace.getComponentsByUserInput(!options.pattern, options.pattern); - // TODO: logic for creating preview servers must be refactored to this aspect from the DevServer aspect. - const previewServers = await this.bundler.devServer(components); - previewServers.forEach((server) => { - const envId = server.context.envRuntime.id; - this.serversMap[envId] = server; + this.upsertBootstrapSpinner('Preview dev servers: preparing...'); + try { + this.listenToDevServers(options.showInternalUrls); + const workspaceIdsCount = this.workspace.listIds().length; + this.upsertBootstrapSpinner( + `Preview dev servers: loading workspace components ${bulletSymbol} ${chalk.cyan(workspaceIdsCount.toString())}` + ); + const componentsLoadStart = Date.now(); + const components = await this.workspace.getComponentsByUserInput(!options.pattern, options.pattern); + const componentsLoadMs = Date.now() - componentsLoadStart; + const componentsCount = components.length; + this.upsertBootstrapSpinner( + `Preview dev servers: runtime ready ${bulletSymbol} ${chalk.cyan(componentsCount.toString())} component${ + componentsCount === 1 ? '' : 's' + } ${chalk.dim(`in ${(componentsLoadMs / 1000).toFixed(1)}s`)}` + ); + + // TODO: logic for creating preview servers must be refactored to this aspect from the DevServer aspect. + this.upsertBootstrapSpinner('Preview dev servers: creating environments...'); + const envCreateStart = Date.now(); + const previewServers = await this.bundler.devServer(components); + const envCreateMs = Date.now() - envCreateStart; + const envCount = previewServers.length; + // envs that could not build their preview no longer take the healthy envs down with them - + // they come back here as failures so we can report them and carry on. see DevServerService. + const failures = this.bundler.getDevServerFailures(); + failures.forEach((failure) => { + this.logger.error(`failed to create a preview dev server for env ${failure.envId}`, failure.error); + }); - this.serversState[envId] = { - isCompiling: false, - isReady: false, - isStarted: false, - isCompilationDone: false, - isPendingPublish: false, - }; + if (!envCount && failures.length) { + this.bootstrapFailureReported = true; + this.failBootstrapSpinner( + `no preview dev server could be created - component previews will be unavailable.\n${formatDevServerFailures(failures)}` + ); + throw new Error(`preview dev servers failed for all ${failures.length} environment groups`); + } - // DON'T add wait! this promise never resolves, so it would stop the start process! - // eslint-disable-next-line @typescript-eslint/no-floating-promises - server.listen(); - }); - this.watcher - .watch({ - spawnTSServer: true, - checkTypes: CheckTypes.None, - preCompile: false, - compile: true, - initiator: CompilationInitiator.Start, - }) - .catch((err) => { - const msg = `watcher found an error`; - this.logger.error(msg, err); - this.logger.console(`${msg}, ${err.message}`); + if (!envCount) { + this.succeedBootstrapSpinner('No preview dev servers were created (no preview environments matched).'); + this.setReady(); + } else { + this.upsertBootstrapSpinner( + `Preview dev servers: bootstrapped ${bulletSymbol} ${chalk.cyan(envCount.toString())} environment${ + envCount === 1 ? '' : 's' + } ${chalk.dim(`in ${(envCreateMs / 1000).toFixed(1)}s`)}. Waiting for compilation...` + ); + if (failures.length) { + this.logger.console( + chalk.yellow( + `preview bootstrap: ${failures.length} environment${ + failures.length === 1 ? '' : 's' + } could not be served - their components will have no preview:\n` + ) + formatDevServerFailures(failures) + ); + } + } + + previewServers.forEach((server) => { + const envId = server.context.envRuntime.id; + this.cacheServerLookup(server); + + this.serversState[envId] = { + isCompiling: true, + isReady: false, + isStarted: false, + isCompilationDone: false, + isPendingPublish: false, + }; + + // DON'T add wait! this promise never resolves, so it would stop the start process! + // eslint-disable-next-line @typescript-eslint/no-floating-promises + server.listen(); }); - this.previewServers = this.previewServers.concat(previewServers); + this.watcher + .watch({ + spawnTSServer: true, + checkTypes: CheckTypes.None, + preCompile: false, + compile: true, + initiator: CompilationInitiator.Start, + }) + .catch((err) => { + const msg = `watcher found an error`; + this.logger.error(msg, err); + this.logger.console(`${msg}, ${err.message}`); + }); + this.previewServers = this.previewServers.concat(previewServers); + } catch (err: any) { + // reached only when the whole bootstrap failed (e.g. loading components or creating the env + // runtime), not when a single env failed to bundle - that is isolated per env above. the UI + // stays up, which means the only signal the developer gets is this message, and dumping raw + // bundler stats here buried the actual cause under hundreds of lines of module listings. + if (!this.bootstrapFailureReported) { + this.failBootstrapSpinner( + `Preview dev servers failed to start - component previews will be unavailable.\n${summarizeBundlerError(err)}` + ); + } + this.logger.error('preview dev server bootstrap failed', err); + throw err; + } + } + + private upsertBootstrapSpinner(text: string) { + if (this.lastBootstrapText === text) return; + this.bootstrapActive = true; + this.lastBootstrapText = text; + this.logger.console(chalk.cyan(`preview bootstrap: ${text}`)); + } + + private succeedBootstrapSpinner(text: string) { + this.bootstrapActive = false; + this.lastBootstrapText = text; + this.logger.console(chalk.green(`preview bootstrap: ${text}`)); + } + + private failBootstrapSpinner(text: string) { + this.bootstrapActive = false; + this.lastBootstrapText = text; + this.logger.console(chalk.red(`preview bootstrap: ${text}`)); } getProxy(): ProxyEntry[] { @@ -189,50 +343,62 @@ export class PreviewStartPlugin implements StartPlugin { this.handleOnDoneCompiling(id, results, showInternalUrls); }, }); - // @deprecated - // for legacy webpack bit report plugin - SubscribeToWebpackEvents(this.pubsub, { - onStart: (id) => { - this.handleOnStartCompiling(id); - }, - onDone: (id, results) => { - this.handleOnDoneCompiling(id, results, showInternalUrls); - }, - }); } private handleOnStartCompiling(id: string) { - this.serversState[id] = { - ...this.serversState[id], + const server = this.resolveServerByEventId(id); + const envId = this.resolveEnvRuntimeId(id); + if (server) { + server.isCompiling = true; + } + const uiServer = this.ui.getUIServer(); + uiServer?.setComponentServerProxyActive(envId, false); + if (this.bootstrapActive) { + const label = chalk.cyan(envId); + this.succeedBootstrapSpinner(`Preview compilation started ${bulletSymbol} ${label}`); + } + + this.serversState[envId] = { + ...this.serversState[envId], isCompiling: true, }; - const spinnerId = getSpinnerId(id); - const text = getSpinnerCompilingMessage(this.serversMap[id] || this.pendingServers.get(id)); + const spinnerId = getSpinnerId(envId); + const text = getSpinnerCompilingMessage(server, envId); const exists = this.logger.multiSpinner.spinners[spinnerId]; if (!exists) { this.logger.multiSpinner.add(spinnerId, { text }); } + this.publishCompilationStatus(id, true).catch((err) => { + this.logger.error(`failed to publish compilation-start status for ${id}`, err); + }); } private handleOnDoneCompiling(id: string, results, showInternalUrls?: boolean) { - this.serversState[id] = { - ...this.serversState[id], + const previewServer = this.resolveServerByEventId(id); + const envId = this.resolveEnvRuntimeId(id); + if (previewServer) { + previewServer.isCompiling = false; + } + const uiServer = this.ui.getUIServer(); + uiServer?.setComponentServerProxyActive(envId, true); + + this.serversState[envId] = { + ...this.serversState[envId], isCompiling: false, isReady: true, isCompilationDone: true, errors: results.errors, warnings: results.warnings, }; - const previewServer = this.serversMap[id] || this.pendingServers.get(id); - const spinnerId = getSpinnerId(id); + const spinnerId = getSpinnerId(envId); const spinner = this.logger.multiSpinner.spinners[spinnerId]; if (spinner && spinner.isActive()) { const errors = results.errors || []; const hasErrors = !!errors.length; const warnings = getWarningsWithoutIgnored(results.warnings); const hasWarnings = !!warnings.length; - const url = `http://localhost:${previewServer.port}`; - const text = getSpinnerDoneMessage(this.serversMap[id], errors, warnings, url, undefined, showInternalUrls); + const url = previewServer ? `http://localhost:${previewServer.port}` : ''; + const text = getSpinnerDoneMessage(previewServer, errors, warnings, url, envId, undefined, showInternalUrls); if (hasErrors) { this.logger.multiSpinner.fail(spinnerId, { text }); } else if (hasWarnings) { @@ -244,20 +410,27 @@ export class PreviewStartPlugin implements StartPlugin { const noneAreCompiling = Object.values(this.serversState).every((x) => !x.isCompiling); if (noneAreCompiling) this.setReady(); + this.publishCompilationStatus(id, false, results).catch((err) => { + this.logger.error(`failed to publish compilation-done status for ${id}`, err); + }); const compilationErrors = results.errors || []; - if (!compilationErrors.length && this.pendingPostInstallPublish.has(id)) { + if ( + !compilationErrors.length && + (this.pendingPostInstallPublish.has(id) || this.pendingPostInstallPublish.has(envId)) + ) { this.pendingPostInstallPublish.delete(id); - const server = this.serversMap[id]; - if (server) { - this.publishServerStarted(server).catch((err) => { + this.pendingPostInstallPublish.delete(envId); + const postInstallServer = this.resolveServerByEventId(id); + if (postInstallServer) { + this.publishServerStarted(postInstallServer).catch((err) => { this.logger.error(`failed to publish post-install server event for ${id}`, err); }); } } - if (this.serversState[id]?.isPendingPublish) { - const server = this.serversMap[id]; + if (this.serversState[envId]?.isPendingPublish) { + const server = this.resolveServerByEventId(id); if (server) { - this.serversState[id].isPendingPublish = false; + this.serversState[envId].isPendingPublish = false; this.publishServerStarted(server).catch((err) => { this.logger.error(`failed to publish server started event for ${server.context.envRuntime.id}`, err); }); @@ -320,7 +493,11 @@ function getSpinnerId(envId: string) { return `preview-${envId}`; } -function getSpinnerCompilingMessage(server: ComponentServer, verbose = false) { +function getSpinnerCompilingMessage(server?: ComponentServer, fallbackEnvId?: string, verbose = false) { + if (!server) { + const envId = chalk.cyan(fallbackEnvId || 'unknown-env'); + return `${chalk.yellow('Compiling')} ${envId}`; + } const envId = chalk.cyan(server.context.envRuntime.id); let includedEnvs = ''; if (server.context.relatedContexts && server.context.relatedContexts.length > 1) { @@ -330,18 +507,19 @@ function getSpinnerCompilingMessage(server: ComponentServer, verbose = false) { } function getSpinnerDoneMessage( - server: ComponentServer, + server: ComponentServer | undefined, errors: Error[], warnings: Error[], url: string, + fallbackEnvId?: string, verbose = false, showInternalUrls?: boolean ) { const hasErrors = !!errors.length; const hasWarnings = !!warnings.length; - const envId = chalk.cyan(server.context.envRuntime.id); + const envId = chalk.cyan(server?.context.envRuntime.id || fallbackEnvId || 'unknown-env'); let includedEnvs = ''; - if (server.context.relatedContexts && server.context.relatedContexts.length > 1) { + if (server?.context.relatedContexts && server.context.relatedContexts.length > 1) { includedEnvs = ` ${chalk.dim('via')} ${chalk.cyan(stringifyIncludedEnvs(server.context.relatedContexts, verbose))}`; } const errorsTxt = hasErrors ? errors.map((err) => err.message).join('\n') : ''; @@ -361,3 +539,87 @@ function stringifyIncludedEnvs(includedEnvs: string[] = [], verbose = false) { if (includedEnvs.length > 2 && !verbose) return ` ${includedEnvs.length} other envs`; return includedEnvs.join(', '); } + +export type BundlerErrorSummaryOptions = { + /** + * how many `ERROR in ...` blocks to summarize before collapsing the rest into a count. + */ + maxErrors?: number; + + /** + * env the error belongs to. used when the failing capsule cannot be recovered from the error, so + * the summary still says which env is affected. + */ + envId?: string; + + /** + * append the "full output is in the debug log" hint. turn it off when summarizing several errors + * in a row so the hint is printed once for the whole group. + */ + debugLogHint?: boolean; +}; + +/** + * Turn a bundler failure into something a developer can act on. Rspack rejects with its full stats + * as the error message - hundreds of lines of module listings around a couple of real errors - so + * pull out the errors themselves and the env each one came from. The untouched original still goes + * to the debug log for when the summary is not enough. + */ +export function summarizeBundlerError(err: any, options: BundlerErrorSummaryOptions = {}): string { + const { maxErrors = 5, envId: knownEnvId, debugLogHint = true } = options; + const message: string = err?.message || String(err); + const lines = message.split('\n'); + const errorIndexes = lines.reduce((acc, line, index) => { + if (line.startsWith('ERROR in ')) acc.push(index); + return acc; + }, []); + + if (!errorIndexes.length) { + const firstLines = lines.slice(0, 4).join('\n '); + return ` ${knownEnvId ? `${knownEnvId}: ` : ''}${firstLines}`; + } + + const summaries = errorIndexes.slice(0, maxErrors).map((start) => { + const block = lines.slice(start, start + 12); + const reason = + block.map((line) => line.match(/×\s*(.+)$/)?.[1]).find(Boolean) || + block[0].replace(/^ERROR in /, '') || + 'unknown error'; + // capsule dirs are named `__@`, which is the env id with the + // separators flattened - recover it so the message names a component, not a cache path. + const capsule = block.join('\n').match(/capsules[/\\][^/\\]+[/\\]([^/\\]+@[^/\\]+)/)?.[1]; + const envId = (capsule ? capsule.replace(/_/g, '/') : undefined) || knownEnvId; + // drop the trailing `in ''` - the env id above already says where this happened + const concise = reason.trim().replace(/ in '[^']*'$/, ''); + return ` ${envId ? `${envId}: ` : ''}${concise}`; + }); + + const remaining = errorIndexes.length - summaries.length; + if (remaining > 0) summaries.push(` ...and ${remaining} more error${remaining === 1 ? '' : 's'}`); + if (debugLogHint) summaries.push(` full bundler output was written to the debug log (bit globals)`); + return summaries.join('\n'); +} + +/** + * one console block for the envs left without a preview dev server: the envs themselves, and under + * them the errors that took them out. envs that share a dev server also share its failure, so the + * failures are grouped by error to report it once instead of once per env. + */ +function formatDevServerFailures(failures: DevServerFailure[]): string { + const byError = new Map(); + failures.forEach((failure) => { + const envIds = byError.get(failure.error) || []; + byError.set(failure.error, envIds.concat(failure.envId, failure.relatedEnvIds)); + }); + + const blocks = Array.from(byError.entries()).map(([error, envIds]) => { + const summary = summarizeBundlerError(error, { + envId: envIds.length === 1 ? envIds[0] : undefined, + maxErrors: 3, + debugLogHint: false, + }); + return ` ${chalk.yellow(envIds.join(', '))}\n${summary}`; + }); + blocks.push(chalk.dim(' full bundler output was written to the debug log (bit globals)')); + return blocks.join('\n'); +} diff --git a/scopes/preview/ui/component-preview/preview.module.scss b/scopes/preview/ui/component-preview/preview.module.scss index b06e66173c18..c34a95d6f0de 100644 --- a/scopes/preview/ui/component-preview/preview.module.scss +++ b/scopes/preview/ui/component-preview/preview.module.scss @@ -1,10 +1,96 @@ .preview { // preview background should always be white to avoid collisions with themes. background-color: var(--bit-bg-color, #fff); - // height: 100%; + position: relative; + overflow: hidden; + contain: layout paint style; + content-visibility: auto; + contain-intrinsic-size: 360px 260px; iframe { overflow: clip; overscroll-behavior: contain; } } + +.loadingPlaceholder { + position: absolute; + inset: 0; + z-index: 1; + background: var(--surface-color, #fff); + pointer-events: none; +} + +.loadingShimmer { + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 10px; + height: 100%; + padding: 10px; +} + +.loadingChrome, +.loadingCanvas { + border-radius: 8px; + border: 1px solid var(--border-medium-color, #ececec); + background: var(--surface-color, #fff); +} + +.loadingChrome { + min-height: 24px; + display: flex; + align-items: center; + gap: 6px; + padding: 0 8px; +} + +.loadingDot { + width: 7px; + height: 7px; + border-radius: 999px; + background: #e2e2e2; +} + +.loadingCanvas { + flex: 1; + min-height: 120px; + display: flex; + flex-direction: column; + justify-content: flex-start; + gap: 10px; + padding: 12px; +} + +.loadingBar { + height: 11px; + border-radius: 6px; + background: linear-gradient(90deg, #f0f0f0 25%, #e3e3e3 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: preview-shimmer 1.4s ease-in-out infinite; +} + +.loadingCaption { + margin-top: 2px; + font-size: 12px; + color: var(--on-surface-medium-color, #717784); +} + +.previewFrame { + opacity: 0; + transition: opacity 180ms ease-out; + background: #fff; +} + +.previewFrameReady { + opacity: 1; +} + +@keyframes preview-shimmer { + from { + background-position: 200% 0; + } + to { + background-position: -200% 0; + } +} diff --git a/scopes/preview/ui/component-preview/preview.tsx b/scopes/preview/ui/component-preview/preview.tsx index be3444d99afc..2fd018cd86ec 100644 --- a/scopes/preview/ui/component-preview/preview.tsx +++ b/scopes/preview/ui/component-preview/preview.tsx @@ -1,6 +1,6 @@ /* eslint-disable complexity */ import type { IframeHTMLAttributes } from 'react'; -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import classNames from 'classnames'; import { compact } from 'lodash'; import { connectToChild } from 'penpal'; @@ -13,6 +13,30 @@ import { computePreviewScale } from './compute-preview-scale'; import { useIframeContentHeight } from './use-iframe-content-height'; import styles from './preview.module.scss'; +const CONNECTION_STATUS_EVENT = 'bit-dev-server-connection-status'; +const PREVIEW_SRCDOC_SKELETON = `
`; +const MAX_AUTO_RETRIES = 4; +const RETRY_BASE_MS = 1400; + +function reportConnectionStatus( + online: boolean, + reason?: 'preview' | 'network', + options?: { previewKey?: string; previewEnvId?: string } +) { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(CONNECTION_STATUS_EVENT, { + detail: { + online, + reason, + previewKey: options?.previewKey, + previewEnvId: options?.previewEnvId, + timestamp: Date.now(), + }, + }) + ); +} + export type OnPreviewLoadProps = { height?: string; width?: string }; // omitting 'referrerPolicy' because of an TS error during build. Re-include when needed export interface ComponentPreviewProps extends Omit, 'src' | 'referrerPolicy'> { @@ -120,21 +144,56 @@ export function ComponentPreview({ const isScaling = component.preview?.isScaling; const currentRef = isScaling ? iframeRef : heightIframeRef; const [forceVisible, setForceVisible] = useState(false); + const [isPreviewReady, setIsPreviewReady] = useState(false); + const [showSlowMessage, setShowSlowMessage] = useState(false); + const [scheduledSrc, setScheduledSrc] = useState(undefined); + const [retryNonce, setRetryNonce] = useState(0); + const navRafRef = useRef(undefined); + const retryTimerRef = useRef(undefined); + const retryCountRef = useRef(0); + const componentId = component.id.toString(); + const previewKey = `${component.id.toString()}:${previewName || 'overview'}`; + const previewEnvId = component.server?.env || component.environment?.id; + const hasLoadedOnceRef = useRef(false); + const prevComponentIdRef = useRef(componentId); // @ts-ignore (https://github.com/frenic/csstype/issues/156) // const height = iframeHeight || style?.height; usePubSubIframe(pubsub ? currentRef : undefined); // const pubsubContext = usePubSub(); // pubsubContext?.connect(iframeHeight); + const getCurrentIframeWindow = () => { + const iframeElement = (currentRef as React.MutableRefObject)?.current; + return iframeElement?.contentWindow; + }; + useEffect(() => { + let isMounted = true; const handleMessage = (event) => { + if (!isMounted) return; + const iframeWindow = getCurrentIframeWindow(); + if (iframeWindow && event.source !== iframeWindow) return; if ((event.data && event.data.event === LOAD_EVENT) || (event.data && event.data.event === 'webpackInvalid')) { + if (event.data.event === LOAD_EVENT) { + reportConnectionStatus(true, 'preview', { previewKey, previewEnvId }); + hasLoadedOnceRef.current = true; + setIsPreviewReady(true); + } else { + // Preview bundle is rebuilding; not a main dev-server offline condition. + reportConnectionStatus(false, 'preview', { previewKey, previewEnvId }); + setIsPreviewReady(false); + } onLoad && onLoad(event); } if (event.data && (event.data.event === ERROR_EVENT || event.data.event === 'AI_FIX_REQUEST')) { + reportConnectionStatus(false, 'preview', { previewKey, previewEnvId }); const errorData = event.data.payload; onPreviewError?.(errorData); + // Keep skeleton visible for offline/restart paths; avoid exposing raw + // fallback iframe responses (blank/black/offline text) as "ready" UI. + setIsPreviewReady(false); + setShowSlowMessage(true); setForceVisible(true); if (propagateError && window.parent && window !== window.parent) { try { @@ -161,28 +220,38 @@ export function ComponentPreview({ window.addEventListener('message', handleMessage); return () => { + isMounted = false; window.removeEventListener('message', handleMessage); }; - }, [component.id.toString(), onLoad, propagateError, onPreviewError]); + }, [componentId, onLoad, previewKey, previewEnvId, propagateError, onPreviewError]); useEffect(() => { - if (!iframeRef.current) return; - connectToChild({ - iframe: iframeRef.current, + const iframeElement = iframeRef.current; + if (!iframeElement) return; + let isMounted = true; + const connection = connectToChild({ + iframe: iframeElement, methods: { pub: (event, message) => { + if (!isMounted) return; if (message.type === 'preview-size') { // disable this for now until we figure out how to correctly calculate the height // const previewHeight = component.preview?.onlyOverview ? message.data.height - 150 : message.data.height; setWidth(message.data.width); // setHeight(previewHeight); setHeight(message.data.height); + hasLoadedOnceRef.current = true; + setIsPreviewReady(true); } onLoad && event && onLoad(event, { height: message.data.height, width: message.data.width }); }, }, }); - }, [iframeRef?.current]); + return () => { + isMounted = false; + connection.destroy(); + }; + }, [iframeRef]); const theme = useThemePicker(); const themeParam = theme?.current?.themeName ? `theme=${theme.current.themeName}` : ''; @@ -195,6 +264,100 @@ export function ComponentPreview({ const targetParams = viewport === null ? baseParams : paramsWithViewport; const url = toPreviewUrl(component, previewName, isScaling ? targetParams : baseParams, includeEnv); + // the retry nonce must live in the document query, before the '#': preview urls carry + // their params inside the hash, and a hash-only change is a fragment navigation that + // never re-fetches a document that failed to load - which is the one thing a retry is for + const srcWithRetryNonce = useMemo(() => { + if (retryNonce <= 0) return url; + const hashIndex = url.indexOf('#'); + const base = hashIndex === -1 ? url : url.slice(0, hashIndex); + const fragment = hashIndex === -1 ? '' : url.slice(hashIndex); + return `${base}${base.includes('?') ? '&' : '?'}bitPreviewRetry=${retryNonce}${fragment}`; + }, [url, retryNonce]); + const isServerCompiling = (component.server as { isCompiling?: boolean } | undefined)?.isCompiling === true; + + const clearNavSchedule = () => { + if (navRafRef.current) { + window.cancelAnimationFrame(navRafRef.current); + navRafRef.current = undefined; + } + }; + const clearRetryTimer = () => { + if (!retryTimerRef.current) return; + window.clearTimeout(retryTimerRef.current); + retryTimerRef.current = undefined; + }; + + useEffect(() => { + const componentChanged = prevComponentIdRef.current !== componentId; + prevComponentIdRef.current = componentId; + if (componentChanged) { + hasLoadedOnceRef.current = false; + retryCountRef.current = 0; + setRetryNonce(0); + } + + setForceVisible(false); + if (componentChanged || !hasLoadedOnceRef.current) { + setIsPreviewReady(false); + } + setShowSlowMessage(false); + reportConnectionStatus(false, 'preview', { previewKey, previewEnvId }); + }, [componentId, previewKey, previewEnvId, url]); + + useEffect(() => { + if (typeof window === 'undefined') { + setScheduledSrc(srcWithRetryNonce); + return; + } + + clearNavSchedule(); + navRafRef.current = window.requestAnimationFrame(() => { + setScheduledSrc((current) => (current === srcWithRetryNonce ? current : srcWithRetryNonce)); + }); + + return () => { + clearNavSchedule(); + }; + }, [srcWithRetryNonce]); + + // If the preview loaded while the dev server was still spinning up, it can get + // stuck on an offline/error response. Retry navigation a few times once the + // server reports it is no longer compiling. + useEffect(() => { + clearRetryTimer(); + if (typeof window === 'undefined') return undefined; + if (isPreviewReady) { + retryCountRef.current = 0; + return undefined; + } + if (!component.server?.url || isServerCompiling) return undefined; + if (retryCountRef.current >= MAX_AUTO_RETRIES) return undefined; + + const attempt = retryCountRef.current + 1; + const delay = RETRY_BASE_MS + attempt * 600; + retryTimerRef.current = window.setTimeout(() => { + retryCountRef.current = attempt; + setRetryNonce((value) => value + 1); + }, delay); + + return () => { + clearRetryTimer(); + }; + }, [component.server?.url, isServerCompiling, isPreviewReady]); + + useEffect(() => { + if (isPreviewReady) return undefined; + const timeout = window.setTimeout(() => setShowSlowMessage(true), 2200); + return () => window.clearTimeout(timeout); + }, [isPreviewReady, url]); + + useEffect(() => { + return () => { + clearRetryTimer(); + }; + }, []); + // const currentHeight = fullContentHeight ? '100%' : height || 1024; const containerWidth = containerRef.current?.offsetWidth || 0; const containerHeight = containerRef.current?.offsetHeight || 0; @@ -208,10 +371,33 @@ export function ComponentPreview({ return (
+ {!isPreviewReady && ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ {showSlowMessage ? 'Preview is waiting for the dev server.' : 'Loading preview bundle...'} +
+
+
+ )}