Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

4.0 KiB
Raw Permalink Blame History

Polling and intervals

When you need to refresh something on a fixed interval (poll a running job, refresh a count, tick a clock), do it through cache.disposables. The disposables plugin handles cleanup on unmount and auto-pauses the interval when the tab is hidden, which is almost always what you want for polling.

For the full disposables API see the using-kea-disposables skill. The patterns below are kea-side conventions for the common shapes.

Why not a bare setInterval?

// don't do this
afterMount(({ actions, cache }) => {
    cache.pollTimer = setInterval(() => actions.loadFoo(), 5000)
}),
beforeUnmount(({ cache }) => clearInterval(cache.pollTimer)),

Problems:

  • Hidden-tab waste: the interval keeps hitting the API while the user is on a different tab.
  • Easy to leak: forget the beforeUnmount and the timer outlives the logic.
  • No way to stop early on a state change (need to track the timer manually).

The disposables plugin solves all three. The patterns below are just the right shape for common polling jobs.

Pattern: poll while a flag is true

Use case: a long-running job, an "active" indicator. The polling lives only as long as the work it's tracking.

listeners(({ actions, values, cache }) => ({
    startPolling: () => {
        cache.disposables.add(() => {
            const id = setInterval(() => actions.loadStatus(), 5000)
            return () => clearInterval(id)
        }, 'statusPoll')
    },
    stopPolling: () => {
        cache.disposables.dispose('statusPoll')
    },
    loadStatusSuccess: ({ status }) => {
        if (status === 'finished') {
            actions.stopPolling()
        }
    },
})),

Why the named key ('statusPoll'): calling startPolling twice replaces the previous timer instead of running two in parallel. And dispose('statusPoll') stops it without unmounting the whole logic.

Pattern: poll while mounted

Use case: a heartbeat / counter that runs the whole time the view is open.

afterMount(({ actions, cache }) => {
    cache.disposables.add(() => {
        const id = setInterval(() => actions.loadCurrentTeam(), 30000)
        return () => clearInterval(id)
    })
}),

No key needed — the timer is fire-and-forget; the plugin tears it down on unmount. The auto-pause on hidden tabs means it costs nothing while the user is elsewhere.

Pattern: poll on hover only

Use case: a tooltip / popover that wants live data while the cursor is over it.

listeners(({ actions, cache }) => ({
    setIsHovering: ({ isHovering }) => {
        if (isHovering) {
            cache.disposables.add(() => {
                const id = setInterval(() => actions.setNow(new Date()), 500)
                return () => clearInterval(id)
            }, 'hoverTick')
        } else {
            cache.disposables.dispose('hoverTick')
        }
    },
})),

The key lets the mouseleave handler stop the ticker without unmounting anything.

Pattern: one-shot timeout that may be spammed

Use case: a "saved!" banner that disappears after 2 seconds; if you save again, the timer resets.

listeners(({ actions, cache }) => ({
    showSaved: () => {
        cache.disposables.add(() => {
            const id = setTimeout(() => actions.hideSaved(), 2000)
            return () => clearTimeout(id)
        }, 'savedBanner')
    },
})),

Same key on each call — the previous timer is auto-disposed before the new one is registered. No manual tracking.

Picking the interval

  • Faster than 1 second: hover/animation only. Anything API-driven at this rate pummels the server.
  • 1–5 seconds: live indicators while the view is open (current online count, hot job status).
  • 10–30 seconds: background state refresh while the user is doing something else.
  • Minutes: stale-data refresh on long-lived pages.

Always include the interval as a named constant near the top of the file (REFRESH_INTERVAL_MS = 5000). It makes the trade-off explicit.

Anti-patterns

See anti-patterns.md for the consolidated catalogue.