Laioutr Release News - August 2026
August was about measurement. The new consent-aware analytics system gives the platform a typed event model: useAnalytics().track(Token, payload) emits an event whose token carries its own payload schema, and delivery to each individual recipient is judged against the visitor's consent at the moment of delivery rather than at emission. Laioutr UI wired a storefront's commerce events straight onto it — product lists, product pages, the cart and search all report with no configuration at all. Alongside that, consent moved from cookie categories to processing purposes, and the ConsentAdapter contract shrank to four members.
The second theme is speed. Orchestr now resolves a request's queries, a query's links and the component resolvers for one entity type concurrently: a storefront home page's server render fell from about 724 ms to about 322 ms. Add to that cache keys roughly 45% shorter, a declarative cache configuration that lets Orchestr build the whole key itself, and several corrections to keys that previously carried no market, offset or locale. In Laioutr UI, sliders and videos hydrate only near the viewport, and web fonts ship as woff2 only.
Third: observability. Frontend Core and Orchestr export OpenTelemetry traces of their server-side work — queries, links and component resolvers as nested spans, every cache read carrying a verdict. And fourth, operations: markets now have a status of Draft or Active, a project can name its default market explicitly, and Studio keeps where you were — page, selection, market, language, preview — in the URL.
Frontend Core v0.38.2 – v0.47.2
v0.38.2 was released on 31 July but only documented in August, so it did not run in the July post. It carries a breaking change and is covered here.
Highlights
- A consent-aware analytics system:
useAnalytics()returns{ track, register, unregister }.track(Token, payload)emits a typed event whose token carries its own payload schema;@laioutr-core/core-types/analyticsprovides the token factories and the platform's ownweb/*events,@laioutr-core/canonical-types/analyticstheecommerce/*commerce vocabulary. Register a recipient withdefineAnalyticsDestination, declaring what it needs asconsent: { purposes: ['analytics'] }, orpurposeSetsfor an OR-of-ANDs. Consent is evaluated per recipient at delivery, never at emission — so one emission fans out to exactly the destinations the visitor allowed. Events emitted before the visitor answers are held and replayed in order once they grant. A destination may instead declareonDeniedto take denied events with the consent state attached, degrading rather than going silent; such an event counts as delivered and is not replayed on a later grant, and a revocation leaves that destination running rather than tearing it down. - Payloads carry Orchestr entities directly:
track(AddToCart, { products: [{ entity: product, quantity: 2 }] })— each entity is projected to a flat wire snapshot at emit time, selected by itsentityType. An entity carrying a slug on itsbasecomponent also gets an absoluteurlon the market's production host.@laioutr-core/core-types/orchestrgainsgetEntityComponent(entity, Token)andgetLinkedEntities(entity, LinkToken). Every payload schema carries an optionalcustomFieldsbag for site- or vendor-specific data; namespace the keys you put there ('acme:productLine'). - Collection and extension: the
v-track-clickandv-track-impressiondirectives, plususeTrackImpression,useTrackScrollDepthanduseTrackVideoProgress. Ambient page, market, session, consent and experiment context attaches to every event;useAnalyticsContexts()adds a provider of your own or overrides one of the platform's. Three synchronous Nuxt hooks extend the pipeline::emitas a veto,:enrichto enrich, and a per-entity:projectfilter, withaugmentProjectiontyping a:projecthandler. - Server side and identity: the browser posts batches to
POST /api/frontend/signals, overridable withanalyticsIngestPathin the module's public runtime config, which moves the server route too. Every event in a batch is judged on its own, so one malformed or oversized event never costs the batch it rode in on.subscribeToAnalyticsregisters a recipient that must not run in the browser; handlers receivesentAtandreceivedAtalongside the event. Visitor and session cookies are minted only under theanalyticspurpose and scoped to the market's registrable domain, so an identity survives a hop between subdomains; on platform hostnames they stay host-only. Inside the Studio preview they are written cross-site and partitioned. Withdrawal deletes both, and a later re-grant mints fresh ones without stitching activity retroactively. - Consent is reported as processing purposes (breaking): a
ConsentAdapternow reportsPartial<ConsentState>overnecessary,functional,analytics,advertisingandpersonalization, and owns the mapping from its own vendor's vocabulary. A purpose absent from a report counts as denied; an adapter grants a purpose when any of its own that map to it is granted. A visitor who allowed measurement but refused personalisation is now reported to Google Consent Mode as exactly that, rather than as allowing both ad purposes. - A new
ConsentAdaptercontract (breaking): an adapter is four members —name,setup,openConsentUiand an optionalhasDecision— and the store installs one withsetAdapter.init,getConsentState,onConsentChange,destroyandisActivecollapse intosetup(report): report the visitor's verdict at once and again on every change, and return a cleanup if you need one. It runs synchronously inside the installing plugin, souseHeadanduseCookieare available. On the storefront side, oneopenConsentUireplaces both former overlay calls, andadapterNamereplacesactiveAdapter. hasDecision()tells a refusal from silence:useConsentStore()reports whether the visitor has answered the consent prompt at all. A state of "denied" is otherwise indistinguishable from "never asked", which matters wherever that state is passed to a third party applying its own regional default. The Cookiebot and CCM19 apps implement it, each reporting a saved refusal as a decision rather than as silence. From 0.42.0 it returnsboolean | undefined:undefinedmeans no decision signal exists at all — no adapter, or one that cannot tell.- Every way a link can fail to resolve now has a name:
linkResolver.resolve()still returns an in-page fallback rather than throwing, but that fallback has one shape —#<code>?<detail>— drawn from a closed set, and each carries what identifies the offending link (#unknown-route?pageId=pdp,#missing-required-params?params=brand). New islinkResolver.resolveOrThrow(link, options?)for a caller that can act on the failure — an analytics projector, a sitemap writer — throwing aLinkResolutionErrorcarryingcodeanddetails. Pass{ withOrigin: true }for an absolute URL rather than an absolute path; the origin comes from the market's own domain, never fromlocation, so an address resolved inside the Studio preview or on a dev host still names the site a visitor would land on. - Markets can be set to Draft, and a project can name its default market: a draft market still serves its own host so it can be checked before launch, but it is excluded from hreflang alternates,
og:locale:alternate,x-defaultand market switchers, and its pages are served withnoindex, nofollow.switchMarketUrlreturns'#market-not-active'for a draft target. Status is read throughRenderMarket.isLinkableandisIndexablerather than thestatusmember.RcProject.defaultMarketIdreplaces the implicit "first market in the configuration" rule forx-default, the primary route path, the unknown-host fallback and nuxt-i18n'sdefaultLocale— which market that was previously depended on the order Cockpit happened to return them in. Leaving the field unset preserves the previous behaviour. - OpenTelemetry tracing: a storefront that sets
OTEL_EXPORTER_OTLP_ENDPOINTat build time exports traces of its server-side work through the standardOTEL_*configuration; without it nothing is installed. Queries, links and component resolvers each become a span nested under the Nitro request span, and an upstream API call made while a resolver runs nests under that resolver — so a trace attributes upstream time to the work that caused it. Attributes are counts and names a backend can group by: how many entities a resolver asked for, a token name, the app a handler comes from. Every cache read carriesorchestr.cache.verdict(hit,miss,partial,skip,uncacheable), and a server-rendered request tags its span with the page type, market slug and locale, since a product page and a listing page share one wildcard route. - Tracing on Vercel is opt-in (breaking): a storefront deployed there first turned it on automatically (0.45.0), which cost every request a failed span export and two error log lines, because Vercel runs no collector for it to reach. From 0.46.0, set
LAIOUTR_OTEL_ENABLED=1on the project to keep tracing; a build configured withOTEL_EXPORTER_OTLP_ENDPOINTis unaffected. A traced request also joins the trace it arrives in, instead of starting a second, unrelated one. - A
laioutr://resource locator:@laioutr-core/core-types/locatorexportsformatLocatorandparseLocatorplus the supporting types and theSTUDIO_CONTAINER_KINDSconstant. A locator names a namespace (studiois the only one today), a container (pageVariant,section,sectionRef,globalSectionorblock) by id, a path of object-key or array-item-by-id steps into its props, and optional view coordinates (locale,market,ref) — for examplelaioutr://studio/block/blk_C3/slides[itm_E5]/heading?locale=de. Both directions also handle a relative form that omits thelaioutr://studio/base.parseLocatornever throws: it returns{ ok: true, value } | { ok: false, error }for every input. useSectionContext()anduseRenderPageContext()are auto-imported: both contexts were already provided at runtime but reachable only through a deep import path. A block never receives its own id, so the section context is the only stable list identity available to one.- Read the visitor's analytics identity server-side:
readAnalyticsIdentity(event)returns the visitor and session tokens the browser is already reporting on its events. A server route or a connector can group its own work into the same visit without minting a second identity. The tokens exist only under theanalyticspurpose, so an empty result means the visitor has not granted it. - Development-only switches live under
laioutr.dev, and a production build discards the whole object rather than each flag on its own. New isconsentDebug: a debug CMP that grants every purpose without asking. Playgrounds install no consent management app, so nothing ever grants a purpose there and the consent-gated paths never run. It reports a decision the visitor never made, so it warns on install.analyticsDebugkeeps logging canonical events through the built-in debug destination, and now only takes effect in dev. - Session cookies survive the Studio preview: cart and customer-session cookies are issued with
SameSite=None; Secure; Partitionedwhen the request comes from the preview frame, so a cart built in the editor persists across reloads. The preview gets its own cookie partition, keeping it separate from your real session on the same shop in the same browser. App authors get two new server auto-imports,setManagedCookieanddeleteManagedCookie, which apply this policy — deletions must go throughdeleteManagedCookie, since a delete that omitsPartitionedaddresses the wrong cookie jar and silently leaves the cookie in place.Secureis now derived from the request origin rather than set per connector. menuTreeAtDepth(items, startLevel): auto-imported alongsidebuildMenuTree, it builds the menu tree and then descendsstartLevellevels so the nodes at that depth become the top level — for skipping a synthetic upstream root node (e.g. a Magento "Root" category) so the first business-facing level renders as the top level.0(the default) keeps the tree as fetched.- A page with a single variant fetches in one request: its page queries and its variant queries now go out together instead of in two round trips in series, so it renders sooner. Pages with several variants are unchanged — a
frontend-core:page-renderer:select-page-varianthandler still reads the resolved page queries before it chooses.
Fixes & Improvements
- Fixed: the reflect endpoint served a previous deployment's section and block catalog. Its cached reflection is now keyed by build id, so a redeploy is a cache miss instead of inheriting whatever the last build left behind, and two frontends sharing one Redis no longer overwrite each other's entry. Cached entries expire after 12 hours.
- Fixed:
laioutrrc.jsonapp config was merged into each app module twice. It was both assigned tonuxt.options[<appName>]and passed toinstallModule, so Nuxt merged it with itself and concatenated every array-valued option — a four-entry Shopifysortingslist arrived as eight and failed the build with a duplicate-key error. - Fixed:
linkResolver.resolve()dropped a link'squery. Because every Laioutr page route carrieslocalizedPaths, internal links always resolved through the localized-path branch — which ignoredqueryand returned before the vue-router fallback that applied it. A header search landed on/searchwithout?q=.urlandanchorlinks now carry theirquerytoo; it is merged into whatever thehrefalready has (the link's ownquerywins on a key collision) and always lands before the fragment. - Fixed: the page endpoint's render cache grew without limit and could exhaust a storefront's memory. The endpoint memoised every rendered page by page, market and language in a map that was never evicted from — and module scope on a serverless host lives for the whole instance. It is now capped.
- Fixed: the consent store is scoped to the Nuxt app rather than the module, so on the server each request gets its own. It was global to the module, which on a server is global to the process: every concurrent render shared one store, the CMP adapter installed by the first request kept serving all later ones with the cookie ref it captured then, and each subsequent request added another consent listener to it.
- Fixed: switching market or language in Studio reloads the previewed content. The preview kept rendering whichever market it was opened with — switching from German to Dutch left the previous menus, product data and prices on screen, so the preview showed one market's content under another market's settings. Orchestr results are scoped to a market and a language, but the client cached them under a key that carried neither. Cached results are now dropped when the selection changes. Storefront rendering is unaffected.
- Fixed: the canonical page types and analytics projectors now register in the browser. The plugin that pulled them used a dynamic import with
@vite-ignoreand a template-literal specifier, so Vite left the bare specifier alone and the browser could not resolve it. The failure went into an emptycatch, so nothing registered and every entity in an analytics payload fell back to its id and address. Page types were unaffected, because they resolve on the server. The module now checks at build time whether the package is installed and, if it is, emits a plugin that imports it statically; the package stays optional. - Fixed: a page no longer runs a query that only an abandoned field still binds. When a section or block drops a field from its schema, the stored value stays in the project configuration — and that value kept the query alive, so every render fetched data no component reads. A header still holding a cart binding this way cost one upstream request on every page of the storefront. A query now survives as long as any live field, or an SEO placeholder, still references it; a section or block that no registry knows loses its queries as well. Each drop warns once and names the component, the field and the query token.
- Fixed: the Studio preview failed when the handshake secret was absent from the URL. It now authorizes off the embed marker the server mints after validating that secret, so a reload or a client-side navigation inside the frame keeps working, and a storefront opened outside Studio renders normally instead of throwing
No secret provided. The marker stays scoped to one project. The project secret no longer reaches client JavaScript, so it no longer appears in the hydration payload of an embedded page. - Fixed: the analytics identity cookies survive on a hosting platform's shared domain. A storefront served from a host such as
example.vercel.apporexample.pages.devscopedlaioutr_vidandlaioutr_sidto the platform suffix itself, which a browser rejects as an invalid cookie domain — so neither cookie existed on those hosts and no visitor or session token reached an event. The cookie domain now resolves against the private section of the Public Suffix List, the same view a browser applies. - Fixed: a query's configured sorting is now applied. The value stored on the query was dropped while the request was built, so a sorting set in Studio — or returned as
defaultSortingfrom aqueryTemplateProvider— never reached the query handler. It is now sent as the query'ssort; ansURL parameter still takes precedence, so the configured value sets the default order rather than a fixed one. Links are unaffected. - Fixed: the
referreron a page event is an absolute URL on every page view, not only the first. Within an SPA session it reported the previous route as a bare path, which a destination that classifies referrers reads as malformed rather than as a same-site visit. - Fixed: a property edit in Studio updates the preview again. Every edit re-rendered the page variant that
PageRenderercaptured when the preview opened, so nothing on the page moved until a reload. - A request span adopts an inbound trace context only when nothing upstream opened one. Where an HTTP instrumentation already started a server span — a node deployment shipping to an OTLP endpoint — that span stays the parent, instead of the request being reattached to the remote caller.
- The startup banner no longer prints during
nuxi prepareandnuxi typecheck, only when the app actually boots.
Notes for Developers (Breaking Changes)
@nuxtjs/robotsis no longer installed (0.38.2):robots.txt, theX-Robots-Tagheader and the route-rulerobotsvalue now come from the@laioutr/app-essentials-seoapp — install it to keep them, and configure them through its app config instead ofnuxt.options.robots. A frontend with neither that app nor its own@nuxtjs/robotsinstall serves no/robots.txt(a 404 tells crawlers to crawl everything, which is what the previous default content said), and anyrobotskey innuxt.configor in a route rule is silently inert. Page-levelrobotsmeta tags are unaffected; they come from the page variant's SEO settings.- The specialized tracking composables are removed (0.42.0): instead of
const { trackAddToCart } = useProductInteraction(); trackAddToCart(payload), nowconst { track } = useAnalytics(); track(AddToCart, payload). - The
ConsentAdaptercontract (0.42.0): upgrade the CMP apps alongside@laioutr-core/frontend-core— an adapter written against the old contract no longer installs.registerAdapterplusactivateAdapterbecome one synchronoussetAdapter, which returns the handle that drops the adapter;deactivateAdapteris gone. Throwing fromsetupmakes the store warn and drop the adapter. On the storefront side,consentStore.openConsentUi()andconsentStore.openConsentUi('preferences')replaceshowConsentOverlay()andrenewConsent().hasDecision()returnsboolean | undefined— code that read a falsy result as a refusal has to tell the two apart. - Consent as purposes rather than cookie categories (0.42.0):
ConsentManagementStateandhasCategoryConsentare gone.{ necessary, functional, statistics, marketing, unclassified }becomes{ necessary, functional, analytics, advertising, personalization }. fillParamsreturnsundefinedwhen a required param has no value, instead of filling it with a blank (0.42.0). The blank collapsed into the neighbouring separator —/:brand/p/:slugbecame/p/shoe, an address that looked resolvable and was not — so callers must now check forundefined. Finite-set defaults (/:page(a|b)) and optional params (:lang?,:rest*) are unaffected;missingRequiredParams(path, params)names the ones missing. Downstream: an hreflang or canonical link whose params cannot be filled is omitted rather than emitted truncated, and a language switcher offers the target domain's homepage rather than a broken path.RenderI18nConfig.marketsnow contains only linkable markets (0.41.0). The complete list, including drafts, moved toRenderI18nConfig.allMarkets. Code that renders a market switcher needs no change and starts honouring status automatically; code that needs every configured market (routing, host resolution, preview) must switch toallMarkets.- Local dev hostnames (0.41.0): for projects hosted on
*.app.laioutr.tech, that suffix is dropped instead of folded into the label. A market onexample-shop.app.laioutr.techis now reachable atexample-shop.local.laioutr.techrather thanexample-shop-app-laioutr-tech.local.laioutr.tech. The same host drives market resolution and the dev cookie domain, so all three stay in step — update any bookmark orallowedHostsentry that named the old form. - An
/apior/.well-knownpath that nothing claims answers with a small plain HTML 404 (0.47.0), instead of the project's whole 404 page with its sections and queries. The response carriesCache-Control: public, s-maxage=3600, so a CDN answers a repeat probe without invoking the function. A registered server route and a file inpublic/both still win, so neither needs changing. A/.well-known/…path served by a Nuxt page does not: move it topublic/or to a server route. - Tracing on Vercel is opt-in (0.46.0): set
LAIOUTR_OTEL_ENABLED=1on the Vercel project to keep it.
Orchestr v0.38.2 – v0.45.0
Orchestr and Frontend Core release in lockstep; this section carries only what appears exclusively in the Orchestr changelog. As with Frontend Core, v0.38.2 was released on 31 July and documented in August.
Highlights
- Concurrent resolution: a request's queries, a query's links, and the component resolvers for one entity type now resolve concurrently instead of one after another. A page waits for its longest strand rather than for the sum of its work. A storefront home page's server render fell from about 724 ms to about 322 ms; a cold product page's six component resolvers finished in 378 ms — run one after another they total 937 ms. At the top level, up to six queries run at a time. Chunks now interleave: a client that routes them by
pathand merges entity chunks by id, as it always had to, is unaffected; one that depends on chunks arriving grouped by query in request order is not. - A failing link no longer discards its query: a link handler that threw collapsed the whole query into one error chunk, taking the query result and every entity chunk its sibling links had already streamed. It now reports an error at its own path,
[queryId, linkToken], and the rest of the query completes. Where several parts of one query fail, each reports its own error rather than only the first. - A declarative cache: a query or link handler enables its cache with a TTL and a strategy, and nothing else —
cache: { ttl: '1 day', strategy: 'ttl' }. Orchestr builds the whole key: the token, the environment, the requested slice, the sorting, the filters and the token's input. The first slice of a listing, unfiltered, is cached by default; the tail of a listing is cold and a filter combination unbounded, so both are opted into (pages: 'all',filters: ['filter.v.availability']) rather than out of.shouldBypassCacherefuses a request the declarative options would admit; it runs after them and can only narrow them. validatedecides from the outcome: a query or link cache config acceptsvalidate, called with the handler's result before it is written. Return false and the result is served but not stored.buildCacheKeyalready decides cacheability from the request, before the handler runs; this decides it from the outcome — a degraded fallback that must not be served for the rest of its TTL, or a result that cost nothing to produce. It gates the write only: an entry already in the cache keeps serving until its TTL lapses.cacheKeysfor hand-built keys:useUserlandCachehands back a bare storage with no prefixing at all. A key built there carries the environment itself or it serves one storefront's data to another, and it escapes an id holding a/or a:or unstorage rewrites it into a different key.cacheKeys.forClientEnv(clientEnv)andcacheKeys.escape(id)close that.cacheKeys.forEntityIdsturns a set of entity ids into a short, fixed-length segment, so a hand-built key stops growing with the page size — a handler that joined ids instead produced over two thousand characters for 48 products, past the 255 bytes a filesystem allows for one path segment and into the request-size limit of a hosted Redis. Query and link handlers do not need it: Orchestr keys a link's source ids itself.- Cache keys are roughly 45% shorter: measured across two live storefronts, a component key fell from 144 bytes to 84 on one and from 121 to 66 on the other, a product-variants link key from 136 to 82, and a page-index key by 17%. The key was a third of what a component entry cost to store, and every key is sent again on each batched read, so the saving lands on stored size and on request payload alike. Three things got shorter: the namespace, the escaping (two bytes for the four characters unstorage would otherwise rewrite, rather than three bytes for every character
encodeURIComponentrecognises — a Shopify GID paid 15 bytes for 5 characters), and the environment segment, where locale, currency, market and preview stage become one 8-character digest. Entity type, id, component and page type stay readable. - A request-level cache report: an execution summary carries a cache report for the request next to its per-query summaries. It answers questions the query summaries could not — whether a component read arrives as one batched call or many single ones, what a given component's hit rate is, and whether a background write was lost when the isolate froze. The counters are gathered only for a request that set
options.dev.enableSummary; every other request pays nothing, including the payload measurement. - Passthrough is scoped to the handler that writes it: every handler in a query used to share one
passthroughstore. A handler now reads every token its callers set, and writes where only the handlers beneath it can read. So a link handler can hand data to its own component resolvers without it reaching the rest of the query. In exchange, two links that set the same token no longer see each other's value.
Fixes & Improvements
- Fixed: a
TypeError: Cannot read properties of undefined (reading 'length')failed any query whose link handler answers withentity,entitiesor a singletargetId. Telemetry added in 0.44.0 counted a link's targets by readingtargetIdson the handler's raw response, which only thetargetIdsshape carries. The failing query returned no data and reported an error chunk, so a page built on it rendered without its products. - Fixed: query, link and entity-component caches now key on the market, and query and link caches also on the pagination limit. Two markets that share a language and a currency no longer read each other's cached results, and a request for 24 items no longer receives the slice a different page size cached.
- Fixed: the requested offset was absent from every query and link key, so
?offset=5&limit=24was served theoffset=0result. And a component resolver'sgetKeySuffixreplaced the environment digest instead of extending it, which would have let two markets collide on one entry. - Fixed: a streamed query result renders once it has settled, rather than once per response chunk. On a server-rendered page, the first client-side navigation that ran a query re-rendered on every chunk — including the window where a link's entity ids have arrived but the entities themselves still report no components. Sections reading those components rendered against that half-loaded state and threw, which read as intermittent because a retry usually landed after the response had finished.
- Fixed: a project that points its cache at a real backend now keeps it in development. Orchestr mounted an in-memory LRU over its own cache namespace on every dev boot, and that mount is more specific than the
cachemount a project configures, so it won. The effect was that dev never ran the path production runs: no round trips, no serialization limits, and a configured Redis that received nothing. The LRU still mounts when no backend is configured. - Fixed: a
pageIndex.locatelookup no longer serves one locale's page metadata to another. The cached result carriesmetaresolved in the locale the lookup was made in, but the entry was keyed on the market alone. Two locales of one market collide whenever they share a route param — which is every product whose slug is a SKU, a brand name or an untranslated model number. The key now covers the locale, and preview and published results no longer share an entry either. - Fixed: cache writes survive on Vercel. Orchestr writes to the cache after responding, through
event.waitUntil, and on Vercel's Node runtime nothing was keeping the function alive to finish them — Nitro's Vercel preset never sets theevent.context.waitUntilthat Nitro itself looks for, so the write was left as a floating promise and died with the invocation. Measured against a deployed probe on a cold instance, the deferred write was lost in four trials out of four and the immediate write in two of them. A Nitro plugin now supplies that hook from Vercel's own request context; it needs no extra dependency, does nothing off Vercel, and defers to any platform that already provides one. - Fixed: devtools traces and execution summaries describe what actually happened. A completed query reported
unknownfor its id and token, a component resolver's missing entity ids were dropped, and spans started concurrently appeared as a chain — initwares running underPromise.allrendered as each one nested inside the last rather than side by side. - Fixed (0.38.2):
listPagesFromreports itsendCursorat any stopping position, not only at thetakeboundary. A consumer that stopped iterating early — on a wall-clock budget, say — readendCursorasundefinedand could not distinguish that from an exhausted enumeration, so a partial walk was recorded as complete. Two fields make the outcome of a pass unambiguous:exhaustedis the termination signal for an accumulation loop, andprogressedreports whether the pass durably advanced. A loop must stop on!progressedas well as onexhausted, or a pass that takes nothing repeats forever.
Notes for Developers (Breaking Changes)
- The storage namespaces moved: a project mounting one driver at
cachecovers both and needs no change. A project mounting at the full path does —'cache:orchestr:internal'becomes'cache:orch:i'. A driver left at an old path receives nothing, and the cache falls back to whatever servescache— in the worst case a per-isolate memory driver, which reports no error and holds nothing between requests. buildCacheKeyis now optional and supplies one trailing segment rather than the whole key. A handler that keeps it keeps working, and returningnullfrom it still refuses the cache.- Every cache key changes shape, in 0.43.1 as in 0.45.0. Existing entries are never read again and expire under their own TTL — expect one cold-cache window after the deploy; nothing needs clearing by hand.
ExecutionSummaryis a union now: narrow ontypebefore reading a query's fields (if (summary.type === 'query') summary.linkSummaries;).- Chunk order: chunks from different queries now interleave. A client that reads chunks by
pathis unaffected; one that relies on grouping by query in request order is not. - Shared
passthroughis gone: a handler that read a sibling link's token now gets its own default.
Laioutr UI v2.8.6 – v2.22.0
Highlights
- Commerce events with no configuration: product lists, product pages, the cart and search now emit commerce events. The instrumentation is always on and has no Studio surface. Product sliders and the product grid report
view_item_listonce they are on screen, not merely rendered. A tile reportsselect_itemon a navigation andadd_to_cartafter the mutation resolves. A product page reportsview_itemon load and again on every variant switch. The cart reportsview_carton open,begin_checkoutfrom the call to action, and a removal or an add for every quantity change, sized to what moved rather than what remains. The header reports a submittedsearch, and a search results page reportsview_search_results. Raw entities go intotrack()and the projector registry turns them into wire shapes, so no call site builds a payload by hand. MediaFeed: a full-screen short-form media feed in the TikTok/Reels pattern — vertical scroll-snap, one item playing at a time, an action rail, a mute toggle and a read-more sheet. Only the item in view is interactive; the others areinert, so tabbing never reaches a control that is off screen, and each item carriesdata-state="active" | "inactive". Render it from props (items+v-model:open) or drive it from anywhere on the page withuseMediaFeedStore(). Because browsers only allow autoplay while muted, the feed starts silent and the top bar carries a way back to sound. SeturlParamto sync the item in view to the URL —?reel=<id>— so an item can be shared and the back button closes the feed instead of leaving the page; the parameter carries the item id, not its position, so a link survives the feed being reordered. The compound parts are exported for composition.- New in the UI Kit alongside
MediaFeed:useOverlayHistorysyncs any overlay's open state and current item to a query parameter — opening pushes one history entry, item changes replace it, and back closes the overlay rather than navigating away.AnimatedIconreflects a boolean active state, cross-fading on the transition with a burst that flies up and outwards, sized in multiples of the icon so it holds at everysize, and coloured through--animated-icon-active-color.AnimatedButtonisButtonwith a continuous pulse, paused on hover, underprefers-reduced-motion, or viapaused.$count(1234)gives compact number formatting for the current locale, honouring your ownnumberFormats; the default abbreviation is CLDR's and differs by language — English shortens from a thousand (1.2K), German only from a million.Badgegains aglass-blackvariant for badges sitting over arbitrary media, andMediaandMediaVideogainv-model:currentTime, the sibling ofv-model:paused: assigning to it seeks, and the element reports its own progress back. - Four new locale bundles: French, Italian, Spanish and Polish — storefronts in those languages stop rendering their UI chrome in English.
ui-kitshippedde,enandnlonly, and any other language fell through toen: 257 messages across 33 components, including the search field, the cart, "Add to cart", "Sold Out", the filter drawer and the header's account, wishlist and cart labels. The page content was translated; the chrome around it was not. The new bundles cover all 257 keys, verified key-for-key againsten.tswith every{placeholder}token preserved. Two pre-existing gaps are closed at the same time:dewas missingsliderNavigation.play/.pause, andnlthe wholemediaFeedblock. Not a behaviour change for existing projects —de,enandnlresolve exactly as before. The Dutch bundle for the UI Kit had landed earlier in the month (2.11.0). - The product tile pins a variant in its link only when the connector authored it: a tile in a listing, a grid or a slider produced
/<slug>?variant=<variant-id>for every product. The value came fromdefaultVariant, which most connectors derive from stock, so the same product changed its URL whenever one of its sizes sold out. A derived default now stays out of the link, and every listing and slider URL for those products changes. An authored default is a merchant's choice and does not move with stock, so the link still carries it. An explicit?variant=in a shared link, an ad landing URL or a wishlist entry is untouched. In step with that, a product detail page opened without?variant=now opens the product's default variant rather than its first, and a colour swatch is marked as selected only for an authored default variant.ProductDefaultVariant.origincarries the distinction. - Product tiles show the colour swatches of the product they render: the swatch row existed but was never filled, so it was always empty. Tiles read the product's option axes directly and no longer load every variant to build a listing, which cuts the data a product grid ships to the browser. A sold-out product still disables its add-to-cart button; that state now comes from the product's default variant instead of a loaded variant entity.
ProductTileBasic.sizeVariantscarries one entry per value on the product's size axis, each addressing the variant the connector names for that size and disabled when no purchasable variant carries it. - Web fonts are configurable:
fontson the@laioutr-app/uimodule reaches the whole@nuxt/fontsconfiguration —families,defaults,providersandpreload. A value you set replaces the default, arrays included;falseskips the module, for a storefront that ships its own@font-facerules. Fonts are served as woff2 only: a provider answers one request per user agent and returns every format tier at once, and the legacywofffaces carry nounicode-range, so they match every character and are written last — which makes a modern browser download them in place of the woff2 files. On top of that,fonts.defaults.subsetsnow takes effect at all: previously every subset a provider returned reached the stylesheet, and an unused one still downloaded as soon as a single character claimed it — 36.7 KB of glyphs that never render, on every page, on one storefront. - Sliders and videos hydrate and load late:
CommonSwiperhydrates once it comes near the viewport instead of during the initial hydration pass, so carousels far down a page no longer initialise Swiper on load. Server-rendered markup is unchanged — a slider looks the same before it hydrates, it just does not answer arrows, bullets or autoplay yet. ImportCommonSwiperEagerfor the previous behaviour. Videos rendered throughMedialikewise wait until they are near the viewport before fetching; videos under aMediaAboveTheFoldprovider are unaffected, and elsewherevideoPreloadoverrides it per video. And an above-the-fold image emits its preload link withfetchpriority="high"— the<img>already carried the hint, but the preload link is what starts the fetch and carried none, so the browser fetched the LCP image at default priority. - Banners: height, background colour and two-dimensional alignment: all three CTA banner sections — Banner Basic, Banner Integrated and Banner Showcase — gain a Sizing control (fixed height, responsive height or aspect ratio) to give the banner a definite height. Banner Basic's Content Alignment becomes two-dimensional (vertical plus horizontal, e.g.
bottom-left) and Banner Integrated gains a Vertical Alignment control, so content can sit at any edge — including the bottom. Vertical alignment only takes effect once the banner has a definite height. All three also gain, in their section as in their block variant, a Background Color for the inner banner tile, independent of the section's outer backdrop; text and icon contrast adapt to the chosen colour automatically. Existing sections render unchanged. Containergains a tablet stage:columnsTabletandgapTabletadd a stage between mobile and desktop. Column count and gap now resolve in three stages — mobile (below 600px), tablet (600–1279px), desktop (1280px and up) — one breakpoint scheme shared with the Sizer. Both fall back to their mobile value when unset. The Container section gains Tablet columns and Gap (Tablet) controls for it.- Fully clickable banners and hero slides:
HeroSlide,BannerBasic,BannerIntegratedandBannerShowcaseaccept anhrefthat turns the whole component into a single link, rendered as a transparent full-area overlay. It activates only when no CTA button is present, so a component is either button-driven or fully clickable — never both, which would nest interactive elements inside an anchor. The matching blocks and, from 2.14.0,SectionBannerBasicgain a Link field for it. - New blocks and controls for Studio:
BlockBasicTablebrings theBasicTablemolecule to Studio as a block, with an authored array of label/value pairs and the primitive'soutlined/plainvariant as its Design style; it is standalone, so it is offered in every section.BlockProductSlidermakes the Product Slider available as a standalone block so it can sit in a container slot — configuration, data binding and rendering are identical toSectionProductSlider.ProductDetailButtonGroupandBlockProductDetailButtonGroupstack full-width calls to action on the product detail page. The Mega Menu, Mobile Menu (Shop) and Side-by-Side Menu blocks gain a Start Level control that descends the fetched category tree by the configured number of levels — for skipping a synthetic upstream root node without changing the query or connector.BlockPaginationhides itself when the result set fits on a single page, with ashowOnSinglePagetoggle to keep it. AndBlockFooterMenuitems gain a Visible in markets list: leaving it empty shows the item everywhere, which is what every menu authored before this release does. The two pieces behind it are exported for other components to adopt —visibleMarketsFieldfor the schema andinMarket()for the render. - Captions gain a Text Size setting, wired the same way heading and subline already are.
CaptionFlaghad its size hardcoded; it is now optional, with the variant-derived value as the fallback. The option list is a deliberate subset of the shared text-size scale: the caption utilities come from the--caption-*tokens, which stop atxl, so2xland above are not offered rather than offered and silently ineffective. The setting lands on the sharedcaptionVariantstyle decorator, so every section and block that already uses it gets the control. MediaVideoreports milestones: amilestoneevent fires as playback crosses each quarter of the video, carrying{ milestone, currentTime, duration }wheremilestoneis the fraction reached —0.25,0.5,0.75or1, each once per mounted player.useMediaMilestones(playback, onMilestone, config?)is the headless detector behind it, for wiring the same behaviour to a non-native player; it takes reactivecurrentTimeandduration, knows nothing about media elements or analytics, and accepts{ fractions }to replace the default quarters.SocialSharereports the platform picked: it emitssharewith the platform a visitor chose, andBlockSocialSharereports it as aweb/shareevent. The payload carriesmethodalone — the platform id (facebook,x,linkedin,pinterest,email). The block binds to no entity, so what was shared is the page, and the ambient page context already puts its url, path and type on every event. Every button is an outbound link to the platform's own dialog, so the event marks the click rather than a completed share.- Login and account: a visitor who wants to write a product review is now sent to their account. The login prompt resolves through the
ecommerce/auth/login-oauthaction, so it works with whichever connector answers it — an OAuth authorization URL where accounts are hosted, or a link to the account page where the storefront renders its own. The account button and the review section's login prompt redirect to customer login instead of opening a popup, and carry the current path so the customer comes back to it; that needs a connector that readsAuthLoginOauthAction'sreturnToinput, and against one that ignores it login still works. The login popup also no longer fails silently in Safari and Firefox: it is opened while the click is still a trusted gesture, and falls back to a full-page redirect where a popup is blocked anyway. And a signed-in customer's cart now carries their identity, so checkout opens authenticated and the resulting order attaches to their account. - A link naming several option values opens the right variant: a product-detail URL that names several option values opens the variant carrying all of them. Any single matching value was previously enough, so
?variant=Red&variant=XLcould open a red product in the wrong size. A URL naming one value still opens the first variant carrying it. - A Child Categories link, and category cards from a data source (2.8.6): new is the
ChildCategoriesLinktoken (ecommerce/category/child-categories) in canonical-types. The Shopify connector implements it by reading the navigation menu (newcategoryMenuHandleoption, defaultmain-menu), locating the source collection's node and returning its collection children as inlineCategoryentities, localized via@inContext.SectionCategoryCardSlidergains an optional Category query whose entities render as cards; manual slot cards still take precedence. MenuSideBySidegains abelow-separatorslot on the root level. The area under the root drawer's separator renderedMobileMenuListItems from therootMenuItemsprop and nothing else, so a storefront whose quick links are styled buttons rather than list rows had nowhere to put them. The slot is forwarded at every level between a consumer and it, each time only when a consumer actually provides it.ProductListingGridaccepts aproductTilescoped slot, bound as{ product }, matching the oneProductSlideralready exposes; its fallback is the tile it rendered before.CartSummaryBoxemitscheckoutwhen its call to action is clicked, andCartSheetforwards it — the button keeps itshref, so navigation is unchanged.CartSheetcan be told how many items its header pill should report, via the new optionalcartContent.itemCount. The pill was hardcoded to the number of lines, which only equals the number of items while every line has quantity 1 — a storefront whose header cart badge counts units therefore showed two different numbers for the same cart: two products at quantity 3 read 6 in the header and 2 in the drawer. Without the value it falls back to the line count.
Fixes & Improvements
- Fixed:
QuantityPickerremoved an item by mistake. It emitteddeletewhen the quantity field lost focus at the minimum value, or when the minus button stepped down to it — so clicking the quantity input of a cart line at quantity 1 removed the line.deletenow fires only for the delete button at the minimum, or a value typed below it. - Fixed: the product tiles in a Product Grid show their add-to-cart button, brand, description, flags and rating again.
ConnectedProductTileBasicdeclared each toggle as an optional boolean without a default, so Vue cast an unset one tofalseand the component forwarded thatfalseon toProductTileBasic— beating the tile's owndefault: true.BlockProductsListingpasses none of these toggles, so every tile in a grid rendered with all five switched off. - Fixed: a price in a commerce event is computed for the variant the event names, not the product it belongs to. A product's own price is a "from" price across its variants, and the projection already reported the active variant's sku and name — so pairing those with the product's price priced one variant as another, understating the amount by an order of magnitude on a product with a wide variant spread.
projectAnalyticsProductnow reads the active variant's prices and falls back to the product's only where the variant has none. A variant price never carries the product's strike-through price, since carrying it across would invent a discount. A tile'sadd_to_cartvalue is computed from the variant actually being added. - Fixed: product tiles keep rendering when a variant is missing the components they read. A query result can legitimately carry a product variant whose components have not been resolved; the shared product-tile mapper read
options.selectedandavailability.statusoff every variant, so a single such variant took the whole Product Grid, Product Slider or Product Slider – Showcase down behind its error boundary. Those variants are now skipped, and the tile renders with a momentarily shorter size list. - Fixed: the add-to-cart button on tiles in the Product Slider – Showcase section did nothing when clicked — no spinner, no toast, no cart request — while the same button in the plain Product Slider worked. Both now behave identically: per-tile loading state, success and error toasts, and the cart count updating.
ProductSliderShowcasealso gains an optionalproductTileslot. - Fixed:
CategoryCardSliderandCategoryCardGridrender theirnodesprop and their default slot together,nodesfirst. Previously the default slot replacednodesentirely, and because the Category Card sections always pass one,nodesnever rendered through them at all — so a Category Card Slider bound to a category data source showed an empty slider. - Fixed: the filter off-canvas accordion opened the wrong panel.
FilterOffCanvasAccordionItemusedactiveValue(the display text) as its rekaAccordionItemidentity, so every unselected list and range filter sharedvalue=""and clicking any trigger opened the first one, overlapping the others. The item now takes a dedicated, uniquevalueprop for identity. - Fixed: the Filter Bar's header and quick-filter rows line up with the surrounding page content on large screens. Their horizontal padding was a fixed
--spacing-ml, narrower than the page container's own, so both rows sat slightly inside the content column fromlgupwards. They now read--container-padding. - Fixed:
BlockMedia's Sizing setting applies to the image only, not to the image plus its description.Sizerwrapped the whole block, so an aspect ratio of 16/9 was the ratio of image and caption together: the image lost exactly the height the caption took, and a longer caption shrank it further.MediaPreviewnow takes an optionalsizingand applies it to its media box, with the description below it and outside the sized box. - Fixed: a crash on a hero slide that shows a caption.
SectionHeroSlidercalledresolveCaptionVariantwithout importing it, and ui-app registers no auto-import directory, so building the slide props threw aReferenceError. Only a slide with at least one visible caption reached the call, which is why it stayed latent. - Fixed:
BrandHeroshows a background colour when no background image is set.fallbackColorwas passed toMediaStageall along, butfinalMediafell back to the theme's decorative hero SVG wheneverbackgroundwas empty — so the colour was painted behind an opaque image. An explicit colour now takes precedence; the theme default still applies when no colour is set. The field is relabelled "Background Color" inSectionBrandHero.BrandHero'sdescriptionalso becomes rich text: the field was atextarea, which takes typed copy only — and on a brand page its whole point is to carry the brand's own description from the catalogue. Asrichtext, Studio offers a data source on it; the prop widens tostring | HtmlFragmentand callers passing a plain string keep working. - Fixed: the filled heart icon (
essentials/heart-filled) rendering atsize="s"andsize="m". - Added: the
review.logInstring in all seven shipped locales — the review login prompt's button was hardcoded English. - The Product Slider – Showcase now defaults Show Product Flags to on, matching the standard Product Slider (new sections only; existing configurations are unaffected). And
productTileMapperemits asizeVariantsarray ({ value, label, disabled }, keyed by variant id,disabledfor sold-out sizes); the shared query's variant limit rises from 5 to 30 for it, so full size runs are no longer truncated.
Notes for Developers (Breaking Changes)
AuthLoginOauthAction(ecommerce/auth/login-oauth) no longer returns a bareauthorizationUrl(2.11.0), but a discriminated union: the OAuth authorization URL when the customer has no session, and aLinkto their account when they do. Narrow ontypebefore reading either. The account destination is aLinkrather than a URL string, so the frontend resolves it — Shopify returns an external link to its hosted account page, and a connector whose account is a storefront page returns apageTypelink from the same contract.Reviewreports the intent instead of handling it itself (2.17.0): it emitssubmitwith the form data when the review form is sent, andloginwhen the visitor confirms the login prompt. The login prompt's primary button no longer opens a confirmation dialog of its own — it emitslogin, and the consumer decides where the visitor goes. AReviewrendered without aloginlistener shows the prompt but cannot log anyone in.CartListItemmarks a line whose merchandise cannot be bought (2.16.0): it takes a newisSoldOutprop, renders an "Out of stock" badge and carriesdata-unavailableon its root for styling;ConnectedCartSheetsets it from the cart item's availability component. A custom locale must supply the newcart.itemSoldOutkey.SectionCategoryCardSliderno longer pads itself by default (2.14.0): the section overrode the sharedpaddingFieldwithdefault: 's', so every instance started with vertical section padding nobody asked for — and a section that pads itself cannot be placed flush against its neighbour. Behaviour change: existing instances that never touched the Padding field lose that padding. Set it toSexplicitly to keep the old look. Seven other sections carry the samedefault: 's'override and are deliberately left alone here.Container's desktop gap switches at 1280px instead of 800px (2.10.0): containers with an explicit desktop gap and no tablet gap now use the mobile gap between 800 and 1279px where they previously used the desktop gap — the same breakpoint unification the Sizer already applies. Column behaviour is unaffected.BlockPaginationhides itself on single-page result sets (2.13.0). Behaviour change: storefronts that showed the controls on single-page listings will stop doing so. SwitchshowOnSinglePageon in the block's Rules group to keep the old rendering — worth doing where a listing's height should not change as filters narrow the result set, since a control appearing and disappearing moves everything below it.- A
BrandHerowith a background colour and no image switches from the theme graphic to that colour (2.15.0). That is the reported bug, but the change is visible — clear the colour to keep the graphic. - Every listing and slider URL for products with a derived default variant changes (2.20.0), because the
?variant=leaves it. Explicitly shared links are untouched.
Cockpit (Studio) August 2026
Highlights
- Studio keeps where you were in the URL: your open page, selection and preview live in the URL — reload or share a link to return exactly where you were. The selected market and language are kept there too, so a reload or a shared link opens the same market and language instead of the project default.
- Markets have a status: Draft or Active, new markets start as Draft, and the market cards show Draft and Default badges. A project can choose its default market from its active markets, and the selector names the fallback used when none is chosen.
- Sorting in the query editor: set a sorting on a query to change the default order of its results.
- Link fields from a query result: a link field can now take its target from a query result, the same way media fields already can.
- An Abandoned Props panel in the Studio Devtools: a new panel reports stored props whose field a section or block no longer defines, or whose value no longer fits it — and can prune them.
- Redirects on the Vercel edge: a managed-Vercel deploy publishes its redirects to the Vercel edge, which answers them before any route in the deployment, so an old URL no longer costs a function invocation.
- The preview opens at the desktop frame (1920) instead of stretching to the width of the editor window.
Fixes & Improvements
- Fixed: importing a redirect CSV with more than 256 rows now works.
- Fixed: projects with more than 1000 redirects deploy every redirect, export the full list to CSV, and re-import without creating duplicates.
- Fixed: editing a query's limit, label, URL alias and input rules in Studio now saves.
- Fixed: Studio reports a rejected project secret and names the project the host serves, with a link to that project.
- Fixed: clearing a number field resets it to its default, and a field set to 0 shows the 0.
- Fixed: billing settings are now always available, so you can open the billing portal before a payment method has been added.
Find the full changelogs with all technical details at docs.laioutr.io/getting-started/changelogs.