Skip to content

Fix resilient link preview providers#361

Merged
appflowy merged 3 commits into
mainfrom
fix/link-preview-provider-fallbacks
May 22, 2026
Merged

Fix resilient link preview providers#361
appflowy merged 3 commits into
mainfrom
fix/link-preview-provider-fallbacks

Conversation

@appflowy
Copy link
Copy Markdown
Contributor

@appflowy appflowy commented May 22, 2026

Summary

  • add an extensible link-preview provider registry with deterministic precedence
  • render URL-derived fallback metadata immediately instead of showing a failed preview state
  • share link preview fetching between preview blocks and external link mentions
  • add bounded TTL/LRU caching, in-flight request dedupe, and provider-registration cache invalidation

Tests

  • pnpm exec jest src/utils/tests/link-preview.test.ts --runInBand --no-coverage
  • pnpm run type-check

Summary by Sourcery

Introduce a shared, extensible link preview infrastructure with fallbacks and caching, and wire it into link preview blocks and external link mentions.

New Features:

  • Add a pluggable link preview provider registry with deterministic precedence and default Microlink, GitHub, and URL-fallback providers.
  • Expose utilities to build URL-based fallback metadata and to fetch link preview data with provider orchestration.

Enhancements:

  • Update link preview and external link mention components to use shared preview utilities, show URL-derived fallback metadata immediately, and support both logo and image thumbnails.
  • Implement TTL- and size-bounded caching with in-flight request deduplication and provider-registration invalidation for link previews.

Tests:

  • Add unit test coverage for link preview provider selection, caching behavior, custom provider registration, and GitHub URL parsing.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 22, 2026

Reviewer's Guide

Introduces a shared, extensible link-preview provider system with caching and uses it from both the editor link-preview block and external link mentions, replacing direct Microlink calls and improving fallback behavior and resilience.

Sequence diagram for link preview fetching with caching and fallback rendering

sequenceDiagram
  actor User
  participant LinkPreview as LinkPreviewComponent
  participant Utils as link_preview_utils
  participant Cache as linkPreviewDataCache
  participant Providers as LinkPreviewProviders

  User->>LinkPreview: Paste URL
  activate LinkPreview
  LinkPreview->>LinkPreview: buildFallbackLinkPreviewData(url)
  LinkPreview->>User: Render fallbackData immediately

  LinkPreview->>Utils: fetchLinkPreviewData(url, signal)
  activate Utils
  Utils->>Cache: getCachedLinkPreviewData(cacheKey)
  alt cache hit
    Cache-->>Utils: LinkPreviewData
    Utils-->>LinkPreview: LinkPreviewData
  else cache miss
    Utils->>Providers: fetchLinkPreviewDataFromProviders(normalizedUrl)
    activate Providers
    Providers->>Providers: getProviderGroups(context)
    Providers->>Providers: fetchFirstSuccessfulProviderData(providers, context)
    Providers-->>Utils: LinkPreviewData or fallbackData
    deactivate Providers
    Utils->>Cache: setCachedLinkPreviewData(cacheKey, data)
    Utils-->>LinkPreview: LinkPreviewData
  end
  deactivate Utils

  LinkPreview->>LinkPreview: setRemotePreview(data)
  LinkPreview->>User: Re-render with remote preview data
  deactivate LinkPreview
Loading

File-Level Changes

Change Details Files
Refactor link preview block component to use shared provider-based fetching with immediate URL-derived fallback metadata.
  • Replace direct axios Microlink call with fetchLinkPreviewData and AbortController-based cancellation.
  • Maintain remote preview state keyed by URL and compute final display data as remote data with fallback to URL-derived metadata.
  • Remove explicit not-found UI state and instead always render based on available metadata, including new logo support for image selection.
  • Guard description rendering so it only shows when present and update image URL selection logic to use logo or image.
src/components/editor/components/blocks/link-preview/LinkPreview.tsx
Refactor MentionExternalLink to share link preview fetching logic and fallback behavior with the main link preview component.
  • Remove inline axios Microlink usage in favor of fetchLinkPreviewData with AbortController and error handling.
  • Introduce URL-derived fallback metadata via buildFallbackLinkPreviewData and merge with remote provider data.
  • Unify image selection to prefer logo then image and adjust alt/title rendering to use structured metadata.
src/components/editor/components/leaf/mention/MentionExternalLink.tsx
Add an extensible, prioritized link preview provider registry with Microlink, GitHub-specific, and URL-fallback providers plus bounded caching and request deduplication.
  • Define LinkPreviewData/Provider interfaces and a registry with deterministic priority ordering and registration/invalidation APIs.
  • Implement Microlink universal provider, GitHub domain-specific provider (issues, pulls, repos), and a URL-based fallback provider.
  • Add TTL+LRU-like cache with versioned keys and in-flight request deduplication for fetchLinkPreviewData, including abort signal support.
  • Provide helpers for parsing GitHub URLs, normalizing/truncating metadata, building fallback metadata from URLs, and racing requests with AbortController-aware abort detection.
src/utils/link-preview.ts
Add tests to verify provider behavior, caching semantics, registry extension, and GitHub URL parsing.
  • Mock axios and validate universal provider success, failure fallthrough to fallback, and deduplication of concurrent requests.
  • Test cache hit behavior, TTL expiration, and LRU eviction when reaching the maximum cache size.
  • Verify cache invalidation on provider registration and deterministic provider precedence within equal-priority groups.
  • Exercise custom provider registration, priority placement relative to default providers, and GitHub URL parsing for repos, issues, and pull requests.
src/utils/__tests__/link-preview.test.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues, and left some high level feedback:

  • The AbortSignal passed into fetchLinkPreviewData(url, signal) is only used in raceWithAbortSignal and never threaded into LinkPreviewProviderContext, so providers always see context.signal === undefined and their axios calls can’t actually be aborted; consider passing the signal into fetchLinkPreviewDataFromProviders and storing it on the context so provider-level requests are cancelable.
  • The RemoteLinkPreviewData type and fallback/remote merge logic are duplicated between LinkPreview and MentionExternalLink; consider extracting a shared type and/or a small hook/helper to centralize this behavior and avoid divergence over time.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `AbortSignal` passed into `fetchLinkPreviewData(url, signal)` is only used in `raceWithAbortSignal` and never threaded into `LinkPreviewProviderContext`, so providers always see `context.signal === undefined` and their axios calls can’t actually be aborted; consider passing the signal into `fetchLinkPreviewDataFromProviders` and storing it on the context so provider-level requests are cancelable.
- The `RemoteLinkPreviewData` type and fallback/remote merge logic are duplicated between `LinkPreview` and `MentionExternalLink`; consider extracting a shared type and/or a small hook/helper to centralize this behavior and avoid divergence over time.

## Individual Comments

### Comment 1
<location path="src/utils/link-preview.ts" line_range="241-250" />
<code_context>
+  }
+}
+
+export async function fetchLinkPreviewData(url: string, signal?: AbortSignal): Promise<LinkPreviewData> {
+  const normalizedUrl = processUrl(url) || url;
+  const cacheKey = getLinkPreviewCacheKey(normalizedUrl);
+  const cachedData = getCachedLinkPreviewData(cacheKey);
+
+  if (cachedData) return cachedData;
+
+  let request = inFlightLinkPreviewRequests.get(cacheKey);
+
+  if (!request) {
+    const registryVersion = providerRegistryVersion;
+
+    request = fetchLinkPreviewDataFromProviders(normalizedUrl)
+      .then((data) => {
+        if (registryVersion === providerRegistryVersion) {
</code_context>
<issue_to_address>
**issue (bug_risk):** AbortSignal is not propagated into provider fetches, so network requests cannot actually be cancelled.

In `fetchLinkPreviewData`, the `signal` is only used by `raceWithAbortSignal` and never passed into `fetchLinkPreviewDataFromProviders`, so providers see `signal: undefined` and the axios calls in `microlinkProvider`/`githubProvider` are never cancelled. This wastes network/resources when callers abort (e.g., unmounts or rapid URL changes).

Please update `fetchLinkPreviewDataFromProviders` to accept an optional `signal` and include it in `LinkPreviewProviderContext` (and update its callers), so axios receives the real `AbortSignal` and `isAbortError(result.error, context.signal)` can reliably detect aborts.
</issue_to_address>

### Comment 2
<location path="src/utils/__tests__/link-preview.test.ts" line_range="27-34" />
<code_context>
+    mockedAxios.isCancel.mockReturnValue(false);
+  });
+
+  it('builds a readable fallback for any URL', () => {
+    expect(buildFallbackLinkPreviewData('https://example.com/docs/getting-started?tab=web')).toEqual({
+      title: 'example.com/docs/getting-started',
</code_context>
<issue_to_address>
**suggestion (testing):** Strengthen `buildFallbackLinkPreviewData` tests with non-URL and edge-case inputs.

Given this is the last-resort path, consider adding tests for:

- A clearly invalid string (e.g. `'not a url'`) to verify we return `{ title: originalString, description: '' }`.
- A URL with percent-encoded segments (including malformed sequences, e.g. `'https://example.com/%E0%A4%A'`) to exercise `safeDecodeURIComponent`.
- A bare domain without scheme (if `processUrl` supports it) to confirm host extraction and `www.` stripping.

These will better pin down behavior for edge inputs.

```suggestion
  it('builds a readable fallback for any URL', () => {
    expect(buildFallbackLinkPreviewData('https://example.com/docs/getting-started?tab=web')).toEqual({
      title: 'example.com/docs/getting-started',
      description: '',
    });
  });

  it('falls back to the original string for clearly invalid input', () => {
    expect(buildFallbackLinkPreviewData('not a url')).toEqual({
      title: 'not a url',
      description: '',
    });
  });

  it('handles URLs with percent-encoded segments, including malformed sequences', () => {
    const encodedUrl = 'https://example.com/%E0%A4%A';

    expect(buildFallbackLinkPreviewData(encodedUrl)).toEqual({
      title: 'example.com/%E0%A4%A',
      description: '',
    });
  });

  it('handles bare domains without a scheme when supported by processUrl', () => {
    expect(buildFallbackLinkPreviewData('example.com/docs/getting-started')).toEqual({
      title: 'example.com/docs/getting-started',
      description: '',
    });
  });

  it('uses generic metadata when the universal provider succeeds', async () => {
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/utils/link-preview.ts
Comment on lines +241 to +250
export async function fetchLinkPreviewData(url: string, signal?: AbortSignal): Promise<LinkPreviewData> {
const normalizedUrl = processUrl(url) || url;
const cacheKey = getLinkPreviewCacheKey(normalizedUrl);
const cachedData = getCachedLinkPreviewData(cacheKey);

if (cachedData) return cachedData;

let request = inFlightLinkPreviewRequests.get(cacheKey);

if (!request) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

issue (bug_risk): AbortSignal is not propagated into provider fetches, so network requests cannot actually be cancelled.

In fetchLinkPreviewData, the signal is only used by raceWithAbortSignal and never passed into fetchLinkPreviewDataFromProviders, so providers see signal: undefined and the axios calls in microlinkProvider/githubProvider are never cancelled. This wastes network/resources when callers abort (e.g., unmounts or rapid URL changes).

Please update fetchLinkPreviewDataFromProviders to accept an optional signal and include it in LinkPreviewProviderContext (and update its callers), so axios receives the real AbortSignal and isAbortError(result.error, context.signal) can reliably detect aborts.

Comment on lines +27 to +34
it('builds a readable fallback for any URL', () => {
expect(buildFallbackLinkPreviewData('https://example.com/docs/getting-started?tab=web')).toEqual({
title: 'example.com/docs/getting-started',
description: '',
});
});

it('uses generic metadata when the universal provider succeeds', async () => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

suggestion (testing): Strengthen buildFallbackLinkPreviewData tests with non-URL and edge-case inputs.

Given this is the last-resort path, consider adding tests for:

  • A clearly invalid string (e.g. 'not a url') to verify we return { title: originalString, description: '' }.
  • A URL with percent-encoded segments (including malformed sequences, e.g. 'https://example.com/%E0%A4%A') to exercise safeDecodeURIComponent.
  • A bare domain without scheme (if processUrl supports it) to confirm host extraction and www. stripping.

These will better pin down behavior for edge inputs.

Suggested change
it('builds a readable fallback for any URL', () => {
expect(buildFallbackLinkPreviewData('https://example.com/docs/getting-started?tab=web')).toEqual({
title: 'example.com/docs/getting-started',
description: '',
});
});
it('uses generic metadata when the universal provider succeeds', async () => {
it('builds a readable fallback for any URL', () => {
expect(buildFallbackLinkPreviewData('https://example.com/docs/getting-started?tab=web')).toEqual({
title: 'example.com/docs/getting-started',
description: '',
});
});
it('falls back to the original string for clearly invalid input', () => {
expect(buildFallbackLinkPreviewData('not a url')).toEqual({
title: 'not a url',
description: '',
});
});
it('handles URLs with percent-encoded segments, including malformed sequences', () => {
const encodedUrl = 'https://example.com/%E0%A4%A';
expect(buildFallbackLinkPreviewData(encodedUrl)).toEqual({
title: 'example.com/%E0%A4%A',
description: '',
});
});
it('handles bare domains without a scheme when supported by processUrl', () => {
expect(buildFallbackLinkPreviewData('example.com/docs/getting-started')).toEqual({
title: 'example.com/docs/getting-started',
description: '',
});
});
it('uses generic metadata when the universal provider succeeds', async () => {

@appflowy appflowy force-pushed the fix/link-preview-provider-fallbacks branch from f46e010 to 3473f8b Compare May 22, 2026 03:51
@appflowy appflowy merged commit f42258c into main May 22, 2026
13 checks passed
@appflowy appflowy deleted the fix/link-preview-provider-fallbacks branch May 22, 2026 09:07
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