fix(map): stop deleted spots flashing back from cache

A NIP-09 kind-5 delete is only honoured by relays and was never applied
locally, so on refresh the persisted React Query cache and the Dexie spot
cache re-rendered a just-deleted spot for a few seconds until fresh data
(or the admin hidden-label query) filtered it out.

Add src/lib/localDeletions.ts: a device-local tombstone keyed by coord
and event id, written optimistically by useDeleteEntity on the owner path
(rolled back if signing/publishing fails). The shared filterAdminHidden/
filterAdminHiddenMixed now also drop tombstoned items, so every list hook
filters them from first render with no per-hook changes. useSkateSpot
returns null for a tombstoned spot, and cacheSpots/cacheSpot refuse and
evict tombstoned rows. Prune by tombstone only (callers use limits 8-2000,
so pruning by 'not in fresh result' would wipe valid cached spots).
This commit is contained in:
SkateSpots 2026-10-09 09:03:16 +07:00
parent 72693732dd
commit ef01c3a5ed
6 changed files with 196 additions and 26 deletions

View file

@ -1621,6 +1621,26 @@ session with **no other memory of this project**. So:
functions with a free draft (`netlify deploy --dir=dist`), not a prod deploy. functions with a free draft (`netlify deploy --dir=dist`), not a prod deploy.
Verified end-to-end on a draft (2026-09-28): claim → resolves → a second key Verified end-to-end on a draft (2026-09-28): claim → resolves → a second key
gets 409 → release → gone. gets 409 → release → gone.
- **Deleted content flashed back from cache (fixed 2026-10-09):** on refresh, a
just-deleted spot (e.g. the Africa test pins) appeared for a few seconds and
then vanished. Two cache layers render before "current truth": the persisted
React Query cache (`skate-spots`/`skate-shops` are allow-listed in
`queryPersister.ts`) and the Dexie spot cache (`offlineCache.ts`). A NIP-09
kind-5 delete is honoured **only by relays** and is never applied locally, and
some relays ignore it — so cached copies kept resurfacing until the fresh query
(or the `useAdminModeration` hidden-label query) filtered them out.
Fix: **`src/lib/localDeletions.ts`** — a device-local tombstone
(`skatespots_local_deletions_v1`) keyed by BOTH coord (`kind:pubkey:d`) and
`e:<id>`, written optimistically in `useDeleteEntity` on the owner path (rolled
back if signing/publish fails). The shared **`filterAdminHidden` /
`filterAdminHiddenMixed`** now also drop tombstoned items, so every list hook
(spots/shops/events/tours/crews/marketplace/skaters/coaches/lab) filters them
from the first render — no per-hook changes. `useSkateSpot` returns null for a
tombstoned spot. `cacheSpots`/`cacheSpot` refuse to re-cache tombstoned rows and
evict existing ones. ⚠️ Do NOT prune the Dexie cache by "not in the fresh
result": callers use limits from 8 to 2000, so a small-limit query would wipe
valid cached spots — prune by tombstone only. Admin `hide` was already instant
via `localModeration`'s local verdicts; this closes the NIP-09 self-delete gap.
- **Offline / dead-relay behaviour (2026-09-28):** the app is a utility, so a - **Offline / dead-relay behaviour (2026-09-28):** the app is a utility, so a
relay outage must degrade, not blank. ⚠️ `querySkateContent` used to swallow relay outage must degrade, not blank. ⚠️ `querySkateContent` used to swallow
**every** relay error and return `[]`, so a dead relay looked like "no spots" **every** relay error and return `[]`, so a dead relay looked like "no spots"

View file

@ -8,6 +8,7 @@ import {
MODERATION_LABEL_CLEARED, MODERATION_LABEL_CLEARED,
} from '@/lib/types'; } from '@/lib/types';
import { getLocalVerdicts, type LocalVerdict } from '@/lib/localModeration'; import { getLocalVerdicts, type LocalVerdict } from '@/lib/localModeration';
import { getLocalDeletions } from '@/lib/localDeletions';
/** /**
* App-wide admin moderation layer. * App-wide admin moderation layer.
@ -56,11 +57,16 @@ const EMPTY: AdminHiddenSet = {
export function filterAdminHidden< export function filterAdminHidden<
T extends { id: string; pubkey: string; identifier: string }, T extends { id: string; pubkey: string; identifier: string },
>(items: T[], kind: number, hidden: AdminHiddenSet): T[] { >(items: T[], kind: number, hidden: AdminHiddenSet): T[] {
if (hidden.coords.size === 0 && hidden.ids.size === 0) return items; const deleted = getLocalDeletions();
return items.filter( if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0) return items;
(it) => return items.filter((it) => {
!hidden.has({ coord: `${kind}:${it.pubkey}:${it.identifier}`, id: it.id }), const coord = `${kind}:${it.pubkey}:${it.identifier}`;
); if (hidden.has({ coord, id: it.id })) return false;
// Locally-deleted (NIP-09 pending): drop immediately so a deleted item can
// never flash back from the persistence/offline cache on refresh.
if (deleted.has(coord) || deleted.has(`e:${it.id}`)) return false;
return true;
});
} }
/** /**
@ -76,14 +82,14 @@ export function filterAdminHiddenMixed<
event: { kind: number }; event: { kind: number };
}, },
>(items: T[], hidden: AdminHiddenSet): T[] { >(items: T[], hidden: AdminHiddenSet): T[] {
if (hidden.coords.size === 0 && hidden.ids.size === 0) return items; const deleted = getLocalDeletions();
return items.filter( if (hidden.coords.size === 0 && hidden.ids.size === 0 && deleted.size === 0) return items;
(it) => return items.filter((it) => {
!hidden.has({ const coord = `${it.event.kind}:${it.pubkey}:${it.identifier}`;
coord: `${it.event.kind}:${it.pubkey}:${it.identifier}`, if (hidden.has({ coord, id: it.id })) return false;
id: it.id, if (deleted.has(coord) || deleted.has(`e:${it.id}`)) return false;
}), return true;
); });
} }
export function useAdminModeration() { export function useAdminModeration() {

View file

@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { isAdminPubkey } from '@/lib/admins'; import { isAdminPubkey } from '@/lib/admins';
import { useAdminHide } from '@/hooks/useAdminHide'; import { useAdminHide } from '@/hooks/useAdminHide';
import { addLocalDeletion, removeLocalDeletion } from '@/lib/localDeletions';
import { toast } from '@/hooks/useToast'; import { toast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
@ -102,6 +103,12 @@ export function useDeleteEntity() {
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}; };
// Record a local tombstone NOW so the deleted item cannot flash back from
// the persistence/offline cache while the kind 5 propagates (or if some
// relay never honours it). Rolled back below if the delete does not go
// through, so a cancelled/failed delete cannot silently hide real content.
addLocalDeletion({ coord, id: event.id });
// ── PHASE 1: SIGN ──────────────────────────────────────────────────────── // ── PHASE 1: SIGN ────────────────────────────────────────────────────────
// With the Amethyst-style foreground signer, Amber pops up its Approve/Reject // With the Amethyst-style foreground signer, Amber pops up its Approve/Reject
// screen directly — no more silent ContentProvider stall. The signer has its // screen directly — no more silent ContentProvider stall. The signer has its
@ -124,6 +131,8 @@ export function useDeleteEntity() {
); );
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
// The delete did not go through — undo the optimistic tombstone.
removeLocalDeletion({ coord, id: event.id });
// Distinguish a deliberate rejection from a real failure. // Distinguish a deliberate rejection from a real failure.
if (/reject/i.test(msg)) { if (/reject/i.test(msg)) {
toast({ title: t('toastsUi.deleteCancelled'), description: t('toastsUi.deleteCancelledDesc') }); toast({ title: t('toastsUi.deleteCancelled'), description: t('toastsUi.deleteCancelledDesc') });
@ -151,6 +160,9 @@ export function useDeleteEntity() {
); );
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : String(e); const msg = e instanceof Error ? e.message : String(e);
// Signed fine but the relay refused/failed — undo the tombstone so the
// content is not hidden locally when nothing was actually deleted.
removeLocalDeletion({ coord, id: event.id });
// The event was SIGNED fine — the relay is the slow/failing part. // The event was SIGNED fine — the relay is the slow/failing part.
toast({ toast({
title: t('toastsUi.deleteStuck'), title: t('toastsUi.deleteStuck'),

View file

@ -3,6 +3,7 @@ import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
import { hasLocalDeletion } from '@/lib/localDeletions';
import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache'; import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache';
// Spots change infrequently — cache for 5 minutes, keep for 30 minutes // Spots change infrequently — cache for 5 minutes, keep for 30 minutes
@ -93,7 +94,11 @@ export function useSkateSpot(identifier: string, pubkey?: string) {
queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()], queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()],
select: (spot: SkateSpot | null) => { select: (spot: SkateSpot | null) => {
if (!spot) return null; if (!spot) return null;
if (!hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })) { const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
// Locally-deleted (NIP-09 pending): treat as gone immediately so a
// stale cached copy can't resurface on a direct visit either.
if (hasLocalDeletion({ coord, id: spot.id })) return null;
if (!hidden.has({ coord, id: spot.id })) {
return spot; return spot;
} }
// A hidden spot stays reachable for its OWN AUTHOR — otherwise their spot // A hidden spot stays reachable for its OWN AUTHOR — otherwise their spot

91
src/lib/localDeletions.ts Normal file
View file

@ -0,0 +1,91 @@
/**
* Local (optimistic) content-deletion tombstones.
*
* WHY THIS EXISTS
* ---------------
* A NIP-09 deletion request (kind 5) is only honoured by relays, and the client
* never applies it locally. Until the relays have processed it — and some relays
* never do — a deleted event can still come back from the React Query
* persistence cache or the Dexie offline cache, so a deleted spot flashes on the
* map for a few seconds on refresh before the fresh relay result removes it.
*
* Recording a tombstone the moment the author deletes something lets every list
* filter drop it from the very first render, independent of relay timing. The
* tombstone is device-local and best-effort; it is NOT a substitute for the
* kind-5 event (that is what removes the content for everyone else).
*
* KEYING
* ------
* A target is keyed BOTH by its addressable coordinate (`kind:pubkey:d`) and by
* its concrete event id (`e:<id>`) when both are known, so a list filter matches
* whichever form it has. Entries carry a timestamp so the store can be pruned
* later if needed.
*/
export interface LocalDeletionEntry {
/** Unix seconds. */
at: number;
}
const STORAGE_KEY = 'skatespots_local_deletions_v1';
type Store = Record<string, LocalDeletionEntry>;
function read(): Store {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? (parsed as Store) : {};
} catch {
return {};
}
}
function write(store: Store): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
/* storage full / unavailable — the optimistic tombstone is best-effort */
}
}
/** Record that a target was deleted by its author on this device. */
export function addLocalDeletion(opts: { coord?: string; id?: string }): void {
const { coord, id } = opts;
if (!coord && !id) return;
const store = read();
const at = Math.floor(Date.now() / 1000);
if (coord) store[coord] = { at };
if (id) store[`e:${id}`] = { at };
write(store);
}
/** Undo a tombstone (e.g. the deletion was cancelled or failed to publish). */
export function removeLocalDeletion(opts: { coord?: string; id?: string }): void {
const { coord, id } = opts;
const store = read();
let changed = false;
if (coord && coord in store) {
delete store[coord];
changed = true;
}
if (id && `e:${id}` in store) {
delete store[`e:${id}`];
changed = true;
}
if (changed) write(store);
}
/** Every locally-recorded deletion, keyed by coord or `e:<id>`. */
export function getLocalDeletions(): Map<string, LocalDeletionEntry> {
return new Map(Object.entries(read()));
}
/** True when this target carries a local deletion tombstone. */
export function hasLocalDeletion(opts: { coord?: string; id?: string }): boolean {
const store = read();
if (opts.coord && opts.coord in store) return true;
if (opts.id && `e:${opts.id}` in store) return true;
return false;
}

View file

@ -1,6 +1,7 @@
import Dexie, { type Table } from 'dexie'; import Dexie, { type Table } from 'dexie';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import type { SkateSpot } from '@/lib/types'; import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
import { getLocalDeletions } from '@/lib/localDeletions';
// Define the database schema // Define the database schema
interface CachedSpot { interface CachedSpot {
@ -95,6 +96,16 @@ export async function cacheSpot(event: NostrEvent, spot: SkateSpot): Promise<voi
const db = getDB(); const db = getDB();
const now = Date.now(); const now = Date.now();
// Never re-cache a locally-deleted spot, and evict a stale cached copy.
const deleted = getLocalDeletions();
if (
deleted.has(`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`) ||
deleted.has(`e:${event.id}`)
) {
await db.spots.delete(event.id);
return;
}
await db.spots.put({ await db.spots.put({
id: event.id, id: event.id,
pubkey: spot.pubkey, pubkey: spot.pubkey,
@ -112,18 +123,43 @@ export async function cacheSpot(event: NostrEvent, spot: SkateSpot): Promise<voi
export async function cacheSpots(spotsWithEvents: Array<{ event: NostrEvent; spot: SkateSpot }>): Promise<void> { export async function cacheSpots(spotsWithEvents: Array<{ event: NostrEvent; spot: SkateSpot }>): Promise<void> {
const db = getDB(); const db = getDB();
const now = Date.now(); const now = Date.now();
const deleted = getLocalDeletions();
await db.spots.bulkPut( const isTombstoned = (event: NostrEvent, spot: SkateSpot) =>
spotsWithEvents.map(({ event, spot }) => ({ deleted.has(`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`) ||
id: event.id, deleted.has(`e:${event.id}`);
pubkey: spot.pubkey,
identifier: spot.identifier, const keep =
event, deleted.size > 0
spot, ? spotsWithEvents.filter(({ event, spot }) => !isTombstoned(event, spot))
cachedAt: now, : spotsWithEvents;
viewedAt: now,
})) if (keep.length > 0) {
); await db.spots.bulkPut(
keep.map(({ event, spot }) => ({
id: event.id,
pubkey: spot.pubkey,
identifier: spot.identifier,
event,
spot,
cachedAt: now,
viewedAt: now,
}))
);
}
// Evict previously-cached rows that now carry a tombstone (deleted after they
// were cached), so the offline map can never resurrect a deleted spot.
if (deleted.size > 0) {
const stale = (await db.spots.toArray())
.filter(
(c) =>
deleted.has(`${SKATE_SPOT_KIND}:${c.pubkey}:${c.identifier}`) ||
deleted.has(`e:${c.id}`),
)
.map((c) => c.id);
if (stale.length > 0) await db.spots.bulkDelete(stale);
}
} }
/** /**