@solidtv/nativescript
SolidTV on NativeScript: what an app needs to run
@solidtv/renderer and
@solidtv/solid on Apple TV and iOS
without a browser. NativeScript is the runtime host, @nativescript/canvas
the WebGL surface, and @nativescript/canvas-polyfill the browser-shaped
environment the renderer expects. The plan, its phases and their outcomes are
in docs/nativescript-integration-plan.md.
Status: phases 0 to 3 of the plan run on the iPhone and Apple TV simulators (2026-09-17): the demo, the Siri Remote driven by real presses, and the suspend and resume cycle. The same day all of it ran on a physical Apple TV HD too: the exit test passed there, a looping animation survived a minute in the background, and the measurement settled the WebGL decision below. Only the App Store Connect export is open, for want of a paid team. Phase 4 (2026-09-17) runs the public demo app on the Apple TV simulator from its own repository, built from the same source as its web build, with one Back rule added to that source and nothing else: the shims and the platform below carry every seam. See docs/phase4-results.md; the device run there waits on a provisioning profile for the demo's bundle id.
What is here
src/, the package:TvPlatform: the rendererPlatformfor this runtime. Sizes the canvas view's GL surface without touching its layout; keeps the web clock, which the runtime'srequestAnimationFrameshares; hands the renderer the Canvas viewbindCanvasregistered and a target that swallows the append, so an app's owncreateRenderer()needs neither; and through the renderer'sresolveSettingshook corrects the settings the host cannot honour: WebGL2, no image workers, both startup probes skipped, no inspector.bindCanvas(view): registers the Canvas view and makesTvPlatformthe renderer's default platform, for an app written for the browser and booted here unchanged.rendererSettings(canvas, screen)andstubTarget: the same as explicit renderer options, for an app that passes them itself as the harness does: 1920x1080 logical, SDF text, the platform's own settings (WebGL2, no image workers, both startup probes skipped, no inspector), and a target that keeps the renderer offdocument. WebGL2 because the plugin's WebGL1 context has no vertex array objects: 137 GL calls per frame against 32, on an Apple TV HD as on the simulators, at the same 60 fps. Spread the result to override a setting, but not one of the platform's:TvPlatformapplies those over whatever the settings say, so changing one takes a subclass that overridesresolveSettings, passed as theplatform, which is how the harness runs its WebGL1 comparison.KeyBridge: turns native presses intokeydownandkeyupevents for a focus manager, repeats a held key keyboard style, and is itself the event target:useFocusManager(undefined, bridge). Given atarget, it also dispatches every event there:document, for a focus manager that listens where a browser app's does.bindRemote(bridge, Application.ios.window, options?): the Siri Remote into the bridge on tvOS. A recognizer per button on the root view controller's view and four swipe recognizers. A press akeydownlistener handled withpreventDefault()is the app's; an unhandled Menu goes to the system and leaves the app. Swipes on the touch surface are not supported: they need not work, the swipe recognizers are a courtesy left unverified, and nothing depends on them.bindLifecycle(renderer, bridge, Application): onsuspend, releases the bridge's held keys and pauses the renderer at once; onresume, resumes it. The renderer may be a function returning it, for an app that creates its renderer after the host has bound the lifecycle.mirrorConsole(file, options?): every console line into a file in the app's container, written once a second and on suspend and exit, for a device whose console the CLI cannot stream.@solidtv/nativescript/shims: the browser globals the polyfill lacks or installs unusably, so an app written for the browser boots unchanged.locationas afile:URL into the app folder, which is what makes a relative asset URL a file in it; ahistorywith a real stack that raiseshashchange, for aHashRouterandhistory.back();window.dispatchEvent; adocumentwhose listeners work; fixes toXMLHttpRequestfor a file-backedjsonortextresponse, the runtime'sfile:///app/root, a JSON response asked for as a blob (whatfetchasks for) and a request with no response type; andconsole.table. Import it right after the polyfill. The polyfill bugs behind each are listed in docs/phase4-results.md.
webpack.cjs,@solidtv/nativescript/webpack:chainSolidTV, the Solid JSX rule, browser export conditions,.jsimports resolved to.tssources, the polyfill's optional-module ignore, and on request the hex-colour transform of@lightningtv/vite-hex-transformas a loader (hexColorLoader.cjs), aliases andimport.meta.envmembers, for a@nativescript/webpackconfig.harness/: the app that runs the plan's phases on both simulators, with the recorded results. See harness/README.md.docs/: the plan, the phase 2, 3 and 4 briefs (historical), the device brief for the device half of phase 3, and phase4-results.md, the demo app on the Apple TV.
Using it in an app
An app written for the browser, unchanged
The demo app runs on the Apple TV from its own src/, the source of its
web build, with a NativeScript project beside it and nothing of the host in
it: see
solid-demo-app/nativescript.
Its boot file, in short:
// app/app.ts
import "@nativescript/canvas-polyfill";
import "@solidtv/nativescript/shims";
import { Application } from "@nativescript/core";
import { Canvas } from "@nativescript/canvas";
import { Config, renderer } from "@solidtv/solid";
import {
KeyBridge,
bindCanvas,
bindLifecycle,
bindRemote,
} from "@solidtv/nativescript";
// A key press a handler consumed stays with the app; Menu with nothing to
// handle it goes to the system and leaves the app. SolidTV 1.7 or later.
Config.preventDefaultOnHandledKeys = true;
canvas.on("ready", () => {
// The app's own createRenderer() draws into this view through TvPlatform.
bindCanvas(canvas);
// The remote, into the bridge, onto document, where the app's focus
// manager listens.
const bridge = new KeyBridge({ target: document });
bindLifecycle(() => renderer, bridge, Application);
bindRemote(bridge, Application.ios.window);
// The app's own entry: it reads the URL, sets Config, creates the
// renderer and renders its routes, as in the browser.
import("../../src/index");
});
The app keeps its HashRouter, its focus manager on document, its
fetch, its relative asset URLs, its import.meta.env.BASE_URL and its
loadFonts. The host's webpack config adds chainSolidTV with the
hex-colour loader over the app's source and the app's aliases, copies the
app's public fonts and assets into the bundle, and turns the bundle's type
check off, as the web build has none.
An app that passes the host its settings
// app.ts
import '@nativescript/canvas-polyfill';
import '@solidtv/nativescript/shims';
import { Application, Screen } from '@nativescript/core';
import { isTvOS } from '@nativescript/core/platform';
import { Canvas } from '@nativescript/canvas';
import { createRenderer, registerDefaultShaders } from '@solidtv/solid';
import { useFocusManager } from '@solidtv/solid/primitives';
import {
KeyBridge,
bindLifecycle,
bindRemote,
rendererSettings,
stubTarget,
} from '@solidtv/nativescript';
canvas.on('ready', () => {
const { renderer, render } = createRenderer(
rendererSettings(canvas, Screen.mainScreen),
stubTarget,
);
registerDefaultShaders(renderer.stage.shManager);
const bridge = new KeyBridge();
bindLifecycle(renderer, bridge, Application);
if (isTvOS) {
bindRemote(bridge, Application.ios.window);
}
// The renderer's own font loading: the shims make it work on file URLs.
renderer.stage.loadFont('sdf', {
fontFamily: 'Ubuntu',
atlasUrl: 'file://' + appPath + '/assets/Ubuntu-Regular.msdf.png',
atlasDataUrl: 'file://' + appPath + '/assets/Ubuntu-Regular.msdf.json',
});
render(() => <App bridge={bridge} />);
});
// webpack.config.js
const webpack = require("@nativescript/webpack");
const { chainSolidTV } = require("@solidtv/nativescript/webpack");
module.exports = (env) => {
webpack.init(env);
webpack.chainWebpack(chainSolidTV);
return webpack.resolveConfig();
};
The app's tsconfig.json needs "jsx": "preserve" and
"jsxImportSource": "@solidtv/solid". The bridge has no input of its own:
on tvOS bindRemote feeds it from the Siri Remote; elsewhere the host calls
bridge.down(key) and bridge.up(key) (or tap) from whatever reports
presses, on-screen buttons in the harness on iOS. A Back handler that
navigates calls e.preventDefault(), or Menu leaves the app: SolidTV's
focus manager does not call it for a handler that returned true. The
harness is the worked example of all of this, including the pixel ratios,
the file:// asset URLs and what to log.
Peers: @solidtv/renderer 1.9.4 or later, which adds setDefaultPlatform,
the Platform.resolveSettings hook, the platform's own element lookup for
the render target, and relative font URLs resolved like image URLs;
TvPlatform imports setDefaultPlatform, so the package does not load on
1.9.3. That was the first release with the ./platform export and the
Platform.sizeCanvas hook
(solid-tv/renderer#199)
and the immediate pause, renderer.pause({ immediate: true }), which the
lifecycle binding calls
(solid-tv/renderer#201).
solid-js for the JSX preset, and webpack through @nativescript/webpack.
With SolidTV, 1.6.4 or later, which adds Config.preventDefaultOnHandledKeys,
the Menu contract, and ignores a second useFocusManager argument that
cannot listen; 1.6.3 was the first release where useFocusManager takes the
event target (solid-tv/solid#63),
and took that second argument for the target.
Developing
pnpm 10 and Node 18 or later. The renderer comes from npm. The harness is
its own pnpm root under harness/ and links this package from ..; see
its README for the rest.
pnpm install
pnpm build
pnpm test
pnpm lint
Releasing
release-it, the way the renderer and SolidTV release: on main, clean and
up to date, with tests and lint green, it asks for the increment, bumps
package.json, commits Release x.y.z, tags x.y.z, pushes, publishes to
npm (prepack builds first) and creates the GitHub release. It needs an npm
login with publish rights on @solidtv and a GitHub token in
GITHUB_TOKEN; the gh CLI's token serves:
GITHUB_TOKEN=$(gh auth token) pnpm release
Add --dry-run to see the plan without changing anything.
License
Apache-2.0, see LICENSE.