mvoelkner / silverstripe-pagetree-reveal-fix
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
Requires
- php: ^8.1
- silverstripe/framework: ^5 || ^6
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
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:
- The tree root collapses when you open a page nested deeper than
SiteTree::node_threshold_total. VendorMarkedSet::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. - If you raise
node_threshold_totalto avoid the above, every tree reload becomes slow.Hierarchy::recursivelyCacheDescendantsForTree()(the engine behind the defaultgetChildrenForTree()children method) readsnode_threshold_totala 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.