@onlook/storybook-plugin 0.4.0-beta.11 → 0.4.0-beta.13

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/dist/cli/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
  import { createRequire } from 'node:module';
3
3
  import { command, string, boolean, run } from '@drizzle-team/brocli';
4
4
  import { execFile, spawn } from 'child_process';
5
- import fs, { readFileSync, existsSync } from 'fs';
6
5
  import path, { resolve, join, dirname } from 'path';
7
6
  import crypto from 'crypto';
7
+ import fs, { existsSync, readFileSync } from 'fs';
8
8
  import { createRequire as createRequire$1 } from 'module';
9
9
  import { promisify } from 'util';
10
10
  import { chromium } from 'playwright';
@@ -143,6 +143,77 @@ async function closeBrowser() {
143
143
  console.error("Error closing browser:", error);
144
144
  }
145
145
  }
146
+
147
+ // src/containment-contract/containment-contract.ts
148
+ var ONLOOK_RENDER_STATE_ATTR = "data-onlook-render-state";
149
+ var ONLOOK_CONTAINED_CARD_ATTR = "data-onlook-contained-card";
150
+
151
+ // src/screenshot-service/utils/screenshot/screenshot.ts
152
+ function classifyPreviewSnapshot(s) {
153
+ if (s.text.includes("Sorry, something went wrong")) return "errored";
154
+ if (s.text.includes("Couldn't find story")) return "errored";
155
+ if (s.hasContainedCard) return "contained";
156
+ if (s.renderStateAttr === "contained") return "contained";
157
+ if (s.renderStateAttr === "rendered") return "rendered";
158
+ if (s.text.length > 0) return "rendered";
159
+ if (s.hasSizedElement) return "rendered";
160
+ return "pending";
161
+ }
162
+ async function detectRenderState(page, opts = {}) {
163
+ const pollMs = opts.pollMs ?? 250;
164
+ const settleMs = opts.settleMs ?? 4e3;
165
+ const blankStablePolls = opts.blankStablePolls ?? 6;
166
+ const hardCapMs = opts.hardCapMs ?? 2e4;
167
+ const now = opts.now ?? Date.now;
168
+ const start = now();
169
+ let emptyStreak = 0;
170
+ const snapshot = () => page.evaluate(
171
+ ({ stateAttr, cardAttr }) => {
172
+ const root = document.getElementById("storybook-root") ?? document.body;
173
+ const text = (root?.textContent ?? "").trim();
174
+ let hasSizedElement = false;
175
+ if (root) {
176
+ const all = root.querySelectorAll("*");
177
+ for (let i = 0; i < all.length; i++) {
178
+ const rect = all[i]?.getBoundingClientRect();
179
+ if (rect && rect.width > 0 && rect.height > 0) {
180
+ hasSizedElement = true;
181
+ break;
182
+ }
183
+ }
184
+ }
185
+ return {
186
+ text,
187
+ hasContainedCard: Boolean(root?.querySelector(`[${cardAttr}]`)),
188
+ renderStateAttr: root?.getAttribute(stateAttr) ?? null,
189
+ hasSizedElement
190
+ };
191
+ },
192
+ { stateAttr: ONLOOK_RENDER_STATE_ATTR, cardAttr: ONLOOK_CONTAINED_CARD_ATTR }
193
+ );
194
+ while (true) {
195
+ let state;
196
+ let threw = false;
197
+ try {
198
+ state = classifyPreviewSnapshot(await snapshot());
199
+ } catch {
200
+ state = "pending";
201
+ threw = true;
202
+ }
203
+ if (state === "rendered" || state === "contained" || state === "errored") {
204
+ return state;
205
+ }
206
+ const elapsed = now() - start;
207
+ if (threw) {
208
+ emptyStreak = 0;
209
+ } else if (elapsed >= settleMs) {
210
+ emptyStreak += 1;
211
+ if (emptyStreak >= blankStablePolls) return "blank";
212
+ }
213
+ if (elapsed >= hardCapMs) return "timedOut";
214
+ await page.waitForTimeout(pollMs);
215
+ }
216
+ }
146
217
  function getScreenshotPath(storyId, theme) {
147
218
  const storyDir = path.join(SCREENSHOTS_DIR, storyId);
148
219
  return path.join(storyDir, `${theme}.png`);
@@ -151,7 +222,7 @@ function screenshotExists(storyId, theme) {
151
222
  const screenshotPath = getScreenshotPath(storyId, theme);
152
223
  return fs.existsSync(screenshotPath);
153
224
  }
154
- async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
225
+ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", loadTimeoutMs = 9e4, renderTimeoutMs = 2e4) {
155
226
  const browser = await getBrowser();
156
227
  const context = await browser.newContext({
157
228
  viewport: { width, height },
@@ -160,13 +231,14 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
160
231
  const page = await context.newPage();
161
232
  try {
162
233
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
163
- await page.goto(url, { timeout: timeoutMs });
164
- await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
165
- await page.waitForLoadState("load", { timeout: timeoutMs });
234
+ await page.goto(url, { timeout: loadTimeoutMs });
235
+ await page.waitForLoadState("domcontentloaded", { timeout: loadTimeoutMs });
236
+ await page.waitForLoadState("load", { timeout: loadTimeoutMs });
166
237
  try {
167
238
  await page.waitForLoadState("networkidle", { timeout: 5e3 });
168
239
  } catch {
169
240
  }
241
+ const renderState = await detectRenderState(page, { hardCapMs: renderTimeoutMs });
170
242
  await page.evaluate(() => document.fonts.ready);
171
243
  try {
172
244
  await page.evaluate(async () => {
@@ -228,7 +300,7 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
228
300
  } else {
229
301
  screenshotBuffer = await page.screenshot({ type: "png" });
230
302
  }
231
- return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
303
+ return { buffer: screenshotBuffer, boundingBox: resultBoundingBox, renderState };
232
304
  } finally {
233
305
  await context.close();
234
306
  }
@@ -431,36 +503,54 @@ function detectPackageManager(repoRoot) {
431
503
  }
432
504
  return "npm";
433
505
  }
506
+ function getStorybookScript(storybookDir) {
507
+ const packageJsonPath = join(storybookDir, "package.json");
508
+ if (!existsSync(packageJsonPath)) {
509
+ return null;
510
+ }
511
+ try {
512
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
513
+ return packageJson.scripts?.storybook ?? null;
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
434
518
 
435
519
  // src/cli/generate-screenshots/generate-screenshots.ts
520
+ function execStorybookCommand(pm) {
521
+ const devFlags = ["dev", "-p", "6006", "--no-open"];
522
+ switch (pm) {
523
+ case "pnpm":
524
+ return { command: "pnpm", args: ["exec", "storybook", ...devFlags] };
525
+ case "yarn":
526
+ return { command: "yarn", args: ["exec", "storybook", ...devFlags] };
527
+ case "bun":
528
+ return { command: "bunx", args: ["storybook", ...devFlags] };
529
+ default:
530
+ return { command: "npx", args: ["storybook", ...devFlags] };
531
+ }
532
+ }
436
533
  function getStorybookCommand(storybookDir) {
437
534
  const repoRoot = findRepoRoot(storybookDir);
438
535
  const pm = repoRoot ? detectPackageManager(repoRoot) : "npm";
439
536
  console.log(
440
537
  `\u{1F4E6} Detected package manager: ${pm}${repoRoot ? ` (from ${repoRoot})` : ""}`
441
538
  );
442
- let extraArgs = [];
443
- try {
444
- const pkgPath = resolve(storybookDir, "package.json");
445
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
446
- const script = pkg.scripts?.storybook ?? "";
447
- const hasPort = script.includes("-p ") || script.includes("--port");
448
- const hasNoOpen = script.includes("--no-open");
449
- if (!hasPort || !hasNoOpen) {
450
- const needsSeparator = pm === "npm" || pm === "pnpm";
451
- const flags = [];
452
- if (!hasPort) flags.push("-p", "6006");
453
- if (!hasNoOpen) flags.push("--no-open");
454
- extraArgs = needsSeparator && flags.length > 0 ? ["--", ...flags] : flags;
455
- }
456
- } catch {
457
- const separator = pm === "npm" || pm === "pnpm" ? ["--"] : [];
458
- extraArgs = [...separator, "-p", "6006", "--no-open"];
539
+ const memberScript = getStorybookScript(storybookDir);
540
+ const rootScript = !memberScript && repoRoot && repoRoot !== storybookDir ? getStorybookScript(repoRoot) : null;
541
+ const [script, scriptDir] = memberScript ? [memberScript, storybookDir] : rootScript && repoRoot ? [rootScript, repoRoot] : [null, storybookDir];
542
+ if (!script) {
543
+ console.log("\u26A0\uFE0F No `storybook` script found; invoking the binary directly");
544
+ return { ...execStorybookCommand(pm), cwd: storybookDir };
459
545
  }
460
- return {
461
- command: pm,
462
- args: ["run", "storybook", ...extraArgs]
463
- };
546
+ const hasPort = script.includes("-p ") || script.includes("--port");
547
+ const hasNoOpen = script.includes("--no-open");
548
+ const flags = [];
549
+ if (!hasPort) flags.push("-p", "6006");
550
+ if (!hasNoOpen) flags.push("--no-open");
551
+ const needsSeparator = pm === "npm" || pm === "pnpm";
552
+ const extraArgs = needsSeparator && flags.length > 0 ? ["--", ...flags] : flags;
553
+ return { command: pm, args: ["run", "storybook", ...extraArgs], cwd: scriptDir };
464
554
  }
465
555
  async function isStorybookRunning(url) {
466
556
  try {
@@ -575,8 +665,8 @@ async function generateScreenshots(options = {}) {
575
665
  if (alreadyRunning) {
576
666
  console.log("\u2705 Storybook is already running");
577
667
  } else {
578
- const { command: command2, args } = getStorybookCommand(storybookDir);
579
- storybookProcess = await startStorybook(command2, args, storybookDir);
668
+ const { command: command2, args, cwd } = getStorybookCommand(storybookDir);
669
+ storybookProcess = await startStorybook(command2, args, cwd);
580
670
  weStartedStorybook = true;
581
671
  console.log("\u23F3 Waiting for Storybook to finish compiling...");
582
672
  await waitForStorybookReady(url);
package/dist/index.d.ts CHANGED
@@ -53,6 +53,20 @@ type OnlookContainedPayload = {
53
53
  message: string;
54
54
  /** Present when the failure is attributed to a contained module (U4). */
55
55
  module?: ContainedModuleMarker;
56
+ /**
57
+ * React component stack from `onCaughtError`'s `errorInfo` (ONL-1505,
58
+ * additive). Present only on the `via: 'onCaughtError'` path — the legacy
59
+ * boundary decorator has no component stack to offer.
60
+ */
61
+ componentStack?: string;
62
+ /**
63
+ * Which catch path emitted this (ONL-1505, additive). `'onCaughtError'` =
64
+ * the React root hook registered via `parameters.react.rootOptions` (catches
65
+ * component render-throws, incl. those caught by Storybook's own
66
+ * renderToCanvas ErrorBoundary); `'decorator'` = the legacy
67
+ * OnlookContainmentBoundary decorator. Absent ⟹ legacy decorator (pre-1505).
68
+ */
69
+ via?: 'onCaughtError' | 'decorator';
56
70
  };
57
71
  type OnlookRenderedPayload = {
58
72
  storyId: string;
package/dist/index.js CHANGED
@@ -509,11 +509,76 @@ async function getBrowser() {
509
509
  }
510
510
  return browserPromise;
511
511
  }
512
+ function classifyPreviewSnapshot(s) {
513
+ if (s.text.includes("Sorry, something went wrong")) return "errored";
514
+ if (s.text.includes("Couldn't find story")) return "errored";
515
+ if (s.hasContainedCard) return "contained";
516
+ if (s.renderStateAttr === "contained") return "contained";
517
+ if (s.renderStateAttr === "rendered") return "rendered";
518
+ if (s.text.length > 0) return "rendered";
519
+ if (s.hasSizedElement) return "rendered";
520
+ return "pending";
521
+ }
522
+ async function detectRenderState(page, opts = {}) {
523
+ const pollMs = opts.pollMs ?? 250;
524
+ const settleMs = opts.settleMs ?? 4e3;
525
+ const blankStablePolls = opts.blankStablePolls ?? 6;
526
+ const hardCapMs = opts.hardCapMs ?? 2e4;
527
+ const now = opts.now ?? Date.now;
528
+ const start = now();
529
+ let emptyStreak = 0;
530
+ const snapshot = () => page.evaluate(
531
+ ({ stateAttr, cardAttr }) => {
532
+ const root = document.getElementById("storybook-root") ?? document.body;
533
+ const text = (root?.textContent ?? "").trim();
534
+ let hasSizedElement = false;
535
+ if (root) {
536
+ const all = root.querySelectorAll("*");
537
+ for (let i = 0; i < all.length; i++) {
538
+ const rect = all[i]?.getBoundingClientRect();
539
+ if (rect && rect.width > 0 && rect.height > 0) {
540
+ hasSizedElement = true;
541
+ break;
542
+ }
543
+ }
544
+ }
545
+ return {
546
+ text,
547
+ hasContainedCard: Boolean(root?.querySelector(`[${cardAttr}]`)),
548
+ renderStateAttr: root?.getAttribute(stateAttr) ?? null,
549
+ hasSizedElement
550
+ };
551
+ },
552
+ { stateAttr: ONLOOK_RENDER_STATE_ATTR, cardAttr: ONLOOK_CONTAINED_CARD_ATTR }
553
+ );
554
+ while (true) {
555
+ let state;
556
+ let threw = false;
557
+ try {
558
+ state = classifyPreviewSnapshot(await snapshot());
559
+ } catch {
560
+ state = "pending";
561
+ threw = true;
562
+ }
563
+ if (state === "rendered" || state === "contained" || state === "errored") {
564
+ return state;
565
+ }
566
+ const elapsed = now() - start;
567
+ if (threw) {
568
+ emptyStreak = 0;
569
+ } else if (elapsed >= settleMs) {
570
+ emptyStreak += 1;
571
+ if (emptyStreak >= blankStablePolls) return "blank";
572
+ }
573
+ if (elapsed >= hardCapMs) return "timedOut";
574
+ await page.waitForTimeout(pollMs);
575
+ }
576
+ }
512
577
  function getScreenshotPath(storyId, theme) {
513
578
  const storyDir = path7.join(SCREENSHOTS_DIR, storyId);
514
579
  return path7.join(storyDir, `${theme}.png`);
515
580
  }
516
- async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
581
+ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", loadTimeoutMs = 9e4, renderTimeoutMs = 2e4) {
517
582
  const browser = await getBrowser();
518
583
  const context = await browser.newContext({
519
584
  viewport: { width, height },
@@ -522,13 +587,14 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
522
587
  const page = await context.newPage();
523
588
  try {
524
589
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
525
- await page.goto(url, { timeout: timeoutMs });
526
- await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
527
- await page.waitForLoadState("load", { timeout: timeoutMs });
590
+ await page.goto(url, { timeout: loadTimeoutMs });
591
+ await page.waitForLoadState("domcontentloaded", { timeout: loadTimeoutMs });
592
+ await page.waitForLoadState("load", { timeout: loadTimeoutMs });
528
593
  try {
529
594
  await page.waitForLoadState("networkidle", { timeout: 5e3 });
530
595
  } catch {
531
596
  }
597
+ const renderState = await detectRenderState(page, { hardCapMs: renderTimeoutMs });
532
598
  await page.evaluate(() => document.fonts.ready);
533
599
  try {
534
600
  await page.evaluate(async () => {
@@ -590,7 +656,7 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
590
656
  } else {
591
657
  screenshotBuffer = await page.screenshot({ type: "png" });
592
658
  }
593
- return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
659
+ return { buffer: screenshotBuffer, boundingBox: resultBoundingBox, renderState };
594
660
  } finally {
595
661
  await context.close();
596
662
  }
@@ -795,6 +861,7 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
795
861
  const page = await context.newPage();
796
862
  const logs = [];
797
863
  const pageErrors = [];
864
+ const failedRequests = [];
798
865
  page.on("console", (msg) => {
799
866
  const level = msg.type();
800
867
  logs.push({
@@ -806,6 +873,28 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
806
873
  page.on("pageerror", (err) => {
807
874
  pageErrors.push(err.message);
808
875
  });
876
+ page.on("response", (res) => {
877
+ const status = res.status();
878
+ if (status >= 400) {
879
+ const req = res.request();
880
+ failedRequests.push({
881
+ url: res.url(),
882
+ status,
883
+ method: req.method(),
884
+ resourceType: req.resourceType(),
885
+ failure: null
886
+ });
887
+ }
888
+ });
889
+ page.on("requestfailed", (req) => {
890
+ failedRequests.push({
891
+ url: req.url(),
892
+ status: null,
893
+ method: req.method(),
894
+ resourceType: req.resourceType(),
895
+ failure: req.failure()?.errorText ?? "request failed"
896
+ });
897
+ });
809
898
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
810
899
  try {
811
900
  await page.goto(url, { timeout: timeoutMs });
@@ -816,7 +905,7 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
816
905
  } catch {
817
906
  }
818
907
  await new Promise((r) => setTimeout(r, 1e3));
819
- return { logs, pageErrors, url };
908
+ return { logs, pageErrors, failedRequests, url };
820
909
  } finally {
821
910
  await context.close();
822
911
  }
@@ -1148,9 +1237,11 @@ var serveMetadataAndScreenshots = (req, res, next) => {
1148
1237
  res.end(JSON.stringify({ error: 'theme must be "light" or "dark"' }));
1149
1238
  return;
1150
1239
  }
1151
- captureScreenshotBuffer(storyId, theme, width, height).then(({ buffer }) => {
1240
+ captureScreenshotBuffer(storyId, theme, width, height).then(({ buffer, renderState }) => {
1152
1241
  res.setHeader("Content-Type", "image/png");
1153
1242
  res.setHeader("Access-Control-Allow-Origin", "*");
1243
+ res.setHeader("Access-Control-Expose-Headers", "x-onlook-render-state");
1244
+ res.setHeader("x-onlook-render-state", renderState);
1154
1245
  res.setHeader("Cache-Control", "no-cache");
1155
1246
  res.end(buffer);
1156
1247
  }).catch((error) => {
@@ -2,9 +2,13 @@ import { O as OnlookPluginOptions } from '../storybook-onlook-plugin-B9Eo_OIq.js
2
2
  import 'vite';
3
3
 
4
4
  declare function pluginOptionsFromPresetOptions(options: Record<string, unknown> | undefined): OnlookPluginOptions;
5
+ type ChannelLike = {
6
+ on(eventName: string, listener: (...args: unknown[]) => void): unknown;
7
+ };
8
+ declare function experimental_serverChannel(channel: ChannelLike): Promise<ChannelLike>;
5
9
  type ViteConfigLike = {
6
10
  plugins?: unknown[];
7
11
  } & Record<string, unknown>;
8
12
  declare function viteFinal(config: ViteConfigLike, options?: Record<string, unknown>): Promise<ViteConfigLike>;
9
13
 
10
- export { pluginOptionsFromPresetOptions, viteFinal };
14
+ export { experimental_serverChannel, pluginOptionsFromPresetOptions, viteFinal };
@@ -13,6 +13,15 @@ import { promisify } from 'util';
13
13
  import { chromium } from 'playwright';
14
14
 
15
15
  globalThis.require = createRequire(import.meta.url);
16
+
17
+ // src/containment-contract/containment-contract.ts
18
+ var ONLOOK_CONTAINED_MODULES_GLOBAL = "__ONLOOK_CONTAINED_MODULES__";
19
+ var ONLOOK_CONTAINED_PROP = "__onlookContained";
20
+ var ONLOOK_CONTAINED_EVENT = "onlook/contained";
21
+ var ONLOOK_RENDERED_EVENT = "onlook/rendered";
22
+ var ONLOOK_UNCONTAINED_ERROR_EVENT = "onlook/uncontained-error";
23
+ var ONLOOK_RENDER_STATE_ATTR = "data-onlook-render-state";
24
+ var ONLOOK_CONTAINED_CARD_ATTR = "data-onlook-contained-card";
16
25
  function componentLocPlugin(options = {}) {
17
26
  const include = options.include ?? /\.(jsx|tsx)$/;
18
27
  const traverse = traverseModule.default ?? traverseModule;
@@ -73,12 +82,6 @@ function componentLocPlugin(options = {}) {
73
82
  }
74
83
  };
75
84
  }
76
-
77
- // src/containment-contract/containment-contract.ts
78
- var ONLOOK_CONTAINED_MODULES_GLOBAL = "__ONLOOK_CONTAINED_MODULES__";
79
- var ONLOOK_CONTAINED_PROP = "__onlookContained";
80
-
81
- // src/env-containment/env-containment.ts
82
85
  var ENV_CONTAINMENT_PLUGIN_NAME = "onlook-env-containment";
83
86
  var ENV_CONTAINMENT_VIRTUAL_PREFIX = "\0onlook-env-containment:";
84
87
  var VALID_IDENT_RE = /^[A-Za-z_$][\w$]*$/;
@@ -386,11 +389,76 @@ async function getBrowser() {
386
389
  }
387
390
  return browserPromise;
388
391
  }
392
+ function classifyPreviewSnapshot(s) {
393
+ if (s.text.includes("Sorry, something went wrong")) return "errored";
394
+ if (s.text.includes("Couldn't find story")) return "errored";
395
+ if (s.hasContainedCard) return "contained";
396
+ if (s.renderStateAttr === "contained") return "contained";
397
+ if (s.renderStateAttr === "rendered") return "rendered";
398
+ if (s.text.length > 0) return "rendered";
399
+ if (s.hasSizedElement) return "rendered";
400
+ return "pending";
401
+ }
402
+ async function detectRenderState(page, opts = {}) {
403
+ const pollMs = opts.pollMs ?? 250;
404
+ const settleMs = opts.settleMs ?? 4e3;
405
+ const blankStablePolls = opts.blankStablePolls ?? 6;
406
+ const hardCapMs = opts.hardCapMs ?? 2e4;
407
+ const now = opts.now ?? Date.now;
408
+ const start = now();
409
+ let emptyStreak = 0;
410
+ const snapshot = () => page.evaluate(
411
+ ({ stateAttr, cardAttr }) => {
412
+ const root = document.getElementById("storybook-root") ?? document.body;
413
+ const text = (root?.textContent ?? "").trim();
414
+ let hasSizedElement = false;
415
+ if (root) {
416
+ const all = root.querySelectorAll("*");
417
+ for (let i = 0; i < all.length; i++) {
418
+ const rect = all[i]?.getBoundingClientRect();
419
+ if (rect && rect.width > 0 && rect.height > 0) {
420
+ hasSizedElement = true;
421
+ break;
422
+ }
423
+ }
424
+ }
425
+ return {
426
+ text,
427
+ hasContainedCard: Boolean(root?.querySelector(`[${cardAttr}]`)),
428
+ renderStateAttr: root?.getAttribute(stateAttr) ?? null,
429
+ hasSizedElement
430
+ };
431
+ },
432
+ { stateAttr: ONLOOK_RENDER_STATE_ATTR, cardAttr: ONLOOK_CONTAINED_CARD_ATTR }
433
+ );
434
+ while (true) {
435
+ let state;
436
+ let threw = false;
437
+ try {
438
+ state = classifyPreviewSnapshot(await snapshot());
439
+ } catch {
440
+ state = "pending";
441
+ threw = true;
442
+ }
443
+ if (state === "rendered" || state === "contained" || state === "errored") {
444
+ return state;
445
+ }
446
+ const elapsed = now() - start;
447
+ if (threw) {
448
+ emptyStreak = 0;
449
+ } else if (elapsed >= settleMs) {
450
+ emptyStreak += 1;
451
+ if (emptyStreak >= blankStablePolls) return "blank";
452
+ }
453
+ if (elapsed >= hardCapMs) return "timedOut";
454
+ await page.waitForTimeout(pollMs);
455
+ }
456
+ }
389
457
  function getScreenshotPath(storyId, theme) {
390
458
  const storyDir = path7.join(SCREENSHOTS_DIR, storyId);
391
459
  return path7.join(storyDir, `${theme}.png`);
392
460
  }
393
- async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
461
+ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", loadTimeoutMs = 9e4, renderTimeoutMs = 2e4) {
394
462
  const browser = await getBrowser();
395
463
  const context = await browser.newContext({
396
464
  viewport: { width, height },
@@ -399,13 +467,14 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
399
467
  const page = await context.newPage();
400
468
  try {
401
469
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
402
- await page.goto(url, { timeout: timeoutMs });
403
- await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
404
- await page.waitForLoadState("load", { timeout: timeoutMs });
470
+ await page.goto(url, { timeout: loadTimeoutMs });
471
+ await page.waitForLoadState("domcontentloaded", { timeout: loadTimeoutMs });
472
+ await page.waitForLoadState("load", { timeout: loadTimeoutMs });
405
473
  try {
406
474
  await page.waitForLoadState("networkidle", { timeout: 5e3 });
407
475
  } catch {
408
476
  }
477
+ const renderState = await detectRenderState(page, { hardCapMs: renderTimeoutMs });
409
478
  await page.evaluate(() => document.fonts.ready);
410
479
  try {
411
480
  await page.evaluate(async () => {
@@ -467,7 +536,7 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
467
536
  } else {
468
537
  screenshotBuffer = await page.screenshot({ type: "png" });
469
538
  }
470
- return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
539
+ return { buffer: screenshotBuffer, boundingBox: resultBoundingBox, renderState };
471
540
  } finally {
472
541
  await context.close();
473
542
  }
@@ -788,6 +857,7 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
788
857
  const page = await context.newPage();
789
858
  const logs = [];
790
859
  const pageErrors = [];
860
+ const failedRequests = [];
791
861
  page.on("console", (msg) => {
792
862
  const level = msg.type();
793
863
  logs.push({
@@ -799,6 +869,28 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
799
869
  page.on("pageerror", (err) => {
800
870
  pageErrors.push(err.message);
801
871
  });
872
+ page.on("response", (res) => {
873
+ const status = res.status();
874
+ if (status >= 400) {
875
+ const req = res.request();
876
+ failedRequests.push({
877
+ url: res.url(),
878
+ status,
879
+ method: req.method(),
880
+ resourceType: req.resourceType(),
881
+ failure: null
882
+ });
883
+ }
884
+ });
885
+ page.on("requestfailed", (req) => {
886
+ failedRequests.push({
887
+ url: req.url(),
888
+ status: null,
889
+ method: req.method(),
890
+ resourceType: req.resourceType(),
891
+ failure: req.failure()?.errorText ?? "request failed"
892
+ });
893
+ });
802
894
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
803
895
  try {
804
896
  await page.goto(url, { timeout: timeoutMs });
@@ -809,7 +901,7 @@ async function captureConsoleLogs(storyId, theme = "light", storybookUrl = "http
809
901
  } catch {
810
902
  }
811
903
  await new Promise((r) => setTimeout(r, 1e3));
812
- return { logs, pageErrors, url };
904
+ return { logs, pageErrors, failedRequests, url };
813
905
  } finally {
814
906
  await context.close();
815
907
  }
@@ -1141,9 +1233,11 @@ var serveMetadataAndScreenshots = (req, res, next) => {
1141
1233
  res.end(JSON.stringify({ error: 'theme must be "light" or "dark"' }));
1142
1234
  return;
1143
1235
  }
1144
- captureScreenshotBuffer(storyId, theme, width, height).then(({ buffer }) => {
1236
+ captureScreenshotBuffer(storyId, theme, width, height).then(({ buffer, renderState }) => {
1145
1237
  res.setHeader("Content-Type", "image/png");
1146
1238
  res.setHeader("Access-Control-Allow-Origin", "*");
1239
+ res.setHeader("Access-Control-Expose-Headers", "x-onlook-render-state");
1240
+ res.setHeader("x-onlook-render-state", renderState);
1147
1241
  res.setHeader("Cache-Control", "no-cache");
1148
1242
  res.end(buffer);
1149
1243
  }).catch((error) => {
@@ -1338,12 +1432,12 @@ function storybookOnlookPlugin(options = {}) {
1338
1432
  "manifest.json"
1339
1433
  );
1340
1434
  refreshManifest(cachedManifestPath);
1341
- let pending2;
1435
+ let pending3;
1342
1436
  const scheduleRefresh = (file) => {
1343
1437
  if (file !== cachedManifestPath) return;
1344
- if (pending2) clearTimeout(pending2);
1345
- pending2 = setTimeout(() => {
1346
- pending2 = void 0;
1438
+ if (pending3) clearTimeout(pending3);
1439
+ pending3 = setTimeout(() => {
1440
+ pending3 = void 0;
1347
1441
  refreshManifest(cachedManifestPath);
1348
1442
  }, 100);
1349
1443
  };
@@ -1428,6 +1522,68 @@ function storybookOnlookPlugin(options = {}) {
1428
1522
  );
1429
1523
  }
1430
1524
 
1525
+ // src/utils/notifyStoryError/notifyStoryError.ts
1526
+ var DEBOUNCE_MS3 = 300;
1527
+ var REQUEST_TIMEOUT_MS2 = 5e3;
1528
+ var pending2 = null;
1529
+ var inFlight2 = false;
1530
+ var trailing = false;
1531
+ var latest = null;
1532
+ function notifyStoryError(payload) {
1533
+ enqueue({ type: "STORY_ERROR", ...payload });
1534
+ }
1535
+ function notifyStoryResolved(storyId) {
1536
+ enqueue({ type: "STORY_RESOLVED", storyId });
1537
+ }
1538
+ function enqueue(event) {
1539
+ const proxyToken = process.env.ONBOOK_PROXY_TOKEN;
1540
+ const broadcastUrl = process.env.ONBOOK_BROADCAST_URL;
1541
+ if (!proxyToken || !broadcastUrl) return;
1542
+ latest = event;
1543
+ if (pending2) clearTimeout(pending2);
1544
+ pending2 = setTimeout(() => {
1545
+ pending2 = null;
1546
+ if (inFlight2) {
1547
+ trailing = true;
1548
+ return;
1549
+ }
1550
+ void fire2(proxyToken, broadcastUrl);
1551
+ }, DEBOUNCE_MS3);
1552
+ }
1553
+ async function fire2(proxyToken, broadcastUrl) {
1554
+ const event = latest;
1555
+ if (!event) return;
1556
+ inFlight2 = true;
1557
+ try {
1558
+ const res = await fetch(broadcastUrl, {
1559
+ method: "POST",
1560
+ headers: {
1561
+ Authorization: `Bearer ${proxyToken}`,
1562
+ "Content-Type": "application/json"
1563
+ },
1564
+ body: JSON.stringify({ event }),
1565
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS2)
1566
+ });
1567
+ if (!res.ok) {
1568
+ console.warn("[STORYBOOK_PLUGIN] story-event broadcast non-2xx", {
1569
+ status: res.status,
1570
+ statusText: res.statusText
1571
+ });
1572
+ }
1573
+ } catch (err) {
1574
+ console.warn(
1575
+ "[STORYBOOK_PLUGIN] story-event broadcast failed",
1576
+ err instanceof Error ? err.message : String(err)
1577
+ );
1578
+ } finally {
1579
+ inFlight2 = false;
1580
+ if (trailing) {
1581
+ trailing = false;
1582
+ void fire2(proxyToken, broadcastUrl);
1583
+ }
1584
+ }
1585
+ }
1586
+
1431
1587
  // src/preset/preset.ts
1432
1588
  var OPTION_KEY_MAP = {
1433
1589
  port: true,
@@ -1449,10 +1605,56 @@ function pluginOptionsFromPresetOptions(options) {
1449
1605
  }
1450
1606
  return picked;
1451
1607
  }
1608
+ var erroredStories = /* @__PURE__ */ new Map();
1609
+ var RENDER_RESOLVE_SUPPRESS_MS = 1500;
1610
+ async function experimental_serverChannel(channel) {
1611
+ try {
1612
+ channel.on(ONLOOK_CONTAINED_EVENT, (...args) => {
1613
+ const payload = args[0];
1614
+ if (!payload) return;
1615
+ console.error(`[ONBOOK_STORY_ERROR] contained story=${payload.storyId || "?"}`);
1616
+ if (payload.storyId) erroredStories.set(payload.storyId, Date.now());
1617
+ notifyStoryError({
1618
+ storyId: payload.storyId ?? "",
1619
+ message: payload.message ?? "Unknown story error",
1620
+ source: "contained",
1621
+ ...payload.componentStack ? { componentStack: payload.componentStack } : {}
1622
+ });
1623
+ });
1624
+ channel.on(ONLOOK_UNCONTAINED_ERROR_EVENT, (...args) => {
1625
+ const payload = args[0];
1626
+ if (!payload) return;
1627
+ console.error(`[ONBOOK_STORY_ERROR] uncontained story=${payload.storyId || "?"}`);
1628
+ if (payload.storyId) erroredStories.set(payload.storyId, Date.now());
1629
+ notifyStoryError({
1630
+ storyId: payload.storyId ?? "",
1631
+ message: payload.message ?? "Unknown story error",
1632
+ source: "uncontained"
1633
+ });
1634
+ });
1635
+ channel.on(ONLOOK_RENDERED_EVENT, (...args) => {
1636
+ const payload = args[0];
1637
+ if (!payload?.storyId) return;
1638
+ const erroredAt = erroredStories.get(payload.storyId);
1639
+ if (erroredAt === void 0) return;
1640
+ if (Date.now() - erroredAt < RENDER_RESOLVE_SUPPRESS_MS) return;
1641
+ erroredStories.delete(payload.storyId);
1642
+ console.error(`[ONBOOK_STORY_ERROR] resolved story=${payload.storyId}`);
1643
+ notifyStoryResolved(payload.storyId);
1644
+ });
1645
+ console.error("[ONBOOK_STORY_ERROR] server-channel error + recovery tap attached");
1646
+ } catch (err) {
1647
+ console.error(
1648
+ "[ONBOOK_STORY_ERROR] failed to attach server-channel tap",
1649
+ err instanceof Error ? err.message : String(err)
1650
+ );
1651
+ }
1652
+ return channel;
1653
+ }
1452
1654
  async function viteFinal(config, options = {}) {
1453
1655
  const plugins = storybookOnlookPlugin(pluginOptionsFromPresetOptions(options));
1454
1656
  if (plugins.length === 0) return config;
1455
1657
  return { ...config, plugins: [...config.plugins ?? [], ...plugins] };
1456
1658
  }
1457
1659
 
1458
- export { pluginOptionsFromPresetOptions, viteFinal };
1660
+ export { experimental_serverChannel, pluginOptionsFromPresetOptions, viteFinal };
@@ -44,6 +44,20 @@ type OnlookContainedPayload = {
44
44
  message: string;
45
45
  /** Present when the failure is attributed to a contained module (U4). */
46
46
  module?: ContainedModuleMarker;
47
+ /**
48
+ * React component stack from `onCaughtError`'s `errorInfo` (ONL-1505,
49
+ * additive). Present only on the `via: 'onCaughtError'` path — the legacy
50
+ * boundary decorator has no component stack to offer.
51
+ */
52
+ componentStack?: string;
53
+ /**
54
+ * Which catch path emitted this (ONL-1505, additive). `'onCaughtError'` =
55
+ * the React root hook registered via `parameters.react.rootOptions` (catches
56
+ * component render-throws, incl. those caught by Storybook's own
57
+ * renderToCanvas ErrorBoundary); `'decorator'` = the legacy
58
+ * OnlookContainmentBoundary decorator. Absent ⟹ legacy decorator (pre-1505).
59
+ */
60
+ via?: 'onCaughtError' | 'decorator';
47
61
  };
48
62
  type OnlookRenderedPayload = {
49
63
  storyId: string;
@@ -92,7 +106,7 @@ declare class OnlookContainmentBoundary extends React.Component<BoundaryProps, B
92
106
  private renderedSignalSent;
93
107
  constructor(props: BoundaryProps);
94
108
  static getDerivedStateFromError(error: unknown): BoundaryState;
95
- componentDidCatch(error: unknown): void;
109
+ componentDidCatch(): void;
96
110
  componentDidMount(): void;
97
111
  componentDidUpdate(): void;
98
112
  componentWillUnmount(): void;
@@ -134,6 +148,27 @@ declare function getPreviewChannel(): ChannelLike | null;
134
148
  * stay byte-stable.
135
149
  */
136
150
  declare function isSnapshotEnvironment(): boolean;
151
+ /** Trim a React component stack to a payload-sized summary. */
152
+ declare function summarizeComponentStack(stack: unknown, maxLength?: number): string | undefined;
153
+ /**
154
+ * React root `onCaughtError` handler (ONL-1505). Registered via the preview
155
+ * annotations' `parameters.react.rootOptions.onCaughtError`, which
156
+ * `@storybook/react`'s `renderToCanvas` threads verbatim into `createRoot`.
157
+ *
158
+ * React 19 calls this for ANY error caught by an error boundary in the story
159
+ * tree — crucially including Storybook's own `renderToCanvas` ErrorBoundary,
160
+ * which is what catches a component render-throw. This is the ONLY node-
161
+ * reachable signal for render-crashes: `window.onerror` fires only for
162
+ * UNCAUGHT errors, and the channel `storyThrewException` / `storyErrored`
163
+ * events don't fire for boundary-caught render-throws. We re-emit as the
164
+ * existing `onlook/contained` event (tagged `via: 'onCaughtError'`) so the
165
+ * node-side server-channel tap needs no change.
166
+ *
167
+ * Registering `onCaughtError` replaces React's default (which logs caught
168
+ * errors to `console.error`), so we re-log for dev parity — keeping the
169
+ * iframe console behavior unchanged and giving the live tap a second marker.
170
+ */
171
+ declare function handleRootCaughtError(error: unknown, componentStack?: unknown): void;
137
172
  /**
138
173
  * Attribute an error to a contained module (U4's env-containment stand-ins).
139
174
  * Primary: the marker the stand-in attaches at `error.__onlookContained`.
@@ -152,5 +187,14 @@ declare function onlookBeforeAll(): void;
152
187
 
153
188
  declare const decorators: (typeof onlookContainmentDecorator)[];
154
189
  declare const beforeAll: typeof onlookBeforeAll;
190
+ declare const parameters: {
191
+ react: {
192
+ rootOptions: {
193
+ onCaughtError: (error: unknown, errorInfo?: {
194
+ componentStack?: string;
195
+ }) => void;
196
+ };
197
+ };
198
+ };
155
199
 
156
- export { ContainmentCard, ONLOOK_CONTAINED_CARD_ATTR, ONLOOK_CONTAINED_EVENT, ONLOOK_CONTAINMENT_CONTRACT_GLOBAL, ONLOOK_CONTAINMENT_CONTRACT_VERSION, ONLOOK_RENDERED_EVENT, ONLOOK_RENDER_STATE_ATTR, ONLOOK_RENDER_STORY_ATTR, ONLOOK_UNCONTAINED_ERROR_EVENT, type OnlookContainedPayload, OnlookContainmentBoundary, type OnlookContainmentContract, type OnlookRenderState, type OnlookRenderedPayload, type OnlookUncontainedErrorPayload, attributeErrorToModule, beforeAll, decorators, getPreviewChannel, installUncontainedErrorRelay, isSnapshotEnvironment, onlookBeforeAll, onlookContainmentDecorator };
200
+ export { ContainmentCard, ONLOOK_CONTAINED_CARD_ATTR, ONLOOK_CONTAINED_EVENT, ONLOOK_CONTAINMENT_CONTRACT_GLOBAL, ONLOOK_CONTAINMENT_CONTRACT_VERSION, ONLOOK_RENDERED_EVENT, ONLOOK_RENDER_STATE_ATTR, ONLOOK_RENDER_STORY_ATTR, ONLOOK_UNCONTAINED_ERROR_EVENT, type OnlookContainedPayload, OnlookContainmentBoundary, type OnlookContainmentContract, type OnlookRenderState, type OnlookRenderedPayload, type OnlookUncontainedErrorPayload, attributeErrorToModule, beforeAll, decorators, getPreviewChannel, handleRootCaughtError, installUncontainedErrorRelay, isSnapshotEnvironment, onlookBeforeAll, onlookContainmentDecorator, parameters, summarizeComponentStack };
@@ -85,6 +85,28 @@ function emitRendered(storyId) {
85
85
  contractVersion: ONLOOK_CONTAINMENT_CONTRACT_VERSION
86
86
  });
87
87
  }
88
+ function summarizeComponentStack(stack, maxLength = 2e3) {
89
+ if (typeof stack !== "string") return void 0;
90
+ const trimmed = stack.trim();
91
+ if (!trimmed) return void 0;
92
+ return trimmed.length > maxLength ? `${trimmed.slice(0, maxLength - 1)}\u2026` : trimmed;
93
+ }
94
+ function handleRootCaughtError(error, componentStack) {
95
+ try {
96
+ console.error(error);
97
+ } catch {
98
+ }
99
+ const storyId = currentStoryId();
100
+ const stack = summarizeComponentStack(componentStack);
101
+ const module = attributeErrorToModule(error);
102
+ emitContained({
103
+ storyId: storyId ?? "",
104
+ contractVersion: ONLOOK_CONTAINMENT_CONTRACT_VERSION,
105
+ message: summarizeError(error),
106
+ ...module ? { module } : {},
107
+ ...stack ? { componentStack: stack } : {}
108
+ });
109
+ }
88
110
  function summarizeError(error, maxLength = 400) {
89
111
  const raw = error instanceof Error ? error.message : typeof error === "string" ? error : String(error);
90
112
  const oneline = raw.trim();
@@ -242,15 +264,8 @@ var OnlookContainmentBoundary = class extends React.Component {
242
264
  static getDerivedStateFromError(error) {
243
265
  return { error, hasError: true };
244
266
  }
245
- componentDidCatch(error) {
246
- const module = attributeErrorToModule(error);
267
+ componentDidCatch() {
247
268
  setRenderState("contained", this.props.storyId);
248
- emitContained({
249
- storyId: this.props.storyId,
250
- contractVersion: ONLOOK_CONTAINMENT_CONTRACT_VERSION,
251
- message: summarizeError(error),
252
- ...module ? { module } : {}
253
- });
254
269
  }
255
270
  componentDidMount() {
256
271
  exposeContract();
@@ -298,5 +313,12 @@ function onlookContainmentDecorator(Story, context) {
298
313
  // src/preview/index.ts
299
314
  var decorators = [onlookContainmentDecorator];
300
315
  var beforeAll = onlookBeforeAll;
316
+ var parameters = {
317
+ react: {
318
+ rootOptions: {
319
+ onCaughtError: (error, errorInfo) => handleRootCaughtError(error, errorInfo?.componentStack)
320
+ }
321
+ }
322
+ };
301
323
 
302
- export { ContainmentCard, ONLOOK_CONTAINED_CARD_ATTR, ONLOOK_CONTAINED_EVENT, ONLOOK_CONTAINMENT_CONTRACT_GLOBAL, ONLOOK_CONTAINMENT_CONTRACT_VERSION, ONLOOK_RENDERED_EVENT, ONLOOK_RENDER_STATE_ATTR, ONLOOK_RENDER_STORY_ATTR, ONLOOK_UNCONTAINED_ERROR_EVENT, OnlookContainmentBoundary, attributeErrorToModule, beforeAll, decorators, getPreviewChannel, installUncontainedErrorRelay, isSnapshotEnvironment, onlookBeforeAll, onlookContainmentDecorator };
324
+ export { ContainmentCard, ONLOOK_CONTAINED_CARD_ATTR, ONLOOK_CONTAINED_EVENT, ONLOOK_CONTAINMENT_CONTRACT_GLOBAL, ONLOOK_CONTAINMENT_CONTRACT_VERSION, ONLOOK_RENDERED_EVENT, ONLOOK_RENDER_STATE_ATTR, ONLOOK_RENDER_STORY_ATTR, ONLOOK_UNCONTAINED_ERROR_EVENT, OnlookContainmentBoundary, attributeErrorToModule, beforeAll, decorators, getPreviewChannel, handleRootCaughtError, installUncontainedErrorRelay, isSnapshotEnvironment, onlookBeforeAll, onlookContainmentDecorator, parameters, summarizeComponentStack };
@@ -141,6 +141,77 @@ async function closeBrowser() {
141
141
  console.error("Error closing browser:", error);
142
142
  }
143
143
  }
144
+
145
+ // src/containment-contract/containment-contract.ts
146
+ var ONLOOK_RENDER_STATE_ATTR = "data-onlook-render-state";
147
+ var ONLOOK_CONTAINED_CARD_ATTR = "data-onlook-contained-card";
148
+
149
+ // src/screenshot-service/utils/screenshot/screenshot.ts
150
+ function classifyPreviewSnapshot(s) {
151
+ if (s.text.includes("Sorry, something went wrong")) return "errored";
152
+ if (s.text.includes("Couldn't find story")) return "errored";
153
+ if (s.hasContainedCard) return "contained";
154
+ if (s.renderStateAttr === "contained") return "contained";
155
+ if (s.renderStateAttr === "rendered") return "rendered";
156
+ if (s.text.length > 0) return "rendered";
157
+ if (s.hasSizedElement) return "rendered";
158
+ return "pending";
159
+ }
160
+ async function detectRenderState(page, opts = {}) {
161
+ const pollMs = opts.pollMs ?? 250;
162
+ const settleMs = opts.settleMs ?? 4e3;
163
+ const blankStablePolls = opts.blankStablePolls ?? 6;
164
+ const hardCapMs = opts.hardCapMs ?? 2e4;
165
+ const now = opts.now ?? Date.now;
166
+ const start = now();
167
+ let emptyStreak = 0;
168
+ const snapshot = () => page.evaluate(
169
+ ({ stateAttr, cardAttr }) => {
170
+ const root = document.getElementById("storybook-root") ?? document.body;
171
+ const text = (root?.textContent ?? "").trim();
172
+ let hasSizedElement = false;
173
+ if (root) {
174
+ const all = root.querySelectorAll("*");
175
+ for (let i = 0; i < all.length; i++) {
176
+ const rect = all[i]?.getBoundingClientRect();
177
+ if (rect && rect.width > 0 && rect.height > 0) {
178
+ hasSizedElement = true;
179
+ break;
180
+ }
181
+ }
182
+ }
183
+ return {
184
+ text,
185
+ hasContainedCard: Boolean(root?.querySelector(`[${cardAttr}]`)),
186
+ renderStateAttr: root?.getAttribute(stateAttr) ?? null,
187
+ hasSizedElement
188
+ };
189
+ },
190
+ { stateAttr: ONLOOK_RENDER_STATE_ATTR, cardAttr: ONLOOK_CONTAINED_CARD_ATTR }
191
+ );
192
+ while (true) {
193
+ let state;
194
+ let threw = false;
195
+ try {
196
+ state = classifyPreviewSnapshot(await snapshot());
197
+ } catch {
198
+ state = "pending";
199
+ threw = true;
200
+ }
201
+ if (state === "rendered" || state === "contained" || state === "errored") {
202
+ return state;
203
+ }
204
+ const elapsed = now() - start;
205
+ if (threw) {
206
+ emptyStreak = 0;
207
+ } else if (elapsed >= settleMs) {
208
+ emptyStreak += 1;
209
+ if (emptyStreak >= blankStablePolls) return "blank";
210
+ }
211
+ if (elapsed >= hardCapMs) return "timedOut";
212
+ await page.waitForTimeout(pollMs);
213
+ }
214
+ }
144
215
  function getScreenshotPath(storyId, theme) {
145
216
  const storyDir = path.join(SCREENSHOTS_DIR, storyId);
146
217
  return path.join(storyDir, `${theme}.png`);
@@ -149,7 +220,7 @@ function screenshotExists(storyId, theme) {
149
220
  const screenshotPath = getScreenshotPath(storyId, theme);
150
221
  return fs.existsSync(screenshotPath);
151
222
  }
152
- async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", timeoutMs = 3e4) {
223
+ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, height = VIEWPORT_HEIGHT, storybookUrl = "http://localhost:6006", loadTimeoutMs = 9e4, renderTimeoutMs = 2e4) {
153
224
  const browser = await getBrowser();
154
225
  const context = await browser.newContext({
155
226
  viewport: { width, height },
@@ -158,13 +229,14 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
158
229
  const page = await context.newPage();
159
230
  try {
160
231
  const url = `${storybookUrl}/iframe.html?id=${storyId}&viewMode=story&globals=theme:${theme}`;
161
- await page.goto(url, { timeout: timeoutMs });
162
- await page.waitForLoadState("domcontentloaded", { timeout: timeoutMs });
163
- await page.waitForLoadState("load", { timeout: timeoutMs });
232
+ await page.goto(url, { timeout: loadTimeoutMs });
233
+ await page.waitForLoadState("domcontentloaded", { timeout: loadTimeoutMs });
234
+ await page.waitForLoadState("load", { timeout: loadTimeoutMs });
164
235
  try {
165
236
  await page.waitForLoadState("networkidle", { timeout: 5e3 });
166
237
  } catch {
167
238
  }
239
+ const renderState = await detectRenderState(page, { hardCapMs: renderTimeoutMs });
168
240
  await page.evaluate(() => document.fonts.ready);
169
241
  try {
170
242
  await page.evaluate(async () => {
@@ -226,7 +298,7 @@ async function captureScreenshotBuffer(storyId, theme, width = VIEWPORT_WIDTH, h
226
298
  } else {
227
299
  screenshotBuffer = await page.screenshot({ type: "png" });
228
300
  }
229
- return { buffer: screenshotBuffer, boundingBox: resultBoundingBox };
301
+ return { buffer: screenshotBuffer, boundingBox: resultBoundingBox, renderState };
230
302
  } finally {
231
303
  await context.close();
232
304
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlook/storybook-plugin",
3
- "version": "0.4.0-beta.11",
3
+ "version": "0.4.0-beta.13",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "onlook-storybook": "./dist/cli/index.js"
@@ -10,6 +10,7 @@
10
10
  "types": "./dist/index.d.ts",
11
11
  "default": "./dist/index.js"
12
12
  },
13
+ "./package.json": "./package.json",
13
14
  "./preset": {
14
15
  "types": "./dist/preset/index.d.ts",
15
16
  "default": "./dist/preset/index.js"