Skip to content
Draft
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
163 changes: 143 additions & 20 deletions packages/visual-editor/src/components/customCode/CustomCodeSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from "react";
import { CodeXml } from "lucide-react";
import { AnalyticsScopeProvider } from "@yext/pages-components";
import { AnalyticsScopeProvider, useAnalytics } from "@yext/pages-components";
import { VisibilityWrapper } from "../atoms/visibilityWrapper.tsx";
import { msg, pt } from "../../utils/i18n/platform.ts";
import { useDocument } from "../../hooks/useDocument.tsx";
Expand All @@ -9,6 +9,77 @@ import { resolveEmbeddedFieldsInString } from "../../utils/resolveYextEntityFiel
import { processHandlebarsTemplate } from "./customCodeHandlebars.ts";
import { YextComponentConfig, YextFields } from "../../fields/fields.ts";

type AnalyticsTrackProps = Parameters<
NonNullable<ReturnType<typeof useAnalytics>>["track"]
>[0];

type CustomCodeAnalyticsData = Record<string, unknown> & {
action?: AnalyticsTrackProps["action"];
};

/** The default analytics action used when Custom Code scripts do not provide one. */
const DEFAULT_CUSTOM_CODE_ANALYTICS_ACTION: AnalyticsTrackProps["action"] =
"C_CUSTOM";

type CustomCodeAnalyticsBridge = {
track: (eventName: string, data?: CustomCodeAnalyticsData) => void;
};

type YextCustomCodeAnalytics = {
register: (
componentId: string,
analyticsBridge: CustomCodeAnalyticsBridge
) => void;
unregister: (componentId: string) => void;
track: (
componentId: string,
eventName: string,
data?: CustomCodeAnalyticsData
) => void;
};

declare global {
interface Window {
YextCustomCodeAnalytics?: YextCustomCodeAnalytics;
}
}

/**
* Creates or returns the shared browser analytics bridge used by CustomCodeSection scripts.
* Each CustomCodeSection registers its scoped analytics instance by componentId so multiple
* custom code components can coexist without overwriting each other's analytics handlers.
*/
const getYextCustomCodeAnalytics = (): YextCustomCodeAnalytics => {
if (window.YextCustomCodeAnalytics) {
return window.YextCustomCodeAnalytics;
}

const scopes: Record<string, CustomCodeAnalyticsBridge> = {};

window.YextCustomCodeAnalytics = {
register(componentId, analyticsBridge) {
scopes[componentId] = analyticsBridge;
},
unregister(componentId) {
delete scopes[componentId];
},
track(componentId, eventName, data) {
const scope = scopes[componentId];

if (!scope) {
console.warn(
`No Custom Code analytics scope found for componentId: ${componentId}`
);
return;
}

scope.track(eventName, data);
},
};

return window.YextCustomCodeAnalytics;
};

export interface CustomCodeSectionProps {
/**
* The HTML content to be rendered. Must be present for the component to display.
Expand Down Expand Up @@ -89,7 +160,72 @@ const EmptyCustomCodeSection = () => {
);
};

/**
* Registers the current component's scoped analytics bridge before executing user JavaScript.
* This ensures injected scripts can call `yextAnalytics.track(...)` immediately while still
* using the AnalyticsScopeProvider context for this CustomCodeSection instance.
*/
const CustomCodeAnalyticsBridgeAndScriptRunner = ({
componentId,
containerRef,
processedJavascript,
scriptTagId,
}: {
componentId: string;
containerRef: React.RefObject<HTMLDivElement>;
processedJavascript: string;
scriptTagId: string;
}) => {
const analytics = useAnalytics();

React.useEffect(() => {
if (!containerRef.current || !processedJavascript) {
return;
}

const customCodeAnalytics = getYextCustomCodeAnalytics();
customCodeAnalytics.register(componentId, {
track: (eventName, data) => {
analytics?.track({
...data,
action: data?.action ?? DEFAULT_CUSTOM_CODE_ANALYTICS_ACTION,
eventName,
});
},
});

const prevScript = containerRef.current.querySelector(`#${scriptTagId}`);
if (prevScript) {
prevScript.remove();
}

const script = document.createElement("script");
script.id = scriptTagId;
script.type = "text/javascript";
// Wrap user code in a block so each component gets a local yextAnalytics helper without leaking a global.
script.text = `
{
const yextAnalytics = {
track: (eventName, data) =>
window.YextCustomCodeAnalytics.track(${JSON.stringify(componentId)}, eventName, data),
};

${processedJavascript}
}
`;
containerRef.current.appendChild(script);
Comment on lines +205 to +216

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the test to confirm reliance on global-scope handler resolution
fd -t f 'CustomCodeSection.test.tsx' packages/visual-editor/src/components/customCode --exec sed -n '1,80p' {}

Repository: yext/visual-editor

Length of output: 2587


🏁 Script executed:

python3 - <<'PY'
import subprocess, textwrap, json, os, sys, tempfile

# Use node's vm to run code as a script in a fresh global-like context.
js = r"""
const vm = require('vm');

function run(src) {
  const sandbox = { console };
  vm.createContext(sandbox);
  try {
    vm.runInContext(src, sandbox);
  } catch (e) {
    return { error: String(e), sandbox };
  }
  return { sandbox };
}

for (const [name, src] of [
  ['direct function', `function changeColor() {}`],
  ['block function', `{ function changeColor() {} }`],
  ['block const func expr', `{ const changeColor = () => {} }`],
  ['block let class', `{ let changeColor = 1; class Foo {} }`],
]) {
  const res = run(src);
  const sandbox = res.sandbox || {};
  console.log('---', name);
  console.log('error:', res.error || null);
  console.log('has changeColor in sandbox:', Object.prototype.hasOwnProperty.call(sandbox, 'changeColor'));
  console.log('typeof sandbox.changeColor:', typeof sandbox.changeColor);
  console.log('has Foo in sandbox:', Object.prototype.hasOwnProperty.call(sandbox, 'Foo'));
  console.log('typeof sandbox.Foo:', typeof sandbox.Foo);
}
"""
subprocess.run(["node", "-e", js], check=False)
PY

Repository: yext/visual-editor

Length of output: 797


Wrapping user JS in a block breaks global handler resolution.
let/const/class declarations inside the injected script stay block-scoped, so inline handlers like onclick="changeColor(this)" will fail unless the handler is a function declaration. That still works only because of sloppy-mode Annex B semantics, so the current test can miss the regression. Keep the user script at top level and inject yextAnalytics another way.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/visual-editor/src/components/customCode/CustomCodeSection.tsx`
around lines 205 - 216, The injected user script in CustomCodeSection’s script
creation is wrapped in a block, which prevents global handler resolution for
inline callbacks like onclick="changeColor(this)". Update the script injection
around processedJavascript so user code runs at top level instead of inside a
block, and provide yextAnalytics through another mechanism in the same
CustomCodeSection flow without relying on block-scoped declarations.


return () => {
script.remove();
customCodeAnalytics.unregister(componentId);
};
}, [analytics, componentId, processedJavascript, scriptTagId]);

return null;
};

const CustomCodeSectionWrapper = ({
id,
html,
css,
javascript,
Expand All @@ -109,25 +245,6 @@ const CustomCodeSectionWrapper = ({
locale
);

React.useEffect(() => {
if (!containerRef.current) {
return;
}

const prevScript = containerRef.current.querySelector(`#${scriptTagId}`);
if (prevScript) {
prevScript.remove();
}

if (processedJavascript) {
const script = document.createElement("script");
script.id = scriptTagId;
script.type = "text/javascript";
script.text = processedJavascript;
containerRef.current.appendChild(script);
}
}, [processedJavascript]);

if (!processedHtml) {
return puck.isEditing ? <EmptyCustomCodeSection /> : null;
}
Expand All @@ -139,6 +256,12 @@ const CustomCodeSectionWrapper = ({
ref={containerRef}
dangerouslySetInnerHTML={{ __html: processedHtml }}
/>
<CustomCodeAnalyticsBridgeAndScriptRunner
componentId={id}
containerRef={containerRef}
processedJavascript={processedJavascript}
scriptTagId={scriptTagId}
/>
</div>
);
};
Expand Down
Loading