From b6af81d8a6d080bee8d0139decbe560fdd966d38 Mon Sep 17 00:00:00 2001 From: mkucmus Date: Mon, 14 Sep 2026 17:11:08 +0200 Subject: [PATCH 01/15] fix: use cacheable GET reads only for fresh default guest sessions --- .changeset/calm-reviews-read.md | 5 + .changeset/fresh-guests-read.md | 5 + .changeset/quiet-tokens-stay.md | 5 + .changeset/shared-carts-stay.md | 5 + AGENTS.md | 18 +- apps/docs/src/best-practices/caching.md | 63 ++-- packages/api-client/README.md | 4 + packages/api-client/src/createAPIClient.ts | 14 + .../api-client/src/createApiClient.test.ts | 70 +++++ .../src/resolveRequestHeaders.test.ts | 10 + .../api-client/src/resolveRequestHeaders.ts | 8 +- .../CmsElementProductDescriptionReviews.vue | 26 +- .../src/createShopwareContext.test.ts | 3 + .../composables/src/createShopwareContext.ts | 8 + packages/composables/src/index.ts | 4 + .../cacheableReadCoverage.test.ts | 100 ++++++ .../useCacheableRead/useCacheableRead.test.ts | 291 ++++++++++++++++++ .../src/useCacheableRead/useCacheableRead.ts | 189 ++++++++++++ .../useCategorySearch.test.ts | 16 +- .../useCategorySearch/useCategorySearch.ts | 55 ++-- .../src/useCountries/useCountries.test.ts | 14 +- .../src/useCountries/useCountries.ts | 15 +- .../useInternationalization.test.ts | 1 + .../useInternationalization.ts | 9 +- .../src/useNavigation/useNavigation.test.ts | 8 +- .../src/useNavigation/useNavigation.ts | 51 +-- .../useNavigationSearch.test.ts | 7 +- .../useNavigationSearch.ts | 15 +- .../useProductConfigurator.spec.ts | 7 +- .../useProductConfigurator.ts | 15 +- .../useProductReviews.test.ts | 7 +- .../useProductReviews/useProductReviews.ts | 28 +- .../useProductSearch/useProductSearch.test.ts | 26 +- .../src/useProductSearch/useProductSearch.ts | 35 +-- .../src/useSalutations/useSalutations.spec.ts | 1 + .../src/useSalutations/useSalutations.ts | 8 +- .../useShopwareContext/useShopwareContext.ts | 10 + .../composables/src/useUser/useUser.test.ts | 32 +- packages/composables/src/useUser/useUser.ts | 25 +- packages/nuxt-module/plugin.test.ts | 32 ++ packages/nuxt-module/plugin.ts | 3 + .../vue-demo-store/app/pages/wishlist.vue | 13 +- templates/vue-starter-template/README.md | 2 +- .../components/form/CountrySearchSelect.vue | 13 +- .../app/components/form/SalutationSelect.vue | 7 +- 45 files changed, 1002 insertions(+), 281 deletions(-) create mode 100644 .changeset/calm-reviews-read.md create mode 100644 .changeset/fresh-guests-read.md create mode 100644 .changeset/quiet-tokens-stay.md create mode 100644 .changeset/shared-carts-stay.md create mode 100644 packages/composables/src/useCacheableRead/cacheableReadCoverage.test.ts create mode 100644 packages/composables/src/useCacheableRead/useCacheableRead.test.ts create mode 100644 packages/composables/src/useCacheableRead/useCacheableRead.ts diff --git a/.changeset/calm-reviews-read.md b/.changeset/calm-reviews-read.md new file mode 100644 index 0000000000..cdc1c03720 --- /dev/null +++ b/.changeset/calm-reviews-read.md @@ -0,0 +1,5 @@ +--- +"@shopware/cms-base-layer": patch +--- + +`CmsElementProductDescriptionReviews` loads reviews through `useCacheableRead().invokeRead`, so only a fresh default guest reads them from the cacheable GET route. diff --git a/.changeset/fresh-guests-read.md b/.changeset/fresh-guests-read.md new file mode 100644 index 0000000000..0d65ddca7f --- /dev/null +++ b/.changeset/fresh-guests-read.md @@ -0,0 +1,5 @@ +--- +"@shopware/composables": minor +--- + +Added `useCacheableRead().invokeRead`. With `cacheableReads` on, it sends a read as the cacheable GET route, without `sw-context-token`, only for a fresh default guest; every other session stays POST. Branching on `useShopwareContext().cacheableReads` is deprecated. `createShopwareContext` takes a new `guestServerRender` option. diff --git a/.changeset/quiet-tokens-stay.md b/.changeset/quiet-tokens-stay.md new file mode 100644 index 0000000000..1c2695e2b9 --- /dev/null +++ b/.changeset/quiet-tokens-stay.md @@ -0,0 +1,5 @@ +--- +"@shopware/api-client": patch +--- + +A request header set to an empty string is no longer sent, so one call can drop a default header such as `sw-context-token`. A response to a request that leaves out the client's `sw-context-token` no longer replaces it. diff --git a/.changeset/shared-carts-stay.md b/.changeset/shared-carts-stay.md new file mode 100644 index 0000000000..dc58f4fdf0 --- /dev/null +++ b/.changeset/shared-carts-stay.md @@ -0,0 +1,5 @@ +--- +"@shopware/nuxt-module": patch +--- + +The cart state is now provided app-wide, and the plugin tells `createShopwareContext` when the server render runs without the visitor's session (`guestServerRender`). Cacheable reads use both to decide between GET and POST. diff --git a/AGENTS.md b/AGENTS.md index 0d9176584c..04c027688b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,20 +85,18 @@ The full reference is [Best practices: Caching](apps/docs/src/best-practices/cac The essentials for code changes: - **`cacheableReads` (request layer)** — an opt-in context flag (default - `false`) that switches a defined set of anonymous read composables from POST to - the cacheable GET variant of the Store API. Wired `nuxt.config` + `false`) that lets a defined set of anonymous reads use the cacheable GET + variant of the Store API. Wired `nuxt.config` (`shopware: { cacheableReads: true }`) → `createShopwareContext` → - `useShopwareContext()`. GET-over-POST is a Shopware platform decision: POST + `useCacheableRead()`. GET-over-POST is a Shopware platform decision: POST bodies are not HTTP-cacheable, so reads compress the Criteria into a `_criteria` query param via `encodeForQuery` from `@shopware/api-client/helpers` (JSON → gzip → base64url, matching the backend `RequestCriteriaBuilder`). - - When adding or editing a read composable, branch on `cacheableReads` and call - the GET route with `query: { _criteria: encodeForQuery(criteria) }`; keep the - POST variant as the `else`. Mutations always stay POST/PATCH. Where a GET - variant does not declare `_criteria` in the generated types yet, there is one - precedent for a local intersection type — `useCategorySearch.search`. Which - reads have already moved shifts as the platform ships GET variants, so read - that from the composables rather than from a list here. + - For a read, call `useCacheableRead().invokeRead` with the POST operation; it + sends GET only for a fresh default guest, without the token. Never branch on + `cacheableReads`. Add a new GET twin to the registry in + `packages/composables/src/useCacheableRead/`; the coverage test there + enforces both. Mutations always stay POST/PATCH. - **`routeRules` (render layer)** — page-level caching lives in each template's `nuxt.config.ts`: `isr` for catalog/content, `ssr: false` for personalized routes (`/checkout`, `/account/**`), immutable `Cache-Control` for static diff --git a/apps/docs/src/best-practices/caching.md b/apps/docs/src/best-practices/caching.md index 9c8a41b201..2942c31481 100644 --- a/apps/docs/src/best-practices/caching.md +++ b/apps/docs/src/best-practices/caching.md @@ -36,9 +36,9 @@ ISR and route-rule caching are honored only by deployment targets that support t ## Request layer: `cacheableReads` -`cacheableReads` is the headline new capability. It is an opt-in boolean flag (default `false`) that switches a defined set of read composables from POST to the cacheable GET variants of the Store API. POST responses are not cached by CDNs, reverse proxies, or browsers in practice (HTTP allows it only when explicitly marked, which shared caches generally ignore), whereas GET responses are cacheable by default. Enabling the flag is what makes that possible from the frontend side. +`cacheableReads` is the headline new capability. It is an opt-in boolean flag (default `false`) that lets a defined set of read composables use the cacheable GET variants of the Store API instead of POST. POST responses are not cached by CDNs, reverse proxies, or browsers in practice (HTTP allows it only when explicitly marked, which shared caches generally ignore), whereas GET responses are cacheable by default. Enabling the flag is what makes that possible from the frontend side. -When enabled, the affected composables encode the Shopware Criteria object into a `_criteria` query parameter (gzip + base64url, via `encodeForQuery` from `@shopware/api-client/helpers`) and call the GET route instead of sending a POST body. +When enabled, and only for a session that equals a fresh default guest, these composables encode the Shopware Criteria object into a `_criteria` query parameter (gzip + base64url, via `encodeForQuery` from `@shopware/api-client/helpers`) and call the GET route instead of sending a POST body. All other sessions keep using POST, see [Which sessions use GET](#which-sessions-use-get). ### Why GET over POST (the architectural decision) @@ -46,7 +46,7 @@ Routing reads through GET is a deliberate Shopware platform decision, not just a The `_criteria` query parameter exists to remove that constraint. Its encoding - JSON -> gzip -> base64url - is defined by the platform, and `encodeForQuery` in `@shopware/api-client/helpers` implements precisely that format, keeping the Criteria small enough to travel in the URL for typical reads (very large criteria can still exceed environment URL-length limits). On the backend, `RequestCriteriaBuilder` decodes `_criteria` and rebuilds the same Criteria it would have parsed from a POST body, so the GET and POST variants return identical data. This is a transitional design: the interim approach until the HTTP `QUERY` method (a cacheable method that carries a body) is standardized. -A route can only be migrated to GET once its GET variant declares `_criteria` in the OpenAPI schema, because the generated, typed client is built from that schema. That schema gap - not a runtime limitation - is the only reason the reads listed further down stay on POST; the backend already honors `_criteria` on those GET routes at runtime. +The generated types do not declare `_criteria` on every GET route yet. That does not block a route: `useCacheableRead` builds the GET call itself. The reads listed further down stay on POST because they are not migrated yet, or on purpose. ### Enabling it @@ -71,25 +71,24 @@ const shopware = createShopwareContext(app, { app.use(shopware); ``` -Inside a composable the flag is read from the Shopware context and used to branch the request: +Inside a composable, do not read the flag. Call `useCacheableRead()` at setup and pass the POST operation and its params to `invokeRead`. It picks GET or POST on each request and returns the same type as `apiClient.invoke` for that operation: ```ts -import { encodeForQuery } from "@shopware/api-client/helpers"; - -const { apiClient, cacheableReads } = useShopwareContext(); +const { invokeRead } = useCacheableRead(); -const result = cacheableReads - ? await apiClient.invoke("readCountryGet get /country", { - query: { _criteria: encodeForQuery(criteria) }, - }) - : await apiClient.invoke("readCountry post /country", { - body: criteria, - }); +async function fetchCountries() { + const result = await invokeRead("readCountry post /country", { + body: criteria, + }); + return result.data; +} ``` +A read can use GET only when its route is in the registry in `useCacheableRead`. A coverage test next to it fails when a read with a GET twin is in neither the registry nor its POST-only list, or when code calls a registered route without `invokeRead`. + ### Which reads switch to GET -Exactly these composables gain a GET branch when `cacheableReads` is enabled: +Exactly these composables read through `invokeRead`: - `useNavigation` - `useNavigationSearch` (`resolvePath`) @@ -99,21 +98,33 @@ Exactly these composables gain a GET branch when `cacheableReads` is enabled: - `useInternationalization` (`getAvailableLanguages`) - `useProductConfigurator` - `useProductSearch` (single product detail) -- `useCategorySearch.advancedSearch` (category list) +- `useProductReviews` +- `useCategorySearch` (`search` for a single category, `advancedSearch` for the category list) -::: tip -The flag is a blanket GET/POST switch per composable, not a runtime authentication check. Even account-related lookups such as `useUser.loadCountry`/`loadSalutation` use GET when the flag is on. "Anonymous" here means the data is public reference or catalog data suitable for shared HTTP caching, not that the code inspects the login state. Whether a response is actually cached, and how it is scoped per user, is governed by the Shopware backend cache rules and your CDN configuration. -::: +### Which sessions use GET + +`invokeRead` checks the session on every request. It sends GET only when the session equals a fresh default guest: no customer (a guest account counts as one), an empty cart, and the sales channel's default language, currency, country, payment method and shipping method. When it cannot tell, it sends POST. + +The GET is sent without `sw-context-token`, so the backend answers as it would for a fresh guest. The cached entry can never hold another visitor's data or token. Every other session sends the same POST as with the flag off. + +The check needs the cart. With `useUserContextInSSR: true` the server render does not load it, so server-side reads stay POST. Outside Nuxt, provide `swSessionContext` and `swCart` refs on the app yourself, or reads stay POST once a context token exists. + +The check only sees what the frontend has loaded. It cannot see: + +- plugins that extend the cache hash, or cookies added to `shopware.http_cache.cookies` +- changes made in another tab with the same token +- state that changed but is not refreshed in the frontend yet + +In those cases one session can get default guest data. Other visitors are never affected. ### Which reads stay on POST, and why -A few read paths intentionally stay on POST because the generated Store API schema does not type the `_criteria` parameter on their GET route: +These reads have a GET twin but are not migrated yet: -- `useListing` (product listing) - always `readProductListing post /product-listing/{categoryId}` -- `useCategorySearch.search` (single category) - always `readCategory post /category/{navigationId}` +- `useListing` (product listing and search) - always `readProductListing post /product-listing/{categoryId}` or `searchPage post /search`, tracked in [#2691](https://github.com/shopware/frontends/issues/2691). Shopware core [PR #17204](https://github.com/shopware/shopware/pull/17204) declared `_criteria` on `GET /store-api/product-listing` (released in 6.7.12.0). - `useLandingSearch` - always `readLandingPage post /landing-page/{landingPageId}` -As those GET schemas gain `_criteria` typing upstream, these reads can migrate too. Product listing is the first: Shopware core [PR #17204](https://github.com/shopware/shopware/pull/17204) declared `_criteria` on `GET /store-api/product-listing` (released in 6.7.12.0), so `useListing` can switch to the cacheable GET variant once the Store API types are regenerated against that schema. +Reads that depend on the session, such as payment and shipping methods, stay on POST on purpose. The coverage test holds the full POST-only list, with a reason for each route. Write and auth/context mutations (login, register, logout, `readCustomer`, `updateContext`, checkout) also stay on POST/PATCH regardless of the flag, because they are mutations and are not cacheable by design. @@ -244,13 +255,13 @@ The frontend layers stop at producing cacheable requests. Whether a GET response This is where `cacheableReads` pays off. By switching reads to GET with a deterministic `_criteria` URL, the request layer produces cacheable requests; the backend reverse proxy then applies `Cache-Control`, cache tags, `sw-cache-hash`, and invalidation. None of that is handled by the frontend `@shopware/api-client` - it only forwards Shopware Store API headers (`sw-access-key`, `sw-context-token`, `sw-language-id`, and so on) and refreshes the context token from non-public response headers. It reads `Cache-Control` only to ignore `sw-context-token` on publicly cacheable responses; it does not set `Cache-Control` or handle cache tags or `sw-cache-hash`. -When a route is cacheable, Shopware marks it with the `_httpCache` route attribute and the `CacheResponseSubscriber` emits a public `Cache-Control` header (the documented default for cacheable Store API routes is `public, max-age=0, s-maxage=1800, stale-while-revalidate=86400, stale-if-error=7200`; non-cacheable routes get `no-cache, private`). Cache entries are scoped per context: the backend sets `sw-language-id`, `sw-currency-id`, and `sw-context-hash` response headers and adds them to `Vary`, so a reverse proxy or CDN stores separate entries per language, currency, and login/rule state. Invalidation reuses Shopware's existing cache tags. See the [HTTP cache concept](https://developer.shopware.com/docs/concepts/framework/http_cache.html) and the [Store API cache strategy](https://developer.shopware.com/docs/resources/references/adr/2025-09-15-store-api-cache-strategy.html) for the full model. +When a route is cacheable, Shopware marks it with the `_httpCache` route attribute and the `CacheResponseSubscriber` emits a public `Cache-Control` header (the documented default for cacheable Store API routes is `public, max-age=0, s-maxage=1800, stale-while-revalidate=86400, stale-if-error=7200`; non-cacheable routes get `no-cache, private`). The backend sets `sw-language-id`, `sw-currency-id`, and `sw-cache-hash` response headers and adds them to `Vary`. Invalidation reuses Shopware's existing cache tags. See the [HTTP cache concept](https://developer.shopware.com/docs/concepts/framework/http_cache.html) and the [Store API cache strategy](https://developer.shopware.com/docs/resources/references/adr/2025-09-15-store-api-cache-strategy.html) for the full model. A few consequences follow from how the backend cache works: -- A request carrying an active `sw-context-token` (cart/session) is typically treated as non-cacheable by the backend. This is standard HTTP-cache behavior, and it is one reason personalized routes use `ssr: false`. +- A `sw-context-token` does not keep a GET response out of a shared cache. The cache looks up entries by URL and `Vary` headers, and the token is not one of them. This is why `invokeRead` sends GET only for fresh default guests. - If the backend responds with `no-store`/`no-cache`, nothing is cached regardless of using GET. -- Enabling `cacheableReads` without a backend that supports the GET read routes and the `_criteria` parameter yields no caching benefit (the calls still succeed, they just are not cached). +- `cacheableReads` needs Shopware 6.7.6 or newer. Before that, the GET routes return `405` or ignore `_criteria`, so keep the flag off on older backends. To configure the backend cache, follow the [Shopware reverse HTTP cache guide](https://developer.shopware.com/docs/guides/hosting/infrastructure/reverse-http-cache.html). The `_criteria` GET support is tracked in [Shopware issue #12388](https://github.com/shopware/shopware/issues/12388), referenced directly in the `encodeForQuery` source. diff --git a/packages/api-client/README.md b/packages/api-client/README.md index 5cc8b21a2d..85de24d9eb 100644 --- a/packages/api-client/README.md +++ b/packages/api-client/README.md @@ -418,6 +418,10 @@ async function loadMainNavigation() { } ``` +### Dropping a default header per request + +Set a header to an empty string to leave a default header out of one request, for example `headers: { "sw-context-token": "" }`. The header is removed, not sent empty. A response to a request that leaves out the client's `sw-context-token` does not replace it. + ### Uploading files (`multipart/form-data`) and other binary bodies Some endpoints accept binary uploads sent as `multipart/form-data` - for example the Admin API `uploadV2 post /_action/media/upload`. For these requests, build a [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) instance and pass it as `body`: diff --git a/packages/api-client/src/createAPIClient.ts b/packages/api-client/src/createAPIClient.ts index a1fdc6b2ab..96e9f236ca 100644 --- a/packages/api-client/src/createAPIClient.ts +++ b/packages/api-client/src/createAPIClient.ts @@ -120,11 +120,20 @@ export function createAPIClient< let currentAccessToken = params.accessToken; function createFetchClient(baseURL: string | undefined) { + // checked at send time, the token can change before the response arrives + const requestsWithoutClientToken = new WeakSet(); + return ofetch.create({ baseURL, ...params.fetchOptions, async onRequest(context) { apiClientHooks.callHook("onRequest", context); + if ( + defaultHeaders["sw-context-token"] && + !context.options.headers.has("sw-context-token") + ) { + requestsWithoutClientToken.add(context.options); + } }, async onResponse(context) { apiClientHooks.callHook("onSuccessResponse", context.response); @@ -136,6 +145,11 @@ export function createAPIClient< if (isPubliclyCacheableResponse(context.response)) { return; } + // A request sent without the client's token gets a fresh guest token + // back, which must not replace the client's session. + if (requestsWithoutClientToken.has(context.options)) { + return; + } if ( context.response.headers.has("sw-context-token") && defaultHeaders["sw-context-token"] !== diff --git a/packages/api-client/src/createApiClient.test.ts b/packages/api-client/src/createApiClient.test.ts index 33bb7d1557..55c2652c9a 100644 --- a/packages/api-client/src/createApiClient.test.ts +++ b/packages/api-client/src/createApiClient.test.ts @@ -249,6 +249,76 @@ describe("createAPIClient", () => { ); }); + it("should NOT adopt sw-context-token from a response to a request sent without it", async () => { + const app = createApp().use( + "/language", + eventHandler(async (event) => { + setHeader(event, "sw-context-token", "fresh-guest-token"); + return { elements: [] }; + }), + ); + + const baseURL = await createPortAndGetUrl(app); + const contextChangedMock = vi.fn().mockImplementation(() => {}); + + const client = createAPIClient({ + accessToken: "123", + contextToken: "logged-in-token", + baseURL, + }); + client.hook("onContextChanged", contextChangedMock); + + await client.invoke("readLanguagesGet get /language", { + // @ts-expect-error sw-context-token is not a typed header of this endpoint + headers: { "sw-context-token": "" }, + }); + + expect(contextChangedMock).not.toHaveBeenCalled(); + expect(client.defaultHeaders["sw-context-token"]).toEqual( + "logged-in-token", + ); + }); + + it("should adopt sw-context-token from requests sent before the client had one", async () => { + let releaseLogin = () => {}; + const loginReleased = new Promise((resolve) => { + releaseLogin = resolve; + }); + const app = createApp() + .use( + "/context", + eventHandler(async (event) => { + setHeader(event, "sw-context-token", "first-token"); + return {}; + }), + ) + .use( + "/account/login", + eventHandler(async (event) => { + await loginReleased; + setHeader(event, "sw-context-token", "login-token"); + return {}; + }), + ); + + const baseURL = await createPortAndGetUrl(app); + + const client = createAPIClient({ + accessToken: "123", + baseURL, + }); + + const login = client.invoke("loginCustomer post /account/login", { + body: { username: "user", password: "pass" }, + }); + await client.invoke("readContext get /context"); + expect(client.defaultHeaders["sw-context-token"]).toEqual("first-token"); + releaseLogin(); + await login; + + expect(client.defaultHeaders["sw-context-token"]).toEqual("login-token"); + }); + it("should NOT invoke onContextChanged method when no context header is set in response", async () => { const app = createApp().use( "/context", diff --git a/packages/api-client/src/resolveRequestHeaders.test.ts b/packages/api-client/src/resolveRequestHeaders.test.ts index 88efff3644..e5a2264af3 100644 --- a/packages/api-client/src/resolveRequestHeaders.test.ts +++ b/packages/api-client/src/resolveRequestHeaders.test.ts @@ -21,6 +21,16 @@ describe("resolveRequestHeaders", () => { expect(headers.accept).toBe("application/json"); }); + it("drops a header set to an empty string instead of sending it empty", () => { + const headers = resolveRequestHeaders( + { "sw-context-token": "" }, + { ...DEFAULTS, "sw-context-token": "token" }, + undefined, + ); + expect(headers).not.toHaveProperty("sw-context-token"); + expect(headers.accept).toBe("application/json"); + }); + it("keeps the default when there is no body", () => { const headers = resolveRequestHeaders(undefined, DEFAULTS, undefined); expect(contentTypeOf(headers)).toBe("application/json"); diff --git a/packages/api-client/src/resolveRequestHeaders.ts b/packages/api-client/src/resolveRequestHeaders.ts index 72bce04b8c..eb5f026a26 100644 --- a/packages/api-client/src/resolveRequestHeaders.ts +++ b/packages/api-client/src/resolveRequestHeaders.ts @@ -27,6 +27,12 @@ function withoutContentType(headers: ClientHeaders): ClientHeaders { ); } +function withoutEmptyValues(headers: ClientHeaders): ClientHeaders { + return Object.fromEntries( + Object.entries(headers).filter(([, value]) => value !== ""), + ); +} + /** * A `multipart/form-data` is only usable with a non-empty `boundary`, and the * value may be quoted (`boundary="a;b"`). Read the value out rather than @@ -100,7 +106,7 @@ export function resolveRequestHeaders( defaultHeaders: ClientHeaders, body: unknown, ): ClientHeaders { - const mergedHeaders = defu(callerHeaders, defaultHeaders); + const mergedHeaders = withoutEmptyValues(defu(callerHeaders, defaultHeaders)); // The caller's Content-Type wins over the default, regardless of header // casing (`defu` merges case-sensitively, so both could otherwise survive). diff --git a/packages/cms-base-layer/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue b/packages/cms-base-layer/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue index 5b90705b65..9637684735 100644 --- a/packages/cms-base-layer/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue +++ b/packages/cms-base-layer/app/components/public/cms/element/CmsElementProductDescriptionReviews.vue @@ -1,5 +1,4 @@