Introduction
Hi, I'm a big fan of Quartz and have been excited about the upcoming v5 with the plugin system. Even though it hasn't been officially released and everything is moving fast, I still can't wait to migrate my site to v5 and give it a try. The modularity is great! I can finally configure my site without having to modify typescript (which I don't understand) and maintain my own fork. All I need to keep is reduced to a yaml and a lockfile (and some custom CSS). This is wonderful.
Problem Description
- The explorer toggle button is not working on my site, neither on desktop nor mobile.
- Nevertheless, the explorer toggle in Quartz's documentation site built on v5 is working.
- I tried to tweak my config and discovered that the toggle button works when I remove all customization in the explorer plugin options.
The versions of my setup are:
- Quartz: branch
v5, commit 6d08b5f0703346edcccff2a49c5369d5c58a6a00.
- Explorer plugin: commit
01c780361b12e55b02ee2db0982ee5386e99ce31.
Out of curiosity, I prompted AI to explore and debug this issue since I know nothing about web development or typescript. We went back and forth and ran some small scripts in the browser to locate the issue. Below is a reference of the bug report it generated.
I want to help, but I don't want to bombard this GitHub issue with irresponsible AI-generated content, so the words so far are entirely written by human. I'm willing to apologize if you find this irritating. In addition, let me know if you need any extra clarifications from me, and thank you for this project!
Toggle AI-generated bug report
Explorer Toggle Double-Fire Bug Report
Summary
The Explorer plugin's toggle button (both desktop and mobile) stops working when the plugin is configured with custom options in quartz.config.yaml. Clicking the toggle appears to do nothing because the click handler fires twice, toggling the .collapsed class on then immediately off (or vice versa), resulting in no net change.
Affected Versions
- Quartz core: v5 (
main branch as of 2025-03-24)
- Explorer plugin:
github:quartz-community/explorer (installed via npx quartz plugin add)
Reproduction
-
In quartz.config.yaml, configure the explorer plugin with custom options:
- source: github:quartz-community/explorer
enabled: true
options:
title: My Explorer
folderClickBehavior: link
folderDefaultState: collapsed
useSavedState: false
layout:
position: left
priority: 50
-
Build and serve the site (npx quartz build --serve).
-
On desktop, click the Explorer title/chevron toggle. The explorer content will not expand/collapse. On mobile, the hamburger menu similarly does not open.
-
Control case: Remove the options: block entirely (or leave it empty). Rebuild. The toggle now works correctly.
Root Cause
The Explorer's inline script (explorer.inline.ts) is included twice in the compiled postscript.js, causing two separate handleNavOrRender functions to be registered on the nav event. When nav fires, both handlers execute, and each calls classList.toggle("collapsed") on the same element — the two toggles cancel each other out.
Why the script is duplicated
The duplication stems from an interaction between the component registry's instantiation cache and the resource collection pipeline:
-
buildLayoutForEntries (config-loader.ts:726–734) instantiates the Explorer constructor via componentRegistry.instantiate(constructor, optsArg) where optsArg is the user-provided options object (e.g. { title: "My Explorer", folderClickBehavior: "link", ... }). The cache key becomes ctor_N:{"folderClickBehavior":"link",...}. This creates Instance A.
-
componentRegistry.getAllComponents() (registry.ts:71–93), called by getComponentResources (componentResources.ts:39), iterates all registered constructors and calls instantiate(constructor, undefined). The cache key becomes ctor_N: (empty options string). Since this differs from step 1, a second instance (Instance B) is created.
-
Each Explorer instance internally calls OverflowListFactory(), which increments a module-level counter (numLists++) and embeds the resulting unique ID (list-0, list-1, ...) into its overflowListAfterDOMLoaded script string.
-
Each instance's afterDOMLoaded is the concatenation of the explorer inline script and the overflow list script. Because the overflow list IDs differ, the two combined strings are not identical.
-
getComponentResources uses a Set<string> to deduplicate afterDOMLoaded scripts. Since the strings differ, both are included in postscript.js, each wrapped in its own IIFE.
-
Both IIFEs execute document.addEventListener("nav", handleNavOrRender), registering two independent handlers. A single nav event triggers both, producing the double-toggle.
Why the default config is unaffected
When no custom options are specified, buildLayoutForEntries computes optsArg as undefined (line 730: Object.keys(opts).length > 0 ? opts : undefined). This matches the cache key used by getAllComponents(), so both calls return the same cached instance. Only one afterDOMLoaded string is produced, and the toggle works correctly.
Suggested Fixes
Option A: Fix in componentResources.ts (Quartz core)
Deduplicate collected components by constructor identity before extracting resources, rather than relying on string-level deduplication of the generated scripts:
// In getComponentResources(), after collecting allComponents:
// Deduplicate by checking if two components share the same constructor origin
Option B: Fix in registry.ts (Quartz core)
getAllComponents() currently instantiates every registered constructor with undefined options. It could instead skip constructors that already have a cached instance (with any options), or return all cached instances rather than creating new default-options ones:
getAllComponents(): QuartzComponent[] {
// Return all cached instances instead of creating new ones with undefined options
const seen = new Set<QuartzComponent>()
for (const instance of this.instanceCache.values()) {
seen.add(instance)
}
return [...seen]
}
Option C: Fix in Explorer plugin (explorer.inline.ts)
Guard the event listener attachment to prevent duplicate handlers on the same element:
if (explorer.dataset.listenersAttached) continue;
explorer.dataset.listenersAttached = "true";
// ... attach click handlers ...
// On cleanup:
// delete explorer.dataset.listenersAttached;
This is the most surgical and backwards-compatible fix, but it treats the symptom rather than the underlying resource duplication.
Option D: Fix in config-loader.ts (Quartz core)
Ensure buildLayoutForEntries also registers the options-instantiated component in a way that getAllComponents() won't create a redundant default-options instance. For example, after instantiating with user options, mark the constructor so getAllComponents() skips it.
Impact
This bug affects any plugin with an inline script that is configured with custom options in quartz.config.yaml, not just the Explorer. Any component whose constructor produces unique afterDOMLoaded strings (e.g., via counters or generated IDs) will have its script duplicated in postscript.js when custom options cause a cache key mismatch.
Plugins observed to use the same nav/render dual-listener pattern that would be affected: darkmode, reader-mode, search, table-of-contents, graph.
Introduction
Hi, I'm a big fan of Quartz and have been excited about the upcoming v5 with the plugin system. Even though it hasn't been officially released and everything is moving fast, I still can't wait to migrate my site to v5 and give it a try. The modularity is great! I can finally configure my site without having to modify typescript (which I don't understand) and maintain my own fork. All I need to keep is reduced to a yaml and a lockfile (and some custom CSS). This is wonderful.
Problem Description
The versions of my setup are:
v5, commit6d08b5f0703346edcccff2a49c5369d5c58a6a00.01c780361b12e55b02ee2db0982ee5386e99ce31.Out of curiosity, I prompted AI to explore and debug this issue since I know nothing about web development or typescript. We went back and forth and ran some small scripts in the browser to locate the issue. Below is a reference of the bug report it generated.
I want to help, but I don't want to bombard this GitHub issue with irresponsible AI-generated content, so the words so far are entirely written by human. I'm willing to apologize if you find this irritating. In addition, let me know if you need any extra clarifications from me, and thank you for this project!
Toggle AI-generated bug report
Explorer Toggle Double-Fire Bug Report
Summary
The Explorer plugin's toggle button (both desktop and mobile) stops working when the plugin is configured with custom options in
quartz.config.yaml. Clicking the toggle appears to do nothing because the click handler fires twice, toggling the.collapsedclass on then immediately off (or vice versa), resulting in no net change.Affected Versions
mainbranch as of 2025-03-24)github:quartz-community/explorer(installed vianpx quartz plugin add)Reproduction
In
quartz.config.yaml, configure the explorer plugin with custom options:Build and serve the site (
npx quartz build --serve).On desktop, click the Explorer title/chevron toggle. The explorer content will not expand/collapse. On mobile, the hamburger menu similarly does not open.
Control case: Remove the
options:block entirely (or leave it empty). Rebuild. The toggle now works correctly.Root Cause
The Explorer's inline script (
explorer.inline.ts) is included twice in the compiledpostscript.js, causing two separatehandleNavOrRenderfunctions to be registered on thenavevent. Whennavfires, both handlers execute, and each callsclassList.toggle("collapsed")on the same element — the two toggles cancel each other out.Why the script is duplicated
The duplication stems from an interaction between the component registry's instantiation cache and the resource collection pipeline:
buildLayoutForEntries(config-loader.ts:726–734) instantiates the Explorer constructor viacomponentRegistry.instantiate(constructor, optsArg)whereoptsArgis the user-provided options object (e.g.{ title: "My Explorer", folderClickBehavior: "link", ... }). The cache key becomesctor_N:{"folderClickBehavior":"link",...}. This creates Instance A.componentRegistry.getAllComponents()(registry.ts:71–93), called bygetComponentResources(componentResources.ts:39), iterates all registered constructors and callsinstantiate(constructor, undefined). The cache key becomesctor_N:(empty options string). Since this differs from step 1, a second instance (Instance B) is created.Each Explorer instance internally calls
OverflowListFactory(), which increments a module-level counter (numLists++) and embeds the resulting unique ID (list-0,list-1, ...) into itsoverflowListAfterDOMLoadedscript string.Each instance's
afterDOMLoadedis the concatenation of the explorer inline script and the overflow list script. Because the overflow list IDs differ, the two combined strings are not identical.getComponentResourcesuses aSet<string>to deduplicateafterDOMLoadedscripts. Since the strings differ, both are included inpostscript.js, each wrapped in its own IIFE.Both IIFEs execute
document.addEventListener("nav", handleNavOrRender), registering two independent handlers. A singlenavevent triggers both, producing the double-toggle.Why the default config is unaffected
When no custom options are specified,
buildLayoutForEntriescomputesoptsArgasundefined(line 730:Object.keys(opts).length > 0 ? opts : undefined). This matches the cache key used bygetAllComponents(), so both calls return the same cached instance. Only oneafterDOMLoadedstring is produced, and the toggle works correctly.Suggested Fixes
Option A: Fix in
componentResources.ts(Quartz core)Deduplicate collected components by constructor identity before extracting resources, rather than relying on string-level deduplication of the generated scripts:
Option B: Fix in
registry.ts(Quartz core)getAllComponents()currently instantiates every registered constructor withundefinedoptions. It could instead skip constructors that already have a cached instance (with any options), or return all cached instances rather than creating new default-options ones:Option C: Fix in Explorer plugin (
explorer.inline.ts)Guard the event listener attachment to prevent duplicate handlers on the same element:
This is the most surgical and backwards-compatible fix, but it treats the symptom rather than the underlying resource duplication.
Option D: Fix in
config-loader.ts(Quartz core)Ensure
buildLayoutForEntriesalso registers the options-instantiated component in a way thatgetAllComponents()won't create a redundant default-options instance. For example, after instantiating with user options, mark the constructor sogetAllComponents()skips it.Impact
This bug affects any plugin with an inline script that is configured with custom options in
quartz.config.yaml, not just the Explorer. Any component whose constructor produces uniqueafterDOMLoadedstrings (e.g., via counters or generated IDs) will have its script duplicated inpostscript.jswhen custom options cause a cache key mismatch.Plugins observed to use the same
nav/renderdual-listener pattern that would be affected:darkmode,reader-mode,search,table-of-contents,graph.