Skip to content

Fallback for images - #711

Merged
feruzm merged 3 commits into
developfrom
probe
Mar 20, 2026
Merged

Fallback for images#711
feruzm merged 3 commits into
developfrom
probe

Conversation

@feruzm

@feruzm feruzm commented Mar 19, 2026

Copy link
Copy Markdown
Member

Summary by CodeRabbit

  • New Features

    • Per-image immediate rewrite to a fallback image server on error and a deterministic global fallback that activates after repeated unique failures; cached fallback is applied at render to prevent image flashes.
  • Performance

    • Added DNS prefetch/preconnect for the fallback image server.
  • Bug Fixes

    • Deduplicated failure handling and consolidated reporting into a single warning when fallback activates.
  • Tests

    • Expanded tests for per-image rewrite, global fallback threshold, probe-driven switching, cached fallback behavior, and state reset between tests.

@coderabbitai

coderabbitai Bot commented Mar 19, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

The PR implements deterministic image-proxy fallback: tracks unique image failures, rewrites failing image/src and background-image URLs to a fallback host, probes the primary host in background, activates a one-time global fallback persisted in sessionStorage, and emits a single Sentry warning on fallback activation.

Changes

Cohort / File(s) Summary
Image Proxy Fallback Logic
apps/web/src/app/_components/image-failure-tracker.tsx
Replaced randomized/sampled per-image reporting with deterministic global tracking using failedUrls, retriedElements, and globalSwitched. Immediate per-element src and CSS background-image rewrites from PRIMARY_HOSTFALLBACK_HOST; threshold-based one-time global fallback activation and sessionStorage flag; background probe with timeout to auto-switch; exported ImageFailureTracker._resetImageProxyFallback() for tests; single Sentry capture on activation.
Client Init Adjustments
apps/web/src/app/client-init.tsx
On startup, read cached image_proxy but skip applying it via setProxyBase when a cached fallback is active and the stored proxy equals the default allowed server; guarded sessionStorage access with try/catch.
Defaults & Config
apps/web/src/defaults.ts
Added IMAGE_PROXY_FALLBACK and FALLBACK_SESSION_KEY. defaults.imageServer now returns the fallback when session key is set (safe sessionStorage access) and only honors a stored override if it differs from defaultImageServer.
Layout Prefetching
apps/web/src/app/layout.tsx
Added dns-prefetch and preconnect link tags for https://img.ecency.com (fallback host) alongside existing preconnects.
Tests
apps/web/src/specs/features/shared/image-failure-tracker.spec.tsx
Refactored tests to mock setProxyBase, stub global Image for probe control, call _resetImageProxyFallback() in setup, make img.src writable, and assert per-URL rewrites, dedupe, threshold-triggered global switch & sessionStorage flag, probe-driven switching/timeout behavior, non-switching when overridden by non-default proxy, and SSR-time rewriting when fallback cached.

Sequence Diagram(s)

sequenceDiagram
    participant UI as Rendered Image
    participant Tracker as Image Failure Tracker
    participant Config as SessionStorage/Config
    participant Proxy as Proxy Handler
    participant Sentry as Sentry Logger

    UI->>Tracker: image error (capture phase)
    Tracker->>Tracker: verify PRIMARY_HOST && not retried
    Tracker->>UI: replace element src → FALLBACK_HOST
    Tracker->>Tracker: add key to failedUrls
    Tracker->>Config: check failedUrls size >= threshold?
    
    alt Threshold reached
        Tracker->>Config: sessionStorage[FALLBACK_SESSION_KEY] = "1"
        Tracker->>Proxy: setProxyBase(IMAGE_PROXY_FALLBACK)
        Tracker->>UI: rewrite CSS background-image URLs to FALLBACK_HOST
        Tracker->>Sentry: captureMessage("fallback activated", extras)
    else Threshold not reached
        Note over Tracker: continue tracking
    end

    rect rgba(100,150,200,0.5)
        Note over Tracker,Proxy: Background probe flow
        Tracker->>Proxy: probe primary-host image (with timeout)
        alt Probe fails/timeout
            Tracker->>Config: sessionStorage[FALLBACK_SESSION_KEY] = "1"
            Tracker->>Proxy: setProxyBase(IMAGE_PROXY_FALLBACK)
        else Probe succeeds
            Note over Tracker: no change
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • Image error listener #709: Prior changes to ImageFailureTracker implementing Sentry-based error reporting and sampling; touches the same component and behavior replaced/extended here.
  • Alternative image proxy #673: Modifies image-proxy startup/default logic (client-init.tsx, defaults.ts); overlaps with this PR's changes to when setProxyBase is applied and imageServer resolution.

Suggested labels

patch

Poem

🐰 I hopped through pixels, watched the primary fall,
I nudged the URLs, no more stuttered mall.
A probe and a session, a gentle fallback dance,
One-lined to the new host — I gave it a chance.
Hooray — images bloom, and I twitch my whisker-prance!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fallback for images' directly relates to the primary change: implementing automatic fallback from primary image host to fallback host when image failures are detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch probe
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

coderabbitai[bot]

This comment was marked as resolved.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

♻️ Duplicate comments (1)
apps/web/src/app/_components/image-failure-tracker.tsx (1)

7-11: ⚠️ Potential issue | 🟠 Major

The override-detection fix is still using a different resolver than defaults.ts.

The earlier hard-coded proxy concern is only partially addressed. Line 33 treats any parsed string that does not contain images.ecency.com as a user override, but apps/web/src/defaults.ts:32-57 only honors values in ALLOWED_IMAGE_SERVERS that differ from defaultImageServer. A stale/invalid image_proxy can therefore leave the app on the primary host while this tracker skips both the probe and the cached-fallback path. Please reuse the shared proxy-resolution/helper/constants instead of duplicating the logic here. A regression test with an invalid stored value would help.

Also applies to: 27-37

🤖 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 7 - 11,
The override-detection in image-failure-tracker (using PRIMARY_HOST,
FALLBACK_HOST, PROBE_TIMEOUT_MS, SESSION_KEY) incorrectly treats any parsed
image_proxy string not containing "images.ecency.com" as a user override;
replace this ad-hoc logic with the shared resolver/constant logic used in
defaults.ts (specifically reuse ALLOWED_IMAGE_SERVERS and defaultImageServer or
the shared resolveImageServer helper) so only allowed overrides are honored,
ensure the probe/cached-fallback path runs when a stored image_proxy is
invalid/stale, and add a regression test that stores an invalid image_proxy
value to verify the tracker falls back to probing and caching the fallback host.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/web/src/app/_components/image-failure-tracker.tsx`:
- Around line 109-127: The background probe created inside the useEffect can
outlive the component; add a cleanup function that cancels the timeout and
detaches the image handlers to prevent stale callbacks calling
switchToFallback() after unmount. Specifically, inside the useEffect that
references globalSwitched, hasNonDefaultProxy(), PROBE_TIMEOUT_MS and creates
const img and const timeout, return a cleanup that calls clearTimeout(timeout),
sets img.onload = null and img.onerror = null, and sets img.src = "" (or
otherwise aborts the request) only if the probe was created so you don't touch
undefined variables.
- Around line 13-24: The retry dedupe currently uses retriedUrls (Set<string>)
keyed by src which prevents later <img> elements with the same URL from
attempting a fallback; change the dedupe to be per DOM node: replace retriedUrls
with a WeakSet<HTMLImageElement> (e.g., retriedElements) or a Map<string,
WeakSet<HTMLImageElement>> if you must group by src, update all references that
check/add to retriedUrls to instead check/add the actual image element (the same
places around the retry logic and where retriedUrls is used at lines ~39-43 and
~137-142), and update _resetImageProxyFallback to clear the new retriedElements
structure so tests can reset state; keep failedUrls keyed by URL as-is.

---

Duplicate comments:
In `@apps/web/src/app/_components/image-failure-tracker.tsx`:
- Around line 7-11: The override-detection in image-failure-tracker (using
PRIMARY_HOST, FALLBACK_HOST, PROBE_TIMEOUT_MS, SESSION_KEY) incorrectly treats
any parsed image_proxy string not containing "images.ecency.com" as a user
override; replace this ad-hoc logic with the shared resolver/constant logic used
in defaults.ts (specifically reuse ALLOWED_IMAGE_SERVERS and defaultImageServer
or the shared resolveImageServer helper) so only allowed overrides are honored,
ensure the probe/cached-fallback path runs when a stored image_proxy is
invalid/stale, and add a regression test that stores an invalid image_proxy
value to verify the tracker falls back to probing and caching the fallback host.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a6182125-c3cb-4654-b4a8-892c6c018bef

📥 Commits

Reviewing files that changed from the base of the PR and between 4caa3e6 and 9281429.

📒 Files selected for processing (2)
  • apps/web/src/app/_components/image-failure-tracker.tsx
  • apps/web/src/specs/features/shared/image-failure-tracker.spec.tsx

Comment thread apps/web/src/app/_components/image-failure-tracker.tsx
Comment thread apps/web/src/app/_components/image-failure-tracker.tsx

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (3)
apps/web/src/specs/features/shared/image-failure-tracker.spec.tsx (3)

47-51: Nit: Redundant mock clear.

Line 49 calls mockSetProxyBase.mockClear(), but vi.clearAllMocks() on line 47 already clears all mocks including mockSetProxyBase.

♻️ Remove redundant call
   beforeEach(() => {
     vi.clearAllMocks();
     _resetImageProxyFallback();
-    mockSetProxyBase.mockClear();
     probeInstances = [];
     urlCounter = 0;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/specs/features/shared/image-failure-tracker.spec.tsx` around
lines 47 - 51, The test teardown double-clears mocks: vi.clearAllMocks() already
resets mockSetProxyBase, so remove the redundant mockSetProxyBase.mockClear()
call; update the teardown in image-failure-tracker.spec.tsx (remove the
mockSetProxyBase.mockClear() line) and ensure remaining cleanup still calls
_resetImageProxyFallback(), clears probeInstances and resets urlCounter as
before.

248-266: Consider more robust DOM cleanup for SSR test.

The manually appended <img> elements are only removed in the happy path. If render() or the assertions throw, these elements remain in the DOM and could affect subsequent tests.

♻️ Use try/finally for guaranteed cleanup
   it("rewrites SSR-rendered img elements on hydration when fallback is cached", () => {
     sessionStorage.setItem("image_proxy_fallback_active", "1");

     // Simulate SSR-rendered img elements already in the DOM
     const img1 = document.createElement("img");
     img1.src = `https://${PRIMARY_HOST}/p/abc123?format=match`;
     const img2 = document.createElement("img");
     img2.src = `https://${PRIMARY_HOST}/u/user1/avatar/medium`;
     document.body.appendChild(img1);
     document.body.appendChild(img2);

+    try {
       render(<ImageFailureTracker />);

       expect(img1.src).toContain(FALLBACK_HOST);
       expect(img2.src).toContain(FALLBACK_HOST);
-
-    document.body.removeChild(img1);
-    document.body.removeChild(img2);
+    } finally {
+      img1.remove();
+      img2.remove();
+    }
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/specs/features/shared/image-failure-tracker.spec.tsx` around
lines 248 - 266, The test that simulates SSR-rendered <img> elements (in
image-failure-tracker.spec.tsx) should guarantee DOM cleanup by wrapping the DOM
mutation, render(<ImageFailureTracker />) call, and assertions in a try/finally
block and removing img1 and img2 in the finally to ensure they are removed even
if render or assertions throw; locate the img element creation variables (img1,
img2), the render call, and the expect assertions and move the
document.body.removeChild(img1)/removeChild(img2) into the finally so cleanup
always runs.

10-14: Optional: Simplify the mock wrapper.

The wrapper function adds indirection that isn't needed since mockSetProxyBase is already a vi.fn().

♻️ Proposed simplification
 const mockSetProxyBase = vi.fn();
 vi.mock("@ecency/render-helper", async () => ({
   ...(await vi.importActual("@ecency/render-helper")),
-  setProxyBase: (...args: unknown[]) => mockSetProxyBase(...args)
+  setProxyBase: mockSetProxyBase
 }));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/specs/features/shared/image-failure-tracker.spec.tsx` around
lines 10 - 14, The vi.mock wrapper for "@ecency/render-helper" adds an
unnecessary layer by forwarding to mockSetProxyBase; replace the wrapper
function with a direct reference so the mock exports setProxyBase:
mockSetProxyBase instead of setProxyBase: (...args: unknown[]) =>
mockSetProxyBase(...args), keeping the vi.fn() mockSetProxyBase and the rest of
the imported actual module unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@apps/web/src/specs/features/shared/image-failure-tracker.spec.tsx`:
- Around line 47-51: The test teardown double-clears mocks: vi.clearAllMocks()
already resets mockSetProxyBase, so remove the redundant
mockSetProxyBase.mockClear() call; update the teardown in
image-failure-tracker.spec.tsx (remove the mockSetProxyBase.mockClear() line)
and ensure remaining cleanup still calls _resetImageProxyFallback(), clears
probeInstances and resets urlCounter as before.
- Around line 248-266: The test that simulates SSR-rendered <img> elements (in
image-failure-tracker.spec.tsx) should guarantee DOM cleanup by wrapping the DOM
mutation, render(<ImageFailureTracker />) call, and assertions in a try/finally
block and removing img1 and img2 in the finally to ensure they are removed even
if render or assertions throw; locate the img element creation variables (img1,
img2), the render call, and the expect assertions and move the
document.body.removeChild(img1)/removeChild(img2) into the finally so cleanup
always runs.
- Around line 10-14: The vi.mock wrapper for "@ecency/render-helper" adds an
unnecessary layer by forwarding to mockSetProxyBase; replace the wrapper
function with a direct reference so the mock exports setProxyBase:
mockSetProxyBase instead of setProxyBase: (...args: unknown[]) =>
mockSetProxyBase(...args), keeping the vi.fn() mockSetProxyBase and the rest of
the imported actual module unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e1168ed5-4e09-4ca3-8ba8-c00b154af0a0

📥 Commits

Reviewing files that changed from the base of the PR and between 9281429 and 875f222.

📒 Files selected for processing (2)
  • apps/web/src/app/_components/image-failure-tracker.tsx
  • apps/web/src/specs/features/shared/image-failure-tracker.spec.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/src/app/_components/image-failure-tracker.tsx

@feruzm
feruzm merged commit c95bc57 into develop Mar 20, 2026
1 check passed
@feruzm
feruzm deleted the probe branch March 20, 2026 07:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant