fix: silence two cosmetic boot-time devtools warnings

1. /api/auth/organization/get-active-member 400
   The Better-Auth org plugin returns 400 ("active organization not
   found") whenever the session has no activeOrganizationId yet — i.e.
   on every fresh inkognito login. The fetch was already tolerated
   (fetchActiveMember returns null on 400), but the network panel
   logged it as a noisy red row.

   Fix: gate the call on the localStorage hint. The hint is set by
   writeActiveSpaceHint() after every successful set-active, so its
   presence is a reliable proxy for "session has activeOrganizationId
   set". Without a hint we go straight to list + auto-activate
   Personal — same effective outcome, no 400.

2. Chrome "Autofocus processing was blocked" on /onboarding/name and
   /onboarding/wish
   The static `autofocus` attribute races the previous route's focus
   owner across the SvelteKit transition. Chrome refuses to honour
   autofocus when a document already has a focused element and warns.

   Fix: replace the attribute with `bind:this={el}` + a $effect that
   imperatively `el.focus()`s after `tick()` — by then the outgoing
   page has unmounted and there's no competing focus claim. The
   svelte-ignore directives are no longer needed and have been removed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-28 15:10:15 +02:00
parent 537719032e
commit eaa1d7432b
3 changed files with 55 additions and 20 deletions

View file

@ -186,13 +186,22 @@ export function writeActiveSpaceHint(id: string | null): void {
/**
* Resolve the user's active space. Order of truth:
* 1. Server-side active member (get-active-member) trustworthy when
* Better Auth's activeOrganizationId landed in the session.
* 2. Client-side hint from localStorage survives cookie drops across
* page reloads in dev. If present, we call set-active to re-sync
* the server to match.
* 1. **Localstorage hint present** we've already gone through the
* list+set-active flow on this device, so `get-active-member` is
* certain to return 200 (Better Auth's session has an
* activeOrganizationId). Call it as the source of truth (reflects
* cross-tab switches the user may have made elsewhere).
* 2. **No hint** fresh session. `get-active-member` would return
* 400 ("active organization not found") because nothing has been
* set yet. Skip the call entirely so the network panel stays clean,
* and go straight to list + activate-Personal-or-localStorage-hint.
* 3. Personal space fallback brand-new session, no previous choice.
*
* The skip-on-fresh-session optimisation eliminates the noisy 400 the
* webapp used to log on every fresh inkognito login. Functionally
* equivalent `fetchActiveMember()` returned `null` on 400 anyway and
* we fell through to the same auto-activate path.
*
* Errors captured in `lastError`; status flips to 'error'. Callers can
* retry via `loadActiveSpace({ force: true })`.
*/
@ -203,20 +212,25 @@ export async function loadActiveSpace(opts: { force?: boolean } = {}): Promise<A
lastError = null;
try {
// Only call `get-active-member` when we have a localStorage hint —
// the hint is set by writeActiveSpaceHint() after every successful
// set-active, so its presence is a reliable proxy for "the server's
// session has activeOrganizationId set". Without a hint, the server
// would 400 and we'd fall through to the activation path anyway.
const hintId = readActiveSpaceHint();
if (hintId) {
const member = await fetchActiveMember();
if (member) {
writeActiveSpaceHint(member.id);
applyActiveSpace(member);
return member;
}
// Hint present but server returned no active org (cookie wipe,
// session reset, …) — fall through to list + auto-activate so
// the hint is honoured if it still points at a real space.
}
// Server says no active org. Two reasons we might still know one:
// (a) the user switched to a non-personal space earlier and the
// hint survived in localStorage even though the cookie didn't.
// (b) it's truly a fresh session, in which case we activate
// Personal.
const orgs = await fetchOrganizations();
const hintId = readActiveSpaceHint();
const hinted = hintId ? orgs.find((o) => o.id === hintId) : null;
const chosen = hinted ?? orgs.find((o) => o.type === 'personal') ?? null;

View file

@ -6,6 +6,7 @@
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { tick } from 'svelte';
import { browser } from '$app/environment';
import { authStore } from '$lib/stores/auth.svelte';
import { onboardingFlow } from '$lib/stores/onboarding-flow.svelte';
@ -26,9 +27,18 @@
let name = $state((onboardingFlow.pendingName ?? authStore.user?.name ?? '').trim());
let saving = $state(false);
let error = $state<string | null>(null);
let inputEl = $state<HTMLInputElement | null>(null);
let canSubmit = $derived(name.trim().length >= 1 && name.trim().length <= 40 && !saving);
// Imperative focus after the previous route's focus owner has fully
// unmounted. Using `<input autofocus>` would race the router and
// trigger Chrome's "Autofocus processing was blocked because a
// document already has a focused element" warning.
$effect(() => {
void tick().then(() => inputEl?.focus());
});
async function saveName(value: string) {
const token = await authStore.getValidToken();
if (!token) throw new Error('Not authenticated');
@ -75,15 +85,14 @@
</div>
<div class="field">
<!-- svelte-ignore a11y_autofocus -->
<input
type="text"
bind:this={inputEl}
bind:value={name}
onkeydown={handleKeydown}
placeholder="z. B. Till"
maxlength={40}
autocomplete="given-name"
autofocus
aria-label="Dein Name"
/>
{#if error}

View file

@ -13,6 +13,7 @@
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { tick } from 'svelte';
import { ArrowLeft, Check, Globe, Lock } from '@mana/shared-icons';
import { onboardingFlow } from '$lib/stores/onboarding-flow.svelte';
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
@ -24,10 +25,22 @@
let isPublic = $state(true);
let saving = $state(false);
let submittedDisplayName = $state<string | null>(null);
let textareaEl = $state<HTMLTextAreaElement | null>(null);
let trimmed = $derived(wish.trim());
let charsLeft = $derived(MAX_LEN - wish.length);
// Imperative focus after the previous onboarding screen has fully
// unmounted. Using the static `autofocus` attribute would race the
// outgoing route's focus owner (Chrome warns "Autofocus processing
// was blocked because a document already has a focused element"
// when a router transition leaves a button focused). `tick()` waits
// for the next microtask so the textarea is mounted and has no
// competing focus claim.
$effect(() => {
void tick().then(() => textareaEl?.focus());
});
async function handleFinish() {
if (saving) return;
saving = true;
@ -78,13 +91,12 @@
</div>
<div class="field">
<!-- svelte-ignore a11y_autofocus -->
<textarea
bind:this={textareaEl}
bind:value={wish}
maxlength={MAX_LEN}
placeholder="Ich möchte Mana nutzen, um …"
rows="6"
autofocus
aria-label="Was du dir von Mana wünschst"
></textarea>
<div class="counter" class:warn={charsLeft < 100}>