next-evolve 0.3.3 → 0.3.4
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 +113 -86
- package/dist/cli/detect.js +1 -1
- package/dist/cli/files.js +4 -3
- package/dist/cli/init.js +6 -2
- package/dist/cli/load-app.js +1 -1
- package/dist/client/beacon.d.ts +7 -0
- package/dist/client/beacon.js +28 -1
- package/dist/client/evolve-script.js +2 -15
- package/dist/react/region.d.ts +12 -0
- package/dist/react/region.js +23 -11
- package/dist/store/memory.js +1 -0
- package/docs/api.md +238 -0
- package/docs/concepts.md +70 -0
- package/docs/configuration.md +74 -0
- package/docs/goals-and-attribution.md +81 -0
- package/docs/production.md +75 -0
- package/docs/stripe.md +91 -0
- package/docs/troubleshooting.md +90 -0
- package/docs/what-leaves-your-server.md +38 -0
- package/docs/without-an-account.md +28 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,137 +1,164 @@
|
|
|
1
1
|
# next-evolve
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 10 minutes)
|
|
13
10
|
|
|
14
|
-
|
|
11
|
+
This gets you from an existing Next.js app to a running experiment in local development.
|
|
15
12
|
|
|
16
|
-
|
|
13
|
+
### 1. Create a site
|
|
17
14
|
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
docker run -d --name evolve-pg -e POSTGRES_PASSWORD=postgres -p 54329:5432 postgres:17
|
|
24
23
|
```
|
|
25
24
|
|
|
26
|
-
|
|
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
|
-
|
|
27
|
+
### 3. Run init
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
// evolve.goals.ts
|
|
32
|
-
import { defineGoals } from "next-evolve/config";
|
|
29
|
+
In your app's directory:
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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:54329/postgres \
|
|
36
|
+
--routes "/"
|
|
44
37
|
```
|
|
45
38
|
|
|
46
|
-
|
|
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
|
-
|
|
41
|
+
It installs `next-evolve` and `zod`, checks the key, migrates the database and writes:
|
|
49
42
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
58
|
+
### 4. Make a component optimizable
|
|
61
59
|
|
|
62
|
-
|
|
60
|
+
Declare which props Evolve may change with a Zod schema, next to the component:
|
|
63
61
|
|
|
64
|
-
```
|
|
65
|
-
|
|
62
|
+
```tsx
|
|
63
|
+
// components/hero.tsx
|
|
64
|
+
import { optimizable } from "next-evolve";
|
|
65
|
+
import { z } from "zod";
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
86
|
+
Render it with `<Optimizable>` on a tested page. The props you pass are the control:
|
|
72
87
|
|
|
73
|
-
|
|
88
|
+
```tsx
|
|
89
|
+
import { Optimizable } from "next-evolve";
|
|
90
|
+
import Hero from "@/components/hero";
|
|
74
91
|
|
|
75
|
-
|
|
92
|
+
<Optimizable id="hero" component={Hero} props={{ title: "Ship faster", cta: "Start free" }} />;
|
|
93
|
+
```
|
|
76
94
|
|
|
77
|
-
|
|
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
|
-
|
|
97
|
+
```tsx
|
|
98
|
+
<Suspense>
|
|
99
|
+
<Optimizable id="hero" component={Hero} props={{ title: "Ship faster", cta: "Start free" }} />
|
|
100
|
+
</Suspense>;
|
|
101
|
+
```
|
|
80
102
|
|
|
81
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
### 7. See the variant and the exposure
|
|
99
120
|
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
+
```sh
|
|
144
|
+
npx next-evolve connect --test --app-url https://your-app.com
|
|
145
|
+
npx next-evolve doctor
|
|
122
146
|
```
|
|
123
147
|
|
|
124
|
-
|
|
148
|
+
More in [docs/production.md](docs/production.md).
|
|
125
149
|
|
|
126
|
-
|
|
150
|
+
## Docs
|
|
127
151
|
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
MIT
|
package/dist/cli/detect.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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")}`);
|
package/dist/cli/load-app.js
CHANGED
package/dist/client/beacon.d.ts
CHANGED
|
@@ -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. */
|
package/dist/client/beacon.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/react/region.d.ts
CHANGED
|
@@ -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.
|
package/dist/react/region.js
CHANGED
|
@@ -32,18 +32,30 @@ async function currentPath() {
|
|
|
32
32
|
return null;
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
|
-
/**
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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) {
|
|
@@ -124,4 +136,4 @@ async function Optimizable({ id, component, props }) {
|
|
|
124
136
|
...parsed.data
|
|
125
137
|
}));
|
|
126
138
|
}
|
|
127
|
-
export { Optimizable };
|
|
139
|
+
export { Optimizable, scheduleRegistration };
|
package/dist/store/memory.js
CHANGED
|
@@ -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;
|