Search by

mvoelkner / silverstripe-pagetree-reveal-fix

mvoelkner

Fixes the SilverStripe CMS admin PageTree pair: root collapses when opening a page nested deeper than SiteTree::node_threshold_total, and every tree reload is slow when the threshold is raised to avoid that. See README.md.

Package info

github.com/mvoelkner/silverstripe-pagetree-reveal-fix

Type:silverstripe-vendormodule

pkg:composer/mvoelkner/silverstripe-pagetree-reveal-fix

Statistics

Installs: 10

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-09-16 14:42 UTC

This package is auto-updated.

Last update: 2026-09-16 14:42:44 UTC


README

Fixes a pair of related bugs in the SilverStripe CMS admin PageTree:

  1. The tree root collapses when you open a page nested deeper than SiteTree::node_threshold_total. Vendor MarkedSet::markToExpose() only opens ancestors that were already marked during the threshold-bounded breadth-first scan; a page sitting outside that window doesn't get its ancestor chain exposed.
  2. If you raise node_threshold_total to avoid the above, every tree reload becomes slow. Hierarchy::recursivelyCacheDescendantsForTree() (the engine behind the default getChildrenForTree() children method) reads node_threshold_total a second time, independently, against a request-wide, never-reset counter of already-fetched nodes. Once that counter is exceeded once, it returns an empty child list for every node for the rest of the request. So a small threshold breaks (1), and the only vendor-level fix for (1) is a threshold large enough to cover your entire reachable tree - which then gets walked and rendered again on every single tree reload (the CMS admin issues a deferred ajax reload of the tree after every page load).

The fix

A small MarkedSet subclass, swapped in via Injector, that exposes the currently open page's ancestor chain only through a direct, uncapped per-node query - bypassing the poisoned bulk-fetch cache entirely for that one path. Cost is O(depth) simple queries regardless of how deep the page sits or how wide the rest of the tree is. Every other branch stays exactly as collapsed as a small node_threshold_total would leave it, which is the desired "expand the current branch, leave the rest closed" behaviour.

This means you can keep node_threshold_total small (the framework default is 50) and get both a cheap general tree render and correct exposure of deeply nested pages.

Install

composer require mvoelkner/silverstripe-pagetree-reveal-fix
sake db:build --flush

The fix applies unconditionally via _config/config.yml, exactly like any other SilverStripe module config - no build step, no install-time script, nothing to run beyond the usual db:build --flush.

Why there's no install-time compatibility check

An earlier version of this package shipped a Composer plugin that inspected the installed silverstripe/framework source at composer install/update time and printed whether the bug pattern and vendor API this fix depends on still matched. It was removed after it took down a real project's entire admin (every sake call, not just --flush):

SilverStripe's config system (SilverStripe\Config\Transformer\PrivateStaticTransformer) walks every class the framework has ever discovered and calls class_exists() on each one, to collect private static config properties. A Composer plugin class necessarily implements Composer\Plugin\PluginInterface - a real interface only while Composer's own process is running (composer install/update), not a class present in the consuming project's own vendor/autoload.php. The moment SilverStripe's scan reached that class, class_exists() tried to autoload it, hit the missing interface, and fataled - permanently, since the broken result gets cached. Confirmed the same failure independently affects composer/installers and silverstripe/vendor-plugin, already-installed Composer plugins in that project's own dependency tree; it had simply never been forced through this exact scan since they were installed. This is a SilverStripe-specific hazard, not a Composer or PHP one - most frameworks don't reflect every vendor class in this way.

If you want to double check the vendor internals this fix depends on still match, read the docblock on src/ORM/Hierarchy/MarkedSet.php and diff it against your installed vendor/silverstripe/framework/src/ORM/Hierarchy/{Hierarchy,MarkedSet}.php by hand.

Configuration

# app/_config/mysite.yml
Mvoelkner\PageTreeRevealFix\ORM\Hierarchy\MarkedSet:
  reveal_mode: branch   # default: expose only the current record's ancestor chain (cheap, any depth)
  # reveal_mode: full    # legacy vendor behaviour: needs node_threshold_total large enough to
                          # cover your entire reachable tree, expensive on every reload, but
                          # marks/opens everything reachable rather than just the current branch

Keeping other branches open (remember_visited_branches)

In branch mode (the default), clicking a new page only exposes that page's branch - every other branch you had open collapses, which is jarring while editing (working through a few sibling pages, cross-checking a couple of branches, ...). Turn on remember_visited_branches to keep the last few visited pages open instead of just the current one:

# app/_config/mysite.yml
Mvoelkner\PageTreeRevealFix\ORM\Hierarchy\MarkedSet:
  remember_visited_branches: true   # default: false
  remember_visited_limit: 10        # default: 10 - caps how many branches stay open at once
  remember_visited_ttl: 7776000     # default: 90 days, in seconds

Off by default - it's an editing-comfort trade-off, not a correctness fix, and it does increase the amount of tree rendered per request.

How it works: history is tracked per-Member in a named PSR-16 cache pool (Psr\SimpleCache\CacheInterface.pagetree-reveal-fix, registered in this package's own _config/config.yml) - not a cookie. psr/simple-cache is already a transitive production dependency of silverstripe/framework itself (via symfony/cache), so this needs nothing extra installed. On every tree render, each of the last remember_visited_limit distinct pages that Member visited is exposed the same way the current page's branch always is - one direct, uncapped query per ancestor level, same mechanism, same cost model. Performance is bounded by the limit, not by the size or depth of the rest of the tree: remember_visited_limit: 10 costs at most 10x the single-branch case (10x whatever depth each of those pages sits at), regardless of whether the site has 100 or 100,000 pages. Deleted pages are dropped from the history automatically rather than retried on every request. Only applies in branch mode - full mode already renders everything reachable, so there's nothing for it to add.

Trade-off versus the cookie-backed approach an earlier version used: a cache entry is disposable by design (site flush, deploy, or - if the site runs multiple app servers on a filesystem cache without shared storage - simply landing on a different server next request). That's an acceptable cost here, since the worst case is falling back to single-branch behaviour for that one page, not broken data. In exchange, remember_visited_limit isn't bounded by the ~4096-byte browser cookie ceiling (RFC 6265) a cookie-backed history hits once it holds a few hundred entries - a large limit is a real, honoured choice here, not a silent failure mode.

Compatibility

Targets silverstripe/framework ^5 || ^6.

Uninstall

composer remove mvoelkner/silverstripe-pagetree-reveal-fix

Removing the package removes its _config/config.yml, so the Injector override stops applying and SilverStripe's own MarkedSet is used again — no other cleanup needed.