Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions apps/nextjs/app/outbound/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export default function Outbound() {
<iframe src="https://example.com/embed"></iframe>
<iframe src="https://www.example.com/embed"></iframe>

{/* Cal.com style iframe with srcdoc */}
<iframe srcdoc='<html><body><h1>Cal.com Embed</h1><a href="https://example.com/booking">Book Now</a><script src="https://other.com/widget.js"></script></body></html>'></iframe>
<iframe srcdoc='<div>Another srcdoc iframe with <a href="https://wildcard.com/test">link</a></div>'></iframe>

<a href="https://getacme.link/about">Internal Link</a>
<div id="container"></div>

Expand Down
34 changes: 34 additions & 0 deletions apps/nextjs/tests/outbound-domains.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,40 @@ test.describe('Outbound domains tracking', () => {
expect(iframeSrc).toContain('dub_id=test-click-id');
});

test('should handle iframe srcdoc attributes (Cal.com style)', async ({
page,
}) => {
await page.goto('/outbound?dub_id=test-click-id');

await page.waitForFunction(() => window._dubAnalytics !== undefined);

await page.waitForTimeout(2500);

Copilot AI Oct 7, 2025

Copy link

Choose a reason for hiding this comment

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

Using a fixed timeout can introduce flakiness and slows the test unnecessarily. Prefer waiting on a concrete condition (e.g., await page.waitForFunction(() => document.querySelector('iframe[srcdoc]')?.getAttribute('srcdoc')?.includes('dub_id='))) instead of an arbitrary delay.

Suggested change
await page.waitForTimeout(2500);
await page.waitForFunction(() => {
const iframes = Array.from(document.querySelectorAll('iframe[srcdoc]'));
return iframes.some(iframe =>
iframe.getAttribute('srcdoc')?.includes('dub_id=test-click-id')
);
});

Copilot uses AI. Check for mistakes.

// Check the first srcdoc iframe
const srcdocIframes = await page.$$('iframe[srcdoc]');
expect(srcdocIframes.length).toBeGreaterThan(0);

const firstSrcdocIframe = srcdocIframes[0];
const srcdocContent = await firstSrcdocIframe?.getAttribute('srcdoc');

// Should contain the tracking parameter in the URLs within srcdoc
expect(srcdocContent).toContain('dub_id=test-click-id');

// Check that both example.com and other.com URLs got the tracking parameter
expect(srcdocContent).toContain('example.com/booking?dub_id=test-click-id');
expect(srcdocContent).toContain('other.com/widget.js?dub_id=test-click-id');

Copilot AI Oct 7, 2025

Copy link

Choose a reason for hiding this comment

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

The test asserts that multiple distinct URLs inside the same srcdoc receive the tracking parameter, but the current implementation only transforms the first matched URL. Either enhance the implementation to handle all matches or adjust the test expectation to align with the implemented scope.

Suggested change
expect(srcdocContent).toContain('other.com/widget.js?dub_id=test-click-id');
// expect(srcdocContent).toContain('other.com/widget.js?dub_id=test-click-id');

Copilot uses AI. Check for mistakes.

// Check the second srcdoc iframe
if (srcdocIframes.length > 1) {
const secondSrcdocIframe = srcdocIframes[1];
const secondSrcdocContent =
await secondSrcdocIframe?.getAttribute('srcdoc');
expect(secondSrcdocContent).toContain(
'wildcard.com/test?dub_id=test-click-id',
);
}
});

test('should not add tracking to links on the same domain', async ({
page,
}) => {
Expand Down
62 changes: 55 additions & 7 deletions packages/script/src/extensions/outbound-domains.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,37 @@ const initOutboundDomains = () => {
}
}

function extractUrlFromSrcdoc(srcdoc) {
if (!srcdoc) return null;

// Look for URLs in the srcdoc content
// This is a basic implementation - may need refinement based on actual Cal.com usage
const urlRegex = /https?:\/\/[^\s"'<>]+/g;
const matches = srcdoc.match(urlRegex);

if (matches && matches.length > 0) {
// Return the first URL found - this might need to be more sophisticated
// depending on how Cal.com structures their srcdoc content
return matches[0];
}

return null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

function updateSrcdocWithTracking(element, originalUrl, newUrl) {
if (!element.srcdoc) return false;

try {
// Replace the original URL with the new URL in the srcdoc content
const updatedSrcdoc = element.srcdoc.replace(originalUrl, newUrl);

Copilot AI Oct 7, 2025

Copy link

Choose a reason for hiding this comment

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

Only the first matched URL in the srcdoc is returned, so additional outbound URLs inside the same srcdoc (e.g., multiple or <script src>) are never processed or tagged. Iterate over all matches and update each outbound URL instead of returning early.

Suggested change
if (!srcdoc) return null;
// Look for URLs in the srcdoc content
// This is a basic implementation - may need refinement based on actual Cal.com usage
const urlRegex = /https?:\/\/[^\s"'<>]+/g;
const matches = srcdoc.match(urlRegex);
if (matches && matches.length > 0) {
// Return the first URL found - this might need to be more sophisticated
// depending on how Cal.com structures their srcdoc content
return matches[0];
}
return null;
}
function updateSrcdocWithTracking(element, originalUrl, newUrl) {
if (!element.srcdoc) return false;
try {
// Replace the original URL with the new URL in the srcdoc content
const updatedSrcdoc = element.srcdoc.replace(originalUrl, newUrl);
if (!srcdoc) return [];
// Look for URLs in the srcdoc content
// This is a basic implementation - may need refinement based on actual Cal.com usage
const urlRegex = /https?:\/\/[^\s"'<>]+/g;
const matches = srcdoc.match(urlRegex);
// Return all matched URLs as an array, or an empty array if none found
return matches ? matches : [];
}
function updateSrcdocWithTracking(element, urlMap) {
if (!element.srcdoc) return false;
try {
let updatedSrcdoc = element.srcdoc;
// urlMap is an array of { originalUrl, newUrl }
urlMap.forEach(({ originalUrl, newUrl }) => {
// Replace all occurrences of the original URL with the new URL
const regex = new RegExp(originalUrl.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
updatedSrcdoc = updatedSrcdoc.replace(regex, newUrl);
});

Copilot uses AI. Check for mistakes.
element.srcdoc = updatedSrcdoc;

Copilot AI Oct 7, 2025

Copy link

Choose a reason for hiding this comment

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

String.replace without a global regex only replaces the first occurrence of originalUrl; if the same URL appears multiple times in srcdoc, later instances remain untagged. Use replaceAll (with proper escaping) or construct a global RegExp to ensure all occurrences are updated.

Copilot uses AI. Check for mistakes.
return true;
} catch (e) {
console.error('Error updating srcdoc:', e);
return false;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Major: String replace only replaces the first occurrence.

Line 56 uses replace() which only replaces the first occurrence of the URL. If the same URL appears multiple times in the srcdoc content, subsequent occurrences won't be updated.

Apply this diff:

-      const updatedSrcdoc = element.srcdoc.replace(originalUrl, newUrl);
+      const updatedSrcdoc = element.srcdoc.replaceAll(originalUrl, newUrl);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function updateSrcdocWithTracking(element, originalUrl, newUrl) {
if (!element.srcdoc) return false;
try {
// Replace the original URL with the new URL in the srcdoc content
const updatedSrcdoc = element.srcdoc.replace(originalUrl, newUrl);
element.srcdoc = updatedSrcdoc;
return true;
} catch (e) {
console.error('Error updating srcdoc:', e);
return false;
}
}
function updateSrcdocWithTracking(element, originalUrl, newUrl) {
if (!element.srcdoc) return false;
try {
// Replace the original URL with the new URL in the srcdoc content
const updatedSrcdoc = element.srcdoc.replaceAll(originalUrl, newUrl);
element.srcdoc = updatedSrcdoc;
return true;
} catch (e) {
console.error('Error updating srcdoc:', e);
return false;
}
}
🤖 Prompt for AI Agents
In packages/script/src/extensions/outbound-domains.js around lines 51 to 63, the
updateSrcdocWithTracking function uses String.prototype.replace which only
replaces the first occurrence; change the replacement to replace all occurrences
by creating a global replacement: escape originalUrl for safe use in a RegExp,
build a new RegExp(escapedOriginalUrl, 'g'), use srcdoc.replace(regexp, newUrl)
(or use split(originalUrl).join(newUrl) if you prefer avoiding regex), assign
the result back to element.srcdoc and keep the existing try/catch and return
values.


function addOutboundTracking(clickId) {
// Handle both string and array configurations for outbound domains
const outboundDomains = Array.isArray(DOMAINS_CONFIG.outbound)
Expand All @@ -47,16 +78,19 @@ const initOutboundDomains = () => {
const existingCookie = clickId || cookieManager.get(DUB_ID_VAR);
if (!existingCookie) return;

// Get all links and iframes
const elements = document.querySelectorAll('a[href], iframe[src]');
// Get all links and iframes (including those with srcdoc)
const elements = document.querySelectorAll(
'a[href], iframe[src], iframe[srcdoc]',
);
if (!elements || elements.length === 0) return;

elements.forEach((element) => {
// Skip already processed elements
if (outboundLinksUpdated.has(element)) return;

try {
const urlString = element.href || element.src;
const urlString =
element.href || element.src || extractUrlFromSrcdoc(element.srcdoc);
if (!urlString) return;

// Check if the URL matches any of our outbound domains
Expand All @@ -70,15 +104,29 @@ const initOutboundDomains = () => {
// Only add the tracking parameter if it's not already present
if (!url.searchParams.has(DUB_ID_VAR)) {
url.searchParams.set(DUB_ID_VAR, existingCookie);
const newUrlString = url.toString();

// Update the appropriate attribute based on element type
if (element.tagName.toLowerCase() === 'a') {
element.href = url.toString();
element.href = newUrlString;
outboundLinksUpdated.add(element);
} else if (element.tagName.toLowerCase() === 'iframe') {
element.src = url.toString();
if (element.src) {
// Standard iframe with src attribute
element.src = newUrlString;
outboundLinksUpdated.add(element);
} else if (element.srcdoc) {
// Iframe with srcdoc attribute (like Cal.com)
const updated = updateSrcdocWithTracking(
element,
urlString,
newUrlString,
);
if (updated) {
outboundLinksUpdated.add(element);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

The single-URL processing approach won't handle multiple outbound URLs in srcdoc.

This logic processes only one URL per element due to the Set check. For iframes with srcdoc containing multiple outbound URLs (like the test case with both example.com/booking and other.com/widget.js), only the first extracted URL will be tracked, and the element will be marked as processed. Other outbound URLs in the same srcdoc will never be updated.

This requires a more comprehensive refactor to:

  1. Extract all URLs from srcdoc
  2. Check each against outbound domains
  3. Update all matching URLs in a single pass
  4. Mark the element as processed only after all URLs have been handled

Example approach:

} else if (element.tagName.toLowerCase() === 'iframe') {
  if (element.src) {
    element.src = newUrlString;
    outboundLinksUpdated.add(element);
  } else if (element.srcdoc) {
    // Extract all URLs from srcdoc
    const urls = extractUrlsFromSrcdoc(element.srcdoc);
    let updatedSrcdoc = element.srcdoc;
    let hasUpdates = false;
    
    urls.forEach(url => {
      try {
        const parsedUrl = new URL(url);
        if (filteredDomains.some(domain => isMatchingDomain(url, domain))) {
          if (!parsedUrl.searchParams.has(DUB_ID_VAR)) {
            parsedUrl.searchParams.set(DUB_ID_VAR, existingCookie);
            updatedSrcdoc = updatedSrcdoc.replaceAll(url, parsedUrl.toString());
            hasUpdates = true;
          }
        }
      } catch (e) {
        // Skip invalid URLs
      }
    });
    
    if (hasUpdates) {
      element.srcdoc = updatedSrcdoc;
      outboundLinksUpdated.add(element);
    }
  }
}
🤖 Prompt for AI Agents
In packages/script/src/extensions/outbound-domains.js around lines 107 to 129,
the current logic only processes a single extracted URL per iframe.srcdoc and
marks the element as handled, which misses additional outbound URLs in the same
srcdoc; refactor to extract all URLs from srcdoc, iterate each URL, for each
valid URL check against the outbound domains and add the tracking param if
missing, build an updated srcdoc string replacing each matched URL with its
updated version, and only after processing all URLs set element.srcdoc to the
updated string and add the element to outboundLinksUpdated if any replacements
were made.


outboundLinksUpdated.add(element);
}
} catch (e) {
console.error('Error processing element:', e);
Expand Down