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
10 changes: 10 additions & 0 deletions runner/docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ gate trusts the declaration, the review verifies it.
**New spec vs. modify existing:** a new feature or endpoint → a new spec; a bug
fix → a failing case in the closest existing spec.

**SSR hydration, when you touch what we inject into a preview document**
(`packages/runtime/src/{monitor,scheme,inject-html}.ts`): unit tests can pin the
emitted bytes and the receiver's behaviour, but not whether a framework's hydrator
accepts the result — and remix hydrates with `hydrateRoot(document, …)` on React 18,
which throws the whole document away if `<head>` holds anything the server did not
render. Run `node runner/scripts/ssr-hydration-probe.mjs` against a locally served
starter; it puts the real injections and a real shell around it and exits non-zero on
a mismatch. The standing guard is the nightly starter matrix, which is where DEV-2580
was caught — four red remix cells, everything else green.

## The env-gate taxonomy

The default `playwright test` run is the deterministic PR suite: `stubShell()`
Expand Down
16 changes: 14 additions & 2 deletions runner/e2e/preview-scheme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,21 @@ function isDark(colour: string): boolean {
return (nums[0]! + nums[1]! + nums[2]!) * scale / 3 < 128;
}

/** True when the runner's own override is present in the preview document. */
/** True when the runner's own override is present in the preview document.
*
* Both carriers count. The receiver adopts a constructed stylesheet (DEV-2580: a
* `<style>` in the head is present when a React 18 document hydrator runs, and
* breaks it), and falls back to the `#hot-runner-scheme` element on a browser
* without constructible stylesheets. Checking only the element would report "no
* override" for every passing run. */
async function hasOverride(page: Page): Promise<boolean> {
return grid(page).evaluate(() => !!document.getElementById("hot-runner-scheme"));
return grid(page).evaluate(() => {
const adopted = Array.from(document.adoptedStyleSheets ?? []);
const inSheets = adopted.some((sheet) =>
Array.from(sheet.cssRules).some((rule) => rule.cssText.includes('[class*="ht-theme-"]')),
);
return inSheets || !!document.getElementById("hot-runner-scheme");
});
}

async function waitForGrid(page: Page) {
Expand Down
43 changes: 43 additions & 0 deletions runner/packages/runtime/src/inject-html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Why an injected `<script>` deletes its own element (DEV-2580).
//
// `injectReporterIntoHtml` (monitor.ts) and `injectSchemeIntoHtml` (scheme.ts) both
// insert their receiver as the first child of `<head>` in a document the
// framework's server has already rendered. Remix's client entry is
// `hydrateRoot(document, …)` on React 18, which strict-matches every child of
// `<head>`: a node the server never rendered is a hydration mismatch, so the whole
// document is thrown away and client-rendered. That is what took remix @ 15/16/17/18
// red in the nightly starter matrix — React #418 followed by #423, surfacing as
// "Hydration failed because the initial UI does not match what was rendered on the
// server" at `RenderErrorBoundary`, an SSR flash for the visitor, and (a detail worth
// knowing) the re-render wiping the receiver's own `<style>` override with it.
//
// So the tag removes itself while it runs. Both injections are classic inline
// scripts, which execute during head parse — before the framework's own deferred
// module scripts, let alone hydration — and removing the element does not stop the
// script already executing. By the time any hydrator reads the DOM, the head is the
// one the server sent.
//
// This is the fix rather than "inject outside the hydrated document" (for remix
// there is no outside — the document *is* the root) or "inject only for non-SSR
// frameworks" (that deletes the colour-scheme bridge, ADR-0035, for exactly the
// frameworks it was built for; and the `proxyToSandbox` seam does not know the
// framework).
//
// Removal runs *before* the payload rather than in a `finally` after it: the payload
// is allowed to throw, and the ordering needs no bookkeeping to be correct. It is an
// IIFE rather than a bare `var` because an inline classic script's `var` lands on
// `window`, and the demo's globals are not ours to crowd.
//
// ES5 by hand, byte-deterministic, for the two reasons the receivers document: babel
// 6 parses the Tier-1 parcel entry, and `SandpackRuntime.sameFiles` skips the compile
// when the sandbox is unchanged.

/** Deletes the executing script element. Guarded: `document.currentScript` is null
* for a module or async script, and monitoring must never be why a preview fails. */
export const SELF_REMOVING_PRELUDE =
`(function(){var s=document.currentScript;if(s&&s.parentNode){s.parentNode.removeChild(s);}})();`;

/** Wrap injected source as a `<script>` tag that leaves no node behind. */
export function injectedScriptTag(source: string): string {
return `<script>${SELF_REMOVING_PRELUDE}\n${source}</script>`;
}
17 changes: 13 additions & 4 deletions runner/packages/runtime/src/monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
// `@handsontable/demo-runtime`, so the reporter source must never be duplicated
// into workers/api — a second copy is a second set of caps to keep in sync.

import { injectedScriptTag } from "./inject-html.js";

/** The `postMessage` discriminator. Also the injection idempotency marker. */
export const MONITOR_MESSAGE_TYPE = "hot-runner-monitor";

Expand Down Expand Up @@ -646,22 +648,29 @@ function alreadyInjected(source: string): boolean {
* fault raised while the demo's own scripts evaluate is exactly the class we are
* here for, so the reporter has to be hooked before them.
*
* Inserted with no surrounding whitespace, and the tag deletes its own element
* (see `inject-html.ts`): a React 18 hydrator that owns the whole document — remix's
* `hydrateRoot(document, …)` — strict-matches every child of `<head>`, and a leftover
* newline text node fails that match exactly as the `<script>` element does. Both
* halves were measured against the remix starter; either one alone still throws
* React #418 (DEV-2580).
*
* Returns `html` unchanged when it is already injected.
*/
export function injectReporterIntoHtml(html: string): string {
if (alreadyInjected(html)) return html;
const tag = `<script>${REPORTER_SOURCE}</script>`;
const tag = injectedScriptTag(REPORTER_SOURCE);
const head = /<head\b[^>]*>/i.exec(html);
if (head) {
const at = head.index + head[0].length;
return html.slice(0, at) + "\n" + tag + html.slice(at);
return html.slice(0, at) + tag + html.slice(at);
}
const body = /<body\b[^>]*>/i.exec(html);
if (body) {
const at = body.index + body[0].length;
return html.slice(0, at) + "\n" + tag + html.slice(at);
return html.slice(0, at) + tag + html.slice(at);
}
return tag + "\n" + html;
return tag + html;
}

/**
Expand Down
95 changes: 87 additions & 8 deletions runner/packages/runtime/src/scheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
// reason: the API worker already depends on this package, so a second copy in
// `workers/api` would be a second set of rules to keep in sync.

import { injectedScriptTag } from "./inject-html.js";

/** The `postMessage` discriminator. Also the injection idempotency marker. */
export const SCHEME_MESSAGE_TYPE = "hot-runner-scheme";

Expand Down Expand Up @@ -59,8 +61,10 @@ export function isSchemeReady(message: unknown): message is SchemeReadyMessage {
return !!value && value.source === SCHEME_MESSAGE_TYPE && value.ready === true;
}

/** The element id the receiver owns, so the override can be found and replaced
* rather than accumulating one `<style>` per message. */
/** The element id the receiver owns on its fallback path, so the override can be
* found and replaced rather than accumulating one `<style>` per message. The
* primary path adopts a constructed stylesheet and creates no element at all —
* see `SCHEME_RECEIVER_SOURCE`. */
export const SCHEME_STYLE_ID = "hot-runner-scheme";

/**
Expand All @@ -78,6 +82,34 @@ export const SCHEME_STYLE_ID = "hot-runner-scheme";
* stylesheet keyed on `[class*="ht-theme-"]` also covers a demo that mounts more
* than one grid.
*
* It is carried by a *constructed* stylesheet on `document.adoptedStyleSheets`
* rather than a `<style>` element, which is load-bearing and was measured
* (DEV-2580). A remix preview hydrates with `hydrateRoot(document, …)` on React 18,
* which strict-matches every child of `<head>`; the shell answers this receiver's
* `ready` while the head is still parsing, so a `<style>` appended there lands
* *before* hydration and is exactly as fatal as the injected `<script>` was — the
* document is thrown away and client-rendered ("Hydration failed because the initial
* UI does not match what was rendered on the server", React #418). Measured against
* the remix starter: an override `<style>` present at hydration reproduces it on its
* own. An adopted sheet is not a node, so no hydrator can see it.
*
* `adoptedStyleSheets` is an ObservableArray, not an Array — hence
* `Array.prototype.slice.call` and an assignment back, which also preserves any
* sheet the demo adopted itself. `auto` detaches ours by identity rather than
* blanking it, so "no override" stays observable from the outside.
*
* Once the adopted path has failed the receiver latches onto the fallback for the
* life of the document (`fallbackOnly`). Without the latch, a `<style>` created
* because the constructor threw is never taken away again: `auto` reaches the adopted
* branch, finds no sheet of ours, reports success, and leaves the override in place.
* A failure *after* a sheet was already adopted detaches it on the way out, so the
* two carriers can never both hold a mode.
*
* The `<style>` fallback is kept for a browser without constructible stylesheets
* (older Safari, where `new CSSStyleSheet()` throws): there, the toggle keeps
* working and a React 18 document hydrator keeps mismatching. A browser-gated
* residue on the record, not a silent hole.
*
* Written as ES5 by hand and never transpiled: on Tier 1 the parcel path runs babel
* 6 over injected code, which will not parse anything newer. No timestamp, no id,
* no iteration-dependent ordering — `SandpackRuntime.sameFiles` skips the compile
Expand All @@ -90,7 +122,42 @@ export const SCHEME_RECEIVER_SOURCE = `(function () {
window.__hotRunnerScheme = true;
var STYLE_ID = ${JSON.stringify(SCHEME_STYLE_ID)};
var SOURCE = ${JSON.stringify(SCHEME_MESSAGE_TYPE)};
function apply(mode) {
var sheet = null;
var fallbackOnly = false;
function rule(mode) {
return '[class*="ht-theme-"]{color-scheme:' + mode + ' !important;}';
}
function canAdopt() {
return typeof CSSStyleSheet === 'function'
&& !!CSSStyleSheet.prototype
&& typeof CSSStyleSheet.prototype.replaceSync === 'function'
&& !!document.adoptedStyleSheets;
}
function applyAdopted(mode) {
var on = mode === 'light' || mode === 'dark';
if (!sheet) {
if (!on) { return true; }
try { sheet = new CSSStyleSheet(); } catch (e) { return false; }
}
var sheets = Array.prototype.slice.call(document.adoptedStyleSheets);
var at = sheets.indexOf(sheet);
if (!on) {
if (at !== -1) { sheets.splice(at, 1); document.adoptedStyleSheets = sheets; }
return true;
}
try {
sheet.replaceSync(rule(mode));
} catch (e) {
// Detach on the way out. An adopted sheet left carrying the *previous* mode
// outranks the fallback element that is about to be created, so the stale
// scheme would win and no later message could clear it.
if (at !== -1) { sheets.splice(at, 1); document.adoptedStyleSheets = sheets; }
return false;
}
if (at === -1) { sheets.push(sheet); document.adoptedStyleSheets = sheets; }
return true;
}
function applyElement(mode) {
var el = document.getElementById(STYLE_ID);
if (mode !== 'light' && mode !== 'dark') {
if (el && el.parentNode) { el.parentNode.removeChild(el); }
Expand All @@ -101,7 +168,12 @@ export const SCHEME_RECEIVER_SOURCE = `(function () {
el.id = STYLE_ID;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = '[class*="ht-theme-"]{color-scheme:' + mode + ' !important;}';
el.textContent = rule(mode);
}
function apply(mode) {
if (!fallbackOnly && canAdopt() && applyAdopted(mode)) { return; }
fallbackOnly = true;
applyElement(mode);
}
window.addEventListener('message', function (event) {
var data = event.data;
Expand Down Expand Up @@ -136,22 +208,29 @@ function alreadyInjected(source: string): boolean {
* still placed in `<head>` for the same reason the monitor is: it is the one
* insertion point every document has, and being early costs nothing.
*
* Inserted with no surrounding whitespace, and the tag deletes its own element
* (see `inject-html.ts`): a React 18 hydrator that owns the whole document — remix's
* `hydrateRoot(document, …)` — strict-matches every child of `<head>`, and a leftover
* newline text node fails that match exactly as the `<script>` element does. Both
* halves were measured against the remix starter; either one alone still throws
* React #418 (DEV-2580).
*
* Returns `html` unchanged when it is already injected.
*/
export function injectSchemeIntoHtml(html: string): string {
if (alreadyInjected(html)) return html;
const tag = `<script>${SCHEME_RECEIVER_SOURCE}</script>`;
const tag = injectedScriptTag(SCHEME_RECEIVER_SOURCE);
const head = /<head\b[^>]*>/i.exec(html);
if (head) {
const at = head.index + head[0].length;
return html.slice(0, at) + "\n" + tag + html.slice(at);
return html.slice(0, at) + tag + html.slice(at);
}
const body = /<body\b[^>]*>/i.exec(html);
if (body) {
const at = body.index + body[0].length;
return html.slice(0, at) + "\n" + tag + html.slice(at);
return html.slice(0, at) + tag + html.slice(at);
}
return tag + "\n" + html;
return tag + html;
}

/**
Expand Down
116 changes: 116 additions & 0 deletions runner/pipeline/inject-html.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import test from "node:test";
import assert from "node:assert/strict";
import vm from "node:vm";
import { Parser } from "acorn";
import { injectReporterIntoHtml } from "../packages/runtime/dist/monitor.js";
import { injectSchemeIntoHtml } from "../packages/runtime/dist/scheme.js";
import { SELF_REMOVING_PRELUDE, injectedScriptTag } from "../packages/runtime/dist/inject-html.js";

// DEV-2580. Both HTML injectors put their receiver in the `<head>` of a document
// the framework's server already rendered, and remix hydrates with
// `hydrateRoot(document, …)` on React 18 — which strict-matches head children, so
// an extra node there is a hydration mismatch and the whole document is
// client-rendered. What is pinned here is the property that makes the injection
// invisible to a hydrator: the tag deletes its own element as it runs.
//
// Executed, not read: a test asserting the emitted string "contains the removal
// snippet" would keep passing over a snippet that removes the wrong node, or that
// never runs because the payload threw first.

/** The inline source of the first `<script>` in `html`. */
function firstScriptSource(html) {
const match = /<script>([\s\S]*?)<\/script>/.exec(html);
assert.ok(match, "the injector emitted no inline script");
return match[1];
}

/** A head with one child: the script that is executing. Records the removal. */
function makeDocument({ currentScript = true } = {}) {
const removed = [];
const head = {
removeChild(node) {
removed.push(node);
node.parentNode = null;
return node;
},
appendChild(node) {
node.parentNode = head;
return node;
},
};
const script = { tagName: "SCRIPT", parentNode: head };
return { doc: { currentScript: currentScript ? script : null, head }, script, removed };
}

/** Execute one injected `<script>`'s source against stubs. The payloads reach for
* bare `window`/`parent`/`console`/`document`/`location`, so hand them in as
* parameters the same way `monitor-inject.test.mjs` does. */
function runTag(source, { doc, throws = false } = {}) {
// The scheme receiver stands down when `window.parent === window` and posts its
// `ready` through it, so the stub parent has to be a distinct object.
const parent = { postMessage() {} };
const win = { addEventListener() {}, parent };
// eslint-disable-next-line no-new-func
const run = new Function(
"window",
"parent",
"console",
"document",
"XMLHttpRequest",
"location",
source,
);
const call = () =>
run(win, parent, { error() {}, warn() {} }, doc, undefined, { host: "x.test" });
if (throws) assert.throws(call);
else call();
}

for (const [name, html] of [
["the monitor", injectReporterIntoHtml("<html><head><title>d</title></head><body>hi</body></html>")],
["the scheme receiver", injectSchemeIntoHtml("<html><head><title>d</title></head><body>hi</body></html>")],
]) {
test(`${name} removes its own script element from the head it was injected into`, () => {
const { doc, script, removed } = makeDocument();
runTag(firstScriptSource(html), { doc });
assert.deepEqual(removed, [script], "the injected script element must be gone");
assert.equal(script.parentNode, null);
});
}

test("removal happens before the payload, so a throwing payload still leaves no node", () => {
// The order is the guarantee: a `finally` around the payload would be one more
// thing to get wrong, and a payload is allowed to throw.
const { doc, script, removed } = makeDocument();
runTag(firstScriptSource(injectedScriptTag("throw new Error('boom');")), { doc, throws: true });
assert.deepEqual(removed, [script]);
});

test("a document with no currentScript is not an error", () => {
// `document.currentScript` is null for a module or async script. The receivers
// must never be the reason a preview fails to boot.
const { doc, removed } = makeDocument({ currentScript: false });
runTag(firstScriptSource(injectedScriptTag("var ok = 1;")), { doc });
assert.deepEqual(removed, []);
});

test("the prelude leaks no global into the demo's document", () => {
// An inline classic script runs in global scope, so a bare `var s` would land on
// `window` and collide with whatever the demo calls `s`.
const context = vm.createContext({ document: makeDocument().doc });
const before = new Set(Object.keys(context));
vm.runInContext(SELF_REMOVING_PRELUDE, context);
const added = Object.keys(context).filter((key) => !before.has(key));
assert.deepEqual(added, [], `the prelude added globals: ${added.join(", ")}`);
});

test("the prelude parses as ES5", () => {
// Tier-1's parcel path runs babel 6 over the injected HTML entry, which will not
// parse anything newer — the same gate the two receivers carry.
Parser.parse(SELF_REMOVING_PRELUDE, { ecmaVersion: 5 });
});

test("the tag is byte-deterministic", () => {
// `SandpackRuntime.sameFiles` skips the compile when the sandbox is unchanged.
assert.equal(injectedScriptTag("run();"), injectedScriptTag("run();"));
});
Loading
Loading