Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions runner/apps/authoring/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2053,10 +2053,18 @@ function Authoring({
});
return () => { cancelled = true; };
}, [version, nextVersion, versionsResolved]);
// True while a next-format version's real availability is still unknown:
// either /api/versions hasn't resolved yet, or the exists-check above is
// in flight. Blocks the runtime-mount effect until it's settled.
const versionPending = isNextPrereleaseVersion(version) && (!versionsResolved || versionCheckPending);
// True while the version to mount is still unknown, which blocks the
// runtime-mount effect until it's settled. Two cases:
// - a next-format version whose real availability is unknown: /api/versions
// hasn't resolved yet, or the exists-check above is in flight;
// - a visit that pinned nothing, still on the DEFAULT_VERSION placeholder:
// the versions fetch swaps in npm `latest`, and between an npm release and
// the next bucket re-pin that is a different version, so mounting first
// boots the preview twice (DEV-3344). Only the mount waits; the starter
// artifact still loads in parallel.
const versionPending =
(isNextPrereleaseVersion(version) && (!versionsResolved || versionCheckPending)) ||
(!versionsResolved && !hadUrlVersion.current && version === DEFAULT_VERSION);

// A candidate bucket absent from the static index (Sentry DEMOS-1C) is a
// by-design outcome (ADR-0021 #2/#3, most selectable versions have no
Expand Down
57 changes: 54 additions & 3 deletions runner/e2e/telemetry-faro.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { test, expect, type Route, type Page } from "@playwright/test";
import { spawn, execSync, type ChildProcess } from "node:child_process";
import { fileURLToPath } from "node:url";
import { activeEditor, flushFaro, previewReady, stubShell, abortSandpackHosts } from "./helpers.js";
import { readFileSync } from "node:fs";
import { activeEditor, flushFaro, previewReady, stubShell, abortSandpackHosts, workspaceFiles } from "./helpers.js";
import { fingerprint } from "../packages/runtime/src/telemetry/fingerprint.js";
import { stableBucketVersions } from "../packages/runtime/src/version.js";

// The version a bare visit starts on (`catalog.ts`'s DEFAULT_VERSION), read
// from `catalog.json` so the weekly bucket re-pin cannot leave it stale here.
const DEFAULT_VERSION = stableBucketVersions(
(JSON.parse(readFileSync(new URL("../catalog.json", import.meta.url), "utf8")) as { bucketVersions: Record<string, string> })
.bucketVersions,
)[0];
const newerPatch = (v: string) => v.replace(/\d+$/, (patch) => String(Number(patch) + 1));

// Faro in the authoring app. Gated: needs a dist built with
// VITE_TELEMETRY_LOCAL=1 (contract §10), served on its own port (never
Expand Down Expand Up @@ -656,8 +666,8 @@ test.describe("Faro in the authoring app", () => {
await stubShell(page);
const captured = captureTelemetry(page);
await page.goto("/");
// The stubbed version list remounts the preview once after load, and a
// remount closes the open burst; drive the ladder after it.
// The preview mounts once the stubbed version list resolves, and a mount
// closes the open burst; drive the ladder after it.
await expect(page).toHaveURL(/[?&]v=18\.0\.0\b/);

const relay = (message: string, sentry = false) =>
Expand Down Expand Up @@ -921,6 +931,47 @@ test.describe("Faro in the authoring app", () => {
expect(record!.type).toBe("DemoError");
expect(uncaught()[0]!.context).toMatchObject({ "hot.surface": "demo-runtime", "hot.framework": "javascript" });
});

// Between an npm release and the weekly bucket re-pin, npm `latest` is newer
// than the default version a bare visit starts on. Mounting before
// /api/versions answered booted the preview on the default, then remounted on
// `latest`: two Sandpack boots and an `abandoned` preview.ready_ms on every
// such visit (DEV-3344). The versions response is held until the starter
// artifact has loaded, which is the order that remounted. The oracle is the
// `abandoned` point the first mount's cleanup emits; the aborted bundler
// keeps the preview booting, so nothing else settles the tracker first.
test("a bare visit mounts the preview once when npm latest is newer than the default version", async ({ page }) => {
await stubShell(page);
const latest = newerPatch(DEFAULT_VERSION);
let releaseVersions!: () => void;
const versionsHeld = new Promise<void>((resolve) => { releaseVersions = resolve; });
// Registered after stubShell, so it takes precedence over the stub there.
await page.route("**/api/versions", async (route) => {
await versionsHeld;
await route.fulfill({ json: { latest, next: "19.0.0-next.1", versions: [latest, DEFAULT_VERSION] } });
});
const captured = captureTelemetry(page);
const bundlerNavigations: string[] = [];
page.on("request", (r) => {
if (r.isNavigationRequest() && /sandpack/.test(new URL(r.url()).hostname)) bundlerNavigations.push(r.url());
});
const pinnedCore = async () => /"handsontable": "([^"]+)"/.exec((await workspaceFiles(page))["/package.json"] ?? "")?.[1];

await page.goto("/?example=react");
await page.waitForFunction(() => "__HOT_FILES__" in window);
await expect.poll(pinnedCore, { message: "the starter artifact loaded on the default version" }).toBe(DEFAULT_VERSION);
releaseVersions();
// The same-bucket re-pin runs in the commit that would have torn down the first mount.
await expect.poll(pinnedCore, { message: "the workspace moved to npm latest" }).toBe(latest);
await expect.poll(() => bundlerNavigations.length, { message: "the preview mounted" }).toBeGreaterThan(0);
await flushFaro(page, eventSeen(captured));

const abandoned = captured
.flatMap((b) => b.measurements ?? [])
.filter((m) => m.type === "preview.ready_ms")
.filter((m) => (m.context as Record<string, string> | undefined)?.["hot.outcome"] === "abandoned");
expect(abandoned, "no preview was torn down by the version swap").toEqual([]);
});
});

// ---- Sentry scope switch = uncaught ----------------------------------------
Expand Down
Loading