Files
repomix-mirror/website/client/components/Home/TryItUrlInput.vue
Kazuki YamadaandClaude Opus 4.7 7c9e44616b perf(website): Pre-mint Turnstile token on user intent, not on mount
intent(latency-and-counter): The Cloudflare Turnstile dashboard's "challenges issued" counter sits at roughly 1.25× of GA page_view, which means simply loading `api.js` on every visitor (the PR #1541 pre-warm) is already inflating the counter — render() and execute() are not the only trigger. At the same time, click→token latency is the main remaining UX cost. This PR reshapes pre-warm so script load + challenge happen only when the user has shown real intent (filled a valid URL or chose a file *and* interacted with the form), achieving both a lower counter and a near-zero click→token latency.

fix(useTurnstile-api): Replace the single `getToken()` entry point with a `preMintToken()` / `takeToken()` pair backed by an in-memory `{ token, mintedAt, consumed }` cache. `preMintToken()` runs the challenge in the background and stashes the resulting token; `takeToken()` consumes the cache synchronously (instant submit) or awaits the in-flight mint, falling back to a cold mint with the supplied AbortSignal. `invalidateCache()` lets the caller drop the cache without minting a new token. TTL is bounded at 240 s — Cloudflare hard-caps tokens at 300 s, the margin absorbs clock skew and network round-trips so a token that's "almost expired" is never sent to siteverify.

fix(useTurnstile-no-mount-prewarm): Stop calling `loadTurnstileScript()` from `setContainer()`. The mount-time script load was the source of the page-view-shaped counter inflation. Pre-warm now only runs from the intent-gated trigger in `usePackRequest`, so visitors who never interact with the form never appear on the dashboard.

fix(usePackRequest-intent-gate): Add a `userTouched` ref that flips on real user interaction (input event, file upload, mode switch) and never goes back. A debounced (500 ms) `watch(isSubmitValid && userTouched)` calls `preMintToken()`, so URL-parameter hydration (`?repo=`), browser form restoration, and autofill never pre-mint. `submitRequest()` switches from `getToken()` to `takeToken()` so the cached token is consumed on the first click, with the cold mint path as a transparent fallback.

fix(TryItUrlInput-user-input-event): Emit a new `userInput` event from the URL field's actual `@input` handler. `TryIt.vue` wires it to `markUserTouched()`. Watching `inputUrl` directly would have re-fired during onMounted hydration and defeated the gate.

learned(cloudflare-counter-includes-script-load): Even with `execution: 'execute'`, the Cloudflare Turnstile dashboard counts api.js loads toward "challenges issued" (verified by comparing GA page_view ≈ 106 with CF issued ≈ 132 in the same 30 min window after the PR #1541 deploy). Treat any new place that loads api.js as a billable analytics side effect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:16:54 +09:00

198 lines
4.8 KiB
Vue

<script setup lang="ts">
import { AlertTriangle } from 'lucide-vue-next';
import { computed, onMounted, ref } from 'vue';
import { isValidRemoteValue } from '../utils/validation';
import PackButton from './PackButton.vue';
const props = defineProps<{
url: string;
loading: boolean;
showButton?: boolean;
}>();
const emit = defineEmits<{
'update:url': [value: string];
submit: [];
keydown: [event: KeyboardEvent];
cancel: [];
// Emitted on real DOM input — typing, paste, IME compose end, datalist
// selection. Used by usePackRequest to gate the Turnstile pre-mint so
// URL-parameter hydration / form restoration don't trigger background
// challenges.
userInput: [];
}>();
const isValidUrl = computed(() => {
if (!props.url) return false;
return isValidRemoteValue(props.url.trim());
});
// Array to manage input history
const urlHistory = ref<string[]>([]);
const historyListId = 'repository-url-history';
// Load URL history when component is mounted
onMounted(() => {
loadUrlHistory();
});
// Load URL history from localStorage
function loadUrlHistory() {
try {
const savedHistory = localStorage.getItem('repomix-url-history');
if (savedHistory) {
urlHistory.value = JSON.parse(savedHistory);
}
} catch (error) {
console.error('Failed to load URL history from localStorage:', error);
// Continue with empty history rather than displaying an error to the user
// as this is a non-critical feature
urlHistory.value = [];
}
}
// Save URL to history
function saveUrlToHistory(url: string) {
if (!url) return;
const trimmedUrl = url.trim();
if (!isValidRemoteValue(trimmedUrl)) return;
// Remove existing entry and add to the beginning
const filteredHistory = urlHistory.value.filter((item) => item !== trimmedUrl);
urlHistory.value = [trimmedUrl, ...filteredHistory].slice(0, 5); // Keep only the latest 10 entries
try {
localStorage.setItem('repomix-url-history', JSON.stringify(urlHistory.value));
} catch (error) {
console.error('Failed to save URL history to localStorage:', error);
// Non-critical error, so we don't need to show it to the user
}
}
function handleUrlInput(event: Event) {
const input = event.target as HTMLInputElement;
emit('update:url', input.value);
emit('userInput');
}
// Process and save valid URL
function processValidUrl() {
if (isValidUrl.value) {
saveUrlToHistory(props.url);
}
}
function handleSubmit() {
processValidUrl();
emit('submit');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' && isValidUrl.value) {
processValidUrl();
}
emit('keydown', event);
}
</script>
<template>
<div class="input-group">
<div class="url-input-container">
<input
:value="url"
@input="handleUrlInput"
@keydown="handleKeydown"
type="text"
placeholder="GitHub repository URL or user/repo (e.g., yamadashy/repomix)"
class="repository-input"
:class="{ 'invalid': url && !isValidUrl }"
aria-label="GitHub repository URL"
autocomplete="on"
:list="historyListId"
/>
<datalist :id="historyListId">
<option v-for="historyUrl in urlHistory" :key="historyUrl" :value="historyUrl" />
</datalist>
</div>
<div v-if="url && !isValidUrl" class="url-warning">
<AlertTriangle class="warning-icon" :size="16" />
<span>Please enter a valid GitHub repository URL (e.g., yamadashy/repomix)</span>
</div>
<div v-if="showButton" class="pack-button-container">
<PackButton :isValid="isValidUrl" :loading="loading" @click="handleSubmit" @cancel="$emit('cancel')"/>
</div>
</div>
</template>
<style scoped>
.input-group {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.url-input-container {
flex: 1;
position: relative;
height: 100%;
}
.repository-input {
width: 100%;
height: 50px;
padding: 12px 16px;
font-size: 16px;
border: 1px solid var(--vp-c-border);
border-radius: 8px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
transition: border-color 0.2s;
/* Hide datalist dropdown arrow in different browsers */
&::-webkit-calendar-picker-indicator {
display: none !important;
}
&::-webkit-list-button {
display: none !important;
}
&::-webkit-inner-spin-button {
display: none !important;
}
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
}
.repository-input:focus {
outline: none;
border-color: var(--vp-c-brand-1);
}
.repository-input.invalid {
border-color: var(--vp-c-danger-1);
}
.url-warning {
margin-top: 8px;
display: flex;
align-items: center;
gap: 6px;
color: var(--vp-c-warning-1);
font-size: 14px;
}
.warning-icon {
flex-shrink: 0;
color: var(--vp-c-warning-1);
}
.pack-button-container {
margin-top: 16px;
display: flex;
justify-content: center;
width: 100%;
}
</style>