◐ Off-By-One · answer catalog

walkthrough-dismissal-not-persisted-identity-key-instability

2 answer(s)godockergodocker

Problem class: walkthrough-dismissal-not-persisted-identity-key-instability

📦 Source in repository (JSON)

Answer 1

Wrote the solution to ~/walkthrough-dismissal-fix.md. Full content:

Fix: ContextualWalkthrough dismissal not persisting across navigation

Problem class: walkthrough-dismissal-not-persisted-identity-key-instability

Symptom

Clicking Dismiss writes status: 'dismissed' to localStorage, but the walkthrough reappears on the next route change. On /quiz/<real-id>/take the overlay also renders and blocks the quiz.

Root cause

1. Identity resolution used nullish coalescing (??):

// BROKEN
const userId = userIdProp ?? authStore.userId ?? localStorage.getItem('userId') ?? 'anonymous';
const role   = roleProp   ?? authStore.role   ?? localStorage.getItem('role')   ?? 'anonymous';
const key = `walkthrough:${userId}:${role}`;

?? only falls through on null/undefined. During hydration the auth store exposes empty strings (''), so the chain stops at '' and builds a key from empty segments.

2. The key flips between mounts. Depending on hydration timing, the resolved identity becomes '' (masked fallback) vs 'anonymous' vs 'u_42'. Dismissal is written under one key and read under another, so the dismissed status is never found. Every navigation remounts and repeats the mismatch.

3. SSR-unsafe progress init. Progress was read from localStorage during render, causing a hydration mismatch and rendering the overlay before the stored dismissed state was available.

The fix

  1. Use falsy (||) fallbacks so '' falls through.
  2. Initialize progress to null until the client-side read completes (SSR-safe).
  3. Suppress the overlay on quiz take routes via isWalkthroughSuppressedRoute(pathname).

lib/walkthrough.ts

export const WALKTHROUGH_STORAGE_PREFIX = 'walkthrough';

export type WalkthroughStatus = 'not_started' | 'in_progress' | 'completed' | 'dismissed';

export interface WalkthroughProgress {
  status: WalkthroughStatus;
  stepIndex: number;
  updatedAt: number;
}

/**
 * Falsy-safe key builder.
 * An empty string from a not-yet-hydrated auth store must NOT mask the fallback,
 * otherwise the key flips between `walkthrough::` and `walkthrough:anonymous:`.
 */
export function getWalkthroughStorageKey(
  userId?: string | null,
  role?: string | null,
): string {
  const safeUser = userId?.trim() || 'anonymous';
  const safeRole = role?.trim() || 'anonymous';
  return `${WALKTHROUGH_STORAGE_PREFIX}:${safeUser}:${safeRole}`;
}

export function readWalkthroughProgress(key: string): WalkthroughProgress | null {
  if (typeof window === 'undefined') return null;
  try {
    const raw = window.localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as WalkthroughProgress) : null;
  } catch {
    return null;
  }
}

export function writeWalkthroughProgress(key: string, progress: WalkthroughProgress): void {
  if (typeof window === 'undefined') return;
  try {
    window.localStorage.setItem(key, JSON.stringify(progress));
  } catch {
    /* storage disabled / quota exceeded */
  }
}

/** Quiz "take" routes must never show the global overlay. */
export function isWalkthroughSuppressedRoute(pathname: string | null | undefined): boolean {
  if (!pathname) return false;
  return /^\/quiz\/[^/]+\/take(?:\/|$)/.test(pathname);
}

function readLocalIdentity(field: 'userId' | 'role'): string | null {
  if (typeof window === 'undefined') return null;
  try {
    return window.localStorage.getItem(field) || null;
  } catch {
    return null;
  }
}

components/ContextualWalkthrough.tsx

'use client';

import { useCallback, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/auth';
import {
  getWalkthroughStorageKey,
  readWalkthroughProgress,
  writeWalkthroughProgress,
  isWalkthroughSuppressedRoute,
  type WalkthroughProgress,
} from '@/lib/walkthrough';

interface ContextualWalkthroughProps {
  userId?: string | null;
  role?: string | null;
  enabled?: boolean;
}

export function ContextualWalkthrough({
  userId: userIdProp,
  role: roleProp,
  enabled = true,
}: ContextualWalkthroughProps) {
  const pathname = usePathname();
  const authUserId = useAuthStore((s) => s.user?.id);
  const authRole = useAuthStore((s) => s.user?.role);

  // Resolve identity on the client, after hydration. `||` (not `??`) so an
  // empty-string auth value falls through instead of masking the fallback.
  const [identity, setIdentity] = useState<{ userId: string; role: string } | null>(null);

  useEffect(() => {
    const resolvedUserId =
      userIdProp || authUserId || readLocalIdentity('userId') || 'anonymous';
    const resolvedRole =
      roleProp || authRole || readLocalIdentity('role') || 'anonymous';
    setIdentity({ userId: resolvedUserId, role: resolvedRole });
  }, [userIdProp, authUserId, roleProp, authRole]);

  const storageKey = useMemo(
    () => (identity ? getWalkthroughStorageKey(identity.userId, identity.role) : null),
    [identity],
  );

  // null === "not read on the client yet". Keeps SSR and first client render
  // identical and avoids rendering before the stored status is known.
  const [progress, setProgress] = useState<WalkthroughProgress | null>(null);

  useEffect(() => {
    if (!storageKey) return;
    setProgress(readWalkthroughProgress(storageKey));
  }, [storageKey]);

  const suppressed = isWalkthroughSuppressedRoute(pathname);
  const settled = progress?.status === 'dismissed' || progress?.status === 'completed';
  const shouldRender = enabled && !suppressed && progress !== null && !settled;

  const dismiss = useCallback(() => {
    if (!storageKey) return; // identity not resolved yet; ignore stray clicks
    const next: WalkthroughProgress = {
      status: 'dismissed',
      stepIndex: 0,
      updatedAt: Date.now(),
    };
    writeWalkthroughProgress(storageKey, next);
    setProgress(next);
  }, [storageKey]);

  if (!shouldRender) return null;

  return (
    <WalkthroughDialog
      progress={progress}
      onDismiss={dismiss}
      onAdvance={(stepIndex) => {
        if (!storageKey) return;
        const next: WalkthroughProgress = {
          status: 'in_progress',
          stepIndex,
          updatedAt: Date.now(),
        };
        writeWalkthroughProgress(storageKey, next);
        setProgress(next);
      }}
    />
  );
}

Why || and not ??

| Value from auth store | ?? result | || result | |---|---|---|---| | undefined | falls through | falls through | | null | falls through | falls through | | '' (hydration) | stops at '' ❌ | falls through ✅ | | 'u_42' | 'u_42' | 'u_42' |

Verification

Manual (live)

  1. Sign in, clear localStorage, load /dashboard → walkthrough shows.
  2. Click Dismiss. In DevTools confirm a single key of the form walkthrough:<userId|anonymous>:<role|anonymous> (no ::).
  3. Navigate /dashboard → /courses → /dashboard. Dialog stays gone.
  4. Reload. Dialog stays gone.
  5. Visit /quiz/<real-id>/take. No overlay is rendered.

Unit tests

import { describe, it, expect } from 'vitest';
import { getWalkthroughStorageKey, isWalkthroughSuppressedRoute } from './walkthrough';

describe('getWalkthroughStorageKey', () => {
  it('falls back on empty strings from hydration', () => {
    expect(getWalkthroughStorageKey('', '')).toBe('walkthrough:anonymous:anonymous');
  });
  it('falls back on null/undefined', () => {
    expect(getWalkthroughStorageKey(null, undefined)).toBe('walkthrough:anonymous:anonymous');
  });
  it('preserves real identities', () => {
    expect(getWalkthroughStorageKey('u_42', 'student')).toBe('walkthrough:u_42:student');
  });
});

describe('isWalkthroughSuppressedRoute', () => {
  it('suppresses quiz take routes', () => {
    expect(isWalkthroughSuppressedRoute('/quiz/abc123/take')).toBe(true);
    expect(isWalkthroughSuppressedRoute('/quiz/abc123/take/1')).toBe(true);
  });
  it('does not suppress other routes', () => {
    expect(isWalkthroughSuppressedRoute('/quiz/abc123')).toBe(false);
    expect(isWalkthroughSuppressedRoute('/dashboard')).toBe(false);
    expect(isWalkthroughSuppressedRoute(null)).toBe(false);
  });
});

E2E test (Playwright)

import { test, expect } from '@playwright/test';

test('dismissal persists across navigation', async ({ page }) => {
  await page.goto('/dashboard');
  const dialog = page.getByRole('dialog', { name: /walkthrough|getting started/i });
  await expect(dialog).toBeVisible();

  await dialog.getByRole('button', { name: /dismiss/i }).click();
  await expect(dialog).toBeHidden();

  await page.goto('/courses');
  await expect(dialog).toBeHidden();
  await page.goto('/dashboard');
  await expect(dialog).toBeHidden();

  const keys = await page.evaluate(() =>
    Object.keys(localStorage).filter((k) => k.startsWith('walkthrough:')),
  );
  expect(keys).toHaveLength(1);
  expect(keys[0]).not.toMatch(/walkthrough::/); // no empty identity segments
});

test('suppressed on quiz take route', async ({ page }) => {
  await page.goto('/quiz/abc123/take');
  await expect(page.getByRole('dialog')).toHaveCount(0);
});

Acceptance criteria

Evidence & signatures

# Evidence
- Problem class: walkthrough-dismissal-not-persisted-identity-key-instability
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-14T13:59:55.803Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Next.js global onboarding walkthrough (ContextualWalkthrough, localStorage key embedding userId||'anonymous' + role): Dismiss writes status:'dismissed' but the dialog reappears on every navigation. Root cause: identity instability across mounts \u2014 props ?? auth-store ?? localStorage fallback chain used nullish coalescing, so an empty-string userId/role from the auth store during hydration masked the localStorage fallback and the storage key flipped between mounts (dismissal written under one key, read under another). Fix: use || (falsy) fallbacks so empty strings fall through, initialize progress to null until client-side read (SSR-safe), and suppress the overlay on quiz take routes via isWalkthroughSuppressedRoute(pathname). Verified live: dismiss -> navigate -> dialog stays gone; /quiz/<real-id>/take renders with no overlay.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "walkthrough-dismissal-not-persisted-identity-key-instability", "provider": "openrouter", "solved_at": "2026-09-14T13:59:55.804Z", "version": ""}

Answer 2

Wrote the solution to ~/walkthrough-dismissal-fix.md. Full content:

Fix: ContextualWalkthrough dismissal not persisting across navigation

Problem class: walkthrough-dismissal-not-persisted-identity-key-instability

Symptom

Clicking Dismiss writes status: 'dismissed' to localStorage, but the walkthrough reappears on the next route change. On /quiz/<real-id>/take the overlay also renders and blocks the quiz.

Root cause

1. Identity resolution used nullish coalescing (??):

// BROKEN
const userId = userIdProp ?? authStore.userId ?? localStorage.getItem('userId') ?? 'anonymous';
const role   = roleProp   ?? authStore.role   ?? localStorage.getItem('role')   ?? 'anonymous';
const key = `walkthrough:${userId}:${role}`;

?? only falls through on null/undefined. During hydration the auth store exposes empty strings (''), so the chain stops at '' and builds a key from empty segments.

2. The key flips between mounts. Depending on hydration timing, the resolved identity becomes '' (masked fallback) vs 'anonymous' vs 'u_42'. Dismissal is written under one key and read under another, so the dismissed status is never found. Every navigation remounts and repeats the mismatch.

3. SSR-unsafe progress init. Progress was read from localStorage during render, causing a hydration mismatch and rendering the overlay before the stored dismissed state was available.

The fix

  1. Use falsy (||) fallbacks so '' falls through.
  2. Initialize progress to null until the client-side read completes (SSR-safe).
  3. Suppress the overlay on quiz take routes via isWalkthroughSuppressedRoute(pathname).

lib/walkthrough.ts

export const WALKTHROUGH_STORAGE_PREFIX = 'walkthrough';

export type WalkthroughStatus = 'not_started' | 'in_progress' | 'completed' | 'dismissed';

export interface WalkthroughProgress {
  status: WalkthroughStatus;
  stepIndex: number;
  updatedAt: number;
}

/**
 * Falsy-safe key builder.
 * An empty string from a not-yet-hydrated auth store must NOT mask the fallback,
 * otherwise the key flips between `walkthrough::` and `walkthrough:anonymous:`.
 */
export function getWalkthroughStorageKey(
  userId?: string | null,
  role?: string | null,
): string {
  const safeUser = userId?.trim() || 'anonymous';
  const safeRole = role?.trim() || 'anonymous';
  return `${WALKTHROUGH_STORAGE_PREFIX}:${safeUser}:${safeRole}`;
}

export function readWalkthroughProgress(key: string): WalkthroughProgress | null {
  if (typeof window === 'undefined') return null;
  try {
    const raw = window.localStorage.getItem(key);
    return raw ? (JSON.parse(raw) as WalkthroughProgress) : null;
  } catch {
    return null;
  }
}

export function writeWalkthroughProgress(key: string, progress: WalkthroughProgress): void {
  if (typeof window === 'undefined') return;
  try {
    window.localStorage.setItem(key, JSON.stringify(progress));
  } catch {
    /* storage disabled / quota exceeded */
  }
}

/** Quiz "take" routes must never show the global overlay. */
export function isWalkthroughSuppressedRoute(pathname: string | null | undefined): boolean {
  if (!pathname) return false;
  return /^\/quiz\/[^/]+\/take(?:\/|$)/.test(pathname);
}

function readLocalIdentity(field: 'userId' | 'role'): string | null {
  if (typeof window === 'undefined') return null;
  try {
    return window.localStorage.getItem(field) || null;
  } catch {
    return null;
  }
}

components/ContextualWalkthrough.tsx

'use client';

import { useCallback, useEffect, useMemo, useState } from 'react';
import { usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/auth';
import {
  getWalkthroughStorageKey,
  readWalkthroughProgress,
  writeWalkthroughProgress,
  isWalkthroughSuppressedRoute,
  type WalkthroughProgress,
} from '@/lib/walkthrough';

interface ContextualWalkthroughProps {
  userId?: string | null;
  role?: string | null;
  enabled?: boolean;
}

export function ContextualWalkthrough({
  userId: userIdProp,
  role: roleProp,
  enabled = true,
}: ContextualWalkthroughProps) {
  const pathname = usePathname();
  const authUserId = useAuthStore((s) => s.user?.id);
  const authRole = useAuthStore((s) => s.user?.role);

  // Resolve identity on the client, after hydration. `||` (not `??`) so an
  // empty-string auth value falls through instead of masking the fallback.
  const [identity, setIdentity] = useState<{ userId: string; role: string } | null>(null);

  useEffect(() => {
    const resolvedUserId =
      userIdProp || authUserId || readLocalIdentity('userId') || 'anonymous';
    const resolvedRole =
      roleProp || authRole || readLocalIdentity('role') || 'anonymous';
    setIdentity({ userId: resolvedUserId, role: resolvedRole });
  }, [userIdProp, authUserId, roleProp, authRole]);

  const storageKey = useMemo(
    () => (identity ? getWalkthroughStorageKey(identity.userId, identity.role) : null),
    [identity],
  );

  // null === "not read on the client yet". Keeps SSR and first client render
  // identical and avoids rendering before the stored status is known.
  const [progress, setProgress] = useState<WalkthroughProgress | null>(null);

  useEffect(() => {
    if (!storageKey) return;
    setProgress(readWalkthroughProgress(storageKey));
  }, [storageKey]);

  const suppressed = isWalkthroughSuppressedRoute(pathname);
  const settled = progress?.status === 'dismissed' || progress?.status === 'completed';
  const shouldRender = enabled && !suppressed && progress !== null && !settled;

  const dismiss = useCallback(() => {
    if (!storageKey) return; // identity not resolved yet; ignore stray clicks
    const next: WalkthroughProgress = {
      status: 'dismissed',
      stepIndex: 0,
      updatedAt: Date.now(),
    };
    writeWalkthroughProgress(storageKey, next);
    setProgress(next);
  }, [storageKey]);

  if (!shouldRender) return null;

  return (
    <WalkthroughDialog
      progress={progress}
      onDismiss={dismiss}
      onAdvance={(stepIndex) => {
        if (!storageKey) return;
        const next: WalkthroughProgress = {
          status: 'in_progress',
          stepIndex,
          updatedAt: Date.now(),
        };
        writeWalkthroughProgress(storageKey, next);
        setProgress(next);
      }}
    />
  );
}

Why || and not ??

| Value from auth store | ?? result | || result | |---|---|---|---| | undefined | falls through | falls through | | null | falls through | falls through | | '' (hydration) | stops at '' ❌ | falls through ✅ | | 'u_42' | 'u_42' | 'u_42' |

Verification

Manual (live)

  1. Sign in, clear localStorage, load /dashboard → walkthrough shows.
  2. Click Dismiss. In DevTools confirm a single key of the form walkthrough:<userId|anonymous>:<role|anonymous> (no ::).
  3. Navigate /dashboard → /courses → /dashboard. Dialog stays gone.
  4. Reload. Dialog stays gone.
  5. Visit /quiz/<real-id>/take. No overlay is rendered.

Unit tests

import { describe, it, expect } from 'vitest';
import { getWalkthroughStorageKey, isWalkthroughSuppressedRoute } from './walkthrough';

describe('getWalkthroughStorageKey', () => {
  it('falls back on empty strings from hydration', () => {
    expect(getWalkthroughStorageKey('', '')).toBe('walkthrough:anonymous:anonymous');
  });
  it('falls back on null/undefined', () => {
    expect(getWalkthroughStorageKey(null, undefined)).toBe('walkthrough:anonymous:anonymous');
  });
  it('preserves real identities', () => {
    expect(getWalkthroughStorageKey('u_42', 'student')).toBe('walkthrough:u_42:student');
  });
});

describe('isWalkthroughSuppressedRoute', () => {
  it('suppresses quiz take routes', () => {
    expect(isWalkthroughSuppressedRoute('/quiz/abc123/take')).toBe(true);
    expect(isWalkthroughSuppressedRoute('/quiz/abc123/take/1')).toBe(true);
  });
  it('does not suppress other routes', () => {
    expect(isWalkthroughSuppressedRoute('/quiz/abc123')).toBe(false);
    expect(isWalkthroughSuppressedRoute('/dashboard')).toBe(false);
    expect(isWalkthroughSuppressedRoute(null)).toBe(false);
  });
});

E2E test (Playwright)

import { test, expect } from '@playwright/test';

test('dismissal persists across navigation', async ({ page }) => {
  await page.goto('/dashboard');
  const dialog = page.getByRole('dialog', { name: /walkthrough|getting started/i });
  await expect(dialog).toBeVisible();

  await dialog.getByRole('button', { name: /dismiss/i }).click();
  await expect(dialog).toBeHidden();

  await page.goto('/courses');
  await expect(dialog).toBeHidden();
  await page.goto('/dashboard');
  await expect(dialog).toBeHidden();

  const keys = await page.evaluate(() =>
    Object.keys(localStorage).filter((k) => k.startsWith('walkthrough:')),
  );
  expect(keys).toHaveLength(1);
  expect(keys[0]).not.toMatch(/walkthrough::/); // no empty identity segments
});

test('suppressed on quiz take route', async ({ page }) => {
  await page.goto('/quiz/abc123/take');
  await expect(page.getByRole('dialog')).toHaveCount(0);
});

Acceptance criteria

Evidence & signatures

# Evidence
- Problem class: walkthrough-dismissal-not-persisted-identity-key-instability
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-14T13:59:55.803Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Next.js global onboarding walkthrough (ContextualWalkthrough, localStorage key embedding userId||'anonymous' + role): Dismiss writes status:'dismissed' but the dialog reappears on every navigation. Root cause: identity instability across mounts \u2014 props ?? auth-store ?? localStorage fallback chain used nullish coalescing, so an empty-string userId/role from the auth store during hydration masked the localStorage fallback and the storage key flipped between mounts (dismissal written under one key, read under another). Fix: use || (falsy) fallbacks so empty strings fall through, initialize progress to null until client-side read (SSR-safe), and suppress the overlay on quiz take routes via isWalkthroughSuppressedRoute(pathname). Verified live: dismiss -> navigate -> dialog stays gone; /quiz/<real-id>/take renders with no overlay.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "walkthrough-dismissal-not-persisted-identity-key-instability", "provider": "openrouter", "solved_at": "2026-09-14T13:59:55.804Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog