From e5ead50a3d0f74b7e0cacf6e3f8a61ba25ff1d51 Mon Sep 17 00:00:00 2001 From: vimzh Date: Sun, 24 May 2026 11:46:19 +0530 Subject: [PATCH 1/5] fix(tweet-preview): guard JSON.parse to avoid crashing on malformed tweet data TweetPreview was passing the result of `JSON.parse(data)` straight into `enrichTweet` whenever `data` was a string. If a document's stored tweet metadata was ever a non-JSON string, truncated JSON, or `"null"` / `"123"`, the unguarded parse threw and crashed the React tree rendering the document modal or memory card. Extract the parse into a `parseTweetData` helper that returns `null` when the input is missing, fails to parse, or doesn't resolve to an object. TweetPreview now renders nothing in those cases instead of crashing. Also widen the prop type from `Tweet` to `Tweet | string` to reflect the runtime input both call sites already pass. --- .../components/document-cards/tweet-preview.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/web/components/document-cards/tweet-preview.tsx b/apps/web/components/document-cards/tweet-preview.tsx index 807ba5b9a..894d4d12c 100644 --- a/apps/web/components/document-cards/tweet-preview.tsx +++ b/apps/web/components/document-cards/tweet-preview.tsx @@ -117,14 +117,26 @@ function CustomTweetMedia({ ) } +function parseTweetData(data: Tweet | string): Tweet | null { + if (!data) return null + if (typeof data !== "string") return data + try { + const parsed = JSON.parse(data) + return parsed && typeof parsed === "object" ? (parsed as Tweet) : null + } catch { + return null + } +} + export function TweetPreview({ data, noBgColor, }: { - data: Tweet + data: Tweet | string noBgColor?: boolean }) { - const parsedTweet = typeof data === "string" ? JSON.parse(data) : data + const parsedTweet = parseTweetData(data) + if (!parsedTweet) return null const tweet = enrichTweet(parsedTweet) return ( From 0944e5fb33bbd5be645b884a50147470cc18e239 Mon Sep 17 00:00:00 2001 From: vimzh Date: Sun, 24 May 2026 11:54:53 +0530 Subject: [PATCH 2/5] fix(tweet-preview): reject array inputs in parseTweetData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous guard accepted any non-null `typeof === "object"`, which means `JSON.parse("[]")` or `JSON.parse("[1,2,3]")` slipped through and reached `enrichTweet` — exactly the class of crash the helper was added to prevent. Add an `Array.isArray` check so only plain objects are passed downstream. --- apps/web/components/document-cards/tweet-preview.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/components/document-cards/tweet-preview.tsx b/apps/web/components/document-cards/tweet-preview.tsx index 894d4d12c..984b469c6 100644 --- a/apps/web/components/document-cards/tweet-preview.tsx +++ b/apps/web/components/document-cards/tweet-preview.tsx @@ -122,7 +122,9 @@ function parseTweetData(data: Tweet | string): Tweet | null { if (typeof data !== "string") return data try { const parsed = JSON.parse(data) - return parsed && typeof parsed === "object" ? (parsed as Tweet) : null + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Tweet) + : null } catch { return null } From 3fe7eb83d190d4988771f62e7b1623d5f6dbe070 Mon Sep 17 00:00:00 2001 From: vimzh Date: Sun, 24 May 2026 12:44:28 +0530 Subject: [PATCH 3/5] fix(tweet-preview): shape-check, fallback UI, memoize, and warn on bad data Copilot review flagged three things: 1. Casting an arbitrary parsed object to Tweet without validating shape can still crash inside enrichTweet. Added an isTweetLike type guard that requires the 'user' field, which both CustomTweetHeader and the downstream enrichTweet expect. 2. Returning null on bad data silently hid the failure. Now render a small 'Tweet preview unavailable' fallback (matching the style of the existing fallback in document-modal/content/tweet.tsx) and emit a console.warn so failures show up during debugging. 3. JSON.parse was running on every render. Wrapped in useMemo keyed by the data prop so reparsing only happens when the input changes. --- .../document-cards/tweet-preview.tsx | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/apps/web/components/document-cards/tweet-preview.tsx b/apps/web/components/document-cards/tweet-preview.tsx index 984b469c6..77e90971c 100644 --- a/apps/web/components/document-cards/tweet-preview.tsx +++ b/apps/web/components/document-cards/tweet-preview.tsx @@ -1,6 +1,6 @@ "use client" -import { Suspense } from "react" +import { Suspense, useMemo } from "react" import type { Tweet } from "react-tweet/api" import { TweetBody, enrichTweet, TweetSkeleton } from "react-tweet" import { cn } from "@lib/utils" @@ -117,19 +117,43 @@ function CustomTweetMedia({ ) } +function isTweetLike(value: unknown): value is Tweet { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "user" in value + ) +} + function parseTweetData(data: Tweet | string): Tweet | null { if (!data) return null - if (typeof data !== "string") return data + if (typeof data !== "string") return isTweetLike(data) ? data : null try { - const parsed = JSON.parse(data) - return parsed && typeof parsed === "object" && !Array.isArray(parsed) - ? (parsed as Tweet) - : null - } catch { + const parsed: unknown = JSON.parse(data) + if (isTweetLike(parsed)) return parsed + console.warn("TweetPreview: parsed value did not match Tweet shape") + return null + } catch (error) { + console.warn("TweetPreview: failed to parse tweet data", error) return null } } +function TweetPreviewFallback({ noBgColor }: { noBgColor?: boolean }) { + return ( +
+ Tweet preview unavailable +
+ ) +} + export function TweetPreview({ data, noBgColor, @@ -137,8 +161,8 @@ export function TweetPreview({ data: Tweet | string noBgColor?: boolean }) { - const parsedTweet = parseTweetData(data) - if (!parsedTweet) return null + const parsedTweet = useMemo(() => parseTweetData(data), [data]) + if (!parsedTweet) return const tweet = enrichTweet(parsedTweet) return ( From e7684c1e54b1871af14c01745bf295f85cda09bb Mon Sep 17 00:00:00 2001 From: vimzh Date: Wed, 27 May 2026 21:54:03 +0530 Subject: [PATCH 4/5] fix(tweet-preview): tighten Tweet shape check to fields enrichTweet reads Review feedback: the previous isTweetLike() only checked for a 'user' key, so shapes like {"user": null}, {"user": {}}, or {"user": {}, "text": null} still passed the guard and then crashed inside enrichTweet, which reads user.screen_name, text, display_text_range, and entities.*. Validate those four fields up front so anything missing or of the wrong type falls back to the 'Tweet preview unavailable' UI instead of throwing during render. --- .../document-cards/tweet-preview.tsx | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/web/components/document-cards/tweet-preview.tsx b/apps/web/components/document-cards/tweet-preview.tsx index 77e90971c..6edfee5e5 100644 --- a/apps/web/components/document-cards/tweet-preview.tsx +++ b/apps/web/components/document-cards/tweet-preview.tsx @@ -118,12 +118,30 @@ function CustomTweetMedia({ } function isTweetLike(value: unknown): value is Tweet { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - "user" in value - ) + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false + } + const candidate = value as Record + const user = candidate.user + if ( + typeof user !== "object" || + user === null || + Array.isArray(user) || + typeof (user as { screen_name?: unknown }).screen_name !== "string" + ) { + return false + } + if (typeof candidate.text !== "string") return false + if (!Array.isArray(candidate.display_text_range)) return false + const entities = candidate.entities + if ( + typeof entities !== "object" || + entities === null || + Array.isArray(entities) + ) { + return false + } + return true } function parseTweetData(data: Tweet | string): Tweet | null { From 162e1d3779681eed4b615769eb13315cd2faba99 Mon Sep 17 00:00:00 2001 From: vimzh Date: Tue, 2 Jun 2026 03:13:58 +0530 Subject: [PATCH 5/5] fix(tweet-preview): default entity arrays and guard enrichTweet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isTweetLike validated the top-level shape, but enrichTweet iterates entities.hashtags, .user_mentions, .urls and .symbols with for...of, so a payload like {"user":{...},"text":"hi","display_text_range":[0,2],"entities":{}} passed the guard and then threw "not iterable" during render. Default those four required entity arrays to [] before enriching (real syndication payloads always include them, sometimes empty), and wrap the enrichTweet call in try/catch so the remaining shape-dependent paths it walks — quoted tweets, media entities, and per-entity indices — degrade to the existing "Tweet preview unavailable" fallback instead of crashing the modal. Parse and enrich now share a single useMemo so neither reruns on unrelated renders. --- .../document-cards/tweet-preview.tsx | 60 +++++++++++++++---- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/apps/web/components/document-cards/tweet-preview.tsx b/apps/web/components/document-cards/tweet-preview.tsx index 6edfee5e5..ce2d29c2a 100644 --- a/apps/web/components/document-cards/tweet-preview.tsx +++ b/apps/web/components/document-cards/tweet-preview.tsx @@ -144,18 +144,46 @@ function isTweetLike(value: unknown): value is Tweet { return true } +function ensureArray(value: unknown): T[] { + return Array.isArray(value) ? (value as T[]) : [] +} + +// enrichTweet iterates entities.hashtags, .user_mentions, .urls and .symbols +// with `for...of`, so any of them being absent throws "not iterable". A real +// syndication payload always carries the four arrays (often empty), so default +// them before the tweet reaches enrichTweet. `media` is intentionally left +// untouched: enrichTweet only reads it when truthy and indexes media[0], so an +// empty array would crash — the enrich-time guard below covers that case. +function normalizeTweet(tweet: Tweet): Tweet { + const entities = tweet.entities as unknown as Record + return { + ...tweet, + entities: { + ...tweet.entities, + hashtags: ensureArray(entities.hashtags), + user_mentions: ensureArray(entities.user_mentions), + urls: ensureArray(entities.urls), + symbols: ensureArray(entities.symbols), + }, + } +} + function parseTweetData(data: Tweet | string): Tweet | null { if (!data) return null - if (typeof data !== "string") return isTweetLike(data) ? data : null - try { - const parsed: unknown = JSON.parse(data) - if (isTweetLike(parsed)) return parsed + let value: unknown = data + if (typeof data === "string") { + try { + value = JSON.parse(data) + } catch (error) { + console.warn("TweetPreview: failed to parse tweet data", error) + return null + } + } + if (!isTweetLike(value)) { console.warn("TweetPreview: parsed value did not match Tweet shape") return null - } catch (error) { - console.warn("TweetPreview: failed to parse tweet data", error) - return null } + return normalizeTweet(value) } function TweetPreviewFallback({ noBgColor }: { noBgColor?: boolean }) { @@ -179,9 +207,21 @@ export function TweetPreview({ data: Tweet | string noBgColor?: boolean }) { - const parsedTweet = useMemo(() => parseTweetData(data), [data]) - if (!parsedTweet) return - const tweet = enrichTweet(parsedTweet) + const tweet = useMemo(() => { + const parsed = parseTweetData(data) + if (!parsed) return null + try { + return enrichTweet(parsed) + } catch (error) { + // enrichTweet still walks quoted tweets, media entities and per-entity + // indices, so a partially-malformed payload can throw here even after + // the shape check. Degrade to the fallback instead of crashing render. + console.warn("TweetPreview: failed to enrich tweet data", error) + return null + } + }, [data]) + + if (!tweet) return return (