perf(desktop): restore project context during startup (#6939)

## Summary
Project channels previously appeared as ordinary channels until the
community-wide project enumeration completed, delaying the contextual
right rail most noticeably on larger relays.

- Restore the last fully validated, relay-and-identity-scoped project
collection immediately while keeping live relay data authoritative.
- Resolve the active channel's project home through scoped
`#buzz-channel` queries instead of waiting for the complete project
scan.
- Keep snapshots aligned with community removal and relay reconnect
invalidation.

### Related issue
None found.

### Testing
- Pre-push `file-size-check`, `desktop-check`, `desktop-typecheck`, and
`desktop-test`
- Project enumeration, snapshot persistence, and relay invalidation unit
coverage
- Targeted Playwright scenario: project sidebar rows open the home
channel and nest extra channels

No numeric startup benchmark was captured; this draft validates the
cache and scoped-query behavior while leaving timing measurement for
review.

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
thomaspblock
2026-08-27 22:36:26 -04:00
committed by GitHub
parent 350caf1de3
commit 9733863516
27 changed files with 1003 additions and 124 deletions

View File

@@ -119,6 +119,7 @@ export default defineConfig({
"**/inbox-reactions.spec.ts",
"**/inbox-edit.spec.ts",
"**/send-channel-binding.spec.ts",
"**/project-cold-start.spec.ts",
"**/project-commit-detail.spec.ts",
"**/project-inbox.spec.ts",
"**/projects-v3-screenshots.spec.ts",

View File

@@ -61,6 +61,7 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen";
import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay";
import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync";
import { seedProjectSnapshot } from "@/features/projects/projectSnapshot";
import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider";
import { createBuzzQueryClient } from "@/shared/api/queryClient";
import { hydrateChannelHeads } from "@/features/messages/lib/channelHeadCache";
@@ -234,6 +235,7 @@ function CommunityQueryProvider({
const [queryClient] = useState(() => {
const client = createBuzzQueryClient();
if (pubkey && relayUrl) {
seedProjectSnapshot(client, { pubkey, relayUrl });
void hydrateChannelHeads(client, { pubkey, relayUrl });
}
return client;

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation";
import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache";
@@ -13,8 +14,16 @@ import {
isBroadcastReply,
} from "@/features/messages/lib/threading";
import { useProfileQuery } from "@/features/profile/hooks";
import { useProjectsQuery } from "@/features/projects/hooks";
import {
useProjectHomeForChannelQuery,
useProjectsQuery,
} from "@/features/projects/hooks";
import { findProjectHomeByChannelId } from "@/features/projects/lib/projectHomeChannel";
import {
isProjectCollectionAuthoritative,
isProjectRelayValidated,
shouldUseScopedProjectHomeLookup,
} from "@/features/projects/projectSnapshot";
import { ProjectChannelHome } from "@/features/projects/ui/ProjectChannelHome";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getEventById } from "@/shared/api/tauri";
@@ -112,6 +121,7 @@ export function ChannelRouteScreen({
targetThreadRootId,
}: ChannelRouteScreenProps) {
const isHuddleTranscript = huddleWindowChannelId() !== null;
const queryClient = useQueryClient();
const { closeForumPost, goForumPost } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const projectsQuery = useProjectsQuery();
@@ -133,10 +143,22 @@ export function ChannelRouteScreen({
memberChannel ??
openDirectoryQuery.data?.find((channel) => channel.id === channelId) ??
null;
const projectHome = findProjectHomeByChannelId(
const enumeratedProjectHome = findProjectHomeByChannelId(
channelId,
projectsQuery.data ?? [],
);
const projectCollectionIsAuthoritative =
isProjectCollectionAuthoritative(queryClient);
const projectHomeLookupQuery = useProjectHomeForChannelQuery(
channelId,
shouldUseScopedProjectHomeLookup({
collectionIsAuthoritative: projectCollectionIsAuthoritative,
hasEnumeratedProjectHome: Boolean(enumeratedProjectHome),
isHuddleTranscript,
}),
);
const projectHome =
enumeratedProjectHome ?? projectHomeLookupQuery.data ?? null;
const [targetMessageEvents, setTargetMessageEvents] = React.useState<
RelayEvent[]
>(() => {
@@ -276,6 +298,7 @@ export function ChannelRouteScreen({
if (projectHome && !isHuddleTranscript) {
return (
<ProjectChannelHome
allowRepositoryHealing={isProjectRelayValidated(projectHome)}
autoSendDraftKey={autoSendDraftKey}
project={projectHome}
projects={projectsQuery.data ?? [projectHome]}

View File

@@ -19,6 +19,7 @@ import {
import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage";
import { removeUserLabelCacheForRelay } from "@/features/profile/lib/userLabelStorage";
import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot";
import { removeProjectSnapshotForRelay } from "@/features/projects/projectSnapshot";
import { clearChannelHeadCache } from "@/shared/api/tauriChannelHeadCache";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore";
@@ -235,6 +236,7 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
removeSelfProfileCachesForRelay(removed.relayUrl);
removeUserLabelCacheForRelay(removed.relayUrl);
removeChannelSnapshotForRelay(removed.relayUrl);
removeProjectSnapshotForRelay(removed.relayUrl);
void getIdentity()
.then((identity) =>
clearChannelHeadCache({

View File

@@ -3,7 +3,6 @@ import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import { getRelaySelf } from "@/features/moderation/lib/relaySelf";
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
import { signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import {
@@ -67,13 +66,15 @@ import {
type Project,
type Repository,
} from "./projectModels";
import { fetchProjectHomeForChannel, fetchProjects } from "./projectFetch";
import {
buildProjectsFromFetcher,
type FetchProjectEventsExhaustively,
fetchProjectEventsExhaustively,
} from "./projectEnumeration";
markProjectCollectionAuthoritative,
persistProjectSnapshot,
PROJECT_QUERY_STRUCTURAL_SHARING,
} from "./projectSnapshot";
import { projectMatchesRouteId } from "./projectRoutes";
export { fetchProjects } from "./projectFetch";
export { projectsQueryKey };
export type {
@@ -85,7 +86,6 @@ export type {
};
export type ProjectPullRequestCommentDecision = "request-changes";
const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1";
export type RepoState = {
branches: Array<{ name: string; commit: string }>;
tags: Array<{ name: string; commit: string }>;
@@ -134,23 +134,6 @@ export type ProjectIssueListItem = {
issue: ProjectIssue;
};
function readHiddenProjectCards(): string[] {
if (typeof window === "undefined") {
return [];
}
try {
const parsed = JSON.parse(
window.localStorage.getItem(HIDDEN_PROJECT_CARDS_KEY) ?? "[]",
);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return [];
}
}
/**
* Converts a kind:30617 repo announcement into a `Project`.
*
@@ -170,27 +153,6 @@ export function eventToProject(
return repository;
}
export async function fetchProjects(
fetchExhaustively?: FetchProjectEventsExhaustively,
signal?: AbortSignal,
): Promise<Project[]> {
// Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which
// is the pure, Tauri-free core of this operation. Its javadoc explains
// fail-closed tombstones and NIP-OA owner-deletion suppression.
const viewerPubkey = await getIdentity()
.then((identity) => identity.pubkey)
.catch(() => undefined);
const fetcher: FetchProjectEventsExhaustively =
fetchExhaustively ??
((kinds, extraFilter) =>
fetchProjectEventsExhaustively(kinds, extraFilter, undefined, signal));
return buildProjectsFromFetcher(fetcher, {
relayOrigin: getCachedRelayOrigin(),
hiddenAddresses: new Set(readHiddenProjectCards()),
viewerPubkey,
});
}
function eventToRepoState(event: RelayEvent): RepoState {
const branches: RepoState["branches"] = [];
const tags: RepoState["tags"] = [];
@@ -650,24 +612,51 @@ export const PROJECT_ACTIVITY_STALE_TIME_MS = 2 * 60_000;
export const PROJECT_LOCAL_REPOS_STALE_TIME_MS = 2 * 60_000;
export function useProjectsQuery(enabled = true) {
const queryClient = useQueryClient();
return useQuery({
queryKey: projectsQueryKey,
queryFn: ({ signal }) => fetchProjects(undefined, signal),
queryFn: async ({ signal }) => {
const projects = await fetchProjects(undefined, signal);
markProjectCollectionAuthoritative(queryClient);
persistProjectSnapshot(queryClient, projects);
return projects;
},
staleTime: PROJECTS_STALE_TIME_MS,
gcTime: PROJECTS_GC_TIME_MS,
structuralSharing: PROJECT_QUERY_STRUCTURAL_SHARING,
enabled,
});
}
export function useProjectHomeForChannelQuery(
channelId: string,
enabled = true,
) {
return useQuery({
queryKey: ["projects", "home-channel", channelId],
queryFn: ({ signal }) => fetchProjectHomeForChannel(channelId, signal),
staleTime: PROJECTS_STALE_TIME_MS,
gcTime: PROJECTS_GC_TIME_MS,
enabled: enabled && channelId.length > 0,
});
}
export function useProjectQuery(projectId: string) {
const queryClient = useQueryClient();
return useQuery({
queryKey: projectsQueryKey,
queryFn: ({ signal }) => fetchProjects(undefined, signal),
queryFn: async ({ signal }) => {
const projects = await fetchProjects(undefined, signal);
markProjectCollectionAuthoritative(queryClient);
persistProjectSnapshot(queryClient, projects);
return projects;
},
select: (projects) =>
projects.find((project) => projectMatchesRouteId(project, projectId)) ??
null,
staleTime: PROJECTS_STALE_TIME_MS,
gcTime: PROJECTS_GC_TIME_MS,
structuralSharing: PROJECT_QUERY_STRUCTURAL_SHARING,
});
}

View File

@@ -1,64 +1,12 @@
import { useProjectsQuery } from "@/features/projects/hooks";
import type { Project } from "@/features/projects/projectModels";
import { isProjectHomeChannel } from "./projectHomeSelection";
/** Resolves the canonical visible project home for a channel. */
export function findProjectHomeByChannelId(
channelId: string | null | undefined,
projects: readonly Project[],
): Project | null {
if (!channelId) return null;
const matching = projects
.filter(
(project) =>
!project.legacy &&
project.projectChannelId === channelId &&
hasAuthoritativeHomeBinding(project),
)
.sort((left, right) => left.createdAt - right.createdAt);
return (
matching.find((project) => project.visibility !== "unlisted") ??
matching[0] ??
null
);
}
export type ProjectHomeCandidate = {
owner: string;
projectChannelId: string | null;
repositories: ReadonlyArray<{
channelId?: string | null;
maintainers?: ReadonlyArray<string>;
owner: string;
}>;
};
export function hasAuthoritativeHomeBinding(
project: ProjectHomeCandidate,
): boolean {
const channelId = project.projectChannelId;
if (!channelId) return false;
const projectOwner = project.owner.toLowerCase();
return project.repositories.some((repository) => {
if (repository.channelId !== channelId) return false;
if (repository.owner.toLowerCase() === projectOwner) return true;
return repository.maintainers?.some(
(maintainer) => maintainer.toLowerCase() === projectOwner,
);
});
}
export function isProjectHomeChannel(
channelId: string | null | undefined,
projects: ReadonlyArray<ProjectHomeCandidate>,
): boolean {
if (!channelId) return false;
return projects.some(
(project) =>
project.projectChannelId === channelId &&
hasAuthoritativeHomeBinding(project),
);
}
export {
findProjectHomeByChannelId,
hasAuthoritativeHomeBinding,
isProjectHomeChannel,
type ProjectHomeCandidate,
} from "./projectHomeSelection";
export function useIsProjectHomeChannel(channelId: string | null | undefined) {
const projectsQuery = useProjectsQuery();

View File

@@ -0,0 +1,60 @@
import type { Project } from "@/features/projects/projectModels";
/** Resolves the canonical visible project home for a channel. */
export function findProjectHomeByChannelId(
channelId: string | null | undefined,
projects: readonly Project[],
): Project | null {
if (!channelId) return null;
const matching = projects
.filter(
(project) =>
!project.legacy &&
project.projectChannelId === channelId &&
hasAuthoritativeHomeBinding(project),
)
.sort((left, right) => left.createdAt - right.createdAt);
return (
matching.find((project) => project.visibility !== "unlisted") ??
matching[0] ??
null
);
}
export type ProjectHomeCandidate = {
owner: string;
projectChannelId: string | null;
repositories: ReadonlyArray<{
channelId?: string | null;
maintainers?: ReadonlyArray<string>;
owner: string;
}>;
};
export function hasAuthoritativeHomeBinding(
project: ProjectHomeCandidate,
): boolean {
const channelId = project.projectChannelId;
if (!channelId) return false;
const projectOwner = project.owner.toLowerCase();
return project.repositories.some((repository) => {
if (repository.channelId !== channelId) return false;
if (repository.owner.toLowerCase() === projectOwner) return true;
return repository.maintainers?.some(
(maintainer) => maintainer.toLowerCase() === projectOwner,
);
});
}
export function isProjectHomeChannel(
channelId: string | null | undefined,
projects: ReadonlyArray<ProjectHomeCandidate>,
): boolean {
if (!channelId) return false;
return projects.some(
(project) =>
project.projectChannelId === channelId &&
hasAuthoritativeHomeBinding(project),
);
}

View File

@@ -3,6 +3,7 @@ import test from "node:test";
import {
enumerateProjectEvents,
buildProjectHomeFromFetcher,
buildProjectsFromFetcher,
} from "./projectEnumeration.ts";
@@ -32,6 +33,64 @@ function fetcherFor(events) {
.slice(0, limit);
}
test("buildProjectHomeFromFetcher scopes startup lookup to the active channel", async () => {
const owner = "a".repeat(64);
const channelId = "11111111-1111-4111-8111-111111111111";
const repositoryAddress = `30617:${owner}:relay`;
const projectEvent = {
id: "p".repeat(64),
kind: 30621,
pubkey: owner,
created_at: 200,
content: "",
tags: [
["d", "relay"],
["name", "Relay"],
["buzz-channel", channelId],
["a", repositoryAddress],
],
};
const repositoryEvent = {
id: "r".repeat(64),
kind: 30617,
pubkey: owner,
created_at: 100,
content: "",
tags: [
["d", "relay"],
["name", "Relay"],
["buzz-channel", channelId],
],
};
const calls = [];
const fetchExhaustively = async (kinds, extraFilter) => {
calls.push({ kinds, extraFilter });
if (kinds.includes(30621)) return [projectEvent];
if (kinds.includes(30617)) return [repositoryEvent];
return [];
};
const project = await buildProjectHomeFromFetcher(
fetchExhaustively,
channelId,
{ viewerPubkey: owner },
);
assert.equal(project?.projectChannelId, channelId);
assert.deepEqual(calls[0], {
kinds: [30621],
extraFilter: { "#buzz-channel": [channelId] },
});
assert.deepEqual(calls[1], {
kinds: [30617],
extraFilter: { "#buzz-channel": [channelId] },
});
assert.deepEqual(calls[2], {
kinds: [5],
extraFilter: { "#a": [`30621:${owner}:relay`, repositoryAddress] },
});
});
test("enumerateProjectEvents drains a tied boundary second before advancing", async () => {
const events = [
relayEvent("a", 1_000),

View File

@@ -6,6 +6,7 @@ import {
KIND_REPO_ANNOUNCEMENT,
} from "@/shared/constants/kinds";
import { absorbStandaloneProjectRepositories } from "./lib/projectCollection";
import { findProjectHomeByChannelId } from "./lib/projectHomeSelection";
import { buildProjectReadModels, type Project } from "./projectModels";
const PROJECT_ENUMERATION_PAGE_SIZE = 500;
@@ -17,6 +18,7 @@ const TOMBSTONE_COORDINATE_CHUNK_SIZE = 100;
/** Additional server-side scoping merged into every enumeration page. */
export type ProjectEventExtraFilter = {
"#a"?: string[];
"#buzz-channel"?: string[];
};
type ProjectEventFilter = ProjectEventExtraFilter & {
@@ -213,3 +215,29 @@ export async function buildProjectsFromFetcher(
}),
).sort((a, b) => b.createdAt - a.createdAt);
}
/**
* Resolves one channel's authoritative project home without waiting for the
* community-wide project enumeration used by the sidebar and Projects view.
*/
export async function buildProjectHomeFromFetcher(
fetchExhaustively: FetchProjectEventsExhaustively,
channelId: string,
options: {
relayOrigin?: string | null;
hiddenAddresses?: ReadonlySet<string>;
viewerPubkey?: string | null;
} = {},
): Promise<Project | null> {
const projects = await buildProjectsFromFetcher(
(kinds, extraFilter) =>
fetchExhaustively(
kinds,
kinds.includes(KIND_DELETION)
? extraFilter
: { ...extraFilter, "#buzz-channel": [channelId] },
),
options,
);
return findProjectHomeByChannelId(channelId, projects);
}

View File

@@ -0,0 +1,75 @@
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
import { getIdentity } from "@/shared/api/tauriIdentity";
import {
buildProjectHomeFromFetcher,
buildProjectsFromFetcher,
type FetchProjectEventsExhaustively,
fetchProjectEventsExhaustively,
} from "./projectEnumeration";
import type { Project } from "./projectModels";
import { markProjectDataAuthoritative } from "./projectSnapshot";
const HIDDEN_PROJECT_CARDS_KEY = "buzz.projects.hidden-cards.v1";
function readHiddenProjectCards(): string[] {
if (typeof window === "undefined") {
return [];
}
try {
const parsed = JSON.parse(
window.localStorage.getItem(HIDDEN_PROJECT_CARDS_KEY) ?? "[]",
);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string")
: [];
} catch {
return [];
}
}
/** Enumerates the projects visible to the current relay identity. */
export async function fetchProjects(
fetchExhaustively?: FetchProjectEventsExhaustively,
signal?: AbortSignal,
): Promise<Project[]> {
// Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which
// is the pure, Tauri-free core of this operation. Its javadoc explains
// fail-closed tombstones and NIP-OA owner-deletion suppression.
const viewerPubkey = await getIdentity()
.then((identity) => identity.pubkey)
.catch(() => undefined);
const fetcher: FetchProjectEventsExhaustively =
fetchExhaustively ??
((kinds, extraFilter) =>
fetchProjectEventsExhaustively(kinds, extraFilter, undefined, signal));
const projects = await buildProjectsFromFetcher(fetcher, {
relayOrigin: getCachedRelayOrigin(),
hiddenAddresses: new Set(readHiddenProjectCards()),
viewerPubkey,
});
return projects.map((project) =>
markProjectDataAuthoritative(project, "relay"),
);
}
/** Resolves the active channel's project home with a scoped relay query. */
export async function fetchProjectHomeForChannel(
channelId: string,
signal?: AbortSignal,
): Promise<Project | null> {
const viewerPubkey = await getIdentity()
.then((identity) => identity.pubkey)
.catch(() => undefined);
const project = await buildProjectHomeFromFetcher(
(kinds, extraFilter) =>
fetchProjectEventsExhaustively(kinds, extraFilter, undefined, signal),
channelId,
{
relayOrigin: getCachedRelayOrigin(),
hiddenAddresses: new Set(readHiddenProjectCards()),
viewerPubkey,
},
);
return project ? markProjectDataAuthoritative(project, "relay") : null;
}

View File

@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import test from "node:test";
import { QueryClient } from "@tanstack/react-query";
import {
isProjectCollectionAuthoritative,
isProjectDataAuthoritative,
isProjectRelayValidated,
markProjectCollectionAuthoritative,
markProjectDataAuthoritative,
persistProjectSnapshot,
PROJECT_QUERY_STRUCTURAL_SHARING,
projectSnapshotKey,
readProjectSnapshot,
removeProjectSnapshotForRelay,
seedProjectSnapshot,
shouldUseScopedProjectHomeLookup,
} from "./projectSnapshot.ts";
if (typeof globalThis.window === "undefined") {
const storage = new Map();
globalThis.window = {
localStorage: {
get length() {
return storage.size;
},
key: (index) => [...storage.keys()][index] ?? null,
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value),
removeItem: (key) => storage.delete(key),
},
};
}
const RELAY = "wss://relay.example.com";
const OWNER = "a".repeat(64);
const PROJECT = {
id: `30621:${OWNER}:relay`,
dtag: "relay",
name: "Relay",
description: "",
owner: OWNER,
createdAt: 100,
projectChannelId: "11111111-1111-4111-8111-111111111111",
relatedChannelIds: [],
status: "active",
projectAddress: `30621:${OWNER}:relay`,
primaryRepositoryAddress: null,
repositoryAddresses: [],
repositoryRelayHints: {},
repositories: [],
unavailableRepositoryAddresses: [],
visibility: "listed",
legacy: false,
};
test.beforeEach(() => {
removeProjectSnapshotForRelay(RELAY);
});
test("project snapshot is scoped by normalized relay and identity", () => {
const client = new QueryClient();
seedProjectSnapshot(client, { pubkey: OWNER, relayUrl: RELAY });
persistProjectSnapshot(client, [PROJECT]);
assert.deepEqual(readProjectSnapshot(RELAY, OWNER), [PROJECT]);
assert.equal(readProjectSnapshot(RELAY, "b".repeat(64)), null);
assert.equal(
projectSnapshotKey("WSS://Relay.Example.com/", OWNER.toUpperCase()),
projectSnapshotKey(RELAY, OWNER),
);
});
test("seedProjectSnapshot paints stale data into a fresh query client", () => {
const writer = new QueryClient();
seedProjectSnapshot(writer, { pubkey: OWNER, relayUrl: RELAY });
persistProjectSnapshot(writer, [PROJECT]);
const reader = new QueryClient();
seedProjectSnapshot(reader, { pubkey: OWNER, relayUrl: RELAY });
assert.deepEqual(reader.getQueryData(["projects"]), [PROJECT]);
assert.equal(reader.getQueryState(["projects"])?.dataUpdatedAt, 0);
const snapshotProject = reader.getQueryData(["projects"])[0];
assert.equal(isProjectDataAuthoritative(snapshotProject), false);
assert.equal(isProjectCollectionAuthoritative(reader), false);
const locallyWrittenProject = markProjectDataAuthoritative(
{ ...PROJECT, id: `${PROJECT.id}:local` },
"local-write",
);
reader.setQueryData(["projects"], (current = []) => [
...current,
locallyWrittenProject,
]);
assert.ok((reader.getQueryState(["projects"])?.dataUpdatedAt ?? 0) > 0);
assert.equal(isProjectCollectionAuthoritative(reader), false);
assert.equal(isProjectDataAuthoritative(snapshotProject), false);
assert.equal(isProjectDataAuthoritative(locallyWrittenProject), true);
assert.equal(isProjectRelayValidated(locallyWrittenProject), false);
assert.equal(
shouldUseScopedProjectHomeLookup({
collectionIsAuthoritative: isProjectCollectionAuthoritative(reader),
hasEnumeratedProjectHome: false,
isHuddleTranscript: false,
}),
true,
);
});
test("successful relay data is authoritative", () => {
const client = new QueryClient();
const relayProject = markProjectDataAuthoritative({ ...PROJECT }, "relay");
client.setQueryData(["projects"], [relayProject]);
markProjectCollectionAuthoritative(client);
assert.equal(isProjectDataAuthoritative(relayProject), true);
assert.equal(isProjectRelayValidated(relayProject), true);
assert.equal(isProjectCollectionAuthoritative(client), true);
assert.equal(
shouldUseScopedProjectHomeLookup({
collectionIsAuthoritative: isProjectCollectionAuthoritative(client),
hasEnumeratedProjectHome: false,
isHuddleTranscript: false,
}),
false,
);
});
test("equal relay data replaces snapshot objects and preserves provenance", async () => {
const writer = new QueryClient();
seedProjectSnapshot(writer, { pubkey: OWNER, relayUrl: RELAY });
persistProjectSnapshot(writer, [PROJECT]);
const reader = new QueryClient();
seedProjectSnapshot(reader, { pubkey: OWNER, relayUrl: RELAY });
const snapshotProject = reader.getQueryData(["projects"])[0];
const liveProject = markProjectDataAuthoritative({ ...PROJECT }, "relay");
await reader.fetchQuery({
queryKey: ["projects"],
queryFn: async () => {
markProjectCollectionAuthoritative(reader);
return [liveProject];
},
structuralSharing: PROJECT_QUERY_STRUCTURAL_SHARING,
});
const cachedProject = reader.getQueryData(["projects"])[0];
assert.notEqual(cachedProject, snapshotProject);
assert.equal(cachedProject, liveProject);
assert.equal(isProjectRelayValidated(cachedProject), true);
assert.equal(isProjectCollectionAuthoritative(reader), true);
});

View File

@@ -0,0 +1,233 @@
import type { QueryClient } from "@tanstack/react-query";
import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
import type { Project } from "./projectModels";
const STORAGE_KEY_PREFIX = "buzz-projects.v1";
export const PROJECTS_QUERY_KEY = ["projects"] as const;
/** Project provenance is non-enumerable, so equal relay rows must replace snapshots. */
export const PROJECT_QUERY_STRUCTURAL_SHARING = false;
const PROJECT_PROVENANCE = Symbol("project-provenance");
type ProjectProvenance = "local-write" | "relay";
type ProjectSnapshotScope = {
pubkey: string;
relayUrl: string;
};
type StoredProjectSnapshot = {
integrity: string;
ownerPubkey: string;
projects: Project[];
updatedAt: number;
version: 1;
};
const snapshotScopes = new WeakMap<QueryClient, ProjectSnapshotScope>();
const authoritativeProjectCollections = new WeakSet<QueryClient>();
/** Marks one project's origin without upgrading sibling snapshot rows. */
export function markProjectDataAuthoritative<T extends Project>(
project: T,
provenance: ProjectProvenance,
): T {
Object.defineProperty(project, PROJECT_PROVENANCE, {
configurable: true,
value: provenance,
});
return project;
}
/** Returns whether this exact project came from a live read or local write. */
export function isProjectDataAuthoritative(
project: Project | null | undefined,
): boolean {
return Boolean(
project &&
(project as Project & { [PROJECT_PROVENANCE]?: ProjectProvenance })[
PROJECT_PROVENANCE
],
);
}
/** Returns whether relay reads validated this project's repository models. */
export function isProjectRelayValidated(
project: Project | null | undefined,
): boolean {
return (
(
project as
| (Project & { [PROJECT_PROVENANCE]?: ProjectProvenance })
| null
| undefined
)?.[PROJECT_PROVENANCE] === "relay"
);
}
/** Copies non-serialized provenance when replacing one cached project object. */
export function inheritProjectDataProvenance<T extends Project>(
source: Project,
replacement: T,
): T {
const provenance = (
source as Project & { [PROJECT_PROVENANCE]?: ProjectProvenance }
)[PROJECT_PROVENANCE];
return provenance
? markProjectDataAuthoritative(replacement, provenance)
: replacement;
}
/** Records that exhaustive relay enumeration completed for this query client. */
export function markProjectCollectionAuthoritative(
queryClient: QueryClient,
): void {
authoritativeProjectCollections.add(queryClient);
}
/** Returns whether exhaustive relay enumeration completed for this client. */
export function isProjectCollectionAuthoritative(
queryClient: QueryClient,
): boolean {
return authoritativeProjectCollections.has(queryClient);
}
/** Keeps the active-channel fast path live while only a snapshot is present. */
export function shouldUseScopedProjectHomeLookup({
collectionIsAuthoritative,
hasEnumeratedProjectHome,
isHuddleTranscript,
}: {
collectionIsAuthoritative: boolean;
hasEnumeratedProjectHome: boolean;
isHuddleTranscript: boolean;
}): boolean {
return (
!isHuddleTranscript &&
!hasEnumeratedProjectHome &&
!collectionIsAuthoritative
);
}
function projectSnapshotRelayPrefix(relayUrl: string): string {
return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}:`;
}
export function projectSnapshotKey(
relayUrl: string,
ownerPubkey: string,
): string {
return `${projectSnapshotRelayPrefix(relayUrl)}${ownerPubkey.toLowerCase()}`;
}
function snapshotIntegrity(ownerPubkey: string, projects: Project[]): string {
const value = JSON.stringify([ownerPubkey.toLowerCase(), projects]);
let result = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
result ^= value.charCodeAt(index);
result = Math.imul(result, 0x01000193);
}
return (result >>> 0).toString(16).padStart(8, "0");
}
function isProject(value: unknown): value is Project {
if (typeof value !== "object" || value === null) return false;
const project = value as Partial<Project>;
return (
typeof project.id === "string" &&
typeof project.owner === "string" &&
typeof project.projectAddress === "string" &&
(typeof project.projectChannelId === "string" ||
project.projectChannelId === null) &&
Array.isArray(project.repositoryAddresses) &&
Array.isArray(project.repositories) &&
typeof project.legacy === "boolean"
);
}
function parseProjectSnapshot(
value: unknown,
ownerPubkey: string,
): Project[] | null {
if (typeof value !== "object" || value === null) return null;
const snapshot = value as Partial<StoredProjectSnapshot>;
if (
snapshot.version !== 1 ||
snapshot.ownerPubkey?.toLowerCase() !== ownerPubkey.toLowerCase() ||
!Array.isArray(snapshot.projects) ||
!snapshot.projects.every(isProject) ||
typeof snapshot.integrity !== "string" ||
snapshot.integrity !== snapshotIntegrity(ownerPubkey, snapshot.projects)
) {
return null;
}
return snapshot.projects;
}
export function readProjectSnapshot(
relayUrl: string,
ownerPubkey: string,
): Project[] | null {
try {
const raw = window.localStorage.getItem(
projectSnapshotKey(relayUrl, ownerPubkey),
);
return raw ? parseProjectSnapshot(JSON.parse(raw), ownerPubkey) : null;
} catch {
return null;
}
}
/**
* Seeds the last fully validated project collection into a community's fresh
* query client. Timestamp zero keeps it stale so the relay revalidates it.
*/
export function seedProjectSnapshot(
queryClient: QueryClient,
scope: ProjectSnapshotScope,
): void {
snapshotScopes.set(queryClient, scope);
const projects = readProjectSnapshot(scope.relayUrl, scope.pubkey);
if (projects) {
queryClient.setQueryData(PROJECTS_QUERY_KEY, projects, { updatedAt: 0 });
}
}
/** Persists a successful complete enumeration for the current community. */
export function persistProjectSnapshot(
queryClient: QueryClient,
projects: Project[],
): void {
const scope = snapshotScopes.get(queryClient);
if (!scope) return;
try {
const snapshot: StoredProjectSnapshot = {
integrity: snapshotIntegrity(scope.pubkey, projects),
ownerPubkey: scope.pubkey.toLowerCase(),
projects,
updatedAt: Date.now(),
version: 1,
};
setLocalStorageItemWithRecovery(
projectSnapshotKey(scope.relayUrl, scope.pubkey),
JSON.stringify(snapshot),
);
} catch {
// Snapshot persistence is optional; live relay data remains authoritative.
}
}
/** Removes every identity's project snapshot for a deleted community. */
export function removeProjectSnapshotForRelay(relayUrl: string): void {
try {
const prefix = projectSnapshotRelayPrefix(relayUrl);
const keys: string[] = [];
for (let index = 0; index < window.localStorage.length; index += 1) {
const key = window.localStorage.key(index);
if (key?.startsWith(prefix)) keys.push(key);
}
for (const key of keys) window.localStorage.removeItem(key);
} catch {
// Storage access failures are non-fatal.
}
}

View File

@@ -85,12 +85,14 @@ function ProjectHomeHeaderToggle({
}
export function ProjectChannelHome({
allowRepositoryHealing,
autoSendDraftKey,
project,
projects,
targetMessageEvents = EMPTY_TARGET_MESSAGE_EVENTS,
targetMessageId,
}: {
allowRepositoryHealing: boolean;
autoSendDraftKey?: string | null;
project: Project;
projects: Project[];
@@ -194,7 +196,11 @@ export function ProjectChannelHome({
},
[],
);
useHealProjectHomeRepositories(project, identityQuery.data?.pubkey);
useHealProjectHomeRepositories(
project,
allowRepositoryHealing,
identityQuery.data?.pubkey,
);
const handleOpenCommit = React.useCallback(
(commitHash: string) => {
if (!workspaceRepository) return;
@@ -252,6 +258,7 @@ export function ProjectChannelHome({
)}
data-project-context-detached={summaryVisible ? "true" : undefined}
data-project-detail-screen
data-repository-healing-enabled={allowRepositoryHealing}
data-testid="project-channel-home"
>
<div

View File

@@ -48,6 +48,7 @@ import {
import { wantsProjectRepositorySurface } from "@/features/projects/lib/projectDetailSearch";
import { hasAuthoritativeHomeBinding } from "@/features/projects/lib/projectHomeChannel";
import { selectProjectRepository } from "@/features/projects/projectModels";
import { isProjectRelayValidated } from "@/features/projects/projectSnapshot";
import { ProjectSelectionProvider } from "@/features/projects/lib/useProjectSelection";
import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess";
import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds";
@@ -693,6 +694,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
if (showChannelHome) {
return (
<ProjectChannelHome
allowRepositoryHealing={isProjectRelayValidated(project)}
project={project}
projects={projectsQuery.data ?? [project]}
/>

View File

@@ -11,6 +11,7 @@ import { isUnsupportedProjectKindError } from "@/features/projects/projectCreati
import { buildProjectRelatedChannelPatchTemplate } from "@/features/projects/projectChannelCreation";
import { addRelatedChannelToProject } from "@/features/projects/projectModels";
import { publishOwnedAgentProjectAnnouncements } from "@/features/projects/projectOwnerControl";
import { markProjectDataAuthoritative } from "@/features/projects/projectSnapshot";
import { publishProjectOwnerAnnouncement } from "@/shared/api/projectGit";
import { relayClient } from "@/shared/api/relayClient";
import { deleteChannel as deleteChannelApi } from "@/shared/api/tauriChannels";
@@ -183,6 +184,7 @@ export function useAddProjectChannelMutation() {
createChannel: createChannelMutation.mutateAsync,
}),
onSuccess: ({ channel, project }) => {
markProjectDataAuthoritative(project, "local-write");
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) =>
current.map((candidate) =>
candidate.id === project.id ? project : candidate,

View File

@@ -18,6 +18,7 @@ import {
addRepositoryToProject,
eventToRepository,
} from "@/features/projects/projectModels";
import { markProjectDataAuthoritative } from "@/features/projects/projectSnapshot";
import { publishProjectOwnerAnnouncement } from "@/shared/api/projectGit";
import { relayClient } from "@/shared/api/relayClient";
import type { RelayEvent } from "@/shared/api/types";
@@ -315,6 +316,7 @@ export function useAddProjectRepositoryMutation() {
mutationFn: (input: AddProjectRepositoryInput) =>
addProjectRepository(input),
onSuccess: ({ previousProjectId, project }) => {
markProjectDataAuthoritative(project, "local-write");
if (previousProjectId !== project.id) {
queryClient.removeQueries({
exact: true,

View File

@@ -8,6 +8,7 @@ import {
import { publishOwnedAgentProjectAnnouncements } from "@/features/projects/projectOwnerControl";
import { addRepositoryToProject } from "@/features/projects/projectModels";
import { buildProjectPatchTemplate } from "@/features/projects/projectRepositoryCreation";
import { markProjectDataAuthoritative } from "@/features/projects/projectSnapshot";
import { publishProjectOwnerAnnouncement } from "@/shared/api/projectGit";
import { relayClient } from "@/shared/api/relayClient";
import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds";
@@ -109,6 +110,7 @@ export function useAttachProjectRepositoryMutation() {
return useMutation({
mutationFn: attachProjectRepository,
onSuccess: ({ previousProjectId, project }) => {
markProjectDataAuthoritative(project, "local-write");
if (previousProjectId !== project.id) {
queryClient.removeQueries({
exact: true,

View File

@@ -7,6 +7,7 @@ import {
} from "@/features/projects/hooks";
import { eventToRepository } from "@/features/projects/projectModels";
import { buildRepositoryChannelBindingTemplate } from "@/features/projects/projectRepositoryCreation";
import { inheritProjectDataProvenance } from "@/features/projects/projectSnapshot";
import { relayClient } from "@/shared/api/relayClient";
import { signRelayEvent } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
@@ -53,14 +54,24 @@ export function useBindProjectRepositoryChannelMutation() {
mutationFn: bindProjectRepositoryChannel,
onSuccess: (repository) => {
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) =>
current.map((project) => ({
...project,
repositories: project.repositories.map((candidate) =>
candidate.repoAddress === repository.repoAddress
? repository
: candidate,
),
})),
current.map((project) => {
if (
!project.repositories.some(
(candidate) => candidate.repoAddress === repository.repoAddress,
)
) {
return project;
}
const updatedProject = {
...project,
repositories: project.repositories.map((candidate) =>
candidate.repoAddress === repository.repoAddress
? repository
: candidate,
),
};
return inheritProjectDataProvenance(project, updatedProject);
}),
);
void queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},

View File

@@ -19,6 +19,7 @@ import {
applyProjectHomeCanvas,
PROJECT_HOME_TEMPLATE_ID,
} from "@/features/projects/lib/projectHomeTemplate";
import { markProjectDataAuthoritative } from "@/features/projects/projectSnapshot";
import type { Channel } from "@/shared/api/types";
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
@@ -37,6 +38,7 @@ export function useCreateProjectMutation() {
mutationFn: (input: CreateProjectInput) =>
createProject(input, resumeRef.current),
onSuccess: async ({ channel, project }, input) => {
markProjectDataAuthoritative(project, "local-write");
addProjectToSidebar(
project.projectAddress,
getCachedRelayOrigin(),

View File

@@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { canHealProjectHomeRepositories } from "./useHealProjectHomeRepositories.ts";
const OWNER = "a".repeat(64);
const PROJECT = {
owner: OWNER,
repositories: [{ id: "repository" }],
};
test("snapshot-derived projects cannot trigger repository healing", () => {
assert.equal(
canHealProjectHomeRepositories({
identityPubkey: OWNER,
project: PROJECT,
projectDataIsAuthoritative: false,
}),
false,
);
});
test("authoritative owner projects with repositories can heal", () => {
assert.equal(
canHealProjectHomeRepositories({
identityPubkey: OWNER,
project: PROJECT,
projectDataIsAuthoritative: true,
}),
true,
);
});
test("repository healing still rejects a non-owner identity", () => {
assert.equal(
canHealProjectHomeRepositories({
identityPubkey: "b".repeat(64),
project: PROJECT,
projectDataIsAuthoritative: true,
}),
false,
);
});

View File

@@ -7,6 +7,24 @@ import { relayClient } from "@/shared/api/relayClient";
import { KIND_PROJECT_ANNOUNCEMENT } from "@/shared/constants/kinds";
import { normalizePubkey } from "@/shared/lib/pubkey";
/** Allows automatic healing only from relay-validated owner project data. */
export function canHealProjectHomeRepositories({
identityPubkey,
project,
projectDataIsAuthoritative,
}: {
identityPubkey?: string;
project: Pick<Project, "owner" | "repositories">;
projectDataIsAuthoritative: boolean;
}): boolean {
return Boolean(
projectDataIsAuthoritative &&
identityPubkey &&
normalizePubkey(identityPubkey) === normalizePubkey(project.owner) &&
project.repositories.length > 0,
);
}
/**
* When the signed-in user owns this project, bind absorbed home-channel
* repositories onto the `kind:30621` so Overview, other clients, and NIP-MP
@@ -15,6 +33,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
*/
export function useHealProjectHomeRepositories(
project: Project,
projectDataIsAuthoritative: boolean,
identityPubkey?: string,
) {
const attachMutation = useAttachProjectRepositoryMutation();
@@ -22,11 +41,15 @@ export function useHealProjectHomeRepositories(
const mutateAsync = attachMutation.mutateAsync;
React.useEffect(() => {
if (!identityPubkey) return;
if (normalizePubkey(identityPubkey) !== normalizePubkey(project.owner)) {
if (
!canHealProjectHomeRepositories({
identityPubkey,
project,
projectDataIsAuthoritative,
})
) {
return;
}
if (project.repositories.length === 0) return;
let cancelled = false;
void (async () => {
@@ -58,5 +81,5 @@ export function useHealProjectHomeRepositories(
return () => {
cancelled = true;
};
}, [identityPubkey, mutateAsync, project]);
}, [identityPubkey, mutateAsync, project, projectDataIsAuthoritative]);
}

View File

@@ -63,6 +63,7 @@ test("relay invalidation separates relay project queries from local repo work",
["project", "project-1", "pull-requests"],
["projects", "issues", ["project-1"]],
["projects", "activity-summaries", ["addr-1"]],
["projects", "home-channel", "channel-1"],
// Work items fan out over the relay and are fresh for two minutes;
// reconnect auto-heal must be able to repair a partial result.
["projects", "work-items", ["project-1"], ["addr-1"]],

View File

@@ -37,6 +37,7 @@ const RELAY_QUERY_ROOTS = new Set<string>([
const RELAY_PROJECT_QUERY_PARTS = new Set<string>([
"activity-summaries",
"home-channel",
"issues",
"pull-requests",
// Fresh for two minutes (PROJECT_WORK_ITEMS_STALE_TIME_MS) and tolerant of

View File

@@ -34,10 +34,11 @@ function install(ls) {
}
test("startup recovery removes disposable caches but preserves user state", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 6 });
const ls = makeQuotaLocalStorage({ maxEntries: 7 });
install(ls);
ls.store.set("buzz-channel-messages.v1:relay:chan", "big");
ls.store.set("buzz-channels.v1:relay", "big");
ls.store.set("buzz-projects.v1:relay:owner", "big");
ls.store.set("buzz-timeline-skeleton-shape.v1:chan", "small");
ls.store.set("buzz-sidebar-skeleton-shape.v1:community:user", "small");
ls.store.set("buzz-user-labels.v1:relay", "small");
@@ -47,6 +48,7 @@ test("startup recovery removes disposable caches but preserves user state", () =
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:chan"), null);
assert.equal(ls.getItem("buzz-channels.v1:relay"), null);
assert.equal(ls.getItem("buzz-projects.v1:relay:owner"), null);
assert.equal(ls.getItem("buzz-timeline-skeleton-shape.v1:chan"), null);
assert.equal(
ls.getItem("buzz-sidebar-skeleton-shape.v1:community:user"),
@@ -163,7 +165,7 @@ test("global cache byte budget spans relays and preserves durable state", () =>
test("rejects a single cache entry larger than the global byte budget", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 10 });
install(ls);
const key = "buzz-channel-messages.v1:relay:oversized";
const key = "buzz-projects.v1:relay:oversized";
ls.store.set(key, "previous snapshot");
assert.equal(
@@ -183,15 +185,35 @@ test("writes normally when under quota", () => {
test("evicts pure caches and retries on quota failure", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 2 });
install(ls);
ls.store.set("buzz-channel-messages.v1:relay:chan", "big");
ls.store.set("buzz-projects.v1:relay:owner", "big");
ls.store.set("buzz-channels.v1:relay", "big");
assert.equal(setLocalStorageItemWithRecovery("k", "v"), true);
assert.equal(ls.getItem("k"), "v");
assert.equal(ls.getItem("buzz-channel-messages.v1:relay:chan"), null);
assert.equal(ls.getItem("buzz-projects.v1:relay:owner"), null);
assert.equal(ls.getItem("buzz-channels.v1:relay"), null);
});
test("project snapshots participate in global LRU budgeting", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 20 });
install(ls);
ls.store.set("buzz-communities", "keep");
const snapshot = (updatedAt) =>
JSON.stringify({ updatedAt, payload: "x".repeat(400_000) });
const projectKey = "buzz-projects.v1:relay:owner";
const newerKey = "buzz-channels.v1:relay:newer";
const newestKey = "buzz-channel-messages.v1:relay:newest";
assert.equal(setLocalStorageItemWithRecovery(projectKey, snapshot(1)), true);
assert.equal(setLocalStorageItemWithRecovery(newerKey, snapshot(2)), true);
assert.equal(setLocalStorageItemWithRecovery(newestKey, snapshot(3)), true);
assert.equal(ls.getItem(projectKey), null);
assert.notEqual(ls.getItem(newerKey), null);
assert.notEqual(ls.getItem(newestKey), null);
assert.equal(ls.getItem("buzz-communities"), "keep");
});
test("returns false when eviction frees nothing", () => {
const ls = makeQuotaLocalStorage({ maxEntries: 2 });
install(ls);

View File

@@ -11,6 +11,7 @@ const PURE_CACHE_KEY_PREFIXES = [
"buzz-channel-messages.v1:",
"buzz-channels.v1:",
"buzz-observed-unread.v1:",
"buzz-projects.v1:",
"buzz-sidebar-skeleton-shape.v1:",
"buzz-timeline-skeleton-shape.v1:",
"buzz-user-labels.v1:",

View File

@@ -1053,6 +1053,7 @@ type MockSubscription = {
type MockFilter = {
"#a"?: string[];
"#buzz-channel"?: string[];
"#d"?: string[];
"#e"?: string[];
"#h"?: string[];
@@ -1256,6 +1257,8 @@ declare global {
}>;
/** Omits kind 30621 seeds while retaining standalone kind 30617 repositories. */
__BUZZ_E2E_REPOSITORY_ONLY_PROJECTS__?: boolean;
/** Leaves broad project enumeration pending while scoped project queries remain available. */
__BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__?: boolean;
/** Project-scoped events accepted by the mock relay. */
__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?: Array<{
content: string;
@@ -10490,6 +10493,17 @@ function sendToMockSocket(args: {
]);
return;
}
if (
window.__BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__ &&
!filter.authors &&
!filter["#a"] &&
!filter["#buzz-channel"] &&
!filter["#d"] &&
!filter["#e"] &&
!filter.ids
) {
return;
}
for (const event of filterMockProjectEvents(filter)) {
sendWsText(socket.handler, ["EVENT", subId, event]);
}

View File

@@ -0,0 +1,171 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
const PROJECT_HOME_CHANNEL_ID = "cf63feec-21bb-5bf0-a2f8-0e4c3de8ec73";
async function enableProjectsFeature(page: import("@playwright/test").Page) {
await page.addInitScript(() => {
window.localStorage.setItem(
"buzz-feature-overrides-v1",
JSON.stringify({ projects: true }),
);
});
}
async function waitForProjectSnapshot(
page: import("@playwright/test").Page,
): Promise<void> {
await expect
.poll(() =>
page.evaluate(() =>
Object.keys(window.localStorage).some((key) =>
key.startsWith("buzz-projects.v1:"),
),
),
)
.toBe(true);
}
async function mutateProjectCache(
page: import("@playwright/test").Page,
): Promise<void> {
await expect
.poll(() =>
page.evaluate(
() =>
"__BUZZ_E2E_QUERY_CLIENT__" in window &&
Boolean(window.__BUZZ_E2E_QUERY_CLIENT__),
),
)
.toBe(true);
await page.evaluate(() => {
const queryClient = (
window as typeof window & {
__BUZZ_E2E_QUERY_CLIENT__?: {
setQueryData: (
key: readonly string[],
updater: (current: unknown) => unknown,
) => void;
};
}
).__BUZZ_E2E_QUERY_CLIENT__;
if (!queryClient) throw new Error("E2E query client is unavailable.");
queryClient.setQueryData(["projects"], (current) =>
Array.isArray(current) ? [...current] : current,
);
});
}
async function waitForProjectEnumeration(
page: import("@playwright/test").Page,
): Promise<void> {
await expect
.poll(() =>
page.evaluate(() => {
const queryClient = window.__BUZZ_E2E_QUERY_CLIENT__;
const state = queryClient?.getQueryState(["projects"]);
return Boolean(
state && state.fetchStatus === "idle" && state.dataUpdatedAt > 0,
);
}),
)
.toBe(true);
}
test("snapshot project home cannot publish repository healing", async ({
page,
}) => {
await enableProjectsFeature(page);
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForProjectSnapshot(page);
await page.getByTestId("channel-buzz").click();
await expect(page.getByTestId("project-home-context-panel")).toBeVisible();
await page.addInitScript(() => {
window.__BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__ = true;
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__ = [];
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await mutateProjectCache(page);
await page.getByTestId("channel-buzz").click();
await expect(page.getByTestId("project-home-context-panel")).toBeVisible();
await page.waitForTimeout(500);
const projectPublications = await page.evaluate(
() =>
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.filter(
(event) =>
event.kind === 30621 &&
event.tags.some((tag) => tag[0] === "d" && tag[1] === "buzz"),
) ?? [],
);
expect(projectPublications).toEqual([]);
});
test("stale non-matching snapshot uses the scoped project-home lookup", async ({
page,
}) => {
await enableProjectsFeature(page);
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForProjectSnapshot(page);
await page.evaluate(() => {
const key = Object.keys(window.localStorage).find((candidate) =>
candidate.startsWith("buzz-projects.v1:"),
);
if (!key) throw new Error("Project snapshot was not persisted.");
const snapshot = JSON.parse(window.localStorage.getItem(key) ?? "{}");
snapshot.projects = snapshot.projects.filter(
(project) => project.dtag !== "buzz",
);
const value = JSON.stringify([
snapshot.ownerPubkey.toLowerCase(),
snapshot.projects,
]);
let integrity = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
integrity ^= value.charCodeAt(index);
integrity = Math.imul(integrity, 0x01000193);
}
snapshot.integrity = (integrity >>> 0).toString(16).padStart(8, "0");
window.localStorage.setItem(key, JSON.stringify(snapshot));
});
await page.addInitScript(() => {
window.__BUZZ_E2E_DEFER_FULL_PROJECT_QUERIES__ = true;
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await mutateProjectCache(page);
await page.getByTestId("channel-buzz").click();
await expect(page.getByTestId("project-home-context-panel")).toBeVisible();
const usedScopedLookup = await page.evaluate((channelId) => {
return window.__BUZZ_E2E_PROJECT_QUERY_FILTERS__?.some((filter) =>
filter["#buzz-channel"]?.includes(channelId),
);
}, PROJECT_HOME_CHANNEL_ID);
expect(usedScopedLookup).toBe(true);
});
test("equal live project data enables healing after snapshot reconciliation", async ({
page,
}) => {
await page.addInitScript(() => {
Date.now = () => 1_787_872_972_113;
});
await enableProjectsFeature(page);
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForProjectSnapshot(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await waitForProjectEnumeration(page);
await page.getByTestId("channel-buzz").click();
await expect(page.getByTestId("project-channel-home")).toHaveAttribute(
"data-repository-healing-enabled",
"true",
);
});