diff --git a/CLAUDE.md b/CLAUDE.md index 8c0449a..3a668ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/src/lib/nip34-helpers.ts b/src/lib/nip34-helpers.ts index 837d9b0..9eab3aa 100644 --- a/src/lib/nip34-helpers.ts +++ b/src/lib/nip34-helpers.ts @@ -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; + }); } /**