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
i99dashinstalled (pnpm add i99dash).esbuildas a dev dependency (pnpm add -D esbuild).- An entry module (
src/main.js) and ansrc/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 eagerly —
MiniAppClient.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, afterwhenHostReady(). - Never treat the eager
NotInsideHostErroras "this is a browser." Re-check viawhenHostReady()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
- Build:
pnpm bundle. Confirm the output starts with(() => {or(function, with no top-levelimport/export. - Publish:
i99dash publish. - Open the app on a Di5.0 car (L5). The boot chip shows the new version and the app renders real content.
- 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.
Related
Capability detection
Use `client.has()` and `client.capabilities()` to render conditionally so an older host without a family doesn't crash your UI.
Multi-display mini-apps
Render on the passenger screen, drive the cluster, build for cars with 1, 2, or 3 screens. The SDK's display + surface + gesture families end-to-end.