Appearance
Component Providers
A provider is a PHP class that turns the settings saved on a node into the props the component renders. It runs on the server for every page that shows the node: it can load products, read configuration, translate text, build URLs and tag the page for the full page cache.
A component without a provider receives its settings as they are. Add a provider when the component needs data from Magento, or when it has Localized…, Image or Url settings (Setting Types).
A first provider
The Highlight component from Creating Components has two translatable settings, and needs labels for nothing else:
php
<?php
declare(strict_types=1);
namespace Vendor\Module\Provider;
use OOCS\LynxComponent\Helper\LocalizedText;
use OOCS\LynxComponent\Model\AbstractComponentProvider;
class Highlight extends AbstractComponentProvider
{
public function __construct(
private readonly LocalizedText $localizedText,
) {
}
/**
* @param array $props the node's settings, defaults filled in
* @return array the props the component receives
*/
public function resolve(array $props): array
{
$props['title'] = $this->localizedText->get($props['title']);
$props['text'] = $this->localizedText->get($props['text']);
return $props;
}
}Name it in the provider argument of the component's @component:
graphql
type VendorHighlight @component(
code: "vendor_highlight"
template: "Vendor_Module::Components/Highlight"
provider: "Vendor\\Module\\Provider\\Highlight"
…
)Name the class itself, without \Proxy: the registry creates your provider, and everything it depends on, only on pages that actually contain the component.
The contract
A provider implements OOCS\LynxComponent\Api\ComponentProviderInterface. Extend OOCS\LynxComponent\Model\AbstractComponentProvider and override only what you need:
| Method | Default in the base class | What it decides |
|---|---|---|
resolve(array $props): array | (abstract) | The props the component receives |
deferred(array $props): bool | false | Leave the props out of the page and let the browser fetch them right after it loads |
deferredGroup(array $props): string | 'default' | Deferred props in the same group are fetched in one request |
optional(array $props): bool | false | Leave the props out of the page until the component asks for them |
Every method receives the node's props: the settings saved on the node with every declared default filled in. A provider can therefore decide per node, from a setting the merchant chose.
Things to know about resolve():
- Return the props you received, plus your data. Whatever you return is what the component gets; keys you drop are gone.
- Declare every key you add as a field of the component's type in its
.graphqlsfile, with nested types for structured data (Provider data). Keys that are not declared are dropped before the props reach the browser; in developer mode each one is logged tovar/log/system.log. The declaration is also what types the prop inLynx.<Type>. - It is memoized per request by component name and props. Two nodes of the same component with identical settings on one page resolve once.
- The
providerkey never reaches the browser. - Read the page context from Magento, as a block would:
current_productandcurrent_categoryfromMagento\Framework\Registry, the store fromStoreManagerInterface, the customer session and so on.
php
/** @var \Magento\Catalog\Model\Product|null $product */
$product = $this->registry->registry('current_product');
if (!$product) {
return $props;
}Text and labels
Shopper-facing text that the component shows (buttons, headings it adds, placeholders, aria-label, messages) comes from the provider in a labels prop, translated with __(). The component renders it and has no text of its own.
Declare it once on the component's type:
graphql
"Every string the component shows, translated by the provider"
labels: StringMap! @optionalphp
$props['labels'] = [
'addToCart' => (string) __('Add to Cart'),
// Values only the browser knows stay as %1, %2
'itemsOf' => (string) __('Items %1 to %2 of %3 total'),
];vue
<script setup lang="ts">
const props = withDefaults(defineProps<Lynx.VendorExample>(), { labels: () => ({}) })
</script>
<template>
<button class="l-btn l-btn-primary">{{ labels.addToCart }}</button>
<p>{{ (labels.itemsOf ?? '').replace('%1', String(from)).replace('%2', String(to)).replace('%3', String(total)) }}</p>
</template>- Reuse phrases Magento already has ("Sign In", "Add to Cart", "Items %1 to %2 of %3 total") so existing language packs translate them. Add new phrases to your module's
i18n/en_US.csv. - Name keys by role (
submit,empty), not by wording. - A component that has text but needs no data still gets a small provider that only returns
labels. - The labels of a deferred or optional provider arrive with its data. Text that must show on the first paint belongs to a provider that is not deferred.
Send only what the component will show
Send a prop, a label or a URL only when the component will show it: its setting is on and the store configuration allows the feature. The component shows the feature when the URL or the label is there, and nothing when it is not, so it has no rule of its own to repeat. Add to the props when the condition holds; do not build everything and remove it afterwards.
php
// The wish list button, while the node shows it and the Wish List is on
if ($props['show_wishlist'] && $this->wishlistHelper->isAllow()) {
$props['urls']['add_to_wishlist'] = $this->urlBuilder->getUrl('wishlist/index/add');
$props['labels'] += ['addToFavorites' => (string) __('Add to Wish List')];
}vue
<button v-if="urls.add_to_wishlist" type="button" @click="addToWishlist">{{ labels.addToFavorites }}</button>The built-in Product Detail, product cards and Compare Products work this way for the wish list and compare buttons, so a theme override reads those URLs as optional.
Images, links and translations
Settings of type Localized…, Image and Url are stored in a portable form. The provider resolves them for the store being rendered:
php
<?php
declare(strict_types=1);
namespace Vendor\Module\Provider;
use OOCS\LynxComponent\Helper\LocalizedText;
use OOCS\LynxComponent\Model\AbstractComponentProvider;
use OOCS\LynxThemeComponent\Helper\MediaUrl;
use OOCS\LynxThemeComponent\Helper\StoreLink;
class PromoTile extends AbstractComponentProvider
{
public function __construct(
private readonly LocalizedText $localizedText,
private readonly MediaUrl $mediaUrl,
private readonly StoreLink $storeLink,
) {
}
public function resolve(array $props): array
{
$props['title'] = $this->localizedText->get($props['title']);
$props['image'] = $this->mediaUrl->url((string) $props['image']);
$props['url'] = $this->storeLink->path((string) $props['url']);
return $props;
}
}MediaUrl and StoreLink are in OOCS_LynxThemeComponent. See Setting Types for what each one returns.
Deferred and optional data
Expensive data, or data that only some visitors look at, does not have to hold up the page.
| Sent with the page | Who fetches it | |
|---|---|---|
| Neither (default) | Yes | |
deferred() returns true | No, but announced in the page | The browser, right after the page loads, one request per deferredGroup |
optional() returns true | No, and not announced | Your component, with router.reload({ only: [_key] }) |
optional wins when both return true.
For a deferred or optional node, the server sends the node with its saved settings only, and adds a _key prop. When the data arrives, Lynx merges it over those props. The component does not have to know it is deferred: it sees props that are undefined at first and filled in later, and shows a placeholder meanwhile.
php
public function deferred(array $props): bool
{
// A toggle setting on the node
return !empty($props['deferred']);
}
public function deferredGroup(array $props): string
{
// Grouped with other recommendation blocks in one request
return 'recommendations';
}vue
<template>
<div v-if="products === undefined" class="l-skeleton h-64 w-full" aria-busy="true"></div>
<ul v-else>…</ul>
</template>Let the merchant choose
deferred() and optional() receive the node's props, so a setting can decide. Related, Up-sell and Cross-sell Products declare a toggle named deferred, on by default, and the provider returns it:
graphql
"On: fetched right after the page loads, so the page is sent sooner. Off: sent with the page and visible to search engines"
deferred: Boolean! @setting(label: "Load after the page", default: true)Declare the data fields of a deferred or optional provider with @optional: they are missing until the data arrives.
Without such a setting, return a constant: true when the block should always load after the page.
Which one to use
| Situation | Use | Why |
|---|---|---|
| Content at the top of the page or important for search: product detail, headings, a hero banner, the category grid | Send with the page (default) | It is part of the first paint and of the server-rendered HTML; deferring it delays the Largest Contentful Paint and hides it from crawlers that do not run JavaScript |
| Below the fold, expensive to compute, not needed for search: related products, recommendations, recently viewed, a reviews summary under the fold | deferred() | The page is sent and painted first; the browser fetches the block right after, without any code in the component |
| Several such blocks on one page | deferred() with the same deferredGroup() | They come back in one request instead of one each |
| Data only some shoppers look at: the content of a closed tab, a modal, a checkout step not reached yet | optional() | Not fetched at all unless the component asks with router.reload({ only: [_key] }) |
| The merchant should decide per block | A toggle setting read by deferred() (above) or the lazy convention (below) | Search visibility versus a faster page is a merchant's call |
| Data that depends on the shopper (cart, wishlist, name) | Neither: read it from customer data in the browser | Page props, deferred ones included, go through the full page cache |
Things to keep in mind:
- Deferred data is not in the server-rendered HTML. With SSR on, the HTML shows the placeholder, and the content appears when the browser has fetched it.
- Every deferred group and every optional reload is one more request. Each request goes through the full page cache (the
_lynxand_lynx_onlyparameters keep it apart from the page), so it is cheap after the first visitor, but do not split a page into many small groups. - Always render a placeholder of about the final size (
l-skeleton) while the props areundefined, so the page does not jump when the data arrives. - Labels of a deferred or optional provider arrive with its data. Text that must show on the first paint belongs to a provider that is not deferred.
Load when shown: the lazy convention
The built-in product page blocks (Description, Specifications, Reviews) let the merchant choose between "send with the page" (visible to search engines) and "load when shown" (faster page, fetched when the block appears, for example when its tab is opened). To do the same:
Declare a
Booleansetting namedlazy, and the data fields as@optional:graphqltype ProductSpecs @component(…) { "Off: sent with the page and visible to search engines. On: fetched only when the block appears, for example when its tab is opened" lazy: Boolean! @setting(label: "Load when shown", default: false) rows: [ProductSpecsRow!]! @optional } type ProductSpecsRow { label: String! value: String! }Make the provider optional when it is on:
phppublic function optional(array $props): bool { return !empty($props['lazy']); }In the component, request the data on mount while it is missing. Test a prop that
resolve()always sets (hererows):
vue
<!-- Specs.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { router } from '@inertiajs/vue3'
const props = withDefaults(defineProps<Lynx.ProductSpecs>(), {
rows: undefined,
_key: '',
})
onMounted(() => {
if (props.lazy && props.rows === undefined && props._key) {
router.reload({ only: [props._key], preserveUrl: true })
}
})
</script>
<template>
<div v-if="rows === undefined" class="grid gap-3" aria-busy="true">
<span v-for="n in 3" :key="n" class="l-skeleton block h-4 w-full"></span>
</div>
<dl v-else-if="rows.length" class="divide-y divide-base-300">
<div v-for="row in rows" :key="row.label" class="flex gap-6 py-3.5">
<dt class="w-32 shrink-0 font-semibold text-base-content/70">{{ row.label }}</dt>
<dd class="text-base-content">{{ row.value }}</dd>
</div>
</dl>
</template>Inside a Tab, "on mount" means when the tab is opened. If your block can have nothing to show (a feature turned off in configuration), return enabled: false and do not make it optional in that case: Tabs then hides the tab from the first paint.
The complete example is OOCS\LynxCatalog\Provider\Product\Specs with Components/Product/Specs.vue in OOCS_LynxCatalog.
Limitation: one key per component
The key a deferred or optional node is fetched under is the component's code (_key holds it). Two nodes of the same component on one page share that key, and the one resolved last wins. A component that can appear several times on one page, such as a carousel, should not be deferred or optional when it is placed more than once.
Full page cache
Lynx pages are cached by Magento's full page cache. When your provider shows entity data, add that entity's cache tags to the page with OOCS\LynxComponent\Model\PageCacheIdentities, so saving the entity refreshes the pages that show it:
php
use Magento\Catalog\Model\Product;
use OOCS\LynxComponent\Model\PageCacheIdentities;
// in resolve(), after loading $products
$identities = [Product::CACHE_TAG];
foreach ($products as $product) {
$identities[] = Product::CACHE_TAG . '_' . $product->getId();
}
$this->pageCacheIdentities->add($identities);The request that fetches a deferred or optional node is cached on its own, so add the tags inside resolve(), which runs in that request too. A block on the product page that shows the current product only needs to tag the product when it is lazy: in the page response, the product page already carries those tags.
Caching expensive data
For data that is costly to build and the same for many pages (a store list, a menu, aggregated reports), use the Lynx Component Data cache type, OOCS\LynxComponent\Model\Cache\Type\ComponentCache. Merchants can clean it in System › Cache Management, and you can tag entries with entity tags so they clear when the entity changes.
php
<?php
declare(strict_types=1);
namespace Vendor\Module\Provider;
use Magento\Framework\Serialize\SerializerInterface;
use Magento\Store\Model\Store;
use Magento\Store\Model\StoreManagerInterface;
use OOCS\LynxComponent\Model\AbstractComponentProvider;
use OOCS\LynxComponent\Model\Cache\Type\ComponentCache;
class StoreList extends AbstractComponentProvider
{
private const CACHE_LIFETIME = 86400;
public function __construct(
private readonly StoreManagerInterface $storeManager,
private readonly ComponentCache $cache,
private readonly SerializerInterface $serializer,
) {
}
public function resolve(array $props): array
{
$key = 'vendor_store_list_' . $this->storeManager->getStore()->getCode();
$cached = $this->cache->load($key);
if ($cached !== false) {
$props['stores'] = $this->serializer->unserialize($cached);
return $props;
}
$props['stores'] = $this->buildList();
$this->cache->save($this->serializer->serialize($props['stores']), $key, [Store::CACHE_TAG], self::CACHE_LIFETIME);
return $props;
}
private function buildList(): array
{
// …
return [];
}
}Include in the cache key everything the data depends on (store, currency, customer group…). Do not cache per-visitor data.
Sample data in the editor preview
Some pages cannot be previewed with real data: the order success page needs an order that was just placed, the sign-in page redirects a visitor who is signed in. Lynx previews such pages with sample data. On those pages, a provider that implements OOCS\LynxComponent\Api\SampleProviderInterface gets a second step:
php
<?php
declare(strict_types=1);
namespace Vendor\Module\Provider;
use OOCS\LynxComponent\Api\SampleProviderInterface;
use OOCS\LynxComponent\Model\AbstractComponentProvider;
class OrderNote extends AbstractComponentProvider implements SampleProviderInterface
{
public function resolve(array $props): array
{
// real data from the session…
return $props;
}
/**
* @param array $resolved what resolve() returned, plugins included
* @param array $props the node's settings, for any that resolve() dropped
* @return array
*/
public function resolveSample(array $resolved, array $props): array
{
$resolved['order_number'] = 'SAMPLE-000000123';
// Endpoints are '#' in a sample preview; the component sends nothing to '#'
$resolved['urls'] = array_map(static fn (): string => '#', $resolved['urls'] ?? []);
return $resolved;
}
}- Lynx calls
resolve()first, thenresolveSample()with its result and the original props. Overwrite only what the session or database cannot provide in the preview, and remove side effects (analytics events, for example). - Providers without the interface resolve normally in the preview, with the admin browser's real session.
- Sample data is used only on the pages that the editor marks as (Sample) in its page list; everywhere else the preview shows real data.