Skip to main content

Platform-specific code

A Sublime app is one TypeScript codebase that builds into web, mobile, and desktop. Most of your code is genuinely shared — models, theme, business logic, component types. But some code can only run on one platform: a barcode scanner that needs a React Native library on mobile, a drag-and-drop uploader that needs a DOM library on web. Sublime keeps those apart with a simple file-suffix convention, so the same import resolves to different code per target.

Why file suffixes exist

The bundler on each platform picks a different file for the same import:

  • Metro (mobile) prefers a *.native.tsx file when one exists.
  • Vite / webpack (web and desktop) ignore .native and pick the base *.tsx file.

So import { Scanner } from './Scanner' resolves to Scanner.native.tsx on mobile and Scanner.tsx on web — no runtime if (isMobile) branch, no dead code shipped to the wrong platform.

The heavy platform libraries behind these files are optional peer dependencies. Your app installs react-native-vision-camera only if it ships mobile, and a DOM-only uploader only if it ships web. Because they are optional, the platforms you don't target never need them installed — as long as the imports stay on the right side of the suffix line.

The folder pattern

Keep every platform-specific feature in its own folder, with a .native file, a base sibling, and a bare index.ts that re-exports without a suffix:

src/features/scanner/
├─ Scanner.native.tsx ← the ONLY file that imports the RN library
├─ Scanner.tsx ← web/desktop sibling — SAME exports, NO RN import
└─ index.ts ← export * from './Scanner' (bare, no suffix)

Rules

  1. The native library is imported only inside a *.native.tsx file.
  2. Every .native file has a base sibling with identical exports.
  3. Never import the native library from index.ts or any shared file.

The rest of your app imports from the folder (import { Scanner } from '@/features/scanner') and never touches a suffixed file directly. The bundler does the picking; your call sites stay platform-agnostic.

The leak failure mode

The convention only holds if the native import stays inside the .native file. The moment a shared file — index.ts, a base Scanner.tsx, a hook, a utility — imports the native-only library, that library gets pulled into the web build's import graph:

// src/features/scanner/index.ts ← WRONG
import { Camera } from 'react-native-vision-camera'; // now in the WEB bundle
export * from './Scanner';

Web (and desktop) will try to bundle react-native-vision-camera, which either fails to resolve — the package isn't installed for web — or breaks at runtime. The failure is symmetric: a web-only package (say a DOM uploader) imported from a shared file leaks into the mobile build the same way. These leaks are easy to introduce with an innocent-looking import and easy to miss until a production bundle breaks.

sublime check

sublime check catches those leaks before the bundler runs. It statically walks each platform's import graph from that platform's entry point and reports any package that reaches a build it shouldn't — a native-only package reachable from web, or a web-only package reachable from mobile — with the exact file:line that pulled it in.

npx sublime check

A clean run exits 0; a leak exits 1. When Scanner.native.tsx's react-native-vision-camera import slips into the shared index.ts, the web graph picks it up while mobile stays clean, and the report reads:

✓ mobile build: no web-only imports leaked
✗ src/features/scanner/index.ts:2
imports 'react-native-vision-camera' (mobile-only)
→ move it into a *.native file

1 leak found.

The (mobile-only) label names where the package belongs relative to the graph it leaked into: a native library has no place in the web build.

Options

  • --platform web|mobile — check only one platform's graph instead of both.
  • --json — emit the findings as JSON for CI or tooling to consume.
  • --project <path> — run against an app in another directory.

A package that declares its React Native or web dependency as an optional peer is treated as universal (cross-platform), so it is never flagged. That's why the framework's own @sublime-ui/ui and @sublime-ui/library — which wrap both MUI and Paper behind optional peers — don't trip the checker.

When it runs

Sublime wires the check into the commands that matter:

  • sublime dev:web / sublime dev:desktopwarns on a leak but keeps going, so you notice it without losing your dev loop.
  • sublime build (mobile) / sublime build:desktopblocks the build: a leak fails the command before the bundler starts.

One important gap to close yourself: the plain web production build is vite build run directly, not through devkit, so it is not devkit-gated. Add sublime check to your CI pipeline or to your build:web npm script so a leak can't slip into a web release:

{
"scripts": {
"build:web": "sublime check --platform web && sublime build:nav && vite build"
}
}

Escape hatch

Some packages are legitimately fine to reach a platform even though the checker would flag them — or you want to point the check at non-default entry files. Configure both in sublime.config.json:

{
"check": {
"allow": {
"web": ["some-pkg"],
"mobile": ["another-pkg"]
},
"entries": {
"web": "web/main.tsx",
"mobile": "mobile/App.native.tsx"
}
}
}

allow lists packages to treat as permitted per platform; entries overrides the entry point each platform's graph is walked from.

What it doesn't catch

sublime check classifies each external package by its top-level identity — it reads that package's own package.json, but it does not walk inside node_modules. So a leak buried in a dependency's own internals is invisible to it. In particular, a package that is universal at the top level can still expose a platform-specific subpath whose barrel re-exports native code: importing @sublime-ui/storage (which re-exports the Expo SQLite driver) from a web file pulls React Native source into the web bundle, but the checker reports it clean, because @sublime-ui/storage classifies as universal. Reach for the platform-safe subpath yourself in those cases — @sublime-ui/storage/web on the web side — the same way you would split your own code with a .native file. The checker guards your import graph; a dependency's internal platform split is still yours to import correctly.

Where to go next

For how platform-specific screens and the shared core sit in your tree, see Project structure. For desktop-only OS capabilities behind a typed bridge, see Native calls. For the full command surface and flags, see the CLI reference.