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:
parent
72693732dd
commit
ef01c3a5ed
6 changed files with 196 additions and 26 deletions
20
AGENTS.md
20
AGENTS.md
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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
91
src/lib/localDeletions.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue