fix: bug fixes, security hardening, and unit tests#18
fix: bug fixes, security hardening, and unit tests#18rubenmarcus wants to merge 2 commits intomainfrom
Conversation
- Break mutual recursion between closeOverlay and switchToHuman (stack overflow) - Add path traversal prevention in Vite and Astro dev server middleware - Escape quotes/backslashes in YAML frontmatter generation - Fix event listener memory leak in widget destroy() - Add isLoading guard to prevent duplicate overlays on rapid clicks - Sync VERSION across index.ts (0.0.11) and CLI (import from index) - Add 10 new unit tests covering all fixes (142 → 152 tests) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Keep isLoading reset from our branch alongside the recursion fix already merged from PR #17. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| } | ||
| }); | ||
|
|
||
| document.addEventListener('keydown', (e) => { | ||
| this.keydownHandler = (e: KeyboardEvent) => { | ||
| if (e.key === 'Escape' && this.overlayElement) { | ||
| this.closeOverlay(); | ||
| } | ||
| }); | ||
| }; | ||
| document.addEventListener('keydown', this.keydownHandler); | ||
| } | ||
|
|
||
| private async switchToAI(): Promise<void> { | ||
| if (this.isLoading) return; | ||
| this.isLoading = true; | ||
| this.isAIMode = true; | ||
| this.updateToggleState(); | ||
| await this.showOverlay(); | ||
| this.isLoading = false; | ||
| } | ||
|
|
||
| private switchToHuman(): void { |
There was a problem hiding this comment.
🔴 A stale loadContent() invocation can attach duplicate event listeners to the new overlay after an open-close-reopen sequence. When the first suspended fetch resumes, it queries copyBtn/downloadBtn via this.overlayElement (now pointing to the second overlay), and the second loadContent() then adds its own listeners too -- causing every Copy or Download click to fire twice. The new rapid-click test does not cover this scenario; capturing this.overlayElement in a local variable before each await and checking it afterward would prevent the stale instance from corrupting the new overlay.
Extended reasoning...
What the bug is and how it manifests
In loadContent() (src/widget/core.ts around line 370), the local variable wrapper is captured from this.overlayElement.querySelector before the await fetch() suspension point. If closeOverlay() runs while the fetch is in flight and a new switchToAI() cycle begins, this.overlayElement is reassigned to a brand-new overlay element. When the original suspended loadContent() resumes, wrapper still references the old detached overlay node, but the subsequent this.overlayElement.querySelector('.aeo-copy-btn') and this.overlayElement.querySelector('.aeo-download-btn') queries are evaluated AFTER the await against the updated this.overlayElement, which now points to the second overlay. The stale invocation attaches click handlers to the new overlay's buttons, and when the second loadContent() also completes it attaches another set, leaving both buttons with duplicate listeners that fire twice per click.
The specific code path that triggers it
switchToAI()setsisLoading = true, creates overlay1, setsthis.overlayElement = overlay1, then callsawait this.showOverlay()which callsawait this.loadContent().loadContent()captureswrapper = overlay1.querySelector('.aeo-content-wrapper'), then suspends atawait fetch(mdPath).- User clicks Human:
closeOverlay()removes overlay1, setsthis.overlayElement = undefined, setsthis.isLoading = false. - User clicks AI again:
isLoadingis false soswitchToAI()enters, creates overlay2, setsthis.overlayElement = overlay2, starts a secondloadContent()which suspends at its ownawait fetch(mdPath). - First fetch resolves. First
loadContent()resumes. Guardif (!this.overlayElement) returnpasses becausethis.overlayElementis now overlay2 (not null).wrapper.innerHTMLwrites to the detached overlay1 node -- harmless and invisible. Thenthis.overlayElement.querySelector('.aeo-copy-btn')returns overlay2's copy button, andaddEventListenerattaches the first stale listener. - Second fetch resolves. Second
loadContent()resumes and attaches its own listener to the same overlay2 copy/download buttons. Both buttons now have two click handlers each.
Why existing code does not prevent it
The isLoading flag introduced by this PR blocks entry into switchToAI() for rapid same-flow clicks (open, open, open). However, closeOverlay() resets isLoading = false, which is necessary to allow a future open. Once that reset happens, the second AI click is allowed -- but the first loadContent() is still suspended and will resume with a stale view of this.overlayElement. The guard inside loadContent() only checks if (!this.overlayElement) return, which was designed for the simpler close-without-reopen case. It is bypassed by the reopen case because this.overlayElement is non-null at resumption time (it points to the new overlay).
Impact
Every click on Copy triggers copyToClipboard() twice, showing two toasts and making two navigator.clipboard.writeText() calls. Every click on Download triggers downloadMarkdown() twice, spawning two anchor click events and two blob URL objects, which may produce two download dialogs in some browsers. This is a genuine user-visible defect on slow network connections where fetch latency makes the race window wide. The open-close-reopen flow is a natural user interaction.
Step-by-step concrete proof
- User clicks AI.
isLoadingbecomes true.this.overlayElement = overlayA.loadContent()suspends atawait fetch('/page.md'). - User clicks Human.
closeOverlay():overlayA.remove(),this.overlayElement = undefined,isLoading = false. - User clicks AI again.
isLoadingis false, so proceeds.this.overlayElement = overlayB. SecondloadContent()suspends atawait fetch('/page.md'). - First fetch resolves. First
loadContent()resumes.this.overlayElementisoverlayB-- guard passes.wrapper(overlayA's content-wrapper) getsinnerHTMLoverwritten with no visible effect.copyBtn = this.overlayElement.querySelector('.aeo-copy-btn')returns overlayB's copy button. FirstaddEventListenercall attaches a stale handler. - Second fetch resolves. Second
loadContent()resumes.copyBtn = this.overlayElement.querySelector('.aeo-copy-btn')returns the same overlayB copy button again. SecondaddEventListenercall attaches a canonical handler. Both fire on every click.
How to fix it
At the start of loadContent(), capture: const myOverlay = this.overlayElement. Replace all subsequent this.overlayElement references within loadContent() with myOverlay. After each await, add: if (myOverlay !== this.overlayElement) return. This ensures the stale invocation self-terminates before attaching any listeners to the new overlay. An integer generation counter or an AbortController are equivalent alternatives.
| import { resolveConfig } from '../core/utils'; | ||
| import type { AeoConfig, PageEntry, ResolvedAeoConfig } from '../types'; | ||
| import { join } from 'path'; | ||
| import { join, resolve, sep } from 'path'; |
Summary
closeOverlay()andswitchToHuman()— infinite call loop caused silent stack overflow on every overlay close (existed since first commit)..and null bytes in.mdfile requests"and\in frontmatter title/description stringsdestroy()isLoadingflag prevents multiple overlays from rapid AI button clicksindex.ts(0.0.11), imported by CLI instead of hardcoded0.0.2Test plan
npx vitest run— 152 tests pass (10 new)npx tsc --noEmit— clean compilenpx aeo.js --versionshows0.0.11🤖 Generated with Claude Code