fix(auth+pwa): resolve stuck-loading race condition and PWA build error

Auth: `getSession()` in a SvelteKit load function races with Supabase's async
localStorage init and can return null for a valid session, triggering a spurious
login() redirect. Moved auth redirect to (app)/+layout.svelte via $effect, which
correctly waits for onAuthStateChange to fire. Also fixed collective data never
loading on page refresh (INITIAL_SESSION event, not SIGNED_IN).

PWA: SvelteKit blocks Workbox imports in src/service-worker.ts, preventing
@vite-pwa/sveltekit from finding the compiled SW output. Switched to generateSW
strategy (plugin auto-generates the SW). Custom SW deferred to Fase 2b using
src/sw.ts (a filename SvelteKit does not intercept).

Docs: added gotchas 6–8 to CLAUDE.md covering both root causes so they are not
reintroduced. Updated plan/fase-2b with the sw.ts workaround note.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 21:44:14 +02:00
parent 5ffd9ca28a
commit b297d253d9
16 changed files with 2125 additions and 279 deletions

View File

@@ -10,7 +10,9 @@ export function getSupabase(): SupabaseClient<Database> {
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true,
// detectSessionInUrl is disabled: the /auth/callback page explicitly calls
// exchangeCodeForSession so there is only one code-exchange attempt.
detectSessionInUrl: false,
// PKCE flow — required for SPA/PWA OAuth
flowType: 'pkce'
}

View File

@@ -2,6 +2,7 @@
import type { Snippet } from 'svelte';
import { page } from '$app/stores';
import { isAuthenticated, authLoading, displayName } from '$lib/stores/auth';
import { login } from '$lib/auth';
let { children }: { children: Snippet } = $props();
import { currentCollective, userCollectives } from '$lib/stores/collective';
@@ -9,6 +10,15 @@
import Avatar from '$lib/components/Avatar.svelte';
import * as m from '$lib/paraglide/messages';
// When auth resolves as unauthenticated, redirect to Keycloak.
// This runs after onAuthStateChange fires (authLoading = false), so there
// is no race with Supabase's async localStorage initialisation.
$effect(() => {
if (!$authLoading && !$isAuthenticated) {
login();
}
});
const navItems = [
{ href: '/lists', label: () => m.nav_lists(), icon: '🛒' },
{ href: '/tasks', label: () => m.nav_tasks(), icon: '✓' },

View File

@@ -1,21 +1,9 @@
import { browser } from '$app/environment';
import { getSupabase } from '$lib/supabase';
import { login } from '$lib/auth';
// No SSR — this is a client-rendered PWA
// No SSR — this is a client-rendered PWA.
// Auth redirect is handled in +layout.svelte via the onAuthStateChange store,
// NOT here. Using getSession() in a load function races with Supabase's async
// localStorage initialisation and can return null for a valid session.
export const ssr = false;
export async function load() {
if (!browser) return {};
const {
data: { session }
} = await getSupabase().auth.getSession();
if (!session) {
// Not authenticated — redirect to Keycloak
await login();
}
export function load() {
return {};
}

View File

@@ -4,19 +4,11 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { getSupabase } from '$lib/supabase';
import { login } from '$lib/auth';
import { currentUser, authLoading } from '$lib/stores/auth';
import { userCollectives, currentCollective } from '$lib/stores/collective';
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
import { i18n } from '$lib/i18n';
// Routes that don't require authentication
const PUBLIC_ROUTES = ['/auth', '/invitation'];
function isPublicRoute(pathname: string): boolean {
return PUBLIC_ROUTES.some((r) => pathname.startsWith(r));
}
onMount(() => {
const supabase = getSupabase();
@@ -26,20 +18,23 @@
currentUser.set(session?.user ?? null);
authLoading.set(false);
if (!session && !isPublicRoute($page.url.pathname)) {
// No session on a protected route → redirect to Keycloak
await login();
return;
}
if (session && event === 'SIGNED_IN') {
if (session) {
// Load collectives on every auth event that provides a session:
// INITIAL_SESSION (page refresh), SIGNED_IN (post-login), TOKEN_REFRESHED.
await loadUserCollectives(session.user.id);
// Post-login routing: onboarding if user has no collective
if ($userCollectives.length === 0 && $page.url.pathname === '/') {
goto('/onboarding');
} else if ($page.url.pathname === '/') {
goto('/lists');
if (event === 'SIGNED_IN') {
// Post-login: send the user where they belong.
// Only redirect when coming from the callback or the root — not on
// token refreshes or when the user is already on a real route.
const path = $page.url.pathname;
if (path === '/auth/callback' || path === '/') {
if ($userCollectives.length === 0) {
goto('/onboarding');
} else {
goto('/lists');
}
}
}
}
});

View File

@@ -1,50 +0,0 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, version } from '$service-worker';
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute, NavigationRoute } from 'workbox-routing';
import { NetworkFirst, CacheFirst } from 'workbox-strategies';
import { BackgroundSyncPlugin } from 'workbox-background-sync';
declare const self: ServiceWorkerGlobalScope;
const CACHE_NAME = `colectivo-${version}`;
const ASSETS = [...build, ...files];
// Precache all static assets
precacheAndRoute(ASSETS.map((url) => ({ url, revision: version })));
// Cache-first for static assets (JS, CSS, fonts, icons)
registerRoute(
({ request }) =>
request.destination === 'style' ||
request.destination === 'script' ||
request.destination === 'font' ||
request.destination === 'image',
new CacheFirst({ cacheName: `${CACHE_NAME}-assets` })
);
// Network-first for app routes (HTML navigation)
registerRoute(
new NavigationRoute(
new NetworkFirst({
cacheName: `${CACHE_NAME}-pages`,
networkTimeoutSeconds: 3
})
)
);
// Background Sync for pending offline operations
const bgSyncPlugin = new BackgroundSyncPlugin('pending-ops-queue', {
maxRetentionTime: 24 * 60 // 24 hours
});
// Listen for messages from the app
self.addEventListener('message', (event) => {
if (event.data?.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});

View File

@@ -11,10 +11,11 @@ export default defineConfig({
}),
sveltekit(),
SvelteKitPWA({
srcDir: './src',
mode: 'development',
strategies: 'injectManifest',
filename: 'service-worker.ts',
// strategies defaults to 'generateSW' — the plugin auto-generates the service
// worker. We do NOT use 'injectManifest' here because SvelteKit's own Vite
// plugin intercepts src/service-worker.ts and blocks external imports (Workbox).
// In Fase 2b we will introduce a custom SW as src/sw.ts (a filename SvelteKit
// does not recognise as a service worker) and switch to injectManifest then.
scope: '/',
base: '/',
manifest: {
@@ -33,9 +34,6 @@ export default defineConfig({
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
]
},
injectManifest: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}']
},
workbox: {
globPatterns: ['client/**/*.{js,css,ico,png,svg,webp,woff,woff2}']
},