Skip to content

v2.20.0

Choose a tag to compare

@lmelomoura lmelomoura released this 11 Nov 09:50
fce34b5

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 by registerFieldDisplay/Input and registerResourceFieldDisplay/Input: a tool, card, filter, action, override or field component is fetched from its own chunk the first time it is drawn. The component resolve() 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 idempotent componentRegistry.preload(key) and preload: 'idle' (fetch once the browser is idle after the app mounts; 'visible' behaves as 'never'). register(key, loader) recognises a plain () => import(...) loader, but only registerLazy is 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, default true).
  • martis.extensions_versioned_urls (MARTIS_EXTENSIONS_VERSIONED_URLS, default true) and a new page, Extensions: caching, with the exact Cache-Control headers for the versioned entry, the hashed chunks and everything else, for Caddy, nginx and Apache. The vite.extensions.config scaffold now writes code-split chunks to chunks/[name]-[hash].js.
  • Sidebar count badges cached on their own. Resource::menuCountCache() (static) and Tool::menuCountCache() return ['scope' => 'shared'|'user', 'ttl' => <seconds>] or null (the default: the count lives in the per-user badge map, as before). A shared count is computed once for every admin; the scope registered with Martis::cacheScopeUsing() is still part of its key, so tenants never share a count. The entries live in the navigation cache layer (cleared by martis: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, default null): 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) (and forgetMenuCounts()): 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 own menuCount(); a failure is reported as a MenuCountFailedException (badge key batch) 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, default null) for a metric that sets none, Metric::$cacheSeconds / cacheForSeconds() per metric, and Metric::$live / live() for a metric that is never cached. They go through the metrics cache 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 of HasMany, HasManyThrough, MorphMany, BelongsToMany and MorphToMany panels, 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, canSee callbacks, accessors and canRun(). 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 to canRun(), called once per page. A row the answer omits falls back to the per-row canRun(), 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 an UnexpectedValueException naming 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 overrides authorizedToRun() 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, default false): a lazy load while a page's rows are serialized throws in local/testing and logs a warning elsewhere. Scoped to the page's rows and restored afterwards; Laravel's global Model::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, and searchableRelations()), warns when one unindexed OR-ed column forces a sequential scan, and with --write generates a non-transactional migration (CREATE EXTENSION IF NOT EXISTS pg_trgm, CREATE INDEX CONCURRENTLY IF NOT EXISTS, a down() 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) and columns (only the columns named by col:term tokens). Config default martis.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 under public/ (ExtensionBundles::urls()), so it can be cached as immutable. URLs with a query string or fragment, absolute URLs and missing files are unchanged. Set extensions_versioned_urls to false to 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.