Repository navigation
v2.20.0
Minor release from a consumer change request: opt-in performance building blocks for apps with many resources and extensions. Every default keeps the previous behaviour, apart from a ?v= version suffix on the extension bundle URL. Also ships the November dependency updates. Republish the assets (php artisan martis:publish-assets) and, to use lazy components, the extension scaffold. Read Upgrading to v2.20.0.
Added
- Extension components loaded on demand.
componentRegistry.registerLazy(key, () => import('./X'), { fallback, preload }), also accepted byregisterFieldDisplay/InputandregisterResourceFieldDisplay/Input: a tool, card, filter, action, override or field component is fetched from its own chunk the first time it is drawn. The componentresolve()returns carries its own Suspense boundary and error state, so every place that renders a registry component (tool pages, dashboard and metric cards, filters, overrides, action modals and drawers, field display and input, shell slots) works with a lazy one and the call sites are unchanged; eager registrations render exactly as before.componentRegistry.isLazy(key), an idempotentcomponentRegistry.preload(key)andpreload: 'idle'(fetch once the browser is idle after the app mounts;'visible'behaves as'never').register(key, loader)recognises a plain() => import(...)loader, but onlyregisterLazyis guaranteed. See Overrides → Lazy registration. reloadOnStaleChunk(error)on@martis/runtime: a tab opened before a deploy that asks for a chunk the deploy replaced reloads (at most once per 60 seconds per tab, never while offline) instead of showing a blank panel; a chunk that still cannot load shows an error state in place of that component only, and its Try again fetches the chunk again. The lazy wrapper applies it itself. Switch:martis.extensions_reload_on_stale(MARTIS_EXTENSIONS_RELOAD_ON_STALE, defaulttrue).martis.extensions_versioned_urls(MARTIS_EXTENSIONS_VERSIONED_URLS, defaulttrue) and a new page, Extensions: caching, with the exactCache-Controlheaders for the versioned entry, the hashed chunks and everything else, for Caddy, nginx and Apache. Thevite.extensions.configscaffold now writes code-split chunks tochunks/[name]-[hash].js.- Sidebar count badges cached on their own.
Resource::menuCountCache()(static) andTool::menuCountCache()return['scope' => 'shared'|'user', 'ttl' => <seconds>]ornull(the default: the count lives in the per-user badge map, as before). Asharedcount is computed once for every admin; the scope registered withMartis::cacheScopeUsing()is still part of its key, so tenants never share a count. The entries live in thenavigationcache layer (cleared bymartis:cache:clear navigation, skipped by the per-request bypass) and also serve the counts the menu embeds. See Cache. martis.cache.navigation.badge_ttl_seconds(MARTIS_CACHE_BADGE_TTL_SECONDS, defaultnull): the badge map TTL in seconds instead of whole minutes. A set but unusable value throws, naming the key.Martis::menuCountsUsing(Closure(Request, Collection $resourceClasses): array)(andforgetMenuCounts()): resolve several resource counts in one query. Called once per badge map, lazily, with only the resources that are navigable, opted in, authorised and unlocked; a class it leaves out keeps its ownmenuCount(); a failure is reported as aMenuCountFailedException(badge keybatch) and every class falls back.MartisCache::rememberFor($type, $key, $callback, $seconds):remember()with a TTL in seconds (same key, scope segment, switches and bypass).- Metric TTL in seconds.
martis.cache.metrics.default_ttl_seconds(MARTIS_CACHE_METRICS_DEFAULT_TTL_SECONDS, defaultnull) for a metric that sets none,Metric::$cacheSeconds/cacheForSeconds()per metric, andMetric::$live/live()for a metric that is never cached. They go through themetricscache layer (clear, switches, bypass and cache scope apply). Custom cards have no server-side compute and keep travelling with the dashboard shape cache. See Metrics and the live-versus-cached table in Cache. Resource::prefetchIndexPage(Request $request, Collection $rows, PageContext $context): opt-in hook (default no-op) called once per listed page by the resource index, a lens and the tables ofHasMany,HasManyThrough,MorphMany,BelongsToManyandMorphToManypanels, after the page is read and before its rows are serialized. Never called when an action resolves its records, nor by detail, edit, create or count endpoints, nor by pickers. See Resources → Avoiding per-row queries on index pages.Martis\Support\PageContext: request-scoped store of per-row facts (put,putMany,get,has,PageContext::current($request)), readable from field resolvers,canSeecallbacks, accessors andcanRun(). Empty outside a listing request, so a reader falls back to its own rule.Action::canRunForRows(Closure(Request, Collection $rows): array<key, bool>): batched alternative tocanRun(), called once per page. A row the answer omits falls back to the per-rowcanRun(), otherwise it is denied; keys are compared as strings; a key that is no row of the batch (a list instead of a map) throws anUnexpectedValueExceptionnaming the action. The run, the fields request, pivot runs and the detail page decide with the same rule, so an action hidden for a row also refuses to run on it. A subclass that overridesauthorizedToRun()keeps its rule: a row runs only when both allow it. See Actions.Martis\Testing\AssertsFlatQueries:assertFlatQueries($seed, $request, few: 2, many: 20)asserts a request runs the same number of queries with few and many rows and lists the queries that repeated.martis.dev.prevent_lazy_loading_in_listings(MARTIS_DEV_PREVENT_LAZY_LOADING_IN_LISTINGS, defaultfalse): a lazy load while a page's rows are serialized throws inlocal/testingand logs a warning elsewhere. Scoped to the page's rows and restored afterwards; Laravel's globalModel::preventLazyLoading()is never turned on.php artisan martis:search-index {resource?} {--write} {--connection=}: reports the trigram (gin_trgm_ops) indexes behind each resource's database search (own fields, every searchable column whoever may see it, andsearchableRelations()), warns when one unindexed OR-ed column forces a sequential scan, and with--writegenerates a non-transactional migration (CREATE EXTENSION IF NOT EXISTS pg_trgm,CREATE INDEX CONCURRENTLY IF NOT EXISTS, adown()that drops them). Schema-qualified tables are supported. Read-only by default; a notice and exit 0 on other drivers.Resource::searchMode()/$searchMode:contains(default, unchanged SQL),prefix(ILIKE 'term%', index friendly) andcolumns(only the columns named bycol:termtokens). Config defaultmartis.search.match_mode(MARTIS_SEARCH_MATCH_MODE); an unknown mode throws, naming the resource and the value. New page Search performance on PostgreSQL.SearchResolver::searchTargets(): the own and relation columns the database search ORs together.
Changed
- The extension bundle URL carries
?v=<content hash>when it is a root-relative path to an existing file underpublic/(ExtensionBundles::urls()), so it can be cached as immutable. URLs with a query string or fragment, absolute URLs and missing files are unchanged. Setextensions_versioned_urlstofalseto keep the bare URL. - Dependencies: the November npm, Composer and GitHub Actions updates (i18next 26.5, PHPStan 2.3), with the type and analysis fixes they needed.