Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
23f2e5d
feat(auth): add sendEmail to the password-reset and verification hooks
Timonwa Sep 8, 2026
c6f7543
docs(auth): explain delegating the emailed-link sends
Timonwa Sep 8, 2026
c7830aa
feat(auth): export the types needed to wrap a hook
Timonwa Sep 8, 2026
ce266c5
docs: show how to type a wrapper around a hook
Timonwa Sep 8, 2026
ff0b268
docs(auth): trim the sendEmail comments to match sendLink
Timonwa Sep 8, 2026
b45ec5c
feat: export CompleteSignInResult, VerifyEmailStatusType and AsyncStatus
Timonwa Sep 8, 2026
87c7792
docs: document AsyncStatus and name the exported result types
Timonwa Sep 8, 2026
d3a9345
feat(auth): let the provider set an email sender per flow
Timonwa Sep 8, 2026
1d42005
docs(auth): document the provider senders
Timonwa Sep 8, 2026
846cebe
docs(ssr): note when a null auth is required, not just allowed
Timonwa Sep 8, 2026
0ffc93d
docs(guides): add the one-page recipe for every emailed link
Timonwa Sep 8, 2026
fc59c2e
feat(auth)!: align the status vocabulary and unify the sender option
Timonwa Sep 8, 2026
999284b
docs(guides): cover every mode the action URL receives
Timonwa Sep 8, 2026
07ee8f5
refactor(auth)!: name types by the house and industry convention
Timonwa Sep 8, 2026
779a36e
feat(auth)!: senders receive { email, actionCodeSettings }
Timonwa Sep 8, 2026
bac2d4d
feat(auth): export what every hook returns as Use<Name>Result
Timonwa Sep 8, 2026
e0cf88f
feat(auth)!: report status with derived booleans instead of loading/s…
Timonwa Sep 8, 2026
8e5930d
feat(playground): show each hook's status instead of a loading flag
Timonwa Sep 8, 2026
14c4f6a
docs: describe the status contract in place of loading/success
Timonwa Sep 8, 2026
7ed1070
build: one Prettier config for the repo
Timonwa Sep 8, 2026
7320780
build(prettier): mirror the editor config
Timonwa Sep 8, 2026
f4d564a
refactor(auth): drop the setError destructure reset() made dead
Timonwa Sep 8, 2026
0d8dfef
style: reformat under the mirrored Prettier config
Timonwa Sep 9, 2026
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
19 changes: 19 additions & 0 deletions .changeset/align-status-and-sender-naming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@timonwa/firebase-hooks": minor
---

**Breaking: `useVerifyEmail`'s `status` uses the standard async vocabulary.** `"processing"` is now `"pending"` and `"failed"` is now `"error"`, matching TanStack Query's `QueryStatus` rather than spelling the same three states differently.

```tsx
- if (status === 'processing') return <Spinner />;
- if (status === 'failed') return <ErrorState message={error} />;
+ if (status === 'pending') return <Spinner />;
+ if (status === 'error') return <ErrorState message={error} />;
```

**Breaking: `useEmailLinkSignIn`'s `sendLink` option is now `sendEmail`.** All three emailed-link hooks take the same option name. The returned `sendLink` function is unchanged.

```tsx
-useEmailLinkSignIn({ sendLink: email => api.send(email) });
+useEmailLinkSignIn({ sendEmail: ({ email }) => api.send(email) });
```
5 changes: 4 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": ["@changesets/changelog-github", { "repo": "Timonwa/firebase-hooks" }],
"changelog": [
"@changesets/changelog-github",
{ "repo": "Timonwa/firebase-hooks" }
],
"commit": false,
"access": "public",
"baseBranch": "main",
Expand Down
14 changes: 14 additions & 0 deletions .changeset/delegate-email-sends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@timonwa/firebase-hooks": minor
---

`useSendPasswordResetEmail` and `useSendEmailVerification` gain a `sendEmail` option, matching `useEmailLinkSignIn`'s `sendLink`. Both hooks previously sent from the browser with no way to delegate, which put an email-sending path outside an app's own rate limiter — awkward when the same app already rate-limits the sign-in link server-side.

```tsx
const { send } = useSendPasswordResetEmail({
sendEmail: ({ email, actionCodeSettings }) =>
requestPasswordReset(email, actionCodeSettings),
});
```

The hook keeps its own bookkeeping either way: `loading`, `error`, `success` and `resetState` behave identically, and a throwing sender surfaces as an ordinary failure result. The sender receives `{ email, actionCodeSettings }` — the same inputs Firebase's client send takes and the Admin SDK's `generate*Link` wants, so a provider-level `actionCodeSettings` still reaches your server. On `useSendEmailVerification`, `email` is the signed-in user's address, since `send()` takes no arguments; an account without one fails clearly rather than calling your sender with nothing.
12 changes: 12 additions & 0 deletions .changeset/export-result-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@timonwa/firebase-hooks": minor
---

Two result types are no longer module-private:

- `CompleteSignInResult` — what `useEmailLinkSignIn`'s `completeSignIn` resolves to, so a callback page can annotate the function that handles it
- `VerifyEmailStatus` — `useVerifyEmail`'s status, instead of re-declaring the three states in app code

Both ship from `@timonwa/firebase-hooks/auth`.

The status vocabulary is also now shared. `AsyncStatus` (`"processing" | "success" | "failed"`) ships from the root entry, and `VerifyEmailStatus` is an alias of it, so hooks that act on mount report the same three states rather than each spelling them differently. There is no idle state: the work starts before a render, which is why `processing` is the initial value.
18 changes: 18 additions & 0 deletions .changeset/export-wrapper-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"@timonwa/firebase-hooks": minor
---

Every type needed to write a wrapper around a hook is now reachable from `@timonwa/firebase-hooks/auth`:

- all 13 `Use*Options` interfaces, so a wrapper can accept and forward a hook's options without restating them
- `AuthProviderProps` and `UseAuthResult` (what `useAuth` returns), for an app provider layered over this one

```tsx
import { useLogin, type UseLoginOptions } from "@timonwa/firebase-hooks/auth";

export function useAppLogin(options?: UseLoginOptions) {
return useLogin(options);
}
```

`HookResult`, `HookErrorOptions` and `HookErrorContext` are unchanged and still ship from the root entry — every service returns them, so they keep one home rather than being re-exported per service.
19 changes: 19 additions & 0 deletions .changeset/named-result-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@timonwa/firebase-hooks": minor
---

Every hook now exports the type it returns as `Use<Name>Result` — `UseLoginResult`, `UseSignupResult`, `UseVerifyEmailResult`, and so on, from `@timonwa/firebase-hooks/auth`. A wrapper can state its return type instead of re-deriving it with `ReturnType<typeof useLogin>`.

```tsx
import {
useLogin,
type UseLoginOptions,
type UseLoginResult,
} from "@timonwa/firebase-hooks/auth";

export function useAppLogin(options?: UseLoginOptions): UseLoginResult {
return useLogin(options);
}
```

Same shape as TanStack Query's `UseQueryResult` / `UseMutationResult`. `useAuth` already returned the named `UseAuthResult`.
20 changes: 20 additions & 0 deletions .changeset/provider-senders.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@timonwa/firebase-hooks": minor
---

`AuthProvider` gains `senders`, so an app that emails links from its own API configures that once instead of at every call site:

```tsx
<AuthProvider
auth={auth}
senders={{
signInLink: ({ email, actionCodeSettings }) => api.sendSignInLink(email, actionCodeSettings),
passwordReset: ({ email, actionCodeSettings }) => api.sendPasswordReset(email, actionCodeSettings),
emailVerification: ({ email, actionCodeSettings }) => api.sendVerification(email, actionCodeSettings),
}}
>
```

One key per flow rather than a single sender: the three send different emails, so one sender for all of them could mail a password reset to someone asking to verify an address. The shape follows the same convention as TanStack Query's `defaultOptions`, which namespaces defaults by operation kind so the per-call signature stays identical to the global one.

Each key follows the existing rule — a hook's own `sendLink`/`sendEmail` overrides it, and `null` opts that one flow back to Firebase's client-side send.
20 changes: 20 additions & 0 deletions .changeset/status-instead-of-loading.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@timonwa/firebase-hooks": minor
---

**Breaking: every action hook reports `status` with derived booleans, replacing `loading`, `success` and `resetState`.**

```tsx
const { login, status, isIdle, isPending, isSuccess, isError, error, reset } =
useLogin();
// ^ 'idle' | 'pending' | 'success' | 'error'
```

| Before | After |
| ------------------------------------------ | ------------------------------- |
| `loading` | `isPending` |
| `success` (six hooks, hand-rolled) | `isSuccess` — now on every hook |
| `resetState()` (two hooks) | `reset()` — now on every hook |
| `useEmailLinkSignIn`'s returned `setError` | removed; `reset()` covers it |

The booleans are derived from `status`, so exactly one is ever true. This is TanStack Query's mutation result, field for field, and the new `ActionStatus` type ships from the root entry beside `AsyncStatus`. Hooks that act on mount — `useVerifyEmail` — keep `AsyncStatus` (no `idle`, since the work starts before you can render) and gain the same `isPending`/`isSuccess`/`isError`.
16 changes: 15 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
node_modules
dist
apps

# Biome's territory — two formatters on one file fight forever.
packages/**/*.ts
packages/**/*.tsx
packages/**/*.json
/*.ts
/*.json

# Next build output
**/.next
**/.source
**/next-env.d.ts

# Generated by changesets — reformatting it would churn on every release.
**/CHANGELOG.md

# Machine-owned; pnpm writes it.
pnpm-lock.yaml
19 changes: 17 additions & 2 deletions .prettierrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"printWidth": 90,
"proseWrap": "never"
"printWidth": 80,
"semi": true,
"trailingComma": "all",
"arrowParens": "avoid",
"bracketSameLine": true,
"proseWrap": "never",
"plugins": ["prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "apps/docs/**",
"options": { "tailwindStylesheet": "./apps/docs/app/global.css" }
},
{
"files": "apps/playground/**",
"options": { "tailwindStylesheet": "./apps/playground/app/globals.css" }
}
]
}
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ This is a pnpm workspace. The published package lives in `packages/firebase-hook

- **One folder per service** (`src/core/`, `src/auth/`, later `src/firestore/`, `src/storage/`), each with its own `index.ts` entry barrel. **One file per hook**, kebab-cased after it — `src/auth/use-login.ts`. Start the file with a `"use client"` directive and a JSDoc block; the JSDoc is what editors show, so keep it agreeing with the README. Internals a service shares live in that folder's `_shared.ts` and never reach the barrel.
- **Follow the shared contract.** `auth: Auth | null` first argument; actions resolve to `HookResult` and never throw (`useAuthTask` gives you the skeleton); sensitive operations reauthenticate first.
- **Options go in an exported `Use<Name>OptionsProps` interface** extending `HookErrorOptions`, with a TSDoc line on every field it declares itself (and `@defaultValue` where there is one). The docs site generates its options table from that interface, so an undocumented field ships an empty cell. Exported for the generator's sake — keep it out of the barrel, so the published types don't change.
- **Options go in an exported `Use<Name>Options` interface** extending `HookErrorOptions`, with a TSDoc line on every field it declares itself (and `@defaultValue` where there is one). The docs site generates its options table from that interface, so an undocumented field ships an empty cell. Re-export it from the barrel too, so an app can type a wrapper around the hook without restating the shape.
- **Export it explicitly** from its service's entry barrel (`src/auth/index.ts` for auth), one line per file, alphabetical. The root entry (`src/core/index.ts`) carries only the service-agnostic core — nothing service-specific is ever added to it; a new service gets a new folder + subpath entry.
- **Document it in the same change** — add a page under `apps/docs/content/docs/auth/` and list it in that folder's `meta.json`. Follow the shape of the existing pages: prose intro, example, `## Returns`, then `<AutoTypeTable>` for the options. The table generates from the option interface's TSDoc, so document each field there rather than hand-writing a table.
- **Add it to the playground in the same change** — a section component under `apps/playground/components/<service>/`, rendered from that service's page, and its name in the right group in `apps/playground/lib/hooks-map.ts` so it appears in the sidebar. See below.
Expand Down
23 changes: 13 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

---

Each hook runs one flow end to end. It holds its own loading, error, and success state, and sets up the browser pieces and callbacks the flow needs. Every action returns a result you can branch on: `{ success: true, … }` when it works, `{ success: false, error, code, cause }` when it doesn't. Firebase's own response is on both paths.
Each hook runs one flow end to end. It holds its own status and error, and sets up the browser pieces and callbacks the flow needs. Every action returns a result you can branch on: `{ success: true, … }` when it works, `{ success: false, error, code, cause }` when it doesn't. Firebase's own response is on both paths.

Zero dependencies — `firebase` and `react` are peers.

Expand Down Expand Up @@ -37,7 +37,7 @@ Create your `Auth` instance once with the Firebase SDK, then wrap your app:
import { AuthProvider } from "@timonwa/firebase-hooks/auth";
import { auth } from "@/lib/firebase"; // getAuth(initializeApp(config))

<AuthProvider auth={auth} onIdToken={(idToken) => createSession(idToken)}>
<AuthProvider auth={auth} onIdToken={idToken => createSession(idToken)}>
{children}
</AuthProvider>;
```
Expand All @@ -48,7 +48,7 @@ Hooks below it need nothing passed:
import { useLogin } from "@timonwa/firebase-hooks/auth";

function LoginForm() {
const { login, loading, error } = useLogin();
const { login, isPending, error } = useLogin();

async function onSubmit(email: string, password: string) {
const result = await login(email, password);
Expand All @@ -74,7 +74,10 @@ The most-used services ship first; more (Realtime Database, Remote Config, Cloud
Each service is its own import, so an app only carries the services it uses. The root holds what every service shares — `formatFirebaseError`, `getFirebaseErrorCode`, and the `HookResult` types.

```ts
import { formatFirebaseError, getFirebaseErrorCode } from "@timonwa/firebase-hooks";
import {
formatFirebaseError,
getFirebaseErrorCode,
} from "@timonwa/firebase-hooks";
import {
AuthProvider,
useLogin,
Expand All @@ -89,19 +92,20 @@ Every hook has its own page — signature, options, and a worked example — in
One contract, so learning one hook is learning them all:

- **Every hook needs an `Auth` instance** — taken from `AuthProvider`, or passed in as the first argument to override it. No global, no hidden singleton. Passing `null` means "not ready yet", never "use the provider's", so an action called before `auth` exists fails cleanly with `{ success: false, error }`.
- **Every action resolves to `HookResult`** — `{ success: true, ...data }` or `{ success: false, error, code, cause }`. The hook's `error` state carries the same message for rendering, and `loading` and `success` track the action. See [Error handling](#error-handling).
- **Every action resolves to `HookResult`** — `{ success: true, ...data }` or `{ success: false, error, code, cause }`. The hook's `status` — `idle`, `pending`, `success`, `error` — tracks the action, with `isPending`/`isSuccess`/`isError` derived from it and `reset()` to go back to idle; `error` carries the message for rendering. See [Error handling](#error-handling).
- **`onIdToken(idToken, user)` runs after a successful sign-in**, with a freshly minted token. Throw inside it to abort the flow; the error surfaces like any other.
- **`currentPassword` triggers reauthentication** in `useUpdatePassword`, `useUpdateEmail`, and `useDeleteAccount`. `useReauthenticate` exposes the same step for custom flows.
- **Provider options are defaults, hook options win.** `onIdToken`, `onBeforeSignOut`, `actionCodeSettings`, `formatErrorMessage`, and the `onError` observer can all be set once on `AuthProvider`; a hook's own option overrides the provider, and an explicit `null` opts that flow out entirely:

```tsx
<AuthProvider
auth={auth}
onIdToken={(idToken) => createSession(idToken)}
onIdToken={idToken => createSession(idToken)}
onBeforeSignOut={() => clearSession()}
actionCodeSettings={{ url: `${origin}/auth/action`, handleCodeInApp: true }}
formatErrorMessage={(e) => formatFirebaseError(e, { messages: AUTH_ERROR_MESSAGES })}
>
formatErrorMessage={e =>
formatFirebaseError(e, { messages: AUTH_ERROR_MESSAGES })
}>
{children}
</AuthProvider>;

Expand Down Expand Up @@ -149,8 +153,7 @@ For logging and analytics, the provider's **`onError` observer** sees every fail
```tsx
<AuthProvider
auth={auth}
onError={(error, { action, code }) => track("auth_error", { action, code })}
>
onError={(error, { action, code }) => track("auth_error", { action, code })}>
{children}
</AuthProvider>
```
Expand Down
4 changes: 0 additions & 4 deletions apps/docs/.prettierignore

This file was deleted.

16 changes: 0 additions & 16 deletions apps/docs/.prettierrc.json

This file was deleted.

28 changes: 14 additions & 14 deletions apps/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,27 @@ Open <http://localhost:3000>.

## Environment

| Variable | Required | What it's for |
| ---------------------- | -------- | ------------------------------------------------------------- |
| `NEXT_PUBLIC_SITE_URL` | No | Absolute origin for canonicals, the sitemap and OG image URLs |
| Variable | Required | What it's for |
| --- | --- | --- |
| `NEXT_PUBLIC_SITE_URL` | No | Absolute origin for canonicals, the sitemap and OG image URLs |

**Nothing to configure on Vercel.** The origin resolves from `VERCEL_PROJECT_PRODUCTION_URL`, which Vercel sets automatically. Set `NEXT_PUBLIC_SITE_URL` only once a custom domain points at the site.

Locally the origin falls back to `http://localhost:3000`. If your dev server picks a different port, OG image URLs will point at a port nothing is serving and link previews will fail to load the image — set `NEXT_PUBLIC_SITE_URL=http://localhost:3001` in `.env.local` to match.

## Layout

| Path | What it is |
| ----------------------- | ---------------------------------------------------------------------------- |
| `content/docs/` | The MDX pages; `meta.json` per folder controls nav order |
| `lib/source.ts` | Content source adapter — the nav, sitemap and OG routes all read from it |
| `lib/site.ts` | Site config: origin, description, author, whether the env is indexable |
| `lib/seo.ts` | `buildMetadata()` — every page's canonical, OG, Twitter and robots |
| `lib/schema.ts` | JSON-LD graph, anchored by stable `@id` |
| `lib/layout.shared.tsx` | Nav title, version pill, and the nav links |
| `app/og/` | OG image routes — `/og` for the home page, `/og/docs/*` prerendered per page |
| `app/(home)/` | Landing page and its footer |
| `app/docs/` | Documentation layout and pages |
| Path | What it is |
| --- | --- |
| `content/docs/` | The MDX pages; `meta.json` per folder controls nav order |
| `lib/source.ts` | Content source adapter — the nav, sitemap and OG routes all read from it |
| `lib/site.ts` | Site config: origin, description, author, whether the env is indexable |
| `lib/seo.ts` | `buildMetadata()` — every page's canonical, OG, Twitter and robots |
| `lib/schema.ts` | JSON-LD graph, anchored by stable `@id` |
| `lib/layout.shared.tsx` | Nav title, version pill, and the nav links |
| `app/og/` | OG image routes — `/og` for the home page, `/og/docs/*` prerendered per page |
| `app/(home)/` | Landing page and its footer |
| `app/docs/` | Documentation layout and pages |

## Writing a page

Expand Down
8 changes: 4 additions & 4 deletions apps/docs/app/(home)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { HomeLayout } from 'fumadocs-ui/layouts/home';
import { SiteFooter } from '@/components/site-footer';
import { baseOptions } from '@/lib/layout.shared';
import { HomeLayout } from "fumadocs-ui/layouts/home";
import { SiteFooter } from "@/components/site-footer";
import { baseOptions } from "@/lib/layout.shared";

export default function Layout({ children }: LayoutProps<'/'>) {
export default function Layout({ children }: LayoutProps<"/">) {
return (
<HomeLayout {...baseOptions()} className="min-h-screen">
{children}
Expand Down
Loading