@stapel/workspaces-react 0.12.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.
- package/CHANGELOG.md +84 -0
- package/MODULE.md +5 -1
- package/README.md +22 -0
- package/dist/api/generated/schema.d.ts +13 -8
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/MembersManager.d.ts.map +1 -1
- package/dist/default/MembersManager.js +20 -6
- package/dist/default/MembersManager.js.map +1 -1
- package/dist/default/WorkspaceSettings.d.ts.map +1 -1
- package/dist/default/WorkspaceSettings.js +7 -3
- package/dist/default/WorkspaceSettings.js.map +1 -1
- package/dist/headless/Can.d.ts +8 -0
- package/dist/headless/Can.d.ts.map +1 -1
- package/dist/headless/Can.js +5 -1
- package/dist/headless/Can.js.map +1 -1
- package/dist/headless/Members.d.ts +14 -13
- package/dist/headless/Members.d.ts.map +1 -1
- package/dist/headless/Members.js +4 -12
- package/dist/headless/Members.js.map +1 -1
- package/dist/headless/RoleSelect.d.ts +15 -9
- package/dist/headless/RoleSelect.d.ts.map +1 -1
- package/dist/headless/RoleSelect.js +12 -8
- package/dist/headless/RoleSelect.js.map +1 -1
- package/dist/headless/WorkspaceList.d.ts +25 -10
- package/dist/headless/WorkspaceList.d.ts.map +1 -1
- package/dist/headless/WorkspaceList.js +14 -5
- package/dist/headless/WorkspaceList.js.map +1 -1
- package/dist/i18n/es.d.ts +34 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +43 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/generated/errors.es.gen.d.ts +9 -0
- package/dist/i18n/generated/errors.es.gen.d.ts.map +1 -0
- package/dist/i18n/generated/errors.es.gen.js +76 -0
- package/dist/i18n/generated/errors.es.gen.js.map +1 -0
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -0
- package/dist/i18n/generated/errors.ru.gen.js.map +1 -1
- package/dist/i18n/keys.d.ts +13 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +23 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +7 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/model/queries.d.ts +13 -7
- package/dist/model/queries.d.ts.map +1 -1
- package/dist/model/queries.js +9 -9
- package/dist/model/queries.js.map +1 -1
- package/dist/model/selection.d.ts +20 -7
- package/dist/model/selection.d.ts.map +1 -1
- package/dist/model/selection.js +32 -12
- package/dist/model/selection.js.map +1 -1
- package/llms.txt +3 -3
- package/manifest.json +16 -3
- package/package.json +12 -3
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +13 -8
- package/src/default/MembersManager.tsx +69 -16
- package/src/default/WorkspaceSettings.tsx +41 -5
- package/src/headless/Can.tsx +13 -1
- package/src/headless/Members.tsx +18 -26
- package/src/headless/RoleSelect.tsx +17 -14
- package/src/headless/WorkspaceList.tsx +31 -14
- package/src/i18n/es.ts +47 -0
- package/src/i18n/generated/errors.es.gen.ts +82 -0
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +7 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -0
- package/src/i18n/keys.ts +26 -0
- package/src/i18n/ru.ts +10 -0
- package/src/model/queries.ts +26 -16
- package/src/model/selection.tsx +73 -21
|
@@ -490,6 +490,13 @@
|
|
|
490
490
|
"remediation": "wait_and_retry",
|
|
491
491
|
"en": "The authentication service is unavailable; try again later"
|
|
492
492
|
},
|
|
493
|
+
{
|
|
494
|
+
"code": "error.503.profiles_not_configured",
|
|
495
|
+
"status": 503,
|
|
496
|
+
"params": [],
|
|
497
|
+
"remediation": "contact_support",
|
|
498
|
+
"en": "This deployment has no profiles service configured, so a display name cannot be written here"
|
|
499
|
+
},
|
|
493
500
|
{
|
|
494
501
|
"code": "error.503.profiles_unavailable",
|
|
495
502
|
"status": 503,
|
|
@@ -77,5 +77,6 @@ export const workspacesErrorBundleRu: Record<WorkspacesErrorCode, string> = {
|
|
|
77
77
|
"error.429.too_many_requests": "Слишком много запросов. Пожалуйста, повторите попытку позже.",
|
|
78
78
|
"error.500.internal": "Что-то пошло не так",
|
|
79
79
|
"error.503.auth_unavailable": "Сервис аутентификации недоступен; повторите попытку позже",
|
|
80
|
+
"error.503.profiles_not_configured": "В этой инсталляции не настроен сервис профилей — отображаемое имя здесь записать нельзя",
|
|
80
81
|
"error.503.profiles_unavailable": "Сервис профилей недоступен; повторите попытку позже",
|
|
81
82
|
};
|
package/src/i18n/keys.ts
CHANGED
|
@@ -12,13 +12,26 @@ import { workspacesErrorBundleEn } from "./generated/errors.gen.js";
|
|
|
12
12
|
*/
|
|
13
13
|
export const WORKSPACES_I18N_KEYS = {
|
|
14
14
|
unknownError: "workspaces.error.unknown",
|
|
15
|
+
// Retry affordance beside a stated failure — an error a person cannot act
|
|
16
|
+
// on is only half the message.
|
|
17
|
+
retry: "workspaces.retry",
|
|
15
18
|
// Workspace list (WorkspaceList headless)
|
|
16
19
|
listLoading: "workspaces.list.loading",
|
|
17
20
|
listEmpty: "workspaces.list.empty",
|
|
21
|
+
/**
|
|
22
|
+
* "We could not load your workspaces" — the sentence whose ABSENCE was the
|
|
23
|
+
* 2026-08-09 incident. Deliberately about US failing to load, never about
|
|
24
|
+
* the person having none, and never a 404's "no longer available" (which
|
|
25
|
+
* asserts the thing does not exist — tracker #211).
|
|
26
|
+
*/
|
|
27
|
+
listLoadFailed: "workspaces.list.load_failed",
|
|
18
28
|
listCreate: "workspaces.list.create",
|
|
19
29
|
listCreating: "workspaces.list.creating",
|
|
20
30
|
// Members (Members headless)
|
|
21
31
|
membersLoading: "workspaces.members.loading",
|
|
32
|
+
membersEmpty: "workspaces.members.empty",
|
|
33
|
+
membersLoadFailed: "workspaces.members.load_failed",
|
|
34
|
+
rolesLoadFailed: "workspaces.roles.load_failed",
|
|
22
35
|
membersInvite: "workspaces.members.invite",
|
|
23
36
|
membersInviting: "workspaces.members.inviting",
|
|
24
37
|
membersUpdateRole: "workspaces.members.update_role",
|
|
@@ -34,6 +47,9 @@ export const WORKSPACES_I18N_KEYS = {
|
|
|
34
47
|
fieldSlug: "workspaces.settings.field.slug",
|
|
35
48
|
fieldType: "workspaces.settings.field.type",
|
|
36
49
|
save: "workspaces.settings.save",
|
|
50
|
+
// Why a settings control is switched off (core's `useActionGate`).
|
|
51
|
+
blockedNotOwner: "workspaces.settings.blocked.not_owner",
|
|
52
|
+
blockedNameRequired: "workspaces.settings.blocked.name_required",
|
|
37
53
|
saving: "workspaces.settings.saving",
|
|
38
54
|
dangerZoneTitle: "workspaces.settings.danger_zone.title",
|
|
39
55
|
deleteWorkspace: "workspaces.settings.danger_zone.delete",
|
|
@@ -102,11 +118,18 @@ export const workspacesI18nBundleEn: I18nDictionary = {
|
|
|
102
118
|
|
|
103
119
|
// workspaces-react UI
|
|
104
120
|
"workspaces.error.unknown": "Something went wrong. Please try again.",
|
|
121
|
+
"workspaces.retry": "Try again",
|
|
105
122
|
"workspaces.list.loading": "Loading workspaces…",
|
|
106
123
|
"workspaces.list.empty": "No workspaces yet.",
|
|
124
|
+
"workspaces.list.load_failed":
|
|
125
|
+
"We could not load your workspaces. This is a problem on our side, not a sign that you have none.",
|
|
107
126
|
"workspaces.list.create": "Create workspace",
|
|
108
127
|
"workspaces.list.creating": "Creating…",
|
|
109
128
|
"workspaces.members.loading": "Loading members…",
|
|
129
|
+
"workspaces.members.empty": "No members yet.",
|
|
130
|
+
"workspaces.members.load_failed": "We could not load the member list.",
|
|
131
|
+
"workspaces.roles.load_failed":
|
|
132
|
+
"We could not load the role list, so the role picker is empty. It is not a workspace without roles.",
|
|
110
133
|
"workspaces.members.invite": "Invite",
|
|
111
134
|
"workspaces.members.inviting": "Inviting…",
|
|
112
135
|
"workspaces.members.update_role": "Change role",
|
|
@@ -120,6 +143,9 @@ export const workspacesI18nBundleEn: I18nDictionary = {
|
|
|
120
143
|
"workspaces.settings.field.slug": "URL slug",
|
|
121
144
|
"workspaces.settings.field.type": "Type",
|
|
122
145
|
"workspaces.settings.save": "Save changes",
|
|
146
|
+
"workspaces.settings.blocked.not_owner":
|
|
147
|
+
"Only the workspace owner can change these settings.",
|
|
148
|
+
"workspaces.settings.blocked.name_required": "Enter a workspace name.",
|
|
123
149
|
"workspaces.settings.saving": "Saving…",
|
|
124
150
|
"workspaces.settings.danger_zone.title": "Danger zone",
|
|
125
151
|
"workspaces.settings.danger_zone.delete": "Delete workspace",
|
package/src/i18n/ru.ts
CHANGED
|
@@ -25,11 +25,18 @@ export const workspacesI18nBundleRu: I18nDictionary = {
|
|
|
25
25
|
|
|
26
26
|
// workspaces-react UI (hand-written ru mirror of the en copy in keys.ts)
|
|
27
27
|
"workspaces.error.unknown": "Что-то пошло не так. Попробуйте ещё раз.",
|
|
28
|
+
"workspaces.retry": "Попробовать снова",
|
|
28
29
|
"workspaces.list.loading": "Загрузка рабочих пространств…",
|
|
29
30
|
"workspaces.list.empty": "Пока нет рабочих пространств.",
|
|
31
|
+
"workspaces.list.load_failed":
|
|
32
|
+
"Не удалось загрузить ваши рабочие пространства. Это сбой на нашей стороне, а не признак того, что их у вас нет.",
|
|
30
33
|
"workspaces.list.create": "Создать рабочее пространство",
|
|
31
34
|
"workspaces.list.creating": "Создание…",
|
|
32
35
|
"workspaces.members.loading": "Загрузка участников…",
|
|
36
|
+
"workspaces.members.empty": "Пока нет участников.",
|
|
37
|
+
"workspaces.members.load_failed": "Не удалось загрузить список участников.",
|
|
38
|
+
"workspaces.roles.load_failed":
|
|
39
|
+
"Не удалось загрузить список ролей, поэтому выбор роли пуст. Это не рабочее пространство без ролей.",
|
|
33
40
|
"workspaces.members.invite": "Пригласить",
|
|
34
41
|
"workspaces.members.inviting": "Приглашение…",
|
|
35
42
|
"workspaces.members.update_role": "Изменить роль",
|
|
@@ -43,6 +50,9 @@ export const workspacesI18nBundleRu: I18nDictionary = {
|
|
|
43
50
|
"workspaces.settings.field.slug": "URL-идентификатор",
|
|
44
51
|
"workspaces.settings.field.type": "Тип",
|
|
45
52
|
"workspaces.settings.save": "Сохранить изменения",
|
|
53
|
+
"workspaces.settings.blocked.not_owner":
|
|
54
|
+
"Менять эти настройки может только владелец рабочего пространства.",
|
|
55
|
+
"workspaces.settings.blocked.name_required": "Введите название рабочего пространства.",
|
|
46
56
|
"workspaces.settings.saving": "Сохранение…",
|
|
47
57
|
"workspaces.settings.danger_zone.title": "Опасная зона",
|
|
48
58
|
"workspaces.settings.danger_zone.delete": "Удалить рабочее пространство",
|
package/src/model/queries.ts
CHANGED
|
@@ -4,8 +4,8 @@ import type {
|
|
|
4
4
|
UseInfiniteQueryResult,
|
|
5
5
|
UseQueryResult,
|
|
6
6
|
} from "@tanstack/react-query";
|
|
7
|
-
import { useActiveSessionReady } from "@stapel/core";
|
|
8
|
-
import type { StapelApiError } from "@stapel/core";
|
|
7
|
+
import { loadStateFromQuery, mapLoad, useActiveSessionReady } from "@stapel/core";
|
|
8
|
+
import type { LoadState, StapelApiError } from "@stapel/core";
|
|
9
9
|
import type {
|
|
10
10
|
InvitationPage,
|
|
11
11
|
InvitationPreview,
|
|
@@ -223,15 +223,21 @@ export function useInvitationPreview(
|
|
|
223
223
|
/** What {@link useCapabilities} returns: the caller's granted capability
|
|
224
224
|
* strings in one workspace plus the wildcard-aware `can()` check. */
|
|
225
225
|
export interface CapabilitiesResult {
|
|
226
|
-
/**
|
|
227
|
-
*
|
|
228
|
-
|
|
226
|
+
/**
|
|
227
|
+
* The capability read, as a state. `ready` with the verbatim registry
|
|
228
|
+
* strings of the caller's role (wildcards included; `[]` for a non-member,
|
|
229
|
+
* and for a backend older than 0.6.0 which does not send the field at all).
|
|
230
|
+
*
|
|
231
|
+
* A state and not an array, because "you may not do this" and "we could not
|
|
232
|
+
* find out whether you may" both end in a hidden button, and only one of
|
|
233
|
+
* them should end in silence. `can()` stays deny-by-default in both — that
|
|
234
|
+
* part is a security property and does not change — but a screen that wants
|
|
235
|
+
* to SAY why can now tell them apart.
|
|
236
|
+
*/
|
|
237
|
+
readonly state: LoadState<readonly string[]>;
|
|
229
238
|
/** Wildcard-aware check (`*` / `prefix.*` — the backend matcher, ported).
|
|
230
239
|
* UI convenience only: the backend re-checks on every operation. */
|
|
231
240
|
can(capability: string): boolean;
|
|
232
|
-
readonly isLoading: boolean;
|
|
233
|
-
readonly isError: boolean;
|
|
234
|
-
readonly error: StapelApiError | null;
|
|
235
241
|
}
|
|
236
242
|
|
|
237
243
|
/**
|
|
@@ -243,13 +249,17 @@ export interface CapabilitiesResult {
|
|
|
243
249
|
*/
|
|
244
250
|
export function useCapabilities(workspaceId: string | null): CapabilitiesResult {
|
|
245
251
|
const query = useWorkspace(workspaceId);
|
|
246
|
-
|
|
252
|
+
// `my_capabilities` is additive since stapel-workspaces 0.6.0, so a
|
|
253
|
+
// SUCCESSFUL read against an older backend legitimately carries none. That
|
|
254
|
+
// is an empty grant, not a failed load, and the two must not merge.
|
|
255
|
+
const state = mapLoad(
|
|
256
|
+
loadStateFromQuery(query),
|
|
257
|
+
(workspace) => workspace.my_capabilities ?? []
|
|
258
|
+
);
|
|
247
259
|
return {
|
|
248
|
-
|
|
249
|
-
can: (capability) =>
|
|
250
|
-
|
|
251
|
-
isError: query.isError,
|
|
252
|
-
error: query.error ?? null,
|
|
260
|
+
state,
|
|
261
|
+
can: (capability) =>
|
|
262
|
+
state.status === "ready" && hasCapability(state.data, capability),
|
|
253
263
|
};
|
|
254
264
|
}
|
|
255
265
|
|
|
@@ -299,7 +309,7 @@ export function useCapabilityGate(
|
|
|
299
309
|
workspaceId: string | null,
|
|
300
310
|
capability: string
|
|
301
311
|
): CapabilityGate {
|
|
302
|
-
const { can,
|
|
312
|
+
const { can, state } = useCapabilities(workspaceId);
|
|
303
313
|
const level = capabilityLevel(capability);
|
|
304
314
|
return {
|
|
305
315
|
capability,
|
|
@@ -307,6 +317,6 @@ export function useCapabilityGate(
|
|
|
307
317
|
level,
|
|
308
318
|
requiresStepUp: level === "high",
|
|
309
319
|
stepUpScope: level === "high" ? SENSITIVE_SCOPE : null,
|
|
310
|
-
isLoading,
|
|
320
|
+
isLoading: state.status === "loading",
|
|
311
321
|
};
|
|
312
322
|
}
|
package/src/model/selection.tsx
CHANGED
|
@@ -8,8 +8,15 @@ import {
|
|
|
8
8
|
useState,
|
|
9
9
|
} from "react";
|
|
10
10
|
import type { Context, ReactElement, ReactNode } from "react";
|
|
11
|
-
import {
|
|
12
|
-
|
|
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
|
|
103
|
-
*
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
414
|
+
state,
|
|
362
415
|
resolved.workspace,
|
|
363
416
|
resolved.source,
|
|
364
417
|
urlWorkspaceInvalid,
|
|
365
|
-
loading,
|
|
366
418
|
switchTo,
|
|
367
419
|
refetch,
|
|
368
420
|
]
|