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
2 changes: 1 addition & 1 deletion docs/reference/functions/AuthCheck.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/reference/functions/ClaimsCheck.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/reference/functions/useIdTokenResult.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/reference/functions/useSigninCheck.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions docs/reference/interfaces/AuthCheckProps.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/reference/interfaces/ClaimCheckErrors.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions docs/reference/interfaces/ClaimsCheckProps.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions docs/reference/interfaces/ClaimsValidator.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions docs/reference/interfaces/SignInCheckOptionsBasic.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions docs/reference/interfaces/SignInCheckOptionsClaimsObject.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/reference/type-aliases/SigninCheckResult.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion src/auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,17 @@ export function useUser<T = unknown>(options?: ReactFireOptions<T>): ObservableS
const observableId = `auth:user:${auth.name}`;
const observable$ = user(auth);

return useObservable(observableId, observable$, options);
const _options: ReactFireOptions<T> = { ...options };

// If a user is already signed in, seed initialData so consumers see the user
// synchronously on the first render without waiting for the async observable.
// We only do this when currentUser is truthy to avoid masking the uninitialized
// (null before auth has loaded from storage) case as "signed out".
if (auth.currentUser && !('initialData' in _options) && !('startWithValue' in _options)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider documenting the behavior change where users will see it: one JSDoc sentence on useUser (first render is success for already-signed-in users; in suspense mode, signed-in users no longer suspend) + a docs regen. The suspense change is arguably the biggest surface change and it's currently only visible by reading this code comment.

_options.initialData = auth.currentUser as unknown as T;

@armando-navarro armando-navarro Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

initialData's type collapses to any, so this assigns with no cast at all (verified with the repo's own tsc). If you keep a cast, the codebase's existing idiom is as any as.

}

return useObservable(observableId, observable$, _options);
}

export function useIdTokenResult(user: User, forceRefresh = false, options?: ReactFireOptions<IdTokenResult>): ObservableStatus<IdTokenResult> {
Expand Down
21 changes: 17 additions & 4 deletions src/useObservable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,24 @@ export function useObservable<T = unknown>(observableId: string, source: Observa

const update = useSyncExternalStore(subscribe, getSnapshot);

// modify the value if initialData exists
// Return a new object with initialData overlaid rather than mutating the shared
// _immutableStatus reference, which is the same object across all components
// using the same observableId.
if (!observable.hasValue && hasData) {
update.data = config?.initialData ?? config?.startWithValue;
update.status = 'success';
update.hasEmitted = true;
const initialDataValue = config?.initialData ?? config?.startWithValue;

// In suspense mode, throw errors so React Error Boundaries can catch them.
// In non-suspense mode, surface errors via status so consumers can handle them locally.
if (suspenseEnabled && update.error) {
throw update.error;
}

return {
...update,
data: initialDataValue,
status: 'success',
hasEmitted: true,
} as ObservableStatus<T>;
}

// throw an error if there is an error
Expand Down
35 changes: 35 additions & 0 deletions test/auth.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { cleanup, render, waitFor, renderHook, act } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import * as React from 'react';
import { NEVER } from 'rxjs';
import { preloadObservable } from '../src/useObservable';
import {
FirebaseAppProvider,
AuthCheck,
Expand Down Expand Up @@ -326,6 +328,39 @@ describe('Authentication', () => {
expect(result.current.data).toEqual(getAuth(app).currentUser);
});

it('synchronously returns the current user without waiting for the observable', async () => {
await act(async () => {
await signIn();
});

// Replace the auth:user observable with NEVER so it never emits.
// Without the fix (no initialData seeding), status is 'loading' on first render.
// With the fix (initialData = auth.currentUser), status is 'success' synchronously.
const cache = (globalThis as any)._reactFirePreloadedObservables as Map<string, any>;
const authUserKey = `auth:user:${getAuth(app).name}`;
cache?.delete(authUserKey);
preloadObservable(NEVER, authUserKey);

let capturedFirstRender: { user: any; status: string } | undefined;

const UserComponent = () => {
const { data: user, status } = useUser();
if (capturedFirstRender === undefined) {
capturedFirstRender = { user, status };
}
return <span data-testid="user-output">{String(status)}</span>;
};

try {
render(<UserComponent />, { wrapper: Provider });

expect(capturedFirstRender!.status).toBe('success');
expect(capturedFirstRender!.user).toEqual(getAuth(app).currentUser);
} finally {
cache?.delete(authUserKey);
}
});

it('does not show a logged-out user after navigating away', async () => {
await signIn();

Expand Down
Loading