Swoff Swoff

Components

UI components for Swoff — UpdatePrompt, SWProgressBar, InstallButton.

React Components

These components use the React hooks to provide ready-to-use UI elements. See Component Specs for behavioral details.

UpdatePrompt

@/components/UpdatePrompt.tsx
import { useSWUpdate } from "./hooks";

export function UpdatePrompt() {
  const {
    updateStatus,
    currentVersion,
    availableVersion,
    progress,
    forceUpdate,
    acceptUpdate,
    dismissUpdate,
  } = useSWUpdate();
  if (updateStatus === "idle") return null;

  return (
    <div
      className={`update-prompt ${forceUpdate ? "fullscreen" : "bottom-sheet"}`}
    >
      <p>
        Update available: v{currentVersion} → v{availableVersion}
      </p>
      {updateStatus === "downloading" ? (
        <div className="progress-bar" style={{ width: `${progress}%` }} />
      ) : (
        <>
          <button onClick={acceptUpdate}>Update</button>
          {!forceUpdate && <button onClick={dismissUpdate}>Later</button>}
        </>
      )}
    </div>
  );
}

Usage:

function App() {
  return (
    <>
      <AppContent />
      <UpdatePrompt />
    </>
  );
}

SWProgressBar

@/components/SWProgressBar.tsx
import { useSWProgress } from "./hooks";

export function SWProgressBar() {
  const { progress, status } = useSWProgress();
  if (status !== "installing") return null;

  return (
    <div className="fixed top-0 left-0 w-full h-1 bg-gray-200">
      <div
        className="h-full bg-blue-500 transition-all"
        style={{ width: `${progress}%` }}
      />
    </div>
  );
}

Usage:

function App() {
  return (
    <>
      <SWProgressBar />
      <AppContent />
    </>
  );
}

InstallButton

@/components/InstallButton.tsx
import { useState, useEffect } from "react";

export function InstallButton() {
  const [deferredPrompt, setDeferredPrompt] = useState<any>(null);

  useEffect(() => {
    window.addEventListener("beforeinstallprompt", (e) => {
      e.preventDefault();
      setDeferredPrompt(e as any);
    });
  }, []);

  if (!deferredPrompt) return null;

  const handleInstall = async () => {
    deferredPrompt.prompt();
    const { outcome } = await deferredPrompt.userChoice;
    if (outcome === "accepted") setDeferredPrompt(null);
  };

  return <button onClick={handleInstall}>Install App</button>;
}

Usage:

function Header() {
  return (
    <header>
      <h1>My App</h1>
      <InstallButton />
    </header>
  );
}

OfflineErrorBoundary

Handle errors gracefully when Service Worker or offline features fail.

@/components/OfflineErrorBoundary.tsx
import { Component, ReactNode } from "react";

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

export class OfflineErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: any) {
    console.error("OfflineErrorBoundary caught:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      if (this.props.fallback) {
        return this.props.fallback;
      }

      return (
        <div className="p-4 bg-yellow-50 border border-yellow-200 rounded">
          <h2 className="text-lg font-semibold">Something went wrong</h2>
          <p className="text-sm text-gray-600">
            {this.state.error?.message || "Offline feature failed to load"}
          </p>
          <button
            className="mt-2 px-4 py-2 bg-blue-500 text-white rounded"
            onClick={() => this.setState({ hasError: false, error: null })}
          >
            Try Again
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

Usage:

function App() {
  return (
    <OfflineErrorBoundary
      fallback={
        <div className="p-4">
          Some offline features are unavailable. The app will work without them.
        </div>
      }
    >
      <Router>
        <Routes>
          <Route path="/" element={<Home />} />
        </Routes>
      </Router>
    </OfflineErrorBoundary>
  );
}

With SW-specific errors:

function App() {
  const [swError, setSwError] = useState(false);

  useEffect(() => {
    window.addEventListener("sw-error", () => setSwError(true));
  }, []);

  return (
    <OfflineErrorBoundary
      fallback={
        <div className="p-4">
          {swError
            ? "Offline caching is unavailable. Data will reload on each visit."
            : "Something went wrong loading the app."}
        </div>
      }
    >
      <AppContent />
    </OfflineErrorBoundary>
  );
}

SyncErrorBanner

Show when mutations fail to sync after multiple retries.

@/components/SyncErrorBanner.tsx
import { useMutationQueue } from "./hooks";

export function SyncErrorBanner() {
  const { lastSync } = useMutationQueue();

  if (!lastSync || lastSync.failed === 0) return null;

  return (
    <div className="bg-red-50 border border-red-200 p-4 rounded flex items-center justify-between">
      <span>{lastSync.failed} change(s) failed to sync</span>
      <button
        className="text-sm text-red-600 hover:underline"
        onClick={() => window.location.reload()}
      >
        Retry
      </button>
    </div>
  );
}

Next Steps

On this page