- Ya
- 26 Agosti 2026, 05:40 UTC
- Mwandishi
- kamo
- Ahadi ya
- 2241a5c
useUserInfo held its session in three useState cells. It has 187 call sites and roughly 27 of them are mounted for the whole session — the nav, every shell provider, the launchpad. The REQUEST was already coalesced, so there was only ever one /api/user-info call, but when it resolved all 27 instances called their own setUserInfo with the same data. Twenty-seven independent React updates carrying identical state, on every tab focus and every five-minute tick. The state moves to a module store behind useSyncExternalStore, so a refresh is one write and one batched render pass. The store is app/lib/userInfoStore.ts rather than a hook-local module for a specific reason: nothing in this repo can catch a rights regression. There is no @testing-library, no test mounts a component, and app/hooks/** is not in vitest's include list — a test placed beside the hook would pass by never running. app/lib/** is included, so the rules that gate rights across the product now have 25 tests covering every write path. Three properties are load-bearing and each is pinned by a test: `loading` starts TRUE and is a one-way latch. The rights guards fall through to a DENY, not to a wait, and checkRight opens with `if (!userInfo) return false` — so a store reporting loading:false before data arrives renders "no permission" to a member who holds the right, and fires the twenty `useEffect(..., [loading])` redirects that read rights on the first non-loading render. Nothing crashes and no test fails. Equally it must never go back to true, or every guard in the app flashes its loading state every five minutes. Writes are browser-only by construction. On the server one process serves every concurrent request, so a module variable holding a member's rights is one shared with every other member's render. publish() refuses to write without a window and getServerSnapshot returns a frozen constant instead of reading the mutable snapshot. That constant is also mandatory: the authenticated shell is server-rendered and this hook is reached during the server pass from the root layout's client tree, where React 19 throws rather than falling back, with no boundary above AuthedChrome to catch it. The snapshot is held, never rebuilt. loadUserInfoShared returns a freshly parsed object every time, which is precisely why all 27 instances re-rendered; a getSnapshot that built one would not merely re-render but throw. Equivalent payloads no longer publish at all, so the consumers keying memos off userInfo identity — SettingsAppPinsContext's nav gate above all — stop recomputing every five minutes for a payload that did not change. Three decisions worth naming, because each had a defensible alternative: The /logout navigation is now gated on there being a subscriber. The old isMountedRef guard sat above the switch, not on a setState, so it skipped the redirect too — an instance that unmounted mid-flight did not navigate. Deleting the ref outright, which is the usual move when adopting a module store, would have silently promoted that redirect from conditional to unconditional. Only the first load is foreground; later mounts refresh in the background. Rights stay as fresh on navigation as before, but a transient error on a late mount can no longer blank userInfo for all 27 consumers when it previously only blanked the one instance that hit it. The seven external loadUserInfoShared callers deliberately do not publish. They fire on websocket reconnect at arbitrary times, and driving the product's gating from a trigger surface nobody is watching is worse than the bounded staleness of not doing so — they read scalar identity that cannot change inside a session. Accessors are left as plain arrows on purpose. Stabilising them is the natural instinct here and would silently break nine call sites — useHrOverview, useSubscriptionPermissions, PlatformRightsContext and six others — that depend on today's unstable identity to stay fresh, computing once against a null snapshot instead. The /logout allowlist entry in check-client-navigation.mjs follows the line it exists for. Verified: tsc clean, all 10 guards pass, 3286 tests green, production build compiles, and the standalone server still renders the authenticated shell — 200, 16,238 bytes of DOM across 153 elements, two headers, ten buttons, My Businesses / My Profile / Log out. Wants a human to click through rights-gated nav, a limited-rights member, and an expiring session before it ships.