Skip to content

Checkout Steps ​

The Lynx checkout is one page made of steps: Address, Shipping Method and Payment out of the box, always in that order. Each step is an ordinary component with a provider. A module can add a step of its own, and the merchant places it in PageBuilder, in one of the two slots of the Checkout component between the built-in steps.

This page explains how the container and the steps work together, the contract a step implements, and builds a Delivery Instructions step as an example.

Before you add a step, check whether something smaller fits:

You needUse
Input that belongs to one carrier (a pickup store, a delivery date for that carrier)A shipping renderer under its rate
Input that belongs to one payment methodA payment renderer
Content next to the checkout (badges, notes)A component in the Below Order Summary slot
A question every shopper answers before payingA checkout step (this page)

Claude Code

The Lynx Claude Code skills can add a checkout step for you, following this page. See Working with Claude Code.

How the checkout is put together ​

Checkout (default_checkout)
├── express                 express payment buttons
│   Address                 (default_checkout_step_shipping_address, rendered by the container)
├── before_shipping_method  extra steps, in slot order
│   Shipping Method         (default_checkout_step_shipping_method, rendered by the container)
├── before_payment          extra steps, in slot order
│   Payment                 (default_checkout_step_payment, rendered by the container)
└── sidebar_after           content below the order summary

The container (OOCS_LynxCheckout::Components/Checkout and the checkout store) owns the flow. A step owns its card.

The containerA step
Reads the flow (steps prop: the built-in steps with the visible nodes of each slot between them) and the URL that answers for them (steps_url)Registers itself with the store when it mounts
Decides which step is current, starts at the first step that takes part, moves forward and backDraws its card: heading, form while current, a collapsed summary once done, nothing else while not reached
Fetches the target step's props from the server and saves the step before itValidates its own input and calls nextStep() when the shopper presses Continue
Keeps the shared draft (email, addresses, shipping and payment method) in localStorage, per visitorContributes its own data to what is posted on every step request
Shows the order summary, coupon, terms and the express buttonsShows its own inline errors
Gives the last step a place below the cards for its main action ([data-checkout-actions], where Payment puts Place Order)

What happens when the shopper presses Continue ​

  1. The step validates its input in the browser. If something is wrong, it shows the error and stops.

  2. It calls checkout.nextStep(). The store marks the next step as current at once (the previous card collapses to its summary) and POSTs to steps_url (/lynx/checkout/process):

    json
    {
        "form_key": "…",
        "step": "vendor_checkout_step_delivery_notes",
        "steps": ["default_checkout_step_shipping_address", "default_checkout_step_shipping_method", "vendor_checkout_step_delivery_notes", "default_checkout_step_payment"],
        "data": { "shippingAddress": { … }, "shippingMethod": "flatrate_flatrate", "vendorDeliveryInstructions": "…" }
    }

    steps lists only the steps that take part (a skipped step is left out). data is the merge of every taking-part step's data().

  3. Controller\Checkout\Process looks up the provider of step in the component registry. If there is none, or its optional() is not true, it answers 404 and nothing is saved.

  4. It finds the step right before the target in steps. If that step's provider implements OOCS\LynxCheckout\Api\CheckoutStepInterface, it calls save($data) on it.

  5. It calls resolve($data) on the target step's provider and answers its props as JSON. The store puts them on the page under the step's node name, and the step component receives them as props.

  6. If save() or resolve() throws a LocalizedException, the answer is { "error": "…" } alone. The store shows the message as an error message and moves the shopper back to the step before the target.

  7. When the saved step registered affectsTotals, the store reads the totals again.

The same request runs whenever a step becomes current: on page load for the first step, on Edit of a finished step, and again for the current step when an Inertia visit on the page drops its props. In every case the step saved is the one right before the target, not necessarily the one the shopper was looking at. Going back to the first step saves nothing.

Payment is not saved through this request: the payment step sends the method and billing address with Magento's payment-information REST call when the order is placed.

The step contract ​

Server: the provider ​

A step's provider extends OOCS\LynxComponent\Model\AbstractComponentProvider like any other (Component Providers). Two things are specific to steps:

MethodRequiredWhen it runsWhat it gets
optional(array $props): boolYes, return trueOn the page, to leave the step's props out; on every step request, as the check in step 3 aboveThe node's settings on the page, the posted draft in a step request
resolve(array $props): arrayYesOnly in a step request, when the step becomes currentThe posted draft (data), not the node's settings
save(array $draft): void (CheckoutStepInterface)Only if the step writes to the quoteIn the request that fetches the step after itThe posted draft, the same array resolve() gets
  • Every step must be optional. Process refuses a step whose provider is missing or not optional, and then also skips saving the step before it. A step with no data of its own still needs a small provider that returns true from optional() and its labels from resolve().
  • $draft holds every step's data, not only yours: the shipping address under shippingAddress, the method under shippingMethod, your keys under the names your data() used. It comes from the browser, so check types and values as you would any request input.
  • Make save() idempotent and cheap. It runs each time the step after it is fetched, including after Edit and back navigation. Compare with what the quote already holds and return early, as the built-in steps do.
  • Throw LocalizedException with a translated message to reject the data. The shopper is sent back to your step and sees the message.
  • The quote is the checkout session's quote (Magento\Checkout\Model\Session::getQuote()), for guests and customers alike. Check getId() and isVirtual() before writing.

The built-in providers are the reference: Provider\Checkout\Step\ShippingAddress (validates and assigns the shipping address, unless its signature matches the quote's), Provider\Checkout\Step\ShippingMethod (saves shipping information, unless the quote already has that method) and Provider\Checkout\Step\Payment (no save(), lists the payment methods).

Client: the component ​

A step gets the checkout store with useCheckoutStore() from OOCS_LynxCheckout/stores/checkoutStore and calls register() once in <script setup>. The store finds the step's node name by itself, from the surrounding node.

register(registration) takes:

KeyTypeMeaning
titlestringThe step's name in the previous step's Continue to … button
skipbooleantrue when the step does not apply to this cart. It is left out of the flow and of steps in requests. The shipping steps pass checkout.isVirtual
affectsTotalsbooleantrue when saving the step changes the totals, so the summary is read again once the shopper has left it
data() => Record<string, unknown>What the step adds to data in every step request. Use keys that cannot clash with other modules

It returns the step's handle. The flags are computed refs (step.isDone.value).

MemberMeaning
indexThe step's position. Put it on the card's root as data-step: the store scrolls to that element and focuses its h2 when the step becomes current
isCurrentThe shopper is on this step: show the form and the Continue button
isReachedCurrent or done
isDoneThe shopper has moved past it: show the collapsed summary with an Edit button
loadingThe step's props are on their way
continueLabelThe text for the Continue button: "Continue to next step's title", or "Continue"
numberThe step's place among the steps the cart goes through, from 1; 0 while it takes no part. Put it before the heading when checkout.appearance.stepNumbering is on

Store members a step uses:

MemberMeaning
nextStep() / prevStep()Move to the next or previous step that takes part
goTo(index)Move to a step, for the Edit button: checkout.goTo(step.index)
labelsThe container's translated labels, available from the first paint (step headings, continue, continueTo)
isVirtualThe cart has nothing to ship
draftThe shared draft. Its shape is fixed (CheckoutDraft); keep your own values in the component
stepCardClassClasses of the step's root, card or flat as the merchant set Step Style on the Checkout component
stepButtonClassClasses of the built-in Continue button, following Continue Button Width
appearanceThe Checkout component's step settings: stepNumbering, plus what the built-in steps read
inputClass(invalid)Classes of the built-in text inputs
refreshTotals()Read the totals again, if your step changes them outside a step request

Errors. Show inline errors in the step and mark them with data-checkout-error, as the built-in fields do; the built-in steps scroll the first one into view with document.querySelector('[data-checkout-error]'). Errors from save() arrive as an error message from the store.

Props. On the page, the step node carries only its saved settings and _key. The provider's props arrive with the step request, so props from resolve() are undefined until the step is first reached. The heading must show from the first paint, before that request, so it cannot come from the step's own labels; the built-in steps read it from the container's checkout.labels. A module adds its heading there with a plugin on the container's provider (see the example).

Registering the component ​

Declare the step in an etc/components/<Template>.graphqls file of your module, like any component (Creating Components), with these arguments of @component:

ArgumentValue
groupcheckout_step, the only group the Before Shipping Method and Before Payment slots accept
availableOn["checkout_index_index"], so the step is offered only on the checkout page
zones["content"]
providerYour provider class

The code is also the node name the store and Process look the step up by. Each slot holds a component once (max: 1 on the slot). Declare every prop resolve() returns as @optional, since the props are undefined until the step is first reached: Process passes the step's props through the .graphqls declaration like a page render does, so an undeclared key never reaches the component.

Example: Delivery Instructions ​

Vendor_DeliveryNotes adds a step between Shipping Method and Payment: a text area for instructions to the courier and a "leave the package at the door" checkbox. The values are saved on the quote and copied to the order.

app/code/Vendor/DeliveryNotes/
├── etc/
│   ├── module.xml
│   ├── db_schema.xml
│   ├── fieldset.xml
│   ├── components/DeliveryNotes.graphqls  ← the step component and its props
│   └── frontend/di.xml            ← the heading plugin
├── i18n/en_US.csv
├── Plugin/AddStepHeading.php
├── Provider/DeliveryNotes.php
├── registration.php
└── view/frontend/lynx/
    └── vue/src/Components/Checkout/Step/DeliveryNotes.vue

Module and storage ​

etc/module.xml loads after the checkout module. In composer.json, require oocscommerce/module-lynx-checkout.

xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Vendor_DeliveryNotes">
        <sequence>
            <module name="OOCS_LynxCheckout"/>
        </sequence>
    </module>
</config>

Storing the values is plain Magento and your module's job: one column per value on quote and on sales_order, etc/db_schema.xml:

xml
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
    <table name="quote">
        <column xsi:type="text" name="vendor_delivery_instructions" nullable="true" comment="Delivery Instructions"/>
        <column xsi:type="smallint" name="vendor_leave_at_door" unsigned="true" nullable="false" default="0"
                comment="Leave At Door"/>
    </table>
    <table name="sales_order">
        <column xsi:type="text" name="vendor_delivery_instructions" nullable="true" comment="Delivery Instructions"/>
        <column xsi:type="smallint" name="vendor_leave_at_door" unsigned="true" nullable="false" default="0"
                comment="Leave At Door"/>
    </table>
</schema>

Generate db_schema_whitelist.json with bin/magento setup:db-declaration:generate-whitelist --module-name=Vendor_DeliveryNotes.

Magento copies quote fields to the order when the order is placed, for the fields listed in the sales_convert_quote fieldset. etc/fieldset.xml:

xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:DataObject/etc/fieldset.xsd">
    <scope id="global">
        <fieldset id="sales_convert_quote">
            <field name="vendor_delivery_instructions">
                <aspect name="to_order"/>
            </field>
            <field name="vendor_leave_at_door">
                <aspect name="to_order"/>
            </field>
        </fieldset>
    </scope>
</config>

Showing the values in the admin order view, emails or the REST API is also up to your module, as for any order attribute.

The provider ​

Provider/DeliveryNotes.php:

php
<?php
declare(strict_types=1);

namespace Vendor\DeliveryNotes\Provider;

use Magento\Checkout\Model\Session as CheckoutSession;
use Magento\Framework\Exception\LocalizedException;
use Magento\Quote\Api\CartRepositoryInterface;
use OOCS\LynxCheckout\Api\CheckoutStepInterface;
use OOCS\LynxComponent\Model\AbstractComponentProvider;

class DeliveryNotes extends AbstractComponentProvider implements CheckoutStepInterface
{
    private const MAX_LENGTH = 250;
    private const FIELD_INSTRUCTIONS = 'vendor_delivery_instructions';
    private const FIELD_LEAVE_AT_DOOR = 'vendor_leave_at_door';

    public function __construct(
        private readonly CheckoutSession $checkoutSession,
        private readonly CartRepositoryInterface $quoteRepository,
    ) {
    }

    /**
     * Fetched when the shopper reaches the step; every step must be optional
     *
     * @param array $props
     * @return bool
     */
    public function optional(array $props): bool
    {
        return true;
    }

    /**
     * What the quote holds, so going back or reloading shows the saved values
     *
     * @param array $props the posted draft
     * @return array
     */
    public function resolve(array $props): array
    {
        $quote = $this->checkoutSession->getQuote();

        $props['instructions'] = (string) $quote->getData(self::FIELD_INSTRUCTIONS);
        $props['leave_at_door'] = (bool) $quote->getData(self::FIELD_LEAVE_AT_DOOR);
        $props['max_length'] = self::MAX_LENGTH;
        $props['labels'] = [
            'instructions' => (string) __('Instructions for the courier'),
            'leaveAtDoor'  => (string) __('Leave the package at the door'),
            'tooLong'      => (string) __('Please enter less or equal than %1 symbols.', self::MAX_LENGTH),
            'none'         => (string) __('None'),
            'edit'         => (string) __('Edit'),
        ];

        return $props;
    }

    /**
     * Write the step's values to the quote, unless it already holds them
     *
     * @param array $draft
     * @return void
     * @throws LocalizedException
     */
    public function save(array $draft): void
    {
        $instructions = $draft['vendorDeliveryInstructions'] ?? null;
        $quote = $this->checkoutSession->getQuote();

        if (!\is_string($instructions) || !$quote->getId() || $quote->isVirtual()) {
            return;
        }

        $instructions = trim($instructions);
        $leaveAtDoor = !empty($draft['vendorLeaveAtDoor']) ? 1 : 0;

        if (mb_strlen($instructions) > self::MAX_LENGTH) {
            throw new LocalizedException(__('Please enter less or equal than %1 symbols.', self::MAX_LENGTH));
        }

        if ((string) $quote->getData(self::FIELD_INSTRUCTIONS) === $instructions
            && (int) $quote->getData(self::FIELD_LEAVE_AT_DOOR) === $leaveAtDoor
        ) {
            return;
        }

        $quote->setData(self::FIELD_INSTRUCTIONS, $instructions !== '' ? $instructions : null);
        $quote->setData(self::FIELD_LEAVE_AT_DOOR, $leaveAtDoor);
        $this->quoteRepository->save($quote);
    }
}

The phrases "Please enter less or equal than %1 symbols.", "None" and "Edit" are Magento's own; add the two new ones to i18n/en_US.csv, together with the heading:

csv
"Delivery Instructions","Delivery Instructions"
"Instructions for the courier","Instructions for the courier"
"Leave the package at the door","Leave the package at the door"

The heading ​

The heading shows before the step is reached, so it goes into the container's labels. Plugin/AddStepHeading.php:

php
<?php
declare(strict_types=1);

namespace Vendor\DeliveryNotes\Plugin;

use OOCS\LynxCheckout\Provider\Checkout\Checkout;

class AddStepHeading
{
    /**
     * Add the step's heading to the container's labels
     *
     * @param Checkout $subject
     * @param array $result
     * @return array
     */
    public function afterResolve(Checkout $subject, array $result): array
    {
        $result['labels']['deliveryInstructions'] = (string) __('Delivery Instructions');

        return $result;
    }
}

etc/frontend/di.xml:

xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="OOCS\LynxCheckout\Provider\Checkout\Checkout">
        <plugin name="vendor_deliverynotes_step_heading" type="Vendor\DeliveryNotes\Plugin\AddStepHeading"/>
    </type>
</config>

Registering the step ​

etc/components/DeliveryNotes.graphqls:

graphql
"Instructions for the courier and a leave-at-door choice, saved on the order"
type VendorCheckoutStepDeliveryNotes @component(
  code: "vendor_checkout_step_delivery_notes"
  template: "Vendor_DeliveryNotes::Components/Checkout/Step/DeliveryNotes"
  provider: "Vendor\\DeliveryNotes\\Provider\\DeliveryNotes"
  label: "Delivery Instructions"
  group: "checkout_step"
  icon: "ph-note-pencil"
  zones: ["content"]
  availableOn: ["checkout_index_index"]
) {
  instructions: String! @optional
  leave_at_door: Boolean! @optional
  max_length: Int! @optional
  "Every string the step shows, translated by the provider"
  labels: StringMap! @optional
}

The components take their props type, Lynx.VendorCheckoutStepDeliveryNotes, from this declaration.

The component ​

The card follows the built-in steps: the heading always, the form while current, a summary with Edit once done. The values live in the component and are seeded from the quote each time the provider answers.

vue
<!-- DeliveryNotes.vue -->
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useCheckoutStore } from 'OOCS_LynxCheckout/stores/checkoutStore'

// From Provider\DeliveryNotes, undefined until the step is first reached
const props = withDefaults(defineProps<Lynx.VendorCheckoutStepDeliveryNotes>(), {
    instructions: undefined,
    leave_at_door: false,
    max_length: 250,
    labels: () => ({}),
})

const checkout = useCheckoutStore()
const form = reactive({ instructions: '', leaveAtDoor: false })
const error = ref('')

const step = checkout.register({
    title: checkout.labels.deliveryInstructions ?? '',
    skip: checkout.isVirtual,
    data: () => ({ vendorDeliveryInstructions: form.instructions.trim(), vendorLeaveAtDoor: form.leaveAtDoor }),
})

// Seed from the quote whenever the provider answers
watch(
    () => [props.instructions, props.leave_at_door] as const,
    ([instructions, leaveAtDoor]) => {
        if (instructions === undefined) return
        form.instructions = instructions
        form.leaveAtDoor = leaveAtDoor
    },
    { immediate: true }
)

function submit() {
    error.value = form.instructions.trim().length > props.max_length ? (props.labels.tooLong ?? '') : ''
    if (!error.value) checkout.nextStep()
}
</script>

<template>
    <div
        v-if="!checkout.isVirtual"
        :data-step="step.index"
        :class="checkout.stepCardClass"
    >
        <h2
            class="text-lg font-semibold text-base-content"
            :class="step.isReached.value ? 'pb-3 mb-4 border-b border-base-300' : ''"
        >
            <template v-if="checkout.appearance.stepNumbering && step.number.value">{{ step.number.value }}. </template
            >{{ checkout.labels.deliveryInstructions }}
        </h2>

        <div
            v-if="step.isDone.value"
            class="flex items-start justify-between gap-3 rounded-box border-(length:--border) border-base-300 p-3"
        >
            <div class="text-base text-base-content/80">
                <p v-if="form.leaveAtDoor" class="font-semibold text-base-content">{{ labels.leaveAtDoor }}</p>
                <p v-if="form.instructions" class="whitespace-pre-line">{{ form.instructions }}</p>
                <p v-if="!form.leaveAtDoor && !form.instructions">{{ labels.none }}</p>
            </div>
            <button
                type="button"
                class="-m-2 p-2 bg-transparent border-none cursor-pointer text-sm font-semibold text-link"
                @click="checkout.goTo(step.index)"
            >
                {{ labels.edit }}
            </button>
        </div>

        <template v-else-if="step.isCurrent.value">
            <div v-if="step.loading.value" class="l-skeleton h-32 w-full" aria-busy="true"></div>

            <div v-else class="grid gap-4">
                <label class="grid gap-1">
                    <span class="text-base font-semibold text-base-content">{{ labels.instructions }}</span>
                    <textarea
                        v-model="form.instructions"
                        rows="3"
                        :maxlength="max_length"
                        :class="['l-textarea w-full', error ? 'l-textarea-error' : '']"
                    ></textarea>
                </label>
                <p v-if="error" data-checkout-error class="text-sm text-error">{{ error }}</p>

                <label class="flex items-center gap-3 cursor-pointer">
                    <input
                        v-model="form.leaveAtDoor"
                        type="checkbox"
                        class="l-checkbox l-checkbox-xs l-checkbox-primary shrink-0"
                    />
                    <span class="text-base text-base-content">{{ labels.leaveAtDoor }}</span>
                </label>
            </div>

            <button type="button" :class="checkout.stepButtonClass" :disabled="step.loading.value" @click="submit">
                {{ step.continueLabel.value }}
            </button>
        </template>
    </div>
</template>

The browser check and the maxlength attribute are for the shopper's convenience; save() enforces the limit.

To report the step to analytics, track an event of your own on the analytics bus in submit() before nextStep(), as the shipping method step does with add_shipping_info. See Forms and Analytics.

Install, build and place the step ​

bash
bin/magento module:enable Vendor_DeliveryNotes
bin/magento setup:upgrade
bin/magento lynx:theme:setup OOCS_LynxVue                      # adds the module to the build
bin/magento lynx:static:deploy                                # also writes the Lynx.* props types from the .graphqls
bin/magento lynx:compile                                      # when the store uses compiled configuration
bin/magento cache:clean

The step shows once the merchant places it. In PageBuilder, open the Checkout page, select the Checkout component, and use Add to Before Payment to add Delivery Instructions. The default design is read-only, so this is done in the merchant's own design (Designs). Put this in your installation guide; do not write the merchant's checkout layout from a data patch, it belongs to their design.

Modifying the built-in steps ​

The built-in steps are not layout nodes: the Checkout component renders them in their fixed order, and their declarations are hidden: true, kept only for Process to find their providers. Their look follows the Checkout component's Steps settings. You do not have to replace a built-in step to change it.

  • Add or change data: an afterResolve plugin on the step's provider (OOCS\LynxCheckout\Provider\Checkout\Step\ShippingAddress, ShippingMethod or Payment) adds props or changes labels. The component receives whatever resolve() returns.
  • Save more when a step is left: a beforeSave plugin on ShippingAddress or ShippingMethod reads your keys from $draft and writes them to the quote before the step's own save. In-Store Pickup's OOCS\LynxMSI\Plugin\SavePickupLocation does this on ShippingMethod (Saving the renderer's data). Payment has no save().
  • Input for one carrier or payment method: a shipping renderer under the rate (with validate, data and summary) or a payment renderer.
  • Change the markup: override the step's .vue file in a theme (Overriding components). Keep the register() call, data-step on the root, and the data() keys the providers read.
  • Address fields: configured by the merchant in Stores › Attributes › Customer Address Fields, see Address form fields.

Limits and things to know ​

  • Pick the slot by what the step reads. In Before Shipping Method the address is already on the quote; in Before Payment the carrier is saved too and the totals are collected again. Payment is always last: it has no Continue button, and Place Order appears below the cards as soon as it is reached.
  • Every step in the flow must register. The flow starts once every step listed in steps has called register(), so call it even when the step does not apply, with skip: true. A component that never registers keeps the checkout from starting. A step whose component is not in the build is left out of steps and skipped.
  • One node per step. Each slot holds a component once, and the store and Process find a step by its code. A hidden node (eye icon) is not part of the flow.
  • Virtual carts. The container sends is_virtual; the shipping steps register with skip: checkout.isVirtual and render nothing. Do the same if your step is about shipping, and check isVirtual() in save() too.
  • Node settings do not reach the provider. resolve() and save() get the posted draft, and on the page the step's provider is not resolved. Settings that need a provider to resolve (_i18n, image, url) are therefore not usable on a step; plain settings reach the component as props.
  • The draft is the container's. Only the built-in fields are kept in localStorage. After a reload the checkout starts again at the first step; your step shows what the quote holds once it is reached again, so values typed but not yet saved are lost.
  • Guests and customers. The same provider runs for both; read the quote from the checkout session. The shared draft in localStorage is dropped when it belongs to a different visitor.
  • Steps load their own code. Load everything through import. The checkout pins the hash of every chunk (Subresource integrity on the checkout), and external scripts need your module's csp_whitelist.xml and the page nonce (checkout.cspNonce), as described in Payment Methods.
  • Style with design tokens and the l- controls, smallest text text-sm (Styling).
  • Editor preview. The checkout preview uses the admin browser's own cart, with real data: add a product to the cart first. Steps added in the editor appear in the preview before the layout is saved, and the step requests save to that real quote.

Testing checklist ​

  • Guest checkout, and signed in with saved addresses.
  • A virtual cart: the shipping steps and any shipping-related step of yours drop out, and the Continue to … labels follow.
  • Edit on each finished step, then Continue again: the values are kept and save() does no unnecessary writes.
  • A value your save() rejects: the message shows and the shopper is back on your step.
  • Reload in the middle of the checkout: the draft is restored and your step shows the saved values when reached.
  • The order placed carries your values (here, sales_order.vendor_delivery_instructions).
  • The editor preview of the checkout page with the step added.