@stapel/workspaces-react 0.13.0 → 0.14.0

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 (52) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/default/MembersManager.d.ts.map +1 -1
  3. package/dist/default/MembersManager.js +20 -6
  4. package/dist/default/MembersManager.js.map +1 -1
  5. package/dist/default/WorkspaceSettings.d.ts.map +1 -1
  6. package/dist/default/WorkspaceSettings.js +7 -3
  7. package/dist/default/WorkspaceSettings.js.map +1 -1
  8. package/dist/headless/Can.d.ts +8 -0
  9. package/dist/headless/Can.d.ts.map +1 -1
  10. package/dist/headless/Can.js +5 -1
  11. package/dist/headless/Can.js.map +1 -1
  12. package/dist/headless/Members.d.ts +14 -13
  13. package/dist/headless/Members.d.ts.map +1 -1
  14. package/dist/headless/Members.js +4 -12
  15. package/dist/headless/Members.js.map +1 -1
  16. package/dist/headless/RoleSelect.d.ts +15 -9
  17. package/dist/headless/RoleSelect.d.ts.map +1 -1
  18. package/dist/headless/RoleSelect.js +12 -8
  19. package/dist/headless/RoleSelect.js.map +1 -1
  20. package/dist/headless/WorkspaceList.d.ts +25 -10
  21. package/dist/headless/WorkspaceList.d.ts.map +1 -1
  22. package/dist/headless/WorkspaceList.js +14 -5
  23. package/dist/headless/WorkspaceList.js.map +1 -1
  24. package/dist/i18n/keys.d.ts +13 -0
  25. package/dist/i18n/keys.d.ts.map +1 -1
  26. package/dist/i18n/keys.js +23 -0
  27. package/dist/i18n/keys.js.map +1 -1
  28. package/dist/i18n/ru.d.ts.map +1 -1
  29. package/dist/i18n/ru.js +7 -0
  30. package/dist/i18n/ru.js.map +1 -1
  31. package/dist/model/queries.d.ts +13 -7
  32. package/dist/model/queries.d.ts.map +1 -1
  33. package/dist/model/queries.js +9 -9
  34. package/dist/model/queries.js.map +1 -1
  35. package/dist/model/selection.d.ts +20 -7
  36. package/dist/model/selection.d.ts.map +1 -1
  37. package/dist/model/selection.js +32 -12
  38. package/dist/model/selection.js.map +1 -1
  39. package/llms.txt +1 -1
  40. package/manifest.json +9 -2
  41. package/package.json +2 -2
  42. package/src/analytics/generated/events.json +1 -1
  43. package/src/default/MembersManager.tsx +69 -16
  44. package/src/default/WorkspaceSettings.tsx +41 -5
  45. package/src/headless/Can.tsx +13 -1
  46. package/src/headless/Members.tsx +18 -26
  47. package/src/headless/RoleSelect.tsx +17 -14
  48. package/src/headless/WorkspaceList.tsx +31 -14
  49. package/src/i18n/keys.ts +26 -0
  50. package/src/i18n/ru.ts +10 -0
  51. package/src/model/queries.ts +26 -16
  52. package/src/model/selection.tsx +73 -21
@@ -8,8 +8,15 @@ import {
8
8
  useState,
9
9
  } from "react";
10
10
  import type { Context, ReactElement, ReactNode } from "react";
11
- import { createRepository } from "@stapel/core";
12
- import type { Repository } from "@stapel/core";
11
+ import {
12
+ bothLoaded,
13
+ createRepository,
14
+ loadLoading,
15
+ loadReady,
16
+ loadStateFromQuery,
17
+ mapLoad,
18
+ } from "@stapel/core";
19
+ import type { LoadState, Repository } from "@stapel/core";
13
20
  import type { Workspace } from "../api/types.js";
14
21
  import { useWorkspaces } from "./queries.js";
15
22
  import { useSetPreferredWorkspace } from "./mutations.js";
@@ -96,11 +103,26 @@ export interface WorkspaceSelectionUrlBinding {
96
103
 
97
104
  /** What {@link useWorkspaceSelection} hands back. */
98
105
  export interface WorkspaceSelection {
99
- /** The caller's workspaces (empty before load, and for a guest). */
100
- readonly workspaces: readonly Workspace[];
101
106
  /**
102
- * The resolved workspace — `null` while loading, and for a person who
103
- * belongs to none.
107
+ * The workspace list, as a state a host cannot flatten: `loading` / `ready`
108
+ * with the rows / `failed` with the error. Render it through core's
109
+ * `matchList`.
110
+ *
111
+ * THIS FIELD IS THE FIX FOR THE 2026-08-09 INCIDENT. What used to be here
112
+ * was `workspaces: readonly Workspace[]` beside `loading: boolean`, and NO
113
+ * error field at all — so when the list endpoint answered 404 (a route
114
+ * mounted one segment too deep), a host saw `loading: false`, `workspaces:
115
+ * []`, `current: null`, and had literally no way to tell that apart from a
116
+ * person who belongs to no workspace. Every screen in the product said "you
117
+ * have no workspaces" for hours. The distinction was not merely easy to
118
+ * miss; it was ABSENT from the type.
119
+ */
120
+ readonly state: LoadState<readonly Workspace[]>;
121
+ /**
122
+ * The resolved workspace, once the list is `ready` and the chain has picked
123
+ * one. `null` while loading, when the load FAILED, and for a person who
124
+ * belongs to none — which is exactly why a screen must branch on
125
+ * {@link state} and not on this being null.
104
126
  *
105
127
  * Downstream fetching must gate on THIS, never on a raw id: an id that has
106
128
  * not been checked against the fetched list is exactly how a client ends up
@@ -116,8 +138,6 @@ export interface WorkspaceSelection {
116
138
  * this. The person has already been landed somewhere sane.
117
139
  */
118
140
  readonly urlWorkspaceInvalid: boolean;
119
- /** The workspace list is still loading. */
120
- readonly loading: boolean;
121
141
  /**
122
142
  * An EXPLICIT choice by the person — the only entry point that persists.
123
143
  * Writes the URL (push), local storage, and the backend preference.
@@ -128,6 +148,10 @@ export interface WorkspaceSelection {
128
148
  refetch: () => Promise<void>;
129
149
  }
130
150
 
151
+ /** Stable identity for the no-rows case — a fresh `[]` per render would
152
+ * re-run every consumer effect that depends on the memoised bag (#251). */
153
+ const EMPTY_WORKSPACES: readonly Workspace[] = [];
154
+
131
155
  const SelectionContext: Context<WorkspaceSelection | null> =
132
156
  createContext<WorkspaceSelection | null>(null);
133
157
 
@@ -227,11 +251,38 @@ export function WorkspaceSelectionProvider(
227
251
  };
228
252
  }, [repo]);
229
253
 
230
- const workspaces = useMemo(
231
- () => query.data?.workspaces ?? [],
232
- [query.data]
254
+ // The list read and the local-storage read must BOTH land before the chain
255
+ // can answer, and a failure of either must not read as "still loading" —
256
+ // `bothLoaded` is exactly that rule, so it is not restated here.
257
+ // Memoised on the query's OWN fields, never on the query object: TanStack
258
+ // returns a new result object every render, so `[query]` here would hand
259
+ // every consumer a new `state` on every render — the #251 loop the bag
260
+ // memoisation below exists to prevent, reintroduced one level up.
261
+ const { status: queryStatus, data: queryData, error: queryError } = query;
262
+ const state = useMemo<LoadState<readonly Workspace[]>>(
263
+ () =>
264
+ mapLoad(
265
+ bothLoaded(
266
+ mapLoad(
267
+ loadStateFromQuery({
268
+ status: queryStatus,
269
+ data: queryData,
270
+ error: queryError,
271
+ }),
272
+ (list) => list.workspaces ?? []
273
+ ),
274
+ localRead ? loadReady(null) : loadLoading()
275
+ ),
276
+ ([rows]) => rows
277
+ ),
278
+ [queryStatus, queryData, queryError, localRead]
233
279
  );
234
- const loading = query.isLoading || !localRead;
280
+
281
+ // The resolution chain below is a COMPUTATION over rows, not a rendering of
282
+ // them, so it reads the rows flat — and it runs only when the state is
283
+ // `ready`, which is what keeps a failed load from resolving to anything.
284
+ const workspaces = state.status === "ready" ? state.data : EMPTY_WORKSPACES;
285
+ const resolvable = state.status === "ready";
235
286
 
236
287
  const memberOf = useCallback(
237
288
  (id: string | null | undefined): Workspace | null =>
@@ -246,7 +297,7 @@ export function WorkspaceSelectionProvider(
246
297
  workspace: Workspace | null;
247
298
  source: WorkspaceSelectionSource | null;
248
299
  }>(() => {
249
- if (loading || workspaces.length === 0) {
300
+ if (!resolvable || workspaces.length === 0) {
250
301
  return { workspace: null, source: null };
251
302
  }
252
303
  const explicit = memberOf(chosenId);
@@ -266,7 +317,7 @@ export function WorkspaceSelectionProvider(
266
317
  // Last resort only — see this module's header for why it is last and why
267
318
  // it is still here.
268
319
  return { workspace: workspaces[0] ?? null, source: "positional" };
269
- }, [loading, workspaces, memberOf, chosenId, urlWorkspaceId, localId, query.data]);
320
+ }, [resolvable, workspaces, memberOf, chosenId, urlWorkspaceId, localId, query.data]);
270
321
 
271
322
  // The URL named something the person cannot open. Deliberately NOT an
272
323
  // error state that blanks the screen, and deliberately NOT a silent
@@ -274,7 +325,7 @@ export function WorkspaceSelectionProvider(
274
325
  // showing workspace Y's data is the same misattribution class as #239.
275
326
  // They land through the rest of the chain, and this flag drives the notice.
276
327
  const urlWorkspaceInvalid =
277
- !loading &&
328
+ resolvable &&
278
329
  workspaces.length > 0 &&
279
330
  chosenId === null &&
280
331
  !!urlWorkspaceId &&
@@ -298,11 +349,14 @@ export function WorkspaceSelectionProvider(
298
349
  // later-corrected backend preference or instance default; deleting it just
299
350
  // stops the next boot retrying a dead id.
300
351
  useEffect(() => {
301
- if (loading || !repo || localId === null) return;
352
+ // Only a SUCCESSFUL load proves a stored id is dead. Deleting it because
353
+ // the request failed would punish an outage by forgetting the person's
354
+ // workspace on every affected boot.
355
+ if (!resolvable || !repo || localId === null) return;
302
356
  if (memberOf(localId)) return;
303
357
  setLocalId(null);
304
358
  void repo.del(REPO_KEY).catch(() => undefined);
305
- }, [loading, repo, localId, memberOf]);
359
+ }, [resolvable, repo, localId, memberOf]);
306
360
 
307
361
  /**
308
362
  * The only path that persists anything, and that is the whole write policy:
@@ -349,20 +403,18 @@ export function WorkspaceSelectionProvider(
349
403
  // spinner that never resolves, with nothing in the console.
350
404
  const value = useMemo<WorkspaceSelection>(
351
405
  () => ({
352
- workspaces,
406
+ state,
353
407
  current: resolved.workspace,
354
408
  source: resolved.source,
355
409
  urlWorkspaceInvalid,
356
- loading,
357
410
  switchTo,
358
411
  refetch,
359
412
  }),
360
413
  [
361
- workspaces,
414
+ state,
362
415
  resolved.workspace,
363
416
  resolved.source,
364
417
  urlWorkspaceInvalid,
365
- loading,
366
418
  switchTo,
367
419
  refetch,
368
420
  ]