@pnlight/sdk-react
v0.7.0
Published
PNLight Remote UI React component powered by DivKit
Readme
@pnlight/sdk-react
A thin React wrapper around the official DivKit Web renderer with PNLight Remote UI behavior installed automatically.
Install
npm install @pnlight/sdk-reactUse
import { RemoteUi } from "@pnlight/sdk-react";
import "@pnlight/sdk-react/styles.css";
export function Screen({ config }: { config: object }) {
return (
<RemoteUi
id="home"
json={config}
theme="system"
onCustomAction={(action) => {
console.log(action.url);
}}
onError={({ error }) => {
console.error(error);
}}
/>
);
}Like the official @divkitframework/react package, the component accepts the
DivKit client render options except target and hydrate. PNLight supplies its
own target, custom components, global scaling/safe-area variables, Lottie
extension, and protocol handlers.
The package also exports PNLightDivKit as an alias for RemoteUi.
The registered custom components include pnlight.animated_prepend_list. It
uses the same props as the iOS component: increasing count by one smoothly
prepends the next discovery-ordered item, while larger jumps or resets apply
immediately. Rows use intrinsic content height, and instance_id preserves the
displayed count if DivKit replaces the underlying custom element.
On the web, DivKit does not make expressions inside custom_props reactive.
For a variable-driven list, keep count as a literal initial value and pass
the variable name through count_variable:
{
"custom_props": {
"count": 0,
"count_variable": "visible_item_count",
"items": []
}
}The component subscribes to that DivKit variable and applies the same prepend animation for each increment.
Progress bar and animated number
schemaVersion: 3 documents can also use pnlight.progress_bar and
pnlight.animated_number, with the same props as the iOS components. The bar
animates from its current fill to a new progress, and the label counts
through every value between the displayed number and a new value, so markup
does not have to drive either with DivKit animations.
The same reactivity rule applies: keep progress / value as the literal
initial value and name the variable in progress_variable / value_variable.
{
"type": "custom",
"custom_type": "pnlight.progress_bar",
"width": { "type": "match_parent" },
"height": { "type": "fixed", "value": 10 },
"custom_props": {
"instance_id": "scan",
"progress": 0,
"progress_variable": "scan_progress"
}
}Numbers are formatted with Intl.NumberFormat for the browser's locale, the
web bar uses a CSS transition, and both components respect
prefers-reduced-motion. On the web track_height is also the bar's minimum
thickness, so give it the same value as any DivKit height thinner than its
8 default. In a schemaVersion 1 or 2 document neither
custom_type is registered, matching the native version gate.
PNLight actions
Navigation, dialogs, and haptics are consumed inside the component for
schemaVersion: 2 and newer documents. Observe them when needed:
<RemoteUi
id="offer"
json={config}
onPNLightAction={({ kind, url, logId, message }) => {
console.log(kind, url, logId, message);
}}
/>Unconsumed custom URLs continue through DivKit's standard onCustomAction
callback.
Close action
A schemaVersion: 5 document asks the host to close it with the plain URL
action pnlight://close. On iOS the SDK only notifies the app, which dismisses
the screen itself. A browser has no app to do that, so the component covers
the rendered document with a full-view "Screen is closed" placeholder and then
calls onClosed:
<RemoteUi
id="offer"
json={config}
onClosed={() => {
// Optional: unmount the component to show your own UI instead.
}}
/>The same action is reported through onPNLightAction with kind: "close".
Right after onClosed, the component also replays the legacy custom action
{ url: "myapp://close", log_id: "close_button" } through onCustomAction,
matching the native SDKs, so hosts that still close from that callback keep
working. Mounting the component again (for example with a new id) renders
the document from scratch.
Safe-area insets
Documents that declare a safe_area read the phone's insets from the
safe_area_top, safe_area_bottom, safe_area_left and safe_area_right
variables (mode content) or get them as padding (mode inset). A browser has
no phone, so the component simulates a fixed one: 24 on top and 16 at the
bottom. A host that draws a particular phone around the document passes that
phone's insets instead, in CSS pixels:
<RemoteUi
id="preview"
json={config}
safeAreaInsets={{ top: 59, bottom: 34 }}
/>Edges left out keep the simulated values. Pass a stable object: a new one on every render remounts the document.
Restore action
A schemaVersion: 6 document restores purchases with the typed custom action
pnlight.restore. params and both follow-up lists are optional:
{
"log_id": "restore",
"typed": { "type": "custom" },
"payload": {
"id": "pnlight.restore",
"params": { "on_success": [], "on_fail": [] }
}
}On iOS the SDK syncs App Store purchases, then runs on_success, or on_fail
on error. On the web the store simulator opens a "Restore purchases" dialog:
Success executes on_success, Fail executes on_fail, and dismissing the
dialog runs nothing. Only one purchase or restore dialog is open at a time.
Products
A schemaVersion: 6 document (single card or flow) can declare up to 16
products at its root. An entry is a product id, or an object with id,
optional selected, and an optional fallback shown until store data arrives:
"products": [
"com.app.monthly",
{ "id": "com.app.yearly", "selected": true,
"fallback": { "price": "$39.99", "period": "year", "period_count": 1,
"price_per_month": "$3.33", "price_per_week": "$0.77",
"has_trial": true, "trial_count": 3, "trial_unit": "day",
"intro_price": "" } }
]Fallback keys are the strings name (what the document calls the plan; the
store has no such name), price, period, price_per_month,
price_per_week, trial_unit, intro_price, the integers period_count,
trial_count, and the boolean has_trial; unknown keys and wrong types are
ignored. Empty or duplicate ids, or a malformed entry, fail the load through
onError with an Invalid PNLight products: ... message.
Before the card renders, three global variables are published to every route:
| Variable | Type | Value |
| --- | --- | --- |
| products | array | One dict per product in document order. Every dict has all keys: id, the fallback keys above (defaults "", 0, false), is_selected, and is_loaded (store data exists for the id). |
| selected_product | string | The selected id: the first entry with selected: true, else the first entry. |
| selected | dict | The products entry of the selected product. |
Each dict is the defaults, overlaid by fallback, overlaid by store data. A
document selects a product with an ordinary set_variable action on
selected_product; products and selected are republished immediately.
Render plans with DivKit's item_builder, and purchase the selection by
passing "@{selected_product}" as the pnlight.purchase product_id:
{
"type": "container",
"items": [
{
"type": "container",
"item_builder": {
"data": "@{products}",
"data_element_name": "product",
"prototypes": [{
"div": {
"type": "text",
"text": "@{product.getString('price')} / @{product.getString('period')}",
"paddings": { "left": 16, "right": 16, "top": 14, "bottom": 14 },
"margins": { "bottom": 8 },
"border": {
"corner_radius": 12,
"stroke": {
"width": 2,
"color": "@{product.getBoolean('is_selected') ? '#0A84FF' : '#D1D1D6'}"
}
},
"actions": [{
"log_id": "select_product",
"typed": {
"type": "set_variable",
"variable_name": "selected_product",
"value": { "type": "string", "value": "@{product.getString('id')}" }
}
}]
}
}]
}
},
{
"type": "text",
"text": "@{selected.getInteger('trial_count')}-@{selected.getString('trial_unit')} free trial",
"visibility": "@{selected.getBoolean('has_trial') ? 'visible' : 'gone'}"
},
{
"type": "text",
"text": "Continue for @{selected.getString('price')}",
"actions": [{
"log_id": "buy",
"typed": { "type": "custom" },
"payload": {
"id": "pnlight.purchase",
"params": { "product_id": "@{selected_product}" }
}
}]
}
]
}Supply store data keyed by product id; every field is optional:
<RemoteUi
id="offer"
json={config}
storeProducts={{
"com.app.yearly": { price: "€35.99", period: "year", has_trial: true,
trial_count: 3, trial_unit: "day" }
}}
/>Changing storeProducts republishes the variables live without remounting
the document and keeps the current selection. The web purchase simulator
resolves @{selected_product}, so its dialog title shows the real product id.
Document files (schema v7)
A schemaVersion: 7 document built by the PNLight console reads its pictures
from storage and lists them under the root files array (id, url,
contentType, bytes). The component downloads and decodes every listed file
before it renders the first screen, waiting up to four seconds, so the screen
appears whole; a file that fails or arrives late loads when it is drawn.
Where the web renderer is filled in (schema v7)
DivKit for the web leaves two things out that native DivKit plays, so the component plays them itself; documents stay as written for the apps.
- Expressions in
custom_props. Apnlight.cta_buttonwhosetitleis"@{selected.getString('price')}"(or any custom component prop holding@{…}) shows the live value and follows the variables it reads, instead of the raw expression. The*_variableprops of the progress bar and the animated number keep working as before. - Entrances around
item_builder. Atransition_inon a list built fromitem_builder, on a prototype inside it, or on any box above it makes DivKit for the web fail the card ("Expression execution error") or empty its texts. The component takes those transitions off the document and plays them with the Web Animations API when the element becomes visible:fade(fromalpha),slide(fromedgebydistance),scale, and asetof them, withduration,start_delayandinterpolator.
Styling
Import @pnlight/sdk-react/styles.css once. Give the component or its parent an
explicit height when the document uses match_parent:
<RemoteUi id="full-screen" json={config} style={{ minHeight: "100dvh" }} />Compatibility
- Missing
schemaVersion, or version1, keeps legacy edge-to-edge behavior. schemaVersion: 2enables PNLight flows, safe areas, scaling variables, dialogs, and haptics.schemaVersion: 3preserves all v2 behavior, accepts SDK-owned typed actions such aspnlight.purchase, and registers thepnlight.progress_barandpnlight.animated_numbercomponents. Its purchase simulator preserves the original success-only contract:Successexecuteson_success, whileFailonly closes the simulator.schemaVersion: 4preserves all v3 behavior and addson_failandon_canceltopnlight.purchase. The simulator adds aCancelbutton;Failexecuteson_fail, whileCancelor dismissing the simulator executeson_cancel. No real StoreKit transaction occurs.schemaVersion: 5preserves all v4 behavior and consumespnlight://close: the component shows its "Screen is closed" view, callsonClosed, and replays the legacymyapp://closecustom action. In older schemas the URL reachesonCustomActionuntouched.schemaVersion: 6preserves all v5 behavior, consumes thepnlight.restoretyped action, publishes root-levelproductsas theproducts,selected_product, andselectedvariables fed by thestoreProductsprop, and accepts"@{selected_product}"as apnlight.purchaseproduct_id. In older schemaspnlight.restorereachesonCustomActionuntouched andproductsis ignored.schemaVersion: 7preserves all v6 behavior, prefetches and decodes rootfilesbefore first render (up to four seconds), resolves expressions in custom component props, and plays otherwise unsupported entrances arounditem_builderon the web. Versions 1–6 retain their previous immediate rendering and unmodified DivKit document behavior.schemaVersion: 8preserves v7 document behavior. The native SDK can also honor the backend UI response'sdeferHomeGestureflag while Remote UI is onscreen. The web preview has no equivalent system gesture.- CTA, icon button, circular loader, animated prepend list, and Lottie support are additive.
This initial package is client-side only. Use it from a client component in SSR frameworks.
