Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b6af81d
fix: use cacheable GET reads only for fresh default guest sessions
mkucmus Sep 14, 2026
675b84a
fix: match headers case-insensitively and never adopt tokens from ano…
mkucmus Sep 14, 2026
2cfc8a1
test: cover guest server render reads and drop the empty reviews body
mkucmus Sep 14, 2026
59f92a2
chore(api-client): drop the unused defu dependency
mkucmus Sep 14, 2026
407ade1
docs: shorten sentences in docs, changesets and comments
mkucmus Sep 14, 2026
7b78974
fix: export client header types and treat missing session ids as unknown
mkucmus Sep 15, 2026
567c19a
docs: align cacheable read docs with the gate and harden header drop
mkucmus Sep 15, 2026
74077f3
test: match cacheable read keys without a regex
mkucmus Sep 15, 2026
24c2115
Merge branch 'main' into fix/cacheable-reads-session-gate
mkucmus Sep 16, 2026
7e42c45
fix(api-client): treat missing default headers as empty in mergeReque…
mkucmus Sep 17, 2026
302280f
Merge branch 'fix/cacheable-reads-session-gate' of https://github.com…
mkucmus Sep 17, 2026
4fd054a
Merge branch 'main' into fix/cacheable-reads-session-gate
mkucmus Sep 17, 2026
048ba7f
fix: Make guestServerRender optional in useShopwareContext
mkucmus Sep 17, 2026
da13787
Merge branch 'main' into fix/cacheable-reads-session-gate
mkucmus Sep 22, 2026
a62d8d5
fix: check a loaded cart during a guest server render
mkucmus Sep 22, 2026
d163edc
test: pin the gate branches with a shared guest fixture
mkucmus Sep 22, 2026
5977d8b
Merge remote-tracking branch 'origin/main' into fix/cacheable-reads-s…
mkucmus Sep 28, 2026
3c50ec3
fix: send category listing params as plain query params
mkucmus Sep 28, 2026
80e22df
Merge remote-tracking branch 'origin/main' into pr2720-merge
patzick Sep 28, 2026
bad7b48
Merge branch 'main' into fix/cacheable-reads-session-gate
mkucmus Sep 28, 2026
373ae20
Merge branch 'main' into fix/cacheable-reads-session-gate
mkucmus Sep 30, 2026
8b667fd
test: expect single-level cms associations in category GET test
mkucmus Sep 30, 2026
0cdde53
Merge branch 'main' into fix/cacheable-reads-session-gate
mkucmus Sep 30, 2026
d64d007
Merge branch 'main' into fix/cacheable-reads-session-gate
mkucmus Oct 5, 2026
2c9b9fa
Merge branch 'main' into fix/cacheable-reads-session-gate
mkucmus Oct 7, 2026
be3a181
Merge branch 'main' into fix/cacheable-reads-session-gate
mkucmus Oct 8, 2026
1eb882d
Merge remote-tracking branch 'origin/main' into fix/cacheable-reads-s…
mkucmus Oct 15, 2026
2aa07d9
docs: limit cacheable GET claims to fresh default guests
mkucmus Oct 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-reviews-read.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopware/cms-base-layer": patch
---

`CmsElementProductDescriptionReviews` loads reviews with `useCacheableRead().invokeRead`.
5 changes: 5 additions & 0 deletions .changeset/fresh-guests-read.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/quiet-tokens-stay.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/shared-carts-stay.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 7 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
90 changes: 58 additions & 32 deletions apps/docs/src/best-practices/caching.md

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions apps/docs/src/frontends-recipes/account/profile.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | <SchemaTypeTooltip type-key='operations["readSalutation post /salutation"]["response"]' /> <SchemaTypeTooltip type-key='operations["readSalutationGet get /salutation"]["response"]' /> |
| Change the profile | `updatePersonalInfo(profile)` | `POST /account/change-profile` | <SchemaTypeTooltip type-key='operations["changeProfile post /account/change-profile"]["body"]' /> |
| Change the email address | `updateEmail(emailChange)` | `POST /account/change-email` | <SchemaTypeTooltip type-key='operations["changeEmail post /account/change-email"]["body"]' /> |
| Read the customer back | `refreshUser()` | `POST /account/customer` | <SchemaTypeTooltip type-key='operations["readCustomer post /account/customer"]["response"]' /> |
| Convert a guest | `apiClient.invoke("convertGuest …", { body: { password } })` | `POST /account/convert-guest` | <SchemaTypeTooltip type-key='operations["convertGuest post /account/convert-guest"]["body"]' /> |
| 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` | <SchemaTypeTooltip type-key='operations["readSalutation post /salutation"]["response"]' /> <SchemaTypeTooltip type-key='operations["readSalutationGet get /salutation"]["response"]' /> |
| Change the profile | `updatePersonalInfo(profile)` | `POST /account/change-profile` | <SchemaTypeTooltip type-key='operations["changeProfile post /account/change-profile"]["body"]' /> |
| Change the email address | `updateEmail(emailChange)` | `POST /account/change-email` | <SchemaTypeTooltip type-key='operations["changeEmail post /account/change-email"]["body"]' /> |
| Read the customer back | `refreshUser()` | `POST /account/customer` | <SchemaTypeTooltip type-key='operations["readCustomer post /account/customer"]["response"]' /> |
| Convert a guest | `apiClient.invoke("convertGuest …", { body: { password } })` | `POST /account/convert-guest` | <SchemaTypeTooltip type-key='operations["convertGuest post /account/convert-guest"]["body"]' /> |
| 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.

Expand All @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/frontends-recipes/account/register.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ You do not call `readContext get /context` yourself after registering, because `
| Refresh the cart | `refreshCart()` | `GET /checkout/cart` | <SchemaTypeTooltip type-key='operations["readCart get /checkout/cart"]["response"]' /> |
| Confirm a double opt-in link | `apiClient.invoke("registerConfirm post /account/register-confirm")` | `POST /account/register-confirm` | <SchemaTypeTooltip type-key='operations["registerConfirm post /account/register-confirm"]["body"]' /> |

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

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/frontends-recipes/catalog/cross-selling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 6 additions & 6 deletions apps/docs/src/frontends-recipes/catalog/reviews.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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.
Expand Down
Loading
Loading