Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -431,3 +431,6 @@ FodyWeavers.xsd

# Claude Code agent worktrees (each is a full checkout; never commit or lint them)
.claude/worktrees/

# Playwright MCP snapshots/logs (hold real MP member data; never commit)
.playwright-mcp/
40 changes: 40 additions & 0 deletions src/contexts/user-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,46 @@ describe('UserContext', () => {
spy.mockRestore();
});

// A promise streamed from a Server Component arrives as React Flight's
// `ReactPromise`: a Promise subclass whose `then()` returns `undefined`, so
// `.catch()` on it returns `undefined` too. Plain Promises hide that.
function flightPromise<T>(settle: (res: (v: T) => void, rej: (e: unknown) => void) => void) {
const inner = new Promise<T>(settle);
const p = Object.create(Promise.prototype) as Promise<T>;
Object.defineProperty(p, 'then', {
value: (onFulfilled?: (v: T) => unknown, onRejected?: (e: unknown) => unknown) => {
inner.then(onFulfilled, onRejected);
return undefined;
},
});
return p;
}

it('should read a Flight-streamed promise whose then() returns undefined', async () => {
await renderWithProvider(flightPromise((res) => res(profile)), <ProfileProbe />);

await waitFor(() => {
expect(screen.getByTestId('name')).toHaveTextContent('John');
});
expect(screen.queryByTestId('err')).toBeNull();
});

it('should degrade a rejected Flight-streamed promise to null', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});

await renderWithProvider(
flightPromise<CurrentUserProfile | null>((_res, rej) => rej(new Error('down'))),
<ProfileProbe />
);

await waitFor(() => {
expect(screen.getByTestId('name')).toHaveTextContent('none');
});
expect(spy).toHaveBeenCalledWith('user.profile.load_failed', { name: 'Error' });

spy.mockRestore();
});

it('should log a non-Error rejection by its type', async () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});

Expand Down
18 changes: 17 additions & 1 deletion src/contexts/user-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ function profileLoadFailed(error: unknown): null {
return null;
}

async function settleProfile(
profilePromise: PromiseLike<CurrentUserProfile | null>
): Promise<CurrentUserProfile | null> {
try {
return await profilePromise;
} catch (error) {
return profileLoadFailed(error);
}
}

interface UserProviderProps {
/**
* The signed-in user's MP profile, started on the server by the `(web)`
Expand Down Expand Up @@ -58,8 +68,14 @@ export function UserProvider({ profilePromise, children }: UserProviderProps) {
// signed-in user with no way to sign out ("Go to sign in" just bounced back
// to the same failure). As `null` it renders the no-profile header, whose
// menu still offers sign-out. Memoised so `use()` sees a stable promise.
//
// Not `profilePromise.catch(...)`: a promise streamed from a Server
// Component arrives as React Flight's `ReactPromise`, whose `then()` returns
// `undefined` instead of a new promise, so `.catch()` returned `undefined`
// and `use(undefined)` threw on every page. Awaiting it only relies on
// `then()` invoking its callbacks.
const safeServerPromise = useMemo(
() => profilePromise.catch(profileLoadFailed),
() => settleProfile(profilePromise),
[profilePromise]
);
const userProfilePromise = refreshedPromise ?? safeServerPromise;
Expand Down
Loading