@littoral/literally-firebase
v0.5.0
Published
Firebase utilities and integration for littoral components.
Readme
@littoral/literally-firebase
Firebase Firestore integration utilities and reactive listener lifecycle mixins for Lit web components.
Installation
Install the package along with its peer dependencies:
npm install @littoral/literally-firebase @littoral/literally lit firebaseFeatures
Reactive Firestore Controllers (Recommended)
Lit Reactive Controllers provide declarative document and query subscriptions without deep mixin inheritance hierarchies. They automatically manage connection lifecycles (hostConnected/hostDisconnected), dynamic reference updates, and reactive states (data, loading, error, exists, count, isFromCache, hasPendingWrites).
FirestoreDocController
Subscribes to a single Firestore document. Supports static DocumentReference objects or reactive getter functions that re-evaluate when component properties change.
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { doc, getFirestore } from 'firebase/firestore';
import { FirestoreDocController } from '@littoral/literally-firebase/firestore';
import { userConverter, type UserProfile } from './models';
@customElement('user-profile-card')
export class UserProfileCard extends LitElement {
@property() userId!: string;
private user = new FirestoreDocController<UserProfile>(this, {
ref: () =>
this.userId
? doc(getFirestore(), 'users', this.userId).withConverter(userConverter)
: null,
});
render() {
if (this.user.loading) return html`<p>Loading user data...</p>`;
if (this.user.error) return html`<p>Error: ${this.user.error.message}</p>`;
if (!this.user.exists) return html`<p>User not found.</p>`;
return html`
<div>
<h3>${this.user.data?.name}</h3>
<p>${this.user.data?.email}</p>
${this.user.isFromCache ? html`<small>(offline cache)</small>` : ''}
</div>
`;
}
}FirestoreQueryController
Subscribes to Firestore collections or queries with automatic mapping to typed arrays.
import { LitElement, html } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { collection, query, where, getFirestore } from 'firebase/firestore';
import { FirestoreQueryController } from '@littoral/literally-firebase/firestore';
import { taskConverter, type Task } from './models';
@customElement('task-list')
export class TaskList extends LitElement {
@property({ type: Boolean }) completed = false;
private tasks = new FirestoreQueryController<Task>(this, {
query: () => {
const col = collection(getFirestore(), 'tasks').withConverter(
taskConverter,
);
return query(col, where('done', '==', this.completed));
},
});
render() {
if (this.tasks.loading) return html`<p>Loading tasks...</p>`;
if (this.tasks.error)
return html`<p>Error: ${this.tasks.error.message}</p>`;
if (this.tasks.empty) return html`<p>No tasks found.</p>`;
return html`
<p>Count: ${this.tasks.count}</p>
<ul>
${this.tasks.data.map((task) => html`<li>${task.title}</li>`)}
</ul>
`;
}
}FirestoreListenerMixin
A LitElement mixin that simplifies managing Firebase Firestore real-time snapshot listeners (onSnapshot).
- Automatically tracks active unsubscribe functions by a unique key.
- Deduplicates watchers: re-registering a watcher under an existing key automatically unsubscribes the previous listener first.
- Prevents memory leaks by automatically unsubscribing all active listeners in the component's
disconnectedCallbacklifecycle hook.
Example Usage
import { LitElement, html } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { doc, onSnapshot, getFirestore } from 'firebase/firestore';
import { FirestoreListenerMixin } from '@littoral/literally-firebase/mixins/firestore-watchers.mixin';
@customElement('user-profile-card')
export class UserProfileCard extends FirestoreListenerMixin(LitElement) {
@state() private userData: any = null;
connectedCallback() {
super.connectedCallback();
this.subscribeToUser('user-123');
}
private subscribeToUser(userId: string) {
const db = getFirestore();
const userRef = doc(db, 'users', userId);
// Register listener with a unique name
const unsubscribe = onSnapshot(userRef, (snapshot) => {
this.userData = snapshot.data();
});
// Automatically tracked and unsubscribed on disconnectedCallback()
this.addFirebaseWatcher('user-subscription', unsubscribe);
}
render() {
if (!this.userData) {
return html`<p>Loading user data...</p>`;
}
return html`
<div>
<h3>${this.userData.name}</h3>
<p>${this.userData.email}</p>
</div>
`;
}
}Mixin API
addFirebaseWatcher(name: string, watcher: Unsubscribe | undefined): void— Registers or replaces a listener by name.hasFirebaseWatcher(name: string): boolean— Checks if a listener is currently registered.stopFirebaseWatcher(name: string): void— Unsubscribes and cleans up a specific listener by name.clearFirebaseWatchers(): void— Unsubscribes and clears all registered listeners.
Converter & Serialization Toolkit
@littoral/literally-firebase/firestore provides utilities to create type-safe converters, eliminate runtime undefined field errors, and streamline Date <-> Timestamp conversions.
createConverter<T>()
A factory function creating an FBConverter<T> (compatible with withConverter()) with automated document ID injection, date conversion, and undefined property removal.
import { createConverter } from '@littoral/literally-firebase/firestore';
export interface CampInfo {
id: string;
name: string;
startOn: Date;
endOn: Date;
notes?: string;
meta?: { lastModified?: Date };
}
export const campConverter = createConverter<CampInfo>({
idField: 'id', // Injects snapshot.id on read (default: 'id')
dateFields: ['startOn', 'endOn', 'meta.lastModified'], // Converts Date <-> Timestamp automatically
cleanUndefined: true, // Strips undefined fields before writing (default: true)
});cleanFirestoreData(data, options?)
Recursively removes undefined properties from an object or array before sending it to Firestore (e.g. via setDoc or updateDoc), preventing Unsupported field value: undefined crashes.
Preserves Firestore types (Timestamp, FieldValue, DocumentReference, GeoPoint, Bytes) and native Date instances intact.
import {
updateDoc,
doc,
serverTimestamp,
getFirestore,
} from 'firebase/firestore';
import { cleanFirestoreData } from '@littoral/literally-firebase/firestore';
const db = getFirestore();
const campRef = doc(db, 'camps', 'camp-123');
await updateDoc(
campRef,
cleanFirestoreData({
notes: userNote || undefined, // undefined values safely stripped
updatedAt: serverTimestamp(), // FieldValue sentinels preserved
}),
);Date Utilities: toDate & toTimestamp
import { toDate, toTimestamp } from '@littoral/literally-firebase/firestore';
const date = toDate(snapshot.get('createdOn')); // Date from Timestamp, ISO string, or number
const timestamp = toTimestamp(new Date()); // Firestore Timestamp from Date or stringAuthentication (@littoral/literally-firebase/auth)
Reactive controller and helper functions for Firebase Authentication.
AuthController
A Reactive Controller tracking user, custom claims, and authentication loading state with automatic onIdTokenChanged listener management.
import { LitElement, html } from 'lit';
import { customElement } from 'lit/decorators.js';
import { getAuth } from 'firebase/auth';
import {
AuthController,
signInWithGoogleWithFallback,
} from '@littoral/literally-firebase/auth';
@customElement('user-menu')
export class UserMenu extends LitElement {
private auth = new AuthController(this, { auth: getAuth() });
private handleSignIn = async () => {
await signInWithGoogleWithFallback(getAuth());
};
render() {
if (this.auth.loading) return html`<p>Loading user...</p>`;
if (!this.auth.isLoggedIn) {
return html`<button @click=${this.handleSignIn}>
Sign In with Google
</button>`;
}
return html`
<div>
<span>Welcome, ${this.auth.displayName}</span>
${this.auth.hasClaim('admin') ? html`<span class="badge">Admin</span>` : ''}
<button @click=${() => this.auth.signOut()}>Sign Out</button>
</div>
`;
}
}signInWithGoogleWithFallback
Attempts popup sign-in, automatically falling back to signInWithRedirect if popup blockers intercept the request on mobile Safari or iOS devices.
import { getAuth } from 'firebase/auth';
import { signInWithGoogleWithFallback } from '@littoral/literally-firebase/auth';
await signInWithGoogleWithFallback(getAuth());Roadmap
See ROADMAP.md for planned features, reactive controllers, converter enhancements, and testing utilities.
