Skip to main content
Version: 3.x

3.1 -> 3.2

This page lists the highlights for upgrading a project from Front-Commerce 3.1 to 3.2.

Update dependencies

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

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

Code changes

Remix entrypoints

In this release, we added new features that required to hook into your Remix application entrypoints created with the initial skeleton.
You must update these files in your application, as detailed below or copy the ones from the latest skeleton.

entry.worker.ts file
diff --git a/app/entry.worker.ts b/app/entry.worker.ts
index 84d71494..62c36c4c 100644
--- a/app/entry.worker.ts
+++ b/app/entry.worker.ts
@@ -1,4 +1,6 @@
/// <reference lib="WebWorker" />
+import type { WorkerDataFunctionArgs } from '@remix-pwa/sw'
+import { synchronizeStorefrontContentFromResponse } from 'theme/modules/StorefrontContent/serviceWorker'

export type {}
declare let self: ServiceWorkerGlobalScope
@@ -10,3 +12,14 @@ self.addEventListener('install', (event: ExtendableEvent) => {
self.addEventListener('activate', (event: ExtendableEvent) => {
event.waitUntil(self.clients.claim())
})
+
+export const defaultFetchHandler = async ({ request }: WorkerDataFunctionArgs) => {
+ const response = await fetch(request)
+ if (!response.ok) {
+ return response
+ }
+
+ synchronizeStorefrontContentFromResponse(response, self.clients)
+
+ return response
+}
diff --git a/app/theme/layouts/GenericLayout.jsx b/app/theme/layouts/GenericLayout.jsx
index 398d02c2..9825d487 100644
entry.server.ts file
diff --git a/app/entry.server.tsx b/app/entry.server.tsx
index bdb7e77a..4ece0e63 100644
--- a/app/entry.server.tsx
+++ b/app/entry.server.tsx
@@ -5,7 +5,7 @@
*/

import { PassThrough } from 'node:stream'
-import type { EntryContext } from '@remix-run/node'
+import type { AppLoadContext, DataFunctionArgs, EntryContext } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import isbot from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
@@ -13,6 +13,7 @@ import { FrontCommerceServer } from '@front-commerce/remix/react'
import prepareV2ServerRenderedApp from '@front-commerce/compat/apollo/prepareV2ServerRenderedApp'
import { loadTranslationMessages } from '@front-commerce/core'
import appManifest from '~/manifest'
+import { transformBrowserResponse, transformDataResponse, transformBotResponse } from '@front-commerce/remix'

const ABORT_DELAY = 5_000

@@ -20,14 +21,15 @@ export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
- remixContext: EntryContext
+ remixContext: EntryContext,
+ loadContext: AppLoadContext
) {
const shop = remixContext.staticHandlerContext.loaderData.root.shop
const messages = await loadTranslationMessages(shop.locale)

return isbot(request.headers.get('user-agent'))
- ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext, messages)
- : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext, messages)
+ ? handleBotRequest(request, responseStatusCode, responseHeaders, remixContext, loadContext, messages)
+ : handleBrowserRequest(request, responseStatusCode, responseHeaders, remixContext, loadContext, messages)
}

function handleBotRequest(
@@ -35,6 +37,7 @@ function handleBotRequest(
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
+ loadContext: AppLoadContext,
messages: Record<string, string>
) {
return new Promise(async (resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
await prepareV2ServerRenderedApp(
<FrontCommerceServer
+ context={loadContext.frontCommerce}
remixContext={remixContext}
messages={messages}
>
@@ -52,12 +55,12 @@ function handleBotRequest(

responseHeaders.set('Content-Type', 'text/html')

- resolve(
- new Response(body, {
- headers: responseHeaders,
- status: responseStatusCode,
- })
- )
+ const response = new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ transformBotResponse(response, loadContext.frontCommerce)
+ resolve(response)

pipe(body)
},
@@ -80,6 +83,7 @@ function handleBrowserRequest(
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
+ loadContext: AppLoadContext,
messages: Record<string, string>
) {
return new Promise(async (resolve, reject) => {
const { pipe, abort } = renderToPipeableStream(
await prepareV2ServerRenderedApp(
<FrontCommerceServer
+ context={loadContext.frontCommerce}
remixContext={remixContext}
messages={messages}
>
@@ -97,12 +101,12 @@ function handleBrowserRequest(

responseHeaders.set('Content-Type', 'text/html')

- resolve(
- new Response(body, {
- headers: responseHeaders,
- status: responseStatusCode,
- })
- )
+ const response = new Response(body, {
+ headers: responseHeaders,
+ status: responseStatusCode,
+ })
+ transformBrowserResponse(response, loadContext.frontCommerce)
+ resolve(response)

pipe(body)
},
@@ -119,3 +123,8 @@ function handleBrowserRequest(
setTimeout(abort, ABORT_DELAY)
})
}
+
+export function handleDataRequest(response: Response, { context }: DataFunctionArgs) {
+ transformDataResponse(response, context.frontCommerce)
+ return response
+}
root.tsx file
diff --git a/app/root.tsx b/app/root.tsx
index 71936d5a1..a28dcf87e 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -1,7 +1,7 @@
import { CompatScripts } from "@front-commerce/compat/CompatProvider";
import { FrontCommerceApp } from "@front-commerce/remix";
import { generateMetas, json } from "@front-commerce/remix/node";
-import { useLoaderData } from "@front-commerce/remix/react";
+import { FrontCommerceScripts } from "@front-commerce/remix/react";
import { cssBundleHref } from "@remix-run/css-bundle";
import type {
LinksFunction,
@@ -73,8 +73,6 @@ export const meta: MetaFunction = (args) => {
};

export default function App() {
- const { process } = useLoaderData<typeof loader>();
-
useSWEffect();

const navigation = useNavigation();
@@ -93,11 +91,7 @@ export default function App() {
<ScrollRestoration />
<Scripts />
<LiveReload />
- <script
- dangerouslySetInnerHTML={{
- __html: `window.process = ${JSON.stringify(process)}`,
- }}
- />
+ <FrontCommerceScripts />
<CompatScripts />
</body>
</html>

Front-Commerce post-install script

In this release, we added a post-install script in order to patch an issue with the current version of @remix-run/dev we are using. To cope with this, please ensure you applied have the following change in your package.json:

// ...
"scripts": {
"build": "front-commerce build",
"dev": "front-commerce dev --manual -c \"pnpm run dev:server\"",
"dev:debug": "front-commerce dev --manual -c \"pnpm run dev:server --inspect\"",
"dev:server": "tsx watch --ignore ./build/version.txt --ignore ./build/index.js --clear-screen=false -r tsconfig-paths/register server.ts",
"start": "cross-env NODE_ENV=production tsx -r tsconfig-paths/register ./server.ts",
"translate": "front-commerce translate ./app/**/*.{js,jsx,ts,tsx} --locale en",
"front-commerce": "front-commerce",
- "typecheck": "tsc"
+ "typecheck": "tsc",
+ "postinstall": "front-commerce postinstall"
},
// ...

Order filters

Breaking: In this release, we updated the order filters feature (released in 3.1.0) to support having filters fields linked by a "OR" logic. This changes required a change in our GraphQL API which will break your application if you're using this feature.

To cope with these changes, you will have to update the usage of orderList query in your application, specifically the format of the attributes argument. You should check for all components and / or routes you're using orderList query from, and fix the usage of attributes argument:

input OrderAttributesInput {
"The attribute's name"
name: String!
"A list of value for the attribute"
value: [String!]!
"The type of filtering to use for this attribute"
type: OrderAttributeInputType = CONTAINS
}

Became:

input OrderAttributesInput {
"The fields filtered"
fields: [String!]!
"A list of value for the attribute"
values: [String!]!
"The type of filtering to use for this attribute"
type: OrderAttributeInputType = CONTAINS
}

fields is now an array of fields the filter should be applied to.

Related commit: 4ef9368f.

Example

Given this attributes object:

[
{
"fields": ["id", "name"],
"value": ["Foo", "123"]
},
{
"fields": ["comment"],
"value": ["Bar", "456"]
}
]

The orders will be filtered with this logic:

(order.id contains "Foo"
OR order.id contains "123"
OR order.name contains "Foo"
OR order.name contains "123")

AND

(order.comment contains "Bar"
OR order.comment contains "456")

Since version 3.2.7

In this release, we fixed some issues related to the requisition lists in the theme.
If your project is using the requisition lists feature, you will need to update those files as detailed below, or copy the ones from the latest skeleton:

theme/modules/RequisitionList/AddCartToRequisitionList/AddCartToRequisitionList.jsx file
diff --git a/theme/modules/RequisitionList/AddCartToRequisitionList/AddCartToRequisitionList.jsx b/theme/modules/RequisitionList/AddCartToRequisitionList/AddCartToRequisitionList.jsx
index 3d7bcfc99..6c039d270 100644
--- a/theme/modules/RequisitionList/AddCartToRequisitionList/AddCartToRequisitionList.jsx
+++ b/theme/modules/RequisitionList/AddCartToRequisitionList/AddCartToRequisitionList.jsx
@@ -2,6 +2,7 @@ import { useMemo } from "react";
import PropTypes from "prop-types";
import AddToRequisitionList from "theme/modules/RequisitionList/AddToRequisitionList";
import { resolveSelectedOptions } from "theme/pages/Product/useSelectedProductWithConfigurableOptions";
+import { resolveSelectedBundleOptions } from "theme/pages/Product/useSelectedProductWithBundleOptions";
import {
productPropTypes,
selectedConfigurableOptionsPropTypes,
@@ -11,7 +12,7 @@ import {
const AddCartToRequisitionList = ({ id = "cart", cart, size }) => {
const cartItems = cart.items;
const items = useMemo(() => {
- return cartItems.map(({ product, options, qty }) => {
+ return cartItems.map(({ product, options, bundleOptions, qty }) => {
const productOptions = product.options?.map((option) => ({
id: option.attribute.id,
label: option.attribute.label,
@@ -23,6 +24,10 @@ const AddCartToRequisitionList = ({ id = "cart", cart, size }) => {
product.options,
options
),
+ selectedBundleOptions: resolveSelectedBundleOptions(
+ product.bundleOptions,
+ bundleOptions
+ ),
quantity: qty,
};
});
theme/modules/RequisitionList/AddProductToRequisitionList/AddProductToRequisitionList.jsx file
diff --git a/theme/modules/RequisitionList/AddProductToRequisitionList/AddProductToRequisitionList.jsx b/theme/modules/RequisitionList/AddProductToRequisitionList/AddProductToRequisitionList.jsx
index 0e5cc0958..d73bde2aa 100644
--- a/theme/modules/RequisitionList/AddProductToRequisitionList/AddProductToRequisitionList.jsx
+++ b/theme/modules/RequisitionList/AddProductToRequisitionList/AddProductToRequisitionList.jsx
@@ -34,6 +34,10 @@ const AddProductToRequisitionList = ({
showOptionsModalIfNotFullyConfigured={
showOptionsModalIfNotFullyConfigured
}
+ redirectOnAddToRequisitionList={
+ // URL to the bundle product
+ product.bundleOptions?.length ? `/product/${product.sku}` : null
+ }
/>
);
};
theme/modules/RequisitionList/AddToRequisitionList/AddToRequisitionList.jsx file
diff --git a/theme/modules/RequisitionList/AddToRequisitionList/AddToRequisitionList.jsx b/theme/modules/RequisitionList/AddToRequisitionList/AddToRequisitionList.jsx
index 13efd3b41..a79e5b025 100644
--- a/theme/modules/RequisitionList/AddToRequisitionList/AddToRequisitionList.jsx
+++ b/theme/modules/RequisitionList/AddToRequisitionList/AddToRequisitionList.jsx
@@ -5,6 +5,7 @@ import Icon from "theme/components/atoms/Icon";
import SelectMenu from "theme/components/molecules/SelectMenu";
import messages from "theme/modules/RequisitionList/AddToRequisitionList/AddToRequisitionListMessages";
import { useIntl } from "react-intl";
+import { useNavigate } from "@remix-run/react";

/** @type {import('./EnhanceAddToRequisitionList').BaseComponent} */
const AddToRequisitionList = ({
@@ -22,11 +23,24 @@ const AddToRequisitionList = ({
addToRequisitionListError,
newRequisitionListModal,
showOptionsModalIfNotFullyConfigured,
+ redirectOnAddToRequisitionList,
productConfigurationModal,
}) => {
const intl = useIntl();
const [isRequisitionListMenuOpen, setIsRequisitionListMenuOpen] =
useState(false);
+ const navigate = useNavigate();
+
+ const shouldRedirect =
+ showOptionsModalIfNotFullyConfigured &&
+ isRequisitionListMenuOpen &&
+ redirectOnAddToRequisitionList;
+
+ useEffect(() => {
+ if (shouldRedirect) {
+ navigate(redirectOnAddToRequisitionList);
+ }
+ }, [shouldRedirect]);

const selectItems = useMemo(() => {
if (!requisitionLists) {
theme/modules/RequisitionList/AddToRequisitionList/withAddMultipleItemsToRequisitionListMutation.jsx file
diff --git a/theme/modules/RequisitionList/AddToRequisitionList/withAddMultipleItemsToRequisitionListMutation.jsx b/theme/modules/RequisitionList/AddToRequisitionList/withAddMultipleItemsToRequisitionListMutation.jsx
index ff92ad7e1..e0ca0580e 100644
--- a/theme/modules/RequisitionList/AddToRequisitionList/withAddMultipleItemsToRequisitionListMutation.jsx
+++ b/theme/modules/RequisitionList/AddToRequisitionList/withAddMultipleItemsToRequisitionListMutation.jsx
@@ -75,20 +75,29 @@ const withAddMultipleItemsToRequisitionListMutation =
}) => ({
sku: product.sku,
quantity,
- selectedConfigurableOptions: Object.entries(
- selectedConfigurableOptions
- ).map(([option_id, option_value]) => ({
- option_id,
- option_value,
- })),
- selectedBundleOptions: Object.entries(
- selectedBundleOptions
- ).map(([option_id, { quantity, values }]) => {
- return {
- option_id,
- option_values: values.map((value) => ({ quantity, value })),
- };
- }),
+ selectedConfigurableOptions:
+ Object.keys(selectedConfigurableOptions || {}).length > 0
+ ? Object.entries(selectedConfigurableOptions).map(
+ ([option_id, option_value]) => ({
+ option_id,
+ option_value,
+ })
+ )
+ : undefined,
+ selectedBundleOptions:
+ Object.keys(selectedBundleOptions || {}).length > 0
+ ? Object.entries(selectedBundleOptions).map(
+ ([option_id, { quantity, values }]) => {
+ return {
+ option_id,
+ option_values: values.map((value) => ({
+ quantity,
+ value,
+ })),
+ };
+ }
+ )
+ : undefined,
})
),
},
theme/modules/RequisitionList/ProductConfigurationModal/ProductConfigurationModalContent.jsx file
diff --git a/theme/modules/RequisitionList/ProductConfigurationModal/ProductConfigurationModalContent.jsx b/theme/modules/RequisitionList/ProductConfigurationModal/ProductConfigurationModalContent.jsx
index 5883fae10..e0449a188 100644
--- a/theme/modules/RequisitionList/ProductConfigurationModal/ProductConfigurationModalContent.jsx
+++ b/theme/modules/RequisitionList/ProductConfigurationModal/ProductConfigurationModalContent.jsx
@@ -6,7 +6,7 @@ import { FormattedMessage } from "react-intl";
import useSelectedProductWithConfigurableOptions from "theme/pages/Product/useSelectedProductWithConfigurableOptions";
import ConfigurableOptions from "theme/modules/Cart/CartItem/CartItemOptionsUpdater/ConfigurableOptions";
import { H2 } from "theme/components/atoms/Typography/Heading";
-import Form from "theme/compat/components/atoms/Form/Form";
+import { Form } from "@remix-run/react";
import FormTitle from "theme/components/molecules/Form/FormTitle";
import useProductBySkuLoader from "theme/hooks/useProductBySkuLoader";
import Stack from "theme/components/atoms/Layout/Stack";
@@ -74,21 +74,22 @@ const ProductConfigurationModalContent = ({
selectedConfigurableOptions
);

- const formRef = useRef();
const [showNotAllOptionsSelected, setShowNotAllOptionsSelected] =
useState(false);

- const onChangeOptions = useCallback(() => {
- const model = formRef.current.getModel();
- Object.keys(model)
- .filter(
- (key) =>
- key.indexOf("custom:") !== 0 && typeof model[key] !== "undefined"
- )
- .forEach((optionId) =>
- setOption(optionId, model[optionId].value || model[optionId])
- );
- }, [setOption]);
+ const onChangeOptions = useCallback(
+ (e) => {
+ const input = e.target;
+ const form = input.form;
+ const data = new FormData(form);
+ for (const pair of data.entries()) {
+ if (pair[0].indexOf("custom:") !== 0) {
+ setOption(pair[0], pair[1]);
+ }
+ }
+ },
+ [setOption]
+ );

const allOptionsSet = useMemo(
() => selectedProduct && areAllOptionsSet(selectedProduct, selectedOptions),
@@ -119,8 +120,7 @@ const ProductConfigurationModalContent = ({

return (
<Form
- setRef={(form) => (formRef.current = form)}
- onValidSubmit={() => onConfiurationsSelected(selectedOptions)}
+ onSubmit={() => onConfiurationsSelected(selectedOptions)}
onChange={onChangeOptions}
>
<Stack>
theme/modules/Cart/CartItem/CartItemOptionsUpdater/CartItemOptionsUpdaterFragment.gql file
diff --git a/theme/modules/Cart/CartItem/CartItemOptionsUpdater/CartItemOptionsUpdaterFragment.gql b/theme/modules/Cart/CartItem/CartItemOptionsUpdater/CartItemOptionsUpdaterFragment.gql
index 19f85b84e..954bbb896 100644
--- a/theme/modules/Cart/CartItem/CartItemOptionsUpdater/CartItemOptionsUpdaterFragment.gql
+++ b/theme/modules/Cart/CartItem/CartItemOptionsUpdater/CartItemOptionsUpdaterFragment.gql
@@ -17,6 +17,14 @@ fragment CartItemCustomOption on Product {
id
}
}
+ bundleOptions {
+ id
+ label
+ values {
+ label
+ value
+ }
+ }
custom_options {
option_id
title