mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
fix(desktop): show edited head content in thread panel
The channel top-level timeline renders a message's newest (edited) body,
but opening its thread panel showed the stale pre-edit body until the
async thread-aux backfill landed (and permanently if it raced/missed).
buildIndependentThreadPanel extracted a single head content event by id
from the channel window and built events = [head, ...replyEvents],
discarding the head's aux overlay events (edits kind:40003, deletions,
reactions) that already sit beside it in that same window.
formatTimelineMessages only applies an edit when the edit event is in the
same array as its target, so the head relied solely on withThreadAux to
re-supply the edit — stale whenever that fetch lagged.
Carry the head's non-content aux events (via !isTimelineContentEvent) from
the channel window into the thread head's event array, deduped against
replyEvents by id. This makes the thread head byte-identical to the main
timeline the instant the thread opens, independent of the async aux fetch.
Restricted to non-content kinds so reply content rows never leak in.
Tests:
- Unit regression (independentThreadPanel.test.mjs): fails pre-fix
("two PRs"), passes post-fix ("these PRs").
- e2e (thread-head-stale-edit.spec.ts): delays thread-replies aux to force
the channel-window-vs-thread-aux divergence; asserts the thread panel
head shows edited content. Red pre-fix (stale head), green post-fix.
- Full desktop unit suite: 5562 pass / 0 fail. biome + tsc clean.
Signed-off-by: Leo <5faf251baee50ee6bcde338aef6acdd70bb3e60115664c2cd490d94a55dfc488@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -20,6 +20,7 @@ export default defineConfig({
|
||||
name: "smoke",
|
||||
testMatch: [
|
||||
"**/smoke.spec.ts",
|
||||
"**/thread-head-stale-edit.spec.ts",
|
||||
"**/sidebar-offcanvas-rail.spec.ts",
|
||||
"**/tooltip-semantics.spec.ts",
|
||||
"**/search-scope-screenshots.spec.ts",
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildIndependentThreadPanel } from "./independentThreadPanel.ts";
|
||||
|
||||
const ROOT_ID = "6".repeat(64);
|
||||
const EDIT_ID = "9".repeat(64);
|
||||
const REPLY_ID = "a".repeat(64);
|
||||
const DELETION_ID = "d".repeat(64);
|
||||
const AUTHOR = "f".repeat(64);
|
||||
const CHANNEL = "chan-uuid";
|
||||
|
||||
function contentEvent(id, content, extraTags = []) {
|
||||
return {
|
||||
id,
|
||||
pubkey: AUTHOR,
|
||||
kind: 9,
|
||||
created_at: 1000,
|
||||
content,
|
||||
tags: [["h", CHANNEL], ...extraTags],
|
||||
sig: "s",
|
||||
};
|
||||
}
|
||||
|
||||
function editEvent(id, targetId, content, createdAt) {
|
||||
return {
|
||||
id,
|
||||
pubkey: AUTHOR,
|
||||
kind: 40003,
|
||||
created_at: createdAt,
|
||||
content,
|
||||
tags: [
|
||||
["h", CHANNEL],
|
||||
["e", targetId],
|
||||
],
|
||||
sig: "s",
|
||||
};
|
||||
}
|
||||
|
||||
function deletionEvent(id, targetId) {
|
||||
return {
|
||||
id,
|
||||
pubkey: AUTHOR,
|
||||
kind: 5,
|
||||
created_at: 3000,
|
||||
content: "",
|
||||
tags: [
|
||||
["h", CHANNEL],
|
||||
["e", targetId],
|
||||
],
|
||||
sig: "s",
|
||||
};
|
||||
}
|
||||
|
||||
function head(channelEvents, replyEvents) {
|
||||
return buildIndependentThreadPanel(
|
||||
channelEvents,
|
||||
replyEvents,
|
||||
ROOT_ID,
|
||||
ROOT_ID,
|
||||
new Set(),
|
||||
null,
|
||||
AUTHOR,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
new Map(),
|
||||
new Map(),
|
||||
null,
|
||||
undefined,
|
||||
).threadHead;
|
||||
}
|
||||
|
||||
// Regression: the reported bug. The head's edit is in the channel window (so the
|
||||
// main timeline shows it) but has NOT yet been pulled into the thread-reply aux
|
||||
// cache. Before the fix the thread head rendered the un-edited original.
|
||||
test("applies the head edit carried only in the channel window", () => {
|
||||
const root = contentEvent(ROOT_ID, "two PRs");
|
||||
const edit = editEvent(EDIT_ID, ROOT_ID, "these PRs (3)", 2000);
|
||||
const result = head([root, edit], []);
|
||||
assert.equal(result?.body, "these PRs (3)");
|
||||
assert.equal(result?.edited, true);
|
||||
});
|
||||
|
||||
// The thread-aux backfill path must keep working when it is the only source.
|
||||
test("applies the head edit carried only in reply-aux events", () => {
|
||||
const root = contentEvent(ROOT_ID, "two PRs");
|
||||
const edit = editEvent(EDIT_ID, ROOT_ID, "these PRs (3)", 2000);
|
||||
const result = head([root], [edit]);
|
||||
assert.equal(result?.body, "these PRs (3)");
|
||||
assert.equal(result?.edited, true);
|
||||
});
|
||||
|
||||
// Same edit in both sources must not double-apply or drop; latest content wins.
|
||||
test("dedups the same edit present in both sources", () => {
|
||||
const root = contentEvent(ROOT_ID, "two PRs");
|
||||
const edit = editEvent(EDIT_ID, ROOT_ID, "these PRs (3)", 2000);
|
||||
const result = head([root, edit], [edit]);
|
||||
assert.equal(result?.body, "these PRs (3)");
|
||||
assert.equal(result?.edited, true);
|
||||
});
|
||||
|
||||
// No edit anywhere: head stays original, unedited.
|
||||
test("leaves an unedited head untouched", () => {
|
||||
const root = contentEvent(ROOT_ID, "two PRs");
|
||||
const result = head([root], []);
|
||||
assert.equal(result?.body, "two PRs");
|
||||
assert.equal(result?.edited, false);
|
||||
});
|
||||
|
||||
// A reply content event also `#e`-references the head as its parent. It must NOT
|
||||
// be pulled in as head aux — replies flow through `replyEvents` only.
|
||||
test("does not treat a channel-window reply as head aux", () => {
|
||||
const root = contentEvent(ROOT_ID, "two PRs");
|
||||
const reply = contentEvent(REPLY_ID, "a reply", [
|
||||
["e", ROOT_ID, "", "reply"],
|
||||
]);
|
||||
const panel = buildIndependentThreadPanel(
|
||||
[root, reply],
|
||||
[],
|
||||
ROOT_ID,
|
||||
ROOT_ID,
|
||||
new Set(),
|
||||
null,
|
||||
AUTHOR,
|
||||
null,
|
||||
undefined,
|
||||
undefined,
|
||||
new Map(),
|
||||
new Map(),
|
||||
null,
|
||||
undefined,
|
||||
);
|
||||
// The reply is not in replyEvents, so it should not surface in the panel.
|
||||
assert.equal(panel.threadHead?.body, "two PRs");
|
||||
assert.deepEqual(panel.visibleReplies, []);
|
||||
});
|
||||
|
||||
// A channel-window deletion of the head must hide it, matching the main timeline.
|
||||
test("applies a head deletion carried in the channel window", () => {
|
||||
const root = contentEvent(ROOT_ID, "two PRs");
|
||||
const deletion = deletionEvent(DELETION_ID, ROOT_ID);
|
||||
const result = head([root, deletion], []);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
@@ -1,7 +1,35 @@
|
||||
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import {
|
||||
formatTimelineMessages,
|
||||
isTimelineContentEvent,
|
||||
} from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { buildThreadPanelData } from "@/features/messages/lib/threadPanel";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
/**
|
||||
* Aux events (edits/deletions/reactions) already loaded in the channel window
|
||||
* that reference `headId` via an `#e` tag. The thread head is the single
|
||||
* content event found by id, but its overlay events live alongside it in the
|
||||
* channel window — `formatTimelineMessages` only applies an edit/deletion when
|
||||
* the aux event sits in the SAME array as its target. Carrying them here keeps
|
||||
* the thread head byte-identical to the main timeline the instant the thread
|
||||
* opens, instead of rendering the un-edited original until the async
|
||||
* thread-aux backfill (`withThreadAux`) lands — the stale-edit-in-thread bug.
|
||||
*
|
||||
* Restricted to non-content kinds so reply content events (which also `#e` the
|
||||
* head as their parent) never leak in here — replies come from `replyEvents`.
|
||||
*/
|
||||
function headAuxEventsFromChannelWindow(
|
||||
channelEvents: RelayEvent[],
|
||||
headId: string,
|
||||
): RelayEvent[] {
|
||||
return channelEvents.filter(
|
||||
(event) =>
|
||||
event.id !== headId &&
|
||||
!isTimelineContentEvent(event) &&
|
||||
event.tags.some((tag) => tag[0] === "e" && tag[1] === headId),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildIndependentThreadPanel(
|
||||
channelEvents: RelayEvent[],
|
||||
replyEvents: RelayEvent[],
|
||||
@@ -17,7 +45,15 @@ export function buildIndependentThreadPanel(
|
||||
};
|
||||
}
|
||||
const head = channelEvents.find((event) => event.id === rootId);
|
||||
const events = head ? [head, ...replyEvents] : replyEvents;
|
||||
// Dedup the channel-window head aux against `replyEvents`: `withThreadAux`
|
||||
// fetches the same overlays by reference, so both sources can carry an edit.
|
||||
const replyEventIds = new Set(replyEvents.map((event) => event.id));
|
||||
const headAux = head
|
||||
? headAuxEventsFromChannelWindow(channelEvents, rootId).filter(
|
||||
(event) => !replyEventIds.has(event.id),
|
||||
)
|
||||
: [];
|
||||
const events = head ? [head, ...headAux, ...replyEvents] : replyEvents;
|
||||
const messages = formatTimelineMessages(events, ...formatArgs);
|
||||
return {
|
||||
...buildThreadPanelData(messages, rootId, replyTargetId, expandedReplyIds),
|
||||
|
||||
135
desktop/tests/e2e/thread-head-stale-edit.spec.ts
Normal file
135
desktop/tests/e2e/thread-head-stale-edit.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
// Regression coverage for the reported bug: the top-level timeline shows the
|
||||
// EDITED message body, but opening its thread panel shows the STALE, un-edited
|
||||
// original. The thread head is taken from the channel window (which carries the
|
||||
// edit) but its overlay was previously dropped, relying solely on the async
|
||||
// thread-reply aux backfill — so before that fetch lands the head renders stale.
|
||||
//
|
||||
// We reproduce the divergence deterministically by delaying the thread-replies
|
||||
// fetch (`threadRepliesDelayMs`): the channel window already holds the edit, but
|
||||
// the thread-aux response is still in flight when the panel first renders.
|
||||
|
||||
const CHANNEL = "general";
|
||||
const SHOT = "test-results/thread-head-stale-edit";
|
||||
|
||||
type MockMessageWindow = Window & {
|
||||
__BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: {
|
||||
channelName: string;
|
||||
content: string;
|
||||
parentEventId?: string | null;
|
||||
pubkey?: string;
|
||||
kind?: number;
|
||||
extraTags?: string[][];
|
||||
}) => { id: string; created_at: number; pubkey: string } | undefined;
|
||||
};
|
||||
|
||||
async function waitForMockLiveSubscription(
|
||||
page: import("@playwright/test").Page,
|
||||
channelName: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(ch) =>
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
||||
channelName: string;
|
||||
}) => boolean;
|
||||
}
|
||||
).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName: ch }) ??
|
||||
false,
|
||||
channelName,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
test("thread head reflects the channel-window edit even before thread aux loads", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Long delay so the thread-replies (and its aux edit backfill) response is
|
||||
// still in flight when the thread panel first renders the head.
|
||||
await installMockBridge(page, { threadRepliesDelayMs: 4000 });
|
||||
await page.goto("/");
|
||||
await page.getByTestId(`channel-${CHANNEL}`).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(CHANNEL);
|
||||
await waitForMockLiveSubscription(page, CHANNEL);
|
||||
|
||||
const ORIGINAL = "can i get a review on these two PRs? (pr 6706, pr 6701)";
|
||||
const EDITED = "can i get a review on these PRs? (pr 6706, pr 6701, pr 6503)";
|
||||
|
||||
// 1. Post a top-level message, then edit it — both land in the channel window.
|
||||
// The message MUST be authored by the active identity (tyler): an edit is
|
||||
// only overlaid onto a message when the edit's signer matches the target's
|
||||
// author (formatTimelineMessages authorization), and the mock `edit_message`
|
||||
// command signs with the active identity.
|
||||
const root = await page.evaluate(
|
||||
({ channelName, content, pubkey }) =>
|
||||
(window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName,
|
||||
content,
|
||||
pubkey,
|
||||
}) ?? null,
|
||||
{
|
||||
channelName: CHANNEL,
|
||||
content: ORIGINAL,
|
||||
pubkey: TEST_IDENTITIES.tyler.pubkey,
|
||||
},
|
||||
);
|
||||
expect(root?.id).toBeTruthy();
|
||||
const rootId = root?.id;
|
||||
if (!rootId) throw new Error("mock message emit did not return an id");
|
||||
|
||||
await page.evaluate(
|
||||
({ channelName, rootId, content, pubkey, editKind }) =>
|
||||
(window as MockMessageWindow).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName,
|
||||
content,
|
||||
pubkey,
|
||||
kind: editKind,
|
||||
// kind:40003 edit event targeting the root — the same shape the relay
|
||||
// delivers into the channel-window subscription.
|
||||
extraTags: [["e", rootId]],
|
||||
}),
|
||||
{
|
||||
channelName: CHANNEL,
|
||||
rootId,
|
||||
content: EDITED,
|
||||
pubkey: TEST_IDENTITIES.tyler.pubkey,
|
||||
editKind: 40003,
|
||||
},
|
||||
);
|
||||
|
||||
// 2. The main timeline row shows the edited body (baseline: edit is applied
|
||||
// in the channel window).
|
||||
const timelineRow = page.locator(
|
||||
`[data-testid="message-row"][data-message-id="${rootId}"]`,
|
||||
);
|
||||
await expect(timelineRow.getByTestId("message-body")).toContainText(
|
||||
"these PRs?",
|
||||
);
|
||||
await expect(timelineRow.getByTestId("message-body")).not.toContainText(
|
||||
"these two PRs?",
|
||||
);
|
||||
|
||||
// 3. Open the thread via the reply action (the flow in the bug report).
|
||||
const replyButton = page.getByTestId(`reply-message-${rootId}`);
|
||||
await replyButton.click({ force: true });
|
||||
const threadPanel = page.getByTestId("message-thread-panel");
|
||||
await expect(threadPanel).toBeVisible();
|
||||
|
||||
// 4. The thread head must show the EDITED body immediately — while the
|
||||
// thread-aux backfill is still delayed in flight. Pre-fix this rendered
|
||||
// the stale original ("these two PRs?").
|
||||
const headBody = threadPanel
|
||||
.locator(`[data-testid="message-row"][data-message-id="${rootId}"]`)
|
||||
.getByTestId("message-body");
|
||||
await expect(headBody).toContainText("these PRs?");
|
||||
await expect(headBody).not.toContainText("these two PRs?");
|
||||
|
||||
await page.screenshot({ path: `${SHOT}/thread-head-edited.png` });
|
||||
});
|
||||
Reference in New Issue
Block a user