@teambit/workspace 1.0.1079 → 1.0.1081

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/dist/{preview-1785260578779.js → preview-1785278532531.js} +2 -2
  2. package/dist/ui/workspace/use-workspace.js +179 -27
  3. package/dist/ui/workspace/use-workspace.js.map +1 -1
  4. package/dist/ui/workspace/workspace-overview/index.d.ts +1 -1
  5. package/dist/ui/workspace/workspace-overview/index.js +5 -5
  6. package/dist/ui/workspace/workspace-overview/index.js.map +1 -1
  7. package/dist/ui/workspace/workspace-overview/workspace-overview.js +13 -143
  8. package/dist/ui/workspace/workspace-overview/workspace-overview.js.map +1 -1
  9. package/dist/ui/workspace/workspace-skeleton.d.ts +8 -0
  10. package/dist/ui/workspace/workspace-skeleton.js +73 -0
  11. package/dist/ui/workspace/workspace-skeleton.js.map +1 -0
  12. package/dist/ui/workspace/workspace-skeleton.module.scss +102 -0
  13. package/dist/ui/workspace/workspace.js +23 -7
  14. package/dist/ui/workspace/workspace.js.map +1 -1
  15. package/package.json +65 -74
  16. package/ui/workspace/use-workspace.ts +185 -27
  17. package/ui/workspace/workspace-overview/index.ts +1 -1
  18. package/ui/workspace/workspace-overview/workspace-overview.tsx +9 -102
  19. package/ui/workspace/workspace-skeleton.module.scss +102 -0
  20. package/ui/workspace/workspace-skeleton.tsx +35 -0
  21. package/ui/workspace/workspace.tsx +16 -5
  22. package/dist/ui/workspace/workspace-overview/card-overlays.d.ts +0 -5
  23. package/dist/ui/workspace/workspace-overview/card-overlays.js +0 -133
  24. package/dist/ui/workspace/workspace-overview/card-overlays.js.map +0 -1
  25. package/dist/ui/workspace/workspace-overview/card-overlays.module.scss +0 -72
  26. package/dist/ui/workspace/workspace-overview/filter-utils.d.ts +0 -10
  27. package/dist/ui/workspace/workspace-overview/filter-utils.js +0 -40
  28. package/dist/ui/workspace/workspace-overview/filter-utils.js.map +0 -1
  29. package/dist/ui/workspace/workspace-overview/hope-component-card.d.ts +0 -15
  30. package/dist/ui/workspace/workspace-overview/hope-component-card.js +0 -186
  31. package/dist/ui/workspace/workspace-overview/hope-component-card.js.map +0 -1
  32. package/dist/ui/workspace/workspace-overview/hope-component-card.module.scss +0 -169
  33. package/dist/ui/workspace/workspace-overview/link-plugin.d.ts +0 -3
  34. package/dist/ui/workspace/workspace-overview/link-plugin.js +0 -14
  35. package/dist/ui/workspace/workspace-overview/link-plugin.js.map +0 -1
  36. package/dist/ui/workspace/workspace-overview/namespace-header.d.ts +0 -9
  37. package/dist/ui/workspace/workspace-overview/namespace-header.js +0 -107
  38. package/dist/ui/workspace/workspace-overview/namespace-header.js.map +0 -1
  39. package/dist/ui/workspace/workspace-overview/namespace-header.module.scss +0 -88
  40. package/dist/ui/workspace/workspace-overview/namespace-sort.d.ts +0 -8
  41. package/dist/ui/workspace/workspace-overview/namespace-sort.js +0 -47
  42. package/dist/ui/workspace/workspace-overview/namespace-sort.js.map +0 -1
  43. package/dist/ui/workspace/workspace-overview/scope-sort.d.ts +0 -1
  44. package/dist/ui/workspace/workspace-overview/scope-sort.js +0 -14
  45. package/dist/ui/workspace/workspace-overview/scope-sort.js.map +0 -1
  46. package/dist/ui/workspace/workspace-overview/use-query-param-with-default.d.ts +0 -6
  47. package/dist/ui/workspace/workspace-overview/use-query-param-with-default.js +0 -102
  48. package/dist/ui/workspace/workspace-overview/use-query-param-with-default.js.map +0 -1
  49. package/dist/ui/workspace/workspace-overview/use-workspace-aggregation.d.ts +0 -3
  50. package/dist/ui/workspace/workspace-overview/use-workspace-aggregation.js +0 -88
  51. package/dist/ui/workspace/workspace-overview/use-workspace-aggregation.js.map +0 -1
  52. package/dist/ui/workspace/workspace-overview/workspace-filter-panel.d.ts +0 -13
  53. package/dist/ui/workspace/workspace-overview/workspace-filter-panel.js +0 -110
  54. package/dist/ui/workspace/workspace-overview/workspace-filter-panel.js.map +0 -1
  55. package/dist/ui/workspace/workspace-overview/workspace-overview.module.scss +0 -91
  56. package/dist/ui/workspace/workspace-overview/workspace-overview.sort.d.ts +0 -1
  57. package/dist/ui/workspace/workspace-overview/workspace-overview.sort.js +0 -30
  58. package/dist/ui/workspace/workspace-overview/workspace-overview.sort.js.map +0 -1
  59. package/dist/ui/workspace/workspace-overview/workspace-overview.types.d.ts +0 -28
  60. package/dist/ui/workspace/workspace-overview/workspace-overview.types.js +0 -3
  61. package/dist/ui/workspace/workspace-overview/workspace-overview.types.js.map +0 -1
  62. package/ui/workspace/workspace-overview/card-overlays.module.scss +0 -72
  63. package/ui/workspace/workspace-overview/card-overlays.tsx +0 -66
  64. package/ui/workspace/workspace-overview/filter-utils.ts +0 -42
  65. package/ui/workspace/workspace-overview/hope-component-card.module.scss +0 -169
  66. package/ui/workspace/workspace-overview/hope-component-card.tsx +0 -152
  67. package/ui/workspace/workspace-overview/link-plugin.ts +0 -5
  68. package/ui/workspace/workspace-overview/namespace-header.module.scss +0 -88
  69. package/ui/workspace/workspace-overview/namespace-header.tsx +0 -72
  70. package/ui/workspace/workspace-overview/namespace-sort.ts +0 -44
  71. package/ui/workspace/workspace-overview/scope-sort.ts +0 -9
  72. package/ui/workspace/workspace-overview/use-query-param-with-default.ts +0 -115
  73. package/ui/workspace/workspace-overview/use-workspace-aggregation.ts +0 -79
  74. package/ui/workspace/workspace-overview/workspace-filter-panel.tsx +0 -109
  75. package/ui/workspace/workspace-overview/workspace-overview.module.scss +0 -91
  76. package/ui/workspace/workspace-overview/workspace-overview.sort.ts +0 -29
  77. package/ui/workspace/workspace-overview/workspace-overview.types.ts +0 -30
@@ -1,8 +1,8 @@
1
- import { useEffect, useMemo } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import { ComponentModel } from '@teambit/component';
3
3
  import useLatest from '@react-hook/latest';
4
4
  import { useDataQuery } from '@teambit/ui-foundation.ui.hooks.use-data-query';
5
- import { gql } from '@apollo/client';
5
+ import { gql, useApolloClient } from '@apollo/client';
6
6
  import type { ComponentIdObj } from '@teambit/component-id';
7
7
  import { ComponentID } from '@teambit/component-id';
8
8
 
@@ -19,8 +19,15 @@ type UseWorkspaceOptions = {
19
19
  };
20
20
  type RawComponent = { id: ComponentIdObj };
21
21
 
22
- const wcComponentFields = gql`
23
- fragment wcComponentFields on Component {
22
+ /**
23
+ * Cheap, card-renderable fields only. Everything here resolves from already-loaded component
24
+ * metadata (id, env, aspects, compositions, preview, deprecation, server, buildStatus, description),
25
+ * so the light query returns fast and the grid can paint immediately. The two expensive async
26
+ * resolvers — `status` (getStatus(): git/scope compare) and `issuesCount` (getIssues(): dependency
27
+ * analysis) — are deliberately omitted here and fetched separately by `wcComponentDetailFields`.
28
+ */
29
+ const wcComponentFieldsLight = gql`
30
+ fragment wcComponentFieldsLight on Component {
24
31
  id {
25
32
  name
26
33
  version
@@ -37,18 +44,6 @@ const wcComponentFields = gql`
37
44
  identifier
38
45
  }
39
46
  description
40
- issuesCount
41
- status {
42
- isOutdated
43
- isNew
44
- isInScope
45
- isStaged
46
- modifyInfo {
47
- hasModifiedFiles
48
- hasModifiedDependencies
49
- }
50
- isDeleted
51
- }
52
47
  buildStatus
53
48
  preview {
54
49
  includesEnvTemplate
@@ -72,40 +67,100 @@ const wcComponentFields = gql`
72
67
  }
73
68
  `;
74
69
 
75
- const WORKSPACE = gql`
76
- query workspace {
70
+ /**
71
+ * The expensive per-component fields, split out so they never block first paint. These drive the
72
+ * status pills (changed/building) and the issues badge, both of which gracefully degrade to a
73
+ * neutral "built" / no-issues state until this query resolves.
74
+ */
75
+ const wcComponentDetailFields = gql`
76
+ fragment wcComponentDetailFields on Component {
77
+ id {
78
+ name
79
+ version
80
+ scope
81
+ }
82
+ issuesCount
83
+ status {
84
+ isOutdated
85
+ isNew
86
+ isInScope
87
+ isStaged
88
+ modifyInfo {
89
+ hasModifiedFiles
90
+ hasModifiedDependencies
91
+ }
92
+ isDeleted
93
+ }
94
+ }
95
+ `;
96
+
97
+ /**
98
+ * Full per-component shape (light + detail). Used by the subscriptions: a single added/changed
99
+ * component is cheap to fully resolve, so live updates always carry fresh status.
100
+ */
101
+ const wcComponentFieldsFull = gql`
102
+ fragment wcComponentFieldsFull on Component {
103
+ ...wcComponentFieldsLight
104
+ ...wcComponentDetailFields
105
+ }
106
+ ${wcComponentFieldsLight}
107
+ ${wcComponentDetailFields}
108
+ `;
109
+
110
+ const WORKSPACE_LIGHT = gql`
111
+ query workspaceLight {
77
112
  workspace {
78
113
  name
79
114
  path
80
115
  icon
81
116
  components {
82
- ...wcComponentFields
117
+ ...wcComponentFieldsLight
118
+ }
119
+ }
120
+ }
121
+ ${wcComponentFieldsLight}
122
+ `;
123
+
124
+ // The detail fields are fetched one page at a time via the existing `components(offset, limit)`
125
+ // resolver. getStatus()/getIssues() are synchronous CPU work; fetching all 204 at once blocks the
126
+ // server's event loop for ~5s straight (a navigation query issued during that window waited ~10s).
127
+ // Paging it lets the loop breathe between chunks — measured ping latency dropped from ~10s to ~0.3s.
128
+ const WORKSPACE_DETAILS_PAGE = gql`
129
+ query workspaceDetailsPage($offset: Int!, $limit: Int!) {
130
+ workspace {
131
+ components(offset: $offset, limit: $limit) {
132
+ ...wcComponentDetailFields
83
133
  }
84
134
  }
85
135
  }
86
- ${wcComponentFields}
136
+ ${wcComponentDetailFields}
87
137
  `;
88
138
 
139
+ // Small pages keep any single server-blocking burst under ~350ms; the gap yields the event loop so
140
+ // other requests (navigation, lanes) are serviced promptly between pages.
141
+ const DETAIL_CHUNK_SIZE = 15;
142
+ const DETAIL_CHUNK_GAP_MS = 60;
143
+
89
144
  const COMPONENT_SUBSCRIPTION_ADDED = gql`
90
145
  subscription OnComponentAdded {
91
146
  componentAdded {
92
147
  component {
93
- ...wcComponentFields
148
+ ...wcComponentFieldsFull
94
149
  }
95
150
  }
96
151
  }
97
- ${wcComponentFields}
152
+ ${wcComponentFieldsFull}
98
153
  `;
99
154
 
100
155
  const COMPONENT_SUBSCRIPTION_CHANGED = gql`
101
156
  subscription OnComponentChanged {
102
157
  componentChanged {
103
158
  component {
104
- ...wcComponentFields
159
+ ...wcComponentFieldsFull
105
160
  }
106
161
  }
107
162
  }
108
- ${wcComponentFields}
163
+ ${wcComponentFieldsFull}
109
164
  `;
110
165
 
111
166
  const COMPONENT_SUBSCRIPTION_REMOVED = gql`
@@ -131,10 +186,94 @@ const COMPONENT_SERVER_STARTED = gql`
131
186
  }
132
187
  `;
133
188
 
189
+ function componentKey(id: ComponentIdObj): string {
190
+ return `${id.scope}/${id.name}@${id.version}`;
191
+ }
192
+
134
193
  export function useWorkspace(options: UseWorkspaceOptions = {}): UseWorkspaceResult {
194
+ // Light query drives first paint. cache-and-network so a revisit paints instantly from cache,
195
+ // then refreshes in the background (the workspace host otherwise defaults to network-only).
135
196
  // `previousData` is destructured out (not spread into `...rest`) so the returned shape matches
136
197
  // UseWorkspaceResult, which omits it (along with `data`, replaced by `workspace`).
137
- const { data, previousData: _previousData, subscribeToMore, ...rest } = useDataQuery(WORKSPACE);
198
+ const {
199
+ data,
200
+ previousData: _previousData,
201
+ subscribeToMore,
202
+ loading,
203
+ ...rest
204
+ } = useDataQuery(WORKSPACE_LIGHT, {
205
+ fetchPolicy: 'cache-and-network',
206
+ });
207
+
208
+ // CRITICAL: the detail data (status + issuesCount) is NOT fetched in parallel with the light query.
209
+ // getStatus()/getIssues() are synchronous CPU work that monopolizes the server's single-threaded
210
+ // event loop for several seconds on large workspaces; running it concurrently would stall the light
211
+ // query behind it (measured: light alone ~10ms, but ~5s when racing the detail query) and the grid
212
+ // would never paint early. So we wait for the light data to arrive AND for the browser to go idle
213
+ // (i.e. the grid has painted) before kicking off the (chunked) detail fetch in the background.
214
+ const [detailEnabled, setDetailEnabled] = useState(false);
215
+ const lightDataReady = Boolean(data?.workspace);
216
+ const totalComponents = data?.workspace?.components?.length ?? 0;
217
+
218
+ useEffect(() => {
219
+ if (!lightDataReady || detailEnabled || typeof window === 'undefined') return undefined;
220
+ const enable = () => setDetailEnabled(true);
221
+ const ric = (window as any).requestIdleCallback as
222
+ | ((cb: () => void, opts?: { timeout: number }) => number)
223
+ | undefined;
224
+ if (ric) {
225
+ const handle = ric(enable, { timeout: 1000 });
226
+ return () => (window as any).cancelIdleCallback?.(handle);
227
+ }
228
+ const handle = window.setTimeout(enable, 200);
229
+ return () => window.clearTimeout(handle);
230
+ }, [lightDataReady, detailEnabled]);
231
+
232
+ // Detail (status + issuesCount) is paged in once the grid has painted, with a yield between pages
233
+ // so the server stays responsive. Results accumulate by component key and merge in progressively —
234
+ // status pills and issue counts fill in chunk by chunk rather than all-or-nothing after ~5s.
235
+ const client = useApolloClient();
236
+ const [detailByKey, setDetailByKey] = useState<Map<string, { status?: any; issuesCount?: number }>>(new Map());
237
+
238
+ useEffect(() => {
239
+ if (!detailEnabled || !totalComponents) return undefined;
240
+ let cancelled = false;
241
+
242
+ (async () => {
243
+ for (let offset = 0; offset < totalComponents; offset += DETAIL_CHUNK_SIZE) {
244
+ if (cancelled) return;
245
+ try {
246
+ const res = await client.query({
247
+ query: WORKSPACE_DETAILS_PAGE,
248
+ variables: { offset, limit: DETAIL_CHUNK_SIZE },
249
+ fetchPolicy: 'network-only',
250
+ });
251
+ if (cancelled) return;
252
+ const comps: { id: ComponentIdObj; status?: any; issuesCount?: number }[] =
253
+ res.data?.workspace?.components ?? [];
254
+ if (comps.length) {
255
+ setDetailByKey((prev) => {
256
+ const next = new Map(prev);
257
+ for (const c of comps) next.set(componentKey(c.id), { status: c.status, issuesCount: c.issuesCount });
258
+ return next;
259
+ });
260
+ }
261
+ } catch {
262
+ // a failed page must not abort the rest of the pages
263
+ }
264
+ if (cancelled) return;
265
+ // yield the server event loop between pages
266
+ await new Promise((resolve) => {
267
+ setTimeout(resolve, DETAIL_CHUNK_GAP_MS);
268
+ });
269
+ }
270
+ })();
271
+
272
+ return () => {
273
+ cancelled = true;
274
+ };
275
+ }, [detailEnabled, totalComponents, client]);
276
+
138
277
  const optionsRef = useLatest(options);
139
278
 
140
279
  useEffect(() => {
@@ -249,11 +388,30 @@ export function useWorkspace(options: UseWorkspaceOptions = {}): UseWorkspaceRes
249
388
  }, [optionsRef]);
250
389
 
251
390
  const workspace = useMemo(() => {
252
- return data?.workspace ? Workspace.from(data?.workspace) : undefined;
253
- }, [data?.workspace]);
391
+ const rawWorkspace = data?.workspace;
392
+ if (!rawWorkspace) return undefined;
393
+
394
+ // Merge the paged-in heavy fields (status, issuesCount) onto the light components by id.
395
+ // Components are not normalized in the Apollo cache (their `id` is an object, not a scalar key),
396
+ // so the detail pages are accumulated in `detailByKey` and merged here by hand. The light
397
+ // component wins when it already carries a value — a live subscription update (full fragment)
398
+ // refreshes status there, and the paged detail is only the initial enrichment.
399
+ const components = rawWorkspace.components.map((component: any) => {
400
+ const detail = detailByKey.get(componentKey(component.id));
401
+ if (!detail) return component;
402
+ return {
403
+ ...component,
404
+ status: component.status ?? detail.status,
405
+ issuesCount: component.issuesCount ?? detail.issuesCount,
406
+ };
407
+ });
408
+
409
+ return Workspace.from({ ...rawWorkspace, components });
410
+ }, [data?.workspace, detailByKey]);
254
411
 
255
412
  return {
256
413
  workspace,
414
+ loading,
257
415
  subscribeToMore,
258
416
  ...rest,
259
417
  };
@@ -1,2 +1,2 @@
1
1
  export { WorkspaceOverview } from './workspace-overview';
2
- export { useQueryParamWithDefault, useListParamWithDefault } from './use-query-param-with-default';
2
+ export { useQueryParamWithDefault, useListParamWithDefault } from '@teambit/explorer.ui.components-overview';
@@ -1,116 +1,23 @@
1
- import React, { useContext, useMemo } from 'react';
2
- import { ComponentGrid } from '@teambit/explorer.ui.gallery.component-grid';
3
- // Legacy empty state — kept around as a fallback while the new blank state rolls out.
4
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
5
- import { EmptyWorkspace } from '@teambit/workspace.ui.empty-workspace';
6
- import compact from 'lodash.compact';
7
- import { ScopeID } from '@teambit/scopes.scope-id';
8
- import { useCloudScopes } from '@teambit/cloud.hooks.use-cloud-scopes';
1
+ import React, { useContext } from 'react';
9
2
  import { useWorkspaceMode } from '@teambit/workspace.ui.use-workspace-mode';
3
+ import { ComponentsOverview } from '@teambit/explorer.ui.components-overview';
10
4
  import { WorkspaceContext } from '../workspace-context';
11
- import { useWorkspaceAggregation } from './use-workspace-aggregation';
12
- import { useQueryParamWithDefault, useListParamWithDefault } from './use-query-param-with-default';
13
- import { NamespaceHeader } from './namespace-header';
14
- import { HopeComponentCard } from './hope-component-card';
15
- import type { AggregationType } from './workspace-overview.types';
16
- import { WorkspaceFilterPanel } from './workspace-filter-panel';
17
5
  import { WorkspaceBlankState } from './workspace-blank-state';
18
- import styles from './workspace-overview.module.scss';
19
6
 
20
7
  export function WorkspaceOverview() {
21
8
  const workspace = useContext(WorkspaceContext);
22
9
  const { components, componentDescriptors } = workspace;
23
-
24
10
  const { isMinimal } = useWorkspaceMode();
25
- const uniqueScopes = [...new Set(components.map((c) => c.id.scope))];
26
- const { cloudScopes } = useCloudScopes(uniqueScopes);
27
- const cloudMap = new Map((cloudScopes || []).map((s) => [s.id.toString(), s]));
28
-
29
- const compDescriptorMap = new Map(componentDescriptors.map((d) => [d.id.toString(), d]));
30
-
31
- const items = compact(
32
- components.map((component) => {
33
- if (component.deprecation?.isDeprecate) return null;
34
-
35
- const descriptor = compDescriptorMap.get(component.id.toString());
36
- if (!descriptor) return null;
37
-
38
- const cloudScope = cloudMap.get(component.id.scope);
39
- const scope =
40
- cloudScope ||
41
- (ScopeID.isValid(component.id.scope) && { id: ScopeID.fromString(component.id.scope) }) ||
42
- undefined;
43
-
44
- return {
45
- component,
46
- componentDescriptor: descriptor,
47
- scope: scope
48
- ? { id: scope.id, icon: (scope as any).icon, backgroundIconColor: (scope as any).backgroundIconColor }
49
- : undefined,
50
- };
51
- })
52
- );
53
-
54
- const [aggregation, setAggregation] = useQueryParamWithDefault<AggregationType>('aggregation', 'namespaces');
55
- const [activeNamespaces, setActiveNamespaces] = useListParamWithDefault('ns');
56
- const [activeScopes, setActiveScopes] = useListParamWithDefault('scopes');
57
-
58
- const filters = useMemo(
59
- () => ({ namespaces: activeNamespaces, scopes: activeScopes, statuses: new Set() as any }),
60
- [activeNamespaces, activeScopes]
61
- );
62
-
63
- const { groups, groupType, availableAggregations, filteredCount } = useWorkspaceAggregation(
64
- items,
65
- aggregation,
66
- filters
67
- );
68
11
 
69
12
  if (!components.length) return <WorkspaceBlankState />;
70
13
 
71
14
  return (
72
- <div className={styles.container}>
73
- <WorkspaceFilterPanel
74
- aggregation={aggregation}
75
- onAggregationChange={setAggregation}
76
- availableAggregations={availableAggregations}
77
- items={items}
78
- activeNamespaces={activeNamespaces}
79
- onNamespacesChange={setActiveNamespaces}
80
- activeScopes={activeScopes}
81
- onScopesChange={setActiveScopes}
82
- />
83
-
84
- <div className={styles.content}>
85
- {filteredCount === 0 && <WorkspaceBlankState />}
86
-
87
- {groups.map((group) => (
88
- <section key={group.name} className={styles.section}>
89
- {groupType !== 'none' && (
90
- <div className={styles.sectionHeader}>
91
- <NamespaceHeader
92
- namespace={group.name}
93
- items={group.items}
94
- scopeIcon={group.scopeIcon}
95
- scopeIconColor={group.scopeIconColor}
96
- />
97
- </div>
98
- )}
99
-
100
- <ComponentGrid className={styles.cardGrid}>
101
- {group.items.map((item) => (
102
- <HopeComponentCard
103
- key={item.component.id.toString()}
104
- component={item.component}
105
- componentDescriptor={item.componentDescriptor}
106
- scope={item.scope as any}
107
- showPreview={isMinimal}
108
- />
109
- ))}
110
- </ComponentGrid>
111
- </section>
112
- ))}
113
- </div>
114
- </div>
15
+ <ComponentsOverview
16
+ components={components}
17
+ componentDescriptors={componentDescriptors}
18
+ showPreview={isMinimal}
19
+ storageNamespace="workspace-overview"
20
+ emptyState={<WorkspaceBlankState />}
21
+ />
115
22
  );
116
23
  }
@@ -0,0 +1,102 @@
1
+ .wrapper {
2
+ display: flex;
3
+ flex-direction: column;
4
+ height: 100vh;
5
+ overflow: hidden;
6
+ background: var(--background-color, #fff);
7
+ }
8
+
9
+ /* ---- Top bar ---- */
10
+
11
+ .topbar {
12
+ flex: none;
13
+ display: flex;
14
+ align-items: center;
15
+ height: 48px;
16
+ border-bottom: 1px solid var(--border-medium-color, #e6e6e6);
17
+ }
18
+
19
+ .corner {
20
+ width: 246px;
21
+ height: 100%;
22
+ flex: none;
23
+ border-right: 1px solid var(--border-medium-color, #e6e6e6);
24
+ background: color-mix(in srgb, var(--on-background-color, #000) 3%, transparent);
25
+ }
26
+
27
+ .topbarNav {
28
+ display: flex;
29
+ align-items: center;
30
+ gap: 18px;
31
+ padding: 0 24px;
32
+ }
33
+
34
+ .navItem {
35
+ width: 64px;
36
+ height: 12px;
37
+ border-radius: 4px;
38
+ background: var(--surface01-color, color-mix(in srgb, var(--on-background-color, #000) 6%, transparent));
39
+ }
40
+
41
+ /* ---- Main: sidebar + content ---- */
42
+
43
+ .main {
44
+ flex: 1 1;
45
+ display: flex;
46
+ min-height: 0;
47
+ }
48
+
49
+ .sidebar {
50
+ width: 246px;
51
+ flex: none;
52
+ border-right: 1px solid var(--border-medium-color, #e6e6e6);
53
+ padding: 16px 12px;
54
+ display: flex;
55
+ flex-direction: column;
56
+ gap: 14px;
57
+ overflow: hidden;
58
+ }
59
+
60
+ .sidebarSearch {
61
+ height: 30px;
62
+ border-radius: 8px;
63
+ background: var(--surface01-color, color-mix(in srgb, var(--on-background-color, #000) 6%, transparent));
64
+ margin-bottom: 6px;
65
+ }
66
+
67
+ .sidebarRow {
68
+ height: 11px;
69
+ border-radius: 4px;
70
+ background: var(--surface01-color, color-mix(in srgb, var(--on-background-color, #000) 6%, transparent));
71
+ }
72
+
73
+ .content {
74
+ flex: 1 1;
75
+ min-width: 0;
76
+ overflow: hidden;
77
+ }
78
+
79
+ /* gentle pulse across the whole shell so it reads as loading */
80
+ .sidebar,
81
+ .topbarNav,
82
+ .corner {
83
+ animation: pulse 1.6s ease-in-out infinite;
84
+ }
85
+
86
+ @keyframes pulse {
87
+ 0%,
88
+ 100% {
89
+ opacity: 1;
90
+ }
91
+ 50% {
92
+ opacity: 0.65;
93
+ }
94
+ }
95
+
96
+ @media (prefers-reduced-motion: reduce) {
97
+ .sidebar,
98
+ .topbarNav,
99
+ .corner {
100
+ animation: none;
101
+ }
102
+ }
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import { ComponentsOverviewSkeleton } from '@teambit/explorer.ui.components-overview';
3
+ import styles from './workspace-skeleton.module.scss';
4
+
5
+ /**
6
+ * Full-shell loading placeholder shown while the (light) workspace query resolves. Mirrors the real
7
+ * workspace chrome — top bar, sidebar column, content grid — so there is no blank flash and no
8
+ * layout shift when the live UI takes over. Previously this gap rendered an empty `<div>`, which
9
+ * read as a frozen/blank screen on slower workspaces.
10
+ */
11
+ export function WorkspaceSkeleton() {
12
+ return (
13
+ <div className={styles.wrapper} aria-busy="true" aria-live="polite">
14
+ <div className={styles.topbar}>
15
+ <div className={styles.corner} />
16
+ <div className={styles.topbarNav}>
17
+ <span className={styles.navItem} />
18
+ <span className={styles.navItem} />
19
+ <span className={styles.navItem} />
20
+ </div>
21
+ </div>
22
+ <div className={styles.main}>
23
+ <div className={styles.sidebar}>
24
+ <span className={styles.sidebarSearch} />
25
+ {Array.from({ length: 10 }).map((_, i) => (
26
+ <span key={i} className={styles.sidebarRow} style={{ width: `${55 + ((i * 13) % 35)}%` }} />
27
+ ))}
28
+ </div>
29
+ <div className={styles.content}>
30
+ <ComponentsOverviewSkeleton count={12} />
31
+ </div>
32
+ </div>
33
+ </div>
34
+ );
35
+ }
@@ -24,6 +24,7 @@ import { useNavigationMessageListener } from '@teambit/workspace.hooks.use-navig
24
24
  import { useWorkspace } from './use-workspace';
25
25
  import { WorkspaceOverview } from './workspace-overview';
26
26
  import { WorkspaceProvider } from './workspace-provider';
27
+ import { WorkspaceSkeleton } from './workspace-skeleton';
27
28
  import styles from './workspace.module.scss';
28
29
  import type { WorkspaceUI } from '../../workspace.ui.runtime';
29
30
  import { ThemeFromUrlSync } from './theme-from-url';
@@ -67,7 +68,11 @@ export function Workspace({ routeSlot, menuSlot, sidebar, workspaceUI, onSidebar
67
68
  }, []);
68
69
  const sidebarOpenness = isSidebarOpen ? Layout.row : Layout.right;
69
70
  const themeName = currentTheme?.themeName || 'light';
70
- onSidebarTogglerChange(handleSidebarToggle);
71
+ // register the toggler with the parent in an effect, not during render — calling a parent callback
72
+ // (which stores/setStates it) mid-render risks "cannot update a component while rendering another".
73
+ useEffect(() => {
74
+ onSidebarTogglerChange(handleSidebarToggle);
75
+ }, [onSidebarTogglerChange, handleSidebarToggle]);
71
76
 
72
77
  useEffect(() => {
73
78
  if (!window) return;
@@ -82,14 +87,20 @@ export function Workspace({ routeSlot, menuSlot, sidebar, workspaceUI, onSidebar
82
87
 
83
88
  const location = useLocation();
84
89
 
85
- if (!workspace) {
86
- return <div className={styles.emptyContainer}></div>;
87
- }
90
+ // push the loaded components into the workspace UI store from an effect rather than during render —
91
+ // mutating an external store mid-render can re-render its subscribers in the middle of this render.
92
+ useEffect(() => {
93
+ if (workspace) workspaceUI.setComponents(workspace.components);
94
+ }, [workspaceUI, workspace]);
88
95
 
89
- workspaceUI.setComponents(workspace.components);
90
96
  const inIframe = typeof window !== 'undefined' && window.parent && window.parent !== window;
91
97
  const isOverview = location.pathname === '/' || location.pathname === '';
92
98
  const showTopBar = !isMinimal || (isMinimal && !isOverview);
99
+ if (!workspace) {
100
+ // While the light workspace query resolves, show a full-shell skeleton instead of a blank
101
+ // screen. The heavy per-component data (status, issues) streams in after first paint.
102
+ return <WorkspaceSkeleton />;
103
+ }
93
104
 
94
105
  return (
95
106
  <WorkspaceProvider workspace={workspace}>
@@ -1,5 +0,0 @@
1
- import React from 'react';
2
- export declare function ChangedPill(): React.JSX.Element;
3
- export declare function BuildSpinner(): React.JSX.Element;
4
- export declare function BuildingPreview(): React.JSX.Element;
5
- export declare function QueuedPreview(): React.JSX.Element;