Numable authoring docsthe same content is available in the terminal: `numable docs <topic>`中文

xpage — declarative pages (.xpage)

Audience: people building tools, and the AI working on their behalf. Both read this same page.

What it is

.xpage is "RCN for a whole page": one JSON file is a node tree, containers place things and leaves draw things. It sits alongside html pages and is selected by type: "xpage" on that route in page/router.json (for how to write routes, see numable docs page).

There is exactly one reason to pick it: you want the page to speak the same visual language as the widgets. Detail pages, chart pages, tab pages, paginated lists — written this way they use the same render core, the same .rcn drawing model and the same light/dark pairs as a .xwidget. Conversely, forms, long-form text and anything you want to lay out with the DOM are less work as an html page.

Two structural differences from html pages shape how you write it:

A minimal working example

page/xpage/detail.xpage:

{
  "type": "page",
  "id": "detail",
  "i18n": {
    "zh-CN": { "empty": "还没有数据" },
    "en-US": { "empty": "Nothing yet" }
  },
  "root": {
    "type": "container",
    "id": "root",
    "layout": "list",
    "direction": "vertical",
    "padding": "16pt",
    "gap": "12pt",
    "paddingTop": "${@contentInset.top}pt",
    "paddingBottom": "${@contentInset.bottom}pt",
    "depends": [{ "flow": "@[file://page/flow/detail.df]", "params": { "id": "${id}" } }],
    "events": { "onLoad": "@[file://page/flow/mark-read.af]" },
    "items": [
      {
        "type": "Canvas",
        "id": "hero",
        "h": "120pt",
        "params": { "title": "${title}", "sub": "${sub}" },
        "canvas": { "source": "@[file://page/rc/hero.rcn]" }
      },
      {
        "op": "forEach",
        "props": { "items": "${rows}", "key": "r", "index": "i" },
        "items": [
          {
            "type": "Canvas",
            "id": "row-${r.id}",
            "h": "56pt",
            "params": { "label": "${r.label}", "value": "${r.value}" },
            "events": { "onClick": "@[file://page/flow/open-row.af]" },
            "canvas": { "source": "@[file://page/rc/row.rcn]" }
          }
        ]
      }
    ]
  }
}

The shell and the root

The shell has only four keys, three of them required:

Field Required Notes
type ✓ Always "page"
id ✓ Page identifier; match the route name
root ✓ The content root, which must be a container
i18n The page-level string table {locale: {key: string}}, used by ${@i18n.key} at the node level

The shell has no depends / events / layout / state / width. Page-level fetching is the depends on root, the page lifecycle is the events on root, the width is always given by the container, and the presentation belongs to router.json.

The page title goes on root (optional): "title": "${detail.name}". Once the page scrolls past its header, the container shows a small title in the pill row, and this is where it comes from. It is evaluated against everything the root node can read (route params, state, data, the output of the root's depends) and recomputed on first paint, on every redraw and when the root's fetch returns, so the title follows the data; if it evaluates to empty, it falls back to name / title in the route query, then to the router.json title and the package name (see numable docs page). You can leave it out — users then see the route title after scrolling.

There are only two kinds of node:

id must be unique within the page; nodes generated by forEach stay unique through interpolation ("id": "row-${r.id}"), because a duplicate id makes a redraw land on the wrong cell.

Besides canvas.source, a Canvas leaf can carry its own canvas.depends (re-fetch only this block without disturbing the page) and canvas.refresh (timed re-fetch; for the syntax and the floor see numable docs xwidget):

{
  "type": "Canvas",
  "id": "quote",
  "h": "96pt",
  "canvas": {
    "source": "@[file://page/rc/quote.rcn]",
    "depends": { "flow": "@[file://page/flow/quote.df]", "params": { "code": "${code}" } },
    "refresh": { "interval": ["300"] }
  }
}

Making room at the top

The container chrome is a floating capsule and it reserves no space for the page. The root container has to make room itself:

{
  "paddingTop": "${@contentInset.top}pt",
  "paddingBottom": "${@contentInset.bottom}pt"
}

The eight layouts

layout is required. The default is flex, not list — a scrolling list that forgot its layout becomes a vertical flex with no spacing, which looks like "gap isn't working".

Each layout only understands its own fields; fields belonging to another layout raise no error and do nothing.

absolute — free anchoring

Children position themselves with x/y/w/h (plus r for the right edge and b for the bottom) and support geometry expressions: {parent.w}, sibling anchors {sibling_id.r}, arithmetic, and pt/px.

{
  "type": "container",
  "id": "hero",
  "layout": "absolute",
  "h": "220pt",
  "items": [
    { "type": "Canvas", "id": "bg", "x": "0pt", "y": "0pt", "w": "{parent.w}", "h": "{parent.h}", "canvas": { "source": "@[file://page/rc/bg.rcn]" } },
    { "type": "Canvas", "id": "badge", "x": "{bg.r}-72pt", "y": "16pt", "w": "56pt", "h": "24pt", "canvas": { "source": "@[file://page/rc/badge.rcn]" } }
  ]
}

{parent.h} is only available when the parent's height is known (the parent declared h, or the parent is the page root). If the parent's height is content-driven, referencing it can't be resolved and falls back to auto.

stack — overlay

Children cover each other, items order is bottom to top, and positioning comes from align on the child — no coordinates.

{
  "type": "container",
  "id": "card",
  "layout": "stack",
  "h": "160pt",
  "items": [
    { "type": "Canvas", "id": "bg", "align": "fill", "canvas": { "source": "@[file://page/rc/bg.rcn]" } },
    { "type": "Canvas", "id": "tag", "align": "bottom|right", "w": "88pt", "h": "28pt", "canvas": { "source": "@[file://page/rc/tag.rcn]" } }
  ]
}

align is matched as a substring, not an enum, and three rules cover it:

Written Effect
fill Fills both axes; once it fills, center / right / bottom all stop mattering
Horizontal center centers, right right-aligns; when both are present right wins; neither means left
Vertical bottom aligns to the bottom, otherwise the top — there is no vertical centering, center only affects the horizontal axis

The default is top|left|fill. Because matching is by substring, words like middle and middleCenter mean nothing at all and silently fall back to the default. A child that isn't fill must write its own w, or it gets stretched to the full width.

flex — sequential placement

Only two fields do anything: direction (vertical by default / horizontal) and gap. Horizontally, children use their own w, and share the space equally if they don't declare one.

{
  "type": "container",
  "id": "tabbar",
  "layout": "flex",
  "direction": "horizontal",
  "padding": "12pt",
  "gap": "8pt",
  "items": [
    { "type": "Canvas", "id": "tab0", "w": "112pt", "h": "44pt", "params": { "label": "Quotes" }, "events": { "onClick": "@[file://page/flow/tab0.af]" }, "canvas": { "source": "@[file://page/rc/tab.rcn]" } },
    { "type": "Canvas", "id": "tab1", "w": "112pt", "h": "44pt", "params": { "label": "Watchlist" }, "events": { "onClick": "@[file://page/flow/tab1.af]" }, "canvas": { "source": "@[file://page/rc/tab.rcn]" } }
  ]
}

wrap / justify / align, and flexGrow / flexShrink / flexBasis on children, are not implemented and do nothing. For wrapping use flow; for centering, compute w and padding yourself.

flow — wrap when full

For tags, chips and other naturally flowing blocks. Fields: itemSpacing (spacing between items, default 8) and lineSpacing (spacing between lines, default 8). Children declare their own w (80 is assumed when absent) and wrap to the next line when they no longer fit.

{
  "type": "container",
  "id": "tags",
  "layout": "flow",
  "itemSpacing": "8pt",
  "lineSpacing": "8pt",
  "items": [
    { "type": "Canvas", "id": "t1", "w": "72pt", "h": "28pt", "params": { "label": "A-shares" }, "canvas": { "source": "@[file://page/rc/tag.rcn]" } },
    { "type": "Canvas", "id": "t2", "w": "96pt", "h": "28pt", "params": { "label": "HK stocks" }, "canvas": { "source": "@[file://page/rc/tag.rcn]" } }
  ]
}

lineAlignment and maxLines are not implemented.

list — single-axis collection

The one you'll use most, and the default choice for a page root.

Field Notes
direction vertical (default) / horizontal, which sets the scroll axis
itemSpacing Spacing between items, takes precedence over gap; gap only applies when it is absent (two names, one meaning)
edgeInsets Extra space at each end, applied once at both ends of the main axis, on top of padding
snap true = snap to an item's start on release; only meaningful when this list is itself the scroll source
h Vertical: declaring a fixed height makes this list its own scroll source (it always occupies h from the outside and scrolls internally when the content overflows); omitting it makes the list as tall as its content and leaves scrolling to the page

A horizontal band:

{
  "type": "container",
  "id": "band",
  "layout": "list",
  "direction": "horizontal",
  "h": "120pt",
  "itemSpacing": "8pt",
  "edgeInsets": "16pt",
  "snap": true,
  "items": [
    { "type": "Canvas", "id": "s1", "w": "140pt", "h": "120pt", "params": { "label": "1" }, "canvas": { "source": "@[file://page/rc/slot.rcn]" } },
    { "type": "Canvas", "id": "s2", "w": "140pt", "h": "120pt", "params": { "label": "2" }, "canvas": { "source": "@[file://page/rc/slot.rcn]" } }
  ]
}

Children of a horizontal list take their own w (omit it and one item fills the screen), the content scrolls horizontally when it overflows, and it scrolls without requiring an h — the "needs a fixed height to scroll" rule is vertical-only. ⚠️ A horizontal list does not support onReachEnd, so there is no horizontal infinite scroll.

pager — paging

The paging axis is always horizontal. Each page is a container, and a page runs its own depends only when you first turn to it, so "one data set per page" needs no nested pages.

Field Notes
h Declare it explicitly; 400 is assumed otherwise
pageSize Page width; when it is smaller than the container, the current page is centered and the neighbors peek out on both sides (a carousel). Omitted or wider than the container means one full-width page
pageSpacing Spacing between pages, default 0; at full width you only see this gap during the swipe
loop true = wrap around from the last page to the first, effective only with more than two pages
pageCacheCount How many pages to pre-render on each side of the current one, default 1; 0 = only the current page. It's a page count, not a length
scrollEnabled false = no swiping, pages can only be changed by the controlled page
page The controlled page index, written as an expression ("${activeTab}"); tapping a tab changes state and turns the page
{
  "type": "container",
  "id": "pager",
  "layout": "pager",
  "h": "300pt",
  "pageSize": "280pt",
  "pageSpacing": "12pt",
  "loop": "true",
  "page": "${activeTab}",
  "events": { "onPageChange": "@[file://page/flow/tab-sync.af]" },
  "items": [
    { "type": "container", "id": "p0", "layout": "list", "depends": [{ "flow": "@[file://page/flow/p0.df]", "params": {} }], "items": [] },
    { "type": "container", "id": "p1", "layout": "list", "depends": [{ "flow": "@[file://page/flow/p1.df]", "params": {} }], "items": [] }
  ]
}

The payload of onPageChange is @event.page, always the logical index 0..n-1 (loop included). direction: "vertical" and initialPage are not implemented; set the initial page with the page expression. loop and pageCacheCount take effect on a device; the local preview doesn't simulate either of them.

grid — equal-width grid

Field Notes
columnCount Column count, must be a number ("2" is read as absent and falls back to the default of 2)
columnSpacing / rowSpacing Column spacing / row spacing
{
  "type": "container",
  "id": "grid",
  "layout": "grid",
  "columnCount": 2,
  "rowSpacing": "12pt",
  "columnSpacing": "12pt",
  "items": [
    { "type": "Canvas", "id": "g1", "h": "88pt", "canvas": { "source": "@[file://page/rc/cell.rcn]" } },
    { "type": "Canvas", "id": "g2", "h": "88pt", "canvas": { "source": "@[file://page/rc/cell.rcn]" } }
  ]
}

The container computes the column width ((content width − spacing) / columns), and children get stretched to it whether or not they declare a w; every row is as tall as its tallest item. itemAspectRatio and crossAxisAlignment are not implemented.

waterfall — ragged-height masonry

Same fields as grid (columnCount / columnSpacing / rowSpacing), except each child keeps its own height and a new item always lands in whichever column is currently shortest. The column assignment strategy isn't configurable; balanceStrategy does nothing.

{
  "type": "container",
  "id": "feed",
  "layout": "waterfall",
  "columnCount": 2,
  "columnSpacing": "12pt",
  "rowSpacing": "12pt",
  "items": []
}

Control flow: op

Besides XContainer and Canvas, a node array can hold op nodes: an op renders nothing itself, it only decides which nodes this stretch expands into, and with what variables. Its children go in items (not the react that RCN uses).

There are five, and they behave identically on iPhone, Android, HarmonyOS and desktop:

op props What it does
forEach items array · key (variable name for the current element, defaults to item) · index (variable name for the position, defaults to index) Expands the stretch in items once per array element
for from · to · step (defaults to 1) · index (defaults to index) Expands a fixed number of times. Half-open: from is included, to is not; a negative step counts down
if cond · else (a node array, optional) Expands items when the condition holds, otherwise else
set name · value Computes a variable for the siblings that follow it
remove cond Drops this whole stretch when the condition holds
{
  "op": "forEach",
  "props": { "items": "${rows}", "key": "r", "index": "i" },
  "items": [
    { "type": "Canvas", "id": "row-${r.id}", "h": "56pt",
      "params": { "label": "${r.label}", "n": "${i}" },
      "canvas": { "source": "@[file://page/rc/row.rcn]" } }
  ]
}

⚠️ if takes its condition in cond — this is not the same thing as the op:if in a data flow or an action flow, which reads props.val. Getting them the wrong way round raises no error, and breaks differently in each direction: writing val in a .xpage leaves cond empty, and an empty condition counts as true — so the stretch expands every time and else is never reached, which looks like "the condition does nothing"; in a flow, writing cond means you never branch at all. The rule is one line: write cond in a .xpage, and val in a .df / .af.

⚠️ For the same reason, a cond that cannot be evaluated counts as true (as does leaving cond out entirely). So "cond": "${maybeMissing}" needs that key to be present: an empty string reads as false, while a missing key reads as true — the two kinds of "empty" give opposite answers.

⚠️ for takes from / to / step, not the count that RCN's for uses. The same word means different things in the two places; do not copy one into the other.

⚠️ set changes what follows it, not what is inside it: it merges the variable into the scope of this level, so every node after it — items included — can read it, while nodes written before it cannot.

⚠️ Loop variables are gone once the stretch is expanded: key and index exist only during expansion and are not kept on the node tree. To use one inside an event flow, pass it in through params at expansion time ("n": "${i}" above).

⚠️ Put the loop variable in the id ("id": "row-${r.id}"): duplicate ids on one page make a redraw land on the wrong row.

Page scopes: params / state / data

When reading values there is one merged scope. Write ${rows}, not ${params.rows}. At runtime the six levels below are searched top down, and the first hit wins:

Priority Source
1 Local variables of an op operation (forEach's key / index, set's name)
2 This node's depends output
3 This node's explicit params
4 Params inherited down the parent chain (already including every ancestor's depends output; nearer wins)
5 Page state
6 Global data

The write side, in contrast, is strictly three separate scopes:

Scope Who writes it Direction Typical use
params Fetching and author declarations, read-only Top down only List data, widget inputs
state xpage.setState / xpage.patchState in an .af Any direction within the page Selected tab, expanded state, accumulated pages
data data.set and friends in an .af Across pages The user's watchlist, settings

state is the only channel that carries values sideways or upward within a page: a child taps and sets state.selectedId, and a sibling reads ${selectedId} to highlight itself. It isn't declared in the .xpage file and is empty when you enter the page; all the pages of a pager share one state.

When names really do collide, disambiguate with a prefix: ${state.tab} / ${params.tab} / ${data.tab}.

The two state-writing actions behave very differently:

Use patchState for everyday appending and tab switching; save setState for when you genuinely want to wipe and start over. After changing state, remember to follow it with a redraw (below).

⚠️ Referencing a key that doesn't exist raises no error — the literal string is passed straight through. A title rendered as Box ␣␣ or a date showing 1970 is almost always this. When something renders oddly, check whether the first character is $.

depends and the three states

depends can go on any node, not just the root:

{
  "depends": [
    { "flow": "@[file://page/flow/quote.df]", "params": { "code": "${code}" } },
    { "flow": "@[file://page/flow/news.df]", "params": {} }
  ]
}

Every node with a depends is an async boundary with three states:

State When What it renders
loading Fetching and this node has never had successful data The app's built-in skeleton (laid out to match this node)
error Any flow failed and there is still no successful data The app's built-in error message plus retry
loaded Everything succeeded The normal content

The phrase that matters is "has never had successful data": once data exists, a later refresh keeps the current content, fetches in the background and swaps it in — no skeleton flash; a failure keeps the old data rather than switching to an error page. So nested boundaries fill in block by block instead of making the whole page wait on the slowest flow.

The app draws both of these states for you — you don't write them and can't: a loading / error field on a node has no effect, and check flags it with G1c so you remember to remove it.

The skeleton doesn't appear the moment a fetch starts, either: if the result comes back within 150ms it is shown straight away, without a single skeleton frame; only after 150ms does the skeleton fade in, and once shown it stays for at least 300ms before cross-fading into the result. So on pages that hit the local cache or call a fast API, users never see a skeleton at all.

A node with a depends must have a height that can be computed before the fetch (a hard-coded h, or one given by the parent). Otherwise the height changes when loading finishes and the whole page jumps.

Events

Values are always standard bindings: "@[file://page/flow/x.af]", {"flow": "…", "params": {…}}, or an inline array of actions.

Event Where it goes Payload When it fires
onLoad Any node none After this node first reaches loaded. Exactly once per lifetime
onRefresh root none The user pulls down. Declaring it takes over pull-to-refresh completely
onReachEnd Vertical list / waterfall none The content scrolls to the bottom
onPageChange pager @event.page A page turn completes
onClick Any node @event.id A tap
longClick Any node @event.id A long-press. Write it and the same node's menu never opens
onChange / onSubmit / onBlur / onFocus on an input input @event.value (none for onFocus) See below

The timing of the three data events, pinned down in one table:

Action depends onLoad onRefresh
Entering the page for the first time All run Runs once —
Pull down, page has no onRefresh All run Doesn't run —
Pull down, page has an onRefresh None run Doesn't run Runs
Refresh from the container menu All run Runs again —
Retry on the error page Only that one node's Doesn't run —
xpage.reloadPage All run Doesn't run —
xpage.reenterPage All run Runs again —
The user switches the app language All run Doesn't run —
xpage.setState / patchState + redraw Don't run, existing data is used Doesn't run —

Two easy traps follow from that:

  1. Once onRefresh exists, pulling down no longer re-fetches automatically, even when the root has a depends. If you want to reset state and re-fetch, add an explicit xpage.reloadPage at the end of that .af.
  2. onLoad runs only once. For "start over every time I come back to this page", use xpage.reenterPage — don't count on onLoad.

The four redraw / reload actions, by strength (the full table is in numable docs af):

Action What it does
xpage.redraw Redraws only the node named by id, no re-fetch
xpage.redrawPage Redraws the whole page, no re-fetch
xpage.reloadPage Soft reload: re-runs every depends, onLoad does not run again, state is kept
xpage.reenterPage Hard reload: as if closed and reopened — state cleared, onLoad runs again

Infinite scroll is the standard combination of those four: put onReachEnd on the list → fetch the next page in the flow → xpage.patchState to append the new data to ${items} → xpage.redraw to redraw just that list. Don't reload the page — it flashes, and it re-runs every depends.

Pagination also needs loadMore declared on the same list, or the reach-end event never fires (hasMore defaults to false):

{
  "type": "container",
  "id": "rows",
  "layout": "list",
  "direction": "vertical",
  "events": { "onReachEnd": "@[file://page/flow/next-page.af]" },
  "loadMore": { "hasMore": "${hasMore}", "noMoreText": "No more items" },
  "items": []
}

There is one more switch on root: "refresh": false = don't attach a pull-to-refresh header. A root with neither a depends nor an onRefresh has none anyway, so you don't need to write it.

visible

visible accepts true / false or an expression; absent means visible.

{
  "type": "Canvas",
  "id": "k90",
  "visible": "$[if::(eq::(${seg},90),1,0)]",
  "h": "180pt",
  "canvas": { "source": "@[file://page/rc/k90.rcn]", "depends": { "flow": "@[file://page/flow/k90.df]", "params": { "n": 90 } } }
}

A node with visible: false isn't in the tree at all: it takes no space, doesn't render, and its depends doesn't run either. It fetches for the first time only once state changes — which is exactly how three mutually exclusive chart ranges avoid two requests nobody is going to look at.

⚠️ The test is strict: only true, 1 or a non-zero number counts as visible, and everything else is treated as hidden. So referencing a key that doesn't exist (leaving the literal ${…} string) makes the whole node disappear with no error. The safe form is $[if::(…,1,0)], which explicitly produces 1 or 0.

enabled is a different matter: it does get evaluated, but nothing consumes it, so writing it won't disable events. To make a node untappable, don't give it events.

menu

A menu array on any node means a long-press opens a declarative menu there. For the fields and their precedence see the "long-press node menu" section of numable docs page; only two things are specific to xpage:

The input node

input is the second kind of leaf and handles exactly three things: text editing, the keyboard, and the caret. It has no visuals of its own — no border, no background, no focus highlight. Visuals come from layering: put a Canvas underneath to draw the rounded background and border, and lay the input on top.

{
  "type": "input",
  "id": "kw",
  "x": "28pt",
  "y": "16pt",
  "w": "276pt",
  "h": "44pt",
  "props": {
    "bindKey": "kw",
    "hint": "Search…",
    "hintColor": "#999999|#8E8E93",
    "inputType": "text",
    "fontSize": 16,
    "color": "#1C1C1E|#F2F2F7",
    "caretColor": "#128F66|#6FE8BE",
    "changeFireOn": "change",
    "changeDebounce": 300
  },
  "events": { "onChange": "@[file://page/flow/search.af]" }
}

w and h must be written explicitly (a single-line input does not size its height to its content).

props Notes
bindKey The page state key the value is written back to; defaults to the node id
value The initial value, written as ${…}. Uncontrolled: it is not pushed back in afterwards, only input.setValue overwrites it
hint / hintColor The placeholder and its color; the color supports a light|dark pair
inputType text / number / decimal / password / email / phone / url, which picks the keyboard layout
secure true is equivalent to inputType: "password"
lines 1 = single line (default); N = a multi-line box fixed at N lines
maxLength The length limit. Characters mid-composition (e.g. pinyin input) don't count
fontSize / color / align / typeface Typography; color supports a light|dark pair
caretColor The caret color — the one visual property that was kept
autoFocus Focus and raise the keyboard on mount
clearButton A trailing clear button
returnKey done / send / search / next / go, the look and meaning of the return key
keyboardToolbar done = add a "Done" bar above the keyboard
contentType Tells the system what this field holds so password managers and one-time-code autofill can hook in. Use the platform autofill identifiers (username · current-password · new-password · one-time-code · email · tel …); leave it out and there is no autofill
changeFireOn When onChange fires: change (default, debounced) / blur / submit
changeDebounce The debounce in milliseconds for change mode, default 300

The value lives in page state, and the write-back is silent: every keystroke writes into state[bindKey] for other nodes to read, but triggers no redraw (otherwise every keystroke would rebuild the whole tree and yank the box you are typing in out from under you). For anything reactive — a focus highlight, a character counter — call xpage.patchState + xpage.redraw explicitly in the onChange / onFocus flow.

Imperative control goes through five actions, all taking the input node's id: input.focus / input.blur / input.clear / input.selectAll / input.setValue (which also takes value and updates the state key that bindKey points at).

If what you want is "pop something up and ask for one value" rather than a box living on the page, don't use an input node — use singleValue / .xform (see numable docs params).

Fields that do nothing

These names are legal, raise no error, and have no effect whatsoever. They turn up most often in pages copied from somewhere else:

Field Written on For that effect
virtualization list / grid / waterfall No substitute; a list of a few dozen items doesn't need it
preloadCount list / waterfall Same as above
scrollEnabled list / waterfall (it does work on pager) A vertical list without a fixed h won't scroll on its own
direction: "vertical" pager The paging axis is always horizontal
initialPage pager Use the controlled page expression
onScroll Collection containers No substitute
onVisible / onHidden Any node To refresh on returning from a subpage, call xpage.reloadPage from the continuation of the flow that opened it
enabled Any node Don't attach events
wrap / justify / align flex containers Use flow for wrapping; compute w and padding yourself for alignment
flexGrow / flexShrink / flexBasis Children of a flex Hard-code w
lineAlignment / maxLines flow No substitute
balanceStrategy waterfall It is always "land in the shortest column"
itemAspectRatio / crossAxisAlignment grid Hard-code h on the children
navStyle route / page The chrome is always the floating capsule and isn't configurable

Rules (breaking one means rework)

Rule How it's checked Symptom when broken Fix
The root container's paddingTop must reference ${@contentInset.top} check G17 The top of the page sits under the floating capsule, with no error on any platform Write "paddingTop": "${@contentInset.top}pt" on the root; for a deliberate full-bleed page write _lintTopInset: "exempt" and explain it in _notePad
The root can't be layout: "pager" check G17 The inset has no effect and the top is still covered Wrap it in a list root
depends can't be a bare string check G12 The input parameters get swallowed, the flow still reports success, and the whole page renders -- Write {"flow": "…", "params": {"k": "${k}"}}
A ${@i18n.key} at the node level (params / props / menu[].label) must exist in this page's top-level i18n table check G35 That string renders empty, as if you forgot it Add it to the page table; strings drawn inside a Canvas still live in each .rcn's own table
An events value can't start with ${ check G36 The dispatcher classifies by first character before interpolating, and a string starting with an interpolation looks like none of the categories → taps do nothing Put the known prefix outside the interpolation: "/detail?id=${id}"
@[file://…] in the .xpage and in page/rc/*.rcn is relative to the package root Manual review It resolves to nothing: no fetch, nothing drawn, no error Spell out page/flow/x.df, page/rc/x.rcn
On a page with route inputs, don't add a same-named params fallback on the root node Manual review You tap A and see B, with no error — the root params override the route query Delete the same-named fallback on the root, and compare two queries side by side
Every node id is unique within the page, interpolated inside forEach Manual review Redraws land on the wrong cell, and the once-only onLoad test crosses wires "id": "row-${r.id}"
A node with a depends must have a height determinable before the fetch Manual review The whole page jumps the moment the data arrives Hard-code h, or let the parent give it
If you attach onReachEnd, you must supply loadMore.hasMore Manual review Scrolling to the bottom does nothing Add "loadMore": {"hasMore": "${hasMore}"} and maintain that key in the flow

When something breaks

Symptom Most likely cause What to do first
The whole page is blank with no error The route type isn't xpage (any unknown value is treated as html); or the entry path is wrong Check those three route fields
The top of the page is under the capsule The root has no top inset, or the root is a pager Run numable check and look for G17
List items are packed together and gap looks broken The container has no layout, so it defaulted to flex Add "layout": "list"
You wrote itemSpacing and nothing changed The container isn't a list (no other layout knows that name) Switch to gap, or make the container a list
A node vanished entirely The visible expression evaluated to something other than true/1, including a reference to a key that doesn't exist Use $[if::(…,1,0)] to produce 1/0 explicitly
Text renders as a literal ${xxx} That key doesn't exist at any of the six scope levels Check the output key names of the .df; when something renders oddly, check whether the first character is $
One string is blank A node-level ${@i18n.key} isn't in the page table Run numable check and look for G35
Nothing changes after pull-to-refresh You wrote onRefresh, it has taken over the pull, and no depends runs Add xpage.reloadPage at the end of that .af
Coming back from a subpage, the page data doesn't move You counted on onVisible (not implemented) Call xpage.reloadPage from the continuation of the flow that opened the subpage
Scrolling to the bottom doesn't load the next page loadMore.hasMore is missing, or it's a horizontal list Add loadMore; horizontal lists have no reach-end event
You switched tabs but the page didn't move You called patchState without a redraw Follow it with xpage.redraw (or redrawPage)
You type in the input but other places still read the old value The write-back is silent and never redraws on its own Call patchState + redraw in the onChange flow

Related