Skip to main content
Version: 3.x

3.20 -> 3.21

This page lists the highlights for upgrading a project from Front-Commerce 3.20 to 3.21

Update dependencies

Update all your @front-commerce/* dependencies to this version:

pnpm update "@front-commerce/*@3.21.0"

Automated Migration

We provide a codemod to automatically update your codebase to the latest version of Front-Commerce. This tool will update your code when possible.

pnpm run front-commerce migrate --transform 3.21.0

Manual Migration

Node.js 20 is no longer supported

Node.js 20 reached its End of Life on April 30, 2026. Front-Commerce 3.21 drops support for it entirely.

Supported versions: ^22.22.0 || ^24.14.0

1. Update your Node.js version

Ensure your local environment, CI pipelines, and production servers run Node.js 22.22.0+ or Node.js 24.14.0+.

node -v
# Must output v22.22.x or v24.14.x (or higher)

2. Update your package.json

Update the engines field in your project's package.json:

package.json
"engines": {
- "node": "^20.19.0 || ^22.13.0 || ^24.11.0"
+ "node": "^22.22.0 || ^24.14.0"
}

3. Update your Docker images (if applicable)

If you use Docker for CI or deployment, update your Node.js base images:

Dockerfile
- FROM node:20-bookworm-slim
+ FROM node:22-bookworm-slim

4. Update @types/node (if applicable)

If your project has a direct dependency on @types/node, update it to match the new minimum version:

package.json
"devDependencies": {
- "@types/node": "20.19.39",
+ "@types/node": "22.19.17",
}

QuickOrder page refactored

The QuickOrder page (theme/pages/QuickOrder/QuickOrder) was refactored to be configurable. Defaults preserve the previous behavior, so a vanilla project does not need any change.

The following sections cover the cases where you need to do something:

For an overview of the new QuickOrderConfig API (CSV columns, picker, submission hook, configuration modal hook, etc.), see the Quick orders guide.

To change the page behavior from an extension without forking the page, the recommended path is to override the new useQuickOrderConfig hook instead:

my-extension/src/theme/pages/QuickOrder/useQuickOrderConfig.ts
import type { QuickOrderConfig } from "theme/pages/QuickOrder/defaultConfig";
import myQuickOrderConfig from "./myQuickOrderConfig";

const useQuickOrderConfig = (): QuickOrderConfig => myQuickOrderConfig;

export default useQuickOrderConfig;

If you override the QuickOrder page

If your project overrides theme/pages/QuickOrder/QuickOrder (the page, not the module), the recommended path is to drop your override and override the new useQuickOrderConfig hook instead (see above). If you still need a full page override, apply the following changes to your override file:

1. Read the active config, the configuration modal handle and the CSV error state at the top of the component

theme/pages/QuickOrder/QuickOrder.jsx
+ import { useState } from "react";
+ import useQuickOrderConfig from "theme/pages/QuickOrder/useQuickOrderConfig";

const QuickOrderPage = () => {
const intl = useIntl();
+ const config = useQuickOrderConfig();
+ const [csvError, setCsvError] = useState("");

const {
loading, entries, success, deleteEntry, addEmptyEntry, addToCart,
setEntries, isThereAtLeastOneValidEntry, onTodoUpdate, onQtyChange,
mergeEntries, productsErrors,
- } = useQuickOrder();
+ } = useQuickOrder(config);
+ const Picker = config.Picker;
+ const { needsConfiguration, useConfigurationModal } = config;
+ const { showFor, modalElement } = useConfigurationModal();

2. Pass the new props to the CSV buttons and render the CSV error

<DownloadTemplateButton
fileName="quick-order-template.csv"
- lines={[
- { sku: "sku1", quantity: "1" },
- { sku: "sku2", quantity: "2" },
- ]}
- headers={["sku", "quantity"]}
+ csvSampleLines={config.csvSampleLines}
+ csvColumns={config.csvColumns}
+ csvDelimiter={config.csvDelimiter}
+ csvUnparseOptions={config.csvUnparseOptions}
/>
<CsvUploadButton
- headers={["sku", "quantity"]}
+ csvColumns={config.csvColumns}
+ csvDelimiter={config.csvDelimiter}
onCsvUploaded={(newEntries) => {
+ setCsvError("");
setEntries(mergeEntries([...entries, ...newEntries]));
}}
+ onError={setCsvError}
/>
...
{success && <SuccessAlert>{success}</SuccessAlert>}
+ {csvError && <ErrorAlert>{csvError}</ErrorAlert>}

3. Render rows via config.Picker instead of the hardcoded <QuickOrder> module

- <QuickOrder
- sku={item.sku}
+ <Picker
+ entry={item}
quantity={item.qty}
...
/>

The picker now receives the full entry object instead of just a SKU, so any custom picker can read fields a CSV column added (for example a marketplace entry.offer_id).

4. Behaviour change on the onStatusUpdate guard (subtle)

The guard that gates onTodoUpdate widened. The previous version only fired on a status change; the new version also fires when the picker emits a valid status with a different product reference. This is what lets a custom picker push edits to an already-valid entry (for example a marketplace picker that bakes an offer into the resolved product after the modal validates).

onStatusUpdate={(status, product, statusText) => {
- if (!item.status || (item.status && item.status !== status)) {
+ if (item.status !== status || item.product !== product) {
onTodoUpdate(index, status, product, statusText);
}
}}

If your override depends on the old behaviour (an entry stayed sticky once it reached valid), align with the new guard.

5. Render the modal at the page level and pass openConfigurationModal to each <Picker>

The configuration modal (config.useConfigurationModal()) lives at the page level — render its modalElement once near the root, then pass showFor to every <Picker> instance via the new openConfigurationModal prop so a custom picker can auto-pop the modal at pick time.

return (
<div className="container">
+ {modalElement}
<div className="quick-order-page__header">
...
</div>
...
<Picker
entry={item}
...
+ openConfigurationModal={showFor}
/>

6. Render a "Configure" button on each row when needsConfiguration(entry) returns true

The page now decides per row whether a "Configure" button should be displayed. Plug it next to the existing "Delete" button, in the same wrapper that already renders the row picker:

- const DeleteWrapper = ({ children, onDelete }) => {
+ const LineWrapper = ({ children, onDelete, onConfigure }) => {
const intl = useIntl();
return (
<div className="quick-order-page__line">
<div className="quick-order-page__line-content">{children}</div>
+ {onConfigure && (
+ <Button onClick={onConfigure}>
+ {intl.formatMessage(messages.configure)}
+ </Button>
+ )}
<Button onClick={onDelete}>{intl.formatMessage(messages.delete)}</Button>
</div>
);
};

...

{entries.map((item, index) => {
+ const onConfigure = needsConfiguration(item)
+ ? () =>
+ showFor(item, (updatedEntry) =>
+ onTodoUpdate(
+ index,
+ updatedEntry.status ?? ENTRY_STATUS.VALID,
+ updatedEntry.product,
+ updatedEntry.statusText,
+ ),
+ )
+ : undefined;
...
- <DeleteWrapper onDelete={() => deleteEntry(item)}>
+ <LineWrapper onDelete={() => deleteEntry(item)} onConfigure={onConfigure}>
<Picker ... />
- </DeleteWrapper>
+ </LineWrapper>

A new pages.QuickOrder.configure translation key is also needed (defaults to "Configure").

useQuickOrder() now takes a config

If you call the hook directly, pass the config you want to use:

useQuickOrder consumer
- const result = useQuickOrder();
+ import { defaultQuickOrderConfig } from "theme/pages/QuickOrder/defaultConfig";
+ const result = useQuickOrder(defaultQuickOrderConfig);

CsvUploadButton / DownloadTemplateButton props

The headers prop is removed. Both components now take csvColumns and csvDelimiter so the CSV layout, the parsing rules and the template generation are all driven from a single source of truth.

CSV buttons consumer
- <CsvUploadButton headers={["sku", "quantity"]} onCsvUploaded={...} />
+ <CsvUploadButton
+ csvColumns={defaultQuickOrderConfig.csvColumns}
+ csvDelimiter={defaultQuickOrderConfig.csvDelimiter}
+ onCsvUploaded={...}
+ />

- <DownloadTemplateButton
- headers={["sku", "quantity"]}
- lines={[{ sku: "sku1", quantity: "1" }]}
- />
+ <DownloadTemplateButton
+ csvColumns={defaultQuickOrderConfig.csvColumns}
+ csvDelimiter={defaultQuickOrderConfig.csvDelimiter}
+ csvSampleLines={defaultQuickOrderConfig.csvSampleLines}
+ csvUnparseOptions={defaultQuickOrderConfig.csvUnparseOptions}
+ />

CsvUploadButton also gained an onError prop. CSV parsing errors are no longer rendered by the button itself: the page (or any consumer) gets the message via onError and decides how to display it.

CSV parsing now relies on papaparse, which requires a header row on line 1. Columns are matched by name (not position), so all required column headers must be present and any extra column in the CSV is rejected.

A column declared with required: false may be left empty on a row or omitted entirely from the header; the parser only flags the entry as invalid when a required column has no value (the default is required: true). This lets extensions add optional columns (for example a marketplace offer id) without forcing every line — or every uploaded file — to fill them.

Picker contract

The row component passed via config.Picker now receives the full entry as its entry prop, instead of the previous value scalar. Extensions can read any custom field they added (for example entry.offer_id) while the default picker keeps mapping entry.sku onto the standard <QuickOrder> module:

Picker consumer
- const MyPicker = ({ value, ...rest }) => <QuickOrder {...rest} sku={value} />;
+ const MyPicker = ({ entry, ...rest }) => <QuickOrder {...rest} sku={entry?.sku} />;

QuickOrder route gated by the quickOrder feature flag

The Quick Order page (/quick-order) and its navigation link in the theme-chocolatine header are now gated by a new quickOrder feature, exposed via the registerFeature hook. When the feature is inactive, the route returns a 404 and no link is rendered in the header.

@front-commerce/adobe-b2b and @front-commerce/gezy-marketplace enable quickOrder automatically — no action is required when one of these extensions is installed.

If your project uses /quick-order without either extension, enable the feature in your project's extension onFeaturesInit hook to preserve the previous behavior:

my-extension/src/index.ts
return defineRemixExtension({
// ...
unstable_lifecycleHooks: {
onFeaturesInit: (hooks) => {
hooks.registerFeature("quickOrder", { flags: { enabled: true } });
},
},
});

The flag is resolved at build time. Toggling it through an environment variable at runtime is not supported.

addOfferToCart mutation now takes an input object

Only relevant if you call the addOfferToCart mutation from @front-commerce/gezy-marketplace directly (custom theme, extension, or any consumer outside the standard theme — the theme-chocolatine call site has already been updated).

The mutation signature has changed to align with the new AddOfferToCartInput shape (and pave the way for the bulk addMultipleOffersToCart mutation):

addOfferToCart consumer
- mutation AddOfferToCart($sku: String!, $offer_id: String!, $qty: Int) {
- addOfferToCart(sku: $sku, offer_id: $offer_id, qty: $qty) {
+ mutation AddOfferToCart($input: AddOfferToCartInput!) {
+ addOfferToCart(input: $input) {
success
errorMessage
}
}

The sku argument is removed (it was unused — the V4 endpoint resolves the product from the offer). offer_id is now typed ID! instead of String!.

Order detail CSV download

The order detail page (/user/orders/:id) gained a Download CSV action, backed by a new Remix route _main.user.orders_.$id[.csv].tsx that serves the line items of the order using the shared CsvEntries codec.

If your project overrides theme/pages/Account/Orders/Details/OrderDetailsLayout/OrderActions, add the new button to your override so customers see the action:

theme/pages/Account/Orders/Details/OrderDetailsLayout/OrderActions.jsx
+ import OrderCsvDownload from "theme/pages/Account/Orders/Details/OrderDetailsLayout/OrderCsvDownload";

// ...

+ <div className="order-actions__action">
+ <OrderCsvDownload order={order} />
+ </div>
<div className="order-actions__action">
<RenewButton orderId={order.order_id} />
</div>

To customize the downloaded file (column layout, item-to-entry mapping, filename pattern), see the Customize the CSV format guide.

WYSIWYG widgets can declare block-level rendering

Optional — only relevant if your project registers custom Magento widgets that render block-level content (a <div>, a section, a grid…).

HTML forbids block-level elements inside a <p>. When a CMS author drops such a widget inside a paragraph, the browser closes the <p> before the widget and the server-rendered markup no longer matches the hydrated DOM, causing a hydration error (<div> cannot be a descendant of <p>).

registerWidgetType now accepts a blockLevel option. When a flagged widget is a direct child of a <p>, the WYSIWYG loader renders that paragraph as a <div> server-side, keeping the markup valid:

extensions/my-extension/modules/wysiwyg/runtime.ts
loaders.MagentoWidget.registerWidgetType(
"My\\Local\\Block\\Widget\\MultipleColumns",
"WidgetMultipleColumns",
+ { blockLevel: true }
);

blockLevel also accepts a (node) => boolean callback to decide from the widget payload. The default is false, so behaviour is unchanged until you opt in. See the WYSIWYG platform guide.

useApiFetcher exposes isValidating; loading is the initial load only

useApiFetcher (and the hooks built on it, such as useCart) now return loading as the initial-load signal only — it is no longer true during background revalidations. A new isValidating flag exposes the revalidation state for components that want to surface a background

The shipped MiniCartContent now opts into isValidating to keep avoiding a stale-data flash while the cart revalidates. If you override theme/modules/Cart/MiniCart/MiniCartContent/MiniCartContent, mirror the change:

theme/modules/Cart/MiniCart/MiniCartContent/MiniCartContent.jsx
- const { cart, loading, error } = useCart();
- if (loading || !cart) {
+ const { cart, loading, isValidating, error } = useCart();
+ if (loading || isValidating || !cart) {
return <MiniCartContentLoading />;
}

Storybook: mock useApiFetcher calls via MSW

Front-Commerce now ships a @front-commerce/storybook package that provides a Storybook preset to mock useApiFetcher calls in stories via MSW. Stories can declare handlers through parameters.msw.handlers.

Automated Migration

The 3.21.0 codemod patches your Storybook config for you (run the migration command from the top of this page):

pnpm run front-commerce migrate --transform 3.21.0

The codemod is idempotent and will warn if your addons value is not an inline array literal (for example, a variable reference or a spread). In that case, add "@front-commerce/storybook" manually to your addons list.

1. Install the package

Add @front-commerce/storybook as a dev dependency of your project:

pnpm add -D @front-commerce/storybook@3.21.0

2. Register the addon in your Storybook config

Add @front-commerce/storybook to the addons array in your .storybook/main.ts:

.storybook/main.ts
addons: [
getAbsolutePath("@chromatic-com/storybook"),
getAbsolutePath("storybook-react-intl"),
getAbsolutePath("@storybook/addon-docs"),
+ "@front-commerce/storybook",
],

3. Mock useApiFetcher calls in your stories (optional)

Stories that render components calling useApiFetcher (directly or via hooks such as useCart or useProductBySkuLoader) can now declare MSW handlers:

MyComponent.stories.tsx
import { http, HttpResponse } from "msw";

export const Default = {
parameters: {
msw: {
handlers: [
http.get("/api/products/:sku", () =>
HttpResponse.json({ name: "Sample product" })
),
],
},
},
};