diff --git a/.gitignore b/.gitignore index 95e5696..705c837 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/src/contexts/user-context.test.tsx b/src/contexts/user-context.test.tsx index 7faca6d..454d915 100644 --- a/src/contexts/user-context.test.tsx +++ b/src/contexts/user-context.test.tsx @@ -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(settle: (res: (v: T) => void, rej: (e: unknown) => void) => void) { + const inner = new Promise(settle); + const p = Object.create(Promise.prototype) as Promise; + 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)), ); + + 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((_res, rej) => rej(new Error('down'))), + + ); + + 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(() => {}); diff --git a/src/contexts/user-context.tsx b/src/contexts/user-context.tsx index cac530e..ba71e9e 100644 --- a/src/contexts/user-context.tsx +++ b/src/contexts/user-context.tsx @@ -31,6 +31,16 @@ function profileLoadFailed(error: unknown): null { return null; } +async function settleProfile( + profilePromise: PromiseLike +): Promise { + 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)` @@ -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;