next-evolve 0.3.3 → 0.3.5

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.
package/README.md CHANGED
@@ -1,137 +1,164 @@
1
1
  # next-evolve
2
2
 
3
- Self-improving Next.js sites: server-side variants with no flicker, measured in your own Postgres. Needs an Evolve Cloud account.
3
+ A/B tests and bandits for Next.js App Router sites. Variants are chosen on the server, so there is no flicker. Raw events stay in your own Postgres; Evolve Cloud only receives aggregate counts and does the statistics.
4
4
 
5
- > Alpha.
5
+ > Alpha. Needs an Evolve Cloud account (sign-up is invite-only for now). Without one the SDK serves your normal pages and records nothing; see [docs/without-an-account.md](docs/without-an-account.md).
6
6
 
7
- ## Setup
7
+ **Requirements:** Next.js 15.5 or newer with the App Router, Node 22.12 or newer, a Postgres 14+ database, and a host that runs a Node server (not `output: "export"`).
8
8
 
9
- ```sh
10
- npx next-evolve init --routes "/ /pricing" # add --dry-run to see the diff first
11
- npx next-evolve doctor # checks env, key, DB, bundle, proxy, pages, cloud → app
12
- ```
9
+ ## Quickstart (about 15 minutes)
13
10
 
14
- `init` installs the package, connects to your site (secret key and webhook secret from the dashboard), sets up the database, and writes the config, route handler, proxy, layout script, instrumentation and `prebuild` hook. Static-mode pages move under `app/[evolve]/` and get wrapped with `withEvolve()`; when that isn't safe (a top-level `[slug]`, `force-dynamic`, a `"use client"` page…) the route stays in dynamic mode and `init` says why. It never overwrites your `.env.local` lines, and a re-run changes nothing. `--yes` skips every prompt.
11
+ This gets you from an existing Next.js app to a running experiment in local development. A timed run on a fresh `create-next-app` reached a variant served, an exposure recorded and the visitor in the dashboard in under 6 minutes.
15
12
 
16
- Other commands: `migrate`, `pull`, `push`, `connect --test [--app-url]`, `aggregate`. The sections below describe what `init` writes.
13
+ ### 1. Create a site
17
14
 
18
- ## Database
15
+ Sign in at [app.evolveab.com](https://app.evolveab.com), enter a site name and click **Create site**. Copy the **secret key** (`evk_live_…`) and the **webhook secret** (`whsec_…`). They are shown only once. If you lose them, issue a new key on the site's **Connect** page.
19
16
 
20
- Raw events (exposures, goal events, identities) stay in **your** Postgres (≥ 14), in a separate `evolve` schema. Evolve Cloud only ever receives aggregate counts.
17
+ ### 2. Get a Postgres database
18
+
19
+ Evolve writes events into its own `evolve` schema, so your app's existing database is fine. For local development, Docker works:
21
20
 
22
21
  ```sh
23
- EVOLVE_DATABASE_URL=postgres://… npx next-evolve migrate
22
+ docker run -d --name evolve-pg -e POSTGRES_PASSWORD=postgres -p 54320:5432 postgres:17
24
23
  ```
25
24
 
26
- Use the pooled URL on serverless hosts (e.g. Neon's `-pooler` host).
25
+ For production, any Postgres works (Neon, Supabase, RDS, your own). On serverless hosts use the pooled URL (for example Neon's `-pooler` host).
27
26
 
28
- ## Goals
27
+ ### 3. Run init
29
28
 
30
- ```ts
31
- // evolve.goals.ts
32
- import { defineGoals } from "next-evolve/config";
29
+ In your app's directory:
33
30
 
34
- export default defineGoals({
35
- cta_click: { event: "cta_click", window: "1d" },
36
- signup_page: { pageview: "/signup", window: "1d" },
37
- paid: {
38
- event: "subscription_started",
39
- source: "stripe",
40
- window: "30d",
41
- value: (e) => e.props.amount as number,
42
- },
43
- });
31
+ ```sh
32
+ npx next-evolve init \
33
+ --key evk_live_… \
34
+ --webhook-secret whsec_… \
35
+ --db postgres://postgres:postgres@localhost:54320/postgres \
36
+ --routes "/"
44
37
  ```
45
38
 
46
- Function values run in your app when the event is written; only `{ kind: "local-fn" }` is sent to the cloud.
39
+ `--routes` lists the pages you want to test (exact paths, e.g. `"/ /pricing"`). Add `--dry-run` first to see the diff without changing anything. `init` is safe to re-run.
47
40
 
48
- ## Routes and beacon
41
+ It installs `next-evolve` and `zod`, checks the key, migrates the database and writes:
49
42
 
50
- ```ts
51
- // app/api/evolve/[...evolve]/route.ts
52
- import { createEvolveHandler } from "next-evolve/server";
53
- import evolveConfig from "@/evolve.config";
54
- import goals from "@/evolve.goals";
43
+ | File | What it is |
44
+ | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
45
+ | `evolve.config.ts` | The routes Evolve may test, and their mode |
46
+ | `evolve.goals.ts` | What counts as success (a sample `signed_up` goal) |
47
+ | `app/api/evolve/[...evolve]/route.ts` | The routes the beacon and the cloud call |
48
+ | `proxy.ts` (`middleware.ts` on Next 15) | Picks a variant per visitor; no network call |
49
+ | `instrumentation.ts` | Registers goals and sends your config to the cloud on startup |
50
+ | `app/layout.tsx` | Adds `<EvolveScript />`, the beacon |
51
+ | `package.json` | `"prebuild": "next-evolve pull"` |
52
+ | `.env.local` | `EVOLVE_SECRET_KEY`, `EVOLVE_WEBHOOK_SECRET`, `EVOLVE_DATABASE_URL`, `EVOLVE_TRACK_SECRET` (never overwrites a line that's there) |
55
53
 
56
- export const { GET, POST } = createEvolveHandler({ config: evolveConfig, goals });
57
- export const maxDuration = 60;
58
- ```
54
+ **Static or dynamic mode.** By default each tested page moves to `app/[evolve]/…` and is cached once per variant ("static mode"). `init` keeps a page where it is ("dynamic mode") when moving it isn't safe, and says why. If your `next.config` has `cacheComponents: true` (new `create-next-app` projects do), every route uses dynamic mode. See [docs/concepts.md](docs/concepts.md#static-and-dynamic-mode).
55
+
56
+ If you already have a `proxy.ts`/`middleware.ts`, or `init` can't edit your layout, it prints the lines to add.
59
57
 
60
- Put `<EvolveScript />` (from `next-evolve/client`) in the root layout. It sends page views, exposures and clicks on `[data-evolve-goal]` elements (forms: on submit) to `/api/evolve/collect` on your own origin.
58
+ ### 4. Make a component optimizable
61
59
 
62
- ## Tracking
60
+ Declare which props Evolve may change with a Zod schema, next to the component:
63
61
 
64
- ```ts
65
- import { evolve } from "next-evolve/server";
62
+ ```tsx
63
+ // components/hero.tsx
64
+ import { optimizable } from "next-evolve";
65
+ import { z } from "zod";
66
66
 
67
- await evolve.track("project_created", { userId: user.id });
68
- await evolve.identify(user.id); // links the visitor's evolve_id cookie; first link wins
67
+ const heroSchema = z.object({
68
+ title: z.string().min(3).max(60),
69
+ cta: z.string().min(2).max(24),
70
+ });
71
+
72
+ function Hero({ title, cta }: z.infer<typeof heroSchema>) {
73
+ return (
74
+ <section>
75
+ <h1>{title}</h1>
76
+ <a href="/signup" data-evolve-goal="signup">
77
+ {cta}
78
+ </a>
79
+ </section>
80
+ );
81
+ }
82
+
83
+ export default optimizable(Hero, heroSchema);
69
84
  ```
70
85
 
71
- In the browser: `import { track, identify } from "next-evolve/client"`.
86
+ Render it with `<Optimizable>` on a tested page. The props you pass are the control:
72
87
 
73
- ### Server-to-server events
88
+ ```tsx
89
+ import { Optimizable } from "next-evolve";
90
+ import Hero from "@/components/hero";
74
91
 
75
- `POST /api/evolve/track` with `Authorization: Bearer $EVOLVE_TRACK_SECRET` and `{ "events": [{ "name", "userId"?, "anonId"?, "value"?, "props"?, "source"?, "ts"?, "dedupeKey"? }] }` (≤ 500 events). Unset secret → 404; wrong bearer → 401.
92
+ <Optimizable id="hero" component={Hero} props={{ title: "Ship faster", cta: "Start free" }} />;
93
+ ```
76
94
 
77
- ### Stripe recipe
95
+ With `cacheComponents: true`, wrap it in `<Suspense>` (from `react`); without it, `next build` fails with "uncached or runtime data … outside of `<Suspense>`":
78
96
 
79
- Call `evolve.track` from your own Stripe webhook handler, after you verify the signature:
97
+ ```tsx
98
+ <Suspense>
99
+ <Optimizable id="hero" component={Hero} props={{ title: "Ship faster", cta: "Start free" }} />
100
+ </Suspense>;
101
+ ```
80
102
 
81
- ```ts
82
- // app/api/stripe/route.ts
83
- import { evolve } from "next-evolve/server";
103
+ `data-evolve-goal="signup"` fires the `signup` event on click (forms: on submit). The sample goal `signed_up` in `evolve.goals.ts` counts that event.
84
104
 
85
- if (event.type === "invoice.paid") {
86
- const invoice = event.data.object;
87
- await evolve.track("subscription_started", {
88
- userId: invoice.metadata.userId,
89
- source: "stripe",
90
- props: { amount: invoice.amount_paid / 100 },
91
- dedupeKey: event.id, // Stripe retries: stored once
92
- });
93
- }
105
+ ### 5. Start the app and open the page
106
+
107
+ ```sh
108
+ npm run dev
94
109
  ```
95
110
 
96
- ## Aggregation
111
+ Open the page once. The region registers with the cloud and shows up under **Regions** in the dashboard within a few seconds.
112
+
113
+ ### 6. Create and start an experiment
114
+
115
+ In the dashboard: **Regions → hero → New experiment**. Give it a name, change a prop for "Variant B" (for example the title), click **Save draft**, then **Start**.
116
+
117
+ The dev server picks up the change within about 5 seconds (60 seconds in production, or at once through the sync webhook once deployed).
97
118
 
98
- Evolve Cloud never reads your database. It sends a signed request to `POST /api/evolve/aggregate` (verified with `EVOLVE_WEBHOOK_SECRET`), and your app answers with counts and sums per experiment × variant × goal × day, signed with the same secret. No visitor or user ids leave your app. Set `EVOLVE_SECRET_KEY`, `EVOLVE_WEBHOOK_SECRET` and `EVOLVE_DATABASE_URL`; the route needs `maxDuration = 60`.
119
+ ### 7. See the variant and the exposure
99
120
 
100
- In local development the cloud can't reach `localhost`. Run the same request yourself:
121
+ Open the page in a private window: you get Control or Variant B (50/50) and keep it on reloads. Each new private window is a new visitor. The experiment page also has **Preview** links that force a variant without counting anything.
122
+
123
+ The exposure is written to your database:
101
124
 
102
125
  ```sh
103
- npx next-evolve aggregate # print the statistics
104
- npx next-evolve aggregate --upload # and send them to Evolve Cloud
126
+ docker exec evolve-pg psql -U postgres -c "select * from evolve.exposures"
105
127
  ```
106
128
 
107
- ## Sync
129
+ ### 8. See it in the dashboard
130
+
131
+ The cloud can't reach `localhost`, so in development you run the aggregation yourself. Aggregates only count data older than 2 minutes, so wait 2 minutes after the visit, then:
132
+
133
+ ```sh
134
+ npx next-evolve aggregate --upload
135
+ ```
108
136
 
109
- Your app keeps a signed copy of its configuration (the bundle) in memory and serves from it with no network call on the request path. It gets new bundles three ways:
137
+ The experiment page shows the visitor under **Results**. Once deployed, the cloud runs this on a schedule and you don't need the command.
110
138
 
111
- - **Polling:** every 60 s (5 s in dev), in the background.
112
- - **Webhook:** when you change an experiment, Evolve Cloud calls `POST /api/evolve/sync` (signed with `EVOLVE_WEBHOOK_SECRET`). The receiving instance pulls the new bundle within seconds. The webhook carries no configuration, so a forged call can at most cause one extra fetch.
113
- - **Build time:** add `"prebuild": "next-evolve pull"` to `package.json` and pass the result to the proxy, so a fresh instance serves the last bundle before its first fetch:
139
+ ### 9. Deploy
114
140
 
115
- ```ts
116
- // proxy.ts
117
- import { createEvolveProxy } from "next-evolve/proxy";
118
- import buildBundle from "./.evolve/bundle.generated";
119
- import evolveConfig from "./evolve.config";
141
+ Set the four `EVOLVE_*` variables from `.env.local` in your host's production environment, deploy, then tell the cloud where the app lives and test the connection:
120
142
 
121
- export const proxy = createEvolveProxy(evolveConfig, { buildBundle });
143
+ ```sh
144
+ npx next-evolve connect --test --app-url https://your-app.com
145
+ npx next-evolve doctor
122
146
  ```
123
147
 
124
- Add `.evolve/` to `.gitignore`. `pull` never fails the build: without a key or a reachable cloud it keeps the previous bundle or writes an empty one.
148
+ More in [docs/production.md](docs/production.md).
125
149
 
126
- **Long-running Node (`next start`, Docker):** call `registerEvolve()` from `instrumentation.ts`. It polls in the background and keeps the last bundle on disk (`os.tmpdir()`, or `EVOLVE_CACHE_DIR`), so a restart serves it even when the cloud is unreachable.
150
+ ## Docs
127
151
 
128
- **Vercel + Global Config (recommended on Vercel):** without it, a webhook reaches only the instance that receives it and the others catch up within 60 s. With a [Global Config](https://vercel.com/docs/edge-config) store, the sync route writes the bundle there and every instance picks it up within ~10 s. Set:
152
+ - [Concepts](docs/concepts.md): regions, variants, experiments, the bundle, static vs dynamic mode, cookies, bots
153
+ - [API reference](docs/api.md): components, functions, CLI, HTTP routes
154
+ - [Goals and attribution](docs/goals-and-attribution.md): defining goals, tracking events, how conversions are attributed
155
+ - [Stripe recipe](docs/stripe.md): count paid subscriptions as a goal
156
+ - [Going to production](docs/production.md): env, app URL, hosting (Vercel, Docker, Node), sync, firewalls
157
+ - [What leaves your server](docs/what-leaves-your-server.md): every call the SDK makes, and what's in it
158
+ - [Without an account](docs/without-an-account.md): what the SDK does when there's no cloud
159
+ - [Configuration and environment variables](docs/configuration.md)
160
+ - [Troubleshooting](docs/troubleshooting.md)
129
161
 
130
- | Var | Purpose |
131
- | ------------------------- | --------------------------------------- |
132
- | `EVOLVE_GLOBAL_CONFIG` | The store's connection string (read) |
133
- | `EVOLVE_GLOBAL_CONFIG_ID` | The store id (`ecfg_…`), for writes |
134
- | `EVOLVE_VERCEL_API_TOKEN` | A Vercel token that can write the store |
135
- | `EVOLVE_VERCEL_TEAM_ID` | Optional, for team-owned stores |
162
+ ## License
136
163
 
137
- Bundles read from Global Config are verified like any other. With an existing proxy that calls `applyEvolve`, `await evolveReady(evolveConfig)` first (a fresh instance waits ≤ 20 ms for one read).
164
+ MIT
@@ -75,7 +75,7 @@ function detectProject(root) {
75
75
  });
76
76
  if (cacheComponents) problems.push({
77
77
  level: "warn",
78
- message: "cacheComponents is on: static-mode pages are untested with it; prefer dynamic mode"
78
+ message: "cacheComponents is on: Evolve uses dynamic mode (static mode doesn't work with it); wrap <Optimizable> in <Suspense>"
79
79
  });
80
80
  return {
81
81
  root,
package/dist/cli/files.js CHANGED
@@ -107,12 +107,13 @@ function planLayout(plan, d) {
107
107
  const src = plan.read(f);
108
108
  if (src.includes("EvolveScript")) return;
109
109
  const m = /^([ \t]*)<\/body>/m.exec(src);
110
- if (!m) {
110
+ let withTag;
111
+ if (m) withTag = src.replace(m[0], `${m[1]} <EvolveScript />\n${m[0]}`);
112
+ else if (src.split("</body>").length === 2) withTag = src.replace("</body>", "<EvolveScript /></body>");
113
+ else {
111
114
  plan.manual.push(`Add <EvolveScript /> (from "next-evolve/client") inside <body> in ${f}.`);
112
115
  return;
113
116
  }
114
- const indent = `${m[1]} `;
115
- const withTag = src.replace(m[0], `${indent}<EvolveScript />\n${m[0]}`);
116
117
  plan.write(f, addImport(withTag, `import { EvolveScript } from "next-evolve/client";`));
117
118
  }
118
119
  function planInstrumentation(plan, d) {
package/dist/cli/init.js CHANGED
@@ -51,11 +51,15 @@ async function initCommand(args, deps) {
51
51
  for (const pr of d.problems) (pr.level === "error" ? err : out)(`${pr.level === "error" ? "✗" : "!"} ${pr.message}`);
52
52
  if (d.problems.some((pr) => pr.level === "error")) return 1;
53
53
  out(`✓ Next.js ${d.nextVersion} · ${d.appDir}/ · ${d.typescript ? "TypeScript" : "JavaScript"} · ${d.packageManager}`);
54
- const mode = f.mode ?? "static";
54
+ const mode = f.mode ?? (d.cacheComponents ? "dynamic" : "static");
55
55
  if (mode !== "static" && mode !== "dynamic") {
56
56
  err("next-evolve init: --mode is \"static\" or \"dynamic\"");
57
57
  return 1;
58
58
  }
59
+ if (d.cacheComponents && mode === "static") {
60
+ err("✗ cacheComponents is on, and static mode doesn't work with it: use --mode dynamic (the default with cacheComponents)");
61
+ return 1;
62
+ }
59
63
  const paths = (f.routes ?? "/").split(/[\s,]+/).filter(Boolean);
60
64
  if (paths.some((r) => !r.startsWith("/") || /[[\]*?#]/.test(r))) {
61
65
  err("next-evolve init: --routes are concrete paths like \"/ /pricing\"");
@@ -179,7 +183,7 @@ async function initCommand(args, deps) {
179
183
  out(`\nNext:\n${[
180
184
  ...plan.manual,
181
185
  ...added.length && !dry ? [`In production, set ${added.join(", ")} (they're in .env.local now)`] : [],
182
- `Wrap a component: <Optimizable id="hero" component={Hero} props={...} /> (from "next-evolve")`,
186
+ d.cacheComponents ? `Wrap a component: <Suspense><Optimizable id="hero" component={Hero} props={...} /></Suspense> (Optimizable from "next-evolve"; cacheComponents needs the <Suspense>)` : `Wrap a component: <Optimizable id="hero" component={Hero} props={...} /> (from "next-evolve")`,
183
187
  "Visit the page once so the region registers, then open the dashboard",
184
188
  ...f["app-url"] ? ["After you deploy: npx next-evolve connect --test"] : ["After you deploy: npx next-evolve connect --test --app-url https://your-app.com"]
185
189
  ].map((s, i) => ` ${i + 1}. ${s}`).join("\n")}`);
@@ -55,7 +55,7 @@ function reexecWithTypes(command, args, cwd, env, quiet = false) {
55
55
  stdio: [
56
56
  "ignore",
57
57
  quiet ? "ignore" : "inherit",
58
- "inherit"
58
+ quiet ? "ignore" : "inherit"
59
59
  ],
60
60
  cwd,
61
61
  env: {
@@ -2,6 +2,13 @@ type BeaconEvent = Record<string, unknown>;
2
2
  export declare const COLLECT_URL = "/api/evolve/collect";
3
3
  export declare function push(e: BeaconEvent): void;
4
4
  export declare function flush(): void;
5
+ /**
6
+ * Queues one exposure per counted region (`[data-evolve-e]` without
7
+ * `data-evolve-x`) on this page, now and again after 1 s and 4 s: regions
8
+ * inside <Suspense> (dynamic mode, cacheComponents) can stream in after the
9
+ * page's first effect. Returns the cleanup for a route change.
10
+ */
11
+ export declare function watchExposures(p: string): () => void;
5
12
  /** Records a browser event (e.g. `track("cta_click", { plan: "pro" })`). */
6
13
  export declare function track(name: string, props?: Record<string, unknown>): void;
7
14
  /** Links this visitor to your user id. Prefer `evolve.identify()` on the server. */
@@ -26,6 +26,33 @@ function flush() {
26
26
  }).catch(() => {});
27
27
  }
28
28
  }
29
+ /**
30
+ * Queues one exposure per counted region (`[data-evolve-e]` without
31
+ * `data-evolve-x`) on this page, now and again after 1 s and 4 s: regions
32
+ * inside <Suspense> (dynamic mode, cacheComponents) can stream in after the
33
+ * page's first effect. Returns the cleanup for a route change.
34
+ */
35
+ function watchExposures(p) {
36
+ const seen = /* @__PURE__ */ new Set();
37
+ const scan = () => {
38
+ for (const el of document.querySelectorAll("[data-evolve-e]:not([data-evolve-x])")) {
39
+ const e = el.getAttribute("data-evolve-e");
40
+ const v = el.getAttribute("data-evolve-v");
41
+ if (e && v && !seen.has(e)) {
42
+ seen.add(e);
43
+ push({
44
+ t: "exp",
45
+ e,
46
+ v,
47
+ p
48
+ });
49
+ }
50
+ }
51
+ };
52
+ scan();
53
+ const timers = [1e3, 4e3].map((ms) => setTimeout(scan, ms));
54
+ return () => timers.forEach(clearTimeout);
55
+ }
29
56
  /** Records a browser event (e.g. `track("cta_click", { plan: "pro" })`). */
30
57
  function track(name, props) {
31
58
  push({
@@ -48,4 +75,4 @@ function _reset() {
48
75
  timer = void 0;
49
76
  queue = [];
50
77
  }
51
- export { COLLECT_URL, _reset, flush, identify, push, track };
78
+ export { COLLECT_URL, _reset, flush, identify, push, track, watchExposures };
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { flush, push } from "./beacon.js";
2
+ import { flush, push, watchExposures } from "./beacon.js";
3
3
  import { useEffect } from "react";
4
4
  import { usePathname } from "next/navigation";
5
5
  // src/client/evolve-script.tsx
@@ -40,20 +40,7 @@ function EvolveScript() {
40
40
  t: "pv",
41
41
  p
42
42
  });
43
- const seen = /* @__PURE__ */ new Set();
44
- for (const el of document.querySelectorAll("[data-evolve-e]:not([data-evolve-x])")) {
45
- const e = el.getAttribute("data-evolve-e");
46
- const v = el.getAttribute("data-evolve-v");
47
- if (e && v && !seen.has(e)) {
48
- seen.add(e);
49
- push({
50
- t: "exp",
51
- e,
52
- v,
53
- p
54
- });
55
- }
56
- }
43
+ return watchExposures(p);
57
44
  }, [path]);
58
45
  return null;
59
46
  }
@@ -10,6 +10,18 @@ export type OptimizableProps<P extends object, S extends z.ZodObject> = {
10
10
  /** Control props. Variant props are merged over these; schema defaults apply. */
11
11
  props: ControlProps<P, S>;
12
12
  };
13
+ /**
14
+ * Region registration (spec §5.2): after the response, deduped per process,
15
+ * errors swallowed. `after()` is called from a timer, not during render: with
16
+ * `cacheComponents` on, Next flags the clock read inside `after()` as
17
+ * non-deterministic prerender I/O (a dev error overlay). Outside a request
18
+ * scope (tests, some build steps) the push runs directly, still in the
19
+ * background.
20
+ */
21
+ export declare function scheduleRegistration(id: string, meta: {
22
+ jsonSchema: Record<string, unknown>;
23
+ schemaHash: string;
24
+ }, controlProps: Record<string, unknown>, path: string | null): void;
13
25
  /**
14
26
  * Renders the variant of a region chosen by the proxy, or control. Never throws
15
27
  * because of Evolve: any problem renders control. Server component.
@@ -8,7 +8,7 @@ import { CONTROL_CODE, decodeCode } from "@next-evolve/protocol";
8
8
  import { createElement } from "react";
9
9
  import { headers } from "next/headers";
10
10
  import { unstable_rethrow } from "next/navigation";
11
- import { after } from "next/server";
11
+ import { after, connection } from "next/server";
12
12
  // src/react/region.tsx
13
13
  let warnedNoContext = false;
14
14
  async function currentCode() {
@@ -32,18 +32,30 @@ async function currentPath() {
32
32
  return null;
33
33
  }
34
34
  }
35
- /** Region registration (spec §5.2): after the response, deduped per process, errors swallowed. */
35
+ /**
36
+ * Region registration (spec §5.2): after the response, deduped per process,
37
+ * errors swallowed. `after()` is called from a timer, not during render: with
38
+ * `cacheComponents` on, Next flags the clock read inside `after()` as
39
+ * non-deterministic prerender I/O (a dev error overlay). Outside a request
40
+ * scope (tests, some build steps) the push runs directly, still in the
41
+ * background.
42
+ */
36
43
  function scheduleRegistration(id, meta, controlProps, path) {
37
44
  if (!path) return;
38
- try {
39
- after(() => registerRegion({
40
- id,
41
- jsonSchema: meta.jsonSchema,
42
- schemaHash: meta.schemaHash,
43
- controlProps,
44
- path
45
- }));
46
- } catch {}
45
+ const run = () => registerRegion({
46
+ id,
47
+ jsonSchema: meta.jsonSchema,
48
+ schemaHash: meta.schemaHash,
49
+ controlProps,
50
+ path
51
+ });
52
+ setTimeout(() => {
53
+ try {
54
+ after(run);
55
+ } catch {
56
+ run().catch(() => {});
57
+ }
58
+ }, 0);
47
59
  }
48
60
  function findPair(bundle, pairs, region) {
49
61
  for (const p of pairs) {
@@ -88,6 +100,7 @@ async function Optimizable({ id, component, props }) {
88
100
  if (code === CONTROL_CODE) return control();
89
101
  const pairs = decodeCode(code);
90
102
  if (!pairs || pairs.length === 0) return control();
103
+ if (!requestContext().code) await connection();
91
104
  let bundle = await getRenderBundle();
92
105
  let hit = findPair(bundle, pairs, id);
93
106
  const unknown = () => pairs.some((p) => !bundle?.variants[p.var]);
@@ -124,4 +137,4 @@ async function Optimizable({ id, component, props }) {
124
137
  ...parsed.data
125
138
  }));
126
139
  }
127
- export { Optimizable };
140
+ export { Optimizable, scheduleRegistration };
@@ -43,6 +43,7 @@ function createMemoryStore(opts) {
43
43
  schedule();
44
44
  inflight = opts.fetcher(etag).then((res) => {
45
45
  if (res.status === "ok") load(res.token);
46
+ else if (res.status === "error" && res.code === 404 && res.message?.includes("no_bundle")) logRateLimited("fetch:no_bundle", "no bundle yet (no experiment has started): serving control", void 0, 36e5);
46
47
  else if (res.status === "error") logRateLimited(`fetch:${res.code}`, `bundle fetch failed (${res.code})`, res.message);
47
48
  }).catch((e) => logRateLimited("fetch:throw", "bundle fetch failed", e)).finally(() => {
48
49
  inflight = null;