From 4380b22a6d144f0bc88ff4453e666b2213b8b04b Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Tue, 29 Sep 2026 15:30:35 -0400 Subject: [PATCH] fix(user-context): stop every page crashing on the streamed profile promise The profile promise ServerProviders streams to the client arrives as React Flight's ReactPromise, whose then() returns undefined instead of a new promise. UserProvider called profilePromise.catch(...), which therefore returned undefined, so useUser() hit use(undefined) and every signed-in page fell through to the root error boundary. Unit tests missed it because they passed native Promises. Settle the promise with await in an async helper, which only relies on then() invoking its callbacks. Add tests with a Flight-shaped promise for both the resolve and reject paths; they fail without the fix. Also ignore .playwright-mcp/: its snapshots and console logs capture real MP member data. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 3 +++ src/contexts/user-context.test.tsx | 40 ++++++++++++++++++++++++++++++ src/contexts/user-context.tsx | 18 +++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) 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;