Skip to content
Draft
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
2 changes: 1 addition & 1 deletion packages/visual-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"i18n:check-empty": "pnpm exec tsx scripts/checkEmptyTranslations.ts"
},
"dependencies": {
"@measured/puck": "0.20.2",
"@measured/puck": "file:../../../puck/packages/core",
"@microsoft/api-documenter": "^7.26.29",
"@microsoft/api-extractor": "^7.52.8",
"@microsoft/api-extractor-model": "^7.30.6",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ export const TEXT_LIST_CONSTANT_CONFIG: CustomField<string[]> = {
<span style={{ color: TEXT_LIST_BUTTON_COLOR }}>
<IconButton
onClick={() => removeItem(index)}
variant="secondary"
title={pt("deleteItem", "Delete Item")}
type="button"
>
Expand Down Expand Up @@ -181,7 +180,6 @@ export const TRANSLATABLE_TEXT_LIST_CONSTANT_CONFIG: CustomField<
<div className="ve-flex ve-justify-end">
<IconButton
onClick={() => removeItem(index)}
variant="secondary"
title={pt("deleteItem", "Delete Item")}
type="button"
>
Expand Down
5 changes: 4 additions & 1 deletion packages/visual-editor/src/utils/VisualEditorProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ const VisualEditorProvider = <T extends Record<string, any>>({
tailwindConfig,
children,
}: VisualEditorProviderProps<T>) => {
const queryClient = new QueryClient();
// Use useMemo to prevent creating a new QueryClient on every render
// QueryClient maintains internal caches, so creating new instances unnecessarily
// could lead to memory accumulation
const queryClient = React.useMemo(() => new QueryClient(), []);
const normalizedTemplateProps = React.useMemo(
() => normalizeLocalesInObject(templateProps),
[templateProps]
Expand Down
159 changes: 159 additions & 0 deletions packages/visual-editor/src/utils/clearPuckCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Clears Puck's internal cache to prevent memory leaks when processing many pages.
*
* Puck's resolveComponentData maintains a module-level cache that stores resolved
* data per component ID. This cache never gets cleared, causing memory to accumulate
* when processing many unique pages. This function clears that cache.
*
* The cache is exported from @measured/puck's resolve-component-data module.
* We access it via Node.js require.cache to clear it after each page processing.
*/
/**
* Gets the current size of Puck's cache for logging purposes
*/
export function getPuckCacheSize(): number {
try {
const puckPath = require.resolve("@measured/puck");
const puckModule = require.cache[puckPath];

if (!puckModule) {
return -1;
}

const exports = puckModule.exports;

if (exports?.cache?.lastChange) {
return Object.keys(exports.cache.lastChange).length;
}

try {
const resolveComponentDataPath = require.resolve(
"@measured/puck/lib/resolve-component-data"
);
const resolveModule = require.cache[resolveComponentDataPath];
if (resolveModule?.exports?.cache?.lastChange) {
return Object.keys(resolveModule.exports.cache.lastChange).length;
}
} catch {
// Module path might be different, continue to next attempt
}

if (typeof require.cache === "object") {
for (const [modulePath, module] of Object.entries(require.cache)) {
if (modulePath.includes("puck") && module?.exports?.cache?.lastChange) {
return Object.keys(module.exports.cache.lastChange).length;
}
}
}
} catch {
// Silently fail
}
return -1;
}

// Direct import for Deno/ESM compatibility
// This will work in both Node.js and Deno
// Note: We can't import this at build time as it's not exported, so we'll access it via require.cache at runtime
let puckCacheModule: { cache?: { lastChange?: Record<string, any> } } | null =
null;

export function clearPuckCache(): void {
const cacheSizeBefore = getPuckCacheSize();

try {
// Try direct module reference first (works if module was already loaded)
if (puckCacheModule?.cache?.lastChange) {
const sizeBefore = Object.keys(puckCacheModule.cache.lastChange).length;
puckCacheModule.cache.lastChange = {};
console.log(
`[Puck Cache] clearPuckCache: Cleared ${sizeBefore} entries via direct module (was ${sizeBefore}, now 0)`
);
return;
}

// Node.js approach - Access Puck's module from require cache
// This works in Node.js but not in Deno
if (
typeof require !== "undefined" &&
typeof require.cache !== "undefined"
) {
try {
const puckPath = require.resolve("@measured/puck");
const puckModule = require.cache[puckPath];

if (!puckModule) {
if (cacheSizeBefore >= 0) {
console.log(
`[Puck Cache] clearPuckCache: Puck module not found in require cache`
);
}
return;
}

// Try to find the cache in the module exports
const exports = puckModule.exports;

// Check if cache is directly on exports
if (exports?.cache?.lastChange) {
const sizeBefore = Object.keys(exports.cache.lastChange).length;
exports.cache.lastChange = {};
console.log(
`[Puck Cache] clearPuckCache: Cleared ${sizeBefore} entries (was ${sizeBefore}, now 0)`
);
return;
}

// Try to access via internal module path
try {
const resolveComponentDataPath = require.resolve(
"@measured/puck/lib/resolve-component-data"
);
const resolveModule = require.cache[resolveComponentDataPath];
if (resolveModule?.exports?.cache?.lastChange) {
const sizeBefore = Object.keys(
resolveModule.exports.cache.lastChange
).length;
resolveModule.exports.cache.lastChange = {};
console.log(
`[Puck Cache] clearPuckCache: Cleared ${sizeBefore} entries (was ${sizeBefore}, now 0)`
);
return;
}
} catch {
// Module path might be different, continue to next attempt
}

// Last resort: search through all cached modules for the cache object
for (const [modulePath, module] of Object.entries(require.cache)) {
if (
modulePath.includes("puck") &&
module?.exports?.cache?.lastChange
) {
const sizeBefore = Object.keys(
module.exports.cache.lastChange
).length;
module.exports.cache.lastChange = {};
console.log(
`[Puck Cache] clearPuckCache: Cleared ${sizeBefore} entries via search (was ${sizeBefore}, now 0)`
);
return;
}
}
} catch {
// require not available or failed
}
}

if (cacheSizeBefore >= 0) {
console.log(
`[Puck Cache] clearPuckCache: Cache not found (size was ${cacheSizeBefore})`
);
}
} catch (_error) {
// Log error for debugging
console.error(
`[Puck Cache] clearPuckCache: Error clearing cache (size before: ${cacheSizeBefore}):`,
_error
);
}
}
2 changes: 2 additions & 0 deletions packages/visual-editor/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export {
} from "./migrate.ts";
export { resolveComponentData } from "./resolveComponentData.tsx";
export { resolveYextEntityField } from "./resolveYextEntityField.ts";
// export { clearPuckCache, getPuckCacheSize } from "./clearPuckCache.ts";
// export { clearPuckCacheAsync } from "./clearPuckCacheAsync.ts";
export {
resolveUrlTemplateOfChild,
resolvePageSetUrlTemplate,
Expand Down
24 changes: 24 additions & 0 deletions packages/visual-editor/src/vite-plugin/templates/directory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import {
directoryConfig,
getSchema,
getCanonicalUrl,
// clearPuckCache,
// getPuckCacheSize,
// clearPuckCacheAsync,
} from "@yext/visual-editor";
import { AnalyticsProvider, SchemaWrapper } from "@yext/pages-components";

Expand Down Expand Up @@ -111,10 +114,31 @@ export const transformProps: TransformProps<TemplateProps> = async (props) => {
directoryConfig,
document
);

// Log cache size before resolveAllData
// const cacheSizeBefore = getPuckCacheSize();
// console.log(
// `[VE transformProps] Before resolveAllData for document ${document.id}: cache size = ${cacheSizeBefore}`
// );

const updatedData = await resolveAllData(migratedData, directoryConfig, {
streamDocument: document,
});

// Log cache size after resolveAllData but before clearing
// const cacheSizeAfterResolve = getPuckCacheSize();
// console.log(
// `[VE transformProps] After resolveAllData for document ${document.id}: cache size = ${cacheSizeAfterResolve} (was ${cacheSizeBefore})`
// );

// Clear Puck's internal cache after resolving data to prevent memory leaks
// when processing many pages. The cache stores resolved data per component ID
// and never gets cleared, causing memory to accumulate across page invocations.
// The cache also stores parentData with references to document objects, which
// prevents garbage collection and causes exponential memory growth.
// Use async version for Deno compatibility
// await clearPuckCacheAsync();

return { ...props, data: updatedData };
};

Expand Down
24 changes: 24 additions & 0 deletions packages/visual-editor/src/vite-plugin/templates/locator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import {
getCanonicalUrl,
migrate,
migrationRegistry,
// clearPuckCache,
// getPuckCacheSize,
// clearPuckCacheAsync,
} from "@yext/visual-editor";
import { AnalyticsProvider, SchemaWrapper } from "@yext/pages-components";
import mapboxPackageJson from "mapbox-gl/package.json";
Expand Down Expand Up @@ -112,10 +115,31 @@ export const transformProps: TransformProps<TemplateProps> = async (props) => {
locatorConfig,
document
);

// Log cache size before resolveAllData
// const cacheSizeBefore = getPuckCacheSize();
// console.log(
// `[VE transformProps] Before resolveAllData for document ${document.id}: cache size = ${cacheSizeBefore}`
// );

const updatedData = await resolveAllData(migratedData, locatorConfig, {
streamDocument: document,
});

// Log cache size after resolveAllData but before clearing
// const cacheSizeAfterResolve = getPuckCacheSize();
// console.log(
// `[VE transformProps] After resolveAllData for document ${document.id}: cache size = ${cacheSizeAfterResolve} (was ${cacheSizeBefore})`
// );

// Clear Puck's internal cache after resolving data to prevent memory leaks
// when processing many pages. The cache stores resolved data per component ID
// and never gets cleared, causing memory to accumulate across page invocations.
// The cache also stores parentData with references to document objects, which
// prevents garbage collection and causes exponential memory growth.
// Use async version for Deno compatibility
// await clearPuckCacheAsync();

return { ...props, data: updatedData };
};

Expand Down
6 changes: 6 additions & 0 deletions packages/visual-editor/src/vite-plugin/templates/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
mainConfig,
getSchema,
getCanonicalUrl,
clearPuckCache,
} from "@yext/visual-editor";
import { AnalyticsProvider, SchemaWrapper } from "@yext/pages-components";

Expand Down Expand Up @@ -112,6 +113,11 @@ export const transformProps: TransformProps<TemplateProps> = async (props) => {
streamDocument: document,
});

// Clear Puck's internal cache after resolving data to prevent memory leaks
// when processing many pages. The cache stores resolved data per component ID
// and never gets cleared, causing memory to accumulate across page invocations.
clearPuckCache();

return { ...props, data: updatedData };
};

Expand Down
Loading
Loading