fix(activity panel): handle back navigation (#1487)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-07-05 09:24:14 -07:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 6716cd31a5
commit caf644eda7
12 changed files with 410 additions and 5 deletions
@@ -0,0 +1,40 @@
import * as React from "react";
import {
createPanelReturnTargetStore,
type PanelReturnTargetStore,
} from "@/shared/lib/panelReturnTarget";
/**
* React binding for `createPanelReturnTargetStore`: a stable return-target
* breadcrumb for mutually-exclusive panels, plus a reactive `hasTarget` so
* back affordances can hide when there is nowhere to return to.
*
* The store identity is stable for the component's lifetime, so callbacks
* can list it as a dependency without churning. A change of `resetKey`
* (e.g. the active channel id) drops any recorded target, keeping
* breadcrumbs from leaking across contexts.
*/
export function usePanelReturnTarget<T>(resetKey: unknown = null): {
hasTarget: boolean;
store: PanelReturnTargetStore<T>;
} {
const storeRef = React.useRef<PanelReturnTargetStore<T> | null>(null);
storeRef.current ??= createPanelReturnTargetStore<T>();
const store = storeRef.current;
const previousResetKeyRef = React.useRef(resetKey);
if (previousResetKeyRef.current !== resetKey) {
previousResetKeyRef.current = resetKey;
// Render-safe silent drop: useSyncExternalStore re-reads the snapshot
// during this same render, so no notification is needed (or allowed).
store.reset();
}
const hasTarget = React.useSyncExternalStore(
store.subscribe,
() => store.peek() != null,
);
return { hasTarget, store };
}
@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createPanelReturnTargetStore } from "./panelReturnTarget.ts";
test("consume returns the captured target exactly once", () => {
const store = createPanelReturnTargetStore();
store.capture({ kind: "thread", threadHeadId: "head-1" });
assert.deepEqual(store.consume(), { kind: "thread", threadHeadId: "head-1" });
assert.equal(store.consume(), null);
});
test("capture overwrites a previous target", () => {
const store = createPanelReturnTargetStore();
store.capture({ kind: "thread", threadHeadId: "head-1" });
store.capture({ kind: "profile", pubkey: "abc" });
assert.deepEqual(store.consume(), { kind: "profile", pubkey: "abc" });
});
test("capturing null clears the target", () => {
const store = createPanelReturnTargetStore();
store.capture({ kind: "profile", pubkey: "abc" });
store.capture(null);
assert.equal(store.consume(), null);
});
test("clear drops the target without consuming", () => {
const store = createPanelReturnTargetStore();
store.capture({ kind: "profile", pubkey: "abc" });
store.clear();
assert.equal(store.peek(), null);
assert.equal(store.consume(), null);
});
test("peek reads without consuming", () => {
const store = createPanelReturnTargetStore();
store.capture({ kind: "thread", threadHeadId: "head-1" });
assert.deepEqual(store.peek(), { kind: "thread", threadHeadId: "head-1" });
assert.deepEqual(store.consume(), { kind: "thread", threadHeadId: "head-1" });
});
test("subscribe notifies on capture, clear, and consume", () => {
const store = createPanelReturnTargetStore();
let notifications = 0;
const unsubscribe = store.subscribe(() => {
notifications += 1;
});
store.capture({ kind: "profile", pubkey: "abc" });
assert.equal(notifications, 1);
store.consume();
assert.equal(notifications, 2);
store.clear();
assert.equal(notifications, 3);
unsubscribe();
store.capture({ kind: "profile", pubkey: "abc" });
assert.equal(notifications, 3);
});
test("reset drops the target silently", () => {
const store = createPanelReturnTargetStore();
let notifications = 0;
store.subscribe(() => {
notifications += 1;
});
store.capture({ kind: "thread", threadHeadId: "head-1" });
assert.equal(notifications, 1);
store.reset();
assert.equal(notifications, 1);
assert.equal(store.peek(), null);
});
@@ -0,0 +1,72 @@
/**
* One-shot "where did this panel transition come from?" breadcrumb.
*
* Panels that replace one another (rather than stacking) can capture an
* explicit return target when they open, then consume it exactly once when
* their back affordance fires. This avoids popping the wholesale app/browser
* history stack, so an in-panel back press never leaves the current screen
* unexpectedly.
*
* The store is subscribable so UI can react to target presence (e.g. hide
* the back arrow when there is nowhere to return to). Pure store so the
* semantics are unit-testable; the React binding lives in
* `@/shared/hooks/usePanelReturnTarget`.
*/
export type PanelReturnTargetStore<T> = {
/** Record where the panel is coming from. `null` means "nowhere useful". */
capture: (target: T | null) => void;
/** Drop any recorded target without consuming it (e.g. on plain close). */
clear: () => void;
/** Take the recorded target, resetting the store — one back per capture. */
consume: () => T | null;
/** Read without consuming (for tests and conditional affordances). */
peek: () => T | null;
/**
* Drop the target without notifying subscribers. Render-safe: the React
* binding calls this while rendering on reset-key changes, where notifying
* would schedule updates mid-render.
*/
reset: () => void;
/** Subscribe to target changes. Returns an unsubscribe function. */
subscribe: (listener: () => void) => () => void;
};
export function createPanelReturnTargetStore<T>(): PanelReturnTargetStore<T> {
let target: T | null = null;
const listeners = new Set<() => void>();
const notify = () => {
for (const listener of [...listeners]) {
listener();
}
};
return {
capture(next) {
target = next;
notify();
},
clear() {
target = null;
notify();
},
consume() {
const current = target;
target = null;
notify();
return current;
},
peek() {
return target;
},
reset() {
target = null;
},
subscribe(listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
};
}