The Tree collapses because the database result carries no expansion state. Every binding refresh replaces props.items, so every node's expanded flag goes back to whatever the query returned. Keep a separate map of node ID to expanded boolean in a custom property. A change script updates the map when the user expands or collapses a node. A script transform on the items binding re-applies it after every refresh.
Read the Symptom First
You see this on the screen: the user expands three levels, adds a child record, and the whole tree snaps shut or opens fully. Start here, and match what you see against the table before you change anything.
| Symptom | Cause |
|---|---|
| Whole tree collapses after every insert or refresh | Query or sproc output has no expanded key, or returns it as false for every node |
| Whole tree expands after every refresh | Sproc or transform hard-codes expanded to true |
| Map is built but gets wiped on refresh | Change script runs on binding-origin writes and rebuilds the map from the fresh, collapsed items |
| Wrong branch opens after an insert | Map is keyed by label or tree path instead of a stable database ID |
| State is lost when the user navigates away and back | Map lives in a view custom property, which is discarded when the view closes |
Adding an expanded column to the organizational table does not fix this. Expansion belongs to one client session. One shared column means every user who expands a branch changes it for everyone else.
Understand Why the Binding Wipes Expansion
-
The binding owns
props.items. When it fires, it writes a new value to the whole property. It does not merge with what the browser changed. -
Expansion is written by the browser. A click on the expand arrow sets
expandedon that item insideprops.items. That value exists only in the component's current state. -
The database has no way to know it. The sproc rebuilds the tree from records. Whatever it emits for
expandedoverwrites the user's state.
Store that session state somewhere the binding does not overwrite, and merge it back in during the transform. Store only a node ID and a boolean for each expandable row. A full copy of the tree is not needed.
Build the Expansion Map
The names below are examples. Rename them to match your view. The code assumes each tree item carries its database primary key in data.id.
- Put the record's primary key into each item's
dataobject when the sproc or transform builds the tree. Without a stable ID, nothing below works. - Add a custom property
view.custom.expandMapand set it to an empty object{}. Usesession.custominstead if the state has to survive page navigation. - Right-click the Tree's
props.itemsand add a change script:
def valueChanged(self, previousValue, currentValue, origin, missedEvents):
# Only capture user clicks. Binding writes carry fresh, unexpanded data.
if origin != 'Browser':
return
def _get(obj, key, default=None):
try:
v = obj[key]
return default if v is None else v
except Exception:
return default
expandMap = {}
def walk(nodes):
for node in nodes:
children = _get(node, 'items', [])
if len(children) > 0:
data = _get(node, 'data', {})
nodeId = _get(data, 'id')
if nodeId is not None:
expandMap[str(nodeId)] = bool(_get(node, 'expanded', False))
walk(children)
walk(currentValue.value or [])
self.view.custom.expandMap = expandMap
Record only nodes that have children. Leaf rows have nothing to expand, and storing them only makes the map bigger.
Re-Apply State in the Binding Transform
Add a script transform to the query binding on props.items, after any transform that already builds the nested structure. The code assumes value is the nested list of item objects at this point. If you currently build the tree from a flat dataset, do the lookup inside that build loop instead.
def transform(self, value, quality):
raw = self.view.custom.expandMap
expandMap = dict(raw) if raw else {}
def apply(nodes):
out = []
for n in nodes:
node = dict(n)
children = node.get('items') or []
if children:
node['items'] = apply(children)
data = node.get('data') or {}
nodeId = data.get('id') if hasattr(data, 'get') else None
node['expanded'] = bool(expandMap.get(str(nodeId), False))
out.append(node)
return out
return apply(value or [])
Then fix the insert flow so the user can see the new child:
- Run the INSERT.
- Set the parent's key in the map to true, for example
self.view.custom.expandMap[str(parentId)] = True. Do this before the refresh. - Call
self.getSibling('Tree').refreshBinding('props.items')from the button, or the equivalent component path.
If you refresh first and write the map second, the transform reads the old map and the parent stays collapsed.
Verify the Fix
- Add a temporary
system.perspective.print(origin)at the top of the change script. Expand a node and confirm it printsBrowser. Refresh the binding and confirm the binding-origin write returns early. - Watch
view.custom.expandMapin the Designer property editor in preview mode. Each expand or collapse click should add or flip exactly one key. - Expand a mixed set of branches, insert a record under a deep node, and let the refresh complete. Every previously open branch stays open and the new child's parent is open.
- Delete a record through the UI and refresh. Its sibling branches must keep their state. A key left behind in the map for the deleted ID does no harm.
- Open the view in two browser sessions. Expanding in one must not change the other. If it does, the map is in a shared scope, such as a tag, rather than view or session scope.
Avoid the Recurring Pitfalls
- No origin filter. This is the most common failure. The change script also fires on the binding write, rebuilds the map from collapsed data, and deletes the user's state.
- Keying by path or label. Tree paths such as index positions shift whenever a sibling is inserted above. Labels can be duplicated or renamed. Use the database key and cast it to a string, because object keys in Perspective properties are strings.
- Selection has the same problem. Selection stored by path points at the wrong row after an insert. If selection needs to persist, store the selected node ID and resolve it again after refresh.
- Polling the binding. The map approach still works with polling, but each poll rebuilds the full items array. Use event-driven refreshes after writes unless other clients change the tree at the same time.
- Chasing the sproc. Making the procedure return per-user expansion flags adds a round trip and a table write on every click. That fix wastes time. Keep UI state on the client.
-
View scope for multi-page apps. A view custom property is discarded when the view unloads. Move the map to
session.custom, or persist it per user in its own table, keyed by user and node ID, if it has to survive a logout.
FAQ
Why does my Perspective Tree collapse every time the query binding refreshes?
The binding writes a whole new props.items value, and the database result carries no per-user expanded state. Re-apply the state from a separate ID-to-boolean map in a script transform on that binding.
Why does my expansion map get cleared when the tree data reloads?
The change script on props.items also fires on binding writes and rebuilds the map from the fresh, collapsed items. Return early unless origin equals Browser.
Why does the wrong branch expand after inserting a record into the tree?
The map is keyed by tree path or label, and paths shift when siblings are inserted. Carry the database primary key in each item's data object and key the map by that value as a string.
When should I contact Inductive Automation support about Tree refresh behavior?
Escalate if the change script never fires with a Browser origin on an expand click, or if the Tree ignores expanded values that the property editor clearly shows. Before you open a case through Inductive Automation's official support channel, send your Ignition version, a minimal exported view that reproduces the fault, and the gateway log.