perf(nip34): Add memoization caching to helper functions

Use getOrComputeCachedValue from applesauce-core to cache computed
values on event objects. This prevents redundant computation when
helpers are called multiple times for the same event.

Also added documentation in CLAUDE.md about best practices for
writing helper libraries that compute data from Nostr events.

https://claude.ai/code/session_01C6Lty4k9pKxdwnYUCcpzV2
This commit is contained in:
Claude
2026-01-23 11:14:52 +00:00
parent a8bb4fb3c4
commit e7a2992fa8
2 changed files with 145 additions and 57 deletions

View File

@@ -134,7 +134,7 @@ const text = getHighlightText(event);
- `getTagValues(event, name)` - plural version to get array of tag values (src/lib/nostr-utils.ts)
- `resolveFilterAliases(filter, pubkey, contacts)` - resolves `$me`/`$contacts` aliases (src/lib/nostr-utils.ts)
- `getDisplayName(pubkey, metadata)` - enhanced version with pubkey fallback (src/lib/nostr-utils.ts)
- NIP-34 git helpers (src/lib/nip34-helpers.ts) - wraps `getTagValue` for repository, issue, patch metadata
- NIP-34 git helpers (src/lib/nip34-helpers.ts) - uses `getOrComputeCachedValue` for repository, issue, patch metadata
- NIP-C0 code snippet helpers (src/lib/nip-c0-helpers.ts) - wraps `getTagValue` for code metadata
**When to use `useMemo`**:
@@ -142,7 +142,40 @@ const text = getHighlightText(event);
- ✅ Creating objects/arrays for dependency tracking (options, configurations)
- ✅ Expensive computations that don't call applesauce helpers
- ❌ Direct calls to applesauce helpers (they cache internally)
- ❌ Grimoire helpers that wrap `getTagValue` (caching propagates)
- ❌ Grimoire helpers that use `getOrComputeCachedValue` (they cache internally)
### Writing Helper Libraries for Nostr Events
When creating helper functions that compute derived values from Nostr events, **always use `getOrComputeCachedValue`** from applesauce-core to cache results on the event object:
```typescript
import { getOrComputeCachedValue } from "applesauce-core/helpers";
// Define a unique symbol for caching
const MyComputedValueSymbol = Symbol("myComputedValue");
export function getMyComputedValue(event: NostrEvent): string[] {
return getOrComputeCachedValue(event, MyComputedValueSymbol, () => {
// Expensive computation that iterates over tags, parses content, etc.
return event.tags
.filter((t) => t[0] === "myTag")
.map((t) => t[1]);
});
}
```
**Why this matters**:
- Event objects are often accessed multiple times during rendering
- Without caching, the same computation runs repeatedly (e.g., on every re-render)
- `getOrComputeCachedValue` stores the result on the event object using the symbol as a key
- Subsequent calls return the cached value instantly without recomputation
- Components don't need `useMemo` when calling these helpers
**Best practices for helper libraries**:
1. Use `getOrComputeCachedValue` for any function that iterates tags, parses content, or does regex matching
2. Define symbols at module scope (not inside functions) for proper caching
3. Simple `getTagValue()` calls don't need additional caching (already cached by applesauce)
4. Group related helpers in NIP-specific files (e.g., `nip34-helpers.ts`, `nip88-helpers.ts`)
## Major Hooks

View File

@@ -1,12 +1,35 @@
import type { NostrEvent } from "@/types/nostr";
import { getTagValue } from "applesauce-core/helpers";
import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers";
import { parseReplaceableAddress } from "applesauce-core/helpers/pointers";
/**
* NIP-34 Helper Functions
* Utility functions for parsing NIP-34 git event tags
*
* All helper functions use applesauce's getOrComputeCachedValue to cache
* computed values on the event object itself. This means you don't need
* useMemo when calling these functions - they will return cached values
* on subsequent calls for the same event.
*/
// Cache symbols for memoization
const CloneUrlsSymbol = Symbol("cloneUrls");
const WebUrlsSymbol = Symbol("webUrls");
const MaintainersSymbol = Symbol("maintainers");
const RepositoryRelaysSymbol = Symbol("repositoryRelays");
const IssueLabelsSymbol = Symbol("issueLabels");
const PatchSubjectSymbol = Symbol("patchSubject");
const PatchCommitterSymbol = Symbol("patchCommitter");
const IsPatchRootSymbol = Symbol("isPatchRoot");
const IsPatchRootRevisionSymbol = Symbol("isPatchRootRevision");
const PullRequestLabelsSymbol = Symbol("pullRequestLabels");
const PullRequestCloneUrlsSymbol = Symbol("pullRequestCloneUrls");
const RepositoryStateRefsSymbol = Symbol("repositoryStateRefs");
const RepositoryStateBranchesSymbol = Symbol("repositoryStateBranches");
const RepositoryStateTagsSymbol = Symbol("repositoryStateTags");
const StatusRootEventIdSymbol = Symbol("statusRootEventId");
const StatusRootRelayHintSymbol = Symbol("statusRootRelayHint");
// ============================================================================
// Repository Event Helpers (Kind 30617)
// ============================================================================
@@ -46,7 +69,9 @@ export function getRepositoryIdentifier(event: NostrEvent): string | undefined {
* @returns Array of clone URLs
*/
export function getCloneUrls(event: NostrEvent): string[] {
return event.tags.filter((t) => t[0] === "clone").map((t) => t[1]);
return getOrComputeCachedValue(event, CloneUrlsSymbol, () =>
event.tags.filter((t) => t[0] === "clone").map((t) => t[1]),
);
}
/**
@@ -55,7 +80,9 @@ export function getCloneUrls(event: NostrEvent): string[] {
* @returns Array of web URLs
*/
export function getWebUrls(event: NostrEvent): string[] {
return event.tags.filter((t) => t[0] === "web").map((t) => t[1]);
return getOrComputeCachedValue(event, WebUrlsSymbol, () =>
event.tags.filter((t) => t[0] === "web").map((t) => t[1]),
);
}
/**
@@ -64,10 +91,12 @@ export function getWebUrls(event: NostrEvent): string[] {
* @returns Array of maintainer pubkeys
*/
export function getMaintainers(event: NostrEvent): string[] {
return event.tags
.filter((t) => t[0] === "maintainers")
.map((t) => t[1])
.filter((p: string) => p !== event.pubkey);
return getOrComputeCachedValue(event, MaintainersSymbol, () =>
event.tags
.filter((t) => t[0] === "maintainers")
.map((t) => t[1])
.filter((p: string) => p !== event.pubkey),
);
}
/**
@@ -76,10 +105,12 @@ export function getMaintainers(event: NostrEvent): string[] {
* @returns Array of relay URLs
*/
export function getRepositoryRelays(event: NostrEvent): string[] {
const relaysTag = event.tags.find((t) => t[0] === "relays");
if (!relaysTag) return [];
const [, ...relays] = relaysTag;
return relays;
return getOrComputeCachedValue(event, RepositoryRelaysSymbol, () => {
const relaysTag = event.tags.find((t) => t[0] === "relays");
if (!relaysTag) return [];
const [, ...relays] = relaysTag;
return relays;
});
}
// ============================================================================
@@ -101,7 +132,9 @@ export function getIssueTitle(event: NostrEvent): string | undefined {
* @returns Array of label strings
*/
export function getIssueLabels(event: NostrEvent): string[] {
return event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
return getOrComputeCachedValue(event, IssueLabelsSymbol, () =>
event.tags.filter((t) => t[0] === "t").map((t) => t[1]),
);
}
/**
@@ -134,18 +167,20 @@ export function getIssueRepositoryOwner(event: NostrEvent): string | undefined {
* @returns Patch subject/title or undefined
*/
export function getPatchSubject(event: NostrEvent): string | undefined {
// Try subject tag first
const subjectTag = getTagValue(event, "subject");
if (subjectTag) return subjectTag;
return getOrComputeCachedValue(event, PatchSubjectSymbol, () => {
// Try subject tag first
const subjectTag = getTagValue(event, "subject");
if (subjectTag) return subjectTag;
// Try to extract from content (first line or "Subject:" header from git format-patch)
const content = event.content.trim();
const subjectMatch = content.match(/^Subject:\s*(.+?)$/m);
if (subjectMatch) return subjectMatch[1].trim();
// Try to extract from content (first line or "Subject:" header from git format-patch)
const content = event.content.trim();
const subjectMatch = content.match(/^Subject:\s*(.+?)$/m);
if (subjectMatch) return subjectMatch[1].trim();
// Fallback to first line
const firstLine = content.split("\n")[0];
return firstLine?.length > 0 ? firstLine : undefined;
// Fallback to first line
const firstLine = content.split("\n")[0];
return firstLine?.length > 0 ? firstLine : undefined;
});
}
/**
@@ -176,11 +211,13 @@ export function getPatchCommitter(
):
| { name: string; email: string; timestamp: string; timezone: string }
| undefined {
const committerTag = event.tags.find((t) => t[0] === "committer");
if (!committerTag || committerTag.length < 5) return undefined;
return getOrComputeCachedValue(event, PatchCommitterSymbol, () => {
const committerTag = event.tags.find((t) => t[0] === "committer");
if (!committerTag || committerTag.length < 5) return undefined;
const [, name, email, timestamp, timezone] = committerTag;
return { name, email, timestamp, timezone };
const [, name, email, timestamp, timezone] = committerTag;
return { name, email, timestamp, timezone };
});
}
/**
@@ -200,7 +237,9 @@ export function getPatchRepositoryAddress(
* @returns True if this is a root patch
*/
export function isPatchRoot(event: NostrEvent): boolean {
return event.tags.some((t) => t[0] === "t" && t[1] === "root");
return getOrComputeCachedValue(event, IsPatchRootSymbol, () =>
event.tags.some((t) => t[0] === "t" && t[1] === "root"),
);
}
/**
@@ -209,7 +248,9 @@ export function isPatchRoot(event: NostrEvent): boolean {
* @returns True if this is a root revision
*/
export function isPatchRootRevision(event: NostrEvent): boolean {
return event.tags.some((t) => t[0] === "t" && t[1] === "root-revision");
return getOrComputeCachedValue(event, IsPatchRootRevisionSymbol, () =>
event.tags.some((t) => t[0] === "t" && t[1] === "root-revision"),
);
}
// ============================================================================
@@ -231,7 +272,9 @@ export function getPullRequestSubject(event: NostrEvent): string | undefined {
* @returns Array of label strings
*/
export function getPullRequestLabels(event: NostrEvent): string[] {
return event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
return getOrComputeCachedValue(event, PullRequestLabelsSymbol, () =>
event.tags.filter((t) => t[0] === "t").map((t) => t[1]),
);
}
/**
@@ -249,7 +292,9 @@ export function getPullRequestCommitId(event: NostrEvent): string | undefined {
* @returns Array of clone URLs
*/
export function getPullRequestCloneUrls(event: NostrEvent): string[] {
return event.tags.filter((t) => t[0] === "clone").map((t) => t[1]);
return getOrComputeCachedValue(event, PullRequestCloneUrlsSymbol, () =>
event.tags.filter((t) => t[0] === "clone").map((t) => t[1]),
);
}
/**
@@ -317,9 +362,11 @@ export function parseHeadBranch(
export function getRepositoryStateRefs(
event: NostrEvent,
): Array<{ ref: string; hash: string }> {
return event.tags
.filter((t) => t[0].startsWith("refs/"))
.map((t) => ({ ref: t[0], hash: t[1] }));
return getOrComputeCachedValue(event, RepositoryStateRefsSymbol, () =>
event.tags
.filter((t) => t[0].startsWith("refs/"))
.map((t) => ({ ref: t[0], hash: t[1] })),
);
}
/**
@@ -348,12 +395,14 @@ export function getRepositoryStateHeadCommit(
export function getRepositoryStateBranches(
event: NostrEvent,
): Array<{ name: string; hash: string }> {
return event.tags
.filter((t) => t[0].startsWith("refs/heads/"))
.map((t) => ({
name: t[0].replace("refs/heads/", ""),
hash: t[1],
}));
return getOrComputeCachedValue(event, RepositoryStateBranchesSymbol, () =>
event.tags
.filter((t) => t[0].startsWith("refs/heads/"))
.map((t) => ({
name: t[0].replace("refs/heads/", ""),
hash: t[1],
})),
);
}
/**
@@ -364,12 +413,14 @@ export function getRepositoryStateBranches(
export function getRepositoryStateTags(
event: NostrEvent,
): Array<{ name: string; hash: string }> {
return event.tags
.filter((t) => t[0].startsWith("refs/tags/"))
.map((t) => ({
name: t[0].replace("refs/tags/", ""),
hash: t[1],
}));
return getOrComputeCachedValue(event, RepositoryStateTagsSymbol, () =>
event.tags
.filter((t) => t[0].startsWith("refs/tags/"))
.map((t) => ({
name: t[0].replace("refs/tags/", ""),
hash: t[1],
})),
);
}
// ============================================================================
@@ -407,13 +458,15 @@ export function getStatusType(kind: number): IssueStatusType | undefined {
* @returns Event ID or undefined
*/
export function getStatusRootEventId(event: NostrEvent): string | undefined {
// Look for e tag with "root" marker
const rootTag = event.tags.find((t) => t[0] === "e" && t[3] === "root");
if (rootTag) return rootTag[1];
return getOrComputeCachedValue(event, StatusRootEventIdSymbol, () => {
// Look for e tag with "root" marker
const rootTag = event.tags.find((t) => t[0] === "e" && t[3] === "root");
if (rootTag) return rootTag[1];
// Fallback: first e tag without a marker or with empty marker
const firstETag = event.tags.find((t) => t[0] === "e");
return firstETag?.[1];
// Fallback: first e tag without a marker or with empty marker
const firstETag = event.tags.find((t) => t[0] === "e");
return firstETag?.[1];
});
}
/**
@@ -422,11 +475,13 @@ export function getStatusRootEventId(event: NostrEvent): string | undefined {
* @returns Relay URL or undefined
*/
export function getStatusRootRelayHint(event: NostrEvent): string | undefined {
const rootTag = event.tags.find((t) => t[0] === "e" && t[3] === "root");
if (rootTag && rootTag[2]) return rootTag[2];
return getOrComputeCachedValue(event, StatusRootRelayHintSymbol, () => {
const rootTag = event.tags.find((t) => t[0] === "e" && t[3] === "root");
if (rootTag && rootTag[2]) return rootTag[2];
const firstETag = event.tags.find((t) => t[0] === "e");
return firstETag?.[2] || undefined;
const firstETag = event.tags.find((t) => t[0] === "e");
return firstETag?.[2] || undefined;
});
}
/**