Skip to content

[6.x] Reuse the nav tag's tree when a nav is output more than once - #15845

Merged
jasonvarga merged 10 commits into
statamic:6.xfrom
DanielDarrenJones:perf/6x-nav-tree-reuse
Nov 11, 2026
Merged

jasonvarga merged 10 commits into
statamic:6.xfrom
DanielDarrenJones:perf/6x-nav-tree-reuse

Conversation

@DanielDarrenJones

@DanielDarrenJones DanielDarrenJones commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

A page often outputs the same nav more than once: a desktop menu and a mobile one is the usual case. Each {{ nav }} tag builds the tree, augments every page in it and works out every URL from scratch, even when an identical tree was built a moment ago for the tag above it.

The tree a tag builds is now kept for the rest of the request and reused by later tags for the same nav.

What counts as the same tree

Parameters that change which pages are in the tree (show_unpublished, include_home, from, site, conditions) identify it. Parameters that only change how much of it is shown don't: max_depth, limit, offset, select, include_parents, as and scope.

  • A tree built with no max_depth serves any depth.
  • A tree built to a depth serves the same or a shallower one.
  • A tree built to a depth that turned out to reach the bottom serves any depth. Page::hasChildren() is new, and is how the tag knows whether it was cut short.
  • A tag with a parameter that isn't a plain value builds its own tree, as before.

is_current and is_parent are still worked out for each tag, so the current URL isn't part of what's kept.

What is kept

  • The built tree, in its own Blink store.
  • For each page object in it, what that page contributes wherever it's shown: its augmented values, its URL, its absolute URL and whether it's external. This is in a WeakMap held in Blink, so it's keyed by the object itself and goes when the page does.

Both last for the request, like anything else in Blink.

A tree saved partway through a request

The tree's own contents are part of the key, so a nav or collection tree that's saved after a tag has output it is built again. it_sets_is_current_and_is_parent_for_a_nav saves a second tree halfway through and fails without this.

An entry in the nav that's changed after the nav was output (its title, URL or status) is not picked up by a later tag in the same request.

Queue workers and Octane

Octane gets a fresh Blink for each request, so nothing is carried from one request to the next.

A queue worker has no requests, and Blink isn't cleared between its jobs. What the tag has kept is dropped when a worker moves on to its next job, so a job that outputs a nav doesn't see the one an earlier job built. This follows the same rule as the Stache's own reset between jobs: only in a queue: or horizon: process, so a job run synchronously during a request leaves that request's trees alone.

Benchmark

Each page requested with and without this change, one PHP process per request with a warm Stache, run alternately for 3 rounds of 6 (Apple M1 Pro, PHP 8.5, 6.x at 5b60554). The site is a generated one: 358 entries, 65 Antlers templates, and a three-level main nav of 155 items output twice in the header, once with max_depth="3" and once with no parameters, plus a footer nav. The bench is at https://github.com/1994limited/statamic-bench.

Page Before After
Simple page 191.8 ms 170.7 ms −11.0%
Nested page 200.6 ms 180.5 ms −10.0%
Page builder page 365.4 ms 342.8 ms −6.2%
Article 271.0 ms 247.8 ms −8.6%
Article listing 306.4 ms 292.8 ms −4.4%
Tag listing 285.6 ms 262.8 ms −8.0%
Product 225.7 ms 209.6 ms −7.1%
Product listing 270.9 ms 246.6 ms −9.0%
Event 210.4 ms 186.9 ms −11.1%
Team listing 206.3 ms 183.6 ms −11.0%
404 189.4 ms 164.9 ms −12.9%
Home 333.4 ms 318.3 ms −4.5% (within noise)
A page with no nav 181.2 ms 180.5 ms no change

The saving is roughly 15 to 25 ms a page, which is the second build of the main nav. A site that outputs each nav once gains nothing, and loses nothing measurable. Peak memory moved by less than 3 MB either way.

The HTML of every page is byte for byte the same before and after.

A page often outputs the same nav more than once: a desktop menu and a
mobile one, say. Each tag built the tree, augmented every page and worked
out every URL again.

The built tree is now kept for the rest of the request. Parameters that
only change how much of it is shown (max_depth, limit, offset, select,
include_parents) aren't part of what identifies it, so a tree built to a
greater depth, or one that reached the bottom, serves a shallower tag
too. What a page contributes wherever it is shown (its augmented values
and its URLs) is kept per page object. is_current and is_parent are still
worked out for each tag.

The tree's own contents are part of the key, so a tree saved partway
through a request is built again.
The pages still to be augmented kept their positions in the tree, but the
augmentor numbers what it's given from zero, so once some pages were
already known the results were matched to the wrong ones and the rest
were never recorded.
@DanielDarrenJones
DanielDarrenJones marked this pull request as ready for review November 11, 2026 17:01
DanielDarrenJones and others added 4 commits November 11, 2026 17:07
An entry saved after a nav was output in the same request is now picked up by later nav tags, the same way Entry::save already clears its other Blink caches. The queue worker test now changes the entry without saving, since a save no longer leaves the stale tree behind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasonvarga jasonvarga changed the title [6.x] Build a nav's tree once per request [6.x] Reuse the nav tag's tree when a nav is output more than once Nov 11, 2026
jasonvarga and others added 3 commits November 11, 2026 16:17
The page data kept for a nav tag was keyed on the selected fields, with no select written as "*". That's the same key as select="*", which only asks for a field literally named "*", so whichever tag ran first decided what the other one output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A negative max_depth builds an empty tree, which was then kept as complete and served to a later tag with no max_depth. The other way round, it was cut from a kept tree and showed the top level. Such a tag now builds its own tree and keeps nothing, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A subclass can override the protected query methods to filter by something other than its parameters, such as the context it's rendered in. A kept tree skips those methods, so a later tag would show the first tag's pages. Subclasses now build their tree each time, as before, and can override reusesTrees() to opt in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasonvarga
jasonvarga merged commit 7fcab51 into statamic:6.x Nov 11, 2026
61 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants