Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,21 @@ import { EntryPageContext } from "../context";
import { useIsMobile } from "@/utils";

// https://github.com/FezVrasta/react-popper#usage-without-a-reference-htmlelement
// floating-ui requires `contextElement` on virtual references so internal
// functions like getOffsetParent / getClippingRect can traverse the DOM
// without hitting null.parentNode.
class VirtualSelectionReference {
selection: Selection;
contextElement: Element | undefined;

constructor(selection: Selection) {
this.selection = selection;
// Provide a real DOM element so floating-ui can derive document, offset
// parent, and overflow ancestors without null traversal errors.
this.contextElement =
selection.anchorNode instanceof Element
? selection.anchorNode
: selection.anchorNode?.parentElement ?? undefined;
}

get clientWidth() {
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/app/_components/image-failure-tracker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"use client";

import { useEffect } from "react";
import * as Sentry from "@sentry/nextjs";

const IMAGE_HOST = "images.ecency.com";
const REPORT_SAMPLE_RATE = 0.2;
const DEBOUNCE_MS = 5000;

const recentlyReported = new Set<string>();

export function ImageFailureTracker() {
useEffect(() => {
const handler = (event: Event) => {
const el = event.target;
if (!(el instanceof HTMLImageElement)) return;

const src = el.src || el.currentSrc;
if (!src || !src.includes(IMAGE_HOST)) return;

// Deduplicate rapid failures for same URL before sampling
const key = src.slice(0, 200);
if (recentlyReported.has(key)) return;
recentlyReported.add(key);
setTimeout(() => recentlyReported.delete(key), DEBOUNCE_MS);

if (Math.random() > REPORT_SAMPLE_RATE) return;

Sentry.withScope((scope) => {
scope.setTag("failure_type", "image_load");
scope.setTag("image_host", IMAGE_HOST);
scope.setLevel("warning");
scope.setExtras({
image_src: src,
page_url: window.location.href,
connection_type: (navigator as Navigator & { connection?: { effectiveType?: string } }).connection?.effectiveType,
online: navigator.onLine
});
Sentry.captureMessage("Client image load failure: " + IMAGE_HOST);
});
};

// Capture phase to catch errors on <img> elements (they don't bubble)
document.addEventListener("error", handler, true);
return () => document.removeEventListener("error", handler, true);
}, []);

return null;
Comment on lines +12 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if any test file exists for ImageFailureTracker
fd -e spec.ts -e spec.tsx | xargs grep -l "ImageFailureTracker" 2>/dev/null || echo "No test found for ImageFailureTracker"

Repository: ecency/vision-next

Length of output: 99


🏁 Script executed:

# Check if ImageFailureTracker is a new file or has git history
git log --oneline --follow -- "apps/web/src/app/_components/image-failure-tracker.tsx" 2>/dev/null | head -5 || echo "File may be new or untracked"

Repository: ecency/vision-next

Length of output: 92


🏁 Script executed:

# Check existing test examples in src/specs directory to understand conventions
find apps/web/src/specs -name "*.spec.tsx" -type f | head -3 | xargs wc -l

Repository: ecency/vision-next

Length of output: 286


🏁 Script executed:

# Verify the test-utils location mentioned in learnings
test -f "apps/web/src/specs/test-utils.tsx" && echo "test-utils.tsx found" || echo "test-utils.tsx not found"

Repository: ecency/vision-next

Length of output: 82


🏁 Script executed:

# Get details of the commit that added ImageFailureTracker
git show d7a385f07 --stat | head -20

Repository: ecency/vision-next

Length of output: 1076


🏁 Script executed:

# Check if there's a test file in the same commit
git show d7a385f07 --name-only | grep -i "image-failure"

Repository: ecency/vision-next

Length of output: 116


Missing test coverage for new feature.

All new features in @ecency/web require tests. Add a test file in src/specs/ that verifies:

  • The error handler filters for images.ecency.com URLs
  • Non-image element errors are ignored
  • Sentry is called with expected tags and extras
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/app/_components/image-failure-tracker.tsx` around lines 12 - 48,
Add unit tests for the ImageFailureTracker feature by creating a new spec under
src/specs/ that mounts ImageFailureTracker and simulates error events to assert
behavior: fire an error event with an HTMLImageElement whose src includes
IMAGE_HOST and confirm Sentry.captureMessage is called with tags
"failure_type":"image_load" and "image_host":IMAGE_HOST and extras include
image_src, page_url, connection_type, and online; send an error event from a
non-HTMLImageElement and an image with a non-matching host and assert Sentry is
not called; also test debounce/dedup by triggering two rapid errors for the same
src and asserting only one Sentry call (references: ImageFailureTracker,
handler, recentlyReported, REPORT_SAMPLE_RATE, DEBOUNCE_MS).

}
1 change: 1 addition & 0 deletions apps/web/src/app/_components/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./landing-page";
export * from "./hiring-console-log";
export * from "./image-failure-tracker";
export * from "./community-list-item";
export * from "./top-communities-widget";
export * from "./my-favorites-widget";
3 changes: 2 additions & 1 deletion apps/web/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import "@/styles/style.scss";
import "@/core/sdk-init"; // Initialize SDK DMCA filters immediately (SSR)
import Providers from "@/app/providers";
import { HiringConsoleLog } from "@/app/_components";
import { HiringConsoleLog, ImageFailureTracker } from "@/app/_components";
import { cookies } from "next/headers";
import { Theme } from "@/enums";
import { BannerManager } from "@/features/banners";
Expand Down Expand Up @@ -75,6 +75,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<body className={theme === Theme.night ? "dark" : ""}>
<BannerManager />
<HiringConsoleLog />
<ImageFailureTracker />
<Providers>{children}</Providers>
<div id="modal-overlay-container" />
<div id="modal-dialog-container" />
Expand Down
61 changes: 39 additions & 22 deletions apps/web/src/features/ui/util/safe-auto-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,47 +24,64 @@ export function safeAutoUpdate(

let cleanup: (() => void) | null = null;
let observer: MutationObserver | null = null;
let disposed = false;

// Wrap the update callback to check element connectivity before each
// computePosition call. This closes the race window where scroll/resize
// events fire after an element is detached but before the MutationObserver
// callback runs.
const safeUpdate = () => {
if (disposed) return;
try {
const refConnected = reference instanceof Node ? reference.isConnected : true;
if (!refConnected || !floating.isConnected) {
dispose();
return;
}
update();
} catch {
// Swallow errors from computePosition on detached elements
}
};

function dispose() {
if (disposed) return;
disposed = true;
try {
observer?.disconnect();
cleanup?.();
} catch {
// Silently handle cleanup errors
}
cleanup = null;
observer = null;
}

try {
// Wrap autoUpdate in try-catch to handle race conditions where elements
// are removed between our check and when autoUpdate accesses them
cleanup = autoUpdate(reference, floating, update, options);
cleanup = autoUpdate(reference, floating, safeUpdate, options);

observer = new MutationObserver(() => {
try {
if (
(reference instanceof Node && !reference.isConnected) ||
!floating.isConnected
) {
cleanup?.();
observer?.disconnect();
cleanup = null;
observer = null;
dispose();
}
} catch (error) {
} catch {
// Silently handle errors during cleanup check
console.debug('safeAutoUpdate: Error during MutationObserver callback', error);
}
});

observer.observe(floating.ownerDocument ?? document, {
childList: true,
subtree: true,
});
} catch (error) {
} catch {
// Handle initialization errors (e.g., elements removed during autoUpdate setup)
console.debug('safeAutoUpdate: Error during initialization', error);
observer?.disconnect();
return () => {};
dispose();
return dispose;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return () => {
try {
observer?.disconnect();
cleanup?.();
} catch (error) {
// Silently handle cleanup errors
console.debug('safeAutoUpdate: Error during cleanup', error);
}
};
return dispose;
}
1 change: 1 addition & 0 deletions apps/web/src/specs/api/import-route.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";

Expand Down
139 changes: 139 additions & 0 deletions apps/web/src/specs/features/shared/image-failure-tracker.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, act } from "@testing-library/react";
import * as Sentry from "@sentry/nextjs";

vi.mock("@sentry/nextjs", () => ({
withScope: vi.fn((cb) => cb({ setTag: vi.fn(), setLevel: vi.fn(), setExtras: vi.fn() })),
captureMessage: vi.fn()
}));

import { ImageFailureTracker } from "@/app/_components/image-failure-tracker";

const IMAGE_HOST = "images.ecency.com";
let urlCounter = 0;

function uniqueImageUrl() {
return `https://${IMAGE_HOST}/img/${++urlCounter}.png`;
}

function fireImageError(src: string) {
const img = document.createElement("img");
Object.defineProperty(img, "src", { value: src, writable: false });
const event = new Event("error", { bubbles: false });
Object.defineProperty(event, "target", { value: img });
document.dispatchEvent(event);
}

function fireNonImageError() {
const script = document.createElement("script");
const event = new Event("error", { bubbles: false });
Object.defineProperty(event, "target", { value: script });
document.dispatchEvent(event);
}

describe("ImageFailureTracker", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(Math, "random").mockReturnValue(0); // always below sample rate
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

it("reports to Sentry when an image from IMAGE_HOST fails", () => {
render(<ImageFailureTracker />);

const src = uniqueImageUrl();
act(() => {
fireImageError(src);
});

expect(Sentry.withScope).toHaveBeenCalledOnce();
expect(Sentry.captureMessage).toHaveBeenCalledWith(
expect.stringContaining(IMAGE_HOST)
);

// Verify scope was configured with correct tags and extras
interface MockScope {
setTag: ReturnType<typeof vi.fn>;
setLevel: ReturnType<typeof vi.fn>;
setExtras: ReturnType<typeof vi.fn>;
}
const scopeCb = vi.mocked(Sentry.withScope).mock.calls[0][0] as unknown as (scope: MockScope) => void;
const mockScope: MockScope = {
setTag: vi.fn(),
setLevel: vi.fn(),
setExtras: vi.fn()
};
scopeCb(mockScope);

expect(mockScope.setTag).toHaveBeenCalledWith("failure_type", "image_load");
expect(mockScope.setTag).toHaveBeenCalledWith("image_host", IMAGE_HOST);
expect(mockScope.setExtras).toHaveBeenCalledWith(
expect.objectContaining({
image_src: src,
page_url: expect.any(String),
online: expect.any(Boolean)
})
);
});

it("ignores errors from non-HTMLImageElement targets", () => {
render(<ImageFailureTracker />);

act(() => {
fireNonImageError();
});

expect(Sentry.withScope).not.toHaveBeenCalled();
});

it("ignores image errors from non-matching hosts", () => {
render(<ImageFailureTracker />);

act(() => {
fireImageError("https://other-cdn.example.com/photo.jpg");
});

expect(Sentry.withScope).not.toHaveBeenCalled();
});

it("deduplicates rapid errors for the same src", () => {
render(<ImageFailureTracker />);

const src = uniqueImageUrl();
act(() => {
fireImageError(src);
fireImageError(src);
});

expect(Sentry.captureMessage).toHaveBeenCalledTimes(1);

// After DEBOUNCE_MS, the same URL can report again
act(() => {
vi.advanceTimersByTime(5000);
});

act(() => {
fireImageError(src);
});

expect(Sentry.captureMessage).toHaveBeenCalledTimes(2);
});

it("respects sampling rate", () => {
render(<ImageFailureTracker />);

// random() returns 0.5, which is > REPORT_SAMPLE_RATE (0.2) — should skip
vi.mocked(Math.random).mockReturnValue(0.5);

act(() => {
fireImageError(uniqueImageUrl());
});

expect(Sentry.captureMessage).not.toHaveBeenCalled();
});
});
Loading