Skip to content

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:

MethodDefault in the base classWhat it decides
resolve(array $props): array(abstract)The props the component receives
deferred(array $props): boolfalseLeave 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): boolfalseLeave 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 .graphqls file, 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 to var/log/system.log. The declaration is also what types the prop in Lynx.<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 provider key never reaches the browser.
  • Read the page context from Magento, as a block would: current_product and current_category from Magento\Framework\Registry, the store from StoreManagerInterface, 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! @optional
php
$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.

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 pageWho fetches it
Neither (default)Yes
deferred() returns trueNo, but announced in the pageThe browser, right after the page loads, one request per deferredGroup
optional() returns trueNo, and not announcedYour 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 ​

SituationUseWhy
Content at the top of the page or important for search: product detail, headings, a hero banner, the category gridSend 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 folddeferred()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 pagedeferred() 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 yetoptional()Not fetched at all unless the component asks with router.reload({ only: [_key] })
The merchant should decide per blockA 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 browserPage 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 _lynx and _lynx_only parameters 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 are undefined, 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:

  1. Declare a Boolean setting named lazy, and the data fields as @optional:

    graphql
    type 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!
    }
  2. Make the provider optional when it is on:

    php
    public function optional(array $props): bool
    {
        return !empty($props['lazy']);
    }
  3. In the component, request the data on mount while it is missing. Test a prop that resolve() always sets (here rows):

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, then resolveSample() 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.