diff --git a/AGENTS.md b/AGENTS.md index df4b584b..1e41726f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,6 +188,10 @@ agent-browser screenshot /tmp/check.png # then Read the image to inspect it Add `--full` for the whole page. Check both light and dark mode, and a narrow viewport for anything layout-sensitive. If `agent-browser` isn't installed: `npm install -g agent-browser && agent-browser install`. +### Debug trace (UX jank) + +For jank (grid jumping, dialogs remounting, layout shifts), use the admin debug trace instead of guessing: open any page with `?debug=1` (admins; anyone in local dev) or toggle "Debug trace" in the admin account menu. A panel records a timeline of URL writes (with call stacks), React Query fetches/invalidations, masonry reflows (which frames moved, flashed blue), layout shifts (flashed red), slow frames, and dialog/grid mount/unmount/prop changes; "Copy" exports it as JSON. Code lives in `src/lib/debug/` + `src/components/debug/`. Instrument new suspect components with `useDebugLifecycle` / `debugTrace` (no-ops when tracing is off). When driving the app with `agent-browser`, read the trace via `window.__abodeDebugTrace.export().events` (and `.clear()` before reproducing) instead of the clipboard. + For automated tests, run from the `./app` directory: ```bash diff --git a/app/src/app/(app)/dashboard/_components/central-item-dialog.test.tsx b/app/src/app/(app)/dashboard/_components/central-item-dialog.test.tsx index 35bbb0f8..d46fc16c 100644 --- a/app/src/app/(app)/dashboard/_components/central-item-dialog.test.tsx +++ b/app/src/app/(app)/dashboard/_components/central-item-dialog.test.tsx @@ -1,6 +1,8 @@ import { fireEvent, render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { resetTrace } from "@/lib/debug/test-utils"; +import { getTraceEvents } from "@/lib/debug/trace"; import type { Item } from "@/lib/types/item"; import { CentralItemDialog } from "./central-item-dialog"; @@ -273,3 +275,29 @@ describe("CentralItemDialog", () => { expect(screen.queryByTestId("frame")).not.toBeInTheDocument(); }); }); + +describe("CentralItemDialog debug trace", () => { + beforeEach(() => resetTrace()); + afterEach(() => resetTrace({ enabled: false })); + + it("records the resolution source flipping when the open item leaves the list", () => { + dialogState = { openItemId: "a" }; + const { rerender } = renderDialog(); + // e.g. a refetch/search swap drops the open item from the loaded list + rerender( + {}} + canEdit + items={items.filter((item) => item.id !== "a")} + />, + ); + const change = getTraceEvents().find( + (event) => event.event === "CentralItemDialog:change", + ); + expect(change?.data).toMatchObject({ + source: "list → none", + hasContent: "true → false", + needsFetch: "false → true", + }); + }); +}); diff --git a/app/src/app/(app)/dashboard/_components/central-item-dialog.tsx b/app/src/app/(app)/dashboard/_components/central-item-dialog.tsx index 70a9de94..af48eb62 100644 --- a/app/src/app/(app)/dashboard/_components/central-item-dialog.tsx +++ b/app/src/app/(app)/dashboard/_components/central-item-dialog.tsx @@ -3,6 +3,8 @@ import { AnimatePresence } from "motion/react"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { debugTrace } from "@/lib/debug/trace"; +import { useDebugLifecycle } from "@/lib/debug/use-debug-lifecycle"; import { getProxyImageUrl } from "@/lib/image-url"; import { getItemDisplayName } from "@/lib/items/item-display-name"; import { useItem } from "@/lib/items/use-item"; @@ -108,12 +110,35 @@ export function CentralItemDialog({ const showSeed = !resolved && !!seed && seed.id === openItemId; const hasContent = resolved !== null || showSeed; + // Debug trace: which source resolved the open item, so a dialog that exits + // and re-enters shows the input that flipped (e.g. list → none mid-refetch) + useDebugLifecycle({ + name: "CentralItemDialog", + channel: "dialog", + watch: { + openItemId, + source: inList + ? "list" + : fromInitial + ? "initial" + : resolved + ? "fetched" + : showSeed + ? "seed" + : "none", + hasContent, + needsFetch, + fetchError: isError, + }, + }); + return ( {open && hasContent && ( { + debugTrace("dialog", "frame:onOpenChange", { next }); if (!next) closeItem?.(); }} > @@ -155,6 +180,11 @@ function CentralItemBody({ onItemRenamed: (itemId: string, title: string) => void; onItemDeleted?: (itemId: string) => void; }) { + useDebugLifecycle({ + name: "CentralItemBody", + channel: "dialog", + watch: { itemId: item.id, animateEntrance }, + }); const displayName = getItemDisplayName(item); // Local mirror so a rename shows in the dialog immediately; onItemRenamed // keeps the grid card in sync, and displayName re-derives on the next render. diff --git a/app/src/app/(app)/dashboard/item-card.tsx b/app/src/app/(app)/dashboard/item-card.tsx index 1d0021d2..0d49382b 100644 --- a/app/src/app/(app)/dashboard/item-card.tsx +++ b/app/src/app/(app)/dashboard/item-card.tsx @@ -1,6 +1,6 @@ "use client"; -import type { ProcessingStatus } from "@prisma/client"; +import type { ItemKind, ProcessingStatus } from "@prisma/client"; import { AlertCircle, BookOpen, @@ -30,7 +30,6 @@ import { useMotionValue, useTransform, } from "motion/react"; -import dynamic from "next/dynamic"; import Link from "next/link"; import posthog from "posthog-js"; import { type ReactNode, useEffect, useId, useRef, useState } from "react"; @@ -81,6 +80,8 @@ import { import { branchTitlePrefix } from "@/lib/branch-title"; import { copyToClipboard } from "@/lib/copy"; import { getCurrencySymbol } from "@/lib/currency"; +import { debugTrace } from "@/lib/debug/trace"; +import { useDebugLifecycle } from "@/lib/debug/use-debug-lifecycle"; import { gridCardStyle } from "@/lib/grid-styles"; import { decodeHtmlEntities } from "@/lib/html-metadata"; import { getProxyImageUrl } from "@/lib/image-url"; @@ -101,6 +102,7 @@ import { shouldCompleteSeeAiAnalysis, } from "@/lib/milestones/conditions"; import { getPlatformName } from "@/lib/platforms"; +import { preloadableView } from "@/lib/preloadable-view"; import { useSearch } from "@/lib/search"; import { type ChipSearch, @@ -139,89 +141,100 @@ export const DETAIL_IMAGE_CLASSNAME = // Detail views render only inside the click-to-expand modal, never in the // collapsed grid card. Load them lazily so they stay out of the dashboard -// grid's initial JS. ssr:false is safe because the modal is client-only. +// grid's initial JS; client-only is safe because the modal is client-only. const detailViewLoading = () => (
); -const ArticleDetailView = dynamic( +const ArticleDetailView = preloadableView( () => import("@/components/article/article-detail-view").then( (m) => m.ArticleDetailView, ), - { ssr: false, loading: detailViewLoading }, + { loading: detailViewLoading }, ); - -const HighlightsPanel = dynamic( +const HighlightsPanel = preloadableView( () => import("@/components/article/highlights-panel").then( (m) => m.HighlightsPanel, ), - { ssr: false, loading: detailViewLoading }, + { loading: detailViewLoading }, ); - -const BookDetailView = dynamic( - () => - import("@/components/book/book-detail-view").then((m) => m.BookDetailView), - { ssr: false, loading: detailViewLoading }, +const ArticleReadingControls = preloadableView(() => + import("@/components/article/article-reading-controls").then( + (m) => m.ArticleReadingControls, + ), ); - -const ArticleReadingControls = dynamic( +const BookDetailView = preloadableView( () => - import("@/components/article/article-reading-controls").then( - (m) => m.ArticleReadingControls, - ), - { ssr: false }, + import("@/components/book/book-detail-view").then((m) => m.BookDetailView), + { loading: detailViewLoading }, ); - -const BookReadingControls = dynamic( - () => - import("@/components/book/book-reading-controls").then( - (m) => m.BookReadingControls, - ), - { ssr: false }, +const BookReadingControls = preloadableView(() => + import("@/components/book/book-reading-controls").then( + (m) => m.BookReadingControls, + ), ); - -const NoteDetailView = dynamic( +const NoteDetailView = preloadableView( () => import("@/components/note/note-detail-view").then((m) => m.NoteDetailView), - { ssr: false, loading: detailViewLoading }, + { loading: detailViewLoading }, ); - -const ProductDetailView = dynamic( +const ProductDetailView = preloadableView( () => import("@/components/product/product-detail-view").then( (m) => m.ProductDetailView, ), - { ssr: false, loading: detailViewLoading }, + { loading: detailViewLoading }, ); - -const InstagramDetailView = dynamic( +const InstagramDetailView = preloadableView( () => import("@/components/instagram/instagram-detail-view").then( (m) => m.InstagramDetailView, ), - { ssr: false, loading: detailViewLoading }, + { loading: detailViewLoading }, ); - -const TwitterDetailView = dynamic( +const TwitterDetailView = preloadableView( () => import("@/components/twitter/twitter-detail-view").then( (m) => m.TwitterDetailView, ), - { ssr: false, loading: detailViewLoading }, + { loading: detailViewLoading }, ); - -const VideoDetailView = dynamic( +const VideoDetailView = preloadableView( () => import("@/components/video/video-detail-view").then( (m) => m.VideoDetailView, ), - { ssr: false, loading: detailViewLoading }, + { loading: detailViewLoading }, ); +const DETAIL_VIEWS: Partial< + Record Promise }>> +> = { + article: [ArticleDetailView, HighlightsPanel, ArticleReadingControls], + book: [BookDetailView, BookReadingControls], + note: [NoteDetailView], + product: [ProductDetailView], + instagram: [InstagramDetailView], + twitter: [TwitterDetailView], + video: [VideoDetailView], +}; + +/** + * Start loading a kind's lazy detail view on intent (pointer over a card), so + * it's usually loaded by the click and the dialog opens straight into the real + * view instead of flashing a "Loading" placeholder first. + */ +export function preloadDetailView(kind: ItemKind | null): void { + for (const view of (kind && DETAIL_VIEWS[kind]) || []) { + // A failed preload just means the dialog loads it on open as before + view.preload().catch(() => {}); + } +} + type ItemCardProps = { item: Item; name: string; @@ -371,6 +384,7 @@ export function ItemCard({ }; const handleOpenDetail = () => { + debugTrace("dialog", "card:click", { itemId: item.id }); setIsAnimating(true); setShowDetailDialog(true); @@ -1261,6 +1275,11 @@ export function ItemDialogFrame({ defaultValue: false, initializeWithValue: false, }); + useDebugLifecycle({ + name: "ItemDialogFrame", + channel: "dialog", + watch: { open, isTouchDevice }, + }); const dragY = useMotionValue(0); const dragOpacity = useTransform(dragY, [0, 200], [1, 0.5]); const closingOpacity = useMotionValue(1); diff --git a/app/src/app/(app)/dashboard/item-dialog-context.tsx b/app/src/app/(app)/dashboard/item-dialog-context.tsx index 1e63a69c..d2e27274 100644 --- a/app/src/app/(app)/dashboard/item-dialog-context.tsx +++ b/app/src/app/(app)/dashboard/item-dialog-context.tsx @@ -11,6 +11,7 @@ import { useRef, useState, } from "react"; +import { debugTrace } from "@/lib/debug/trace"; import { readItemParam, withOpenItem, @@ -106,11 +107,16 @@ export function ItemDialogProvider({ children }: { children: ReactNode }) { seeds.delete(seeds.keys().next().value ?? ""); } } + debugTrace("dialog", "openItem", { itemId, seeded: seed !== undefined }); const query = withOpenItem(window.location.search, itemId); window.history.pushState(null, "", `?${query}`); }, []); const closeItem = useCallback(() => { + debugTrace("dialog", "closeItem", { + itemId: readItemParam(window.location.search), + via: openedViaPushRef.current ? "history.back" : "strip-param", + }); if (openedViaPushRef.current) { openedViaPushRef.current = false; window.history.back(); diff --git a/app/src/app/(app)/dashboard/item-frame.tsx b/app/src/app/(app)/dashboard/item-frame.tsx index 8a41a36d..226ec3f2 100644 --- a/app/src/app/(app)/dashboard/item-frame.tsx +++ b/app/src/app/(app)/dashboard/item-frame.tsx @@ -16,6 +16,8 @@ type ItemFrameProps = { frameTransition?: string; /** True when this is a freshly-added item that should grow into the grid. */ animateIn: boolean; + /** Item id, exposed as `data-grid-item` for the debug grid observer */ + itemId?: string; children: ReactNode; }; @@ -35,6 +37,7 @@ export function ItemFrame({ columnWidth, frameTransition, animateIn, + itemId, children, }: ItemFrameProps) { const targetPx = @@ -77,7 +80,12 @@ export function ItemFrame({ } return ( - +
{}), }); + const itemKindById = useMemo( + () => new Map(items.map((item) => [item.id, item.kind])), + [items], + ); + + // Debug trace (no-ops unless an admin has tracing on): masonry reflows, list + // changes, and the geometry/loading inputs that drive them + const gridDebugRef = useDebugGridObserver(); + useDebugLifecycle({ + name: "ItemsGrid", + channel: "grid", + watch: { + itemCount: items.length, + frameWidth, + columnWidth, + isLoadingMore, + isSearchPending, + hasMore, + }, + }); + // null until the first render's ids are recorded (an empty list is a real state) + const prevItemIdsRef = useRef(null); + useEffect(() => { + const ids = items.map((item) => item.id); + const prev = prevItemIdsRef.current; + prevItemIdsRef.current = ids; + if (!isTracing() || prev === null) return; + const diff = diffItemIds({ prev, next: ids }); + if (diff.added || diff.removed || diff.reordered) { + debugTrace("grid", "items", { count: ids.length, ...diff }); + } + }, [items]); + // Fresh random order per load; stable across re-renders while loading so the // placeholders don't reshuffle mid-fetch. const skeletonFrames = useMemo( @@ -144,10 +182,33 @@ export function ItemsGrid({ if (seen) for (const item of items) seen.add(item.id); }, [items]); + // One delegated handler: pointing at a card starts loading its detail view + // (a touch fires pointerover just before the tap), so the dialog opens + // straight into it + const handleGridPointerOver = (event: React.PointerEvent) => { + if (!(event.target instanceof Element)) return; + const id = event.target + .closest("[data-grid-item]") + ?.getAttribute("data-grid-item"); + if (id) preloadDetailView(itemKindById.get(id) ?? null); + }; + if (!hasHydrated) { return null; } + // Only complete rows while more pages are coming (the composer takes the + // first slot), so appending a page never rebalances a row already on screen + const composerFrames = showComposer ? 1 : 0; + const renderedItems = items.slice( + 0, + completeRowFrameCount({ + frameCount: items.length + composerFrames, + columnCount, + hasMore: hasMore ?? false, + }) - composerFrames, + ); + // While a search is in flight, dim the shown state and block interaction so // both the grid and a retained empty ("No results") state read as loading. const busyClass = isSearchPending @@ -229,6 +290,8 @@ export function ItemsGrid({ ) ) : (
@@ -242,7 +305,12 @@ export function ItemsGrid({ in flight, just disabled) and is hidden once results are shown, so it doesn't reflow the grid the instant the user types. */} {showComposer && ( - +
)} - {items.map((item) => { + {renderedItems.map((item) => { const meta = item.meta || {}; const isArticleOrWebpage = item.kind === "article" || item.kind === "webpage"; @@ -388,6 +456,7 @@ export function ItemsGrid({ columnWidth={columnWidth} frameTransition={frameTransition} animateIn={animateIn} + itemId={item.id} > ( - +
diff --git a/app/src/app/(public)/[username]/[slug]/_components/room-page-client.tsx b/app/src/app/(public)/[username]/[slug]/_components/room-page-client.tsx index 1b78d08c..81bd15e1 100644 --- a/app/src/app/(public)/[username]/[slug]/_components/room-page-client.tsx +++ b/app/src/app/(public)/[username]/[slug]/_components/room-page-client.tsx @@ -25,6 +25,7 @@ type RoomPageClientProps = { isOwner: boolean; // User data for header isAuthenticated: boolean; + userId?: string | null; email?: string | null; firstName?: string | null; lastName?: string | null; @@ -43,6 +44,7 @@ export function RoomPageClient({ initialHasMore, isOwner, isAuthenticated, + userId, email, firstName, lastName, @@ -129,9 +131,10 @@ export function RoomPageClient({ return ( <> - {isAuthenticated && signOutAction ? ( + {isAuthenticated && userId && signOutAction ? (