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
41 changes: 41 additions & 0 deletions apps/web/CONVENTIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,47 @@ References:
- [Zustand — Prevent rerenders with useShallow](https://zustand.docs.pmnd.rs/guides/prevent-rerenders-with-use-shallow)
- [Zustand v5 selector best practices (community discussion)](https://github.com/pmndrs/zustand/discussions/2867)

### Auto-generated selectors via `createSelectors`

Wrap every store with `createSelectors()` from `src/utils/create-selectors.ts`
to auto-generate per-field selector hooks. This is the
[official Zustand pattern](https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors)
for reducing boilerplate while keeping per-field re-render optimization.

```ts
import { create } from "zustand";
import { createSelectors } from "@/utils/create-selectors.js";

interface BearState {
bears: number;
increase: (by: number) => void;
}

const useBearStoreBase = create<BearState>()((set) => ({
bears: 0,
increase: (by) => set((state) => ({ bears: state.bears + by })),
}));

export const useBearStore = createSelectors(useBearStoreBase);
```

Consumers use the `.use` property — fully typed, with autocomplete:

```ts
// Auto-generated selector — one field, minimal re-renders
const bears = useBearStore.use.bears();
const increase = useBearStore.use.increase();

// .getState() still works for non-React contexts (middleware, interceptors)
const { bears } = useBearStore.getState();
```

Prefer `.use.field()` over manual `(s) => s.field` selectors. For
derived/computed values (e.g. `user?.id`), use `.use.user()` and
access the property from the result.

Reference: [Zustand — Auto Generating Selectors](https://zustand.docs.pmnd.rs/learn/guides/auto-generating-selectors)

### useReducer for component-local state only

When two or more pieces of **component-local** state change together
Expand Down
22 changes: 13 additions & 9 deletions apps/web/src/components/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
/**
* Root provider composition for the web SPA.
*
* Wraps the app in Auth → Organization → scope-keyed QueryClient so that:
* 1. Auth state is available to all descendants.
* 2. Organization context resolves the active org for API headers.
* 3. The React Query cache is keyed by (user, org) — switching users or
* Wraps the app in Organization → scope-keyed QueryClient so that:
* 1. Organization context resolves the active org for API headers.
* 2. The React Query cache is keyed by (user, org) — switching users or
* orgs yields a fresh cache instead of leaking stale data.
*
* Only third-party library providers (React Query) belong here.
* App state uses Zustand stores — see `src/stores/`.
*
* Reference: https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";

import { useAuth } from "@/lib/auth/auth-provider.js";
import { useAuthStore } from "@/stores/auth-store.js";
import {
OrganizationProvider,
useOrganization,
Expand Down Expand Up @@ -55,10 +57,11 @@ function ScopeKeyedQueryClientProvider({
}: {
children: ReactNode;
}) {
const { isLoggedIn, userId } = useAuth();
const isLoggedIn = useAuthStore.use.isLoggedIn();
const user = useAuthStore.use.user();
const { currentOrganizationId } = useOrganization();
const scopeKey = `${
isLoggedIn ? `user:${userId ?? "unknown"}` : "anonymous"
isLoggedIn ? `user:${user?.id ?? "unknown"}` : "anonymous"
}:org:${currentOrganizationId ?? "none"}`;

return (
Expand All @@ -69,9 +72,10 @@ function ScopeKeyedQueryClientProvider({
}

export function AppProviders({ children }: { children: ReactNode }) {
const { isLoggedIn, userId } = useAuth();
const isLoggedIn = useAuthStore.use.isLoggedIn();
const user = useAuthStore.use.user();
const authScopeKey = isLoggedIn
? `user:${userId ?? "unknown"}`
? `user:${user?.id ?? "unknown"}`
: "anonymous";

return (
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/domains/account/pages/account-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AccountHeading } from "@/components/account/account-form.js";
import { AccountShell } from "@/components/account/account-shell.js";
import { PROVIDER_CALLBACK_URL, PROVIDER_ID } from "@/lib/account/login-flow.js";
import { startAuthFlow } from "@/runtime/native-auth.js";
import { useAuth } from "@/lib/auth/auth-provider.js";
import { useAuthStore } from "@/stores/auth-store.js";
import { routes } from "@/utils/routes.js";

/**
Expand All @@ -13,7 +13,10 @@ import { routes } from "@/utils/routes.js";
*/
export function AccountPage() {
const navigate = useNavigate();
const { isLoggedIn, isLoading, username, logout } = useAuth();
const isLoggedIn = useAuthStore.use.isLoggedIn();
const isLoading = useAuthStore.use.isLoading();
const user = useAuthStore.use.user();
const logout = useAuthStore.use.logout();

if (isLoading) {
return (
Expand Down Expand Up @@ -46,7 +49,7 @@ export function AccountPage() {
return (
<AccountShell>
<AccountHeading
title={`Welcome${username ? `, ${username}` : ""}!`}
title={`Welcome${user?.username ? `, ${user.username}` : ""}!`}
subtitle="You are signed in."
/>
<div className="flex flex-col items-center gap-4">
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/domains/account/pages/provider-callback-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { AccountShell } from "@/components/account/account-shell.js";
import { getSession } from "@/lib/auth/allauth-client.js";
import { resolvePostLoginDestination } from "@/lib/account/login-flow.js";
import { classifyCallbackFlows } from "@/lib/account/social-auth.js";
import { useAuth } from "@/lib/auth/auth-provider.js";
import { useAuthStore } from "@/stores/auth-store.js";
import { routes } from "@/utils/routes.js";

const NATIVE_CALLBACK_PREFIX = "/accounts/native/callback";
Expand Down Expand Up @@ -64,7 +64,7 @@ function redirectToNativeApp(
export function ProviderCallbackPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { refreshSession } = useAuth();
const refreshSession = useAuthStore.use.refreshSession();
const error = searchParams.get("error");
const [fallbackError, setFallbackError] = useState<string | null>(null);
const didRun = useRef(false);
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/domains/account/pages/provider-signup-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
submitProviderSignup,
} from "@/lib/auth/allauth-client.js";
import { resolvePostLoginDestination } from "@/lib/account/login-flow.js";
import { useAuth } from "@/lib/auth/auth-provider.js";
import { useAuthStore } from "@/stores/auth-store.js";
import { routes } from "@/utils/routes.js";

/**
Expand All @@ -24,7 +24,7 @@ import { routes } from "@/utils/routes.js";
export function ProviderSignupPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { refreshSession } = useAuth();
const refreshSession = useAuthStore.use.refreshSession();
const returnTo = searchParams.get("returnTo");

const [email, setEmail] = useState("");
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/domains/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from "react";

import { useIsMobile } from "@/hooks/use-is-mobile.js";
import { useAuth } from "@/lib/auth/auth-provider.js";
import { useAuthStore } from "@/stores/auth-store.js";
import { useAssistantLifecycle } from "@/domains/chat/hooks/use-assistant-lifecycle.js";
import {
interactionReducer,
Expand Down Expand Up @@ -38,7 +38,8 @@ const EMPTY_MAP_MESSAGES = new Map<
const EMPTY_SET_STRINGS = new Set<string>();

export function ChatPage() {
const { isLoggedIn, isLoading: authLoading } = useAuth();
const isLoggedIn = useAuthStore.use.isLoggedIn();
const authLoading = useAuthStore.use.isLoading();
const isMobile = useIsMobile();

const navigate = useCallback((_path: string) => {}, []);
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/domains/organization/organization-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {

import { organizationsListOptions } from "@/generated/api/@tanstack/react-query.gen.js";
import type { OrganizationRead } from "@/generated/api/types.gen.js";
import { useAuth } from "@/lib/auth/auth-provider.js";
import { useAuthStore } from "@/stores/auth-store.js";

import {
getActiveOrganizationIdForRequests,
Expand Down Expand Up @@ -93,7 +93,8 @@ function getErrorMessage(error: unknown): string {
}

export function OrganizationProvider({ children }: OrganizationProviderProps) {
const { isLoggedIn, isLoading: isAuthLoading } = useAuth();
const isLoggedIn = useAuthStore.use.isLoggedIn();
const isAuthLoading = useAuthStore.use.isLoading();
const isOrganizationQueryEnabled = isLoggedIn && !isAuthLoading;
const [selectedOrganizationId, setSelectedOrganizationId] = useState<
string | null
Expand Down
52 changes: 52 additions & 0 deletions apps/web/src/lib/auth/auth-middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* React Router v7 auth middleware.
*
* Runs before any protected route component renders. Unauthenticated
* users are redirected to `/account/login` with a `returnTo` parameter.
*
* References:
* - https://reactrouter.com/how-to/middleware
* - https://reactrouter.com/upgrading/future#futurev8_middleware
*/
import {
redirect,
createContext as createRouterContext,
type MiddlewareFunction,
} from "react-router";

import { useAuthStore, type AuthUser } from "@/stores/auth-store.js";

export const authUserContext = createRouterContext<AuthUser | null>(null);

export const authMiddleware: MiddlewareFunction = async ({ request, context }, next) => {
const { isLoggedIn, isLoading, user } = useAuthStore.getState();

if (isLoading) {
await waitForAuthReady();
return authMiddleware({ request, context } as Parameters<MiddlewareFunction>[0], next);
}

if (!isLoggedIn || !user) {
const url = new URL(request.url);
const returnTo = encodeURIComponent(url.pathname + url.search);
throw redirect(`/account/login?returnTo=${returnTo}`);
}

context.set(authUserContext, user);
return next();
};

function waitForAuthReady(): Promise<void> {
return new Promise((resolve) => {
const unsubscribe = useAuthStore.subscribe((state) => {
if (!state.isLoading) {
unsubscribe();
resolve();
}
});
if (!useAuthStore.getState().isLoading) {
unsubscribe();
resolve();
}
});
}
Loading