Subscriptions
Lifecycle, cleanup, framework patterns. The single most common bug class with the SDK.
client.car.onStatusChange(...) and client.car.onConnectionChange(...)
return an unsubscribe fn. Handle it correctly and you have free
real-time updates with no maintenance. Get it wrong and you have
memory leaks, double-renders, or CarStatusQuotaExceededError in
production.
TL;DR
// Always:
const off = client.car.onStatusChange(handler);
// Always (somewhere — useEffect cleanup, onUnmount, etc.):
off();That's the whole API. Everything below is "what happens under the hood" so you can debug correctly when it doesn't.
Lifecycle
┌──────────────────────────────────────────────────────────────┐
│ FIRST onStatusChange call │
│ ├─ adds your callback to the listener set │
│ ├─ lazily opens ONE subscription with the host bridge │
│ └─ installs document.visibilitychange listener (lazy) │
│ │
│ Nth onStatusChange call (same client.car) │
│ └─ just adds to the listener set; reuses the bridge sub │
│ │
│ Each off() call │
│ ├─ removes that callback from the listener set │
│ └─ if it was the last one, closes the bridge subscription │
│ │
│ off() called twice on the same handle │
│ └─ second call is a safe no-op │
└──────────────────────────────────────────────────────────────┘The lazy + refcounted lifecycle means: subscribing N times opens
one bridge subscription (cheap), and unsubscribing N times tears
it down once. As long as every onStatusChange returns a value you
eventually call, no resources leak.
Framework-specific patterns
React (incl. strict-mode double-mount)
import { useEffect, useState } from 'react';
import type { CarStatus } from '@i99dash/sdk';
export function CarStatusWidget({ client }) {
const [status, setStatus] = useState<CarStatus | null>(null);
useEffect(() => {
const off = client.car.onStatusChange(setStatus);
return off; // ← critical: cleanup returns the unsubscribe fn
}, [client]);
if (!status) return null;
return <span>{status.doorsLocked ? '🔒' : '🔓'}</span>;
}React 18 strict-mode mounts effects twice in dev. The lazy + refcounted lifecycle handles this correctly: the SDK opens the bridge sub once on first mount, closes it on first unmount, opens it again on the second mount. No leak, no quota issue.
Vue 3 (Composition API)
<script setup lang="ts">
import { onUnmounted, ref } from 'vue';
import type { CarStatus } from '@i99dash/sdk';
const props = defineProps<{ client: MiniAppClient }>();
const status = ref<CarStatus | null>(null);
const off = props.client.car.onStatusChange((s) => { status.value = s; });
onUnmounted(off);
</script>Svelte 5 (runes)
<script lang="ts">
import { onMount } from 'svelte';
let status = $state<CarStatus | null>(null);
onMount(() => client.car.onStatusChange((s) => { status = s; }));
// onMount returns the cleanup, Svelte calls it on destroy
</script>Vanilla — no framework
const off = client.car.onStatusChange(render);
window.addEventListener('beforeunload', off);
// And anywhere you tear down your widget mid-page-life:
// off();Page Visibility — automatic pause + catch-up
The SDK installs a document.visibilitychange listener the first
time you subscribe. While document.hidden === true:
- Your callback is suppressed (doesn't fire).
- The latest event is buffered as
_lastWhilePaused.
On visibilitychange back to visible:
- ONE catch-up callback fires with the buffered status.
- Normal flow resumes.
You don't opt in or out; it just works. Result: a backgrounded mini-app costs near-zero CPU on car-status traffic, but renders correctly the moment the user returns.
Connection-state callbacks are NOT paused — a backgrounded app still wants to know if the car signal went stale, so it can render the right banner the moment it becomes visible.
Subscriber quota: ≤10 per mini-app
Every viewer enforces a per-mini-app cap of 10 concurrent
subscriptions. The 11th throws CarStatusQuotaExceededError.
If you hit this in production, you have a leak. The fix is store the unsubscribe fn instead of subscribing again:
// ❌ Wrong — resubscribes on every keystroke; leaks 1 sub per render
function Search({ client }) {
const [q, setQ] = useState('');
client.car.onStatusChange(handler); // ← bug: leaks
return <input value={q} onChange={e => setQ(e.target.value)} />;
}
// ✓ Right — useEffect-cleanup teardown; one sub per mount
function Search({ client }) {
const [q, setQ] = useState('');
useEffect(() => client.car.onStatusChange(handler), [client]);
return <input value={q} onChange={e => setQ(e.target.value)} />;
}Throttling — not your problem
The host caps pushes to ~10 events / sec per mini-app via a token bucket. If the underlying car state changes faster than that (impossible in practice — the host polls at 10s), the bucket drops the oldest queued event in favour of the latest. So you always see the freshest state, never replayed history.
Keep-alive events (one every ~30s when nothing changed) are exempt
from the bucket so onConnectionChange stays observable on a
parked car.
You don't write any backpressure code. The host handles it.
Connection state vs. staleness
These are independent signals:
| Signal | Source | What it means | When to use |
|---|---|---|---|
status.staleness | Host clock vs. last poll | "How old is THIS payload?" (fresh < 15 s, stale 15–60 s, very_stale > 60 s) | Greying-out an indicator that's no longer trustworthy |
onConnectionChange | Host's polling-loop health | "Has the host's read-loop succeeded recently?" | Showing a "no signal" banner |
A parked car with healthy polling is connected + fresh. A
daemon crash mid-session is disconnected + (eventually)
stale/very_stale. Render with both — staleness for individual
fields, connection state for the global banner.
Errors
| Error | When | Fix |
|---|---|---|
CarStatusUnavailableError | Bridge isn't a CarStatusBridge (test stub or older host) | Catch + render a fallback UI |
CarStatusQuotaExceededError | >10 concurrent subscribers from this mini-app | Find the leak — see "Subscriber quota" above |
InvalidResponseError | Host pushed a malformed payload | Rare — usually a host/SDK version drift; log + report |
The SDK silently drops malformed events (with a console.warn in
dev) so a single bad payload doesn't throw inside your callback.
Related
- Errors reference — every error code.
- Real-time car status widget recipe — full end-to-end example.
- Testing — how to test these subscriptions.