@preprio/toolkit 0.6.0-alpha.1 → 0.6.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +163 -61
  2. package/dist/astro/{chunk-O7N6GPAH.js → chunk-CG2QBHNU.js} +1 -1
  3. package/dist/astro/graphql.cjs +58 -10
  4. package/dist/astro/graphql.d.cts +33 -38
  5. package/dist/astro/graphql.d.ts +33 -38
  6. package/dist/astro/graphql.js +59 -11
  7. package/dist/astro/index.cjs +1 -1
  8. package/dist/astro/index.js +1 -1
  9. package/dist/{chunk-VI6QZTBM.js → chunk-LIDUJWWJ.js} +1 -1
  10. package/dist/{chunk-TBKSBXGH.js → chunk-M3RBYJ5J.js} +52 -5
  11. package/dist/{chunk-3JLWZQ37.js → chunk-SF6TN4JR.js} +1 -1
  12. package/dist/{chunk-YWIKKWBR.js → chunk-TN6SNAR2.js} +1 -1
  13. package/dist/graphql/codegen.cjs +51 -4
  14. package/dist/graphql/codegen.js +3 -3
  15. package/dist/graphql.cjs +89 -10
  16. package/dist/graphql.d.cts +69 -39
  17. package/dist/graphql.d.ts +69 -39
  18. package/dist/graphql.js +38 -9
  19. package/dist/index.cjs +1 -1
  20. package/dist/index.d.cts +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +3 -3
  23. package/dist/nextjs/chunk-B4GVZEOL.js +549 -0
  24. package/dist/nextjs/components.cjs +297 -81
  25. package/dist/nextjs/components.d.cts +26 -11
  26. package/dist/nextjs/components.d.ts +26 -11
  27. package/dist/nextjs/components.js +288 -73
  28. package/dist/nextjs/graphql.cjs +856 -266
  29. package/dist/nextjs/graphql.d.cts +184 -116
  30. package/dist/nextjs/graphql.d.ts +184 -116
  31. package/dist/nextjs/graphql.js +543 -266
  32. package/dist/nextjs/index.cjs +116 -54
  33. package/dist/nextjs/index.d.cts +61 -23
  34. package/dist/nextjs/index.d.ts +61 -23
  35. package/dist/nextjs/index.js +97 -299
  36. package/dist/nextjs/{server-CjpELYvq.d.ts → server-BL-sqZvu.d.cts} +1 -1
  37. package/dist/nextjs/{server-CjpELYvq.d.cts → server-BL-sqZvu.d.ts} +1 -1
  38. package/dist/nuxt/{chunk-IQDSBPDN.js → chunk-FMC3DP7N.js} +1 -1
  39. package/dist/nuxt/graphql.cjs +58 -10
  40. package/dist/nuxt/graphql.d.cts +33 -38
  41. package/dist/nuxt/graphql.d.ts +33 -38
  42. package/dist/nuxt/graphql.js +59 -11
  43. package/dist/nuxt/index.cjs +1 -1
  44. package/dist/nuxt/index.js +1 -1
  45. package/dist/react/components.cjs +1 -1
  46. package/dist/react/components.js +1 -1
  47. package/dist/sveltekit/{chunk-OKYZ6HPH.js → chunk-AJPKBAHO.js} +1 -1
  48. package/dist/sveltekit/graphql.cjs +58 -10
  49. package/dist/sveltekit/graphql.d.cts +33 -38
  50. package/dist/sveltekit/graphql.d.ts +33 -38
  51. package/dist/sveltekit/graphql.js +59 -11
  52. package/dist/sveltekit/index.cjs +1 -1
  53. package/dist/sveltekit/index.js +1 -1
  54. package/package.json +1 -1
  55. package/dist/nextjs/chunk-BQ6IGSH6.js +0 -231
package/README.md CHANGED
@@ -225,7 +225,9 @@ Codegen's default client-preset output works as is; `documentMode: 'string'` wor
225
225
 
226
226
  `personalized` is `true` when the queried fields contain segment variants or A/B tests, at any depth. Use it to decide whether a page can be served statically.
227
227
 
228
- To serve pages from the cache until you publish, set `revalidate: false` and call `revalidateTag('prepr')` from a Prepr publish webhook. With `revalidate` unset, the installed Next version's fetch caching default applies.
228
+ To serve pages from the cache until you publish, set `revalidate: false` and call `revalidateTag('prepr')` from a Prepr publish webhook. With `revalidate` unset, the installed Next version's fetch caching default applies. With Cache Components, a webhook can refresh only the pages showing the changed item instead: see [Refreshing on publish](#refreshing-on-publish).
229
+
230
+ Tags you pass per query in `init.next.tags` are added to the client's `cacheTags`, never in place of them.
229
231
 
230
232
  In preview, pass `preview: true`: queries skip the cache and stega markers stay in text for click-to-edit. They are always stripped from URL-like fields.
231
233
 
@@ -233,11 +235,11 @@ Custom headers go in two places. Client-level `headers` (`createPreprClient({ ur
233
235
 
234
236
  #### Personalization with Cache Components
235
237
 
236
- With [Cache Components](https://nextjs.org/docs/app/getting-started/cache-components) (`cacheComponents: true`), a personalized page is still served from the cache: what never varies and every A/B test are in the static shell, and only the parts that differ per segment stream in. You keep your own query and your own components.
238
+ With [Cache Components](https://nextjs.org/docs/app/getting-started/cache-components) (`cacheComponents: true`), a personalized page is still served from the cache, whole: every variant of every A/B test and personalization group is in the prerendered page, and each page view's decision shows the visitor's variant before the page paints. Nothing streams, and no default variant shows first to be swapped out. You keep your own query and your own components.
237
239
 
238
240
  **Do you need this?** Only if your pages are cached. A page that renders per request already gets this visitor's variants from `query(..., { personalize: true })`; render the result as is and skip this section.
239
241
 
240
- A normal page needs three changes:
242
+ A normal page needs two changes, plus the [proxy](#the-proxy):
241
243
 
242
244
  ```tsx
243
245
  import { PreprStack } from '@preprio/toolkit/nextjs/graphql';
@@ -261,14 +263,10 @@ export default async function Page({
261
263
  const slug = (await params).slug?.join('/') || '/';
262
264
  const data = await getPage(slug);
263
265
 
264
- // 2. Ask which variants this visitor gets. Do not await it.
265
- const decisions = prepr.decide(PageDocument, { slug }, { from: data });
266
-
267
- // 3. Render the list through PreprStack instead of directly.
266
+ // 2. Render the list through PreprStack instead of directly.
268
267
  return (
269
268
  <PreprStack
270
269
  items={data.Page.content}
271
- decisions={decisions}
272
270
  render={(items, { index }) => <Sections items={items} offset={index} />}
273
271
  />
274
272
  );
@@ -288,7 +286,6 @@ Because `render` runs per block, a position counted inside `Sections` (`items.ma
288
286
  // After
289
287
  <PreprStack
290
288
  items={data.Page.content}
291
- decisions={decisions}
292
289
  render={(items, { index }) => <Sections items={items} offset={index} />}
293
290
  />
294
291
 
@@ -308,11 +305,11 @@ A group takes one position, whichever variant shows. A group that shows nothing
308
305
  | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
309
306
  | One page-builder list (`content`, `sections`, `stack`) | One `PreprStack` around it. |
310
307
  | Personalized lists inside sections (cards, buttons, models in a section) | Nothing. They are narrowed down with the section they sit in. |
311
- | Several lists (hero, body, sidebar) | One `PreprStack` per list, all with the same `decisions`. |
308
+ | Several lists (hero, body, sidebar) | One `PreprStack` per list. |
312
309
  | A personalized list outside any list you already wrap (e.g. `Page.buttons`) | Its own `PreprStack`. |
313
310
  | Data used outside rendering (`generateMetadata`, JSON-LD) | `resolveVariants(data)` gives the default variants and A. |
314
311
  | Impression tracking for Prepr's metrics | `trackVariants` on `PreprStack`; see [Impressions and conversions](#impressions-and-conversions). |
315
- | No personalization on a page | Nothing to change. `PreprStack` renders it statically and `decide()` sends no request. |
312
+ | No personalization on a page | Nothing to change. `PreprStack` renders it as is and the proxy sends no request. |
316
313
 
317
314
  ##### Impressions and conversions
318
315
 
@@ -321,7 +318,7 @@ Prepr's tracking pixel counts impressions of an element carrying `data-prepr-var
321
318
  Opt in with `trackVariants`, and `PreprStack` puts the key on the section each personalized and A/B variant renders:
322
319
 
323
320
  ```tsx
324
- <PreprStack items={data.Page.content} decisions={decisions} trackVariants render={…} />
321
+ <PreprStack items={data.Page.content} trackVariants render={…} />
325
322
  ```
326
323
 
327
324
  For variants nested inside a section (cards, buttons, models), and outside Next.js, spread `preprVariantProps(item)` on the element that shows the item:
@@ -335,62 +332,94 @@ Conversions stay your choice: put `data-prepr-variant-event` (or `data-prepr-var
335
332
  - Keys are added after the page hydrates; the pixel picks them up then. Each item of a variant tags the element it renders, in order. A variant that renders a different number of elements than it has items (a section with several root elements, or one rendering nothing) tags only its first element.
336
333
  - With `trackVariants`, each variant sits in a `display: contents` wrapper. It takes no box, but CSS child combinators (`>`) and `:first-child` from the stack's parent no longer reach the sections.
337
334
  - An element that already has `data-prepr-variant-key` keeps it, so existing manual tagging keeps working.
338
- - Tagging yourself (with your own `_context { variant_key }` or `preprVariantProps`)? Skip the tag when `render` gets `placeholder: true`: that render is the default variant standing in until the visitor's variant of a segment group streams in, and the pixel counts anything with a key that appears on screen, however briefly.
339
-
340
- ```tsx
341
- render={(items, { index, placeholder }) => (
342
- <Sections items={items} offset={index} tagVariants={!placeholder} />
343
- )}
344
- ```
335
+ - `trackVariants` tags only the variant that is shown, once it is shown. A section holding a personalized list (a logo strip whose logos vary) gets the key of the variant it shows. Do not tag stack sections yourself (with your own `_context { variant_key }` or `preprVariantProps`): that puts a key on every variant in the page, including the invisible default that holds its space while the decision is on its way, and Prepr's pixel counts any tagged element in view, invisible or not.
345
336
 
346
- - A hidden A/B variant never scrolls into view, so it records no impression.
337
+ ##### The proxy
347
338
 
348
- ##### A/B tests
349
-
350
- `PreprStack` renders both variants of an A/B test in the static shell and shows the visitor's one before first paint; A is the default, also for crawlers. The visitor's bucket comes from a cookie set in your middleware. Prepr assigns the bucket, once per visitor, and only on pages that have an A/B test:
339
+ One call in `proxy.ts` gives `PreprStack` what it needs: the visitor's Prepr headers and customer id, and each page view's decision: the visitor's variant of every A/B test and personalization group on a page, in one Prepr request. Only the first load of a URL by a visitor waits for it; see [How each visitor gets their variant](#how-each-visitor-gets-their-variant). Tell it which query each URL renders; most sites route every page through one query:
351
340
 
352
341
  ```ts
353
342
  // proxy.ts
354
- const response = createPreprMiddleware(request, { preview });
355
- await resolvePreprBucket(request, response, {
356
- url: process.env.PREPR_GRAPHQL_URL!,
357
- preview,
358
- onlyIf: () => prepr.hasAbTests(PageDocument, { slug }),
343
+ import { createPreprProxy } from '@preprio/toolkit/nextjs/graphql';
344
+
345
+ const handlePrepr = createPreprProxy({
346
+ client: prepr,
347
+ // URL → the query that page renders with `variants: true`; null for non-CMS URLs.
348
+ page: (url) => ({
349
+ document: PageDocument,
350
+ variables: { slug: url.pathname },
351
+ }),
352
+ preview: process.env.PREPR_ENV === 'preview',
359
353
  });
360
- return response;
361
- ```
362
-
363
- Sites without A/B tests leave this out. Under a strict Content-Security-Policy, pass `nonce` to `PreprStack`.
364
354
 
365
- ##### Personalized content above the fold
366
-
367
- A segment group streams in: the default variant shows first, then this visitor's variant replaces it. Below the fold nobody sees that. In the hero they do, so for the blocks on screen before the visitor scrolls, decide in the proxy instead:
355
+ export function proxy(request: NextRequest) {
356
+ return handlePrepr(request); // add your own cookies or headers to this response
357
+ }
358
+ ```
368
359
 
369
360
  ```tsx
370
- <PreprStack items={data.Page.content} decisions={decisions} aboveTheFold={1} render={…} />
361
+ // app/layout.tsx, once: decides every client-side navigation
362
+ import { PreprPersonalizationSwitch } from '@preprio/toolkit/nextjs';
363
+
364
+ <PreprPersonalizationSwitch />;
371
365
  ```
372
366
 
367
+ Deriving the decision query from `PageDocument` needs Prepr's schema, which a fresh server instance downloads before its first decision. Build the decision query ahead of time instead and pass it as `decision`: the codegen plugin (see [Using your own GraphQL client](#using-your-own-graphql-client)) writes it next to your query, and the proxy sends it as is.
368
+
373
369
  ```ts
374
- // proxy.ts, after createPreprMiddleware
375
- await prepr.resolveDecisions(
376
- request,
377
- response,
378
- PageDocument,
379
- { slug },
380
- {
381
- // Only pages whose personalized content is above the fold need it.
382
- onlyIf: () => slug === '/',
383
- },
384
- );
370
+ import { PageDecisionQuery } from './gql/prepr-queries';
371
+
372
+ page: (url) => ({
373
+ document: PageDocument,
374
+ decision: PageDecisionQuery, // no schema download at runtime
375
+ variables: { slug: url.pathname },
376
+ }),
385
377
  ```
386
378
 
387
- `PreprStack` puts every variant of those blocks' segment groups in the cached page, and the A/B switch script shows the variant the proxy decided before first paint: nothing on screen changes. `resolveDecisions` forwards the visitor's headers (customer id, UTM parameters, IP, HubSpot id) to Prepr and stores the answer in the `prepr_decisions` cookie.
379
+ API routes, `/_next/` and URLs whose last segment has a file extension (`/logo.png`, but also `/docs/v1.2`) are skipped. A decision request for any other URL `page` maps to a query sends one decision query; a page without groups gets an empty answer. Pass `onlyIf: (request) => boolean` to skip Prepr for some requests, such as crawlers, which then get the default variants. In preview it also sends the editor through the [draft route](#preview) first. `createPreprMiddleware` and `preprDraftRedirect` stay exported for proxies that need them separately; decision requests are answered by `createPreprProxy` only.
380
+
381
+ ##### A/B tests
382
+
383
+ A/B tests go through the same decision as segment groups: both variants are in the prerendered page, hidden until the page view's decision shows the visitor's one. Prepr assigns a variant per experiment, from the visitor's customer id and that experiment's split, so the decision is the only reliable source; one stored bucket per visitor cannot follow a 20/80 and a 50/50 test at once. A is the default, also for crawlers and when Prepr cannot be asked. Pass `bucket` to `PreprStack` only when the route already knows it. Under a strict Content-Security-Policy, pass `nonce` to `PreprStack`.
384
+
385
+ ##### How each visitor gets their variant
386
+
387
+ Every page view is decided, and its personalized sections paint with the visitor's variant. Decisions are kept per URL in the visitor's `prepr_decisions` cookie, which the proxy and the browser both read and write (see **Reuse** below); nothing is reused from a prefetch, the router cache or another URL's view.
388
+
389
+ - **Page loads.** The page is the same for every visitor and comes from the cache. When the cookie holds no decision for the URL, the proxy asks Prepr before sending it (one request, at most `timeoutMs`) and puts the answer in the cookie; otherwise the page passes straight through. An inline script at the start of each `PreprStack` reads the cookie and shows the visitor's variants before the page paints. So only a visitor's first load of each personalized URL waits for Prepr, on the server, where the browser keeps showing the previous page instead of an empty section. Pages without personalization are remembered by the proxy and never wait.
390
+ - **Client-side navigations.** When the visitor hovers over a link (or presses it, on touch devices), `PreprPersonalizationSwitch` asks the proxy for the target page's decision. A click whose decision is in, which hovering and the cookie mostly ensure, goes through at once; one without (a quick tap, a link reached by keyboard) waits for it, so the new page always paints with the visitor's variants. Links to pages without personalization never wait. Prefetches still bring the page itself; only the decision is fetched.
391
+ - **Never the default first.** Until a view's decision is in, its personalized sections are not shown: the default variant keeps its space, invisible, so the page does not move when the visitor's variant appears (only by any difference in height). The default variant only shows when it is the decision: for visitors in none of the segments, for requests `onlyIf` skips, and when Prepr does not answer within `timeoutMs` (default 1000). Navigations started from code to a URL without a decision ask for one when the page opens; its personalized sections appear when it arrives.
392
+ - **Reuse.** Back and Forward show a page as this tab last showed it, at once, without asking Prepr. Any other view of a URL the cookie holds a decision for shows it at once too: under 10 seconds old without asking again, since Prepr needs a few seconds to process a visitor's activity anyway; up to 30 minutes old while a new decision is asked for in the background. When that one differs, it changes only the sections still below the screen; a section the visitor can see, or has scrolled past, keeps its variant for that view, so nothing they are reading is swapped or moves under them. The next view shows the new decision. A page without personalization answers with no groups, which is trusted for an hour with no background check, so links to it cost nothing; when that page renders personalization by then (published since), it asks at once. A URL with other UTM parameters is another view; a failed answer is kept for 10 seconds and never replaces a real one.
393
+ - **"Right" means what Prepr knows when the visitor clicks.** Prepr assigns segments from the visitor's tracked activity. If it has not yet processed the pixel event of the previous page view, the decision cannot include it, and the visitor sees that segment from a later page view.
394
+ - **Costs.** The page carries the HTML of every variant, and an item with groups nested inside it once per combination of their variants. `render` gets `defaultVariant`: give `priority` images only to the default variant and `loading="lazy"` to the others, or the browser downloads every variant's hero image. Every page load with personalization, and every link hovered or pressed (also a press that turns into a scroll), is at most one decision request when this tab has no fresh decision for that URL, answered from Prepr's cache when other visitors had the same answer.
395
+ - Without JavaScript, visitors see the default variants.
396
+
397
+ ##### Deploying
398
+
399
+ Pages are sent without waiting for Prepr; only the personalized sections wait for their decision, and most of that time is spent at Prepr, in two steps:
400
+
401
+ - **Who is the visitor?** For a visitor Prepr has not seen before, it first works out their segments, which takes a few hundred milliseconds on pages with segment groups. A returning visitor is resolved at once.
402
+ - **The answer.** Prepr's CDN caches each decision per page, segment set and A/B bucket, and shares it between visitors who get the same. A cached answer takes tens of milliseconds; working one out takes most of a second, once per page and combination until Prepr's cache drops it.
403
+
404
+ Under steady traffic most answers come from that cache. Your deployment adds the rest:
405
+
406
+ - **Region.** Run your functions, the proxy included, in the region closest to your visitors and to Prepr.
407
+ - **Keep connections open.** Node.js closes idle connections after a few seconds. On the Node.js runtime, give the client a `fetch` that keeps them longer; this affects only the client's own requests:
408
+
409
+ ```ts
410
+ import { Agent } from 'undici';
388
411
 
389
- - It asks Prepr once per visit, not per page view: later requests reuse the stored decision unless something arrives that can change the segment (UTM parameters, a first visit, a segment forced in preview). Pass `reuse: false` to ask on every request. A visitor who joins a segment mid-visit sees it from the next request that asks.
390
- - That request is uncached, so its time depends on how far your functions run from Prepr: deploy them in the region closest to your visitors and to Prepr, and use `onlyIf` to limit it to pages that need it. Pages without segment groups are skipped after a cached check (the same one `hasAbTests` uses).
391
- - Without a decision (the lookup failed or took longer than `timeoutMs`, default 1000), the default variant shows.
392
- - The page carries the HTML of every variant of those groups, and images in hidden variants still download.
393
- - An item with a segment group nested inside it keeps streaming; only groups at the top level of the list are decided in the proxy.
412
+ const keepAlive = new Agent({ keepAliveTimeout: 60_000 });
413
+ export const prepr = createPreprClient({
414
+ url: process.env.PREPR_GRAPHQL_URL!,
415
+ fetch: (input, init) =>
416
+ fetch(input, { ...init, dispatcher: keepAlive } as RequestInit),
417
+ });
418
+ ```
419
+
420
+ - **Instances.** On Vercel, Fluid Compute reuses a function instance, and its open connections, across requests, so under steady traffic the connection stays warm. Each fresh instance pays the setup on its first request; keep instances warm with your platform's settings if your traffic is low.
421
+ - **No schema at runtime.** Pass the codegen plugin's decision query as `decision` (see [The proxy](#the-proxy)), so a fresh instance does not download Prepr's schema before its first decision.
422
+ - **Timeout.** When Prepr does not answer within `timeoutMs` (default 1000), the personalized sections show the default variants, with nothing swapped in later. It caps how long a first page load waits for Prepr.
394
423
 
395
424
  ##### Preview
396
425
 
@@ -405,17 +434,84 @@ const draft = preprDraftRedirect(request, { preview });
405
434
  if (draft) return draft;
406
435
  ```
407
436
 
408
- Outside preview both do nothing (the route answers 404). Keep the route out of your proxy `matcher`, or pass its path as `route` when it lives elsewhere. Inside the Prepr editor, browsers that refuse the draft cookie get the cached page instead, so keep a publish webhook calling `revalidateTag('prepr')`.
437
+ Outside preview both do nothing (the route answers 404). Keep the route out of your proxy `matcher`, or pass its path as `route` when it lives elsewhere. Inside the Prepr editor, browsers that refuse the draft cookie get the cached page instead, so keep a publish webhook (below) in place.
438
+
439
+ ##### Refreshing on publish
440
+
441
+ `revalidateTag('prepr')` refreshes every Prepr page on every publish. To refresh only the pages that show the changed item, tag each cached page with its items and let a Prepr webhook revalidate the item's tag.
442
+
443
+ 1. **Tag the page with its items.** Add `tagItems: true` to the query inside `'use cache'`. The entry gets a `prepr:<_id>` tag for every item in the result, at any depth; with `variants: true` that is every variant's items, so a change to any variant reaches the page.
444
+
445
+ ```tsx
446
+ async function getPage(slug: string) {
447
+ 'use cache';
448
+ const { data } = await prepr().query(
449
+ GetPageBySlugDocument,
450
+ { slug },
451
+ { variants: true, tagItems: true },
452
+ );
453
+ return data;
454
+ }
455
+ ```
456
+
457
+ With your own client, call `cacheTag(...preprCacheTags(data))` inside `'use cache'` instead.
458
+
459
+ 2. **New items are covered too.** A new item is in no cached result yet, so `tagItems` also tags two kinds of query with their model's `prepr:model:<model>` tag, which a publish of any item of that model revalidates:
460
+ - list queries (`Posts { items { … } }`), so a new post joins the list;
461
+ - lookups that found nothing (`Page(slug: "new")` returning `null`), so publishing that page replaces the cached "not found".
462
+
463
+ Other pages that depend on which items exist (a sitemap, a count) can add the tag themselves:
464
+
465
+ ```ts
466
+ import { cacheTag } from 'next/cache';
467
+ import { preprModelTag } from '@preprio/toolkit/nextjs/graphql';
468
+
469
+ cacheTag(preprModelTag('Post')); // the model's name in Prepr or its GraphQL type
470
+ ```
471
+
472
+ The model name is compared without case, spaces or punctuation, so `'Blog post'` (what webhooks send) and `BlogPost` (the GraphQL type) give the same tag. A model whose API ID was renamed away from its name does not match.
473
+
474
+ 3. **Add the webhook route.**
475
+
476
+ ```ts
477
+ // app/api/prepr/webhook/route.ts
478
+ import { createPreprWebhookRoute } from '@preprio/toolkit/nextjs';
479
+
480
+ export const POST = createPreprWebhookRoute({
481
+ secret: process.env.PREPR_WEBHOOK_SECRET,
482
+ });
483
+ ```
484
+
485
+ Keep the route out of your proxy `matcher`.
486
+
487
+ 4. **Create the webhook in Prepr.** Under **Settings → Webhooks**, add `https://<your-site>/api/prepr/webhook` with the events `content_item.published`, `content_item.unpublished`, `content_item.deleted` and `content_item.invalidated`, plus `asset.changed`, `asset.deleted` and `schema.changed`. Other events are acknowledged and ignored, so subscribing to more does no harm. Save, then copy the webhook's **ID** into `PREPR_WEBHOOK_SECRET`: Prepr signs every request with it.
488
+
489
+ The route checks the `Prepr-Signature` header (an HMAC-SHA256 of the body, keyed with the webhook ID, as hex or base64) and answers 401 to a missing or wrong signature, or to a request older than five minutes. A valid event revalidates:
490
+
491
+ | Tag | When | Reaches |
492
+ | --------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
493
+ | `prepr:<id>` | `content_item.published`, `.unpublished`, `.deleted`, `.invalidated`; `asset.changed`, `.deleted` | pages showing that item or asset |
494
+ | `prepr:model:<model>` | `content_item.published`, `.unpublished`, `.deleted` | list queries, lookups that found nothing, pages tagged by you |
495
+ | `prepr:any` | the same events as `prepr:<id>` | pages with more items than Next.js's tag limit |
496
+ | `prepr:schema` | `schema.changed` | the schema the client rewrites queries with |
497
+
498
+ Draft edits (`content_item.changed`, `.created`), visitor and segment events revalidate nothing: they change no published page.
499
+
500
+ Revalidation uses `revalidateTag(tag, { expire: 0 })`, so the next request after a publish gets the new page. Pass `profile: 'max'` to serve the old page once more while the new one renders in the background.
501
+
502
+ Next.js accepts at most 128 tags per `cacheTag()` call. A page with more items is tagged `prepr:any` instead (`PREPR_ANY_ITEM_TAG`), so it refreshes on every event, as with `revalidateTag('prepr')`. The response lists the revalidated tags, which Prepr shows on the webhook's detail page.
503
+
504
+ `tagItems` makes that query skip the Data Cache: a Data Cache entry carries only the client's `cacheTags`, so after an item tag is revalidated, the `'use cache'` function would re-run against the stale response. The `'use cache'` entry is the cache. The client's `cacheTags` still apply to the entry, so `revalidateTag('prepr')` keeps refreshing everything. `tagItems` needs Next.js 16 and throws outside a `'use cache'` function.
409
505
 
410
506
  <details>
411
507
  <summary>How it works</summary>
412
508
 
413
509
  - **`variants: true`** adds `personalize: false` to every personalizable field in your query, at any depth, and selects each item's `_context`. The client learns which fields are personalizable from one schema introspection request per client, kept in the Data Cache.
414
- - **`decide()`** sends your query cut down to the lists that hold segment groups on this page (`from: data`), selecting only `_context`, with the visitor's headers and never cached. Prepr decides from everything the middleware forwards: customer profile, UTM parameters, visitor IP, HubSpot id. A page without segment groups makes no request.
415
- - **`PreprStack`** renders items that never vary in the static shell. Each segment group, and each item with a segment group nested inside it, gets its own `<Suspense>` boundary: the default variant is the fallback and this visitor's variant streams in. A group with no default variant still gets its place. A failed decision falls back to the defaults.
416
- - **A/B variants** are both in the HTML, each in a `display: contents` wrapper; an inline script (and a layout effect after in-app navigation) shows the bucket from the cookie. A hidden variant never becomes visible, so the tracking pixel records no impression for it.
510
+ - **Decisions** send your query cut down to the lists that hold segment groups, selecting only `_context`, with the visitor's headers and never cached. Prepr decides from everything the middleware forwards: customer profile, UTM parameters, visitor IP, HubSpot id. A page without segment groups makes no request. `prepr.decide()` does the same inside a render, for rendering variants yourself with `resolveVariants`; `PreprStack` does not need it.
511
+ - **`PreprStack`** renders every variant of each segment group, and each item with groups nested inside it once per combination of their variants, in `display: contents` wrappers that stay hidden until the view's decision marks one to show. A group with no default variant still gets its place.
512
+ - **A/B variants** are both in the HTML and picked by the same decision as segment groups. A hidden variant never becomes visible, so the tracking pixel records no impression for it.
417
513
  - **`hasAbTests`** sends your query without its leaf fields and no visitor headers, so Prepr's CDN caches it, and reads the answer from the `Vary` header. Answers are reused for a minute.
418
- - **Tags carry over.** The client's `'prepr'` fetch tag applies to the `'use cache'` entry, so `revalidateTag('prepr')` refreshes it without a `cacheTag()` call.
514
+ - **Tags carry over.** The client's `'prepr'` fetch tag applies to the `'use cache'` entry, so `revalidateTag('prepr')` refreshes it without a `cacheTag()` call. `tagItems` adds a tag per item; see [Refreshing on publish](#refreshing-on-publish).
419
515
  - **Outside `'use cache'`, set `revalidate`** on the client (`false` or seconds). Otherwise a query outside both `'use cache'` and `<Suspense>` fails the build with "uncached data was accessed outside of `<Suspense>`".
420
516
 
421
517
  </details>
@@ -473,7 +569,7 @@ const decisions = decisionQuery
473
569
  const content = resolveVariants(page.Page.content, { decisions, bucket });
474
570
  ```
475
571
 
476
- `visitorPreprHeaders` are the headers the middleware forwards (`getPreprHeaders()` in Next.js); `bucket` is the visitor's A/B bucket (`resolvePreprBucket` stores it in a cookie).
572
+ `visitorPreprHeaders` are the headers the middleware forwards (`getPreprHeaders()` in Next.js); `bucket` is the visitor's A/B bucket, when you know it; leave it out and A/B tests use the decision too (see below).
477
573
 
478
574
  With Apollo, parse the strings once and use them like any document:
479
575
 
@@ -518,7 +614,7 @@ import {
518
614
 
519
615
  Only named queries with personalizable fields get exports; two queries with the same name fail the run. The generated decision query asks about every personalizable list. When you have the variants result at hand, `toDecisionQuery(query, schema, { from })` is smaller and markedly faster for visitors Prepr has not seen before.
520
616
 
521
- A/B tests need no decision query: render both variants and pick the visitor's bucket, as `PreprStack` and `resolvePreprBucket` do, or pass the bucket to `resolveVariants`.
617
+ A decision query without `from` also returns the visitor's variant of each A/B test, per experiment and its split; `from` limits it to the lists with segment groups. Otherwise pass a known bucket to `resolveVariants`.
522
618
 
523
619
  ### Image loader
524
620
 
@@ -915,7 +1011,7 @@ Each wrapper exposes the same five helpers, differing only in how they reach the
915
1011
  | `cacheTags` (Next.js) | `['prepr']` | Tags on cacheable fetches. |
916
1012
  | `revalidate` (Next.js) | unset | `next.revalidate` on cacheable fetches. |
917
1013
 
918
- `query(document, variables?, options?)` takes a query string, a `TypedDocumentString` or a `DocumentNode`, and resolves to `{ data, personalized }`. Per-query options: `headers`, `init`, `fetch`, plus `personalize` (framework versions) and `event` (Nuxt, SvelteKit, Astro). A request carrying per-query headers or `init.headers` is `no-store`; client-level `headers` are not.
1014
+ `query(document, variables?, options?)` takes a query string, a `TypedDocumentString` or a `DocumentNode`, and resolves to `{ data, personalized }`. Per-query options: `headers`, `init`, `fetch`, plus `personalize` (framework versions), `tagItems` (Next.js 16, inside `'use cache'`) and `event` (Nuxt, SvelteKit, Astro). A request carrying per-query headers or `init.headers` is `no-store`; client-level `headers` are not.
919
1015
 
920
1016
  Import `PreprError` from the same subpath as the client when checking `instanceof`: CommonJS entry points do not share classes. `error.code` works from anywhere.
921
1017
 
@@ -929,6 +1025,8 @@ For your own GraphQL client (see [Using your own GraphQL client](#using-your-own
929
1025
  | `toVariantsQuery(query, schema)` | The variants query as a string: what `variants: true` sends. |
930
1026
  | `toDecisionQuery(query, schema, { from?, topLevelOnly? })` | The per-visitor decision query as a string, or `null` when there is nothing to decide. |
931
1027
  | `decisionsFrom(data)` | Decisions from a decision query's `data`, for `resolveVariants` or `PreprStack`. |
1028
+ | `preprCacheTags(data)` | `prepr:<_id>` for every item in a result, or `['prepr:any']` past 128 items. |
1029
+ | `preprModelTag(model)` | `prepr:model:<model>`, for list pages. |
932
1030
 
933
1031
  `@preprio/toolkit/graphql/codegen` is a graphql-codegen plugin that writes `<Name>VariantsQuery` and `<Name>DecisionQuery` for every named query with personalizable fields.
934
1032
 
@@ -954,6 +1052,10 @@ The toolbar. Takes the props returned by `getToolbarProps`, and renders nothing
954
1052
  <PreprToolbar {...toolbarProps} />
955
1053
  ```
956
1054
 
1055
+ #### `PreprPersonalizationSwitch`
1056
+
1057
+ From `@preprio/toolkit/nextjs`. Gets each client-side navigation's decision in before the click: when the visitor hovers over a same-site link (or presses it, on touch devices), it asks `createPreprProxy` for the target page's decision. A click whose decision is not in yet waits for it (see [How each visitor gets their variant](#how-each-visitor-gets-their-variant)). Mount it once in the root layout, so links on pages without a `PreprStack` are covered too. Links to other sites and links that open a new tab or download are left alone. The decision requests are answered by `createPreprProxy` only. Takes no props and renders nothing.
1058
+
957
1059
  #### `PreprPreview`
958
1060
 
959
1061
  From `@preprio/toolkit/react`. The framework-free equivalent of `PreprToolbar`: same preview runtime, with the router binding left to you. Renders nothing.
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var VERSION = "0.6.0-alpha.1";
2
+ var VERSION = "0.6.0-alpha.10";
3
3
 
4
4
  // src/core/features.ts
5
5
  function isEnabled(config) {
@@ -178,7 +178,10 @@ function decisionSelectionSet(set, typeName, path, ctx) {
178
178
  path,
179
179
  ctx
180
180
  );
181
- if (pruned) {
181
+ if (!pruned) continue;
182
+ if (fragment.typeCondition.name.value === typeName) {
183
+ selections.push(...pruned.selections);
184
+ } else {
182
185
  selections.push(
183
186
  inlineFragment(fragment.typeCondition.name.value, [
184
187
  ...pruned.selections
@@ -188,13 +191,19 @@ function decisionSelectionSet(set, typeName, path, ctx) {
188
191
  continue;
189
192
  }
190
193
  if (selection.kind === "InlineFragment") {
194
+ const condition = selection.typeCondition?.name.value ?? typeName;
191
195
  const pruned = decisionSelectionSet(
192
196
  selection.selectionSet,
193
- selection.typeCondition?.name.value ?? typeName,
197
+ condition,
194
198
  path,
195
199
  ctx
196
200
  );
197
- if (pruned) selections.push({ ...selection, selectionSet: pruned });
201
+ if (!pruned) continue;
202
+ if (condition === typeName && !selection.directives?.length) {
203
+ selections.push(...pruned.selections);
204
+ } else {
205
+ selections.push({ ...selection, selectionSet: pruned });
206
+ }
198
207
  continue;
199
208
  }
200
209
  if (!selection.selectionSet) continue;
@@ -268,20 +277,58 @@ function toDecisionDocument(document2, schema, groupPaths, options) {
268
277
  const used = /* @__PURE__ */ new Set();
269
278
  collectVariables(pruned, used);
270
279
  collectVariables(operation.directives, used);
280
+ const shared = sharedContextFragment(schema);
271
281
  return {
272
282
  kind: "Document",
273
283
  definitions: [
284
+ ...shared ? [shared.definition] : [],
274
285
  {
275
286
  ...operation,
276
287
  name: operation.name && name(`${operation.name.value}Decision`),
277
288
  variableDefinitions: (operation.variableDefinitions ?? []).filter(
278
289
  (definition) => used.has(definition.variable.name.value)
279
290
  ),
280
- selectionSet: pruned
291
+ selectionSet: shared ? shared.apply(pruned) : pruned
281
292
  }
282
293
  ]
283
294
  };
284
295
  }
296
+ function sharedContextFragment(schema) {
297
+ const contextTypes = new Set(
298
+ Object.values(schema.types).flatMap(
299
+ (type) => type.hasContext ? [type.fields._context.type] : []
300
+ )
301
+ );
302
+ if (contextTypes.size !== 1) return null;
303
+ const [contextType] = contextTypes;
304
+ const fragmentName = "c";
305
+ const definition = {
306
+ kind: "FragmentDefinition",
307
+ name: name(fragmentName),
308
+ typeCondition: { kind: "NamedType", name: name(contextType) },
309
+ selectionSet: selectionSet(
310
+ ["kind", "group_id", "variant_id"].map(
311
+ (field) => ({ kind: "Field", name: name(field) })
312
+ )
313
+ )
314
+ };
315
+ const spread = {
316
+ kind: "FragmentSpread",
317
+ name: name(fragmentName)
318
+ };
319
+ const apply = (set) => selectionSet(
320
+ set.selections.map((selection) => {
321
+ if (selection.kind === "Field" && selection.alias?.value === PREPR_CONTEXT_ALIAS) {
322
+ return { ...selection, selectionSet: selectionSet([spread]) };
323
+ }
324
+ return "selectionSet" in selection && selection.selectionSet ? {
325
+ ...selection,
326
+ selectionSet: apply(selection.selectionSet)
327
+ } : selection;
328
+ })
329
+ );
330
+ return { definition, apply };
331
+ }
285
332
 
286
333
  // src/core/variants/probe-document.ts
287
334
  function probeSelectionSet(set, fragments) {
@@ -862,6 +909,7 @@ function createVaryProbe(run, options) {
862
909
  }
863
910
 
864
911
  // src/core/graphql/client.ts
912
+ var clientSchemas = /* @__PURE__ */ new WeakMap();
865
913
  function isParseableUrl(url) {
866
914
  try {
867
915
  new URL(url);
@@ -898,7 +946,7 @@ function createPreprClient(options) {
898
946
  ));
899
947
  const variantsDocuments = /* @__PURE__ */ new Map();
900
948
  const decisionDocuments = /* @__PURE__ */ new Map();
901
- return {
949
+ const client = {
902
950
  async query(document2, variables, queryOptions = {}) {
903
951
  const { variants, ...rest } = queryOptions;
904
952
  if (!variants) return run(document2, variables, rest);
@@ -910,10 +958,10 @@ function createPreprClient(options) {
910
958
  return run(derived, variables, rest);
911
959
  },
912
960
  async decide(document2, variables, queryOptions = {}) {
913
- const { from, topLevelOnly, ...rest } = queryOptions;
961
+ const { from, ...rest } = queryOptions;
914
962
  const paths = from === void 0 ? void 0 : personalizationPaths(from);
915
963
  if (paths && paths.size === 0) return /* @__PURE__ */ new Map();
916
- const signature = paths ? [...paths].map(([path, types]) => `${path}:${[...types].sort().join(",")}`).sort().join("|") : topLevelOnly ? "top" : "*";
964
+ const signature = paths ? [...paths].map(([path, types]) => `${path}:${[...types].sort().join(",")}`).sort().join("|") : "*";
917
965
  let byPaths = decisionDocuments.get(document2);
918
966
  if (!byPaths) {
919
967
  byPaths = /* @__PURE__ */ new Map();
@@ -921,9 +969,7 @@ function createPreprClient(options) {
921
969
  }
922
970
  let derived = byPaths.get(signature);
923
971
  if (derived === void 0) {
924
- derived = toDecisionDocument(document2, await loadSchema(), paths, {
925
- topLevelOnly: !paths && topLevelOnly
926
- });
972
+ derived = toDecisionDocument(document2, await loadSchema(), paths);
927
973
  byPaths.set(signature, derived);
928
974
  }
929
975
  if (!derived) return /* @__PURE__ */ new Map();
@@ -937,6 +983,8 @@ function createPreprClient(options) {
937
983
  (vary) => vary === null || SEGMENTS_VARY.test(vary)
938
984
  )
939
985
  };
986
+ clientSchemas.set(client, loadSchema);
987
+ return client;
940
988
  }
941
989
 
942
990
  // src/core/graphql/event-client.ts
@@ -95,39 +95,6 @@ interface PreprVariantProps {
95
95
  */
96
96
  declare function preprVariantProps(item: unknown): PreprVariantProps;
97
97
 
98
- /**
99
- * The schema facts the query rewrites need, from `loadPreprSchema`. Pass it
100
- * along; its shape is not part of the public API.
101
- */
102
- type PreprSchema = PreprSchemaInfo;
103
- /**
104
- * Your query, asking for every personalization and A/B variant: what
105
- * `query(..., { variants: true })` sends, as a string for your own GraphQL
106
- * client. The result is the same for every visitor, so cache it like any
107
- * other content query, and render it with `resolveVariants` or `PreprStack`.
108
- *
109
- * A query without personalizable fields comes back unchanged (minified).
110
- */
111
- declare function toVariantsQuery(document: PreprDocument<unknown, unknown> | string, schema: PreprSchema): string;
112
- /**
113
- * The per-visitor decision query for the same query: send it with the
114
- * visitor's Prepr headers, never cached, and pass the response's `data` to
115
- * `decisionsFrom`.
116
- *
117
- * Pass the variants result as `from` whenever you have it: the query then
118
- * asks only about the lists that hold segment groups on this page, which is
119
- * a few hundred bytes instead of the whole page and markedly faster for a
120
- * visitor Prepr has not seen before. Without `from` it asks about every
121
- * personalizable list. `topLevelOnly` stops at the first personalizable list
122
- * on each path, for middleware that decides only the top of a stack.
123
- *
124
- * Null when there is nothing to decide: send no request then.
125
- */
126
- declare function toDecisionQuery(document: PreprDocument<unknown, unknown> | string, schema: PreprSchema, options?: {
127
- from?: unknown;
128
- topLevelOnly?: boolean;
129
- }): string | null;
130
-
131
98
  /**
132
99
  * A GraphQL document accepted by `query`: a plain string, graphql-codegen's
133
100
  * `TypedDocumentString` (`documentMode: 'string'`), or any `DocumentNode`,
@@ -221,11 +188,6 @@ interface PreprDecideOptions extends Omit<PreprQueryOptions, 'variants'> {
221
188
  * none, e.g. on pages with only A/B tests or no personalization.
222
189
  */
223
190
  from?: unknown;
224
- /**
225
- * @internal Ask only about the first personalizable list on each path,
226
- * without `from`: what middleware needs for groups at the top of a stack.
227
- */
228
- topLevelOnly?: boolean;
229
191
  }
230
192
  interface PreprQueryResult<TResult> {
231
193
  data: TResult;
@@ -236,6 +198,39 @@ interface PreprQueryResult<TResult> {
236
198
  personalized: boolean;
237
199
  }
238
200
 
201
+ /**
202
+ * The schema facts the query rewrites need, from `loadPreprSchema`. Pass it
203
+ * along; its shape is not part of the public API.
204
+ */
205
+ type PreprSchema = PreprSchemaInfo;
206
+ /**
207
+ * Your query, asking for every personalization and A/B variant: what
208
+ * `query(..., { variants: true })` sends, as a string for your own GraphQL
209
+ * client. The result is the same for every visitor, so cache it like any
210
+ * other content query, and render it with `resolveVariants` or `PreprStack`.
211
+ *
212
+ * A query without personalizable fields comes back unchanged (minified).
213
+ */
214
+ declare function toVariantsQuery(document: PreprDocument<unknown, unknown> | string, schema: PreprSchema): string;
215
+ /**
216
+ * The per-visitor decision query for the same query: send it with the
217
+ * visitor's Prepr headers, never cached, and pass the response's `data` to
218
+ * `decisionsFrom`.
219
+ *
220
+ * Pass the variants result as `from` whenever you have it: the query then
221
+ * asks only about the lists that hold segment groups on this page, which is
222
+ * a few hundred bytes instead of the whole page and markedly faster for a
223
+ * visitor Prepr has not seen before. Without `from` it asks about every
224
+ * personalizable list. `topLevelOnly` stops at the first personalizable list
225
+ * on each path, for middleware that decides only the top of a stack.
226
+ *
227
+ * Null when there is nothing to decide: send no request then.
228
+ */
229
+ declare function toDecisionQuery(document: PreprDocument<unknown, unknown> | string, schema: PreprSchema, options?: {
230
+ from?: unknown;
231
+ topLevelOnly?: boolean;
232
+ }): string | null;
233
+
239
234
  /**
240
235
  * Query options for frameworks without an implicit request scope.
241
236
  * `personalize: true` requires the request `event`; the compiler enforces it.