Skip to content
35 changes: 35 additions & 0 deletions .changeset/auth-loading-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@tailor-platform/app-shell": minor
---

Split `AuthProvider`'s guard slot into two props so the loading state and the unauthenticated state can render different UI, and protect against flashes by default when `autoLogin` is enabled.

- **New:** `loadingComponent` — rendered while the initial auth check is in progress (`isReady === false`). Use this for a loading spinner or skeleton.
- **Changed:** `guardComponent` — now only rendered after the auth check has completed and the user is not authenticated (`isReady && !isAuthenticated`). Use this for a sign-in screen.
- **New default:** when `autoLogin` is enabled and a slot is omitted, the protected tree is hidden during that state (instead of briefly rendering children before the redirect). When `autoLogin` is off, children continue to render in the omitted slot's state — preserving public-app and `useAuthSuspense` patterns.

Previously `guardComponent` was rendered for the union of "not ready" and "not authenticated," which caused sign-in screens wired into that slot to flash on every reload before the session was known.

```tsx
// Before — guardComponent doubled as loading + unauthenticated
<AuthProvider client={authClient} guardComponent={() => <LoadingScreen />}>
</AuthProvider>

// After — use the slot that matches your intent
<AuthProvider
client={authClient}
loadingComponent={() => <LoadingScreen />}
guardComponent={() => <SignInScreen />}
>
</AuthProvider>

// Or, with autoLogin, omit both slots and rely on the default
// flash protection (the tree stays hidden until the user is signed in):
<AuthProvider client={authClient} autoLogin>
</AuthProvider>
```

**Migration:** if you were passing a loading UI to `guardComponent`, rename the prop to `loadingComponent`. If you were passing a sign-in screen, keep it on `guardComponent` — it will no longer flash before the auth check resolves. If you relied on children rendering before auth resolved with `autoLogin` on, pass an explicit `loadingComponent` (e.g., `loadingComponent={() => <YourChildren />}` is unusual; more typically you want a spinner there).
33 changes: 26 additions & 7 deletions docs/concepts/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const authClient = createAuthClient({
});

const App = () => (
<AuthProvider client={authClient} autoLogin={true} guardComponent={() => <LoadingScreen />}>
<AuthProvider client={authClient} autoLogin={true} loadingComponent={() => <LoadingScreen />}>
<AppShell modules={modules}>
<SidebarLayout />
</AppShell>
Expand All @@ -46,9 +46,27 @@ Find the above values in Tailor Console:
The above code will:

- Automatically redirect unauthenticated users to the login page (if `autoLogin` is true)
- Show the `guardComponent` while loading or when unauthenticated
- Show the `loadingComponent` while the initial auth check is in progress
- Handle token management and session persistence automatically

### Default flash protection

When `autoLogin` is enabled, `AuthProvider` hides the protected tree by default whenever the auth state is unresolved — that is, during the initial check (`!isReady`) and during the brief window between "ready, unauthenticated" and the login redirect. Providing `loadingComponent` and/or `guardComponent` simply replaces that hidden region with your UI; you do not need to set them to avoid a flash.

When `autoLogin` is off, children render in those windows by default. This preserves the "public app" pattern (where some content is meant to be visible regardless of auth) and the [`useAuthSuspense`](#suspense-compatible-hook) pattern (where a `<Suspense>` boundary inside your tree owns the loading UI).

If you want to render a sign-in screen instead of redirecting (i.e., without `autoLogin`), pass it via `guardComponent` — it is only rendered after the initial auth check has completed and the user is not authenticated, so it does not flash on reload:

```tsx
<AuthProvider
client={authClient}
loadingComponent={() => <LoadingScreen />}
guardComponent={() => <SignInScreen />}
>
{/* ... */}
</AuthProvider>
```

## Authentication Hook

Use the `useAuth` hook to access authentication state and methods:
Expand Down Expand Up @@ -171,11 +189,12 @@ function App() {

## `AuthProvider` Props

| Prop | Type | Required | Description |
| ---------------- | ----------------------- | -------- | ----------------------------------------------------- |
| `client` | `EnhancedAuthClient` | Yes | Auth client created with `createAuthClient` |
| `autoLogin` | `boolean` | No | Automatically redirect unauthenticated users to login |
| `guardComponent` | `() => React.ReactNode` | No | Rendered while loading or when not authenticated |
| Prop | Type | Required | Description |
| ------------------ | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `client` | `EnhancedAuthClient` | Yes | Auth client created with `createAuthClient` |
| `autoLogin` | `boolean` | No | Automatically redirect unauthenticated users to login |
| `loadingComponent` | `() => React.ReactNode` | No | Rendered while the initial auth check is in progress (`isReady === false`). When omitted: children are hidden if `autoLogin` is on, otherwise children render. |
| `guardComponent` | `() => React.ReactNode` | No | Rendered when the auth check has completed and the user is not authenticated. When omitted: children are hidden if `autoLogin` is on, otherwise children render. |

## Integration with AppShell

Expand Down
130 changes: 130 additions & 0 deletions e2e/tests/auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,68 @@ test.describe("AuthProvider", () => {
});
});

test("OAuth callback does not flash the auth guard or trip React rules of hooks", async ({
page,
}) => {
// Regression for two interacting bugs:
// 1. AuthGuard used to render guardComponent during a pending OAuth
// callback when the auth client surfaced a stale isReady &&
// !isAuthenticated state — flashing the sign-in screen the user
// just returned from.
// 2. AuthGuard invoked the slots as plain function calls, which
// inlined slot hooks into AuthGuard's own hook scope. Because
// the slots render conditionally, any slot that called a hook
// (this app's <AuthGuard> calls useAuth) would change AuthGuard's
// hook order across renders → "React has detected a change in
// the order of Hooks called by AuthGuard".
const consoleErrors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") consoleErrors.push(msg.text());
});
page.on("pageerror", (err) => consoleErrors.push(err.message));

await page.goto("/");
await page.getByTestId("login-button").click();

const email = process.env.E2E_USER_EMAIL;
const password = process.env.E2E_USER_PASSWORD;

if (!email || !password) {
test.skip(true, "E2E_USER_EMAIL and E2E_USER_PASSWORD must be set");
return;
}

await page.waitForURL(/idp\.erp\.dev\/.*\/signin/);
await page.getByLabel(/email/i).fill(email);
await page.locator("#password").fill(password);
await page.getByRole("button", { name: /sign in|log in|submit/i }).click();

await page.waitForURL("http://localhost:3100/**");
await expect(page.getByTestId("authenticated-content")).toBeVisible({
timeout: 10000,
});

// Callback blackout: after we land back on the app, the sign-in
// screen must not be in the DOM at all — the AuthProvider should
// have rendered nothing during the callback exchange and then
// transitioned directly to authenticated content.
await expect(page.getByTestId("auth-guard")).toHaveCount(0);

// Hooks isolation: the slot (AuthGuard, which uses useAuth) was
// rendered at least once during the unauthenticated state. If the
// slot were invoked as a plain function call instead of as its own
// fiber, the transition out of !isAuthenticated would log a hook-
// order warning here.
const reactErrors = consoleErrors.filter(
(e) =>
e.includes("order of Hooks") ||
e.includes("Invalid hook call") ||
e.includes("Rendered fewer hooks") ||
e.includes("Rendered more hooks"),
);
expect(reactErrors, `Unexpected React hook errors:\n${reactErrors.join("\n")}`).toEqual([]);
});

test("maintains session on page reload after login", async ({ page }) => {
await page.goto("/");
await page.getByTestId("login-button").click();
Expand Down Expand Up @@ -114,4 +176,72 @@ test.describe("AuthProvider", () => {
});
await expect(page.getByTestId("auth-status")).toHaveText("Logged in");
});

test("reloading an authenticated session does not flash the auth guard", async ({ page }) => {
// Regression for the AuthGuard flash on reload: while the initial
// auth check is in flight (`!isReady`), the guardComponent slot
// must not render — even for a single frame — when the user has
// a valid persisted session. We catch a flash by installing a
// MutationObserver via addInitScript (which runs before any page
// render), so we observe the very first DOM mutations after
// reload.
const email = process.env.E2E_USER_EMAIL;
const password = process.env.E2E_USER_PASSWORD;

if (!email || !password) {
test.skip(true, "E2E_USER_EMAIL and E2E_USER_PASSWORD must be set");
return;
}

// The init script re-runs on every navigation and reload, so the
// sentinel starts `false` on the reloaded page regardless of what
// happened on previous navigations.
await page.addInitScript(() => {
(window as unknown as { __authGuardEverSeen: boolean }).__authGuardEverSeen = false;
const check = () => {
if (document.querySelector("[data-testid='auth-guard']")) {
(window as unknown as { __authGuardEverSeen: boolean }).__authGuardEverSeen = true;
}
};
const startObserving = () => {
if (document.body) {
new MutationObserver(check).observe(document.body, {
childList: true,
subtree: true,
});
check();
} else {
requestAnimationFrame(startObserving);
}
};
startObserving();
});

// Log in first to establish a persisted session.
await page.goto("/");
await page.getByTestId("login-button").click();
await page.waitForURL(/idp\.erp\.dev\/.*\/signin/);
await page.getByLabel(/email/i).fill(email);
await page.locator("#password").fill(password);
await page.getByRole("button", { name: /sign in|log in|submit/i }).click();
await page.waitForURL("http://localhost:3100/**");
await expect(page.getByTestId("authenticated-content")).toBeVisible({
timeout: 10000,
});

// Reload — the init script reinstalls a fresh observer on the new
// document before any React render, so any moment the auth guard
// appears (even for one frame) sets the sentinel.
await page.reload();
await expect(page.getByTestId("authenticated-content")).toBeVisible({
timeout: 10000,
});

const guardSeenOnReload = await page.evaluate(
() => (window as unknown as { __authGuardEverSeen: boolean }).__authGuardEverSeen,
);
expect(guardSeenOnReload, "auth-guard appeared during reload of authenticated session").toBe(
false,
);
});
});
Loading
Loading