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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,13 @@ jobs:
- name: Build authoring (build smoke + e2e target)
run: pnpm --filter @handsontable/demo-authoring build

# Two properties of the *bundle* that no unit test can see: the compiler chunk keeps a
# hash-free path (a rotated hashed chunk answers the SPA fallback and strands open tabs
# — DEV-2569 / DEMOS-15), and it stays lazily imported rather than joining the initial
# load. Checked here because this is the job that has the build.
- name: Check the compiler chunk (DEV-2569)
run: node scripts/check-compiler-chunk.mjs

- name: Upload the authoring build
uses: actions/upload-artifact@v4
with:
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/master.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,14 @@ jobs:
SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
GITHUB_SHA: ${{ github.sha }}

# The one regression that only shows up in a deploy: the compiler chunk's content hash
# coming back, which strands every tab opened before the next deploy (DEV-2569). ci.yml
# checks the PR build; a push or workflow_dispatch that never had a PR build reaches this
# job instead, so it is checked here too — before the upload, not after.
- name: Check the compiler chunk (DEV-2569)
if: needs.changes.outputs.authoring == 'true'
run: node scripts/check-compiler-chunk.mjs

- name: Upload the authoring build
if: needs.changes.outputs.authoring == 'true'
uses: actions/upload-artifact@v4
Expand Down
35 changes: 35 additions & 0 deletions runner/apps/authoring/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,41 @@ export default defineConfig({
// plugin's post-upload cleanup never runs to remove — and a manual
// `wrangler deploy` would publish them.
sourcemap: uploadEnabled,
rollupOptions: {
output: {
// Keep @babel/standalone in a chunk whose path does not change per build
// (DEV-2569). This app is served from Workers Assets with
// `not_found_handling: "single-page-application"` (see wrangler.jsonc), so a
// deploy removes the previous build's hashed chunks and their paths answer
// `200 text/html` instead of a 404. A tab that had not yet fetched the ~2.3 MB
// compiler when a deploy landed was then asking for a file that no longer
// existed — forever, since an HTML body is not a module. A stable path always
// exists in the deploy that is currently live.
//
// No cache-header work goes with this: Workers Assets already serves every
// asset, hashed or not, as `cache-control: public, max-age=0, must-revalidate`
// with an ETag (measured on prod 2026-08-20), so the stable URL revalidates on
// each use and picks up the new bytes.
//
// Two accepted consequences. A tab open across a deploy that fetches the
// compiler afterwards gets the *new* build's babel — benign, we only call
// `transform`, and strictly better than a permanent failure. And the Sentry
// plugin matches sourcemaps by the embedded debug id rather than by filename,
// so reusing a name across releases does not confuse symbolication.
//
// Renaming only, deliberately: no `manualChunks`. Assigning @babel/standalone to
// a named chunk was measured to pull Rollup's shared `getDefaultExportFromCjs`
// helper in with it (`export { … as b, … as g }`), which made two ordinary chunks
// import the 2.3 MB compiler *statically* and put a `modulepreload` for it in
// index.html — the opposite of lazy. Rollup's own split of the dynamic import in
// `packages/runtime/src/transpile.ts` already isolates it under the implicit name
// `babel`; all this does is take the hash off that one file. If a Rollup version
// ever renames that chunk, `scripts/check-compiler-chunk.mjs` goes red in CI rather
// than the hash quietly coming back.
chunkFileNames: (chunk) =>
chunk.name === "babel" ? "assets/compiler-babel.js" : "assets/[name]-[hash].js",
},
},
},
plugins: [
react(),
Expand Down
134 changes: 134 additions & 0 deletions runner/e2e/preview-recovery.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { expectGridRendered, stubShell } from "./helpers";

// The preview must be able to come back. A runtime error in edited code puts the
// pane into `error` (the "The preview could not start" card); fixing the code has
Expand Down Expand Up @@ -278,3 +279,136 @@ test("live: fixing a Vue template error clears the preview error card", async ({
timeout: 60_000,
});
});

// DEV-2569 / Sentry DEMOS-15 — the compiler chunk, and the only place its failure is real.
//
// Tier 1 pre-transpiles sources for the classic `parcel` bundler, which needs
// @babel/standalone: ~2.3 MB, code-split, fetched on first compile. It fails for ordinary
// reasons (offline, a blocked request, an extension) and it used to fail permanently for
// one of ours — a deploy rotating the chunk out from under a tab, which Workers Assets
// answers with `200 text/html` rather than a 404. `assets/compiler-babel.js` is hash-free
// now so that population is gone (`scripts/check-compiler-chunk.mjs` pins the name), and
// what is left is the transient case these two tests drive.
//
// This spec is the *only* place the loader can be tested honestly. Its two import sites are
// byte-identical in source and are not identical in the bundle — Vite rewrites the bare
// specifier and inserts a CJS-interop hop the `@vite-ignore` retry does not get — so the
// retry used to resolve the chunk's raw module record and die on the next compile with
// `e.transform is not a function`. `pipeline/transpile-loader.test.mjs` imports
// `packages/runtime/dist`, where both paths really are equivalent, and was green over
// exactly that. Playwright runs against a real `vite build` (see playwright.config.ts), so
// here the divergence exists.
//
// Verified red: with the two `asBabel` calls in transpile.ts reverted, the recovery
// assertion below fails — the status stays `error` after Restart, which is what production
// did.

/** The compiler chunk plus any retry query. The trailing `*` is load-bearing: without it
* the `?hotRetry=n` requests slip past the block and the test passes for the wrong reason. */
const COMPILER_CHUNK = "**/assets/compiler-babel.js*";

test("a blocked compiler chunk cards, and Restart preview really recovers", async ({ page }) => {
// Local-build only, and not because it is slow or flaky: the premise is the artifact this
// run just built. `e2e-live.yml` runs this file with `E2E_BASE_URL` pointed at a deployment
// for the nightly canary, where `baseURL` is off-localhost — the blanket abort below would
// kill `page.goto` itself, and the deployed build may predate the stable chunk name. The
// live half of this pair is the test after it.
test.skip(
Boolean(process.env.E2E_BASE_URL),
"runs against the locally built app — unset E2E_BASE_URL to run it",
);
test.setTimeout(120_000);
// Deterministic in the strict sense: `stubShell` alone is not enough here. A `parcel`
// sandbox loads its bundler from a *versioned* host (measured: 2-19-8-sandpack.codesandbox.io)
// plus jsdelivr and prod-packager-packages, none of which stubShell's two globs match — so
// this case would have quietly depended on the external bundler. Everything off-localhost is
// aborted instead, which costs the `ready` end-state (that one is the E2E_LIVE case below)
// and keeps a sharper oracle: with the bundler unreachable, `booting` means our transpile
// finished and handed the sandbox over, and `error` means it did not.
await stubShell(page);
await page.route((url) => url.hostname !== "localhost", (route) => route.abort());
Comment thread
cursor[bot] marked this conversation as resolved.

// The oracle. Handing the compiled sandbox to the bundler is the first thing that happens
// *after* our transpile succeeds, and `buildSetup` throws ahead of it when the transpile
// fails — so an attempted bundler request is a positive signal that the compiler produced
// usable output. Measured both ways on this build: 2 attempts with the fix, 0 without.
// The requests are aborted by the route above, so nothing leaves the machine.
const bundlerAttempts: string[] = [];
page.on("request", (r) => {
const { hostname } = new URL(r.url());
if (/sandpack/.test(hostname)) bundlerAttempts.push(hostname);
});

const asked: string[] = [];
let blocked = true;
await page.route(COMPILER_CHUNK, (route) => {
asked.push(new URL(route.request().url()).search || "(bare)");
return blocked ? route.abort() : route.fallback();
});

// `javascript` is Tier 1 on the `parcel` environment (catalog.json) — the one engine that
// needs the compiler at all.
await page.goto("/?example=javascript");
await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "error", {
timeout: 60_000,
});
await expect(page.getByText("The preview could not start")).toBeVisible();
await expect(page.locator("pre")).toContainText("The preview compiler could not be downloaded");
const restart = page.getByRole("button", { name: "Restart preview" });
await expect(restart, "the card has to offer the action its copy names").toBeVisible();
expect(asked, "two bounded attempts, and the second must ask for a URL of its own").toEqual([
"(bare)",
"?hotRetry=1",
]);

expect(bundlerAttempts, "a failed transpile never reaches the bundler").toEqual([]);

blocked = false;
await restart.click();

// The whole fix. Before it the retry resolved the chunk's raw module record and the card
// flipped to the exact string production showed — "Failed to transpile /index.js for the
// parcel sandbox: e.transform is not a function" — with the status stuck on `error` and no
// bundler request ever attempted. Note `data-preview-status` is deliberately *not* the
// oracle here: `booting` is on the failure path too (it precedes `error`), so asserting it
// passes with the fix reverted.
await expect
.poll(() => bundlerAttempts.length, { timeout: 60_000, intervals: [250] })
.toBeGreaterThan(0);
await expect(page.getByText("The preview could not start")).toHaveCount(0);
// A browser caches a failed module fetch in the document's module map, so re-importing the
// *same* specifier never touches the network again — which is why the remount's bare import
// files no third request, and why `rearm` has to mint a fresh query to be honest.
expect(asked).toEqual(["(bare)", "?hotRetry=1", "?hotRetry=2"]);
});

test("live: the grid renders after a compiler-chunk recovery", async ({ page }) => {
test.skip(process.env.E2E_LIVE !== "1", "set E2E_LIVE=1 to run live-render checks");
test.setTimeout(180_000);
// The half that was never clicked through before shipping. `booting` above is our own
// signal; a real bundler behind it is what proves the recovered compiler's output builds.

let blocked = true;
const seen: string[] = [];
await page.route(COMPILER_CHUNK, (route) => {
seen.push(route.request().url());
return blocked ? route.abort() : route.fallback();
});

await page.goto("/?example=javascript");
// Named before the status wait so a target whose build predates the stable chunk name fails
// saying so, rather than as an opaque 60 s timeout on an error card that never appears.
await expect
.poll(() => seen.length, { timeout: 60_000, intervals: [500] })
.toBeGreaterThan(0);
await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "error", {
timeout: 60_000,
});

blocked = false;
await page.getByRole("button", { name: "Restart preview" }).click();
await expect(previewStatus(page)).toHaveAttribute("data-preview-status", "ready", {
timeout: 120_000,
});
await expectGridRendered(page);
});
1 change: 1 addition & 0 deletions runner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"typecheck": "pnpm -r run typecheck",
"test": "pnpm --filter @handsontable/demo-runtime build && node --experimental-strip-types --test pipeline/*.test.mjs",
"e2e": "playwright test",
"check:compiler-chunk": "node scripts/check-compiler-chunk.mjs",
"e2e:matrix": "E2E_STARTER_MATRIX=1 PLAYWRIGHT_JSON_OUTPUT_NAME=test-results/starter-matrix.json playwright test e2e/starter-matrix.spec.ts --workers=2 --retries=2 --reporter=list,json",
"e2e:matrix:report": "node scripts/starter-matrix-report.mjs"
},
Expand Down
92 changes: 81 additions & 11 deletions runner/packages/runtime/src/transpile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,13 +163,16 @@ export function createRetryingLoader<T>(
} catch (cause) {
first = cause;
}
const retry = retryOf(first, generation);
if (retry) {
try {
return await retry;
} catch (cause) {
first = cause;
}
try {
// `retryOf` is inside the try, not just its promise: the real one builds the URL and
// normalises the module inside `.then`, so it rejects — but a synchronous throw from
// it (a bad URL, a shape check moved out of the `.then`) would otherwise escape
// un-wrapped, and an error that is not a `CompilerUnavailableError` is filed as the
// visitor's own compile failure. Half of DEV-2569 was exactly that mis-routing.
const retry = retryOf(first, generation);
if (retry) return await retry;
} catch (cause) {
first = cause;
}
terminal = wrap(first, {});
throw terminal;
Expand All @@ -181,8 +184,76 @@ export function createRetryingLoader<T>(
};
}

/**
* Resolve the babel object out of whatever a dynamic import actually handed back
* (DEV-2569, second pass).
*
* The two import sites in this file are byte-identical in source and *not* identical in the
* shipped bundle. Vite rewrites only the bare specifier, and `@babel/standalone` is CJS, so
* the primary site gets an interop hop the `@vite-ignore` retry does not. Measured in the
* deployed bundle (`/assets/index-uxATgr1X.js`, 2026-08-20):
*
* primary: import("./babel-<hash>.js").then(t => t.b).then(t => t.default ?? t)
* retry: import(`${url}?hotRetry=1`).then(o => o.default ?? o)
*
* and the chunk's only export is that wrapper — `export { Hke as b }`, where `Hke` is Vite's
* `_mergeNamespaces({__proto__: null, default: babel}, [cjs])`. So the retry resolved the raw
* module record `{b: {…}}`, `m.default` was `undefined`, and the loader returned the record:
* the retry "succeeded" and the next compile died as `e.transform is not a function`.
*
* Hence a shape check rather than a fixed unwrap. The three shapes this has to accept, all
* measured:
*
* Node / `dist` (what `pipeline/*.test.mjs` sees) { default: babel, …named }
* bundled primary, after Vite's hop { __proto__: null, default: babel }
* bundled retry, no hop { b: { default: babel, transform } }
*
* `default` is preferred at every level, so the primary path keeps resolving exactly the
* object it resolves today. Walking *values* rather than a hardcoded `b` is what survives
* Rollup renaming that export — the name is bundler-generated and pinned by nothing.
*
* A miss **throws**, and the throw carries the URL: this runs inside the loaders, so the
* rejection reaches `createRetryingLoader` and becomes a `CompilerUnavailableError` — latched,
* carded, and reported as our own infrastructure failure. Left un-thrown it would surface
* downstream inside `babel.transform` and be filed in the visitor-source Sentry bucket as if
* it were the visitor's typo, which is the other half of what this defect was. `assetUrl` is
* recovered by `assetUrlFrom` regexing the cause's *message*, so the URL has to be in it.
*/
export function asBabel(ns: unknown, url?: string | null): Babel {
const babel = findBabel(ns, 2);
if (babel) return babel;
throw new TypeError(
`the in-browser compiler module exported no transform()${url ? `: ${url}` : ""}`,
);
}

/** Depth 2 is what the shapes above need: the record, its `default`, and one wrapper level. */
function findBabel(value: unknown, depth: number): Babel | null {
if (value === null || (typeof value !== "object" && typeof value !== "function")) return null;
const record = value as Record<string, unknown>;
if (depth > 0) {
const viaDefault = findBabel(record.default, depth - 1);
if (viaDefault) return viaDefault;
}
if (typeof record.transform === "function") return record as unknown as Babel;
if (depth > 0) {
for (const nested of Object.values(record)) {
const hit = findBabel(nested, depth - 1);
if (hit) return hit;
}
}
return null;
}

/** No URL is passed: the primary specifier is rewritten to a hashed path at build time, so
* nothing here knows it (that is the whole reason `retryBabelChunk` reads it out of the
* engine's error text). A shape mismatch discovered here therefore reaches
* `CompilerUnavailableError` with `assetUrl: null` and no retry — correct on both counts:
* refetching the same URL returns the same bytes and the same shape, and the chunk now has
* one fixed path (`assets/compiler-babel.js`), so naming it adds nothing the fingerprint
* does not already say. The engine's own wording still rides in `extra.cause`. */
const loadBabelChunk = createLazyLoader<Babel>(() =>
import("@babel/standalone").then((m) => ((m as { default?: Babel }).default ?? m) as Babel),
import("@babel/standalone").then((m) => asBabel(m)),
);

/** The retry's URL: the failed chunk with a query the module map has not seen. `@vite-ignore`
Expand All @@ -192,9 +263,8 @@ function retryBabelChunk(cause: unknown, generation: number): Promise<Babel> | n
const url = assetUrlFrom(cause);
if (!url) return null;
const sep = url.includes("?") ? "&" : "?";
return import(/* @vite-ignore */ `${url}${sep}hotRetry=${generation + 1}`).then(
(m) => ((m as { default?: Babel }).default ?? m) as Babel,
);
const spec = `${url}${sep}hotRetry=${generation + 1}`;
return import(/* @vite-ignore */ spec).then((m) => asBabel(m, spec));
}

const babelLoader = createRetryingLoader<Babel>(
Expand Down
Loading
Loading