From 2108803b0c999993a24071978094a95c75c74793 Mon Sep 17 00:00:00 2001 From: Steven Luscher Date: Mon, 2 May 2022 12:21:13 -0700 Subject: [PATCH] feat: you can now deep link to a particular instruction in Explorer (#24861) * fix: when creating new cluster URLs, don't carry the fragment forward * feat: added a utility that computes a transaction's URL fragment * feat: introduced a React context you can use to scroll to elements * feat: you can now deep link to a particular transaction in Explorer --- .../src/components/ProgramLogsCardBody.tsx | 27 +++-- .../instruction/InstructionCard.tsx | 10 +- explorer/src/index.tsx | 41 ++++--- .../pages/inspector/InstructionsSection.tsx | 8 +- explorer/src/providers/scroll-anchor.tsx | 112 ++++++++++++++++++ explorer/src/scss/_solana.scss | 4 + .../get-instruction-card-scroll-anchor-id.ts | 7 ++ explorer/src/utils/url.ts | 1 + 8 files changed, 179 insertions(+), 31 deletions(-) create mode 100644 explorer/src/providers/scroll-anchor.tsx create mode 100644 explorer/src/utils/get-instruction-card-scroll-anchor-id.ts diff --git a/explorer/src/components/ProgramLogsCardBody.tsx b/explorer/src/components/ProgramLogsCardBody.tsx index faaf6ba0b4..fff28b8b8c 100644 --- a/explorer/src/components/ProgramLogsCardBody.tsx +++ b/explorer/src/components/ProgramLogsCardBody.tsx @@ -4,6 +4,8 @@ import { TableCardBody } from "components/common/TableCardBody"; import { InstructionLogs } from "utils/program-logs"; import { ProgramName } from "utils/anchor"; import React from "react"; +import { Link } from "react-router-dom"; +import getInstructionCardScrollAnchorId from "utils/get-instruction-card-scroll-anchor-id"; const NATIVE_PROGRAMS_MISSING_INVOKE_LOG: string[] = [ "AddressLookupTab1e1111111111111111111111111", @@ -62,17 +64,26 @@ export function ProgramLogsCardBody({ return ( -
+ ({ + ...location, + hash: `#${getInstructionCardScrollAnchorId([index + 1])}`, + })} + > #{index + 1} - {" "} - Instruction -
+ + {" "} + Instruction + + + {programLogs && (
{programLogs.logs.map((log, key) => { diff --git a/explorer/src/components/instruction/InstructionCard.tsx b/explorer/src/components/instruction/InstructionCard.tsx index 3ed6d5fcf0..cce3f73946 100644 --- a/explorer/src/components/instruction/InstructionCard.tsx +++ b/explorer/src/components/instruction/InstructionCard.tsx @@ -12,6 +12,8 @@ import { useRawTransactionDetails, } from "providers/transactions/raw"; import { Address } from "components/common/Address"; +import { useScrollAnchor } from "providers/scroll-anchor"; +import getInstructionCardScrollAnchorId from "utils/get-instruction-card-scroll-anchor-id"; type InstructionProps = { title: string; @@ -54,9 +56,13 @@ export function InstructionCard({ return setShowRaw((r) => !r); }; - + const scrollAnchorRef = useScrollAnchor( + getInstructionCardScrollAnchorId( + childIndex != null ? [index + 1, childIndex] : [index + 1] + ) + ); return ( -
+

diff --git a/explorer/src/index.tsx b/explorer/src/index.tsx index bad6b4e46b..b3ef4b8a2d 100644 --- a/explorer/src/index.tsx +++ b/explorer/src/index.tsx @@ -10,6 +10,7 @@ import { TransactionsProvider } from "./providers/transactions"; import { AccountsProvider } from "./providers/accounts"; import { BlockProvider } from "./providers/block"; import { EpochProvider } from "./providers/epoch"; +import { ScrollAnchorProvider } from "providers/scroll-anchor"; import { StatsProvider } from "providers/stats"; import { MintsProvider } from "providers/mints"; @@ -22,24 +23,26 @@ if (process.env.NODE_ENV === "production") { const root = createRoot(document.getElementById("root")!); root.render( - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + ); diff --git a/explorer/src/pages/inspector/InstructionsSection.tsx b/explorer/src/pages/inspector/InstructionsSection.tsx index c33050437b..baba3a012b 100644 --- a/explorer/src/pages/inspector/InstructionsSection.tsx +++ b/explorer/src/pages/inspector/InstructionsSection.tsx @@ -6,6 +6,8 @@ import { AddressWithContext, programValidator } from "./AddressWithContext"; import { useCluster } from "providers/cluster"; import { getProgramName } from "utils/tx"; import { HexData } from "components/common/HexData"; +import getInstructionCardScrollAnchorId from "utils/get-instruction-card-scroll-anchor-id"; +import { useScrollAnchor } from "providers/scroll-anchor"; export function InstructionsSection({ message }: { message: Message }) { return ( @@ -30,9 +32,11 @@ function InstructionCard({ const { cluster } = useCluster(); const programId = message.accountKeys[ix.programIdIndex]; const programName = getProgramName(programId.toBase58(), cluster); - + const scrollAnchorRef = useScrollAnchor( + getInstructionCardScrollAnchorId([index + 1]) + ); return ( -
+

#{index + 1} diff --git a/explorer/src/providers/scroll-anchor.tsx b/explorer/src/providers/scroll-anchor.tsx new file mode 100644 index 0000000000..843fd44812 --- /dev/null +++ b/explorer/src/providers/scroll-anchor.tsx @@ -0,0 +1,112 @@ +import React, { + createContext, + ReactNode, + RefCallback, + useCallback, + useContext, + useEffect, + useRef, +} from "react"; +import { useLocation } from "react-router-dom"; + +type URLFragment = string; + +type RegisterScrollAnchorFn = (key: string, element: HTMLElement) => void; + +const ScrollAnchorContext = createContext( + typeof WeakRef !== "undefined" + ? (key: string) => { + console.warn( + `Ignoring registration of scroll anchor for key \`${key}\`.` + + "Did you forget to wrap your app in a `ScrollAnchorProvider`?" + ); + } + : // This entire implementation gets disabled if WeakRef is not supported + () => {} +); + +export const ScrollAnchorProvider = + typeof WeakRef !== "undefined" + ? function ScrollAnchorProvider({ children }: { children: ReactNode }) { + const location = useLocation(); + const registeredScrollTargets = useRef<{ + [fragment: URLFragment]: WeakRef[] | undefined; + }>({}); + const scrollEnabled = useRef(false); + const maybeScroll = useCallback(() => { + if (scrollEnabled.current === false) { + return; + } + const targetsByFragment = registeredScrollTargets.current; + const fragment = location.hash.replace(/^#/, ""); + const targets = targetsByFragment[fragment]; + if (!targets) { + return; + } + targets.some((targetWeakRef) => { + const target = targetWeakRef.deref(); + if (!target) { + return false; + } + scrollEnabled.current = false; + target.scrollIntoView(); + return true; + }); + }, [location.hash]); + const registerScrollAnchor = useCallback( + (fragment: string, element: HTMLElement) => { + const targetsByFragment = registeredScrollTargets.current; + const targets = (targetsByFragment[fragment] = + targetsByFragment[fragment] || []); + targets.unshift(new WeakRef(element)); + maybeScroll(); + }, + [maybeScroll] + ); + useEffect(() => { + let distanceScrolled = 0; + let lastKnownScrollPosition = window.scrollY; + const handleScroll = () => { + const currentScrollPosition = window.scrollY; + distanceScrolled += Math.abs( + lastKnownScrollPosition - currentScrollPosition + ); + lastKnownScrollPosition = currentScrollPosition; + if (distanceScrolled > 44) { + // If the user has scrolled the page while waiting for the target + // to appear during initial load, we do not want to steal control + // away from them. + scrollEnabled.current = false; + window.removeEventListener("scroll", handleScroll); + } + }; + window.addEventListener("scroll", handleScroll, { passive: true }); + return () => { + window.removeEventListener("scroll", handleScroll); + }; + }, [location]); + useEffect(() => { + scrollEnabled.current = true; + maybeScroll(); + }, [location, maybeScroll]); + return ( + + {children} + + ); + } + : // This entire implementation gets disabled if WeakRef is not supported + React.Fragment; + +export function useScrollAnchor(key: URLFragment): RefCallback { + const registerScrollTarget = useContext(ScrollAnchorContext); + return useCallback( + (instance) => { + if (!instance) { + return; + } + registerScrollTarget(key, instance); + }, + [key, registerScrollTarget] + ); +} diff --git a/explorer/src/scss/_solana.scss b/explorer/src/scss/_solana.scss index 75321c76e3..ef7356f1e8 100644 --- a/explorer/src/scss/_solana.scss +++ b/explorer/src/scss/_solana.scss @@ -373,6 +373,10 @@ pre.json-wrap { max-width: 36rem; } +.program-log-instruction-name { + color: $white; +} + .staking-card { h1 { margin-bottom: 0.75rem; diff --git a/explorer/src/utils/get-instruction-card-scroll-anchor-id.ts b/explorer/src/utils/get-instruction-card-scroll-anchor-id.ts new file mode 100644 index 0000000000..553935996e --- /dev/null +++ b/explorer/src/utils/get-instruction-card-scroll-anchor-id.ts @@ -0,0 +1,7 @@ +export default function getInstructionCardScrollAnchorId( + // An array of instruction sequence numbers, starting with the + // top level instruction number. Instruction numbers start from 1. + instructionNumberPath: number[] +): string { + return `ix-${instructionNumberPath.join("-")}`; +} diff --git a/explorer/src/utils/url.ts b/explorer/src/utils/url.ts index 8dc658f967..3239f7cb4a 100644 --- a/explorer/src/utils/url.ts +++ b/explorer/src/utils/url.ts @@ -27,6 +27,7 @@ export function pickClusterParams( return { ...location, + hash: "", search: newParams.toString(), }; }