diff --git a/.changeset/calm-reviews-read.md b/.changeset/calm-reviews-read.md new file mode 100644 index 0000000000..a3634763bd --- /dev/null +++ b/.changeset/calm-reviews-read.md @@ -0,0 +1,5 @@ +--- +"@shopware/cms-base-layer": patch +--- + +`CmsElementProductDescriptionReviews` loads reviews with `useCacheableRead().invokeRead`. diff --git a/.changeset/fresh-guests-read.md b/.changeset/fresh-guests-read.md new file mode 100644 index 0000000000..dd0c82bf7f --- /dev/null +++ b/.changeset/fresh-guests-read.md @@ -0,0 +1,5 @@ +--- +"@shopware/composables": minor +--- + +Added `useCacheableRead().invokeRead`. It uses the cacheable GET route only for a fresh default guest. That GET has no `sw-context-token`. Other sessions stay POST. Branching on `useShopwareContext().cacheableReads` is deprecated. New `createShopwareContext` option: `guestServerRender`. A category read over GET now sends its listing params as plain query params, so `limit` is no longer dropped. diff --git a/.changeset/quiet-tokens-stay.md b/.changeset/quiet-tokens-stay.md new file mode 100644 index 0000000000..5549dc58ca --- /dev/null +++ b/.changeset/quiet-tokens-stay.md @@ -0,0 +1,5 @@ +--- +"@shopware/api-client": minor +--- + +Header names now merge case-insensitively. A header set to `""` is removed from the request. A response to a request with an empty `sw-context-token` never changes the client's token. Added `mergeRequestHeaders` to `@shopware/api-client/helpers`. Exported the `ClientHeaders` and `ClientHeadersProxy` types. Dropped the `defu` dependency. diff --git a/.changeset/shared-carts-stay.md b/.changeset/shared-carts-stay.md new file mode 100644 index 0000000000..f0aad27ad4 --- /dev/null +++ b/.changeset/shared-carts-stay.md @@ -0,0 +1,5 @@ +--- +"@shopware/nuxt-module": patch +--- + +The cart state is now app-wide. The plugin sets `guestServerRender` for server renders without a visitor session. diff --git a/AGENTS.md b/AGENTS.md index 4d248ba98c..a59563c18c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,20 +85,17 @@ 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 picks GET or POST per request. Never branch on `cacheableReads`. + - Register new GET twins in `packages/composables/src/useCacheableRead/`. + The coverage test there enforces it. 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 691c5440a3..778eafb946 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 an opt-in flag (default `false`). It lets some read composables use the cacheable GET routes of the Store API. Shared caches do not store POST responses. They do store GET responses. -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. +Only a session that looks like a fresh default guest uses GET. The Criteria goes into a `_criteria` query param (gzip + base64url, via `encodeForQuery` from `@shopware/api-client/helpers`). All other sessions use 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. +Some GET routes lack `_criteria` in the generated types. That is fine: `useCacheableRead` builds the GET call itself. ### Enabling it @@ -83,32 +83,31 @@ app.use(shopware); -Inside a composable the flag is read from the Shopware context and used to branch the request: +In a composable, never read the flag. Call `useCacheableRead()` at setup. Pass the POST operation and its params to `invokeRead`. It picks GET or POST per request. It returns the same type as `apiClient.invoke`: ```ts -import { encodeForQuery } from "@shopware/api-client/helpers"; - -import { useShopwareContext } from "#imports"; +import { useCacheableRead } from "#imports"; -const { apiClient, cacheableReads } = useShopwareContext(); +const { invokeRead } = useCacheableRead(); const criteria = {}; -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; +} ``` +Only routes in the `useCacheableRead` registry can use GET. A coverage test next to it fails when a read with a GET twin is not listed. It also fails when code in the packages and starter templates it scans calls a listed 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`) @@ -118,24 +117,49 @@ Exactly these composables gain a GET branch when `cacheableReads` is enabled: - `useInternationalization` (`getAvailableLanguages`) - `useProductConfigurator` - `useProductSearch` (single product detail) -- `useProductReviews` (`loadProductReviews`) -- `useCategorySearch.advancedSearch` (category list) -- `useCategorySearch.search` (single category) +- `useProductReviews` +- `useCategorySearch` (`search` for a single category, `advancedSearch` for the category list) -One of those GET routes is not fully typed yet. `readCategoryGet get /category/{navigationId}` does not declare `_criteria` in the generated types, so `useCategorySearch.search` supplies it through a local intersection type. That is invisible to callers: the flag still decides which route runs, and a missing `_criteria` in the generated types is therefore not automatically a blocker. +### Which sessions use GET -::: 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. -::: +`invokeRead` checks every request. It sends GET only when the loaded session and cart look like a fresh default guest: + +- no customer (a guest account counts as one) +- an empty cart that belongs to the current context token +- a session that belongs to the current context token +- the sales channel's default language, currency, country, payment method and shipping method +- no country state + +If it cannot tell, it sends POST. Other sessions send the same POST as with the flag off. + +A GET from `invokeRead` has three guarantees: + +1. It carries no `sw-context-token`. +2. Its response cannot set or replace the client's token. This holds even when a login finishes during the read. +3. The GET or POST choice uses the loaded session and cart. That state can be stale or incomplete. So it does not prove the backend session is a fresh guest. + +The check needs the cart. With `useUserContextInSSR: true`, the server render has no cart. So server-side reads stay POST once the visitor has a context token. Outside Nuxt, provide `swSessionContext` and `swCart` refs on the app. Otherwise reads stay POST once a token exists. + +The loaded state cannot show: + +- 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, a session can get default guest data. The backend and any proxy decide what gets stored. See [Backend HTTP cache and reverse proxy](#backend-http-cache-and-reverse-proxy). + +A customer, a filled cart or a non-default currency, country, payment or shipping method always stays POST. Why: the backend looks up its cache before it reads the context token. Only the `sw-cache-hash` header or cookie keeps these sessions apart. A browser on another domain cannot send it. The header is not on the backend's CORS allow-list, and the cookie is `httponly` and needs credentialed CORS. Without the hash, a GET from such a session reads the default guest entry, for example a guest price in another currency. The backend does not store that response, it answers `private, no-cache`. So the guest entry stays clean, but the session gets wrong data. + +A non-default language always stays POST. Why: in Shopware 6.7.6.0 to 6.7.14.0, the built-in HTTP cache ignores `sw-language-id` and `Vary`. A GET in another language would share the default language's cache entry. [shopware#20200](https://github.com/shopware/shopware/pull/20200) fixes this, but it is not released yet. A proxy that honors `Vary` would be safe. The frontend cannot tell which cache it talks to. ### Which reads stay on POST, and why -Two read paths still call POST, for different reasons: +These reads have a GET twin but are not migrated yet: -- `useLandingSearch` - `readLandingPage post /landing-page/{landingPageId}`, because `readLandingPageGet get /landing-page/{landingPageId}` still does not declare `_criteria`. -- `useListing` (product listing) - `readProductListing post /product-listing/{categoryId}`, although nothing in the types blocks the move any more. +- `useListing` (listing and search), tracked in [#2691](https://github.com/shopware/frontends/issues/2691). Core [PR #17204](https://github.com/shopware/shopware/pull/17204) declared `_criteria` on `GET /store-api/product-listing` in 6.7.12.0. +- `useLandingSearch` - always `readLandingPage post /landing-page/{landingPageId}` -`useListing` is waiting on a composable change, not on the schema. The generated types already carry the parameter: `readProductListingGet get /product-listing/{categoryId}` declares `_criteria`, and both operations answer with `ProductListingResult`, so the switch is drop-in for callers. That covers the `categoryListing` branch only — with `listingType: "productSearchListing"` the composable calls `searchPage post /search`, whose GET twin still takes flattened query params instead of `_criteria`. +Session-dependent reads stay POST on purpose, like payment and shipping methods. The coverage test lists every POST-only route with a reason. 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. @@ -294,15 +318,17 @@ These rendering modes (universal SSR, client-side, ISR, and the static/SPA optio The frontend layers stop at producing cacheable requests. Whether a GET response is actually stored, for how long, and with what cache key is decided by the Shopware backend HTTP cache and your reverse proxy (Varnish self-hosted, or Fastly on Shopware Cloud). -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`. +This is where `cacheableReads` pays off. GET reads with a stable `_criteria` URL are cacheable. The backend or proxy handles `Cache-Control`, cache tags, `sw-cache-hash` and invalidation. + +`@shopware/api-client` does none of that. It forwards the Store API headers. It takes the context token from non-public responses. It ignores that token on public responses. It also ignores it on responses to a request with an empty `sw-context-token`, which `invokeRead` sends for its GETs. -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. +Shopware marks cacheable routes with the `_httpCache` attribute. `CacheResponseSubscriber` then sends a public `Cache-Control` header. The default is `public, max-age=0, s-maxage=1800, stale-while-revalidate=86400, stale-if-error=7200`. Other routes get `no-cache, private`. The backend also sends `sw-language-id`, `sw-currency-id` and `sw-cache-hash`, and adds them to `Vary`. Invalidation uses Shopware's 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 key is the URL, plus `Vary` headers in a proxy that honors them. The token is not part of it. So `invokeRead` sends GET only for fresh guests, and without the token. - 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. Older GET routes return `405` or ignore `_criteria`. 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. @@ -312,7 +338,7 @@ Beyond HTTP, the storefront avoids redundant work by sharing in-memory state acr - **Shared composables.** `useCart`, `usePrice`, and `useProductSearchListing` are wrapped with VueUse's `createSharedComposable()`, so a single instance is reused across the app on the client. During SSR it automatically falls back to per-request (non-shared) mode to avoid cross-request state pollution. - **Session, cart, user, listings.** State is held in shared refs under named injection keys (`swSessionContext`, `swCart`, `customer`, `swNavigation-${type}`, listing keys) via a `useContext` helper built on VueUse's `provideLocal`/`injectLocal`. Mutations reassign the shared ref so every consumer sees consistent state without refetching. -- **Single API client.** One Store API client is created per app and provided via `provide`/`inject`, so all composables share its `defaultHeaders` - including the `sw-context-token` it captures from non-public (private or uncached) response headers and reuses on subsequent requests. Tokens on `Cache-Control: public` responses are ignored, so a CDN hit cannot overwrite a logged-in session with a stale guest token. +- **Single API client.** One Store API client per app, shared via `provide`/`inject`. All composables share its `defaultHeaders`, including the `sw-context-token`. It ignores tokens on public responses and on anonymous reads. So neither can overwrite a logged-in session. - **Navigation reuse.** Navigation results are fetched once with `useAsyncData` (with stable keys for deduplication and hydration-payload serialization) and shared down the tree with `provide`/`inject` rather than refetched after hydration. A couple of pieces of client state are also persisted durably outside memory: the `sw-context-token` is written to a cookie by the Nuxt plugin (so the session survives reloads and SSR), and `useLocalWishlist` persists wishlist ids to `localStorage`. diff --git a/apps/docs/src/frontends-recipes/account/profile.md b/apps/docs/src/frontends-recipes/account/profile.md index b3a407e1a3..6e0ab4c730 100644 --- a/apps/docs/src/frontends-recipes/account/profile.md +++ b/apps/docs/src/frontends-recipes/account/profile.md @@ -124,16 +124,16 @@ You do not need to call `refreshSessionContext()` after a profile or email chang ## Request Flow -| Step | Code | Store API | Type | -| ------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Load the salutations | `fetchSalutations()` | `POST /salutation`, or `GET /salutation` with `cacheableReads` | | -| Change the profile | `updatePersonalInfo(profile)` | `POST /account/change-profile` | | -| Change the email address | `updateEmail(emailChange)` | `POST /account/change-email` | | -| Read the customer back | `refreshUser()` | `POST /account/customer` | | -| Convert a guest | `apiClient.invoke("convertGuest …", { body: { password } })` | `POST /account/convert-guest` | | -| Delete the account | `apiClient.invoke("deleteCustomer delete /account/customer")` | `DELETE /account/customer` | none — the operation answers `204 No Content` | +| Step | Code | Store API | Type | +| ------------------------ | ------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Load the salutations | `fetchSalutations()` | `POST /salutation` | | +| Change the profile | `updatePersonalInfo(profile)` | `POST /account/change-profile` | | +| Change the email address | `updateEmail(emailChange)` | `POST /account/change-email` | | +| Read the customer back | `refreshUser()` | `POST /account/customer` | | +| Convert a guest | `apiClient.invoke("convertGuest …", { body: { password } })` | `POST /account/convert-guest` | | +| Delete the account | `apiClient.invoke("deleteCustomer delete /account/customer")` | `DELETE /account/customer` | none — the operation answers `204 No Content` | -The salutation row is the only one whose route depends on configuration. `useSalutations` switches to the cacheable GET variant when `shopware.cacheableReads` is set, and `vue-starter-template` sets it, so that is the request the supported template issues. Everything else on this page stays a POST: the writes are not cacheable, and `readCustomer` has no GET variant to switch to. See [Caching](../../best-practices/caching.html). +Every request on this page is a POST. `useSalutations` has a cacheable GET variant under `shopware.cacheableReads`, which `vue-starter-template` sets. But `invokeRead` sends GET only for a fresh default guest, and this page always has a logged-in customer, see [Which sessions use GET](../../best-practices/caching.html#which-sessions-use-get). The writes are not cacheable, and `readCustomer` has no GET variant to switch to. See [Caching](../../best-practices/caching.html). `refreshUser()` accepts a `Criteria`, so associations the context does not carry — the full `addresses` list, the `salutation` entity — can be requested here rather than in a second call. The default addresses are a different case: `userDefaultBillingAddress` and `userDefaultShippingAddress` are computed straight off the context customer, so they are already there before you call anything. @@ -154,7 +154,7 @@ Pick by scope — how much of the session the composable is about: - **Read** — `user`, `isLoggedIn`, `isCustomerSession`, `isGuestSession`, `defaultBillingAddressId`, `defaultShippingAddressId`, `userDefaultBillingAddress`, `userDefaultShippingAddress`, `userDefaultPaymentMethod`. - **Write** — `updatePersonalInfo`, `updateEmail`, and `refreshUser` to read the customer back afterwards. -- **Resolve by id** — `loadCountry(id)` and `loadSalutation(id)` fill the `country` and `salutation` refs for a single entity. Both follow the same `cacheableReads` switch as `fetchSalutations()`. +- **Resolve by id** — `loadCountry(id)` and `loadSalutation(id)` fill the `country` and `salutation` refs for a single entity. Both read through `invokeRead` like `fetchSalutations()`, so on this page they post too. Four things the generated reference will not tell you: diff --git a/apps/docs/src/frontends-recipes/account/register.md b/apps/docs/src/frontends-recipes/account/register.md index f707687768..8741f5d6aa 100644 --- a/apps/docs/src/frontends-recipes/account/register.md +++ b/apps/docs/src/frontends-recipes/account/register.md @@ -149,7 +149,7 @@ You do not call `readContext get /context` yourself after registering, because ` | Refresh the cart | `refreshCart()` | `GET /checkout/cart` | | | Confirm a double opt-in link | `apiClient.invoke("registerConfirm post /account/register-confirm")` | `POST /account/register-confirm` | | -The two option lists are the only rows whose route depends on configuration. `useSalutations` and `useCountries` switch to the cacheable GET variant when `shopware.cacheableReads` is set, and `vue-starter-template` sets it, so the GET routes are what the supported template actually issues. The registration write itself is always a POST. +The two option lists are the only rows whose route depends on configuration. `useSalutations` and `useCountries` switch to the cacheable GET variant for a fresh default guest when `shopware.cacheableReads` is set. `vue-starter-template` sets it, so a new visitor there gets the GET routes. Other sessions, like one with items in the cart, still post, see [Which sessions use GET](../../best-practices/caching.html#which-sessions-use-get). The registration write itself is always a POST. ## Composables diff --git a/apps/docs/src/frontends-recipes/catalog/cross-selling.md b/apps/docs/src/frontends-recipes/catalog/cross-selling.md index 05c5fccbe2..0afc20f1da 100644 --- a/apps/docs/src/frontends-recipes/catalog/cross-selling.md +++ b/apps/docs/src/frontends-recipes/catalog/cross-selling.md @@ -140,7 +140,7 @@ You do not need this composable on a CMS-driven product page whose layout contai The load row's `Type` cell is empty because there is nothing to type. That is the fact this whole recipe hangs on. -The first row assumes the default configuration. With `shopware: { cacheableReads: true }` in `nuxt.config`, `useProductSearch` switches to `readProductDetailGet get /product/{productId}` and moves the criteria into a `_criteria` query parameter. The cross-selling rows do not change: the composable never calls the GET variant, and neither variant accepts criteria. +The first row assumes the default configuration. With `shopware: { cacheableReads: true }` in `nuxt.config`, `useProductSearch` switches to `readProductDetailGet get /product/{productId}` for a fresh default guest and moves the criteria into a `_criteria` query parameter. The cross-selling rows do not change: the composable never calls the GET variant, and neither variant accepts criteria. ## Composables diff --git a/apps/docs/src/frontends-recipes/catalog/reviews.md b/apps/docs/src/frontends-recipes/catalog/reviews.md index bd626719a6..5b05b604b4 100644 --- a/apps/docs/src/frontends-recipes/catalog/reviews.md +++ b/apps/docs/src/frontends-recipes/catalog/reviews.md @@ -49,7 +49,7 @@ const steps = [ title: "Store API", action: "Return one page", detail: - "POST by default, GET with the Criteria in _criteria when cacheableReads is enabled. Either way the response is an EntitySearchResult: the composable stores only elements, but returns the whole payload — which is where total lives.", + "POST by default. GET with the Criteria in _criteria when cacheableReads is enabled and the session is a fresh guest. Either way the response is an EntitySearchResult: the composable stores only elements, but returns the whole payload — which is where total lives.", code: "productReviews.value = data.elements ?? []", state: "productReviews", typeKeys: [ @@ -99,7 +99,7 @@ Render a product's reviews and let a customer add one. The important part is tha Two read routes and one write route, and none of them is used for the star rating. `product.ratingAverage` comes with the product response, so the summary at the top of a detail page needs no request at all. -`readProductReviews post /product/{productId}/reviews` takes a `Criteria` and returns one page of reviews. When the app enables `cacheableReads`, the composable calls the cacheable GET twin instead — `readProductReviewsGet get /product/{productId}/reviews` — with the same Criteria compressed into the `_criteria` query parameter. `vue-starter-template` sets `cacheableReads: true`, so an app started from it takes the GET path. +`readProductReviews post /product/{productId}/reviews` takes a `Criteria` and returns one page of reviews. When the app enables `cacheableReads` and the session is a fresh default guest, the composable calls the cacheable GET twin instead, `readProductReviewsGet get /product/{productId}/reviews`, with the same Criteria compressed into the `_criteria` query parameter. `vue-starter-template` sets `cacheableReads: true`, so a guest in an app started from it takes the GET path. Other sessions still post, see [Which sessions use GET](../../best-practices/caching.html#which-sessions-use-get). `saveProductReview post /product/{productId}/review` answers `200` with no body — it returns neither the created review nor an updated average. Both facts shape the handler: after a successful submit there is nothing to merge, so you reload, and what comes back is your own review marked as not yet approved. @@ -130,7 +130,7 @@ You do not need a request for the average rating, and you do not need to pass th Two things about the total row. It goes through the method's **return value**, not through a reactive property — the composable stores only `elements`, so pagination controls have to keep the total themselves. And `total-count-mode` defaults to `none`, which means "do not count": without asking for `exact` you get no number to paginate with. -The POST and the GET route are the same request in two shapes. `useProductReviews` picks between them from `cacheableReads` in the Shopware context; your calling code does not change. +The POST and the GET route are the same request in two shapes. `useProductReviews` reads through `invokeRead`, which picks one per request from `cacheableReads` and the session. Your calling code does not change. ## Composables @@ -383,7 +383,7 @@ Reviews carry a `status` and a `comment`. `status` is the moderation flag — `f - A customer can review a product once. A second `addReview` rejects with `VIOLATION::ENTITY_EXISTS`, which deserves its own message rather than a generic failure. - The list is one page. `productReviews` holds `elements` only, so `total` has to be read from the value `loadProductReviews()` resolves to — and only if the Criteria asked for it. - `total-count-mode` defaults to `none`. Without `"total-count-mode": "exact"` the response carries no usable total and every page looks like the last one. -- With `cacheableReads` enabled the composable calls `GET /product/{productId}/reviews` and sends the Criteria in `_criteria`. Anything asserting on the request — a test, a mock, a network expectation — has to cover the shape the app actually uses. +- With `cacheableReads` enabled, a fresh guest calls `GET /product/{productId}/reviews` and sends the Criteria in `_criteria`. A logged-in customer still posts. Anything asserting on the request — a test, a mock, a network expectation — has to cover the shape the app actually uses. - `loadProductReviews` reads `product.value.id` at call time. A product ref that is still `undefined` throws before any request is sent; a product object without an `id` sends one against `/product/undefined/reviews`. - `addReview` accepts only `title`, `content` and `points`. Setting the author's `name` or `email` — for example for a guest review — means calling `apiClient.invoke` with the full body. - `points` is a `double` in the save body and a `float` on the review, not an integer. A half-star UI is possible on the API side. @@ -400,7 +400,7 @@ Reviews carry a `status` and a `comment`. `status` is the moderation flag — `f - Do not render the review form for an anonymous visitor. - Do not read the total from `productReviews.length`. That is one page. - Do not request a total without `"total-count-mode": "exact"`. -- Do not assert on `POST /product/{productId}/reviews` in an app that enables `cacheableReads`. +- Do not assert on one fixed route in an app that enables `cacheableReads`. A fresh guest sends GET, a logged-in customer sends POST. - Do not call `loadProductReviews()` before the product ref is populated. - Do not use `addReview` when you need to set the author name — invoke the operation directly. - Do not treat a duplicate-review rejection as an unknown error. @@ -409,7 +409,7 @@ Reviews carry a `status` and a `comment`. `status` is the moderation flag — `f ## Testing Checklist - The rating average renders before any review request resolves. -- Opening the page issues one review request for the product — `readProductReviews post /product/{productId}/reviews`, or `readProductReviewsGet get /product/{productId}/reviews` with a `_criteria` query when `cacheableReads` is enabled. +- Opening the page issues one review request for the product — `readProductReviews post /product/{productId}/reviews`, or `readProductReviewsGet get /product/{productId}/reviews` with a `_criteria` query when `cacheableReads` is enabled and the visitor is a fresh guest. - The criteria carries the page size and `total-count-mode: exact`. - The total used for pagination comes from the method's return value. - Paging forward issues one request per page and replaces the rendered reviews. diff --git a/apps/docs/src/frontends-recipes/catalog/search.md b/apps/docs/src/frontends-recipes/catalog/search.md index fef9e98260..69bf1f612d 100644 --- a/apps/docs/src/frontends-recipes/catalog/search.md +++ b/apps/docs/src/frontends-recipes/catalog/search.md @@ -83,7 +83,7 @@ const steps = [ title: "Detail", action: "Open one product", detail: - "useProductSearch().search(productId) is a lookup by id, not a text search. It calls the product detail operation — POST, or the GET variant when cacheableReads is on — and returns the product with its configurator.", + "useProductSearch().search(productId) is a lookup by id, not a text search. It calls the product detail operation (the GET variant for a fresh guest when cacheableReads is on, POST otherwise) and returns the product with its configurator.", code: "await search(productId, { withCmsAssociations: true })", state: "none shared", typeKeys: ['Schemas["ProductDetailResponse"]'], @@ -352,7 +352,7 @@ The results are context-dependent like any listing: prices arrive calculated in - `loading` is the shared listing's flag, but it only covers `search()`. `loadMore` sets `loadingMore`, which `useProductSearchSuggest` does not re-export. It is also a single boolean: with two searches in flight the first to settle clears it, so a surface can drop out of its loading state while its own request is still open. - `loadMore`'s argument is optional on `useProductSearchListing` but **required** in the type `useProductSearchSuggest` declares for it, so `loadMore()` type-checks through the listing and not through the suggest composable. - `useProductSearch().search()` takes a **product id**, not a term. The name suggests a text search and it is not one. -- `useProductSearch` switches operation on the context's `cacheableReads` flag: `readProductDetail post /product/{productId}` when it is `false`, `readProductDetailGet get /product/{productId}` with the criteria in a `_criteria` query param when it is `true`. The context default is `false`, but `vue-starter-template` enables it, so the request you will actually observe there is the GET. Pin your tests to the flag your app sets, not to the composable's default. +- `useProductSearch` reads through `invokeRead`. With `cacheableReads` on and a fresh default guest, it sends `readProductDetailGet get /product/{productId}` with the criteria in a `_criteria` query param. Otherwise it sends `readProductDetail post /product/{productId}`. The context default is `false`, but `vue-starter-template` enables it, so a guest there sends the GET. Pin your tests to the flag and the session, not to the composable's default. - `useProductSearch` merges `withCmsAssociations`, `criteria` and `associations` with `defu`, in that precedence order. Passing both `criteria.associations` and `associations` merges them rather than picking one. - Both the listing search and `useProductSearch` send `sw-include-seo-urls: true`, so products from the dropdown and from the detail request alike carry the data a link needs — do not resolve the route separately. `getProductRoute` still returns an unprefixed route, so wrap it in `formatLink` from `useInternationalization` unless the storefront is single-language. - An empty `searchTerm` produces a search for an empty string, which is a valid request returning the unfiltered listing. Guard on the term length before calling. @@ -394,7 +394,7 @@ The results are context-dependent like any listing: prices arrive calculated in - A term deleted below the minimum length inside the debounce window issues no request at all. - Seeding the results page with `setInitialListing` clears the applied listing the dropdown wrote. - An empty result set renders the empty state rather than the previous results, and the previous term's products are not rendered while a new search is pending. -- Selecting a product calls the product detail operation with that product's id — `readProductDetailGet get /product/{productId}` when `cacheableReads` is on, as it is in `vue-starter-template`, and `readProductDetail post /product/{productId}` when it is off. +- Selecting a product calls the product detail operation with that product's id: `readProductDetailGet get /product/{productId}` for a fresh guest when `cacheableReads` is on, as it is in `vue-starter-template`, and `readProductDetail post /product/{productId}` otherwise. - A failing search does not overwrite the applied listing, so the results page behind keeps its products; the dropdown swaps its own list for the error message and announces it through `role="alert"`. - A search that succeeds after an earlier one failed clears the error rather than leaving the banner over valid results. diff --git a/apps/docs/src/frontends-recipes/catalog/variants.md b/apps/docs/src/frontends-recipes/catalog/variants.md index 450af7294f..0c1c0e3d1f 100644 --- a/apps/docs/src/frontends-recipes/catalog/variants.md +++ b/apps/docs/src/frontends-recipes/catalog/variants.md @@ -66,7 +66,7 @@ const steps = [ title: "Store API", action: "Find the variant", detail: - "findVariantForSelectedOptions builds an equals filter on parentId plus one per selected option and searches the product list with limit 1. It does not use the find-variant operation. With cacheableReads enabled the same criteria goes to readProductGet as an encoded _criteria query parameter.", + "findVariantForSelectedOptions builds an equals filter on parentId plus one per selected option and searches the product list with limit 1. It does not use the find-variant operation. With cacheableReads enabled, a fresh guest sends the same criteria to readProductGet as an encoded _criteria query parameter.", code: 'apiClient.invoke("readProduct post /product", { body: criteria })', state: "sw-context-token", typeKeys: ['operations["readProduct post /product"]["body"]'], @@ -104,7 +104,7 @@ The Store API has an operation built for this — `searchProductVariantIds post That search carries an `includes` clause restricting the product to `id`, `translated`, `productNumber` and `seoUrls`, plus a `seoUrls` association and a `seo_url` include for `seoPathInfo`. The result is therefore a link target, not a product you can render. Which is exactly why the shipped configurator pushes the router to the variant's URL and lets the page reload the real product. -The request is `readProduct post /product` by default. With `shopware: { cacheableReads: true }` — which `vue-starter-template` ships with — the composable sends the identical criteria to `readProductGet get /product` in the `_criteria` query parameter instead, so the storefront can cache it. Filter, `includes` and result are the same either way; only the transport changes. See [Caching best practices](../../best-practices/caching.html). +The request is `readProduct post /product` by default. With `shopware: { cacheableReads: true }`, which `vue-starter-template` ships with, a fresh default guest sends the identical criteria to `readProductGet get /product` in the `_criteria` query parameter instead, so the storefront can cache it. Other sessions still post. Filter, `includes` and result are the same either way; only the transport changes. See [Caching best practices](../../best-practices/caching.html). @@ -351,7 +351,7 @@ The selection itself is local to the `useProductConfigurator()` instance, not sh - Do not disable the option groups while resolving. A disabled control cannot hold focus, so a keyboard customer is thrown back to the top of the document mid-selection — use `aria-busy` and guard the handler. - Do not push `getProductRoute(variant)` unprefixed. Wrap it in `buildUrlPrefix` with `useUrlResolver().getUrlPrefix()`, or a localised storefront silently drops its language prefix. - Do not treat `undefined` from `findVariantForSelectedOptions` as a bug — it also means the request failed silently. -- Do not assert the POST route in tests or proxies without checking `cacheableReads`. With the flag on, the same search goes out as `GET /product`. +- Do not assert the POST route in tests or proxies without checking `cacheableReads` and the session. With the flag on, a fresh guest sends the same search as `GET /product`. - Do not build the variant URL by hand. `getProductRoute` uses the `seoUrls` the search asked for. ## Testing Checklist @@ -359,7 +359,7 @@ The selection itself is local to the `useProductConfigurator()` instance, not sh - A product without a configurator renders no option groups. - The initial selection matches `product.optionIds`, one option per group, and drops ids that belong to no group. - Choosing an option updates `getSelectedOptions` and issues no request. -- Submitting issues exactly one variant search — `POST /product`, or `GET /product` when `cacheableReads` is enabled. +- Submitting issues exactly one variant search: `GET /product` for a fresh guest when `cacheableReads` is enabled, `POST /product` otherwise. - That request filters on `parentId` and on one `optionIds` value per selected option. - A resolvable combination navigates to the variant's SEO URL, keeping the active language prefix. - An unavailable combination renders a message, stays on the page, and can be submitted again. diff --git a/apps/docs/src/frontends-recipes/cms/contact-form.md b/apps/docs/src/frontends-recipes/cms/contact-form.md index 1ac9f6a56d..1438cf8539 100644 --- a/apps/docs/src/frontends-recipes/cms/contact-form.md +++ b/apps/docs/src/frontends-recipes/cms/contact-form.md @@ -42,7 +42,7 @@ const steps = [ title: "UI", action: "Load the salutations", detail: - "The form takes a salutationId, not a label, so a salutation select needs the list. useSalutations fetches it on mount when it has not been fetched yet — so never on the server — and switches to the GET route when cacheableReads is enabled, which the starter ships on.", + "The form takes a salutationId, not a label, so a salutation select needs the list. useSalutations fetches it on mount when it has not been fetched yet — so never on the server — and switches to the GET route for a fresh guest when cacheableReads is enabled, which the starter ships on.", code: "const { getSalutations } = useSalutations()", state: "swSalutations", typeKeys: ['operations["readSalutation post /salutation"]["response"]'], @@ -129,7 +129,7 @@ You do not get any identifier back. There is nothing to poll, nothing to look up | Submit the form | `invoke("sendContactMail post /contact-form", { body })` | `POST /contact-form` | | | Read the result | `data.individualSuccessMessage` | `POST /contact-form` | `never` in the generated types — see below | -The salutation row has a second variant, and it is the one you will actually hit. With `cacheableReads` enabled, `useSalutations` calls over `GET /salutation` instead, so the list is HTTP-cacheable. The library default is off, but `vue-starter-template` ships `cacheableReads: true`, so in the supported starting point the salutation list arrives over `GET`. The payload is the same either way, and nothing in your form code changes. +The salutation row has a second variant, and it is the one you will actually hit. With `cacheableReads` enabled, `useSalutations` calls over `GET /salutation` for a fresh default guest, so the list is HTTP-cacheable. The library default is off, but `vue-starter-template` ships `cacheableReads: true`, so in the supported starting point a guest gets the salutation list over `GET`. A logged-in customer still gets it over `POST`. The payload is the same either way, and nothing in your form code changes. The body also declares `slotId`, `cmsPageType` and `entityName`, and `slotId` is the one that matters most: it is how the backend finds the element's own `mailReceiver` and confirmation text. `navigationId` is described in the schema as an override of that configuration, not a replacement for it — send only `navigationId` and the mail goes to the shop's default address with an empty message. The component shipped in the CMS base layer sends only `navigationId`, so an element-level receiver configured in the administration never reaches the API. @@ -156,7 +156,7 @@ Eight things the generated reference will not tell you: - `getConfigValue("title")` falls back to a hardcoded `"Contact"` in the shipped component, which `useCmsTranslations` cannot override, because the title uses `||`. `confirmationText` uses `??`, so it reaches the injected translation only when the element has no value for that key at all: a blank value in the administration renders an empty confirmation, and a config whose `source` is `"mapped"` renders the literal `false`. - `useCmsTranslations` is the reading side and cannot override anything — its whole body is `inject("cmsTranslations", {})`. The override is the opposite call, `provide("cmsTranslations", messages)`, which `vue-starter-template` makes in `app/app.vue`. `SwContactForm` merges the result with `defu` once during setup, so translations swapped after mount never reach an already-rendered form. - `useSalutations` fetches on mount when the list has **not been fetched yet** — the guard is `if (!_salutations.value)`, not a length check. `fetchSalutations` assigns `[]` on an empty response and `[]` is truthy, so a shop that genuinely returns zero salutations is never retried. `getSalutations` renders the unfetched and the fetched-empty state identically, which is what makes the two indistinguishable in a template. -- `useSalutations` picks its route from `cacheableReads`: `readSalutationGet get /salutation` when the flag is on, `readSalutation post /salutation` when it is off. +- `useSalutations` reads through `invokeRead`: `readSalutationGet get /salutation` for a fresh guest when the flag is on, `readSalutation post /salutation` otherwise. - `foreignKey` is a `ComputedRef` that defaults to `""`, never `undefined`. That does not fail loudly — an empty `navigationId` is ignored and the call still answers `200`, despite the UUID pattern the schema declares for it. What you lose is silent: no category override is applied, so the element's own configuration stands when you thought a category's had won. - `useNavigationContext()` called with no argument only injects. Something upstream has to seed it, and in `vue-starter-template` the only thing that does is the catch-all route `app/pages/[...all].vue`. Outside that page's subtree — an explicit file route such as `app/pages/contact.vue`, or a layout — `foreignKey` is `""` on a perfectly resolved URL. There you pass the `SeoUrl` in yourself with `useNavigationContext(seoResult)`, or take `navigationId` as a prop, which is what `[...all].vue` already does for the pages below it. @@ -573,7 +573,7 @@ Send both from an element override: `slotId` from `content.id`, which `CmsSlot` ## Testing Checklist -- The salutation select is populated from `readSalutationGet get /salutation` when `cacheableReads` is on, and from `readSalutation post /salutation` when it is off. Assert whichever your `nuxt.config` selects — `vue-starter-template` ships the flag on. +- The salutation select is populated from `readSalutationGet get /salutation` for a fresh guest when `cacheableReads` is on, and from `readSalutation post /salutation` otherwise. Assert the route for the session you test. `vue-starter-template` ships the flag on. - The salutation select is inert until the list arrives, and the server-rendered HTML does not claim the list is empty. - Submitting calls `sendContactMail post /contact-form` exactly once. - The request body carries `slotId` from the element, so a receiver configured on the element is the one that gets the mail. diff --git a/apps/docs/src/frontends-recipes/cms/rendering.md b/apps/docs/src/frontends-recipes/cms/rendering.md index 57f24636bc..8d5ec4a445 100644 --- a/apps/docs/src/frontends-recipes/cms/rendering.md +++ b/apps/docs/src/frontends-recipes/cms/rendering.md @@ -146,7 +146,7 @@ You do not need a request per section, block or element. The only later requests | Read one slot | `getSlotContent("left")` | none | | | Fetch a page by id directly | `invoke("readCms post /cms/{id}", { pathParams: { id }, body: { slots } })` | `POST /cms/{id}` | | -`POST` is the request-layer default, but not what the supported template does: `vue-starter-template` sets `cacheableReads: true` under `runtimeConfig.public.shopware`, and with that flag `useCategorySearch` calls `readCategoryGet get /category/{navigationId}` instead, compressing the same criteria into a `_criteria` query param so the read is HTTP-cacheable. `useLandingSearch` has not been moved to the cacheable variant and always posts. Either way the same `cmsPage` tree comes back. +`POST` is the request-layer default. `vue-starter-template` sets `cacheableReads: true` under `runtimeConfig.public.shopware`. With that flag, `useCategorySearch` calls `readCategoryGet get /category/{navigationId}` for a fresh default guest, with the same criteria compressed into a `_criteria` query param, so the read is HTTP-cacheable. Every other session still posts, see [Which sessions use GET](../../best-practices/caching.html#which-sessions-use-get). `useLandingSearch` always posts. Either way the same `cmsPage` tree comes back. The last row is the operation nothing uses. It takes a `slots` string of `|`-separated identifiers to resolve only some slots, and a `ProductListingCriteria` for the listing an element on the page may hold. @@ -278,7 +278,7 @@ The two places state does appear are worth knowing. `useCmsTranslations()` injec The CMS payload is context-dependent like everything else. Prices inside a product element are calculated for the current currency and tax state, and `visibility` on a section or block can hide it for a given device. A currency or language switch invalidates the whole rendered page. -That payload is also the one a shared cache may store. With the starter's `cacheableReads: true` the category read comes back `public, s-maxage=1800`, where the `POST` variant is `private, no-cache` — so the CMS tree and the calculated prices of the embedded listing sit in the same cacheable response. Keeping one customer's prices out of another's page is the backend's contract rather than the renderer's: those entries are varied on language, currency and login state, which [Backend HTTP cache and reverse proxy](../../best-practices/caching.html#backend-http-cache-and-reverse-proxy) explains. The frontend guards one thing itself — `@shopware/api-client` ignores `sw-context-token` on a publicly cacheable response, so a replayed guest token from a cache hit cannot overwrite a logged-in session. +That payload is also the one a shared cache may store. With the starter's `cacheableReads: true`, a guest's category read comes back `public, s-maxage=1800`. The `POST` variant is `private, no-cache`. So the CMS tree and the calculated prices of the embedded listing sit in the same cacheable response. The backend keeps customers, currencies and carts apart only with `sw-cache-hash`, and a browser on another domain cannot send it. So the frontend keeps them apart itself: `invokeRead` sends GET only for a fresh default guest and posts for everyone else, see [Which sessions use GET](../../best-practices/caching.html#which-sessions-use-get). `@shopware/api-client` also ignores `sw-context-token` on a publicly cacheable response, so a replayed guest token from a cache hit cannot overwrite a logged-in session. ## Edge Cases @@ -314,7 +314,7 @@ That payload is also the one a shared cache may store. With the starter's `cache ## Testing Checklist -- A category page gets its layout from the category request itself — a `GET /category/{navigationId}?_criteria=…` with the starter template's `cacheableReads: true`, a `POST` without it — and never a separate `readCms post /cms/{id}` request. +- A category page gets its layout from the category request itself, never from a separate `readCms post /cms/{id}` request. With the starter's `cacheableReads: true`, a fresh guest sends `GET /category/{navigationId}?_criteria=…`. After login or a currency switch, the same page sends a `POST`. - Every section in the payload resolves to a component, and an unknown type renders the fallback rather than nothing. - `getPositionContent` returns only the blocks whose `sectionPosition` matches. - `getSlotContent` returns `undefined` for a slot the block does not have, without throwing, and the element renders nothing rather than an empty wrapper. diff --git a/apps/docs/src/frontends-recipes/context/language-and-currency.md b/apps/docs/src/frontends-recipes/context/language-and-currency.md index 33784ee8ee..84c94f0b90 100644 --- a/apps/docs/src/frontends-recipes/context/language-and-currency.md +++ b/apps/docs/src/frontends-recipes/context/language-and-currency.md @@ -117,7 +117,7 @@ Steps 1 to 4 are the language flow in order. Step 5 is the currency branch, not `readCurrency post /currency` has no composable wrapper, and its response is a bare array of currencies rather than an entity search result — the tooltip above is generated from a schema that still describes it as one, so trust the generated type. `changeLanguage post /account/change-language` has no wrapper either — it stores the language on the customer, which is a different thing from switching the current context. -The language list is one of the reads that [`cacheableReads`](../../best-practices/caching.html#request-layer-cacheablereads) moves off POST. With the flag enabled, `getAvailableLanguages()` calls `readLanguagesGet get /language` instead, and the response shape is unchanged. The context patch is a mutation and always stays `PATCH /context`. +The language list is one of the reads that [`cacheableReads`](../../best-practices/caching.html#request-layer-cacheablereads) moves off POST. With the flag enabled, `getAvailableLanguages()` calls `readLanguagesGet get /language` instead for a fresh default guest, and the response shape is unchanged. Once the visitor switches language or currency, it posts again, see [Which sessions use GET](../../best-practices/caching.html#which-sessions-use-get). The context patch is a mutation and always stays `PATCH /context`. ## Composables @@ -379,7 +379,7 @@ The current language itself is read from the context, not from this composable. - `changeLanguage post /account/change-language` takes `{ languageId }`, required, and the backend expects a 32-character hex id. Store API schemas before 6.7.13 named the property `language` while marking `languageId` required, and the tooltip above is rendered from a 6.7.10 snapshot, so it can still show `language`. The generated types you compile against are the ones to trust. - `setLanguage()` on `useSessionContext` returns silently when the language has no `id`, and unlike `changeLanguage()` it refreshes the context — but it ignores `redirectUrl` entirely. - `setCurrency()` also returns silently — it logs and stops when the argument has no `id` — so a failed switch is not always a rejected promise. Compare `currency` against the id you asked for instead of trusting the resolved promise. -- With `cacheableReads` enabled, the language list is fetched over `readLanguagesGet get /language`. Assertions and mocks written against `POST /language` do not match a storefront that has the flag on. +- With `cacheableReads` enabled, a fresh guest fetches the language list over `readLanguagesGet get /language`. After a language or currency switch, it comes over `POST /language`. Assertions and mocks have to cover both. ## Common Mistakes @@ -415,7 +415,7 @@ The current language itself is read from the context, not from this composable. - A `setCurrency()` that rejects because the context refresh failed reports an unconfirmed switch, not a failed one. - Mounted in an application that has not loaded the context, the switcher still marks the active language and currency. - Selecting the language or currency that is already active sends no request. -- With `cacheableReads` enabled, the language list is requested over `GET /language`. +- With `cacheableReads` enabled, a fresh guest requests the language list over `GET /language`, and over `POST /language` after a language or currency switch. ## Related Links diff --git a/apps/docs/src/frontends-recipes/context/navigation.md b/apps/docs/src/frontends-recipes/context/navigation.md index 081bb5a2c2..e9b1c26fb5 100644 --- a/apps/docs/src/frontends-recipes/context/navigation.md +++ b/apps/docs/src/frontends-recipes/context/navigation.md @@ -150,7 +150,7 @@ The breadcrumb request is the only row without a composable. `referrerCategoryId The tooltip on the last two rows shows `Schemas["Breadcrumb"]`, the full wire shape. `pushBreadcrumb` is looser than that: it also accepts a bare `{ name, path }`, which is what you hand it for a crumb you build yourself. -The three POST rows are the default. Under `cacheableReads` the navigation, the path resolution and the category read each switch to their GET variant, so a proxy can cache them. +The three POST rows are the default. Under `cacheableReads` the navigation, the path resolution and the category read each switch to their GET variant for a fresh default guest, so a proxy can cache them. Other sessions still post, see [Which sessions use GET](../../best-practices/caching.html#which-sessions-use-get). ## Composables diff --git a/apps/docs/src/frontends-recipes/context/url-resolving.md b/apps/docs/src/frontends-recipes/context/url-resolving.md index be54dda7ad..69dd3b5db3 100644 --- a/apps/docs/src/frontends-recipes/context/url-resolving.md +++ b/apps/docs/src/frontends-recipes/context/url-resolving.md @@ -64,7 +64,7 @@ const steps = [ title: "Store API", action: "Look the path up", detail: - "A SEO path is matched on seoPathInfo with the leading slash removed. A technical path like /detail/ is matched on pathInfo instead. With cacheableReads enabled the same filter travels as a _criteria query parameter on the GET route.", + "A SEO path is matched on seoPathInfo with the leading slash removed. A technical path like /detail/ is matched on pathInfo instead. With cacheableReads enabled, a fresh guest sends the same filter as a _criteria query parameter on the GET route.", code: 'apiClient.invoke("readSeoUrl post /seo-url", { body: criteria })', state: "sw-context-token", typeKeys: ['operations["readSeoUrl post /seo-url"]["body"]'], @@ -149,7 +149,7 @@ You do not need one route per page type, and you do not fetch the entity in the | Fetch a product page | `useProductSearch().search(id, { withCmsAssociations: true })` | `POST /product/{productId}` | | | Fetch a landing page | `useLandingSearch().search(id, { withCmsAssociations: true })` | `POST /landing-page/{landingPageId}` | | -The methods in that column are the defaults. With `cacheableReads` enabled — `vue-starter-template` sets it under `runtimeConfig.public.shopware`, and `shopware: { cacheableReads: true }` is the equivalent module-option form — `resolvePath`, `useCategorySearch().search` and `useProductSearch().search` call their GET variants instead, with the same criteria compressed into a `_criteria` query parameter: `readSeoUrlGet get /seo-url`, `readCategoryGet get /category/{navigationId}` and `readProductDetailGet get /product/{productId}`. The filters, the fallback and the redirect are identical; only the transport changes. `useLandingSearch` has no GET branch and always posts. See [Caching](../../best-practices/caching.html) for what that buys you. +The methods in that column are the defaults. With `cacheableReads` enabled — `vue-starter-template` sets it under `runtimeConfig.public.shopware`, and `shopware: { cacheableReads: true }` is the equivalent module-option form — `resolvePath`, `useCategorySearch().search` and `useProductSearch().search` call their GET variants for a fresh default guest, with the same criteria compressed into a `_criteria` query parameter: `readSeoUrlGet get /seo-url`, `readCategoryGet get /category/{navigationId}` and `readProductDetailGet get /product/{productId}`. The filters, the fallback and the redirect are identical; only the transport changes. Other sessions still post, see [Which sessions use GET](../../best-practices/caching.html#which-sessions-use-get). `useLandingSearch` has no GET branch and always posts. See [Caching](../../best-practices/caching.html) for what that buys you. `useUrlResolver().resolveUrl` is not part of the resolution chain despite the name. It puts the application's URL prefix in front of a CMS-authored internal navigation link and leaves everything else untouched. @@ -343,7 +343,7 @@ The resolution is provided, not fetched twice. `useNavigationContext(seoUrl)` wr `resolvePath("/")` depends on the session context being loaded. It reads `sessionContext.salesChannel.navigationCategoryId`, so the home page cannot resolve before the root has seeded the context — and the id it returns changes with the sales channel, not with the route. -The lookup itself carries `sw-context-token` like any Store API call, but it changes nothing on the session. Whether it travels as a POST body or as a `_criteria` query parameter is decided once by `cacheableReads` in `nuxt.config.ts`, not per call. +The lookup itself carries `sw-context-token` like any Store API call, but it changes nothing on the session. Whether it travels as a POST body or as a `_criteria` query parameter is decided per call. GET needs `cacheableReads` in `nuxt.config.ts` and a fresh default guest session. `useUrlResolver().getUrlPrefix()` reads an injected `urlPrefix` with `""` as its default. Nothing in the composable provides it; the application does — `vue-starter-template` calls `provide("urlPrefix", prefix)` in `app.vue`, which is how the locale prefix reaches CMS components that render internal links. @@ -351,7 +351,7 @@ The lookup itself carries `sw-context-token` like any Store API call, but it cha - `resolvePath("/")` issues no request. Debugging a wrong home page means looking at the session context, not at the SEO URL table. - A SEO path is filtered with the leading slash removed (`path.substring(1)`), a technical path with its trailing slash removed by `normalizePath`. Sending the wrong form matches nothing. -- With `cacheableReads: true` the lookup is `readSeoUrlGet get /seo-url`, so a network assertion or a proxy rule written against `POST /seo-url` sees nothing. +- With `cacheableReads: true`, a fresh guest's lookup is `readSeoUrlGet get /seo-url`, so a network assertion or a proxy rule written against `POST /seo-url` sees nothing for that guest. - `getRouteFromPathInfo` recognises exactly three prefixes: `/navigation/`, `/detail/` and `/landingPage/`. It returns `null` when the remainder is empty or contains another slash. - The fallback resolution is synthetic: it has a `routeName` and a `foreignKey` and no `seoPathInfo`. `getCanonicalPathForTechnicalPath` returns `null` for it, so no redirect happens. - `getCanonicalPathForTechnicalPath` also returns `null` for SEO paths and for a mapping whose target is itself technical. Only a genuine technical-to-SEO mapping produces a redirect. @@ -373,7 +373,7 @@ The lookup itself carries `sw-context-token` like any Store API call, but it cha - Do not use a `302` for the canonical redirect. The helper's contract is a permanent mapping. - Do not add one Nuxt route per page type. The `routeName` selects the component. - Do not fetch the entity in the catch-all route. Pass the `foreignKey` down. -- Do not assume the lookup is a POST. Read `cacheableReads` before asserting on the request in a test or a cache rule. +- Do not assume the lookup is always a POST, or always a GET. Check `cacheableReads` and the session before asserting on the request in a test or a cache rule. - Do not surface `URL Input too long` to the customer. It is an internal guard. - Do not rely on `resolveUrl` to prefix arbitrary links. It handles navigation links only. - Do not let a failed lookup fall into the `404` branch. Read `error` from `useAsyncData` first and answer with a `5xx`, or a backend outage returns `404` for the whole catalogue and crawlers de-index it. @@ -385,7 +385,7 @@ The lookup itself carries `sw-context-token` like any Store API call, but it cha - Opening `/` resolves to `frontend.navigation.page` without any `/seo-url` request. - A SEO path issues one `/seo-url` lookup filtered on `seoPathInfo` without the leading slash. - A technical path issues one `/seo-url` lookup filtered on `pathInfo`. -- The lookup uses the GET route when `cacheableReads` is enabled and the POST route when it is not. +- The lookup uses the GET route for a fresh guest when `cacheableReads` is enabled, and the POST route after login or when the flag is off. - A technical path with a SEO mapping redirects once with a `301` to the canonical path. - A technical path without a SEO mapping renders the page directly, with no redirect. - An unknown path throws a 404 rather than rendering an empty component. diff --git a/examples/docs-code-examples/src/generated/best-practices/caching/enabling-it-3.ts b/examples/docs-code-examples/src/generated/best-practices/caching/enabling-it-3.ts index a03051f846..38a1ddfd97 100644 --- a/examples/docs-code-examples/src/generated/best-practices/caching/enabling-it-3.ts +++ b/examples/docs-code-examples/src/generated/best-practices/caching/enabling-it-3.ts @@ -1,14 +1,11 @@ -import { encodeForQuery } from "@shopware/api-client/helpers"; +import { useCacheableRead } from "#imports"; -import { useShopwareContext } from "#imports"; - -const { apiClient, cacheableReads } = useShopwareContext(); +const { invokeRead } = useCacheableRead(); const criteria = {}; -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; +} diff --git a/packages/api-client/README.md b/packages/api-client/README.md index bb403b3231..dda732310b 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 `""` to leave a default header out of one request. Example: `headers: { "sw-context-token": "" }`. Header names match case-insensitively. A response to a request that drops `sw-context-token` this way never changes the client's token. + ### 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`: @@ -560,6 +564,20 @@ apiClient.invoke("readProductGet get /product", { }); ``` +### mergeRequestHeaders + +Merges request headers over default headers, like `invoke` does. Names match case-insensitively and come back lowercase. A header set to `""` is removed. + +```typescript +import { mergeRequestHeaders } from "@shopware/api-client/helpers"; + +const headers = mergeRequestHeaders( + { "SW-Language-Id": "my-language-id" }, + apiClient.defaultHeaders, +); +headers["sw-language-id"]; // "my-language-id" +``` + ## Links - [📘 Documentation](https://developer.shopware.com/frontends) diff --git a/packages/api-client/package.json b/packages/api-client/package.json index 06a48a751a..1c26a9d0c5 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -77,7 +77,6 @@ "vitest": "5.0.0" }, "dependencies": { - "defu": "6.1.7", "hookable": "5.5.3", "ofetch": "1.5.1" } diff --git a/packages/api-client/src/createAPIClient.ts b/packages/api-client/src/createAPIClient.ts index a1fdc6b2ab..010f20ea9f 100644 --- a/packages/api-client/src/createAPIClient.ts +++ b/packages/api-client/src/createAPIClient.ts @@ -87,6 +87,14 @@ function isPubliclyCacheableResponse( return /(?:^|,)\s*public\s*(?:,|$)/i.test(cacheControl); } +const ANONYMOUS_REQUEST = Symbol("anonymousRequest"); + +function dropsContextToken(headers: ClientHeaders | undefined): boolean { + return Object.entries(headers ?? {}).some( + ([key, value]) => key.toLowerCase() === "sw-context-token" && value === "", + ); +} + export function createAPIClient< // TODO: Keep this broad until generated operation types are narrowed. OPERATIONS extends Record = operations, @@ -136,6 +144,10 @@ export function createAPIClient< if (isPubliclyCacheableResponse(context.response)) { return; } + // An anonymous request gets a guest token back. Keep the session. + if (ANONYMOUS_REQUEST in context.options) { + return; + } if ( context.response.headers.has("sw-context-token") && defaultHeaders["sw-context-token"] !== @@ -229,6 +241,9 @@ export function createAPIClient< body: currentParams.body, headers: mergedHeaders as HeadersInit, query: currentParams.query, + ...(dropsContextToken(currentParams.headers) && { + [ANONYMOUS_REQUEST]: true, + }), }); return { diff --git a/packages/api-client/src/createApiClient.test.ts b/packages/api-client/src/createApiClient.test.ts index 33bb7d1557..60ce1404f6 100644 --- a/packages/api-client/src/createApiClient.test.ts +++ b/packages/api-client/src/createApiClient.test.ts @@ -249,6 +249,179 @@ describe("createAPIClient", () => { ); }); + it.each([ + { name: "replace the client token", contextToken: "logged-in-token" }, + { name: "set a token", contextToken: undefined }, + ])( + "should NOT let a response to a request with an empty sw-context-token $name", + async ({ contextToken }) => { + 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, + 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(contextToken); + }, + ); + + it.each([ + { cacheControl: "no-cache, private", loginFirst: true }, + { cacheControl: "no-cache, private", loginFirst: false }, + { cacheControl: undefined, loginFirst: true }, + { cacheControl: undefined, loginFirst: false }, + ])( + "should keep the token of a concurrent login over an anonymous read (cache-control: $cacheControl, login responds first: $loginFirst)", + async ({ cacheControl, loginFirst }) => { + const read = Promise.withResolvers(); + const login = Promise.withResolvers(); + const app = createApp() + .use( + "/language", + eventHandler(async (event) => { + await read.promise; + setHeader(event, "sw-context-token", "fresh-guest-token"); + if (cacheControl) setHeader(event, "cache-control", cacheControl); + return { elements: [] }; + }), + ) + .use( + "/account/login", + eventHandler(async (event) => { + await login.promise; + setHeader(event, "sw-context-token", "login-token"); + return {}; + }), + ); + + const baseURL = await createPortAndGetUrl(app); + + const contextChangedMock = vi.fn().mockImplementation(() => {}); + + const client = createAPIClient({ + accessToken: "123", + baseURL, + }); + client.hook("onContextChanged", contextChangedMock); + + const steps = [ + { + release: read.resolve, + request: client.invoke("readLanguagesGet get /language", { + // @ts-expect-error sw-context-token is not a typed header of this endpoint + headers: { "sw-context-token": "" }, + }), + }, + { + release: login.resolve, + request: client.invoke("loginCustomer post /account/login", { + body: { username: "user", password: "pass" }, + }), + }, + ]; + for (const { release, request } of loginFirst ? steps.reverse() : steps) { + release(); + await request; + } + + expect(contextChangedMock.mock.calls).toEqual([["login-token"]]); + expect(client.defaultHeaders["sw-context-token"]).toEqual("login-token"); + }, + ); + + it.each([ + { + defaultHeaders: { "sw-context-token": "token" }, + headers: { "SW-CONTEXT-TOKEN": "" }, + }, + { + defaultHeaders: { "Sw-Context-Token": "token" }, + headers: { "sw-context-token": "" }, + }, + ])( + "should send no sw-context-token when an empty override has a different casing than the default", + async ({ defaultHeaders, headers }) => { + let receivedHeaders = {}; + const app = createApp().use( + "/language", + eventHandler(async (event) => { + receivedHeaders = getHeaders(event); + return { elements: [] }; + }), + ); + + const baseURL = await createPortAndGetUrl(app); + + const client = createAPIClient({ + accessToken: "123", + defaultHeaders, + baseURL, + }); + + // @ts-expect-error sw-context-token is not a typed header of this endpoint + await client.invoke("readLanguagesGet get /language", { headers }); + + // node lowercases incoming header names, so this covers every casing + expect(receivedHeaders).toHaveProperty("sw-access-key", "123"); + expect(receivedHeaders).not.toHaveProperty("sw-context-token"); + }, + ); + + it("should adopt sw-context-token from requests sent before the client had one", async () => { + const loginReleased = Promise.withResolvers(); + const app = createApp() + .use( + "/context", + eventHandler(async (event) => { + setHeader(event, "sw-context-token", "first-token"); + return {}; + }), + ) + .use( + "/account/login", + eventHandler(async (event) => { + await loginReleased.promise; + 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"); + loginReleased.resolve(); + 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/helpers/index.ts b/packages/api-client/src/helpers/index.ts index 3fa1c88323..acc9eb69ab 100644 --- a/packages/api-client/src/helpers/index.ts +++ b/packages/api-client/src/helpers/index.ts @@ -1 +1,2 @@ export { encodeForQuery } from "./encodeForQuery"; +export { mergeRequestHeaders } from "./mergeRequestHeaders"; diff --git a/packages/api-client/src/helpers/mergeRequestHeaders.test.ts b/packages/api-client/src/helpers/mergeRequestHeaders.test.ts new file mode 100644 index 0000000000..1dbe6bb5bf --- /dev/null +++ b/packages/api-client/src/helpers/mergeRequestHeaders.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { mergeRequestHeaders } from "./mergeRequestHeaders"; + +describe("mergeRequestHeaders", () => { + it("lets a caller header win over a default in any casing", () => { + expect( + mergeRequestHeaders( + { "sw-language-id": "caller" }, + { "Sw-Language-Id": "default", Accept: "application/json" }, + ), + ).toEqual({ "sw-language-id": "caller", accept: "application/json" }); + }); + + it("removes a header set to an empty string in every casing", () => { + expect( + mergeRequestHeaders( + { "SW-CONTEXT-TOKEN": "" }, + { "Sw-Context-Token": "token", "sw-access-key": "key" }, + ), + ).toEqual({ "sw-access-key": "key" }); + }); + + it.each([ + { "sw-context-token": "", "Sw-Context-Token": "token" }, + { "Sw-Context-Token": "token", "sw-context-token": "" }, + ])( + "removes the header when another casing is set in the same call", + (callerHeaders) => { + expect(mergeRequestHeaders(callerHeaders, {})).toEqual({}); + }, + ); + + it("skips empty or undefined defaults", () => { + expect( + mergeRequestHeaders(undefined, { + "sw-language-id": undefined, + "sw-currency-id": "", + accept: "application/json", + }), + ).toEqual({ accept: "application/json" }); + }); + + it("treats missing defaults as empty", () => { + expect( + mergeRequestHeaders({ "Sw-Language-Id": "caller" }, undefined), + ).toEqual({ "sw-language-id": "caller" }); + }); + + it("keeps the default when the caller header is undefined", () => { + expect( + mergeRequestHeaders( + { "sw-language-id": undefined }, + { "sw-language-id": "default" }, + ), + ).toEqual({ "sw-language-id": "default" }); + }); +}); diff --git a/packages/api-client/src/helpers/mergeRequestHeaders.ts b/packages/api-client/src/helpers/mergeRequestHeaders.ts new file mode 100644 index 0000000000..1093c87ba2 --- /dev/null +++ b/packages/api-client/src/helpers/mergeRequestHeaders.ts @@ -0,0 +1,23 @@ +import type { ClientHeaders } from "../defaultHeaders"; + +/** + * Merge request headers over the client defaults. Header names are matched + * case-insensitively and returned lowercase. A request header set to `""` + * removes the header in every casing. + */ +export function mergeRequestHeaders( + callerHeaders: ClientHeaders | undefined, + defaultHeaders: ClientHeaders | undefined, +): ClientHeaders { + const headers: ClientHeaders = {}; + for (const [key, value] of Object.entries(defaultHeaders ?? {})) { + if (value) headers[key.toLowerCase()] = value; + } + const dropped: string[] = []; + for (const [key, value] of Object.entries(callerHeaders ?? {})) { + if (value === "") dropped.push(key.toLowerCase()); + else if (value != null) headers[key.toLowerCase()] = value; + } + for (const name of dropped) delete headers[name]; + return headers; +} diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index fa464356fb..a87eb1749e 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -3,4 +3,5 @@ export { createAdminAPIClient } from "./createAdminAPIClient"; export { ApiClientError } from "./ApiError"; export { isTimeoutError } from "./isTimeoutError"; export type { ApiError } from "./ApiError"; +export type { ClientHeaders, ClientHeadersProxy } from "./defaultHeaders"; export type { WithApiOverrides } from "./withApiOverrides"; diff --git a/packages/api-client/src/resolveRequestHeaders.test.ts b/packages/api-client/src/resolveRequestHeaders.test.ts index 88efff3644..46a6d8c099 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"); @@ -192,6 +202,9 @@ describe("resolveRequestHeaders", () => { DEFAULTS, new Blob(["x"]), ); - expect(contentTypeOf(headers)).toBe("application/pdf"); + expect(headers).toEqual({ + "content-type": "application/pdf", + accept: "application/json", + }); }); }); diff --git a/packages/api-client/src/resolveRequestHeaders.ts b/packages/api-client/src/resolveRequestHeaders.ts index 72bce04b8c..1a794253af 100644 --- a/packages/api-client/src/resolveRequestHeaders.ts +++ b/packages/api-client/src/resolveRequestHeaders.ts @@ -1,6 +1,5 @@ -import { defu } from "defu"; - import type { ClientHeaders } from "./defaultHeaders"; +import { mergeRequestHeaders } from "./helpers/mergeRequestHeaders"; const CONTENT_TYPE = "content-type"; @@ -11,22 +10,6 @@ function findContentType(headers: ClientHeaders): string | undefined { return undefined; } -function contentTypeKeyCount(headers: ClientHeaders): number { - let count = 0; - for (const key of Object.keys(headers)) { - if (key.toLowerCase() === CONTENT_TYPE) count++; - } - return count; -} - -function withoutContentType(headers: ClientHeaders): ClientHeaders { - return Object.fromEntries( - Object.entries(headers).filter( - ([key]) => key.toLowerCase() !== CONTENT_TYPE, - ), - ); -} - /** * 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,12 +83,9 @@ export function resolveRequestHeaders( defaultHeaders: ClientHeaders, body: unknown, ): ClientHeaders { - const mergedHeaders = 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). + const mergedHeaders = mergeRequestHeaders(callerHeaders, defaultHeaders); const callerContentType = callerHeaders && findContentType(callerHeaders); - const contentType = callerContentType || findContentType(mergedHeaders); + const contentType = mergedHeaders[CONTENT_TYPE]; if (!contentType) return mergedHeaders; const normalized = contentType.toLowerCase(); @@ -128,11 +108,6 @@ export function resolveRequestHeaders( // non-JSON type (e.g. a Blob's image/png or an octet-stream default) (runtimeManaged && (isMultipart || isSeededDefault)); - if (shouldDrop) return withoutContentType(mergedHeaders); - - // Nothing to drop: leave the merged headers untouched unless the caller's - // Content-Type collided with the default under a different casing, leaving - // two Content-Type keys. In that case keep a single, canonical one. - if (contentTypeKeyCount(mergedHeaders) < 2) return mergedHeaders; - return { ...withoutContentType(mergedHeaders), "Content-Type": contentType }; + if (shouldDrop) delete mergedHeaders[CONTENT_TYPE]; + return mergedHeaders; } 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 dee87ec90d..03ec29a660 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 @@