i99dash docs
Guides

Build for L5 + L8

One bundle that runs on every BYD trim — classic IIFE, low minHostVersion, the SDK, feature detection, and a graceful fallback.

The goal: one published bundle that runs on Di5.0 trims (Leopard 5, L5 Ultra, Song Plus) and Di5.1 trims (L8, L5 Lidar, HAN L). The pieces are documented separately elsewhere; this guide assembles them into one path so you build it right the first time instead of discovering L5 is dead in production.

Before starting, read the two gates in Targets — trims, WebView & host. Everything below makes a build pass both.

Since i99dash 5.1 this is enforced, not advisory. i99dash build (hence publish) statically checks every shipped JS file against the Di5.0 baseline and hard-fails if it can't run there — you can't accidentally publish a bundle that's blank on L5. --target=es2019 below is the simplest way to pass and is recommended, but the actual ceiling is Chromium 95 / ES2022 (Di5.0 ships com.android.webview 95). The real rules the gate enforces: classic/IIFE format (no ES modules), and no Chrome-96+ APIs (structuredClone, Array.findLast, …). If an app is intentionally Di5.1-only, declare requires (escape hatch) instead of fighting the gate.

Prerequisites

  • i99dash installed (pnpm add i99dash).
  • esbuild as a dev dependency (pnpm add -D esbuild).
  • An entry module (src/main.js) and an src/index.html.

Bundle as a classic IIFE, target ES2019

The Di5.0 WebView cannot execute an ES-module or modern chunked bundle. Produce a single classic script instead:

{
  "scripts": {
    "bundle": "esbuild src/main.js --bundle --format=iife --target=es2019 --outfile=src/app.bundle.js"
  }
}

--format=iife emits one classic <script> (no module loader). --target=es2019 down-levels modern syntax the old WebView's parser would choke on. The bundle gets ~5–15% larger; that is the entire cost, and it runs on Di5.1 too.

Load it with a plain script tag

In src/index.html, no type="module":

<script src="./app.bundle.js"></script>

Add a boot chip so you can confirm on-device which build loaded (the catalog can lag a publish by minutes):

<div id="boot" style="position:fixed;bottom:4px;right:4px">v0.1.0</div>

If the chip renders but nothing else does, the bundle never ran — you still have an ES-module or modern-syntax problem.

Use the SDK — it bundles into the classic script

You do not need to hand-roll window.flutter_inappwebview to get a classic bundle. esbuild inlines the SDK into the IIFE:

import { MiniAppClient } from 'i99dash';

const client = MiniAppClient.fromWindow();
const ctx = await client.getContext();

Hand-rolling the raw bridge is the usual source of host-resolution and wire-shape bugs (see Raw host-bridge protocol). Let the SDK own that. The "raw keeps it a single static file" rationale is moot — the SDK bundles to a single static file too.

But resolve it at the right time, not at module top-level — the next step is the single most common L5 production bug.

Wait for the host bridge before you use it

This is the most common L5 production bug, and it's invisible on L8. On a slow Di5.0 WebView the host injects window.flutter_inappwebview, but its callHandler is undefined for a beat after your script first runs. So resolving the bridge eagerlyMiniAppClient.fromWindow() at the top of a module, or in the first line of main() — throws NotInsideHostError even though you are inside the host. The usual catch then renders a "running outside a host" fallback and never touches the bridge again, so the app shows placeholder data on L5 (wrong/missing location, default theme, all- values) while working fine on the faster L8 WebView.

Confirmed on a real Di5.0 / Song Plus: flutter_inappwebview was present but callHandler was undefined, so fromWindow() threw — yet ~300 ms later the same location.read call returned a live GPS fix. The bridge wasn't missing; it just wasn't wired yet.

The fix: await readiness first. The flutter_inappwebview plugin fires a flutterInAppWebViewPlatformReady window event when the bridge is wired — listen for it, poll as a belt-and-braces, and cap with a timeout so a genuine browser preview still resolves:

/** Resolve `true` once the host bridge is actually callable, or `false`
 *  after `timeoutMs` (genuinely no host — browser preview / share link). */
function whenHostReady(timeoutMs = 5000): Promise<boolean> {
  const ready = () =>
    typeof window !== 'undefined' &&
    Boolean(
      (window as any).__i99dashHost?.callHandler ||
        (window as any).flutter_inappwebview?.callHandler,
    );
  return new Promise((resolve) => {
    if (ready()) return resolve(true);
    let settled = false;
    const done = (v: boolean) => {
      if (settled) return;
      settled = true;
      clearInterval(poll);
      clearTimeout(timer);
      window.removeEventListener('flutterInAppWebViewPlatformReady', check);
      resolve(v);
    };
    const check = () => {
      if (ready()) done(true);
    };
    const poll = setInterval(check, 150);
    const timer = setTimeout(() => done(false), timeoutMs);
    window.addEventListener('flutterInAppWebViewPlatformReady', check);
  });
}

Then render your defaults first (so the cabin is never blank), and gate every bridge call — context, location, car.* — behind readiness:

import { MiniAppClient } from 'i99dash';

renderDefaults();                  // paint immediately — never block first paint
if (await whenHostReady()) {
  const client = MiniAppClient.fromWindow();   // safe now — won't throw
  const ctx = await client.getContext();       // theme + locale
  // … client.car.*, location.read, etc.
} else {
  // genuinely outside a host (browser preview) — keep the default render
}

Two rules that prevent the whole class of bug:

  • Never resolve the bridge at module top-level (const client = MiniAppClient.fromWindow() as a file-scope const) — that runs at the instant the bridge is least likely to be wired on L5. Resolve it inside an async bootstrap, after whenHostReady().
  • Never treat the eager NotInsideHostError as "this is a browser." Re-check via whenHostReady() before falling back to a host-less path.

Keep minHostVersion at the floor you actually need

In manifest.json, set it to the lowest version your code requires. A higher value only stops older hosts from launching the app — it never adds capability:

{ "minHostVersion": "0.0.2" }

Feature-detect; never gate the whole app

Some host builds ship fewer car.* catalog categories than others (location and navigation land in v5.1 — see Host catalog status). Probe, then degrade:

const cat = await client.car.list({ category: 'location' });
if (cat.entries.length > 0) {
  // host has it — subscribe
} else {
  // host doesn't — show a default, not a blank screen
}

See Capability detection for the host-family axis.

Always have a non-blank fallback

A missing signal, an unimplemented handler, or an error envelope must land on a useful state — a default value, a cached value, or a clear "unavailable" message — never a frozen skeleton or all- screen. Surface errors; do not swallow them into an empty render.

Verify

  1. Build: pnpm bundle. Confirm the output starts with (() => { or (function, with no top-level import/export.
  2. Publish: i99dash publish.
  3. Open the app on a Di5.0 car (L5). The boot chip shows the new version and the app renders real content.
  4. Open it on a Di5.1 car (L8). Same bundle, same result.

If step 3 blanks but step 4 works, the bundle is still not a classic ES2019 script — recheck steps 1–2.

On this page