mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
6716cd31a5
commit
caf644eda7
@@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user