i99dash docs
Guides

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:

SignalSourceWhat it meansWhen to use
status.stalenessHost 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
onConnectionChangeHost'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

ErrorWhenFix
CarStatusUnavailableErrorBridge isn't a CarStatusBridge (test stub or older host)Catch + render a fallback UI
CarStatusQuotaExceededError>10 concurrent subscribers from this mini-appFind the leak — see "Subscriber quota" above
InvalidResponseErrorHost pushed a malformed payloadRare — 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.

On this page