feat(app): add Share button with compact shareable links#2665
Conversation
Adds a Share button on search, Chart Explorer, dashboards, and the row/session side panels that copies a shareable link to the clipboard. Large view state (e.g. Chart Explorer) is compressed into a single `?share=` token via the browser-native CompressionStream (deflate-raw + base64url) so links are far shorter than the raw query-param URL; small links (path-identified dashboards) stay plain. A global hook expands `?share=` tokens back to normal params on load. Includes unit tests, a changeset, and end-to-end Playwright coverage.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🟡 Tier 3 — StandardIntroduces new logic, modifies core functionality, or touches areas with non-trivial risk. Why this tier:
Review process: Full human review — logic, architecture, edge cases. Stats
|
Deep Review✅ No critical issues found. No P0/P1 ship-blockers: the feature adds no server-side surface, 🟡 P2 -- recommended
🔵 P3 nitpicks (4)
Reviewers (11): correctness, testing, maintainability, project-standards, agent-native, learnings, security, reliability, adversarial, kieran-typescript, julik-frontend-races. Testing gaps: No test exercises a maliciously high-ratio deflate token to confirm the decode path is memory-bounded; the |
Greptile SummaryThis PR adds a Share button to the Search, Chart Explorer, Dashboard, and row/session side panels that copies a compact, shareable URL to the clipboard. Large view state (e.g. Chart Explorer's
Confidence Score: 4/5Safe to merge with minor follow-up recommended for the two side panels. The core compression/decompression pipeline, URL building, and expansion hook are well-designed and covered by unit and E2E tests. The two side panels (session and row) omit the freezeTimeRange step that all other Share surfaces apply, so shared links from those panels can embed a relative time window that resolves to a different data set for the recipient. SessionSidePanel.tsx and components/DBRowSidePanel.tsx — both call buildShareUrl without first calling freezeTimeRange, leaving relative time params in the shared URL. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant User
participant ShareBtn as ShareLinkButton / panel
participant SL as shareLink.ts
participant Clip as Clipboard
User->>ShareBtn: click Share
ShareBtn->>SL: getShareSearch() freezeTimeRange(search, timeRange)
ShareBtn->>SL: buildShareUrl(search)
SL->>SL: cleanSearch drop empty/dup params
alt query large enough to benefit
SL->>SL: encodeShareToken CompressionStream base64url
SL-->>ShareBtn: "origin/path?share=token"
else query small
SL-->>ShareBtn: origin/path?query
end
ShareBtn->>Clip: copyTextToClipboard(url)
ShareBtn-->>User: Copied shareable link toast
Note over User: Recipient opens link
participant App as _app.tsx AppContent
participant Hook as useExpandShareLink
participant Router as Next.js router
App->>Hook: useExpandShareLink() on mount
Hook->>Hook: read share param from window.location.search
alt token present
Hook->>SL: decodeShareToken(token)
SL->>SL: DecompressionStream deflate-raw
SL-->>Hook: expanded query string
Hook->>Router: router.replace(path?expanded, shallow)
Router-->>App: nuqs hooks re-read normal params
else no token
Hook-->>App: no-op
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant User
participant ShareBtn as ShareLinkButton / panel
participant SL as shareLink.ts
participant Clip as Clipboard
User->>ShareBtn: click Share
ShareBtn->>SL: getShareSearch() freezeTimeRange(search, timeRange)
ShareBtn->>SL: buildShareUrl(search)
SL->>SL: cleanSearch drop empty/dup params
alt query large enough to benefit
SL->>SL: encodeShareToken CompressionStream base64url
SL-->>ShareBtn: "origin/path?share=token"
else query small
SL-->>ShareBtn: origin/path?query
end
ShareBtn->>Clip: copyTextToClipboard(url)
ShareBtn-->>User: Copied shareable link toast
Note over User: Recipient opens link
participant App as _app.tsx AppContent
participant Hook as useExpandShareLink
participant Router as Next.js router
App->>Hook: useExpandShareLink() on mount
Hook->>Hook: read share param from window.location.search
alt token present
Hook->>SL: decodeShareToken(token)
SL->>SL: DecompressionStream deflate-raw
SL-->>Hook: expanded query string
Hook->>Router: router.replace(path?expanded, shallow)
Router-->>App: nuqs hooks re-read normal params
else no token
Hook-->>App: no-op
end
|
| const handleShareCopy = useCallback(async () => { | ||
| const ok = await copyTextToClipboard( | ||
| await buildShareUrl(window.location.search), | ||
| ); | ||
| if (!ok) { | ||
| notifications.show({ color: 'red', message: CLIPBOARD_ERROR_MESSAGE }); | ||
| return; | ||
| } | ||
| setShareCopied(true); | ||
| setTimeout(() => setShareCopied(false), 2000); | ||
| }, []); |
There was a problem hiding this comment.
Row side panel also skips
freezeTimeRange
Like SessionSidePanel, handleShareCopy passes window.location.search directly to buildShareUrl without first calling freezeTimeRange. If the current URL carries a relative time query (tq=...), the shared link will re-evaluate that range against the recipient's clock. The Search, Chart, and Dashboard share buttons all pin absolute from/to and strip tq before building the URL; this panel should do the same for consistency.
| useEffect(() => { | ||
| if (handledRef.current) { | ||
| return; | ||
| } | ||
|
|
||
| const token = new URLSearchParams(window.location.search).get(SHARE_PARAM); | ||
| if (!token) { | ||
| return; | ||
| } | ||
| handledRef.current = true; | ||
|
|
||
| let cancelled = false; | ||
| void (async () => { | ||
| const expanded = await decodeShareToken(token); | ||
| if (cancelled) { | ||
| return; | ||
| } | ||
| if (expanded == null) { | ||
| notifications.show({ | ||
| color: 'red', | ||
| message: 'This shared link is invalid or corrupted.', | ||
| }); | ||
| // Strip the bad token; render from whatever real params remain. | ||
| router.replace(window.location.pathname, undefined, { shallow: true }); | ||
| return; | ||
| } | ||
| router.replace(`${window.location.pathname}?${expanded}`, undefined, { | ||
| shallow: true, | ||
| }); | ||
| })(); | ||
|
|
||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| // Run once on mount: window.location is the source of truth for the token. | ||
| // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| }, []); |
There was a problem hiding this comment.
handledRef blocks client-side re-expansion across page transitions
handledRef.current is set to true the moment a token is detected and never reset. AppContent (where this hook lives) is mounted once for the lifetime of the Next.js app and is not torn down on client-side navigation. If a user has the app open, navigates to a second ?share=… URL via router.push or a <Link> click (without a full page reload), the effect's early-return guard fires and the second token is never expanded. Real-world impact is low because share links are normally opened in a new tab, but the behavior is surprising if anyone creates an in-app link to a share URL.
E2E Test Results✅ All tests passed • 245 passed • 1 skipped • 1064s
Tests ran across 4 shards in parallel. |
Summary
Adds a Share button — on search, Chart Explorer, dashboards, and the row/session side panels — that copies a shareable link to the clipboard. HyperDX serializes the entire view state into the URL, which produces very long, double-percent-encoded links; this button collapses large state into a single compressed
?share=token (using the browser-nativeCompressionStream— raw DEFLATE + base64url, so no new dependency), while small path-identified views (e.g. a dashboard) stay a plain URL so links are never made longer. A global hook in_appexpands?share=tokens back into normal query params on load, so every existinguseQueryStatebinding works unchanged and old long links keep working. It's covered by unit tests (encode/decode round-trip, URL-safety, compressed-vs-plain selection, empty/duplicate-param cleaning) and five end-to-end Playwright tests spanning all surfaces, plus a changeset.Screenshots or video
New "Share" button in the search / Chart Explorer / dashboard toolbars and in the row/session side-panel headers; it copies a link and shows a "Copied shareable link to clipboard" toast. (No before state — new feature. Static screenshots not captured in this run; behavior is exercised by the preview steps below and the E2E suite.)
How to test on Vercel preview
Preview routes: /search, /chart
Steps:
References