Build1 publisher3 min readPublished
ngx-json-render 0.4.0 decides recursion with a prefix test on the resolved state path
Two Box elements that point at each other satisfy the schema and rendered 143 nodes before the stack gave out. The rule that stops them still has to draw the recursive file trees that look identical.
The Engineer · Build desk

What happened
- A two-element ngx-json-render spec in which Box a lists b as its only child and b lists a satisfies the schema, and before version 0.4.0 it rendered 143 elements before the stack gave out and took the tab.
- The textbook fix, refusing any element that turns out to be its own ancestor, stops that crash in a few lines and also rejects the standard way to draw a comment thread, where a kids element lists itself.
- Version 0.4.0 compares resolved state paths instead, permitting an element inside itself only when the inner pass reads strictly inside the scope of the nearest ancestor carrying the same key.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Structural validation asks only whether each element is well-formed, and this spec's defect exists in the graph the elements form, so the check has to live in the renderer's walk.
- decision A team hardening a dynamic UI runtime has to choose between a few lines of ancestry checking that forbid recursive menus and a scope-comparison rule that needs resolved state paths threaded through the render.
- exposure The bounded case is the one that reaches users, because it renders and therefore ships, and the cost lands on whoever loads the spec in their browser.
The two specs at issue differ by one path expression. In the recursive tree, the `kids` element repeats over `{ "$item": "children" }` [6]. That path is relative: it resolves against the item the repeat is already inside, so each pass reads one level further into the data and the recursion ends where the data does [7]. In the permutation spec, `node` repeats over the absolute `/items` and lists itself as its only child, so every level re-reads the same eight-item array [9]. According to the ngx-json-render post, models produce this shape when they mean to recurse and reach for the absolute path they already used [10].
The ancestry question fails here. `kids` is its own ancestor by design, so the textbook check that carries ancestry down the render and refuses any element that is its own ancestor throws out the comment thread along with the crash [5][6]. The author's earlier article had listed maximum graph depth, maximum rendered nodes and explicit cycle protection as the work remaining. "Building it turned out to be a lesson in how many wrong answers look right," the author wrote [17].
The next answer compares the element key together with its repeat scope, allowing an element inside itself as long as each pass renders a different item [8]. The tree passes. So does the nine-line permutation spec, because item 3 inside item 1 inside item 5 repeats no item along the path [9]. No item may appear twice on a path, so depth stops at eight and every path is a sequence of distinct items. Full-depth paths alone number 8! = 40,320 [1], and adding every shallower level gives 109,600 boxes [2]. The post reports that those nine lines produce tens of thousands of boxes without crashing, and calls it a bounded denial of service against the tab [9][11].
Version 0.4.0 tests something else, whether the inner pass descended into the data [12]. The renderer walks up from the current render path to the nearest ancestor carrying the same element key, then checks whether the inner resolved scope path starts with the outer scope path plus a slash [13][14]. A sibling move from `/items/0` to `/items/1` fails that check. A move from `/tree/0` to `/tree/0/children/1` passes. A pass with no repeat scope at all, which is the two-element cycle, fails [12][13]. The trailing slash matters: without it, `/tree/10` would count as a step inside `/tree/1` [15].
The 143 is a property of that runtime's stack. Two elements in a cycle means roughly 71 round trips through `a` and `b` before the frames ran out [3]. For the figure to transfer you would need the same per-element frame cost, a renderer that walks children recursively the same way, and nothing else deep on the stack when the spec arrives; the post does not name the browser or version [2].
The descent test covers explicit cycle protection. With the rule in place the recursive tree renders in full [16], and a spec that descends correctly into deep data still renders one node per row it finds. Maximum graph depth and maximum rendered node count stay separate limits on the author's own list [17].
What to watch
- Whether the shipped depth and node-count limits are configurable per spec, and what the renderer does to a spec that trips one mid-render.
- Whether the prefix test holds when a repeat's state path is itself computed or templated and resolves to a sibling scope.
- Whether other JSON-driven UI runtimes adopt a descent test or keep shipping a depth cap alone.