diff --git a/playground/src/pages/components/search-advanced.astro b/playground/src/pages/components/search-advanced.astro
index 470b886..6b83d0e 100644
--- a/playground/src/pages/components/search-advanced.astro
+++ b/playground/src/pages/components/search-advanced.astro
@@ -13,6 +13,7 @@ const headings = [
{ depth: 2, slug: 'index-format', text: 'Index format' },
{ depth: 2, slug: 'tabs', text: 'Tabs' },
{ depth: 2, slug: 'scope-selector', text: 'Scope selector' },
+ { depth: 2, slug: 'two-phase-loading', text: 'Two-phase loading' },
{ depth: 2, slug: 'keyboard', text: 'Keyboard shortcut' },
{ depth: 2, slug: 'usage', text: 'Usage' },
];
@@ -82,6 +83,40 @@ const headings = [
"Current package" option is hidden automatically.
+ Two-phase loading
+
+ When the scope selector is set to All packages and the index is a
+ manifest listing many per-package/version files, the component fetches them in two
+ sequential phases to keep the search feel responsive:
+
+
+ -
+ Phase 1 — only the highest-version file for each package is fetched
+ in parallel. As soon as all phase-1 files arrive the first results are rendered and
+ the query is ready to use.
+
+ -
+ Phase 2 — the remaining (older-version) files are fetched in parallel
+ in the background. The UI stays in a pending state: results are visually
+ greyed out, tab interactions are disabled, and a centered Searching… overlay
+ is displayed over the tabs. A 1 000 ms interval re-renders results as files accumulate,
+ so the counts update progressively rather than jumping at the end.
+
+
+
+ Once every file has loaded the pending state is cleared, interactions are restored, and
+ the final result set (powered by pickHighestVersionWinners()) is shown.
+ Subsequent searches against the same scope reuse the already-fetched index — no
+ additional network requests are made.
+
+
+ The two-phase path is triggered automatically whenever
+ latestPagesPromise is non-null and
+ allIndexLoadingComplete is false at the time a search
+ runs. For single-file indexes or the current-package scope the standard single-phase
+ path is used instead.
+
+
Keyboard shortcut
The trigger button supports Ctrl K
diff --git a/src/components/SearchAdvanced/SearchAdvanced.astro b/src/components/SearchAdvanced/SearchAdvanced.astro
index 594dc2a..d435b0a 100644
--- a/src/components/SearchAdvanced/SearchAdvanced.astro
+++ b/src/components/SearchAdvanced/SearchAdvanced.astro
@@ -277,6 +277,13 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
private allTabLimit: number = 10;
private showScope: boolean = false;
private kindCodeToCategory: Map = new Map();
+ // Two-phase all-index loading: latestPagesPromise resolves with only the
+ // highest-version file per package (fast first result), allIndexPromise
+ // resolves when every version file is loaded (complete result).
+ private latestPagesPromise: Promise | null = null;
+ private allIndexLoadingComplete = false;
+ private allPagesAccumulator: IndexedPage[] = [];
+ private updateInterval: ReturnType | null = null;
connectedCallback() {
this.basePath = this.dataset.base || '/';
@@ -506,6 +513,7 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
}
private closeModal() {
+ this.clearUpdateInterval();
this.dialog.hide();
}
@@ -531,24 +539,43 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
}
private async fetchIndex(): Promise {
+ // Create latestPagesPromise SYNCHRONOUSLY before the first await so that
+ // runSearch() can detect two-phase loading is in progress the moment
+ // fetchIndex() is called (e.g. immediately after scope changes to 'all').
+ let latestResolve!: (pages: IndexedPage[]) => void;
+ if (!this.latestPagesPromise) {
+ this.latestPagesPromise = new Promise(res => { latestResolve = res; });
+ }
+
let data: unknown;
try {
const r = await fetch(this.indexUrl);
- if (!r.ok) return [];
+ if (!r.ok) { this.allIndexLoadingComplete = true; latestResolve([]); return []; }
data = await r.json();
} catch {
+ this.allIndexLoadingComplete = true;
+ latestResolve([]);
return [];
}
// Single-file format: { pages: [...] }
if (Array.isArray((data as any)?.pages)) {
- return (data as any).pages as IndexedPage[];
+ this.allIndexLoadingComplete = true;
+ const pages = (data as any).pages as IndexedPage[];
+ latestResolve(pages);
+ return pages;
}
// Manifest format: { files: ["pkg/version.json", ...] }
if (Array.isArray((data as any)?.files)) {
const baseUrl = this.indexUrl.replace(/[^/]*$/, '');
- const fetches: Promise[] = ((data as any).files as string[]).map(async (file: string) => {
+ const files: string[] = (data as any).files as string[];
+ this.allPagesAccumulator = [];
+
+ const latestFileSet = new Set(pickLatestFiles(files));
+ const otherFiles = files.filter(f => !latestFileSet.has(f));
+
+ const fetchFile = async (file: string): Promise => {
try {
const res = await fetch(`${baseUrl}${file}`);
if (!res.ok) return [];
@@ -557,11 +584,28 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
} catch {
return [];
}
- });
- const results = await Promise.all(fetches);
- return results.flat();
+ };
+
+ // Phase 1: fetch latest-version files in parallel, await all before
+ // starting phase 2. latestPagesPromise resolves when this completes.
+ const phase1 = await Promise.all(Array.from(latestFileSet).map(fetchFile));
+ const latestPages = phase1.flat();
+ this.allPagesAccumulator.push(...latestPages);
+ latestResolve(latestPages);
+
+ // Phase 2: fetch remaining files in parallel; accumulate pages as each
+ // arrives so the 1000ms interval in runSearch can re-render partial results.
+ await Promise.all(otherFiles.map(async (file) => {
+ const pages = await fetchFile(file);
+ this.allPagesAccumulator.push(...pages);
+ }));
+
+ this.allIndexLoadingComplete = true;
+ return this.allPagesAccumulator;
}
+ this.allIndexLoadingComplete = true;
+ latestResolve([]);
return [];
}
@@ -588,6 +632,10 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
// Avoid searching on very short queries — a single or two-character query matches
// virtually everything and causes the classify + render path to freeze the main thread.
if (q.length < 3) {
+ this.clearUpdateInterval();
+ this.tabsEl.removeAttribute('data-search-pending');
+ const contentElShort = this.tabsEl.closest('.igd-search-content');
+ if (contentElShort) contentElShort.removeAttribute('data-search-pending');
this.statusEl.textContent = 'Type at least 3 characters to search\u2026';
this.tabsEl.hidden = true;
this.tabsEl.innerHTML = '';
@@ -596,16 +644,82 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
return;
}
- this.statusEl.textContent = 'Searching\u2026';
+ // Two-phase search for "all packages" scope when the full index is not
+ // yet loaded: show latest-version results immediately (greyed out /
+ // non-interactive), then update every 1000ms as phase 2 files arrive.
+ const useLatestFirst =
+ this.scope === 'all' &&
+ this.latestPagesPromise !== null &&
+ !this.allIndexLoadingComplete;
+
+ if (useLatestFirst) {
+ // Immediately activate the pending overlay so the CSS ::after "Searching…"
+ // text appears centered over the igc-tabs area rather than in the status
+ // element above the tabs. renderTabs(true) will maintain this once phase
+ // 1 results are ready.
+ this.statusEl.textContent = 'Searching\u2026';
+ this.tabsEl.toggleAttribute('data-search-pending', true);
+ const contentElEarly = this.tabsEl.closest('.igd-search-content');
+ if (contentElEarly) contentElEarly.toggleAttribute('data-search-pending', true);
+ } else {
+ this.statusEl.textContent = 'Searching\u2026';
+ }
+
+ if (useLatestFirst) {
+ let latestPages: IndexedPage[];
+ try {
+ latestPages = await this.latestPagesPromise!;
+ } catch {
+ latestPages = [];
+ }
+ if (q !== this.currentQuery) return;
+
+ if (latestPages.length > 0) {
+ const buckets = this.classify(latestPages, q);
+ this.currentMatches = buckets;
+ if (this.currentTab !== 'all' && (buckets.get(this.currentTab)?.length ?? 0) === 0) {
+ this.currentTab = 'all';
+ }
+ this.renderTabs(true);
+ this.renderResults();
+ }
+
+ // Update results every 1000ms while phase 2 files are still arriving.
+ this.clearUpdateInterval();
+ let lastLen = 0;
+ this.updateInterval = setInterval(() => {
+ if (q !== this.currentQuery) { this.clearUpdateInterval(); return; }
+ const len = this.allPagesAccumulator.length;
+ const complete = this.allIndexLoadingComplete;
+ if (len === 0) { if (complete) this.clearUpdateInterval(); return; }
+ if (len === lastLen && !complete) return;
+ lastLen = len;
+ const buckets = this.classify(this.allPagesAccumulator, q);
+ this.currentMatches = buckets;
+ if (this.currentTab !== 'all' && (buckets.get(this.currentTab)?.length ?? 0) === 0) {
+ this.currentTab = 'all';
+ }
+ // Keep pending=true while still loading; the final render below clears it.
+ this.renderTabs(!complete);
+ this.renderResults();
+ if (complete) this.clearUpdateInterval();
+ }, 1000);
+ this.dialog.addEventListener('igcClosed', () => this.clearUpdateInterval(), { once: true });
+ }
let pages: IndexedPage[];
try {
pages = await this.loadIndex();
} catch (err) {
+ this.clearUpdateInterval();
+ this.tabsEl.toggleAttribute('data-search-pending', false);
+ const contentEl = this.tabsEl.closest('.igd-search-content');
+ if (contentEl) contentEl.removeAttribute('data-search-pending');
this.statusEl.textContent = `Search unavailable: ${(err as Error).message}`;
return;
}
+ this.clearUpdateInterval();
if (q !== this.currentQuery) return;
const buckets = this.classify(pages, q);
@@ -613,17 +727,28 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
if (this.currentTab !== 'all' && (buckets.get(this.currentTab)?.length ?? 0) === 0) {
this.currentTab = 'all';
}
- this.renderTabs();
+ this.renderTabs(false);
this.renderResults();
}
+ private clearUpdateInterval() {
+ if (this.updateInterval !== null) {
+ clearInterval(this.updateInterval);
+ this.updateInterval = null;
+ }
+ }
+
private clearResults() {
+ this.clearUpdateInterval();
this.statusEl.textContent = '';
this.tabsEl.hidden = true;
this.tabsEl.innerHTML = '';
this.flatListEl.hidden = true;
this.flatListEl.innerHTML = '';
this.currentMatches = new Map();
+ this.tabsEl.removeAttribute('data-search-pending');
+ const contentEl = this.tabsEl.closest('.igd-search-content');
+ if (contentEl) contentEl.removeAttribute('data-search-pending');
}
private async runBrowse() {
@@ -845,7 +970,7 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
return buckets;
}
- private renderTabs() {
+ private renderTabs(pending = false) {
const totalCount = Array.from(this.currentMatches.values()).reduce((s, l) => s + l.length, 0);
// No-categories fallback: hide tabs, render a plain list
@@ -910,11 +1035,21 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
if (totalCount === 0) {
this.tabsEl.hidden = true;
this.tabsEl.innerHTML = '';
+ this.tabsEl.removeAttribute('data-search-pending');
+ const contentEl = this.tabsEl.closest('.igd-search-content');
+ if (contentEl) contentEl.removeAttribute('data-search-pending');
return;
}
this.tabsEl.innerHTML = tabsHtml.join('');
this.tabsEl.hidden = false;
+ this.tabsEl.toggleAttribute('data-search-pending', pending);
+ this.tabsEl.toggleAttribute('inert', pending);
+ const contentEl = this.tabsEl.closest('.igd-search-content');
+ if (contentEl) contentEl.toggleAttribute('data-search-pending', pending);
+ // Keep the aria-live status text during pending so screen readers still
+ // get a "Searching…" announcement; visually hide it via CSS if desired.
+ if (pending) this.statusEl.textContent = 'Searching\u2026';
// Wire "Show more" buttons — select the target tab (its content is already rendered)
this.tabsEl.querySelectorAll('.igd-search-more-btn').forEach((btn) => {
@@ -991,6 +1126,26 @@ const resolvedIndexUrl = indexUrl ?? `${basePath}api-search-index/manifest.json`
return Array.from(winners.values());
}
+ /**
+ * From a manifest file list ("pkg/version.json" entries), return only the
+ * highest-version file per package. These are the files fetched in the fast
+ * first phase of the two-phase all-packages search.
+ */
+ function pickLatestFiles(files: string[]): string[] {
+ const best = new Map();
+ for (const file of files) {
+ const slash = file.indexOf('/');
+ if (slash < 0) continue;
+ const pkg = file.slice(0, slash);
+ const version = file.slice(slash + 1).replace(/\.json$/, '');
+ const existing = best.get(pkg);
+ if (!existing || compareVersions(version, existing.version) > 0) {
+ best.set(pkg, { version, file });
+ }
+ }
+ return Array.from(best.values()).map(e => e.file);
+ }
+
function highlight(text: string, q: string): string {
if (!q) return escapeHtml(text);
const i = text.toLowerCase().indexOf(q);
diff --git a/src/components/SearchAdvanced/SearchAdvanced.scss b/src/components/SearchAdvanced/SearchAdvanced.scss
index b59035a..c5695d5 100644
--- a/src/components/SearchAdvanced/SearchAdvanced.scss
+++ b/src/components/SearchAdvanced/SearchAdvanced.scss
@@ -154,5 +154,59 @@
letter-spacing: 0;
}
}
-}
+ // Pending state: results from latest-version-only first phase are shown
+ // greyed out and non-interactive while remaining version files load.
+ .igd-search-content {
+ position: relative;
+
+ &[data-search-pending]::after {
+ content: 'Searching\2026';
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: light-dark(var(--ig-gray-600), var(--ig-gray-300));
+ font-size: #{rem(14px)};
+ font-weight: 500;
+ pointer-events: none;
+ z-index: 1;
+ }
+
+ // Keep the aria-live status element in the accessibility tree while pending,
+ // but visually hide it so the overlay is the only visible "Searching…".
+ &[data-search-pending] .igd-search-status {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+ }
+
+ .igd-search-tabs[data-search-pending] {
+ pointer-events: none;
+
+ &::part(selected-indicator),
+ &::part(start-scroll-button),
+ &::part(end-scroll-button) {
+ opacity: 0.25;
+ }
+
+ igc-tab::part(content),
+ igc-tab::part(panel) {
+ opacity: 0.25;
+ }
+
+ .igd-search-results,
+ .igd-search-category-header,
+ .igd-search-tab-count {
+ opacity: 0.25;
+ }
+ }
+}