Skip to content

refactor(imports): untangle ChoiceView's circular deps so settings views are CI-testable#1254

Merged
chhoumann merged 3 commits into
masterfrom
fix/1249-untangle-choiceview-cycles
May 30, 2026
Merged

refactor(imports): untangle ChoiceView's circular deps so settings views are CI-testable#1254
chhoumann merged 3 commits into
masterfrom
fix/1249-untangle-choiceview-cycles

Conversation

@chhoumann
Copy link
Copy Markdown
Owner

@chhoumann chhoumann commented May 30, 2026

Closes #1249.

Problem

ChoiceView (and the other settings views) could not be mounted in a vitest component test. Their import graph formed a 43-module strongly-connected component that esbuild's production bundle tolerates but vitest's ESM evaluation order does not — it threw Class extends value undefined (the fatal edge was VDateInputPrompt extends GenericInputPrompt, reached via quickAddApi). As a result the ChoiceView save/persistence flow was only covered by a local obsidian-e2e test that CI can't run.

Root causes & fix

Two structural causes, identified with an import-graph SCC analysis and broken here:

  1. The main.ts god-module singleton. 11 leaf modules did import QuickAdd from "./main" purely to read the static singleton QuickAdd.instance — and main.ts imports the whole app, dragging everything into one cycle. New leaf module src/quickAddInstance.ts holds the instance (imports main as a type only, fully erased). main.onload publishes it via setQuickAddInstance(this); the 11 leaf modules read it via getQuickAddInstance(). Nothing value-imports main anymore, so it drops out of every cycle.
  2. completeFormatter ⇄ engines. completeFormatter statically imported SingleMacroEngine/SingleTemplateEngine/SingleInlineScriptEngine, which transitively import it back. They're now loaded lazily with await import() at their async call-sites (esbuild inlines these into the single-file bundle).

The 43-node cycle is gone — only benign recursive-component (MultiChoiceListItem ⇄ ChoiceList) and mutual-function (choiceSuggester ⇄ choiceExecutor) cycles remain, none with cross-cycle class inheritance.

Tests

  • src/gui/choiceList/ChoiceView.test.tsrender(ChoiceView) now mounts in vitest, plus the conditional Then/Else persistence flow through ChoiceView's real save path (full nested-fidelity + plain-snapshot assertions). Complements the local e2e test.
  • src/importCycles.test.ts — structural regression guard: builds the value-import graph, runs Tarjan SCC, and fails if any class X extends Y where Y is value-imported from X's own SCC. Proven to catch a regression of either fix (a behavioural test alone does not — the removed completeFormatter ⇄ engine cycle is benign-but-fragile today, so reverting that half leaves the whole suite green).
  • Strengthened persistenceBoundary.test.ts with nested-branch detachment coverage and corrected its now-stale header comment.

Verification

Behaviour-preserving. Full vitest suite 1457 passing (was 1451; +6), tsc --noEmit, svelte-check (0 errors), ESLint, and a production esbuild build all pass.

Also verified live in the dev vault (real Obsidian):

  • Plugin loads clean — no errors, no "instance accessed before initialised".
  • Settings → QuickAdd tab renders the full ChoiceView (filter, all rows, AddChoiceBox).
  • Conditional macro: Configure → Edit then branch → Add Wait → Save persists thenCommands (length 1, elseCommands empty) to data.json as plain JSON with no $state Proxy artifacts.
  • The lazily-imported SingleInlineScriptEngine and SingleTemplateEngine execute correctly at runtime via api.format.

Release impact

None — behaviour-preserving refactor + tests (no feat/fix/perf, so no release). main.js is gitignored and not part of the diff.


Open in Devin Review

Summary by CodeRabbit

  • Refactor

    • Centralized how the plugin obtains its runtime instance and switched some engines to load dynamically to improve module isolation and stability.
  • Bug Fixes

    • Resolved circular-import issues that could crash or prevent UI components, modals, and suggesters from initializing.
  • Tests

    • Added component tests and a new import-cycle regression guard to detect problematic module cycles early.

Review Change Stack

…ews are CI-testable

ChoiceView and the other settings views could not be mounted in a vitest
component test: their import graph formed a 43-module strongly-connected
component that esbuild's bundle tolerates but vitest's ESM evaluation order
does not, throwing "Class extends value undefined".

Two structural causes, both broken here:

- 11 leaf modules value-imported main.ts purely to read the static singleton
  QuickAdd.instance, dragging the entire app into one cycle. Add a leaf holder
  src/quickAddInstance.ts (imports main as a TYPE only); main.onload publishes
  the instance via setQuickAddInstance and the leaf modules read it via
  getQuickAddInstance, so nothing value-imports main anymore.
- completeFormatter statically imported three engines that transitively import
  it back; load them lazily with `await import()` at their async call-sites.

The 43-node cycle is gone; only benign recursive-component and mutual-function
cycles remain, none with cross-cycle class inheritance.

Tests:
- ChoiceView.test.ts: render(ChoiceView) in vitest, plus the conditional
  Then/Else persistence flow through the real save path, complementing the
  local obsidian-e2e test (which CI cannot run).
- importCycles.test.ts: structural guard that fails if any class extends a base
  value-imported from its own import cycle (proven to catch a regression of
  either fix, which a behavioural test alone does not).

Behaviour-preserving: full vitest suite (1457), tsc, svelte-check, eslint and a
production build all pass, and the flow was verified live in the dev vault
(plugin loads clean, settings view renders, conditional then-branch persists
plain JSON to disk, lazily imported engines execute).

Closes #1249
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2a7f2a9a-4ba1-4254-80bb-cf026b1f5c3d

📥 Commits

Reviewing files that changed from the base of the PR and between ed7a4dc and 3decb7e.

📒 Files selected for processing (1)
  • src/importCycles.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/importCycles.test.ts

📝 Walkthrough

Walkthrough

Replaces static QuickAdd.instance with a runtime accessor (get/set); migrates consumers to call getQuickAddInstance(); defers engine imports in CompleteFormatter via dynamic imports; adds component and import-cycle regression tests ensuring ChoiceView mounts in Vitest.

Changes

Breaking circular imports via instance accessor pattern

Layer / File(s) Summary
Instance accessor module and plugin initialization
src/quickAddInstance.ts, src/main.ts
New quickAddInstance.ts exports getQuickAddInstance() and setQuickAddInstance(); QuickAdd.onload() calls the setter and the static QuickAdd.instance field is removed.
Consumer migration to instance accessor
src/engine/MacroChoiceEngine.ts, src/gui/AIAssistantSettingsModal.ts, src/gui/InputPrompt.ts, src/gui/MacroGUIs/*, src/gui/MathModal.ts, src/gui/choiceList/*, src/gui/suggesters/*, src/utils/macroHelpers.ts
~20 files updated to import and call getQuickAddInstance() instead of reading QuickAdd.instance for formatter construction, settings lookup, event registration, and app access.
Dynamic imports in formatter to defer engine loading
src/formatters/completeFormatter.ts
Removed static imports of engine classes; CompleteFormatter now lazy-loads SingleMacroEngine, SingleTemplateEngine, and SingleInlineScriptEngine via await import(...) inside their methods.
Test mocks and component tests updated
src/gui/suggesters/fileSuggester.test.ts, src/gui/choiceList/ChoiceView.test.ts, src/gui/choiceList/persistenceBoundary.test.ts
Added mocks for quickAddInstance, added a ChoiceView Vitest component test (mounting and persistence assertions), and extended persistenceBoundary tests to ensure snapshots deep-detach nested branches.

Circular import regression and validation

Layer / File(s) Summary
Import cycle static analysis regression test
src/importCycles.test.ts
New Vitest test that scans the source tree, builds a value-import graph, computes strongly connected components (Tarjan), and fails if a class inherits across a value-import cycle (prevents 'class extends value undefined').
sequenceDiagram
  participant Plugin as QuickAdd Plugin
  participant Accessor as quickAddInstance
  participant Formatter as CompleteFormatter
  participant Consumer as ChoiceView / UI
  Plugin->>Accessor: setQuickAddInstance(this)
  Accessor->>Consumer: getQuickAddInstance() when needed
  Consumer->>Formatter: create CompleteFormatter(getQuickAddInstance())
  Formatter->>Formatter: await import(Single*Engine)
  Consumer->>Consumer: render(ChoiceView) succeeds in Vitest
Loading

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs:

Suggested labels: released

🐰 I hopped through tangled import vines,

Set an instance where the singleton shines.
Tests now mount in Vitest's gentle light,
Engines load later, and cycles took flight.
A tiny rabbit cheers: the code runs right!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main objective: refactoring imports to break circular dependencies in ChoiceView so it can be tested in vitest.
Linked Issues check ✅ Passed The PR fully addresses both acceptance criteria from issue #1249: ChoiceView now renders in vitest without class-extension errors, and a new component test exercises the Then/Else persistence flow through real save paths.
Out of Scope Changes check ✅ Passed All changes are scoped to breaking the circular import cycle and enabling vitest testing. Lazy imports of engines, the new quickAddInstance module, and test additions directly support the stated objectives with no unrelated modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1249-untangle-choiceview-cycles

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Comment thread src/importCycles.test.ts Fixed
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 30, 2026

Deploying quickadd with  Cloudflare Pages  Cloudflare Pages

Latest commit: 3decb7e
Status: ✅  Deploy successful!
Preview URL: https://9ac09afe.quickadd.pages.dev
Branch Preview URL: https://fix-1249-untangle-choiceview.quickadd.pages.dev

View logs

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/main.ts (1)

193-207: ⚖️ Poor tradeoff

Clear the QuickAdd singleton on onunload (requires a clearing API).

quickAddInstance.ts stores the active plugin in a module-level instance: QuickAdd | undefined, and getQuickAddInstance() only errors when that value is undefined. setQuickAddInstance(plugin: QuickAdd) can set but cannot clear it (no “clear”/undefined path), and src/main.ts’s onunload() doesn’t reset it—leaving a stale reference after disable. Add clearQuickAddInstance() (or allow setQuickAddInstance(plugin: QuickAdd | undefined)) and call it from onunload().

🤖 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 `@src/main.ts` around lines 193 - 207, Add a way to clear the module-level
QuickAdd singleton and call it from the plugin teardown: update
quickAddInstance.ts to export either a new clearQuickAddInstance() that sets the
internal instance: QuickAdd | undefined to undefined, or change
setQuickAddInstance(plugin: QuickAdd) to accept QuickAdd | undefined so callers
can clear it; ensure getQuickAddInstance() still errors when undefined. Then
update src/main.ts:onunload() to invoke clearQuickAddInstance() (or
setQuickAddInstance(undefined)) alongside the existing cleanup so the stale
QuickAdd reference is released on disable.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@src/main.ts`:
- Around line 193-207: Add a way to clear the module-level QuickAdd singleton
and call it from the plugin teardown: update quickAddInstance.ts to export
either a new clearQuickAddInstance() that sets the internal instance: QuickAdd |
undefined to undefined, or change setQuickAddInstance(plugin: QuickAdd) to
accept QuickAdd | undefined so callers can clear it; ensure
getQuickAddInstance() still errors when undefined. Then update
src/main.ts:onunload() to invoke clearQuickAddInstance() (or
setQuickAddInstance(undefined)) alongside the existing cleanup so the stale
QuickAdd reference is released on disable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 45172d33-c2e2-4a02-91fc-f1c9fef01f65

📥 Commits

Reviewing files that changed from the base of the PR and between 9247e00 and 523df4d.

📒 Files selected for processing (20)
  • src/engine/MacroChoiceEngine.ts
  • src/formatters/completeFormatter.ts
  • src/gui/AIAssistantSettingsModal.ts
  • src/gui/InputPrompt.test.ts
  • src/gui/InputPrompt.ts
  • src/gui/MacroGUIs/AIAssistantCommandSettingsModal.ts
  • src/gui/MacroGUIs/AIAssistantInfiniteCommandSettingsModal.ts
  • src/gui/MacroGUIs/UserScriptSettingsModal.ts
  • src/gui/MathModal.ts
  • src/gui/choiceList/ChoiceView.test.ts
  • src/gui/choiceList/persistenceBoundary.svelte.ts
  • src/gui/choiceList/persistenceBoundary.test.ts
  • src/gui/suggesters/LaTeXSuggester.ts
  • src/gui/suggesters/fileSuggester.test.ts
  • src/gui/suggesters/fileSuggester.ts
  • src/gui/suggesters/tagSuggester.ts
  • src/importCycles.test.ts
  • src/main.ts
  • src/quickAddInstance.ts
  • src/utils/macroHelpers.ts

…filter)

The Svelte <script>-block extraction regex in the import-cycle guard used
`</script>`, which CodeQL's js/bad-tag-filter flags because it misses valid
end tags like `</script >`. Allow optional whitespace before `>`
(`</script\s*>`). Behaviour-preserving (only widens matches; verified the guard
test still passes) and resolves the PR #1254 CodeQL alert. The test parses only
our own trusted in-repo Svelte sources, so there was no real security exposure.
Comment thread src/importCycles.test.ts Fixed
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
src/importCycles.test.ts (1)

78-80: ⚠️ Potential issue | 🟡 Minor | 💤 Low value

CodeQL warning: regex doesn't match all malformed closing tags.

The comment says this was added "to satisfy CodeQL", but the warning persists. The regex <\/script\s*> only matches </script> with trailing whitespace, but CodeQL wants it to handle malformed tags like </script [junk]>.

For this use case (parsing trusted Svelte files), the current pattern is sufficient, but you can silence the warning by making the closing tag more permissive.

🔧 Proposed fix to satisfy CodeQL
-		// Closing tag tolerates whitespace (`</script >`) to satisfy CodeQL
-		// js/bad-tag-filter; we only parse our own trusted Svelte sources here.
-		const m = text.match(/<script[^>]*>([\s\S]*?)<\/script\s*>/i);
+		// Closing tag tolerates any content before `>` to satisfy CodeQL js/bad-tag-filter.
+		// We only parse our own trusted Svelte sources, so this permissiveness is safe.
+		const m = text.match(/<script[^>]*>([\s\S]*?)<\/script[^>]*>/i);
🤖 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 `@src/importCycles.test.ts` around lines 78 - 80, Update the closing-tag part
of the regex used when matching script content (currently in the expression
assigned to m via text.match(/<script[^>]*>([\s\S]*?)<\/script\s*>/i)) to make
the closing tag permissive for malformed variants (e.g. allow junk between the
tag name and '>'). Replace the `</script\s*>` fragment with a pattern such as
`</script\b[^>]*>` so the full regex becomes something like
/<script[^>]*>([\s\S]*?)<\/script\b[^>]*>/i to satisfy CodeQL while still safely
parsing trusted Svelte sources.
🤖 Prompt for all review comments with 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.

Duplicate comments:
In `@src/importCycles.test.ts`:
- Around line 78-80: Update the closing-tag part of the regex used when matching
script content (currently in the expression assigned to m via
text.match(/<script[^>]*>([\s\S]*?)<\/script\s*>/i)) to make the closing tag
permissive for malformed variants (e.g. allow junk between the tag name and
'>'). Replace the `</script\s*>` fragment with a pattern such as
`</script\b[^>]*>` so the full regex becomes something like
/<script[^>]*>([\s\S]*?)<\/script\b[^>]*>/i to satisfy CodeQL while still safely
parsing trusted Svelte sources.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a2f68749-8dd2-4c18-bf02-40b10827c3ae

📥 Commits

Reviewing files that changed from the base of the PR and between 523df4d and ed7a4dc.

📒 Files selected for processing (1)
  • src/importCycles.test.ts

…deQL)

CodeQL js/bad-tag-filter re-flagged the closing-tag regex twice (`</script>`
then `</script\s*>`) because an HTML end tag can carry ignored attributes up to
`>` (e.g. `</script\t\n bar>`). Widening the regex just invites another alert,
so drop the tag-matching regex entirely and locate the block with indexOf —
js/bad-tag-filter is a regex-only query and now has nothing to bind to.

The indexOf scan is also strictly more correct: it concatenates every <script>
body (instance + `<script module>`/`<script context="module">`), which the old
single-match regex missed, so module-context imports are now seen by the
import-cycle guard. Verified byte-identical extraction on all existing .svelte
files; guard test, tsc and eslint pass.

Resolves the two CodeQL alerts on PR #1254.
@chhoumann chhoumann merged commit c2d24de into master May 30, 2026
9 checks passed
@chhoumann chhoumann deleted the fix/1249-untangle-choiceview-cycles branch May 30, 2026 01:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Untangle ChoiceView's circular imports so settings views are CI-testable

2 participants