Files
nexu-io__open-design/apps/web/tests/useProjectWorkspaceScope.test.tsx
lefarcen 8d5e90b1e9 fix(web): keep asking for a project scope the daemon has not materialized yet (#7464)
* fix(web): keep asking for a project scope the daemon has not materialized yet

Opening a team project for the first time on a client that has never seen it
left the chat pane spinning indefinitely. Reproduced 3 times out of 3 on a
fresh data directory against a real team workspace; one observed instance sat
there for over 200 seconds.

The project workspace scope is read while the daemon is still materializing
the project, so `/workspace-scope` answers 404 `PROJECT_NOT_FOUND` — the local
row does not exist YET. This reader classified every 404 as `unsupported`,
which its retry policy deliberately treats as final ("an authoritative daemon
capability decision"), so it never asked again. Seconds later the scope was
available; nobody was listening.

A null scope then wedges everything downstream that needs a resolved scope
KIND. `emptyConversationWriterAuthorized` requires personal / unbound / team,
so with `scope === null` all three disjuncts are false, the empty-conversation
seed can neither create a conversation nor settle into the read-only state,
and the chat pane renders neither. Measured in the browser at the moment of
the stall:

  scopeKind = null, writerAuthority = allowed, writerAuthorized = false,
  conversationsLen = 0, seed present, conversationLoadError = none

Both meanings of 404 are real and the daemon already distinguishes them in the
response body, so classify on that: `PROJECT_NOT_FOUND` is the transient
condition `unavailable` already models (and earns the retry), while a
body-less or non-JSON 404 stays `unsupported` for an old daemon that never had
the route.

Red spec first — `keeps polling a project the daemon has not materialized yet`
failed with "expected 1 to be greater than 1", i.e. exactly one request and no
retry. Verified in a real browser afterwards: the same first-open flow now
resolves (`scopeKind = team`, chat renders) on projects that previously stalled.

This removes the permanent wedge only. It does not make materialization
itself faster, and a background tab still has its retry timer throttled by the
browser.

* perf(web): probe a materializing project scope tightly before relaxing

The retry this adds a curve to is almost always waiting on a project row the
daemon has not written yet — the first open of a project pulled from the hub —
and that clears in a second or two. At the flat 5s cadence the reader spent
most of the wait idle AFTER the row already existed, and that dead time is
what a user reads as a slow first open.

Back off instead: 500ms, then doubling to the existing 5s ceiling. A genuinely
long outage still settles into the same cheap poll it had before, so this
costs the daemon nothing in the case the flat cadence was designed for.

The counter is scoped to the effect run, so a project or authority change
restarts the tight probe rather than inheriting the previous run's relaxed
delay.

Red spec first: `probes again quickly before settling into the steady retry
cadence` failed with "expected 1 to be greater than 1" — at 800ms the flat
cadence had not retried at all. Mutation-checked by pinning the delay back to
the constant, which turns it red again.

* fix(web): make scope retryability independent of the failure kind

CI caught a regression this branch introduced: [P0] successful first-open
materialization opens one read-only local mirror with the catalog title went
red, while passing on main in both preceding runs.

Reclassifying a not-yet-materialized project's 404 as `unavailable` did not
only unlock the retry. The failure KIND is what the rest of the app reads to
decide how much authority a project has, and two consumers changed behaviour
with it:

- `projectWorkspaceContextForRun` treats `unavailable` as grounds to fall back
  to the caller's own bound context instead of returning null.
- `ProjectView`'s `projectResourceAuthority` maps `forbidden`/`unsupported` to
  `denied`; with the kind widened it fell through to `workspace` instead.

So first open briefly ran with workspace authority over a project the daemon
had not materialized. That is a bigger change than "ask again", and not one
this branch set out to make.

Report the same kind both 404s always reported, and carry retryability as its
own field on the fetch error. A not-yet-materialized project stays
`unsupported` — every authority decision is byte-for-byte what main does —
and only the retry predicate changes.

The spec now pins the kind explicitly (`failure` stays `unsupported` while
polling), so widening it again turns the test red instead of a P0 three
minutes into CI.

* fix(web): time-box the not-yet-materialized retry

Adding `PROJECT_NOT_FOUND` to the retryable set inherited this reader's
unbounded poll, and that fits the two conditions differently. A backend outage
can start succeeding at any moment, so polling it forever is right — and a P0
covers exactly that (an open Team project stays usable while Workspace
authority retries a 503). "This project is not materialized yet" can only
become true while materialization is running; a project that is simply gone
answers the same 404 forever. Without a deadline a stale tab pointed at a
deleted project polls every 5s for as long as it stays open.

Time-box only the materialization case, at two minutes. The outage path keeps
the unbounded poll it has always had, untouched.

Red spec first: `stops polling a project that never materializes` failed with
57 requests where 33 were expected — it never stopped. Mutation-checked by
deleting the deadline, which returns it to red.
2026-08-27 03:06:05 +00:00

775 lines
26 KiB
TypeScript

// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
ProjectWorkspaceScopeResponse,
WorkspaceCollabContext,
} from '@open-design/contracts';
import {
projectWorkspaceScopeAuthorizesAmr,
useProjectWorkspaceScope,
} from '../src/collab/useProjectWorkspaceScope';
import { WORKSPACE_CONTEXT_REFRESH_EVENT } from '../src/collab/useWorkspaceContext';
class OpeningEventSource {
static instances: OpeningEventSource[] = [];
readonly url: string;
readonly withCredentials = false;
readonly CONNECTING = 0;
readonly OPEN = 1;
readonly CLOSED = 2;
readyState = this.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
closed = false;
deliveredListenerCalls = 0;
private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
constructor(url: string | URL) {
this.url = String(url);
OpeningEventSource.instances.push(this);
queueMicrotask(() => {
if (this.closed) return;
this.readyState = this.OPEN;
this.onopen?.(new Event('open'));
});
}
addEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
): void {
if (!listener) return;
const listeners = this.listeners.get(type) ?? new Set();
listeners.add(listener);
this.listeners.set(type, listeners);
}
removeEventListener(
type: string,
listener: EventListenerOrEventListenerObject | null,
): void {
if (!listener) return;
this.listeners.get(type)?.delete(listener);
}
dispatchEvent(event: Event): boolean {
for (const listener of this.listeners.get(event.type) ?? []) {
this.deliveredListenerCalls += 1;
if (typeof listener === 'function') listener.call(this, event);
else listener.handleEvent(event);
}
return true;
}
close(): void {
this.closed = true;
this.readyState = this.CLOSED;
}
emit(type: string, data: unknown = {}): void {
this.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) }));
}
listenerCount(type: string): number {
return this.listeners.get(type)?.size ?? 0;
}
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((next) => {
resolve = next;
});
return { promise, resolve };
}
function teamScope(
projectId: string,
workspaceId: string,
memberId: string,
): ProjectWorkspaceScopeResponse {
return {
scope: {
kind: 'team',
projectId,
workspaceId,
visibility: 'personal',
context: {
workspaceId,
workspaceType: 'team',
workspaceMemberId: memberId,
role: 'member',
memberStatus: 'active',
lifecycleState: 'active',
billingState: 'active',
planId: 'team_pro',
providerMode: 'platform_credits',
seatSummary: {
seatLimit: 5,
usedSeats: 2,
availableSeats: 3,
isSeatFull: false,
},
permissions: {
canManageMembers: false,
canManageBilling: false,
canInviteMembers: false,
canManageAutoRecharge: false,
canShareProjects: true,
canWriteSyncedFiles: true,
canViewWorkspaceSettings: true,
canManageSharedResources: false,
},
teamId: workspaceId,
teamName: workspaceId,
},
},
};
}
describe('useProjectWorkspaceScope', () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
OpeningEventSource.instances = [];
});
it('uses a fresh bootstrap scope without repeating the initial or connect-time read', async () => {
const response = teamScope('project-bootstrap', 'workspace-bootstrap', 'member-bootstrap');
const caller = response.scope.context as WorkspaceCollabContext;
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('EventSource', OpeningEventSource as unknown as typeof EventSource);
const hook = renderHook(() => useProjectWorkspaceScope(
'project-bootstrap',
caller,
caller.workspaceId,
response.scope,
));
expect(hook.result.current).toMatchObject({
loading: false,
scope: response.scope,
});
await act(async () => {
for (let turn = 0; turn < 4; turn += 1) await Promise.resolve();
});
expect(fetchMock).not.toHaveBeenCalled();
});
it('keeps a settled scope subscribed while SSE catch-up revalidates it', async () => {
let monotonicNow = 0;
vi.spyOn(globalThis.performance, 'now').mockImplementation(() => monotonicNow);
const caller = teamScope('project-loop', 'workspace-loop', 'member-loop')
.scope.context as WorkspaceCollabContext;
const scopeUrls: string[] = [];
vi.stubGlobal('EventSource', OpeningEventSource as unknown as typeof EventSource);
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/workspace/context')) {
return new Response(JSON.stringify({ context: caller }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (!url.includes('/workspace-scope')) {
throw new Error(`Unexpected fetch: ${url}`);
}
scopeUrls.push(url);
// Bound a broken implementation's self-exciting reconnect loop so the
// red test fails deterministically instead of spinning forever.
if (scopeUrls.length > 5) return new Promise<Response>(() => {});
const response = teamScope(
'project-loop',
'workspace-loop',
'member-loop',
);
if (response.scope.context) {
response.scope.context.teamName = `revision-${scopeUrls.length}`;
}
return new Response(
JSON.stringify(response),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}));
const hook = renderHook(() =>
useProjectWorkspaceScope(
'project-loop',
caller,
caller.workspaceId,
),
);
await waitFor(() => {
expect(hook.result.current.scope).toMatchObject({
projectId: 'project-loop',
context: { workspaceMemberId: 'member-loop' },
});
});
await waitFor(() => {
expect(scopeUrls).toHaveLength(2);
expect(hook.result.current.scope?.context).toMatchObject({
teamName: 'revision-2',
});
});
await act(async () => {
// Drain the fetch → React commit → EventSource onopen microtask chain.
// A broken implementation creates a new stream after every scope answer,
// whose onopen synchronously starts the next scope read.
for (let turn = 0; turn < 12; turn += 1) await Promise.resolve();
});
expect(scopeUrls).toHaveLength(2);
expect(hook.result.current.loading).toBe(false);
expect(hook.result.current.scope).not.toBeNull();
expect(OpeningEventSource.instances).toHaveLength(1);
expect(OpeningEventSource.instances[0]?.closed).toBe(false);
expect(
OpeningEventSource.instances[0]?.listenerCount('workspace-context-changed'),
).toBe(1);
// Advance the coalescer's monotonic clock without sleeping so this is a
// distinct invalidation, not the same 250 ms reconnect burst.
monotonicNow = 251;
await act(async () => {
OpeningEventSource.instances[0]?.emit('workspace-context-changed', {
type: 'workspace-context-changed',
});
await Promise.resolve();
});
expect(OpeningEventSource.instances[0]?.deliveredListenerCalls).toBeGreaterThan(0);
await waitFor(() => expect(scopeUrls).toHaveLength(3));
expect(hook.result.current.loading).toBe(false);
expect(hook.result.current.scope).not.toBeNull();
expect(OpeningEventSource.instances).toHaveLength(1);
hook.unmount();
});
it('fails closed when a project-catalog event revokes the currently open project', async () => {
const initial = teamScope(
'project-unshared',
'workspace-unshared',
'member-reader',
);
if (initial.scope.kind === 'team') initial.scope.visibility = 'team';
const caller = initial.scope.context as WorkspaceCollabContext;
const fetchMock = vi.fn(async () => new Response('{}', { status: 403 }));
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('EventSource', OpeningEventSource as unknown as typeof EventSource);
const hook = renderHook(() => useProjectWorkspaceScope(
'project-unshared',
caller,
caller.workspaceId,
initial.scope,
));
expect(hook.result.current).toMatchObject({
loading: false,
scope: initial.scope,
});
await act(async () => {
for (let turn = 0; turn < 4; turn += 1) await Promise.resolve();
});
expect(fetchMock).not.toHaveBeenCalled();
await act(async () => {
OpeningEventSource.instances[0]?.emit('team-projects-changed', {
type: 'team-projects-changed',
projectId: 'project-unshared',
kind: 'catalog',
});
await Promise.resolve();
});
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await waitFor(() => {
expect(hook.result.current).toEqual({
loading: false,
scope: null,
failure: 'forbidden',
});
});
});
it('revalidates and hides a stale scope when the same member authority changes', async () => {
const first = teamScope('project-role', 'workspace-role', 'member-role');
const second = deferred<Response>();
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(first), {
status: 200,
headers: { 'content-type': 'application/json' },
}))
.mockReturnValueOnce(second.promise);
vi.stubGlobal('fetch', fetchMock);
const initialCaller = first.scope.context as WorkspaceCollabContext;
const hook = renderHook(
({ caller }) => useProjectWorkspaceScope(
'project-role',
caller,
caller.workspaceId,
),
{ initialProps: { caller: initialCaller } },
);
await waitFor(() => {
expect(hook.result.current.scope?.context).toMatchObject({
role: 'member',
permissions: { canWriteSyncedFiles: true },
});
});
const promotedCaller: WorkspaceCollabContext = {
...initialCaller,
role: 'admin',
permissions: {
...initialCaller.permissions,
canShareProjects: false,
canWriteSyncedFiles: false,
},
};
hook.rerender({ caller: promotedCaller });
expect(
hook.result.current,
'same member id with changed role/permissions must not expose the old scope',
).toEqual({ loading: true, scope: null });
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
const promotedScope = teamScope(
'project-role',
'workspace-role',
'member-role',
);
if (promotedScope.scope.context) {
promotedScope.scope.context = {
...promotedScope.scope.context,
role: 'admin',
permissions: promotedCaller.permissions,
};
}
second.resolve(new Response(JSON.stringify(promotedScope), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
await waitFor(() => {
expect(hook.result.current.scope?.context).toMatchObject({
role: 'admin',
permissions: {
canShareProjects: false,
canWriteSyncedFiles: false,
},
});
});
});
it('distinguishes old-daemon, revoked and directory-outage failures', async () => {
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('project-old-daemon')) return new Response('{}', { status: 404 });
if (url.includes('project-revoked')) return new Response('{}', { status: 403 });
if (url.includes('project-outage')) return new Response('{}', { status: 503 });
throw new Error(`Unexpected fetch: ${url}`);
}));
const oldDaemon = renderHook(() =>
useProjectWorkspaceScope('project-old-daemon'),
);
await waitFor(() => {
expect(oldDaemon.result.current).toEqual({
loading: false,
scope: null,
failure: 'unsupported',
});
});
oldDaemon.unmount();
const revoked = renderHook(() =>
useProjectWorkspaceScope('project-revoked'),
);
await waitFor(() => {
expect(revoked.result.current).toEqual({
loading: false,
scope: null,
failure: 'forbidden',
});
});
revoked.unmount();
const outage = renderHook(() =>
useProjectWorkspaceScope('project-outage'),
);
await waitFor(() => {
expect(outage.result.current).toEqual({
loading: false,
scope: null,
failure: 'unavailable',
});
});
outage.unmount();
});
// A 404 from this endpoint means two very different things. An old daemon
// that never had the route is authoritatively unsupported. But
// `PROJECT_NOT_FOUND` on a team project simply means the daemon has not
// materialized the local row YET -- the first open of a project pulled from
// the hub. That clears within seconds, so giving up on it permanently leaves
// the scope null forever, which in turn wedges every consumer that needs a
// resolved scope kind (measured: the chat pane spun indefinitely because the
// empty-conversation seed can only act once the scope resolves).
it('keeps polling a project the daemon has not materialized yet', async () => {
vi.useFakeTimers();
let attempts = 0;
const fetchMock = vi.fn(async () => {
attempts += 1;
if (attempts <= 2) {
return new Response(
JSON.stringify({ error: { code: 'PROJECT_NOT_FOUND', message: 'not found' } }),
{ status: 404 },
);
}
return new Response(
JSON.stringify(teamScope('project-materializing', 'ws-1', 'wm-1')),
{ status: 200 },
);
});
vi.stubGlobal('fetch', fetchMock);
const view = renderHook(() => useProjectWorkspaceScope('project-materializing'));
await act(async () => {
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
});
expect(view.result.current.scope).toBeNull();
// Retrying must not widen the failure KIND. Consumers read that kind to
// decide how much authority the project has -- reporting `unavailable`
// here flipped `projectResourceAuthority` from `denied` to `workspace`
// mid-materialization and broke the first-open P0.
expect(view.result.current.failure).toBe('unsupported');
await act(async () => {
await vi.advanceTimersByTimeAsync(30_000);
});
expect(fetchMock.mock.calls.length).toBeGreaterThan(1);
expect(view.result.current.scope?.kind).toBe('team');
view.unmount();
});
// The condition being retried is usually "the daemon has not written this
// project's row yet", which clears in a second or two. A flat 5s cadence
// therefore spends most of the wait idle AFTER the row already exists, and
// that dead time is what a user sees as a slow first open. Probe tightly
// first, then relax to the steady cadence.
it('probes again quickly before settling into the steady retry cadence', async () => {
vi.useFakeTimers();
let attempts = 0;
const fetchMock = vi.fn(async () => {
attempts += 1;
if (attempts === 1) {
return new Response(
JSON.stringify({ error: { code: 'PROJECT_NOT_FOUND', message: 'not found' } }),
{ status: 404 },
);
}
return new Response(
JSON.stringify(teamScope('project-backoff', 'ws-1', 'wm-1')),
{ status: 200 },
);
});
vi.stubGlobal('fetch', fetchMock);
const view = renderHook(() => useProjectWorkspaceScope('project-backoff'));
await act(async () => {
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
});
expect(fetchMock).toHaveBeenCalledTimes(1);
// Well inside the steady 5s cadence.
await act(async () => {
await vi.advanceTimersByTimeAsync(800);
});
expect(fetchMock.mock.calls.length).toBeGreaterThan(1);
expect(view.result.current.scope?.kind).toBe('team');
view.unmount();
});
// "Not materialized yet" is only worth re-asking while it can still become
// true. A project that is simply gone answers the same 404 forever, and a
// stale tab pointed at a deleted project would otherwise poll for as long as
// it stays open. Give the materialization window a deadline; a transient
// backend outage keeps its existing unbounded poll because that one can
// still start succeeding at any moment.
it('stops polling a project that never materializes', async () => {
vi.useFakeTimers();
const fetchMock = vi.fn(async () => new Response(
JSON.stringify({ error: { code: 'PROJECT_NOT_FOUND', message: 'not found' } }),
{ status: 404 },
));
vi.stubGlobal('fetch', fetchMock);
const view = renderHook(() => useProjectWorkspaceScope('project-never-arrives'));
await act(async () => {
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(150_000);
});
const settled = fetchMock.mock.calls.length;
expect(settled).toBeGreaterThan(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(120_000);
});
expect(fetchMock.mock.calls.length).toBe(settled);
view.unmount();
});
it('does not poll a settled forbidden scope on the retry timer', async () => {
vi.useFakeTimers();
const fetchMock = vi.fn(async () => new Response('{}', { status: 403 }));
vi.stubGlobal('fetch', fetchMock);
const revoked = renderHook(() =>
useProjectWorkspaceScope('project-revoked-timer'),
);
await act(async () => {
for (let turn = 0; turn < 10; turn += 1) await Promise.resolve();
});
expect(revoked.result.current.failure).toBe('forbidden');
expect(fetchMock).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(20_000);
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('authorizes AMR only for explicit personal or team scopes', () => {
expect(projectWorkspaceScopeAuthorizesAmr(null)).toBe(false);
expect(projectWorkspaceScopeAuthorizesAmr({
kind: 'unbound',
projectId: 'project-a',
workspaceId: null,
context: null,
})).toBe(false);
expect(projectWorkspaceScopeAuthorizesAmr({
kind: 'unavailable',
projectId: 'project-a',
workspaceId: 'workspace-a',
visibility: 'personal',
context: null,
})).toBe(false);
expect(
projectWorkspaceScopeAuthorizesAmr(
teamScope('project-a', 'workspace-a', 'member-a').scope,
),
).toBe(true);
});
it('drops a late response from the previously open project', async () => {
const projectA = deferred<Response>();
const projectB = deferred<Response>();
vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/project-a/')) return projectA.promise;
if (url.includes('/project-b/')) return projectB.promise;
throw new Error(`Unexpected fetch: ${url}`);
}));
const hook = renderHook(
({ projectId }) => useProjectWorkspaceScope(projectId),
{ initialProps: { projectId: 'project-a' } },
);
hook.rerender({ projectId: 'project-b' });
await act(async () => {
projectB.resolve(new Response(
JSON.stringify(teamScope('project-b', 'workspace-b', 'member-b')),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
});
await waitFor(() => {
expect(hook.result.current.scope).toMatchObject({
projectId: 'project-b',
workspaceId: 'workspace-b',
});
});
await act(async () => {
projectA.resolve(new Response(
JSON.stringify(teamScope('project-a', 'workspace-a', 'member-a')),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
});
expect(hook.result.current.scope).toMatchObject({
projectId: 'project-b',
workspaceId: 'workspace-b',
});
});
it('does not expose project A scope during the transition frame to project B', async () => {
const projectB = deferred<Response>();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/project-a/')) {
return new Response(
JSON.stringify(teamScope('project-a', 'workspace-a', 'member-a')),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
}
if (url.includes('/project-b/')) return projectB.promise;
throw new Error(`Unexpected fetch: ${url}`);
}));
const hook = renderHook(
({ projectId }) => useProjectWorkspaceScope(projectId),
{ initialProps: { projectId: 'project-a' } },
);
await waitFor(() => {
expect(hook.result.current.scope).toMatchObject({
projectId: 'project-a',
workspaceId: 'workspace-a',
});
});
hook.rerender({ projectId: 'project-b' });
expect(hook.result.current).toEqual({ loading: true, scope: null });
});
it('revalidates the same project when the signed-in workspace member changes', async () => {
const memberNew = deferred<Response>();
const scopeResponses: (() => Response | Promise<Response>)[] = [
() =>
new Response(
JSON.stringify(teamScope('project-a', 'workspace-a', 'member-old')),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
() => memberNew.promise,
];
const scopeCalls: string[] = [];
const fetchMock = vi.fn((input: RequestInfo | URL): Promise<Response> => {
const url = String(input);
// The hook resolves the caller's own workspace identity before it can ask
// what this project's scope is FOR that caller. This case is about the
// scope read, so answer "no workspace" and keep the context read out of
// the scope response sequence.
if (url.includes('/api/workspace/context')) {
return Promise.resolve(
new Response(JSON.stringify({ context: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
scopeCalls.push(url);
const next = scopeResponses.shift();
if (!next) throw new Error(`Unexpected scope fetch: ${url}`);
return Promise.resolve(next());
});
vi.stubGlobal('fetch', fetchMock);
const hook = renderHook(() => useProjectWorkspaceScope('project-a'));
await waitFor(() => {
expect(hook.result.current.scope).toMatchObject({
context: { workspaceMemberId: 'member-old' },
});
});
act(() => {
window.dispatchEvent(new Event(WORKSPACE_CONTEXT_REFRESH_EVENT));
});
expect(hook.result.current).toEqual({ loading: true, scope: null });
await act(async () => {
memberNew.resolve(new Response(
JSON.stringify(teamScope('project-a', 'workspace-a', 'member-new')),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
});
await waitFor(() => {
expect(hook.result.current.scope).toMatchObject({
context: { workspaceMemberId: 'member-new' },
});
});
expect(scopeCalls).toHaveLength(2);
});
// A route bootstrap that answers AFTER ProjectView mounted flips
// `initialScope` from absent to present on an already-mounted hook. That must
// not strand the hook: the mount-time read is still the only thing that can
// settle state, because state was never seeded from the late prop.
//
// Regression: the initial-read skip keyed off the CURRENT render's
// "can seed" answer, so the late prop aborted the in-flight scope GET and
// returned without ever seeding a scope or clearing `loading`. `loading`
// then pinned `true` forever — and because the workspace invalidation SSE is
// only subscribed once a scope resolves, no later event could re-trigger the
// read either. ProjectView reads that stuck `loading` as
// `workspaceContextReadOnly`, so the whole workspace fell into the
// "This is a shared project — you can view and comment" read-only mode with a
// permanently non-editable composer.
it('settles when a bootstrap scope arrives after the initial read started', async () => {
vi.stubGlobal('EventSource', OpeningEventSource as unknown as typeof EventSource);
const bootstrapScope = {
kind: 'unbound',
projectId: 'project-late-bootstrap',
workspaceId: null,
context: null,
} as const;
const inFlight = deferred<Response>();
const scopeCalls: string[] = [];
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/workspace/context')) {
return Promise.resolve(new Response(JSON.stringify({ context: null }), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
}
if (!url.includes('/workspace-scope')) {
throw new Error(`Unexpected fetch: ${url}`);
}
scopeCalls.push(url);
// The mount-time read is still in flight when the bootstrap prop lands.
if (scopeCalls.length === 1) return inFlight.promise;
return Promise.resolve(new Response(
JSON.stringify({ scope: bootstrapScope }),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
});
vi.stubGlobal('fetch', fetchMock);
const hook = renderHook(
({ initialScope }: { initialScope?: typeof bootstrapScope }) =>
useProjectWorkspaceScope('project-late-bootstrap', null, null, initialScope),
{ initialProps: {} as { initialScope?: typeof bootstrapScope } },
);
await waitFor(() => expect(scopeCalls).toHaveLength(1));
expect(hook.result.current.loading).toBe(true);
// The route bootstrap answers now, after the hook already mounted.
hook.rerender({ initialScope: bootstrapScope });
await waitFor(() => {
expect(hook.result.current).toMatchObject({
loading: false,
scope: bootstrapScope,
});
});
});
});