|
113 | 113 | return parsed ? parsed.url : '' |
114 | 114 | }) |
115 | 115 |
|
116 | | - let autoStartedRepo = $state('') |
| 116 | + // Auto-start from URL on initial page load only (e.g. shared links). |
| 117 | + // Must not re-trigger on programmatic goto — goto is async, so comparing |
| 118 | + // the URL-derived initialRepo with a stored string races and can start the wrong repo. |
| 119 | + let hasAutoStarted = $state(false) |
117 | 120 |
|
118 | 121 | $effect(() => { |
119 | | - if (browser && initialRepo && initialRepo !== autoStartedRepo) { |
120 | | - autoStartedRepo = initialRepo |
| 122 | + if (browser && initialRepo && !hasAutoStarted) { |
| 123 | + hasAutoStarted = true |
121 | 124 | void startAnalysis(initialRepo) |
122 | 125 | } |
123 | 126 | }) |
124 | 127 |
|
| 128 | + // Generation counter: incremented on each startAnalysis call so stale async |
| 129 | + // flows (cache lookups, fire-and-forget fetches) bail out when superseded. |
| 130 | + let analysisGeneration = 0 |
| 131 | +
|
125 | 132 | const startTimer = (kind: 'clone' | 'process') => { |
126 | 133 | stopTimer() |
127 | 134 | const startTime = Date.now() |
|
296 | 303 | cancel() |
297 | 304 | resetState() |
298 | 305 | lastRepoInput = repoInput |
| 306 | + const generation = ++analysisGeneration |
299 | 307 |
|
300 | 308 | try { |
301 | 309 | const parsed = parseRepoUrl(repoInput) |
302 | | - autoStartedRepo = repoInput // Prevent the URL-watching effect from re-triggering |
| 310 | + hasAutoStarted = true // Prevent the URL-watching effect from re-triggering |
303 | 311 | updateUrl(repoInput) |
304 | 312 |
|
305 | | - // Fetch repo size for display (fire-and-forget, non-blocking) |
| 313 | + // Fetch repo size for display (fire-and-forget, non-blocking). |
| 314 | + // Guarded by generation to avoid setting size for the wrong repo. |
306 | 315 | void fetchRepoSizeBytes(parsed.host, parsed.owner, parsed.repo).then((size) => { |
307 | | - repoSizeBytes = size |
| 316 | + if (generation === analysisGeneration) repoSizeBytes = size |
308 | 317 | }) |
309 | 318 |
|
310 | 319 | // Check local cache first |
311 | 320 | const cached = await getResult(parsed.url) |
| 321 | + if (generation !== analysisGeneration) return |
312 | 322 | if (cached) { |
313 | 323 | cachedResult = cached |
314 | 324 | result = cached |
|
319 | 329 |
|
320 | 330 | // Check shared server cache on local miss |
321 | 331 | const serverEntry = await fetchServerResult(parsed.url) |
| 332 | + if (generation !== analysisGeneration) return |
322 | 333 | if (serverEntry) { |
323 | 334 | cachedResult = serverEntry.result |
324 | 335 | result = serverEntry.result |
|
332 | 343 | // Pre-clone size gate (GitHub only, skipped on force or API failure) |
333 | 344 | if (!force) { |
334 | 345 | const sizeBytes = await fetchRepoSizeBytes(parsed.host, parsed.owner, parsed.repo) |
| 346 | + if (generation !== analysisGeneration) return |
335 | 347 | if (sizeBytes !== null && sizeBytes > sizeGateThresholdBytes) { |
336 | 348 | sizeGateBytes = sizeBytes |
337 | 349 | return |
|
343 | 355 | await cancelCleanup |
344 | 356 | cancelCleanup = undefined |
345 | 357 | } |
| 358 | + if (generation !== analysisGeneration) return |
346 | 359 |
|
347 | 360 | phase = 'cloning' |
348 | 361 | startTimer('clone') |
|
356 | 369 |
|
357 | 370 | await analyzer.analyze(repoInput, corsProxy, handleProgress) |
358 | 371 | } catch (e) { |
| 372 | + if (generation !== analysisGeneration) return |
359 | 373 | stopTimer() |
360 | 374 | if (phase !== 'error') { |
361 | 375 | phase = 'error' |
|
371 | 385 |
|
372 | 386 | const dismissSizeGate = () => { |
373 | 387 | sizeGateBytes = 0 |
| 388 | + updateUrl('') |
374 | 389 | } |
375 | 390 |
|
376 | 391 | let cancelCleanup: Promise<void> | undefined |
|
384 | 399 | phase = 'idle' |
385 | 400 | } |
386 | 401 |
|
| 402 | + /** User-facing cancel: also resets URL so stale repo path doesn't linger. */ |
| 403 | + const handleUserCancel = () => { |
| 404 | + cancel() |
| 405 | + updateUrl('') |
| 406 | + } |
| 407 | +
|
387 | 408 | const retry = () => { |
388 | 409 | if (pendingRefresh) { |
389 | 410 | // Re-attempt the failed refresh |
|
621 | 642 | {processTotal} |
622 | 643 | {processDate} |
623 | 644 | processElapsedMs={processElapsed} |
624 | | - oncancel={cancel} |
| 645 | + {repoSizeBytes} |
| 646 | + oncancel={handleUserCancel} |
625 | 647 | /> |
626 | 648 | </div> |
627 | 649 | {/if} |
|
655 | 677 | </p> |
656 | 678 | <div class="mt-3 flex items-center gap-3"> |
657 | 679 | <button onclick={dismissSizeWarning} class="btn-primary text-sm"> Continue </button> |
658 | | - <button onclick={cancel} class="btn-link text-sm"> Cancel </button> |
| 680 | + <button onclick={handleUserCancel} class="btn-link text-sm"> Cancel </button> |
659 | 681 | </div> |
660 | 682 | </div> |
661 | 683 | </div> |
|
0 commit comments