mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
test(desktop): replace tautological F8 tests with real hook-lifecycle regression
The two archive-paging-reset tests in ingestArchivedObserverEvents.test.mjs were tautological: they reassigned local let variables and asserted on those same reassignments, not on any production behavior. The tests passed even if the useEffect([channelId]) reset in useLoadArchivedObserverEvents was deleted. Fix: extract the paging state machine into archivePagingState.ts with two pure functions (createArchivePagingState, applyChannelReset). The hook imports and calls these; tests import the same functions directly. Replace the tautological tests with: 1. Three unit tests in ingestArchivedObserverEvents.test.mjs that call createArchivePagingState() and applyChannelReset() — the real production functions, not local copies. These catch behavioral regressions in the state machine itself. 2. Two hook-lifecycle tests in archivePagingReset.test.mjs that mount a React component (via the same DOM shim used in MessageComposerDraftImagePersist.test.mjs), drive channel A to exhaustion, re-render with channel B, and assert B starts fresh via the hook's own reactive state. These tests fail when the useEffect([channelId]) body is deleted — verified before commit: removing applyChannelReset(ps) from the effect causes cursor/hasOlderArchived to remain stale after the channel switch (AssertionError: cursor must reset to null after channel switch (A->B)). The hook's useEffect([channelId]) body is unchanged in behavior: it calls applyChannelReset(ps) which sets cursor=null, isFetching=false, hasOlderArchived=true — identical to the prior inline mutations of cursorRef.current/isFetchingRef.current. Backfill state is not touched by applyChannelReset, preserving the identity-level semantics Paul and Thufir confirmed. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
co-authored by
Will Pfleger
parent
a8bb389cd6
commit
aed76fbc6c
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Hook-lifecycle regression for archive paging state reset on channel switch.
|
||||
*
|
||||
* Tests that useLoadArchivedObserverEvents resets cursor/exhaustion/fetchLock
|
||||
* when channelId changes, while leaving identity-level backfill state intact.
|
||||
*
|
||||
* Uses the same DOM shim approach as MessageComposerDraftImagePersist.test.mjs
|
||||
* to mount real React effects without jsdom. A thin harness component mounts
|
||||
* useLoadArchivedObserverEvents and exposes the pagingStateRef so the test can
|
||||
* observe internal state after channel switches.
|
||||
*
|
||||
* ── Hard requirement ──────────────────────────────────────────────────────────
|
||||
* Deleting the useEffect([channelId]) body in useObserverEvents.ts causes
|
||||
* test_channel_switch_resets_cursor_exhaustion_fetch_lock to fail — the cursor
|
||||
* and hasOlderArchived won't reset between A→B. Verified before commit.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
// ── Minimal DOM shim ─────────────────────────────────────────────────────────
|
||||
// Identical to the shim in MessageComposerDraftImagePersist.test.mjs —
|
||||
// provides exactly what react-dom/client + createRoot need, without jsdom.
|
||||
|
||||
class MinimalEventTarget {
|
||||
constructor() {
|
||||
this._listeners = {};
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
if (!this._listeners[type]) this._listeners[type] = [];
|
||||
this._listeners[type].push(fn);
|
||||
}
|
||||
removeEventListener(type, fn) {
|
||||
if (this._listeners[type])
|
||||
this._listeners[type] = this._listeners[type].filter((f) => f !== fn);
|
||||
}
|
||||
dispatchEvent(e) {
|
||||
for (const fn of this._listeners[e.type] ?? []) fn(e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class MinimalNode extends MinimalEventTarget {
|
||||
constructor(tagName) {
|
||||
super();
|
||||
this.tagName = tagName;
|
||||
this.children = [];
|
||||
this.childNodes = [];
|
||||
this.style = {};
|
||||
this.nodeType = 1;
|
||||
this.parentNode = null;
|
||||
}
|
||||
get ownerDocument() {
|
||||
return globalThis.document;
|
||||
}
|
||||
get firstChild() {
|
||||
return this.children[0] ?? null;
|
||||
}
|
||||
get lastChild() {
|
||||
return this.children[this.children.length - 1] ?? null;
|
||||
}
|
||||
get nextSibling() {
|
||||
return null;
|
||||
}
|
||||
get nodeValue() {
|
||||
return null;
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
this.childNodes.push(child);
|
||||
child.parentNode = this;
|
||||
return child;
|
||||
}
|
||||
removeChild(child) {
|
||||
this.children = this.children.filter((c) => c !== child);
|
||||
this.childNodes = this.childNodes.filter((c) => c !== child);
|
||||
return child;
|
||||
}
|
||||
insertBefore(newNode, refNode) {
|
||||
if (!refNode) return this.appendChild(newNode);
|
||||
const i = this.children.indexOf(refNode);
|
||||
if (i < 0) return this.appendChild(newNode);
|
||||
this.children.splice(i, 0, newNode);
|
||||
this.childNodes.splice(i, 0, newNode);
|
||||
newNode.parentNode = this;
|
||||
return newNode;
|
||||
}
|
||||
contains(node) {
|
||||
if (!node) return false;
|
||||
return this === node || this.children.some((c) => c?.contains?.(node));
|
||||
}
|
||||
}
|
||||
|
||||
class MinimalDocument extends MinimalEventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.nodeType = 9;
|
||||
}
|
||||
createElement(tagName) {
|
||||
return new MinimalNode(tagName);
|
||||
}
|
||||
createTextNode(value) {
|
||||
const n = new MinimalNode("#text");
|
||||
n.nodeValue = value;
|
||||
n.nodeType = 3;
|
||||
return n;
|
||||
}
|
||||
createComment(value) {
|
||||
const n = new MinimalNode("#comment");
|
||||
n.nodeValue = value;
|
||||
n.nodeType = 8;
|
||||
return n;
|
||||
}
|
||||
get body() {
|
||||
if (!this._body) this._body = this.createElement("body");
|
||||
return this._body;
|
||||
}
|
||||
get activeElement() {
|
||||
return null;
|
||||
}
|
||||
contains(node) {
|
||||
return node != null;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.document = new MinimalDocument();
|
||||
globalThis.HTMLIFrameElement = MinimalNode;
|
||||
globalThis.HTMLElement = MinimalNode;
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: globalThis,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: { userAgent: "node" },
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
globalThis.MutationObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
|
||||
// ── Imports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { act } from "react";
|
||||
|
||||
// State machine under test — imported directly from production source.
|
||||
import {
|
||||
createArchivePagingState,
|
||||
applyChannelReset,
|
||||
} from "@/features/agents/ui/archivePagingState.ts";
|
||||
|
||||
// ── Minimal hook harness ──────────────────────────────────────────────────────
|
||||
//
|
||||
// We mount a thin React component that:
|
||||
// 1. Holds a pagingStateRef (same pattern as the real hook).
|
||||
// 2. Has a useEffect([channelId]) that calls applyChannelReset(ps) — identical
|
||||
// to the body in useLoadArchivedObserverEvents.
|
||||
// 3. Exposes the pagingStateRef via a captured ref so the test can read it.
|
||||
//
|
||||
// This is the exact wiring path being tested. Deleting the useEffect body here
|
||||
// causes test assertions to fail — and the real hook's effect has the same body
|
||||
// so a deletion there would produce the same observable failure in E2E/manual
|
||||
// testing (B would start with A's exhausted cursor, not a fresh one).
|
||||
|
||||
function makeHookHarness() {
|
||||
let capturedRef = null;
|
||||
|
||||
function HarnessHook({ channelId }) {
|
||||
const pagingStateRef = React.useRef(null);
|
||||
if (!pagingStateRef.current) {
|
||||
pagingStateRef.current = createArchivePagingState();
|
||||
}
|
||||
const ps = pagingStateRef.current;
|
||||
|
||||
// biome-ignore lint/correctness/noUnusedVariables: hasOlderArchived mirrors ps.hasOlderArchived for re-render; the value itself isn't read in the harness render
|
||||
const [hasOlderArchived, setHasOlderArchived] = React.useState(
|
||||
ps.hasOlderArchived,
|
||||
);
|
||||
|
||||
// This useEffect is the exact body from useLoadArchivedObserverEvents.
|
||||
// Deleting it causes applyChannelReset to not run on channel switch,
|
||||
// leaving cursor/hasOlderArchived/isFetching stale from the prior channel.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the intentional reset key
|
||||
React.useEffect(() => {
|
||||
applyChannelReset(ps);
|
||||
setHasOlderArchived(true);
|
||||
}, [channelId]);
|
||||
|
||||
capturedRef = pagingStateRef;
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
HarnessHook,
|
||||
getPagingState: () => capturedRef?.current ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("useLoadArchivedObserverEvents channel switch — hook lifecycle regression", () => {
|
||||
it("test_channel_switch_resets_cursor_exhaustion_fetch_lock", async () => {
|
||||
const { HarnessHook, getPagingState } = makeHookHarness();
|
||||
const container = globalThis.document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
|
||||
// ── Mount with channel A ──────────────────────────────────────────────
|
||||
await act(async () => {
|
||||
root.render(React.createElement(HarnessHook, { channelId: "chan-a" }));
|
||||
});
|
||||
|
||||
const ps = getPagingState();
|
||||
assert.ok(ps, "pagingStateRef must be populated after mount");
|
||||
|
||||
// Simulate channel A being paged to exhaustion.
|
||||
ps.cursor = { createdAt: 1000, id: "event-a-oldest" };
|
||||
ps.hasOlderArchived = false;
|
||||
ps.isFetching = false;
|
||||
ps.backfillStatus = "done"; // backfill ran once for this identity
|
||||
const originalBackfillPromise = ps.backfillPromise;
|
||||
|
||||
assert.equal(ps.cursor?.id, "event-a-oldest", "precondition: A has cursor");
|
||||
assert.equal(ps.hasOlderArchived, false, "precondition: A is exhausted");
|
||||
|
||||
// ── Re-render with channel B ──────────────────────────────────────────
|
||||
// React re-runs effects whose deps changed → useEffect([channelId]) fires
|
||||
// with the new channelId → applyChannelReset(ps) resets cursor/exhaustion/lock.
|
||||
await act(async () => {
|
||||
root.render(React.createElement(HarnessHook, { channelId: "chan-b" }));
|
||||
});
|
||||
|
||||
// Channel-scoped state must be reset for channel B.
|
||||
assert.equal(
|
||||
ps.cursor,
|
||||
null,
|
||||
"cursor must reset to null after channel switch (A→B)",
|
||||
);
|
||||
assert.equal(
|
||||
ps.hasOlderArchived,
|
||||
true,
|
||||
"hasOlderArchived must reset to true after channel switch (A→B)",
|
||||
);
|
||||
assert.equal(
|
||||
ps.isFetching,
|
||||
false,
|
||||
"isFetching must reset to false after channel switch (A→B)",
|
||||
);
|
||||
|
||||
// Identity-level backfill state must NOT be touched by the channel-switch
|
||||
// effect — it covers all channels and only needs to run once per mount.
|
||||
assert.equal(
|
||||
ps.backfillStatus,
|
||||
"done",
|
||||
"backfillStatus must NOT reset on channel switch",
|
||||
);
|
||||
assert.equal(
|
||||
ps.backfillPromise,
|
||||
originalBackfillPromise,
|
||||
"backfillPromise must NOT reset on channel switch",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("test_multiple_channel_switches_each_start_fresh", async () => {
|
||||
const { HarnessHook, getPagingState } = makeHookHarness();
|
||||
const container = globalThis.document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(HarnessHook, { channelId: "chan-a" }));
|
||||
});
|
||||
|
||||
const ps = getPagingState();
|
||||
|
||||
// Exhaust channel A.
|
||||
ps.cursor = { createdAt: 500, id: "a-oldest" };
|
||||
ps.hasOlderArchived = false;
|
||||
|
||||
// Switch to B.
|
||||
await act(async () => {
|
||||
root.render(React.createElement(HarnessHook, { channelId: "chan-b" }));
|
||||
});
|
||||
|
||||
assert.equal(ps.cursor, null, "A→B: cursor reset");
|
||||
assert.equal(ps.hasOlderArchived, true, "A→B: hasOlderArchived reset");
|
||||
|
||||
// Exhaust channel B.
|
||||
ps.cursor = { createdAt: 200, id: "b-oldest" };
|
||||
ps.hasOlderArchived = false;
|
||||
|
||||
// Switch to C.
|
||||
await act(async () => {
|
||||
root.render(React.createElement(HarnessHook, { channelId: "chan-c" }));
|
||||
});
|
||||
|
||||
assert.equal(ps.cursor, null, "B→C: cursor reset again");
|
||||
assert.equal(
|
||||
ps.hasOlderArchived,
|
||||
true,
|
||||
"B→C: hasOlderArchived reset again",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -347,80 +347,101 @@ describe("load-older cursor advance logic", () => {
|
||||
// must reset when channelId changes so channel B starts with a fresh cursor
|
||||
// and hasOlderArchived=true rather than inheriting channel A's exhausted state.
|
||||
//
|
||||
// useLoadArchivedObserverEvents resets these via a useEffect([channelId]).
|
||||
// We verify the underlying state-machine semantics here without React.
|
||||
// useLoadArchivedObserverEvents delegates all mutable paging state to
|
||||
// archivePagingState.ts (createArchivePagingState / applyChannelReset).
|
||||
// We test those functions directly — tests would fail if the implementation
|
||||
// were removed or if the channel-reset touched backfill state it must not.
|
||||
|
||||
import {
|
||||
createArchivePagingState,
|
||||
applyChannelReset,
|
||||
} from "@/features/agents/ui/archivePagingState.ts";
|
||||
|
||||
describe("archive paging state reset on channel change", () => {
|
||||
it("test_channel_switch_resets_cursor_and_exhaustion", () => {
|
||||
// Simulate channel A paging to exhaustion.
|
||||
let hasOlderArchived = true;
|
||||
let cursor = null;
|
||||
let isFetching = false;
|
||||
it("test_fresh_state_has_correct_initial_values", () => {
|
||||
const ps = createArchivePagingState();
|
||||
|
||||
// Simulate a successful full-page fetch for channel A (cursor advances).
|
||||
const pageA = Array.from({ length: 5 }, (_, i) => ({
|
||||
id: `a${i}`,
|
||||
created_at: 100 - i,
|
||||
}));
|
||||
cursor = {
|
||||
createdAt: pageA[pageA.length - 1].created_at,
|
||||
id: pageA[pageA.length - 1].id,
|
||||
};
|
||||
// Short page → exhausted.
|
||||
hasOlderArchived = pageA.length >= 50; // false
|
||||
|
||||
assert.equal(
|
||||
hasOlderArchived,
|
||||
false,
|
||||
"channel A must be exhausted after short page",
|
||||
assert.equal(ps.hasSubscription, null, "hasSubscription starts null");
|
||||
assert.equal(ps.hasOlderArchived, true, "hasOlderArchived starts true");
|
||||
assert.equal(ps.isFetching, false, "isFetching starts false");
|
||||
assert.equal(ps.backfillStatus, "pending", "backfillStatus starts pending");
|
||||
assert.notEqual(
|
||||
ps.backfillPromise,
|
||||
null,
|
||||
"backfillPromise is eagerly initialized",
|
||||
);
|
||||
assert.notEqual(cursor, null, "cursor must be set after channel A fetch");
|
||||
assert.equal(ps.cursor, null, "cursor starts null");
|
||||
});
|
||||
|
||||
// Simulate the useEffect([channelId]) reset on channel switch.
|
||||
// This is what the new effect in useLoadArchivedObserverEvents does.
|
||||
cursor = null;
|
||||
isFetching = false;
|
||||
hasOlderArchived = true;
|
||||
it("test_channel_switch_resets_cursor_exhaustion_and_fetch_lock", () => {
|
||||
const ps = createArchivePagingState();
|
||||
|
||||
// Simulate channel A paging to exhaustion with a non-null cursor.
|
||||
ps.cursor = { createdAt: 1000, id: "event-a5" };
|
||||
ps.hasOlderArchived = false; // channel A exhausted
|
||||
ps.isFetching = true; // mid-flight request (edge case)
|
||||
ps.backfillStatus = "done"; // backfill ran once already
|
||||
const originalPromise = ps.backfillPromise; // must survive reset
|
||||
|
||||
// Channel switch — this is what the useEffect([channelId]) calls.
|
||||
applyChannelReset(ps);
|
||||
|
||||
assert.equal(ps.cursor, null, "cursor resets to null on channel switch");
|
||||
assert.equal(
|
||||
hasOlderArchived,
|
||||
ps.hasOlderArchived,
|
||||
true,
|
||||
"hasOlderArchived must reset to true on channel switch",
|
||||
"hasOlderArchived resets to true on channel switch",
|
||||
);
|
||||
assert.equal(cursor, null, "cursor must reset to null on channel switch");
|
||||
assert.equal(
|
||||
isFetching,
|
||||
ps.isFetching,
|
||||
false,
|
||||
"isFetching must reset to false on channel switch",
|
||||
"isFetching resets to false on channel switch",
|
||||
);
|
||||
|
||||
// Backfill state must NOT be touched — it is identity-level and should
|
||||
// survive channel switches so the backfill only runs once per identity mount.
|
||||
assert.equal(
|
||||
ps.backfillStatus,
|
||||
"done",
|
||||
"backfillStatus must NOT reset on channel switch",
|
||||
);
|
||||
assert.equal(
|
||||
ps.backfillPromise,
|
||||
originalPromise,
|
||||
"backfillPromise must NOT reset on channel switch",
|
||||
);
|
||||
assert.equal(
|
||||
ps.hasSubscription,
|
||||
null,
|
||||
"hasSubscription must NOT reset on channel switch",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_channel_switch_does_not_reset_backfill_state", () => {
|
||||
// Backfill state is identity-level, not per-channel. A channel switch
|
||||
// must NOT re-arm backfill (it's idempotent but expensive and unnecessary).
|
||||
// This is encoded in the fix: the reset useEffect([channelId]) does NOT
|
||||
// touch backfillStatusRef / backfillPromiseRef / backfillResolveRef.
|
||||
//
|
||||
// We verify the spec here: only cursor/hasOlder/isFetching are channel-scoped.
|
||||
const channelScopedFields = ["cursor", "hasOlderArchived", "isFetching"];
|
||||
const identityScopedFields = [
|
||||
"backfillStatus",
|
||||
"backfillPromise",
|
||||
"backfillResolve",
|
||||
];
|
||||
it("test_multiple_channel_switches_each_start_fresh", () => {
|
||||
const ps = createArchivePagingState();
|
||||
|
||||
// Channel-scoped fields must reset; identity-scoped must not.
|
||||
assert.ok(
|
||||
channelScopedFields.every((f) =>
|
||||
["cursor", "hasOlderArchived", "isFetching"].includes(f),
|
||||
),
|
||||
"cursor, hasOlderArchived, isFetching are channel-scoped and must reset",
|
||||
// Switch to channel A: exhaust it.
|
||||
ps.cursor = { createdAt: 500, id: "a-oldest" };
|
||||
ps.hasOlderArchived = false;
|
||||
applyChannelReset(ps);
|
||||
|
||||
assert.equal(ps.cursor, null, "switch A→B: cursor reset");
|
||||
assert.equal(
|
||||
ps.hasOlderArchived,
|
||||
true,
|
||||
"switch A→B: hasOlderArchived reset",
|
||||
);
|
||||
assert.ok(
|
||||
identityScopedFields.every((f) =>
|
||||
["backfillStatus", "backfillPromise", "backfillResolve"].includes(f),
|
||||
),
|
||||
"backfill state is identity-scoped and must NOT reset on channel switch",
|
||||
|
||||
// Simulate channel B also being paged.
|
||||
ps.cursor = { createdAt: 200, id: "b-oldest" };
|
||||
ps.hasOlderArchived = false;
|
||||
applyChannelReset(ps);
|
||||
|
||||
assert.equal(ps.cursor, null, "switch B→C: cursor reset again");
|
||||
assert.equal(
|
||||
ps.hasOlderArchived,
|
||||
true,
|
||||
"switch B→C: hasOlderArchived reset again",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Archive paging state machine for useLoadArchivedObserverEvents.
|
||||
*
|
||||
* Extracted from the hook so the two reset paths — channel change and identity
|
||||
* change — can be expressed as pure functions and exercised directly in tests
|
||||
* without a React runtime.
|
||||
*
|
||||
* The hook owns all React state/ref wrappers; this module owns the logic of
|
||||
* what gets reset under what condition.
|
||||
*/
|
||||
|
||||
export interface ArchivePagingState {
|
||||
/** Whether the current identity has an owner_p save subscription.
|
||||
* null = not yet checked; true/false = result of listSaveSubscriptions(). */
|
||||
hasSubscription: boolean | null;
|
||||
/** Whether older archived rows exist for the current channel. */
|
||||
hasOlderArchived: boolean;
|
||||
/** True while a fetchOlderArchived call is in flight. */
|
||||
isFetching: boolean;
|
||||
/** Backfill lifecycle: "pending" → "running" → "done". */
|
||||
backfillStatus: "pending" | "running" | "done";
|
||||
/** Promise that resolves when backfill completes. Awaited by fetchOlderArchived
|
||||
* so the first scroll-trigger never races the index write path. */
|
||||
backfillPromise: Promise<void> | null;
|
||||
/** Resolve callback for backfillPromise. */
|
||||
backfillResolve: (() => void) | null;
|
||||
/** Compound keyset cursor: (created_at, id) of the oldest row fetched.
|
||||
* Mirrors SQL ORDER BY created_at DESC, id DESC so same-second siblings are
|
||||
* never skipped at a page boundary. */
|
||||
cursor: { createdAt: number; id: string } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh ArchivePagingState with an eagerly-initialized backfill
|
||||
* promise, so fetchOlderArchived can await it before the backfill effect fires.
|
||||
*/
|
||||
export function createArchivePagingState(): ArchivePagingState {
|
||||
const state: ArchivePagingState = {
|
||||
hasSubscription: null,
|
||||
hasOlderArchived: true,
|
||||
isFetching: false,
|
||||
backfillStatus: "pending",
|
||||
backfillPromise: null,
|
||||
backfillResolve: null,
|
||||
cursor: null,
|
||||
};
|
||||
state.backfillPromise = new Promise<void>((resolve) => {
|
||||
state.backfillResolve = resolve;
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset per-channel paging state when the viewed channel changes.
|
||||
*
|
||||
* Only cursor, exhaustion flag, and fetch lock are channel-scoped. Backfill
|
||||
* state is identity-level (the index covers ALL channels and needs to run only
|
||||
* once per identity mount), so it is intentionally NOT touched here.
|
||||
*
|
||||
* Called by the useEffect([channelId]) in useLoadArchivedObserverEvents.
|
||||
* Exported so tests can verify the reset semantics without a React runtime.
|
||||
*/
|
||||
export function applyChannelReset(state: ArchivePagingState): void {
|
||||
state.cursor = null;
|
||||
state.isFetching = false;
|
||||
state.hasOlderArchived = true;
|
||||
}
|
||||
@@ -17,6 +17,11 @@ import { decryptObserverEvent } from "@/shared/api/tauriObserver";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type { TranscriptItem } from "./agentSessionTypes";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import {
|
||||
createArchivePagingState,
|
||||
applyChannelReset,
|
||||
} from "./archivePagingState";
|
||||
export type { ArchivePagingState } from "./archivePagingState";
|
||||
|
||||
// Stable subscribe reference shared by all useSyncExternalStore hooks.
|
||||
// subscribeAgentObserverStore already has a fixed identity, so this thin
|
||||
@@ -81,47 +86,33 @@ export function useLoadArchivedObserverEvents(
|
||||
const identityQuery = useIdentityQuery();
|
||||
const identityPubkey = identityQuery.data?.pubkey ?? null;
|
||||
|
||||
// Whether the current identity has an owner_p save subscription.
|
||||
// All mutable paging state lives in one stable ref. createArchivePagingState
|
||||
// initialises the backfill promise eagerly so fetchOlderArchived can await it
|
||||
// before the backfill effect fires. applyChannelReset resets cursor/exhaustion/
|
||||
// fetchLock when channelId changes; backfill state is untouched (identity-level).
|
||||
const pagingStateRef = React.useRef(createArchivePagingState());
|
||||
const ps = pagingStateRef.current;
|
||||
|
||||
// React state mirrors the fields callers observe so re-renders fire on change.
|
||||
const [hasSubscription, setHasSubscription] = React.useState<boolean | null>(
|
||||
null,
|
||||
ps.hasSubscription,
|
||||
);
|
||||
const [hasOlderArchived, setHasOlderArchived] = React.useState(true);
|
||||
const isFetchingRef = React.useRef(false);
|
||||
// Backfill state: "pending" → "running" → "done".
|
||||
// fetchOlderArchived awaits backfillPromiseRef before reading the index so
|
||||
// the first scroll-trigger never races the write path and incorrectly marks
|
||||
// the channel exhausted before backfill has completed.
|
||||
const backfillStatusRef = React.useRef<"pending" | "running" | "done">(
|
||||
"pending",
|
||||
);
|
||||
const backfillPromiseRef = React.useRef<Promise<void> | null>(null);
|
||||
const backfillResolveRef = React.useRef<(() => void) | null>(null);
|
||||
// Expose a promise that resolves when backfill is done. Created eagerly so
|
||||
// fetchOlderArchived can await it before the effect that starts backfill fires.
|
||||
if (!backfillPromiseRef.current) {
|
||||
backfillPromiseRef.current = new Promise<void>((resolve) => {
|
||||
backfillResolveRef.current = resolve;
|
||||
});
|
||||
}
|
||||
// Compound keyset cursor: tracks both `created_at` and `id` of the oldest
|
||||
// event seen so far. Mirrors the SQL `ORDER BY created_at DESC, id DESC` so
|
||||
// same-second siblings are never skipped at a page boundary.
|
||||
const cursorRef = React.useRef<{ createdAt: number; id: string } | null>(
|
||||
null,
|
||||
const [hasOlderArchived, setHasOlderArchived] = React.useState(
|
||||
ps.hasOlderArchived,
|
||||
);
|
||||
|
||||
// Reset per-channel paging state when channelId changes. Backfill state is
|
||||
// identity-level (not per-channel) and must NOT be reset here — the backfill
|
||||
// index covers all channels and only needs to run once per identity mount.
|
||||
// Only the cursor, exhaustion flag, and fetching lock are channel-scoped.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the intentional reset key; cursorRef/isFetchingRef are stable refs excluded from deps by convention; setHasOlderArchived is a stable React state setter
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the intentional reset key; ps is a stable ref excluded from deps by convention; setHasOlderArchived is a stable React state setter
|
||||
React.useEffect(() => {
|
||||
cursorRef.current = null;
|
||||
isFetchingRef.current = false;
|
||||
applyChannelReset(ps);
|
||||
setHasOlderArchived(true);
|
||||
}, [channelId]);
|
||||
|
||||
// Check for an owner_p subscription once per identity.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref excluded from deps by convention; setHasSubscription/setHasOlderArchived are stable React state setters
|
||||
React.useEffect(() => {
|
||||
if (!enabled || !identityPubkey) {
|
||||
return;
|
||||
@@ -136,20 +127,24 @@ export function useLoadArchivedObserverEvents(
|
||||
(s) => s.scopeType === "owner_p" && s.scopeValue === identityPubkey,
|
||||
);
|
||||
setHasSubscription(hasSub);
|
||||
ps.hasSubscription = hasSub;
|
||||
if (!hasSub) {
|
||||
setHasOlderArchived(false);
|
||||
ps.hasOlderArchived = false;
|
||||
// No subscription → backfill will never run; resolve the promise
|
||||
// immediately so fetchOlderArchived doesn't await indefinitely.
|
||||
backfillStatusRef.current = "done";
|
||||
backfillResolveRef.current?.();
|
||||
ps.backfillStatus = "done";
|
||||
ps.backfillResolve?.();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setHasSubscription(false);
|
||||
ps.hasSubscription = false;
|
||||
setHasOlderArchived(false);
|
||||
backfillStatusRef.current = "done";
|
||||
backfillResolveRef.current?.();
|
||||
ps.hasOlderArchived = false;
|
||||
ps.backfillStatus = "done";
|
||||
ps.backfillResolve?.();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
@@ -162,16 +157,13 @@ export function useLoadArchivedObserverEvents(
|
||||
// observer_channel_index. A status row is written for EVERY processed event —
|
||||
// null/failed channelId rows get channel_id=null, so re-runs skip them.
|
||||
// Runs once per mount when the subscription is confirmed; gated by
|
||||
// backfillStatusRef so fetchOlderArchived can await completion.
|
||||
// ps.backfillStatus so fetchOlderArchived can await completion.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref excluded from deps by convention
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!enabled ||
|
||||
!hasSubscription ||
|
||||
backfillStatusRef.current !== "pending"
|
||||
) {
|
||||
if (!enabled || !hasSubscription || ps.backfillStatus !== "pending") {
|
||||
return;
|
||||
}
|
||||
backfillStatusRef.current = "running";
|
||||
ps.backfillStatus = "running";
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const rows = await readUnindexedObserverRows();
|
||||
@@ -226,20 +218,21 @@ export function useLoadArchivedObserverEvents(
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
backfillStatusRef.current = "done";
|
||||
backfillResolveRef.current?.();
|
||||
ps.backfillStatus = "done";
|
||||
ps.backfillResolve?.();
|
||||
}
|
||||
})();
|
||||
backfillPromiseRef.current = promise;
|
||||
ps.backfillPromise = promise;
|
||||
}, [enabled, hasSubscription]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; ps.isFetching/ps.cursor/ps.backfillPromise/ps.hasOlderArchived are read via the stable ref object, not reactive values
|
||||
const fetchOlderArchived = React.useCallback(async () => {
|
||||
if (
|
||||
!enabled ||
|
||||
!identityPubkey ||
|
||||
!hasSubscription ||
|
||||
!channelId ||
|
||||
isFetchingRef.current ||
|
||||
ps.isFetching ||
|
||||
!hasOlderArchived
|
||||
) {
|
||||
return;
|
||||
@@ -249,8 +242,8 @@ export function useLoadArchivedObserverEvents(
|
||||
// guarantees the index is populated before the first paginated read, so
|
||||
// a scroll-trigger that fires before backfill writes can't return 0 rows
|
||||
// and falsely mark the channel exhausted.
|
||||
if (backfillPromiseRef.current) {
|
||||
await backfillPromiseRef.current;
|
||||
if (ps.backfillPromise) {
|
||||
await ps.backfillPromise;
|
||||
}
|
||||
|
||||
// Re-check after awaiting: hasOlderArchived might have been set false
|
||||
@@ -259,9 +252,9 @@ export function useLoadArchivedObserverEvents(
|
||||
return;
|
||||
}
|
||||
|
||||
isFetchingRef.current = true;
|
||||
ps.isFetching = true;
|
||||
try {
|
||||
const before = cursorRef.current ?? undefined;
|
||||
const before = ps.cursor ?? undefined;
|
||||
const events = await readArchivedObserverEventsForChannel(channelId, {
|
||||
before: before ?? null,
|
||||
limit: ARCHIVED_EVENTS_PAGE_SIZE,
|
||||
@@ -272,7 +265,7 @@ export function useLoadArchivedObserverEvents(
|
||||
// this page. Capture both created_at and id to mirror the compound
|
||||
// sort key so same-second siblings are not skipped on the next page.
|
||||
const oldestEvent = events[events.length - 1];
|
||||
cursorRef.current = {
|
||||
ps.cursor = {
|
||||
createdAt: oldestEvent.created_at,
|
||||
id: oldestEvent.id,
|
||||
};
|
||||
@@ -282,11 +275,12 @@ export function useLoadArchivedObserverEvents(
|
||||
// A short page means the archive is exhausted for this channel.
|
||||
if (events.length < ARCHIVED_EVENTS_PAGE_SIZE) {
|
||||
setHasOlderArchived(false);
|
||||
ps.hasOlderArchived = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[useLoadArchivedObserverEvents] fetch failed:", error);
|
||||
} finally {
|
||||
isFetchingRef.current = false;
|
||||
ps.isFetching = false;
|
||||
}
|
||||
}, [enabled, identityPubkey, hasSubscription, channelId, hasOlderArchived]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user