Skip to content

Release January 20, 2026 - #2582

Open
github-actions[bot] wants to merge 297 commits into
mainfrom
changeset-release/from-canary
Open

Release January 20, 2026#2582
github-actions[bot] wants to merge 297 commits into
mainfrom
changeset-release/from-canary

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Automated release PR from canary branch

@vercel

vercel Bot commented Jan 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
graphcommerce-246 Ready Ready Preview Aug 11, 2026 8:00pm
graphcommerce-247 Ready Ready Preview Aug 11, 2026 8:00pm
graphcommerce-hygraph-dynamic-rows-ui Ready Ready Preview Aug 11, 2026 8:00pm
graphcommerce-open-source Ready Ready Preview Aug 11, 2026 8:00pm
graphcommerce-storyblok Ready Ready Preview Aug 11, 2026 8:00pm

Request Review

…ing & widen RowRenderer content type

- useStoryblokState now narrows to StoryblokPage; global-config uses the upstream
  hook directly via the typed GlobalConfigStory returned by fetchGlobalConfig
- RowRenderer accepts a wider content type so typed StoryblokPage body unions
  ([k: string]: unknown) are assignable
- fetchGlobalConfig and useStoryblokState throw a descriptive error in dev when
  content type doesn't match, return null in production
- fetchStory/fetchStories log non-404 errors in dev so real failures (network,
  auth, malformed) are distinguishable from missing stories
to make sure all pages get fetched when passing the 100 story per fetch API limit

- Fetch page 1, then derive remaining pages from response.total and fetch them
  in parallel chunks of 3 so large slug prefixes aren't capped at 100 stories
- Add fetchWithRetry: retries 429s up to 3 times honoring the Retry-After
  header, lets all other errors propagate to the existing logFetchError path
Wrap the single-story fetch in fetchWithRetry so 429s during builds with many
slugs are retried with Retry-After, matching fetchStories' behavior.
…pages

- RowProduct accepts an optional items prop that bypasses the SKU resolve
  query, used when the parent already has products to surface
- Category page wires a row_product renderer override that passes the first
  8 category products, mirroring the Hygraph pattern
…Paths or sitemap etc) and fetchStories for manually pagination pages (blog)
…ckage

and wrap non-generic stuff with a thin wrapper function in local lib/storyblok helper file
…ypes

The upstream storyblokEditable expects SbBlokData, but auto-generated
component types widen props to `[k: string]: unknown`, which isn't
assignable. The wrapper accepts the wider shape (plus null/undefined)
and returns {} when the blok is missing.
Removes the need for type casting per call location.
The 'global/config' slug and 'global_config' component name are
project-specific, so keeping the function in the package forced those
constants into generic code. Export FetchStoryOpts from the package
instead so the local wrapper can type its own signature.
Added better default renderers (like in Hygraph)
Allow for custom renderers
paales and others added 30 commits August 6, 2026 14:19
…refer-resolved-full-slug

fix(storyblok-ui): resolve story multilinks to the target's current slug
…omer-account-urls

fix(next-config): redirect Magento account URLs to the GraphCommerce routes
Review feedback: remembering individual 404s does not help against the case it
most needs to — crawlers and vulnerability scanners walk an unbounded number of
made-up URLs, and a catch-all route asks the CMS about every one of them. Every
made-up URL is a fresh slug, so a per-slug negative cache never gets a second
chance to help and each one still costs a CDN request.

Replace it with a slug index: one `cdn/links` request per cache-version lists
every published slug in the space, and anything outside that set is answered as
"not found" without a request. Measured against a live space: 300 random URLs
now cost 0 requests instead of 300, while known slugs are unaffected.

- Deliberately unscoped by language. `cdn/links?language=x` returns only the
  stories translated into that language (25 of 63 on the space I tested, for a
  language the space does not even define), while `cdn/stories/<slug>?language=x`
  falls back to the default language and resolves all of them — so a
  language-scoped index would report existing pages as missing.
- Folders are skipped. A folder with a start page contributes that start page as
  its own entry (`clubkleding/`), which is what makes `fetchStory('clubkleding')`
  resolve while a folder without one correctly reports as missing.
- Memoized per cache-version in module scope rather than through the client's
  response cache, because that cache is opt-in and off by default.
- Never claims absence when it does not know: a failed index request, or a space
  larger than 10 000 stories, falls back to the previous per-slug behaviour.
  Draft/preview reads and development skip the index entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`requestStoryblokCacheVersionRefresh()` — what a cache-notify webhook calls when a
story is published — only flipped a flag in the process that happened to receive
the webhook. Nothing told the other replicas anything had changed, so the only
thing keeping them fresh was `cacheVersionTtl`: one `cdn/spaces/me` per server per
interval, forever, whether or not anything is ever published. At the previous 60
second default that is ~450 000 requests a month across a ten-pod deployment, spent
entirely on discovering that nothing happened.

The refresh now fans out over a file instead. The webhook writes a millisecond
timestamp to `renew-all-pages-query.txt` and every server compares it against the
last value it acted on before a published read — a few bytes off the filesystem,
throttled to once a second, never an API request. A server that sees a newer signal
fetches the current cache-version once and pins it, so the cost of a publish is one
request per server that actually serves traffic, instead of one request per server
per minute forever.

- The file name is not new: it is the one the SSR Apollo client convention already
  reads (`examples/*/lib/graphql/graphqlSsrClient.ts` drops its per-locale client
  when the timestamp moves past its own), so one publish can invalidate both caches
  and the contract stays "this file contains a number".
- `storyblok.cacheVersionSignalDir` says where that file lives. It defaults to the
  `./tmp` of the existing convention, which resolves *inside each container* — a
  multi-replica deployment has to point it at a shared volume or the signal never
  leaves the pod that wrote it. `renewSignalPath()` is exported so a project's
  `graphqlSsrClient` can read the same file without hardcoding the path twice.
- `cacheVersionTtl` now defaults to 3600 rather than 60. It is no longer what keeps
  content fresh, only what bounds staleness where the signal cannot be delivered:
  serverless, local development, an unwritable directory. It stays applied rather
  than disabled on purpose — a misconfigured webhook has to degrade to stale
  content, never to frozen content.
- The real cache-version is still fetched rather than derived from the signal's
  timestamp. Storyblok answers a request carrying an unknown `cv` with a 301 to the
  canonical one, so a made-up value would work, but it costs every replica a
  redirect round-trip on its next read and keys the slug index on a version that is
  about to be replaced.
- `node:fs` is loaded through a `webpackIgnore`d dynamic import. This module is
  re-exported from the package's single barrel entry point and Next.js does not
  tree-shake in development, so a static import would break every client component
  importing from the package.

Draft, preview and development reads are untouched — they already send a fresh `cv`
per request.

Verified with ten real OS processes against one shared directory: before, a publish
reached 1 of 10 processes and the other nine kept serving the pre-publish version;
after, all 10 picked it up within a second, at one `cdn/spaces/me` each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`useClearCurrentCartId()` returned a bare arrow function and `useCartLock()`
defined `lock`/`unlock` inline, so all three got a new identity on every
render. Consumers that correctly list them in a `useEffect` dependency array
therefore re-ran that effect on every render.

`useClearCurrentCartId` now uses `useCallback` with `[cache]`, matching its
neighbour `useAssignCurrentCartId`. `lock`/`unlock` use `useEventCallback`
because they read values that legitimately change (`currentCartId`, the
router query state, the Apollo client); that keeps the identity stable
without introducing a stale closure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…identities

fix(magento-cart): stable identities for cart clear and cart lock hooks
The renew signal was a file in a configurable directory, which only fans out
when every replica happens to mount the same volume — the constraint that makes
the `./tmp` convention in `examples/*/lib/graphql/graphqlSsrClient.ts` unreliable
in the first place. `storyblok.cacheVersionSignalDir` made that a supported
option rather than fixing it, so both the config key and the filesystem access
are gone.

The signal now goes through `globalThis.__incrementalCache`, so it reaches every
server that shares a `cacheHandler` — the same requirement a deployment already
has to meet to serve consistent ISR, and nothing extra to configure. Validated on
a two-pod cluster: pod A writes, pod B reads the value within 9 ms.

This leans on two Next.js internals. The global is set in `handleRequestImpl`
(`next/dist/server/base-server.js`) on every incoming request, ahead of API
routes and ISR regeneration, under the same name and shape in Next 13 through 16;
middleware and the edge runtime build their own instance and are not covered.
Writing under `kind: 'FETCH'` with `fetchCache: true` is the one path that stores
a key verbatim rather than through `normalizePagePath()`, which is what allows a
namespaced key. Both are unstable: if either changes, reads and writes fail
closed and freshness falls back to `cacheVersionTtl`. The call shape has drifted
before — `revalidate` became `cacheControl` between Next 14 and 15 — so it needs
re-testing per major.

Why the push is worth having at all: `cdn/spaces/me` polls are billed per request
against the Storyblok quota, so the polling interval is a cost, not just a
staleness knob. `cacheVersionTtl` stays at 3600 as the failsafe.

Also drops the justification prose from the comments and rewrites both changesets
to describe what changes rather than argue for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…blok

Two mechanisms were solving the same problem in different ways: Storyblok
pushed a renew signal through the incremental cache, while `graphqlSsrClient()`
read `./tmp/renew-all-pages-query.txt`. That path resolves inside each
container, so on a multi-replica deployment it silently invalidated nothing —
the assumption that made the file approach wrong for Storyblok in the first
place.

The signal moves to `@graphcommerce/graphql` as `publishRenewSignal()` /
`renewSignal()` / `refreshRenewSignal()`. That package is the right home by
dependency direction: `storyblok-ui` peer-depends on it, it does not depend on
`storyblok-ui`, and all three examples already import from it. The key is
neutral (`gc:signal:content-renew`) because the fact being published is "content
changed", not anything Storyblok-specific.

`graphqlSsrClient()` is synchronous and called as
`const client = graphqlSsrClient(context)` throughout `getStaticProps`, so it
cannot await the read; making it async would break every consumer in every
project. It reads the last known value and schedules the next refresh, which
bounds staleness at one poll interval plus one call.

That makes the cold start explicit rather than accidental. `renewSignal()`
returns `undefined` until the first read completes, which is distinct from "read,
and nothing was published" — every pod is in that state right after a deploy, and
treating it as 0 would silently skip the first invalidation. Callers create the
client without invalidating (a client made now cannot predate a publish) and let
the scheduled read decide the next call. Concurrent callers share one in-flight
read, so a fresh pod taking simultaneous requests does not open one per
`getStaticProps`.

Also sets the `cacheVersionTtl` schema default to 3600 to match the repo
convention. The `?? 3600` fallback stays: schema defaults are applied by the zod
parse but not carried into the generated config values, which is what the runtime
imports — `configurableVariantForSimple` has a `= false` default and still
generates `undefined`.

Verified against a stand-in incremental cache: 25 concurrent refreshes cost one
read, a second process picks up a publish, and the client is recreated once per
publish and stable in between.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…er-api-calls

fix(storyblok-ui): cut redundant Storyblok CDN requests
…ignment

feat!: GraphCommerce 11 — drop Node 20, require Node 22+
fix(date): parse d/m/Y format and avoid mangling ISO offset strings in toDate
OrderInformationInput.lastname (2.4.8) and .postcode (2.4.9) were emitted with
@deprecated, but the GraphQL spec forbids deprecating a required input field —
and postcode oscillates (String! in 2.4.7, removed in 2.4.8, re-added nullable
in 2.4.9), so mergeTypeDefs unions our marker onto the earlier non-null
declaration. Both broke `buildSchema`/mesh ("Required input field X cannot be
deprecated"). Regenerated without the marker on those fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Magento 2.4.9 renamed the guestOrder argument type OrderInformationInput ->
GuestOrderInformationInput and swapped the postcode lookup field for lastname.
The GuestOrder query and guest order form now use the new type/field, so codegen
validates against a 2.4.9 backend. schema-249 re-declares
guestOrder(input: GuestOrderInformationInput!) (generated) so operations keep
validating on older backends via the mesh version shim — mergeTypeDefs is
last-wins for a field's arguments.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The schema-<v> folders are layered onto an OLDER Magento backend, which does not
have the added fields and can never produce a value for them. Declaring them
non-null made codegen emit required properties — breaking algolia-products'
getStoreConfig on a 2.4.7 backend with "Type 'StoreConfig' is missing the
following properties ... and 15 more" once schema-248/249 contributed 19
non-null StoreConfig fields — and was semantically wrong: a non-null field that
resolves to null propagates the error up and nullifies its parent, so one
backfilled Boolean! would blow up the whole storeConfig object at runtime.

Brand-new types keep their nullability (only reachable via an already-nullable
field), as do redeclared existing fields such as guestOrder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ions

Schema defintions for 246,247 and 248
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants