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:
"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:
- 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:
"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:
- you override
theme/pages/QuickOrder/QuickOrder— see If you override the page; - you call
useQuickOrder()directly — seeuseQuickOrder()now takes a config; - you render
<CsvUploadButton>or<DownloadTemplateButton>directly — seeCsvUploadButton/DownloadTemplateButtonprops; - you provide a custom
Pickercomponent to the page (via the new config) — seePickercontract.
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:
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
+ 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:
- 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.
- <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:
- 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:
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):
- 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:
+ 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:
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:
- 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.
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:
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:
import { http, HttpResponse } from "msw";
export const Default = {
parameters: {
msw: {
handlers: [
http.get("/api/products/:sku", () =>
HttpResponse.json({ name: "Sample product" })
),
],
},
},
};