diff --git a/quartz/components/registry.ts b/quartz/components/registry.ts index 65c07ea1a..5f850d0b8 100644 --- a/quartz/components/registry.ts +++ b/quartz/components/registry.ts @@ -18,6 +18,7 @@ export interface RegisteredComponent { class ComponentRegistry { private components = new Map() + private instanceCache = new Map() register( name: string, @@ -40,6 +41,30 @@ class ComponentRegistry { return new Map(this.components) } + /** + * Instantiate a component constructor with options, returning a cached instance + * if the same constructor was already called with equivalent options. + * This prevents duplicate afterDOMLoaded scripts when the same component + * appears in multiple page-type layouts. + */ + instantiate(constructor: QuartzComponentConstructor, options?: unknown): QuartzComponent { + const optsKey = options !== undefined ? JSON.stringify(options) : "" + // Use constructor identity + serialized options as cache key + // We store constructor name as a hint but rely on a unique id for identity + const ctorId = + (constructor as unknown as { __cacheId?: string }).__cacheId ?? + ((constructor as unknown as { __cacheId: string }).__cacheId = + `ctor_${this.instanceCache.size}`) + const cacheKey = `${ctorId}:${optsKey}` + + const cached = this.instanceCache.get(cacheKey) + if (cached) return cached + + const instance = constructor(options) + this.instanceCache.set(cacheKey, instance) + return instance + } + getAllComponents(): QuartzComponent[] { // Deduplicate by component reference (same constructor may be registered under multiple keys) const seen = new Set() @@ -50,7 +75,7 @@ class ComponentRegistry { try { let instance: QuartzComponent if (typeof r.component === "function") { - instance = (r.component as QuartzComponentConstructor)(undefined) + instance = this.instantiate(r.component as QuartzComponentConstructor, undefined) } else { instance = r.component as QuartzComponent } diff --git a/quartz/plugins/loader/config-loader.ts b/quartz/plugins/loader/config-loader.ts index 876216859..2e28358da 100644 --- a/quartz/plugins/loader/config-loader.ts +++ b/quartz/plugins/loader/config-loader.ts @@ -3,7 +3,7 @@ import path from "path" import YAML from "yaml" import { styleText } from "util" import { QuartzConfig, GlobalConfiguration, FullPageLayout } from "../../cfg" -import { QuartzComponent } from "../../components/types" +import { QuartzComponent, QuartzComponentConstructor } from "../../components/types" import { PluginTypes } from "../types" import { PluginManifest, @@ -561,11 +561,12 @@ export async function loadQuartzLayout(layoutOverrides?: { const footerReg = componentRegistry.get("footer") ?? componentRegistry.get("Footer") if (footerReg) { if (typeof footerReg.component === "function" && !("displayName" in footerReg.component)) { - // It's a constructor, instantiate with options + // It's a constructor — use registry cache for consistent instances const opts = { ...footerEntry.options } - footer = (footerReg.component as Function)( + footer = componentRegistry.instantiate( + footerReg.component as QuartzComponentConstructor, Object.keys(opts).length > 0 ? opts : undefined, - ) as QuartzComponent + ) } else { footer = footerReg.component as QuartzComponent } @@ -648,11 +649,14 @@ function buildLayoutForEntries( let component: QuartzComponent if (typeof reg.component === "function" && !("displayName" in reg.component)) { - // It's a constructor, instantiate with options + // It's a constructor — use registry cache to avoid duplicate instances + // (and duplicate afterDOMLoaded scripts) across page-type layouts const opts = { ...entry.options } - component = (reg.component as Function)( - Object.keys(opts).length > 0 ? opts : undefined, - ) as QuartzComponent + const optsArg = Object.keys(opts).length > 0 ? opts : undefined + component = componentRegistry.instantiate( + reg.component as QuartzComponentConstructor, + optsArg, + ) } else { component = reg.component as QuartzComponent }