Overview

Plugins

Widgets

Reference

Publishing

Mobile Widgets

Mobile widgets are React Native components built with Re.Pack and bundled into the plugin zip. The Hiclaro mobile app loads them at runtime using Re.Pack's ScriptManager.

Build setup

Mobile widget bundles are built with Re.Pack (rspack under the hood). The output must be a single bundle.js file with no dynamic imports or lazy chunks — the file runs immediately when ScriptManager.loadScript fetches it, which is what triggers registerMobileWidgets.

The four shared packages below are provided by the host app at runtime via globalThis.__hiclaro_shared__. Declare them as externals so they are not bundled into the output — this keeps the bundle small and ensures a single React instance is shared between the host and all widgets.

Do not use Module Federation for the remote. MF requires the host to call container.init and container.get to initialise the shared scope and retrieve the exposed module. The host uses ScriptManager.loadScript and reads global.__hiclaro_widgetManifests directly — those MF methods are never called, so the entry code would never run.

// widgets/mobile/rspack.config.mjs
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as Repack from '@callstack/repack';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

export default Repack.defineRspackConfig({
  context: __dirname,
  entry: './src/index.ts',
  output: {
    path: path.join(__dirname, 'dist'),
    filename: 'bundle.js',
    publicPath: 'noop:///',
  },
  resolve: { ...Repack.getResolveOptions() },
  module: {
    rules: [
      {
        test: /\.[cm]?[jt]sx?$/,
        type: 'javascript/auto',
        use: { loader: '@callstack/repack/babel-swc-loader', parallel: true, options: {} },
      },
      ...Repack.getAssetTransformRules(),
    ],
  },
  // Shared modules are provided by the host at runtime.
  // Do NOT bundle them — declare them as externals pointing to globalThis.__hiclaro_shared__.
  externals: {
    react: "globalThis.__hiclaro_shared__['react']",
    'react-native': "globalThis.__hiclaro_shared__['react-native']",
    'react-native-svg': "globalThis.__hiclaro_shared__['react-native-svg']",
    '@tabler/icons-react-native': "globalThis.__hiclaro_shared__['@tabler/icons-react-native']",
  },
});

Registration

When the bundle file is executed, it must call registerMobileWidgets(pluginKey, manifests) at module top level. The host reads global.__hiclaro_widgetManifests[pluginKey] immediately after the script loads to obtain the widget list. Calling registerMobileWidgets inside a component, effect, or callback means it runs too late and the widgets will not be registered.

Copy registerMobileWidgets.ts into your plugin's source directory. Both the helper and the call site look like this:

// src/registerMobileWidgets.ts — copy this file into your plugin
import { ComponentType } from 'react';
import { MobileWidgetProps } from './types';

interface MobileWidgetManifest {
  tag: string;
  component: ComponentType<MobileWidgetProps>;
}

declare var global: typeof globalThis;
declare global {
  var __hiclaro_widgetManifests: Record<string, MobileWidgetManifest[]> | undefined;
}

export const registerMobileWidgets = (
  pluginKey: string,
  manifests: MobileWidgetManifest[],
): void => {
  if (!global.__hiclaro_widgetManifests) global.__hiclaro_widgetManifests = {};
  global.__hiclaro_widgetManifests[pluginKey] = manifests;
};
// src/index.ts
import { registerMobileWidgets } from './registerMobileWidgets';
import { MySensorWidget } from './MySensorWidget';

// Must be called at module top level — not inside a component or useEffect.
registerMobileWidgets('my-plugin', [
  { tag: 'my-sensor', component: MySensorWidget },
]);

Component interface

The host passes the following props to every mobile widget component. Copy the types.ts shim into your plugin source for type-checking.

  • api: WidgetApi — the WidgetApi instance for this widget. It is a stable object: its identity does not change between renders. See the section below for a note on datasource dependencies.

  • title?: string — optional display title from the widget instance configuration set by the user in the dashboard editor.

  • Additional keys from instanceConfig are spread onto the props object as [key: string]: unknown. Datasource and action input fields defined in plugin.json widgets[].inputs arrive this way.

// src/types.ts — local WidgetApi shim for type-checking inside the plugin
import { ComponentType } from 'react';

export interface Datasource {
  id: string;
  key: string;
  label: string;
  unit: string | null;
  type: string;
}

export type TelemetryLatestData = Record<
  string,
  Record<string, { value: number | null; state: string | null; recordedAt: number }>
>;

export interface WidgetApi {
  getDatasourceById(id: string): Datasource | undefined;
  getDatasourceByKey(key: string): Datasource | undefined;
  subscribeTelemetry(options: {
    datasourceIds: string[];
    mode: 'latest' | 'history';
    hours?: number;
    callback: (data: TelemetryLatestData) => void;
  }): () => void;
  triggerActionByKey(
    key: string,
    options?: { value?: string; assetId?: string },
  ): Promise<void>;
}

export interface MobileWidgetProps {
  api: WidgetApi;
  title?: string;
  [key: string]: unknown;
}

WidgetApi on mobile

The mobile WidgetApi exposes the following methods. It is a subset of the full WidgetApi documented on the WidgetApi reference page — action lookup helpers and per-device triggering are not available on mobile.

  • getDatasourceById(id) — look up a datasource by UUID.

  • getDatasourceByKey(key) — look up a datasource by dotted key (e.g. ariston.water_heater.current_temperature). Useful when the key is statically known and no ID input is needed.

  • subscribeTelemetry(options) — subscribe to live telemetry. Returns an unsubscribe function; call it in the useEffect cleanup.

  • triggerActionByKey(key, options?) — dispatch a plugin action by its manifest key. Returns a Promise.

Typography

The host app overrides Text in the shared React Native namespace before any plugin bundle is loaded. When your plugin imports Text from react-native, it receives a wrapped version that automatically applies the app's Montserrat font family — no extra setup required.

fontWeight values are mapped to the correct Montserrat variant: '400' / 'normal' → Regular, '700' / 'bold' → Bold, and so on up to '900' → Black. Pass fontWeight in your styles as you normally would — it will be translated automatically and removed from the final style object so it does not conflict with the injected fontFamily.

Datasource subscription

The fix is to call getDatasourceByKey (or getDatasourceById) in the render body — not inside the effect — and include the resolved datasource ID as an effect dependency. When the datasource registry finishes loading, getDatasourceByKey returns a value, causing the component to re-render, the ID changes from undefined to a real UUID, and the effect re-runs and subscribes correctly.

This pattern also means you must make sure the widget is not mounted until datasources are loaded. The host gates widget rendering on the datasource context being ready — plugin widgets do not need to handle the pre-load case themselves.

Zip layout

Place the compiled bundle at widgets/mobile/bundle.js inside the plugin zip. The API detects this path automatically and sets mobileBundleUrl on the plugin response — no additional flag in plugin.json is required.

Full example

A minimal sensor widget showing the correct datasource subscription pattern. The registerMobileWidgets.ts and types.ts files above are also required in your plugin source.

// src/MySensorWidget.tsx
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { MobileWidgetProps } from './types';

export const MySensorWidget = ({ api, title }: MobileWidgetProps) => {
  // Look up the datasource outside useEffect so its ID becomes a dependency.
  // api is a stable object that never changes identity — relying on it alone
  // as a dependency means the effect fires once at mount, before datasources
  // have loaded, and getDatasourceByKey returns undefined. Including
  // datasource?.id causes the effect to re-run once the datasource is ready.
  const datasource = api.getDatasourceByKey('my-plugin.sensor.value');
  const [value, setValue] = useState<number | null>(null);

  useEffect(() => {
    if (!datasource) return;
    return api.subscribeTelemetry({
      datasourceIds: [datasource.id],
      mode: 'latest',
      callback: (data) => {
        const entry = Object.values(data[datasource.id] ?? {})[0];
        if (entry?.value != null) setValue(entry.value);
      },
    });
  }, [api, datasource?.id]);

  return (
    <View style={styles.container}>
      <Text style={styles.label}>{title ?? datasource?.label ?? ''}</Text>
      <Text style={styles.value}>{value !== null ? String(value) : '–'}</Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: { padding: 16 },
  label:     { fontSize: 12, opacity: 0.6, marginBottom: 4 },
  value:     { fontSize: 32, fontWeight: '700', color: 'white' },
});

Layout behaviour

The mobile dashboard renders widget instances in a vertical scroll view, ordered by grid position (y ascending, x as tiebreak). Each widget is rendered full-width — the grid w and h values from the web dashboard are ignored on mobile. Widgets size themselves vertically based on their content.

The mobile app does not support dashboard editing. Users manage the dashboard layout from the web client; the mobile app reflects it automatically.

© 2026 Hiclaro. All rights reserved.