|
1 | | -import React, { createContext, useContext, useEffect } from "react"; |
| 1 | +import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; |
2 | 2 | import { useAtomValue } from "@effect/atom-react"; |
3 | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
4 | 4 |
|
@@ -68,72 +68,58 @@ const AuthProviderClient = ({ |
68 | 68 | onIdentify?: IdentifyFn; |
69 | 69 | }) => { |
70 | 70 | const result = useAtomValue(meAtom); |
| 71 | + |
71 | 72 | // The hint applies one frame AFTER mount: SSR always renders the loading |
72 | | - // state, and React requires the first client render to match that HTML, so |
73 | | - // reading the cookie during render would be a hydration mismatch. The |
74 | | - // post-mount flip costs a frame, not a network round trip — and the hint |
75 | | - // only seeds the in-flight window; `/account/me` is the authority from its |
76 | | - // first resolution on. |
77 | | - const [hint, setHint] = React.useState<AuthHint | null>(null); |
| 73 | + // state and the first client render must match that HTML, so the cookie |
| 74 | + // read is deferred to an effect (the documented pattern for client-only |
| 75 | + // values). The flip costs a frame, not a network round trip. |
| 76 | + const [hint, setHint] = useState<AuthHint | null>(null); |
78 | 77 | useEffect(() => { |
79 | 78 | setHint(readAuthHintCookie()); |
80 | 79 | }, []); |
81 | 80 |
|
82 | | - // What `/account/me` actually said — the authority. |
83 | | - const resolved: AuthState = AsyncResult.match(result, { |
84 | | - onInitial: () => ({ status: "loading" as const }), |
85 | | - onSuccess: ({ value }) => ({ |
86 | | - status: "authenticated" as const, |
87 | | - user: value.user, |
88 | | - organization: value.organization, |
89 | | - }), |
90 | | - onFailure: () => ({ status: "unauthenticated" as const }), |
91 | | - }); |
92 | | - |
93 | | - // What consumers see — the hint papers over the in-flight window only. |
94 | | - const state: AuthState = resolved.status === "loading" ? (hintState(hint) ?? resolved) : resolved; |
95 | | - |
96 | | - // Primitive identity fields of the RESOLVED state, so the effects below |
97 | | - // fire only on real transitions (the objects are rebuilt every render) and |
98 | | - // never on hint-derived optimistic state — identify must not report a stale |
99 | | - // hint to analytics, and the hint cookie must not rewrite itself. |
100 | | - const status = resolved.status; |
101 | | - const userId = resolved.status === "authenticated" ? resolved.user.id : null; |
102 | | - const email = resolved.status === "authenticated" ? resolved.user.email : null; |
103 | | - const name = resolved.status === "authenticated" ? resolved.user.name : null; |
104 | | - const avatarUrl = resolved.status === "authenticated" ? resolved.user.avatarUrl : null; |
105 | | - const organizationId = |
106 | | - resolved.status === "authenticated" ? (resolved.organization?.id ?? null) : null; |
107 | | - const organizationName = |
108 | | - resolved.status === "authenticated" ? (resolved.organization?.name ?? null) : null; |
109 | | - |
| 81 | + // What `/account/me` actually said — the authority. The atom value only |
| 82 | + // changes identity when the query emits, so memoizing on it gives the |
| 83 | + // effects below a dependency that tracks real transitions. |
| 84 | + const resolved = useMemo<AuthState>( |
| 85 | + () => |
| 86 | + AsyncResult.match(result, { |
| 87 | + onInitial: () => ({ status: "loading" as const }), |
| 88 | + onSuccess: ({ value }) => ({ |
| 89 | + status: "authenticated" as const, |
| 90 | + user: value.user, |
| 91 | + organization: value.organization, |
| 92 | + }), |
| 93 | + onFailure: () => ({ status: "unauthenticated" as const }), |
| 94 | + }), |
| 95 | + [result], |
| 96 | + ); |
| 97 | + |
| 98 | + // Both effects key off RESOLVED state, never hint-derived optimism: |
| 99 | + // identify must not report a stale hint to analytics, and the hint cookie |
| 100 | + // must not rewrite itself from its own contents. |
110 | 101 | useEffect(() => { |
111 | | - if (!onIdentify) return; |
112 | | - if (status === "authenticated" && userId && email !== null) { |
113 | | - onIdentify({ |
114 | | - status: "authenticated", |
115 | | - user: { id: userId, email, name, avatarUrl }, |
116 | | - organization: organizationId ? { id: organizationId, name: organizationName ?? "" } : null, |
117 | | - }); |
118 | | - } else if (status === "unauthenticated") { |
119 | | - onIdentify({ status: "unauthenticated" }); |
120 | | - } |
121 | | - }, [onIdentify, status, userId, email, name, avatarUrl, organizationId, organizationName]); |
| 102 | + if (resolved.status === "loading") return; |
| 103 | + onIdentify?.(resolved); |
| 104 | + }, [onIdentify, resolved]); |
122 | 105 |
|
123 | 106 | // Keep the hint cookie in step with reality so the NEXT page load seeds |
124 | 107 | // correctly: refresh it on every confirmed identity, drop it the moment the |
125 | 108 | // server says signed-out. |
126 | 109 | useEffect(() => { |
127 | | - if (status === "authenticated" && userId && email !== null) { |
128 | | - writeAuthHintCookie({ |
129 | | - v: 1, |
130 | | - user: { id: userId, email, name, avatarUrl }, |
131 | | - organization: organizationId ? { id: organizationId, name: organizationName ?? "" } : null, |
132 | | - }); |
133 | | - } else if (status === "unauthenticated") { |
| 110 | + if (resolved.status === "authenticated") { |
| 111 | + writeAuthHintCookie({ v: 1, user: resolved.user, organization: resolved.organization }); |
| 112 | + } else if (resolved.status === "unauthenticated") { |
134 | 113 | clearAuthHintCookie(); |
135 | 114 | } |
136 | | - }, [status, userId, email, name, avatarUrl, organizationId, organizationName]); |
| 115 | + }, [resolved]); |
| 116 | + |
| 117 | + // What consumers see — the hint papers over the in-flight window only. |
| 118 | + // Memoized so context consumers don't re-render on unrelated renders. |
| 119 | + const state = useMemo<AuthState>( |
| 120 | + () => (resolved.status === "loading" ? (hintState(hint) ?? resolved) : resolved), |
| 121 | + [resolved, hint], |
| 122 | + ); |
137 | 123 |
|
138 | 124 | return <AuthContext.Provider value={state}>{children}</AuthContext.Provider>; |
139 | 125 | }; |
|
0 commit comments