fix(frontend): stop the template picker spinning on a hub with no projects

Opening the picker against a reachable hub that has published nothing pinned
the renderer at full CPU and froze the tab. The effect arming the list called
`setLoader` and `loadData`, which read `InfiniteList`'s reactive state as well
as writing it, so the effect depended on what its own load changed and re-ran
itself; a list that stays empty never settles that cycle. It now arms the
loader once per workspace, untracked, and leaves the load to `setLoader`.

The same empty list also claimed the hub was unreachable, since one `empty`
snippet serves both. The loader records which happened, so a hub with nothing
on it says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG
This commit is contained in:
Guilhem Lemouel
2026-09-08 17:02:40 +02:00
co-authored by Claude Opus 5
parent ac6295722a
commit 3acff46383
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte'
import { ArrowRight, ArrowUpRight, LayoutGrid, Star } from 'lucide-svelte'
import InfiniteList from '$lib/components/InfiniteList.svelte'
import {
@@ -22,14 +23,32 @@
// The hub serves its whole catalogue in one response, so paging happens here: the list
// asks for a window and gets a slice of what `hubProjectCatalogue` already holds. Should
// the hub ever paginate, only this loader changes.
// `InfiniteList` shows its `empty` snippet for a list that came back empty and for one that
// failed, so the loader records which happened: a reachable hub that has published nothing
// is not a hub that could not be reached.
let loadFailed = $state(false)
// Armed once per workspace, and inside `untrack`: `setLoader` loads immediately, and both
// it and `loadData` read the list's own reactive state as well as writing it — called
// tracked, this effect depends on what the load changes and re-runs itself. An empty
// catalogue never settles that cycle, which spins the tab at full CPU.
let loadedFor: string | undefined = undefined
$effect(() => {
const workspace = $workspaceStore
if (!list || !workspace) return
list.setLoader(async (page: number, perPage: number) => {
const all = await hubProjectCatalogue(workspace)
return all.slice((page - 1) * perPage, page * perPage)
if (!list || !workspace || loadedFor === workspace) return
loadedFor = workspace
untrack(() => {
list?.setLoader(async (page: number, perPage: number) => {
try {
const all = await hubProjectCatalogue(workspace)
loadFailed = false
return all.slice((page - 1) * perPage, page * perPage)
} catch (error) {
loadFailed = true
throw error
}
})
})
list.loadData('refresh')
})
let hubUrl = $state('https://hub.windmill.dev')
@@ -120,7 +139,11 @@
{#snippet empty()}
<p class="px-3 py-6 text-xs text-secondary">
Could not reach the hub. You can still browse its projects in a new tab.
{#if loadFailed}
Could not reach the hub. You can still browse its projects in a new tab.
{:else}
This hub has no projects yet.
{/if}
</p>
{/snippet}
</InfiniteList>