Create your Link in Bio for Bluesky

Fix umami tracking execution order on 404 page (#210)

Co-authored-by: Claude <noreply@anthropic.com>

authored by mkizka.dev

Claude and committed by
GitHub
06cab58d 7d324158

+92 -28
+5 -3
app/components/error-boundary/index.tsx
··· 4 4 5 5 import { Card } from "~/components/card"; 6 6 import { Main, RootLayout } from "~/components/layout"; 7 + import { useUmami } from "~/hooks/useUmami"; 7 8 import { createLogger } from "~/utils/logger"; 8 9 9 10 // https://unsplash.com/ja/%E5%86%99%E7%9C%9F/%E3%82%B7%E3%83%A7%E3%83%BC%E3%83%88%E3%82%B3%E3%83%BC%E3%83%88%E3%81%AE%E7%99%BD%E3%81%A8%E9%BB%84%E8%A4%90%E8%89%B2%E3%81%AE%E7%8A%AC-BrtCGcrZd10 ··· 51 52 const { t } = useTranslation(); 52 53 const error = useRouteError(); 53 54 const notFound = isRouteErrorResponse(error) && error.status === 404; 55 + const umami = useUmami(); 54 56 55 57 useEffect(() => { 56 58 if (notFound) { 57 - void umami.track("show-404-page", { 59 + umami.track("show-404-page", { 58 60 path: location.pathname, 59 61 }); 60 62 } else { 61 - void umami.track("show-error-page", { 63 + umami.track("show-error-page", { 62 64 path: location.pathname, 63 65 message: error instanceof Error ? error.message : String(error), 64 66 }); 65 67 } 66 - }, [error, notFound]); 68 + }, [error, notFound, umami]); 67 69 68 70 if (notFound) { 69 71 return (
+3 -1
app/components/layout.tsx
··· 4 4 import { useTranslation } from "react-i18next"; 5 5 import { Form, Link } from "react-router"; 6 6 7 + import { useUmami } from "~/hooks/useUmami"; 7 8 import { cn } from "~/utils/cn"; 8 9 9 10 import { BlueskyIcon } from "./icons/bluesky"; ··· 15 16 16 17 export function Header({ isLogin }: HeaderProps) { 17 18 const detailsRef = useRef<HTMLDetailsElement>(null); 19 + const umami = useUmami(); 18 20 const handleClick = () => { 19 21 if (detailsRef.current) { 20 22 detailsRef.current.removeAttribute("open"); ··· 38 40 ) { 39 41 return; 40 42 } 41 - void umami.track("click-header-lang", { 43 + umami.track("click-header-lang", { 42 44 action: "open", 43 45 }); 44 46 }}
+3 -1
app/features/board/card/profile-card.tsx
··· 8 8 import { Button } from "~/components/button"; 9 9 import { Card } from "~/components/card"; 10 10 import { BlueskyIcon } from "~/components/icons/bluesky"; 11 + import { useUmami } from "~/hooks/useUmami"; 11 12 12 13 function Avatar({ avatar }: { avatar: string }) { 13 14 return ( ··· 38 39 export function ProfileCard({ user, url, showEditButton }: ProfileCardProps) { 39 40 const [loading, setLoading] = useState(false); 40 41 const { t } = useTranslation(); 42 + const umami = useUmami(); 41 43 const shareText = t("profile-card.share-text", { 42 44 url, 43 45 displayName: user.displayName, ··· 53 55 ); 54 56 setLoading(false); 55 57 56 - void umami.track("click-share-link"); 58 + umami.track("click-share-link"); 57 59 }; 58 60 59 61 return (
+3 -1
app/features/board/share-modal.tsx
··· 8 8 9 9 import { Button } from "~/components/button"; 10 10 import { BlueskyIcon } from "~/components/icons/bluesky"; 11 + import { useUmami } from "~/hooks/useUmami"; 11 12 12 13 const SHARE_MODAL_ID = "share-modal"; 13 14 ··· 23 24 const [copied, setCopied] = useState(false); 24 25 const [loading, setLoading] = useState(false); 25 26 const shareText = t("share-modal.share-text", { url }); 27 + const umami = useUmami(); 26 28 27 29 useEffect(() => { 28 30 if (searchParams.has("success")) { ··· 43 45 44 46 const trackShareModal = (action: string) => { 45 47 if (!handledRef.current) { 46 - void umami.track("handle-share-modal", { 48 + umami.track("handle-share-modal", { 47 49 action, 48 50 }); 49 51 handledRef.current = true;
+3 -1
app/features/settings/delete-button.tsx
··· 2 2 import { Form, useSubmit } from "react-router"; 3 3 4 4 import { Button } from "~/components/button"; 5 + import { useUmami } from "~/hooks/useUmami"; 5 6 6 7 type Props = { 7 8 handle: string; ··· 10 11 export function DeleteBoardButton({ handle }: Props) { 11 12 const { t } = useTranslation(); 12 13 const submit = useSubmit(); 14 + const umami = useUmami(); 13 15 14 16 const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { 15 17 event.preventDefault(); ··· 17 19 if (ok) { 18 20 void submit(event.currentTarget); 19 21 } 20 - void umami.track("handle-delete-board", { 22 + umami.track("handle-delete-board", { 21 23 action: ok ? "confirm" : "cancel", 22 24 handle, 23 25 });
+3 -1
app/features/settings/logout-button.tsx
··· 2 2 import { Form, useSubmit } from "react-router"; 3 3 4 4 import { Button } from "~/components/button"; 5 + import { useUmami } from "~/hooks/useUmami"; 5 6 6 7 export function LogoutButton() { 7 8 const { t } = useTranslation(); 8 9 const submit = useSubmit(); 10 + const umami = useUmami(); 9 11 10 12 const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => { 11 13 event.preventDefault(); ··· 13 15 if (ok) { 14 16 void submit(event.currentTarget); 15 17 } 16 - void umami.track("handle-logout", { 18 + umami.track("handle-logout", { 17 19 action: ok ? "confirm" : "cancel", 18 20 }); 19 21 };
+61
app/hooks/useUmami.tsx
··· 1 + import { 2 + createContext, 3 + type ReactNode, 4 + useCallback, 5 + useContext, 6 + useEffect, 7 + useRef, 8 + } from "react"; 9 + 10 + type UmamiTrackFn = ( 11 + eventName: string, 12 + eventData?: Record<string, unknown>, 13 + ) => void; 14 + 15 + interface UmamiContextValue { 16 + track: UmamiTrackFn; 17 + } 18 + 19 + const UmamiContext = createContext<UmamiContextValue | null>(null); 20 + 21 + export function UmamiProvider({ children }: { children: ReactNode }) { 22 + const isLoadedRef = useRef(false); 23 + const queueRef = useRef<Array<[string, Record<string, unknown>?]>>([]); 24 + 25 + const track: UmamiTrackFn = useCallback((eventName, eventData) => { 26 + if (isLoadedRef.current) { 27 + void window.umami.track(eventName, eventData); 28 + } else { 29 + queueRef.current.push([eventName, eventData]); 30 + } 31 + }, []); 32 + 33 + useEffect(() => { 34 + const checkInterval = setInterval(() => { 35 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition 36 + if (window.umami && "track" in window.umami) { 37 + isLoadedRef.current = true; 38 + clearInterval(checkInterval); 39 + 40 + queueRef.current.forEach(([eventName, eventData]) => { 41 + void window.umami.track(eventName, eventData); 42 + }); 43 + queueRef.current = []; 44 + } 45 + }, 100); 46 + 47 + return () => clearInterval(checkInterval); 48 + }, []); 49 + 50 + return ( 51 + <UmamiContext.Provider value={{ track }}>{children}</UmamiContext.Provider> 52 + ); 53 + } 54 + 55 + export function useUmami() { 56 + const context = useContext(UmamiContext); 57 + if (!context) { 58 + throw new Error("useUmami must be used within UmamiProvider"); 59 + } 60 + return context; 61 + }
+5 -16
app/root.tsx
··· 15 15 16 16 import type { Route } from "./+types/root"; 17 17 import { Toaster } from "./features/toast/toaster"; 18 + import { UmamiProvider } from "./hooks/useUmami"; 18 19 import { i18nServer, localeCookie } from "./i18n/i18n"; 19 20 import { env } from "./utils/env"; 20 21 ··· 37 38 ); 38 39 } 39 40 40 - const umamiPlaceholderScript = `\ 41 - if (typeof window !== 'undefined' && !window.umami) { 42 - window.umami = { 43 - track: function(...args) { 44 - console.warn('Umami not loaded yet', args); 45 - } 46 - }; 47 - }`; 48 - 49 41 export function Layout({ children }: { children: React.ReactNode }) { 50 42 const loaderData = useRouteLoaderData<typeof loader>("root"); 51 43 return ( 52 44 <html lang={loaderData?.locale ?? "en"} className="font-murecho"> 53 45 <head> 54 - <script 55 - dangerouslySetInnerHTML={{ 56 - __html: umamiPlaceholderScript, 57 - }} 58 - /> 59 46 {loaderData?.umami.scriptUrl && loaderData.umami.websiteId && ( 60 47 <script 61 48 defer ··· 79 66 <Links /> 80 67 </head> 81 68 <body className="flex h-fit min-h-svh flex-col bg-base-300"> 82 - {children} 83 - <Toaster /> 69 + <UmamiProvider> 70 + {children} 71 + <Toaster /> 72 + </UmamiProvider> 84 73 <ScrollRestoration /> 85 74 <Scripts /> 86 75 <script async src="https://embed.bsky.app/static/embed.js"></script>
+6 -4
app/routes/edit.tsx
··· 5 5 import { Main } from "~/components/layout"; 6 6 import { BoardViewer } from "~/features/board/board-viewer"; 7 7 import { RouteToaster } from "~/features/toast/route"; 8 + import { useUmami } from "~/hooks/useUmami"; 8 9 import { i18nServer } from "~/i18n/i18n"; 9 10 import { boardScheme } from "~/models/board"; 10 11 import { getSessionAgent, getSessionUser } from "~/server/oauth/session"; ··· 58 59 export default function Index({ loaderData }: Route.ComponentProps) { 59 60 const { user, board, url } = loaderData; 60 61 const { t } = useTranslation(); 62 + const umami = useUmami(); 61 63 62 64 // 更新ボタンを押したりしたときに確認ダイアログを出す 63 65 useBeforeUnload((event) => { 64 - void umami.track("unload-edit"); 66 + umami.track("unload-edit"); 65 67 event.preventDefault(); 66 68 }); 67 69 ··· 79 81 useEffect(() => { 80 82 if (blocker.state !== "blocked") return; 81 83 if (confirm(t("edit.confirm-leave-message"))) { 82 - void umami.track("leave-edit", { 84 + umami.track("leave-edit", { 83 85 action: "confirm", 84 86 }); 85 87 blocker.proceed(); 86 88 } else { 87 - void umami.track("leave-edit", { 89 + umami.track("leave-edit", { 88 90 action: "cancel", 89 91 }); 90 92 blocker.reset(); 91 93 } 92 - }, [t, blocker]); 94 + }, [t, blocker, umami]); 93 95 94 96 return ( 95 97 <>