diff --git a/.github/workflows/pr-validate.yaml b/.github/workflows/pr-validate.yaml
index c109bd9..f85ee7d 100644
--- a/.github/workflows/pr-validate.yaml
+++ b/.github/workflows/pr-validate.yaml
@@ -47,3 +47,14 @@ jobs:
- name: Verify packages
run: node common/scripts/install-run-rush.js verify --parallelism 1
+
+ - name: Test packed Relay consumer contract
+ run: npm run test:relay-consumer-contract
+
+ - name: Upload Relay consumer contract failure evidence
+ if: failure()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: relay-consumer-contract-failure
+ path: common/temp/relay-consumer-contract-artifacts
+ if-no-files-found: ignore
diff --git a/common/changes/@omgjs/labkit-webapp-graphql-relay/agent-fix-relay-peer-runtime_2026-08-11-11-46.json b/common/changes/@omgjs/labkit-webapp-graphql-relay/agent-fix-relay-peer-runtime_2026-08-11-11-46.json
new file mode 100644
index 0000000..3df4d84
--- /dev/null
+++ b/common/changes/@omgjs/labkit-webapp-graphql-relay/agent-fix-relay-peer-runtime_2026-08-11-11-46.json
@@ -0,0 +1,11 @@
+{
+ "changes": [
+ {
+ "comment": "Require applications to provide the exact Relay 20.1.1 peer pair so Labkit and React Relay share one runtime graph.",
+ "type": "major",
+ "packageName": "@omgjs/labkit-webapp-graphql-relay"
+ }
+ ],
+ "packageName": "@omgjs/labkit-webapp-graphql-relay",
+ "email": "artkor@live.ru"
+}
diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml
index 5d7075f..fe2dcaf 100644
--- a/common/config/rush/pnpm-lock.yaml
+++ b/common/config/rush/pnpm-lock.yaml
@@ -295,12 +295,6 @@ importers:
graphql-ws:
specifier: ^6.0.8
version: 6.0.8(graphql@16.14.0)(ws@8.20.0)
- react-relay:
- specifier: ^20.1.1
- version: 20.1.1(react@19.2.6)
- relay-runtime:
- specifier: ^20.1.1
- version: 20.1.1
devDependencies:
'@omgjs/labkit-eslint-config':
specifier: workspace:*
@@ -320,6 +314,12 @@ importers:
react:
specifier: ^19.2.5
version: 19.2.6
+ react-relay:
+ specifier: 20.1.1
+ version: 20.1.1(react@19.2.6)
+ relay-runtime:
+ specifier: 20.1.1
+ version: 20.1.1
typescript:
specifier: ^6.0.3
version: 6.0.3
diff --git a/common/scripts/test-relay-consumer-contract.mjs b/common/scripts/test-relay-consumer-contract.mjs
new file mode 100644
index 0000000..55b1fec
--- /dev/null
+++ b/common/scripts/test-relay-consumer-contract.mjs
@@ -0,0 +1,1482 @@
+#!/usr/bin/env node
+
+import assert from "node:assert/strict";
+import { spawn } from "node:child_process";
+import { createHash } from "node:crypto";
+import {
+ cp,
+ mkdir,
+ mkdtemp,
+ readFile,
+ readdir,
+ realpath,
+ rm,
+ writeFile,
+} from "node:fs/promises";
+import { createServer } from "node:http";
+import { createRequire } from "node:module";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const SCRIPT_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
+const REPOSITORY_ROOT = path.resolve(SCRIPT_DIRECTORY, "../..");
+const FIXTURE_ROOT = path.join(
+ REPOSITORY_ROOT,
+ "packages/webapp-graphql-relay/consumer-test",
+);
+const PNPM = path.join(
+ REPOSITORY_ROOT,
+ "common/temp/pnpm-local/node_modules/.bin/pnpm",
+);
+const FAILURE_ARTIFACT_ROOT = path.join(
+ REPOSITORY_ROOT,
+ "common/temp/relay-consumer-contract-artifacts",
+);
+const TARGET_PACKAGE = "@omgjs/labkit-webapp-graphql-relay";
+const EXPECTED_RELAY_VERSION = "20.1.1";
+const TEMPORARY_PREFIX = "labkit-relay-consumer-contract-";
+const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60_000;
+const CAPTURE_COMMAND_TIMEOUT_MS = 30_000;
+const TERMINATION_GRACE_MS = 5_000;
+const REGISTRY_AUTH_ENVIRONMENT_NAMES = [
+ "NODE_AUTH_TOKEN",
+ "NPM_AUTH_TOKEN",
+ "NPM_CONFIG__AUTH_TOKEN",
+ "NPM_TOKEN",
+ "NPM_CONFIG_REGISTRY",
+ "npm_config__authToken",
+ "npm_config_registry",
+];
+
+const PEER_DIAGNOSTIC_PATTERN =
+ /ERESOLVE|ERR_PNPM_PEER_DEP_ISSUES|invalid peer|missing peer|peer dependenc|unmet peer|✕ unmet peer|could not resolve dependency/i;
+
+const SUPPORTED_RUNTIME_DEPENDENCIES = {
+ graphql: "16.14.0",
+ "graphql-ws": "6.0.8",
+ react: "19.2.5",
+ "react-dom": "19.2.5",
+ "react-relay": EXPECTED_RELAY_VERSION,
+ "relay-runtime": EXPECTED_RELAY_VERSION,
+};
+
+const SUPPORTED_DEVELOPMENT_DEPENDENCIES = {
+ "@types/node": "25.6.0",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.4",
+ "@types/react-relay": "18.2.1",
+ "@types/relay-runtime": "20.1.1",
+ "relay-compiler": "20.1.1",
+ typescript: "6.0.3",
+ vite: "7.3.1",
+};
+
+const FIXTURE_SCRIPTS = {
+ "build:browser": "vite build",
+ "build:cjs": "tsc -p tsconfig.cjs.json",
+ "build:checks": "tsc -p tsconfig.checks.json",
+ "build:esm": "vite build --config vite.esm.config.ts",
+ "build:ssr": "vite build --config vite.ssr.config.ts",
+ "check:cjs": "node dist-cjs/cjs-smoke.js",
+ "check:esm": "node dist-esm-smoke/esm-smoke.js",
+ "check:esm-resolution": "node dist-checks/esm-resolution-check.js",
+ "check:identity": "node dist-checks/resolution-check.js",
+ "check:render": "node dist-ssr/render-smoke.js",
+ relay: "relay-compiler",
+ typecheck: "tsc -p tsconfig.json --noEmit",
+};
+
+function printHelp() {
+ process.stdout.write(`Usage: npm run test:relay-consumer-contract
+
+Builds ${TARGET_PACKAGE}, packs every publishable Rush project, and tests the
+target tarball from clean npm and isolated pnpm consumers.
+
+Safety note: Rush 5.175.0 treats "rush publish --pack" as a dry run. Its audited
+pack branch requires "--publish --pack" to execute pnpm pack, without applying
+change files or invoking a registry publish. This script adds a defensive
+boundary anyway: it overlays the checkout into an independent "git clone
+--no-local", removes its origin, installs/builds there, replaces inherited
+user/global npm configuration with empty temporary files, scrubs npm
+credential/registry environment variables, and asserts the Rush output contains
+pack commands but no package-manager publish command. The script never passes
+--apply, --commit, or tagging flags.
+
+Temporary consumers are always removed. On failure, logs and compact evidence
+are copied to common/temp/relay-consumer-contract-artifacts for CI upload.
+`);
+}
+
+function sortRecord(record) {
+ return Object.fromEntries(
+ Object.entries(record).sort(([left], [right]) => left.localeCompare(right)),
+ );
+}
+
+function commandDisplay(command, arguments_) {
+ return [command, ...arguments_]
+ .map((value) => (value.includes(" ") ? JSON.stringify(value) : value))
+ .join(" ");
+}
+
+function signalProcessTree(child, signal) {
+ if (!child.pid) {
+ return;
+ }
+ try {
+ if (process.platform === "win32") {
+ child.kill(signal);
+ } else {
+ process.kill(-child.pid, signal);
+ }
+ } catch (error) {
+ if (error?.code !== "ESRCH") {
+ throw error;
+ }
+ }
+}
+
+function isSensitiveNpmEnvironmentName(environmentName) {
+ const normalizedName = environmentName.toLowerCase();
+ if (
+ normalizedName === "node_auth_token" ||
+ normalizedName === "npm_auth_token" ||
+ normalizedName === "npm_token"
+ ) {
+ return true;
+ }
+ if (!normalizedName.startsWith("npm_config_")) {
+ return false;
+ }
+ return /auth|token|password|username|registry|otp/.test(normalizedName);
+}
+
+async function runCommand({
+ allowFailure = false,
+ arguments_ = [],
+ command,
+ cwd = REPOSITORY_ROOT,
+ environment = {},
+ label,
+ logsDirectory,
+ scrubNpmEnvironment = false,
+ timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS,
+ unsetEnvironment = [],
+}) {
+ process.stdout.write(`\n[relay-consumer] ${label}\n`);
+ const result = await new Promise((resolve, reject) => {
+ const childEnvironment = { ...process.env };
+ if (scrubNpmEnvironment) {
+ for (const environmentName of Object.keys(childEnvironment)) {
+ if (isSensitiveNpmEnvironmentName(environmentName)) {
+ delete childEnvironment[environmentName];
+ }
+ }
+ }
+ Object.assign(childEnvironment, {
+ CI: "1",
+ FORCE_COLOR: "0",
+ NO_COLOR: "1",
+ npm_config_color: "false",
+ ...environment,
+ });
+ for (const environmentName of unsetEnvironment) {
+ delete childEnvironment[environmentName];
+ }
+ const child = spawn(command, arguments_, {
+ cwd,
+ detached: process.platform !== "win32",
+ env: childEnvironment,
+ shell: false,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let stdout = "";
+ let stderr = "";
+ let timedOut = false;
+ let forceTerminationTimer;
+ const timeout = setTimeout(() => {
+ timedOut = true;
+ signalProcessTree(child, "SIGTERM");
+ forceTerminationTimer = setTimeout(() => {
+ signalProcessTree(child, "SIGKILL");
+ }, TERMINATION_GRACE_MS);
+ }, timeoutMs);
+
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk;
+ });
+ child.on("error", (error) => {
+ clearTimeout(timeout);
+ clearTimeout(forceTerminationTimer);
+ reject(error);
+ });
+ child.on("close", (status, signal) => {
+ clearTimeout(timeout);
+ clearTimeout(forceTerminationTimer);
+ resolve({
+ combined: `${stdout}\n${stderr}`,
+ signal,
+ status: status ?? 1,
+ stderr,
+ stdout,
+ timedOut,
+ });
+ });
+ });
+
+ const logName = `${label.replace(/[^a-z0-9_.-]+/gi, "-").toLowerCase()}.log`;
+ await writeFile(
+ path.join(logsDirectory, logName),
+ `$ ${commandDisplay(command, arguments_)}\n\nSTDOUT\n${result.stdout}\nSTDERR\n${result.stderr}`,
+ );
+
+ if (result.timedOut) {
+ throw new Error(`${label} timed out after ${timeoutMs}ms. See ${logName}.`);
+ }
+ if (result.status !== 0 && !allowFailure) {
+ throw new Error(
+ `${label} failed with status ${result.status}${result.signal ? ` (${result.signal})` : ""}. See ${logName}.`,
+ );
+ }
+
+ return result;
+}
+
+async function captureCommand(command, arguments_, cwd = REPOSITORY_ROOT) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, arguments_, {
+ cwd,
+ detached: process.platform !== "win32",
+ env: { ...process.env, FORCE_COLOR: "0", NO_COLOR: "1" },
+ shell: false,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ let stdout = "";
+ let stderr = "";
+ let forceTerminationTimer;
+ const timeout = setTimeout(() => {
+ signalProcessTree(child, "SIGTERM");
+ forceTerminationTimer = setTimeout(() => {
+ signalProcessTree(child, "SIGKILL");
+ }, TERMINATION_GRACE_MS);
+ }, CAPTURE_COMMAND_TIMEOUT_MS);
+ child.stdout.setEncoding("utf8");
+ child.stderr.setEncoding("utf8");
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk;
+ });
+ child.on("error", (error) => {
+ clearTimeout(timeout);
+ clearTimeout(forceTerminationTimer);
+ reject(error);
+ });
+ child.on("close", (status) => {
+ clearTimeout(timeout);
+ clearTimeout(forceTerminationTimer);
+ if (status !== 0) {
+ reject(
+ new Error(
+ `${commandDisplay(command, arguments_)} failed with status ${status}: ${stderr}`,
+ ),
+ );
+ return;
+ }
+ resolve(stdout);
+ });
+ });
+}
+
+async function readTarballManifest(tarballPath) {
+ const manifestJson = await captureCommand("tar", [
+ "-xOzf",
+ tarballPath,
+ "package/package.json",
+ ]);
+ return JSON.parse(manifestJson);
+}
+
+async function listTarball(tarballPath) {
+ const contents = await captureCommand("tar", ["-tzf", tarballPath]);
+ return contents.split("\n").filter(Boolean);
+}
+
+async function collectPackedArtifacts(releaseDirectory) {
+ const tarballNames = (await readdir(releaseDirectory)).filter((entry) =>
+ entry.endsWith(".tgz"),
+ );
+ assert.ok(tarballNames.length > 0, "Rush did not create any tarballs.");
+
+ const artifacts = new Map();
+ for (const tarballName of tarballNames) {
+ const tarballPath = path.join(releaseDirectory, tarballName);
+ const manifest = await readTarballManifest(tarballPath);
+ assert.equal(
+ artifacts.has(manifest.name),
+ false,
+ `Rush packed ${manifest.name} more than once.`,
+ );
+ artifacts.set(manifest.name, { manifest, tarballPath });
+ }
+ return artifacts;
+}
+
+function localRuntimeDependencyNames(manifest) {
+ return Object.keys({
+ ...manifest.dependencies,
+ ...manifest.optionalDependencies,
+ }).filter((name) => name.startsWith("@omgjs/labkit-"));
+}
+
+function collectFirstPartyClosure(artifacts) {
+ const closure = new Set();
+ const visit = (packageName) => {
+ if (closure.has(packageName)) {
+ return;
+ }
+ const artifact = artifacts.get(packageName);
+ assert.ok(artifact, `No local tarball was packed for ${packageName}.`);
+ closure.add(packageName);
+ for (const dependencyName of localRuntimeDependencyNames(
+ artifact.manifest,
+ )) {
+ visit(dependencyName);
+ }
+ };
+ visit(TARGET_PACKAGE);
+ return [...closure].sort();
+}
+
+async function validatePackedTarget(artifacts, evidenceDirectory) {
+ const artifact = artifacts.get(TARGET_PACKAGE);
+ assert.ok(artifact, `${TARGET_PACKAGE} was not present in Rush pack output.`);
+ const { manifest, tarballPath } = artifact;
+
+ for (const packageName of ["react-relay", "relay-runtime"]) {
+ assert.equal(manifest.dependencies?.[packageName], undefined);
+ assert.equal(manifest.optionalDependencies?.[packageName], undefined);
+ assert.equal(
+ manifest.peerDependencies?.[packageName],
+ EXPECTED_RELAY_VERSION,
+ );
+ assert.equal(
+ manifest.devDependencies?.[packageName],
+ EXPECTED_RELAY_VERSION,
+ );
+ assert.notEqual(
+ manifest.peerDependenciesMeta?.[packageName]?.optional,
+ true,
+ `${packageName} must remain a required peer.`,
+ );
+ for (const bundledField of ["bundledDependencies", "bundleDependencies"]) {
+ const bundledDependencies = manifest[bundledField];
+ if (Array.isArray(bundledDependencies)) {
+ assert.equal(
+ bundledDependencies.includes(packageName),
+ false,
+ `${packageName} must not be bundled by the packed package.`,
+ );
+ }
+ }
+ }
+
+ for (const dependencyMap of [
+ manifest.dependencies,
+ manifest.devDependencies,
+ manifest.optionalDependencies,
+ manifest.peerDependencies,
+ ]) {
+ for (const version of Object.values(dependencyMap ?? {})) {
+ assert.equal(
+ String(version).startsWith("workspace:"),
+ false,
+ `Packed manifest retained workspace version ${version}.`,
+ );
+ }
+ }
+
+ const contents = await listTarball(tarballPath);
+ assert.ok(contents.includes("package/dist/src/index.js"));
+ assert.ok(contents.includes("package/dist/esm/package.json"));
+ assert.ok(contents.includes("package/dist/esm/src/index.js"));
+ assert.ok(contents.includes("package/dist/src/index.d.ts"));
+ const esmPackageMarker = JSON.parse(
+ await captureCommand("tar", [
+ "-xOzf",
+ tarballPath,
+ "package/dist/esm/package.json",
+ ]),
+ );
+ assert.equal(esmPackageMarker.type, "module");
+ assert.equal(
+ contents.some((entry) => entry.includes("consumer-test")),
+ false,
+ "The private consumer fixture leaked into the published tarball.",
+ );
+
+ await writeFile(
+ path.join(evidenceDirectory, "packed-target-manifest.json"),
+ `${JSON.stringify(manifest, null, 2)}\n`,
+ );
+ await writeFile(
+ path.join(evidenceDirectory, "packed-target-contents.txt"),
+ `${contents.join("\n")}\n`,
+ );
+}
+
+function createLocalDependencies(artifacts, closure) {
+ assert.equal(closure.includes(TARGET_PACKAGE), true);
+ return {
+ [TARGET_PACKAGE]: artifacts.get(TARGET_PACKAGE).manifest.version,
+ };
+}
+
+async function createScopedArtifactRegistry(artifacts, closure) {
+ const packages = new Map();
+ const tarballs = new Map();
+ for (const packageName of closure) {
+ const artifact = artifacts.get(packageName);
+ assert.ok(artifact, `Cannot register missing artifact ${packageName}.`);
+ const tarball = await readFile(artifact.tarballPath);
+ const tarballName = `${createHash("sha512").update(tarball).digest("hex")}.tgz`;
+ const metadata = {
+ integrity: `sha512-${createHash("sha512").update(tarball).digest("base64")}`,
+ manifest: artifact.manifest,
+ shasum: createHash("sha1").update(tarball).digest("hex"),
+ tarball,
+ tarballName,
+ };
+ packages.set(packageName, metadata);
+ tarballs.set(tarballName, metadata);
+ }
+
+ const requests = [];
+ let baseUrl;
+ const server = createServer((request, response) => {
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
+ requests.push({
+ authorization: Boolean(request.headers.authorization),
+ method: request.method,
+ pathname: requestUrl.pathname,
+ });
+
+ const sendJson = (status, value) => {
+ const body = Buffer.from(`${JSON.stringify(value)}\n`);
+ response.writeHead(status, {
+ "content-length": body.length,
+ "content-type": "application/json",
+ });
+ response.end(request.method === "HEAD" ? undefined : body);
+ };
+
+ if (requestUrl.pathname.startsWith("/tarballs/")) {
+ const tarballName = decodeURIComponent(
+ requestUrl.pathname.slice("/tarballs/".length),
+ );
+ const metadata = tarballs.get(tarballName);
+ if (!metadata) {
+ sendJson(404, { error: "Unknown local artifact tarball." });
+ return;
+ }
+ response.writeHead(200, {
+ "content-length": metadata.tarball.length,
+ "content-type": "application/octet-stream",
+ });
+ response.end(request.method === "HEAD" ? undefined : metadata.tarball);
+ return;
+ }
+
+ const packageName = decodeURIComponent(
+ requestUrl.pathname.slice(1),
+ ).toLowerCase();
+ const metadata = packages.get(packageName);
+ if (!metadata) {
+ sendJson(404, { error: `Unknown local package ${packageName}.` });
+ return;
+ }
+ const { manifest } = metadata;
+ const publishedManifest = {
+ ...manifest,
+ dist: {
+ integrity: metadata.integrity,
+ shasum: metadata.shasum,
+ tarball: `${baseUrl}tarballs/${encodeURIComponent(metadata.tarballName)}`,
+ },
+ };
+ sendJson(200, {
+ name: packageName,
+ "dist-tags": { latest: manifest.version },
+ versions: { [manifest.version]: publishedManifest },
+ });
+ });
+
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", () => {
+ server.off("error", reject);
+ resolve();
+ });
+ });
+ const address = server.address();
+ assert.ok(address && typeof address === "object");
+ baseUrl = `http://127.0.0.1:${address.port}/`;
+
+ return {
+ baseUrl,
+ close: async () => {
+ server.closeIdleConnections?.();
+ server.closeAllConnections?.();
+ await new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()));
+ });
+ },
+ integrities: Object.fromEntries(
+ [...packages].map(([packageName, metadata]) => [
+ packageName,
+ metadata.integrity,
+ ]),
+ ),
+ requests,
+ tarballUrls: Object.fromEntries(
+ [...packages].map(([packageName, metadata]) => [
+ packageName,
+ `${baseUrl}tarballs/${encodeURIComponent(metadata.tarballName)}`,
+ ]),
+ ),
+ };
+}
+
+async function configureScopedRegistry(caseDirectory, registry) {
+ await writeFile(
+ path.join(caseDirectory, ".npmrc"),
+ `@omgjs:registry=${registry.baseUrl}\nregistry=https://registry.npmjs.org/\nalways-auth=false\n`,
+ );
+}
+
+function assertScopedRegistryCoverage(requests, closure, registry, caseName) {
+ assert.equal(
+ requests.every((request) => request.authorization === false),
+ true,
+ `${caseName} sent authorization credentials to the local registry.`,
+ );
+ for (const packageName of closure) {
+ assert.equal(
+ requests.some((request) => {
+ if (request.pathname.startsWith("/tarballs/")) {
+ return false;
+ }
+ return (
+ decodeURIComponent(request.pathname.slice(1)).toLowerCase() ===
+ packageName
+ );
+ }),
+ true,
+ `${caseName} did not fetch ${packageName} metadata from the local registry.`,
+ );
+ const tarballPathname = new URL(registry.tarballUrls[packageName]).pathname;
+ assert.equal(
+ requests.some((request) => request.pathname === tarballPathname),
+ true,
+ `${caseName} did not fetch the ${packageName} local tarball.`,
+ );
+ }
+}
+
+async function copySupportedFixture(caseDirectory) {
+ await cp(FIXTURE_ROOT, caseDirectory, {
+ filter(sourcePath) {
+ const relativePath = path.relative(FIXTURE_ROOT, sourcePath);
+ return !(
+ relativePath === "peer-stubs" ||
+ relativePath.startsWith(`peer-stubs${path.sep}`)
+ );
+ },
+ recursive: true,
+ });
+}
+
+function shouldCopyToPackClone(sourcePath) {
+ const relativePath = path.relative(REPOSITORY_ROOT, sourcePath);
+ if (!relativePath) {
+ return true;
+ }
+ const segments = relativePath.split(path.sep);
+ if (segments.includes("node_modules") || segments.includes(".rush")) {
+ return false;
+ }
+ return !(
+ relativePath === ".git" ||
+ relativePath.startsWith(`.git${path.sep}`) ||
+ relativePath === path.join("common", "temp") ||
+ relativePath.startsWith(`${path.join("common", "temp")}${path.sep}`)
+ );
+}
+
+async function createIsolatedPackClone({ logsDirectory, temporaryRoot }) {
+ const cloneDirectory = path.join(temporaryRoot, "pack-clone");
+ await runCommand({
+ arguments_: ["clone", "--no-local", REPOSITORY_ROOT, cloneDirectory],
+ command: "git",
+ label: "create-isolated-pack-clone",
+ logsDirectory,
+ });
+
+ await runCommand({
+ arguments_: ["-C", cloneDirectory, "remote", "remove", "origin"],
+ command: "git",
+ label: "remove-isolated-pack-clone-origin",
+ logsDirectory,
+ });
+
+ for (const entry of await readdir(cloneDirectory)) {
+ if (entry !== ".git") {
+ await rm(path.join(cloneDirectory, entry), {
+ force: true,
+ recursive: true,
+ });
+ }
+ }
+
+ for (const entry of await readdir(REPOSITORY_ROOT)) {
+ if (entry === ".git") {
+ continue;
+ }
+ await cp(
+ path.join(REPOSITORY_ROOT, entry),
+ path.join(cloneDirectory, entry),
+ {
+ filter: shouldCopyToPackClone,
+ force: true,
+ recursive: true,
+ },
+ );
+ }
+ return cloneDirectory;
+}
+
+async function writeManifest(caseDirectory, manifest) {
+ await writeFile(
+ path.join(caseDirectory, "package.json"),
+ `${JSON.stringify(manifest, null, 2)}\n`,
+ );
+}
+
+function supportedManifest(caseName, localDependencies) {
+ return {
+ name: `labkit-relay-consumer-${caseName}`,
+ version: "0.0.0",
+ private: true,
+ type: "module",
+ scripts: FIXTURE_SCRIPTS,
+ dependencies: sortRecord({
+ ...localDependencies,
+ ...SUPPORTED_RUNTIME_DEPENDENCIES,
+ }),
+ devDependencies: sortRecord(SUPPORTED_DEVELOPMENT_DEPENDENCIES),
+ };
+}
+
+function managerCommand(manager) {
+ return manager === "npm" ? "npm" : PNPM;
+}
+
+function installArguments(manager, strict) {
+ if (manager === "npm") {
+ return [
+ "install",
+ "--ignore-scripts",
+ "--no-audit",
+ "--no-fund",
+ ...(strict ? ["--strict-peer-deps"] : []),
+ ];
+ }
+
+ return [
+ "install",
+ "--ignore-scripts",
+ "--cache-dir=.pnpm-cache",
+ "--config.node-linker=isolated",
+ "--store-dir=.pnpm-store",
+ ...(strict
+ ? ["--strict-peer-dependencies", "--config.auto-install-peers=false"]
+ : []),
+ ];
+}
+
+async function installCase({
+ allowFailure = false,
+ caseDirectory,
+ caseName,
+ logsDirectory,
+ manager,
+ npmConfigurationEnvironment,
+ strict = false,
+}) {
+ return runCommand({
+ allowFailure,
+ arguments_: installArguments(manager, strict),
+ command: managerCommand(manager),
+ cwd: caseDirectory,
+ environment: {
+ ...npmConfigurationEnvironment,
+ NO_PROXY: "127.0.0.1,localhost",
+ no_proxy: "127.0.0.1,localhost",
+ ...(manager === "npm"
+ ? { npm_config_cache: path.join(caseDirectory, ".npm-cache") }
+ : {}),
+ },
+ label: `${caseName}-${manager}-${strict ? "strict" : "normal"}-install`,
+ logsDirectory,
+ scrubNpmEnvironment: true,
+ unsetEnvironment: REGISTRY_AUTH_ENVIRONMENT_NAMES,
+ });
+}
+
+function assertNoPeerDiagnostic(result, label) {
+ assert.equal(
+ PEER_DIAGNOSTIC_PATTERN.test(result.combined),
+ false,
+ `${label} emitted a peer dependency diagnostic.`,
+ );
+}
+
+async function runPackageScript({
+ caseDirectory,
+ caseName,
+ logsDirectory,
+ manager,
+ scriptName,
+}) {
+ await runCommand({
+ arguments_: ["run", scriptName],
+ command: managerCommand(manager),
+ cwd: caseDirectory,
+ label: `${caseName}-${scriptName}`,
+ logsDirectory,
+ timeoutMs: scriptName === "check:render" ? 15_000 : undefined,
+ });
+}
+
+async function assertLocalArtifactProvenance(
+ artifacts,
+ caseDirectory,
+ closure,
+ manager,
+ registry,
+) {
+ if (manager === "npm") {
+ const lock = JSON.parse(
+ await readFile(path.join(caseDirectory, "package-lock.json"), "utf8"),
+ );
+ for (const packageName of closure) {
+ const suffix = `node_modules/${packageName}`;
+ const entries = Object.entries(lock.packages ?? {}).filter(
+ ([entryPath]) => entryPath.endsWith(suffix),
+ );
+ assert.ok(
+ entries.length > 0,
+ `${packageName} was absent from package-lock.json.`,
+ );
+ for (const [entryPath, metadata] of entries) {
+ assert.match(
+ metadata.resolved ?? "",
+ new RegExp(
+ `^${registry.tarballUrls[packageName].replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`,
+ ),
+ `${packageName} was not resolved from its local registry tarball.`,
+ );
+ assert.equal(metadata.integrity, registry.integrities[packageName]);
+ const installedManifest = JSON.parse(
+ await readFile(
+ path.join(caseDirectory, entryPath, "package.json"),
+ "utf8",
+ ),
+ );
+ assert.equal(installedManifest.name, packageName);
+ assert.equal(
+ installedManifest.version,
+ artifacts.get(packageName).manifest.version,
+ );
+ }
+ }
+ return;
+ }
+
+ const pnpmLock = await readFile(
+ path.join(caseDirectory, "pnpm-lock.yaml"),
+ "utf8",
+ );
+ const virtualStoreDirectory = path.join(caseDirectory, "node_modules/.pnpm");
+ const virtualStoreEntries = await readdir(virtualStoreDirectory);
+ for (const packageName of closure) {
+ assert.equal(
+ pnpmLock.includes(registry.tarballUrls[packageName]),
+ true,
+ `${packageName} local tarball URL was absent from pnpm-lock.yaml.`,
+ );
+ assert.equal(
+ pnpmLock.includes(registry.integrities[packageName]),
+ true,
+ `${packageName} local tarball integrity was absent from pnpm-lock.yaml.`,
+ );
+ const encodedName = packageName.replace("/", "+");
+ const entries = virtualStoreEntries.filter((entry) =>
+ entry.startsWith(`${encodedName}@`),
+ );
+ assert.ok(
+ entries.length > 0,
+ `${packageName} was absent from pnpm's virtual store.`,
+ );
+ assert.equal(
+ entries.some((entry) => entry.includes("@file+")),
+ false,
+ `${packageName} retained a file-linked copy in pnpm's virtual store: ${entries.join(", ")}`,
+ );
+ assert.equal(
+ entries.every((entry) =>
+ entry.startsWith(
+ `${encodedName}@${artifacts.get(packageName).manifest.version}`,
+ ),
+ ),
+ true,
+ `${packageName} resolved an unexpected version: ${entries.join(", ")}`,
+ );
+ for (const entry of entries) {
+ const installedManifest = JSON.parse(
+ await readFile(
+ path.join(
+ virtualStoreDirectory,
+ entry,
+ "node_modules",
+ packageName,
+ "package.json",
+ ),
+ "utf8",
+ ),
+ );
+ assert.equal(installedManifest.name, packageName);
+ assert.equal(
+ installedManifest.version,
+ artifacts.get(packageName).manifest.version,
+ );
+ }
+ }
+}
+
+async function runSupportedCase({
+ artifacts,
+ caseRoot,
+ closure,
+ evidenceDirectory,
+ localDependencies,
+ logsDirectory,
+ manager,
+ npmConfigurationEnvironment,
+ registry,
+ strict,
+}) {
+ const caseName = `supported-${manager}-${strict ? "strict" : "normal"}`;
+ const caseDirectory = path.join(caseRoot, caseName);
+ await mkdir(caseDirectory, { recursive: true });
+ await copySupportedFixture(caseDirectory);
+ await configureScopedRegistry(caseDirectory, registry);
+ await writeManifest(
+ caseDirectory,
+ supportedManifest(caseName, localDependencies),
+ );
+
+ const registryRequestStart = registry.requests.length;
+ const installResult = await installCase({
+ caseDirectory,
+ caseName,
+ logsDirectory,
+ manager,
+ npmConfigurationEnvironment,
+ strict,
+ });
+ if (!strict) {
+ assertScopedRegistryCoverage(
+ registry.requests.slice(registryRequestStart),
+ closure,
+ registry,
+ caseName,
+ );
+ }
+ assertNoPeerDiagnostic(installResult, caseName);
+ await assertLocalArtifactProvenance(
+ artifacts,
+ caseDirectory,
+ closure,
+ manager,
+ registry,
+ );
+
+ const dependencyArguments =
+ manager === "npm"
+ ? ["ls", "--all", "--json"]
+ : ["list", "--depth", "Infinity", "--json"];
+ await runCommand({
+ arguments_: dependencyArguments,
+ command: managerCommand(manager),
+ cwd: caseDirectory,
+ label: `${caseName}-dependency-graph`,
+ logsDirectory,
+ });
+
+ if (strict) {
+ return;
+ }
+
+ for (const scriptName of [
+ "relay",
+ "typecheck",
+ "build:checks",
+ "check:identity",
+ "check:esm-resolution",
+ "build:cjs",
+ "check:cjs",
+ "build:esm",
+ "check:esm",
+ "build:browser",
+ "build:ssr",
+ "check:render",
+ ]) {
+ await runPackageScript({
+ caseDirectory,
+ caseName,
+ logsDirectory,
+ manager,
+ scriptName,
+ });
+ if (scriptName === "build:cjs") {
+ await writeFile(
+ path.join(caseDirectory, "dist-cjs/package.json"),
+ '{"type":"commonjs"}\n',
+ );
+ }
+ }
+
+ const viteReport = JSON.parse(
+ await readFile(
+ path.join(caseDirectory, "dist/relay-module-identity.json"),
+ "utf8",
+ ),
+ );
+ for (const packageName of ["react-relay", "relay-runtime"]) {
+ assert.equal(viteReport[packageName]?.version, EXPECTED_RELAY_VERSION);
+ assert.ok(viteReport[packageName]?.moduleCount > 0);
+ }
+ await writeFile(
+ path.join(evidenceDirectory, `${caseName}-vite-identity.json`),
+ `${JSON.stringify(viteReport, null, 2)}\n`,
+ );
+}
+
+async function packPeerStubs(
+ releaseDirectory,
+ logsDirectory,
+ npmConfigurationEnvironment,
+) {
+ const stubArtifacts = {};
+ for (const stubName of ["react-relay", "relay-runtime"]) {
+ const stubDirectory = path.join(FIXTURE_ROOT, "peer-stubs", stubName);
+ await runCommand({
+ arguments_: [
+ "--dir",
+ stubDirectory,
+ "pack",
+ "--pack-destination",
+ releaseDirectory,
+ ],
+ command: PNPM,
+ environment: npmConfigurationEnvironment,
+ label: `pack-synthetic-${stubName}`,
+ logsDirectory,
+ scrubNpmEnvironment: true,
+ unsetEnvironment: REGISTRY_AUTH_ENVIRONMENT_NAMES,
+ });
+ const expectedTarball = path.join(
+ releaseDirectory,
+ `${stubName}-${stubName === "react-relay" ? "99.0.0" : "99.0.0"}.tgz`,
+ );
+ stubArtifacts[stubName] = `file:${expectedTarball}`;
+ }
+ return stubArtifacts;
+}
+
+function negativeManifest({
+ caseName,
+ localDependencies,
+ scenario,
+ stubArtifacts,
+}) {
+ const relayDependencies =
+ scenario === "unsupported"
+ ? {
+ "react-relay": stubArtifacts["react-relay"],
+ "relay-runtime": stubArtifacts["relay-runtime"],
+ }
+ : scenario === "mismatched"
+ ? {
+ "react-relay": EXPECTED_RELAY_VERSION,
+ "relay-runtime": stubArtifacts["relay-runtime"],
+ }
+ : {
+ "relay-runtime": EXPECTED_RELAY_VERSION,
+ };
+
+ return {
+ name: `labkit-relay-negative-${caseName}`,
+ version: "0.0.0",
+ private: true,
+ dependencies: sortRecord({
+ ...localDependencies,
+ graphql: "16.14.0",
+ react: "19.2.5",
+ ...relayDependencies,
+ }),
+ };
+}
+
+async function assertAutoInstalledPeer(caseDirectory, packageName) {
+ const requireFromConsumer = createRequire(
+ path.join(caseDirectory, "negative-resolution-anchor.cjs"),
+ );
+ const labkitEntry = await realpath(
+ requireFromConsumer.resolve(TARGET_PACKAGE),
+ );
+ const requireFromLabkit = createRequire(labkitEntry);
+ const entryPath = await realpath(requireFromLabkit.resolve(packageName));
+ let currentPath = path.dirname(entryPath);
+ while (currentPath !== path.dirname(currentPath)) {
+ try {
+ const manifest = JSON.parse(
+ await readFile(path.join(currentPath, "package.json"), "utf8"),
+ );
+ if (manifest.name === packageName) {
+ assert.equal(manifest.version, EXPECTED_RELAY_VERSION);
+ return;
+ }
+ } catch {
+ // Keep walking to the owning package manifest.
+ }
+ currentPath = path.dirname(currentPath);
+ }
+ throw new Error(`Could not verify the auto-installed ${packageName} peer.`);
+}
+
+async function runNegativeCase({
+ caseRoot,
+ localDependencies,
+ logsDirectory,
+ manager,
+ matrixSummary,
+ npmConfigurationEnvironment,
+ registry,
+ scenario,
+ strict,
+ stubArtifacts,
+}) {
+ const caseName = `${scenario}-${manager}-${strict ? "strict" : "normal"}`;
+ const caseDirectory = path.join(caseRoot, caseName);
+ await mkdir(caseDirectory, { recursive: true });
+ await configureScopedRegistry(caseDirectory, registry);
+ const manifest = negativeManifest({
+ caseName,
+ localDependencies,
+ scenario,
+ stubArtifacts,
+ });
+ await writeManifest(caseDirectory, manifest);
+ if (scenario === "missing") {
+ assert.equal(manifest.dependencies["react-relay"], undefined);
+ }
+
+ const result = await installCase({
+ allowFailure: true,
+ caseDirectory,
+ caseName,
+ logsDirectory,
+ manager,
+ npmConfigurationEnvironment,
+ strict,
+ });
+ const diagnostic = PEER_DIAGNOSTIC_PATTERN.test(result.combined);
+ const behavior =
+ result.status !== 0
+ ? "install-failed"
+ : diagnostic
+ ? "install-warned"
+ : "installed";
+
+ if (scenario === "missing" && result.status === 0) {
+ await assertAutoInstalledPeer(caseDirectory, "react-relay");
+ matrixSummary[caseName] =
+ `${behavior}; package manager auto-installed the omitted peer`;
+ return;
+ }
+
+ assert.equal(
+ diagnostic,
+ true,
+ `${caseName} did not emit an actionable peer dependency diagnostic.`,
+ );
+ if (strict) {
+ assert.notEqual(
+ result.status,
+ 0,
+ `${caseName} unexpectedly succeeded in strict peer mode.`,
+ );
+ }
+ matrixSummary[caseName] = behavior;
+}
+
+async function recordVersions(evidenceDirectory, logsDirectory) {
+ const nodeVersion = process.version;
+ const npmResult = await runCommand({
+ arguments_: ["--version"],
+ command: "npm",
+ label: "npm-version",
+ logsDirectory,
+ });
+ const pnpmResult = await runCommand({
+ arguments_: ["--version"],
+ command: PNPM,
+ label: "pnpm-version",
+ logsDirectory,
+ });
+ assert.equal(pnpmResult.stdout.trim(), "10.33.2");
+ await writeFile(
+ path.join(evidenceDirectory, "tool-versions.json"),
+ `${JSON.stringify(
+ {
+ node: nodeVersion,
+ npm: npmResult.stdout.trim(),
+ pnpm: pnpmResult.stdout.trim(),
+ relay: EXPECTED_RELAY_VERSION,
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+}
+
+async function collectFileSnapshot(rootPath, relativePrefix = "") {
+ const snapshot = {};
+ let entries;
+ try {
+ entries = await readdir(rootPath, { withFileTypes: true });
+ } catch (error) {
+ if (error?.code === "ENOENT") {
+ return snapshot;
+ }
+ throw error;
+ }
+
+ for (const entry of entries.sort((left, right) =>
+ left.name.localeCompare(right.name),
+ )) {
+ const absolutePath = path.join(rootPath, entry.name);
+ const relativePath = path.join(relativePrefix, entry.name);
+ if (entry.isDirectory()) {
+ Object.assign(
+ snapshot,
+ await collectFileSnapshot(absolutePath, relativePath),
+ );
+ } else if (entry.isFile()) {
+ snapshot[relativePath] = (await readFile(absolutePath)).toString(
+ "base64",
+ );
+ }
+ }
+ return snapshot;
+}
+
+async function snapshotLiveReleaseInputs() {
+ const targetRoot = path.join(
+ REPOSITORY_ROOT,
+ "packages/webapp-graphql-relay",
+ );
+ const snapshot = {};
+ for (const fileName of ["package.json", "CHANGELOG.json", "CHANGELOG.md"]) {
+ snapshot[`target/${fileName}`] = (
+ await readFile(path.join(targetRoot, fileName))
+ ).toString("base64");
+ }
+ Object.assign(
+ snapshot,
+ Object.fromEntries(
+ Object.entries(
+ await collectFileSnapshot(path.join(REPOSITORY_ROOT, "common/changes")),
+ ).map(([fileName, contents]) => [`changes/${fileName}`, contents]),
+ ),
+ );
+ return snapshot;
+}
+
+async function createEmptyNpmConfiguration(temporaryRoot) {
+ const configurationDirectory = path.join(temporaryRoot, "npm-config");
+ const globalConfiguration = path.join(
+ configurationDirectory,
+ "empty-global.npmrc",
+ );
+ const userConfiguration = path.join(
+ configurationDirectory,
+ "empty-user.npmrc",
+ );
+ await mkdir(configurationDirectory, { recursive: true });
+ await Promise.all([
+ writeFile(globalConfiguration, "# Intentionally empty.\n"),
+ writeFile(userConfiguration, "# Intentionally empty.\n"),
+ ]);
+ return {
+ NPM_CONFIG_GLOBALCONFIG: globalConfiguration,
+ NPM_CONFIG_USERCONFIG: userConfiguration,
+ npm_config_globalconfig: globalConfiguration,
+ npm_config_userconfig: userConfiguration,
+ };
+}
+
+function assertSafeTemporaryRoot(temporaryRoot) {
+ assert.equal(path.dirname(temporaryRoot), os.tmpdir());
+ assert.equal(path.basename(temporaryRoot).startsWith(TEMPORARY_PREFIX), true);
+}
+
+async function preserveFailureEvidence(temporaryRoot) {
+ await rm(FAILURE_ARTIFACT_ROOT, { force: true, recursive: true });
+ await mkdir(FAILURE_ARTIFACT_ROOT, { recursive: true });
+ for (const directoryName of ["evidence", "logs"]) {
+ const source = path.join(temporaryRoot, directoryName);
+ try {
+ await cp(source, path.join(FAILURE_ARTIFACT_ROOT, directoryName), {
+ recursive: true,
+ });
+ } catch (error) {
+ if (error?.code !== "ENOENT") {
+ throw error;
+ }
+ }
+ }
+}
+
+async function main() {
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
+ printHelp();
+ return;
+ }
+ assert.deepEqual(
+ process.argv.slice(2),
+ [],
+ "This command does not accept positional arguments. Use --help for details.",
+ );
+
+ await rm(FAILURE_ARTIFACT_ROOT, { force: true, recursive: true });
+ const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), TEMPORARY_PREFIX));
+ assertSafeTemporaryRoot(temporaryRoot);
+ const caseRoot = path.join(temporaryRoot, "cases");
+ const evidenceDirectory = path.join(temporaryRoot, "evidence");
+ const logsDirectory = path.join(temporaryRoot, "logs");
+ const releaseDirectory = path.join(temporaryRoot, "release");
+ await Promise.all(
+ [caseRoot, evidenceDirectory, logsDirectory, releaseDirectory].map(
+ (directory) => mkdir(directory, { recursive: true }),
+ ),
+ );
+
+ let artifactRegistry;
+ let succeeded = false;
+ try {
+ const liveReleaseInputs = await snapshotLiveReleaseInputs();
+ const npmConfigurationEnvironment =
+ await createEmptyNpmConfiguration(temporaryRoot);
+ await recordVersions(evidenceDirectory, logsDirectory);
+ const cloneDirectory = await createIsolatedPackClone({
+ logsDirectory,
+ temporaryRoot,
+ });
+ const isolatedRushRunner = path.join(
+ cloneDirectory,
+ "common/scripts/install-run-rush.js",
+ );
+ await runCommand({
+ arguments_: [isolatedRushRunner, "install"],
+ command: process.execPath,
+ cwd: cloneDirectory,
+ environment: npmConfigurationEnvironment,
+ label: "isolated-rush-install",
+ logsDirectory,
+ scrubNpmEnvironment: true,
+ unsetEnvironment: REGISTRY_AUTH_ENVIRONMENT_NAMES,
+ });
+ await runCommand({
+ arguments_: [isolatedRushRunner, "build", "--to", TARGET_PACKAGE],
+ command: process.execPath,
+ cwd: cloneDirectory,
+ environment: npmConfigurationEnvironment,
+ label: "isolated-rush-build-target-and-dependencies",
+ logsDirectory,
+ scrubNpmEnvironment: true,
+ unsetEnvironment: REGISTRY_AUTH_ENVIRONMENT_NAMES,
+ });
+
+ // Rush 5.175.0 only executes packing when --publish accompanies --pack.
+ // Its pack branch does not apply change files and does not registry-publish;
+ // the isolated clone and output assertions add defense in depth.
+ const packResult = await runCommand({
+ arguments_: [
+ isolatedRushRunner,
+ "publish",
+ "--publish",
+ "--pack",
+ "--include-all",
+ "--release-folder",
+ releaseDirectory,
+ ],
+ command: process.execPath,
+ cwd: cloneDirectory,
+ environment: npmConfigurationEnvironment,
+ label: "rush-pack-publishable-artifacts",
+ logsDirectory,
+ scrubNpmEnvironment: true,
+ unsetEnvironment: REGISTRY_AUTH_ENVIRONMENT_NAMES,
+ });
+ assert.match(
+ packResult.combined,
+ /EXECUTING:[^\n]*(?:npm|pnpm) pack/i,
+ "Rush did not report an executed package-manager pack command.",
+ );
+ assert.match(
+ packResult.combined,
+ /Tarball Details/i,
+ "Rush did not report any created tarballs.",
+ );
+ assert.doesNotMatch(
+ packResult.combined,
+ /EXECUTING:[^\n]*(?:npm|pnpm) publish/i,
+ "Rush unexpectedly invoked a package-manager publish command.",
+ );
+ assert.deepEqual(
+ await snapshotLiveReleaseInputs(),
+ liveReleaseInputs,
+ "The isolated pack changed live package, changelog, or Rush change metadata.",
+ );
+ await writeFile(
+ path.join(evidenceDirectory, "live-release-inputs-unchanged.txt"),
+ "Live package.json, changelogs, and Rush change files were byte-identical after isolated packing.\n",
+ );
+
+ const artifacts = await collectPackedArtifacts(releaseDirectory);
+ await validatePackedTarget(artifacts, evidenceDirectory);
+ const closure = collectFirstPartyClosure(artifacts);
+ const localDependencies = createLocalDependencies(artifacts, closure);
+ await writeFile(
+ path.join(evidenceDirectory, "first-party-closure.json"),
+ `${JSON.stringify({ closure, localDependencies }, null, 2)}\n`,
+ );
+ artifactRegistry = await createScopedArtifactRegistry(artifacts, closure);
+ const stubArtifacts = await packPeerStubs(
+ releaseDirectory,
+ logsDirectory,
+ npmConfigurationEnvironment,
+ );
+
+ for (const manager of ["npm", "pnpm"]) {
+ await runSupportedCase({
+ artifacts,
+ caseRoot,
+ closure,
+ evidenceDirectory,
+ localDependencies,
+ logsDirectory,
+ manager,
+ npmConfigurationEnvironment,
+ registry: artifactRegistry,
+ strict: false,
+ });
+ await runSupportedCase({
+ artifacts,
+ caseRoot,
+ closure,
+ evidenceDirectory,
+ localDependencies,
+ logsDirectory,
+ manager,
+ npmConfigurationEnvironment,
+ registry: artifactRegistry,
+ strict: true,
+ });
+ }
+
+ const matrixSummary = {};
+ for (const scenario of ["unsupported", "mismatched", "missing"]) {
+ for (const manager of ["npm", "pnpm"]) {
+ for (const strict of [false, true]) {
+ await runNegativeCase({
+ caseRoot,
+ localDependencies,
+ logsDirectory,
+ manager,
+ matrixSummary,
+ npmConfigurationEnvironment,
+ registry: artifactRegistry,
+ scenario,
+ strict,
+ stubArtifacts,
+ });
+ }
+ }
+ }
+ await writeFile(
+ path.join(evidenceDirectory, "matrix-summary.json"),
+ `${JSON.stringify(matrixSummary, null, 2)}\n`,
+ );
+ assert.equal(
+ artifactRegistry.requests.every(
+ (request) => request.authorization === false,
+ ),
+ true,
+ "A package manager sent authorization credentials to the local registry.",
+ );
+ await writeFile(
+ path.join(evidenceDirectory, "scoped-registry-requests.json"),
+ `${JSON.stringify(artifactRegistry.requests, null, 2)}\n`,
+ );
+
+ succeeded = true;
+ process.stdout.write(
+ `\n[relay-consumer] Negative matrix behavior:\n${JSON.stringify(matrixSummary, null, 2)}\n`,
+ );
+ process.stdout.write(
+ "[relay-consumer] Packed npm/pnpm Relay consumer contract passed.\n",
+ );
+ } catch (error) {
+ if (artifactRegistry) {
+ await writeFile(
+ path.join(evidenceDirectory, "scoped-registry-requests.json"),
+ `${JSON.stringify(artifactRegistry.requests, null, 2)}\n`,
+ );
+ }
+ await preserveFailureEvidence(temporaryRoot);
+ process.stderr.write(
+ `\n[relay-consumer] Failure evidence: ${FAILURE_ARTIFACT_ROOT}\n`,
+ );
+ throw error;
+ } finally {
+ await artifactRegistry?.close();
+ assertSafeTemporaryRoot(temporaryRoot);
+ await rm(temporaryRoot, { force: true, recursive: true });
+ if (succeeded) {
+ await rm(FAILURE_ARTIFACT_ROOT, { force: true, recursive: true });
+ }
+ }
+}
+
+await main();
diff --git a/docs/package-groups.md b/docs/package-groups.md
index 6a6f5d9..edb246b 100644
--- a/docs/package-groups.md
+++ b/docs/package-groups.md
@@ -69,8 +69,19 @@ npm install \
@omgjs/labkit-webapp-graphql-relay \
@omgjs/labkit-webapp-external-store \
@omgjs/labkit-webapp-ui \
- @omgjs/labkit-webapp-build-config
+ @omgjs/labkit-webapp-build-config \
+ react-relay@20.1.1 \
+ relay-runtime@20.1.1
+
+npm install --save-dev \
+ relay-compiler@20.1.1 \
+ @types/react-relay@18.2.1 \
+ @types/relay-runtime@20.1.1
```
-Labkit does not pin your Nest, React, Relay, TypeORM, Vite, or GraphQL stack.
-Those framework packages remain application dependencies.
+Nest, React, Relay, TypeORM, Vite, and GraphQL remain application-owned
+framework dependencies. The Relay package currently supports the exact,
+consumer-installed `react-relay@20.1.1` and `relay-runtime@20.1.1` pair. Keep
+the development-time Relay compiler aligned with that runtime; see the
+[Relay package reference](packages/webapp-graphql-relay.md#required-relay-peers)
+for the complete peer and migration contract.
diff --git a/docs/packages/webapp-graphql-relay.md b/docs/packages/webapp-graphql-relay.md
index 2c447b6..0df1f34 100644
--- a/docs/packages/webapp-graphql-relay.md
+++ b/docs/packages/webapp-graphql-relay.md
@@ -6,11 +6,102 @@ route query loading.
## Install
```bash
-npm install @omgjs/labkit-webapp-graphql-relay
+npm install \
+ @omgjs/labkit-webapp-graphql-relay \
+ react-relay@20.1.1 \
+ relay-runtime@20.1.1
+
+npm install --save-dev \
+ relay-compiler@20.1.1 \
+ @types/react-relay@18.2.1 \
+ @types/relay-runtime@20.1.1
```
Runtime: browser/Relay. Package format: CommonJS and ESM.
+## Required Relay Peers
+
+The application must directly install this exact supported runtime pair:
+
+| Package | Supported version | Ownership |
+| --------------- | ----------------- | ------------------------- |
+| `react-relay` | `20.1.1` | Required application peer |
+| `relay-runtime` | `20.1.1` | Required application peer |
+
+Labkit creates Relay environments and preloaded query references; the
+application supplies `RelayEnvironmentProvider`, hooks, generated operations,
+and other Relay imports. These surfaces must resolve one canonical installation
+of each Relay package. A different version, a mixed pair, or a private nested
+copy is outside the supported contract even if it appears to work.
+
+The Relay packages are required peers because Labkit imports both at runtime.
+They are not optional integrations. The exact versions also express that
+`react-relay` and `relay-runtime` must be selected as a coherent pair rather
+than as two independently compatible ranges.
+
+### Compiler and Type Packages
+
+`relay-compiler`, `@types/react-relay`, and `@types/relay-runtime` are
+application development tools, not Labkit runtime peers. The validated set is:
+
+| Package | Version | Purpose |
+| ---------------------- | -------- | -------------------------------- |
+| `relay-compiler` | `20.1.1` | Generate aligned Relay artifacts |
+| `@types/react-relay` | `18.2.1` | React Relay declarations |
+| `@types/relay-runtime` | `20.1.1` | Relay Runtime declarations |
+
+Pin these versions in TypeScript Relay applications. In particular, keep the
+compiler on `20.1.1` so generated artifacts target the runtime that consumes
+them.
+
+### Package-Manager Diagnostics
+
+Required exact peers communicate the supported graph, but not every package
+manager mode rejects a missing or invalid peer. Depending on configuration,
+npm or pnpm can auto-install a peer, emit only a warning, or allow an override.
+An install that succeeds with a peer warning does not establish compatibility.
+
+Do not use `--force`, `--legacy-peer-deps`, aliases, overrides, or forced
+hoisting to admit another Relay pair. After installation or any lockfile
+change, inspect the dependency graph from the application root:
+
+```bash
+npm ls react-relay relay-runtime
+# pnpm consumers:
+pnpm why --recursive react-relay relay-runtime
+```
+
+Both commands should report only `react-relay@20.1.1` and
+`relay-runtime@20.1.1`. There must be no `invalid` or unmet peer, mixed version,
+or Relay installation nested under `@omgjs/labkit-webapp-graphql-relay`. Also
+run the application's typecheck and production build so its TypeScript and
+bundler graphs are checked.
+
+## Migrating from 2.x
+
+Labkit 2.x could install its own Relay dependencies while the application used
+another Relay graph. The new contract makes the application responsible for
+the one supported pair.
+
+To migrate:
+
+1. Add `react-relay@20.1.1` and `relay-runtime@20.1.1` as direct application
+ dependencies. If the application currently selects another Relay release,
+ align it to this pair before upgrading Labkit.
+2. Pin `relay-compiler@20.1.1`, `@types/react-relay@18.2.1`, and
+ `@types/relay-runtime@20.1.1` as development dependencies where the
+ application generates operations and typechecks Relay code.
+3. Remove temporary Relay aliases, overrides, or deduplication rules that were
+ introduced to work around separate application and Labkit runtimes.
+4. Run the normal package-manager install so the lockfile records the direct
+ peers. Do not bypass peer validation with force or legacy-peer modes.
+5. Inspect the graph with the commands above, then run the application
+ typecheck, Relay compilation, production build, and provider/hook tests.
+
+If the application cannot use the exact supported pair, do not suppress the
+peer diagnostic. That Relay combination is not a compatibility target for this
+release.
+
## Public API Groups
- auth and realtime adapter types;
diff --git a/docs/quick-start/README.md b/docs/quick-start/README.md
index b7845e7..cca596b 100644
--- a/docs/quick-start/README.md
+++ b/docs/quick-start/README.md
@@ -67,10 +67,12 @@ npm install -w server \
```bash
npm install -w webapp \
- graphql graphql-ws react-relay relay-runtime
+ graphql graphql-ws \
+ react-relay@20.1.1 relay-runtime@20.1.1
npm install -w webapp -D \
- @types/react-relay @types/relay-runtime relay-compiler
+ relay-compiler@20.1.1 \
+ @types/react-relay@18.2.1 @types/relay-runtime@20.1.1
npm install -w webapp \
@omgjs/labkit-webapp-auth \
@@ -81,5 +83,24 @@ npm install -w webapp \
@omgjs/labkit-webapp-ui
```
+The two Relay runtime packages are direct, required application peers for
+`@omgjs/labkit-webapp-graphql-relay`. Keep the exact `20.1.1` pair together;
+the type packages and compiler are development-time tooling, and the compiler
+must stay aligned with the runtime that executes its generated artifacts.
+
+After installation, verify that the webapp resolves one pair and reports no
+invalid or unmet peer:
+
+```bash
+npm ls -w webapp react-relay relay-runtime
+```
+
+Do not use force, legacy-peer, alias, or override settings to install a
+different pair. Some package-manager modes warn or auto-install instead of
+failing, but an unsupported graph is not compatible merely because installation
+completed. See the
+[Relay package reference](../packages/webapp-graphql-relay.md#required-relay-peers)
+for pnpm inspection and migration guidance.
+
Then continue through the pages in order. Each page gives complete file bodies
for the Labkit adapter files it introduces.
diff --git a/package.json b/package.json
index fbdd6c4..e44674d 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"rush:verify": "node common/scripts/install-run-rush.js verify",
"rush:version": "node common/scripts/install-run-rush.js version",
"rush:publish": "node common/scripts/install-run-rush.js publish",
+ "test:relay-consumer-contract": "node common/scripts/test-relay-consumer-contract.mjs",
"site:docusaurus:build": "npm --prefix website-docusaurus run build",
"site:docusaurus:check": "npm --prefix website-docusaurus run check",
"site:docusaurus:sync-docs": "npm --prefix website-docusaurus run sync-docs",
diff --git a/packages/webapp-graphql-relay/README.md b/packages/webapp-graphql-relay/README.md
index 3142084..0b08865 100644
--- a/packages/webapp-graphql-relay/README.md
+++ b/packages/webapp-graphql-relay/README.md
@@ -4,6 +4,64 @@
auth-aware GraphQL HTTP requests, GraphQL WS subscriptions, route preloading,
and Relay store maintenance.
+## Install
+
+Install the package and its required Relay peers directly in the application:
+
+```bash
+npm install \
+ @omgjs/labkit-webapp-graphql-relay \
+ react-relay@20.1.1 \
+ relay-runtime@20.1.1
+```
+
+The supported runtime pair is exactly `react-relay@20.1.1` and
+`relay-runtime@20.1.1`. Both packages are application-owned, required peers.
+Do not mix versions or substitute another Relay release, even when the package
+manager completes the install with only a warning.
+
+Applications that compile Relay operations or TypeScript can use the
+repository-validated development-tooling set:
+
+```bash
+npm install --save-dev \
+ relay-compiler@20.1.1 \
+ @types/react-relay@18.2.1 \
+ @types/relay-runtime@20.1.1
+```
+
+`relay-compiler` and the type packages are development tools, not runtime
+peers. Keep the compiler aligned with the runtime pair so generated artifacts
+match the runtime that executes them.
+
+## Relay Runtime Contract
+
+Labkit-created environments and preloaded query references are consumed by the
+application's `RelayEnvironmentProvider`, hooks, and Relay imports. All of
+those boundaries must resolve one canonical installation of each required
+Relay package. Labkit does not support a private or nested Relay runtime.
+
+Inspect the installed graph after installation or a lockfile update:
+
+```bash
+npm ls react-relay relay-runtime
+# pnpm consumers:
+pnpm why --recursive react-relay relay-runtime
+```
+
+The graph must contain only the matched `20.1.1` pair, with no `invalid`, unmet,
+or nested copy under Labkit. Peer metadata describes the supported contract,
+but package-manager settings can weaken its diagnostics. Auto-installation,
+warning-only modes, `--force`, `--legacy-peer-deps`, aliases, and overrides do
+not make another graph supported.
+
+When upgrading from Labkit 2.x, add both exact Relay packages as direct
+dependencies, align the compiler and types shown above, remove Relay aliases or
+deduplication overrides used as workarounds, reinstall with the normal package
+manager mode, and inspect the graph before building the application. See the
+[package reference](../../docs/packages/webapp-graphql-relay.md#migrating-from-2x)
+for the complete migration checklist.
+
## Owns
- Relay environment factory.
diff --git a/packages/webapp-graphql-relay/consumer-test/index.html b/packages/webapp-graphql-relay/consumer-test/index.html
new file mode 100644
index 0000000..75ada4c
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Labkit Relay consumer contract
+
+
+
+
+
+
diff --git a/packages/webapp-graphql-relay/consumer-test/peer-stubs/react-relay/package.json b/packages/webapp-graphql-relay/consumer-test/peer-stubs/react-relay/package.json
new file mode 100644
index 0000000..33a70c5
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/peer-stubs/react-relay/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "react-relay",
+ "version": "99.0.0",
+ "private": true,
+ "peerDependencies": {
+ "react": "^19.0.0",
+ "relay-runtime": "99.0.0"
+ }
+}
diff --git a/packages/webapp-graphql-relay/consumer-test/peer-stubs/relay-runtime/package.json b/packages/webapp-graphql-relay/consumer-test/peer-stubs/relay-runtime/package.json
new file mode 100644
index 0000000..e2d7d16
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/peer-stubs/relay-runtime/package.json
@@ -0,0 +1,5 @@
+{
+ "name": "relay-runtime",
+ "version": "99.0.0",
+ "private": true
+}
diff --git a/packages/webapp-graphql-relay/consumer-test/relay.config.json b/packages/webapp-graphql-relay/consumer-test/relay.config.json
new file mode 100644
index 0000000..e77fe44
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/relay.config.json
@@ -0,0 +1,6 @@
+{
+ "eagerEsModules": true,
+ "language": "typescript",
+ "schema": "./schema.graphql",
+ "src": "./src"
+}
diff --git a/packages/webapp-graphql-relay/consumer-test/schema.graphql b/packages/webapp-graphql-relay/consumer-test/schema.graphql
new file mode 100644
index 0000000..29268d4
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/schema.graphql
@@ -0,0 +1,8 @@
+type Query {
+ viewer: Viewer!
+}
+
+type Viewer {
+ id: ID!
+ name: String!
+}
diff --git a/packages/webapp-graphql-relay/consumer-test/src/FixtureOperation.ts b/packages/webapp-graphql-relay/consumer-test/src/FixtureOperation.ts
new file mode 100644
index 0000000..4c28a59
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/src/FixtureOperation.ts
@@ -0,0 +1,10 @@
+import { graphql } from "react-relay";
+
+export const fixtureConsumerContractQuery = graphql`
+ query FixtureOperationQuery {
+ viewer {
+ id
+ name
+ }
+ }
+`;
diff --git a/packages/webapp-graphql-relay/consumer-test/src/app.tsx b/packages/webapp-graphql-relay/consumer-test/src/app.tsx
new file mode 100644
index 0000000..9b2bb90
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/src/app.tsx
@@ -0,0 +1,110 @@
+import {
+ createWebappRelayEnvironment,
+ loadRouteQuery,
+ type GraphqlRelayAuthAdapter,
+} from "@omgjs/labkit-webapp-graphql-relay";
+import { Suspense, type ReactElement } from "react";
+import {
+ RelayEnvironmentProvider,
+ usePreloadedQuery,
+ type PreloadedQuery,
+} from "react-relay";
+import type { Client } from "graphql-ws";
+import { createOperationDescriptor, type Disposable } from "relay-runtime";
+import fixtureQuery, {
+ type FixtureOperationQuery,
+} from "./__generated__/FixtureOperationQuery.graphql";
+
+export const EXPECTED_VIEWER_NAME = "Packed Relay consumer";
+
+const auth: GraphqlRelayAuthAdapter = {
+ getAccessToken: () => null,
+ getAuthRequestCredentials: () => "omit",
+ getAuthSession: () => null,
+ hasAuthRequiredGraphqlErrors: () => false,
+ refreshStoredAuthSession: async () => null,
+ subscribeAuthState: () => () => undefined,
+};
+
+const wsClient = {
+ subscribe: () => () => undefined,
+ terminate: () => undefined,
+} as unknown as Client;
+
+function FixtureView({
+ queryReference,
+}: {
+ queryReference: PreloadedQuery;
+}): ReactElement {
+ const data = usePreloadedQuery(fixtureQuery, queryReference);
+ return {data.viewer.name};
+}
+
+export function createFixtureTree(): {
+ dispose(): void;
+ ready: Promise;
+ tree: ReactElement;
+} {
+ const environment = createWebappRelayEnvironment({
+ auth,
+ fetch: async () => ({
+ json: async () => ({
+ data: {
+ viewer: {
+ id: "fixture-viewer",
+ name: EXPECTED_VIEWER_NAME,
+ },
+ },
+ }),
+ ok: true,
+ status: 200,
+ }),
+ httpEndpoint: "https://fixture.invalid/graphql",
+ realtime: {
+ getClient: () => wsClient,
+ },
+ });
+ const abortController = new AbortController();
+ const operation = createOperationDescriptor(fixtureQuery, {});
+ const initialSnapshot = environment.lookup(operation.fragment);
+ let readinessSubscription: Disposable | undefined;
+ const ready = new Promise((resolve) => {
+ if (!initialSnapshot.isMissingData) {
+ resolve();
+ return;
+ }
+
+ readinessSubscription = environment.subscribe(
+ initialSnapshot,
+ (snapshot) => {
+ if (!snapshot.isMissingData) {
+ readinessSubscription?.dispose();
+ readinessSubscription = undefined;
+ resolve();
+ }
+ },
+ );
+ });
+ const queryReference = loadRouteQuery({
+ abortSignal: abortController.signal,
+ environment,
+ fetchPolicy: "network-only",
+ query: fixtureQuery,
+ variables: {},
+ });
+
+ return {
+ dispose: () => {
+ readinessSubscription?.dispose();
+ queryReference.dispose();
+ },
+ ready,
+ tree: (
+
+ Loading fixture}>
+
+
+
+ ),
+ };
+}
diff --git a/packages/webapp-graphql-relay/consumer-test/src/browser.tsx b/packages/webapp-graphql-relay/consumer-test/src/browser.tsx
new file mode 100644
index 0000000..e267271
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/src/browser.tsx
@@ -0,0 +1,11 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { createFixtureTree } from "./app";
+
+const rootElement = document.getElementById("root");
+if (!rootElement) {
+ throw new Error("Fixture root element was not found.");
+}
+
+const fixture = createFixtureTree();
+createRoot(rootElement).render({fixture.tree});
diff --git a/packages/webapp-graphql-relay/consumer-test/src/cjs-smoke.ts b/packages/webapp-graphql-relay/consumer-test/src/cjs-smoke.ts
new file mode 100644
index 0000000..721def3
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/src/cjs-smoke.ts
@@ -0,0 +1,35 @@
+import assert from "node:assert/strict";
+import * as labkit from "@omgjs/labkit-webapp-graphql-relay";
+import * as relayRuntime from "relay-runtime";
+import type { Client } from "graphql-ws";
+
+const wsClient = {
+ subscribe: () => () => undefined,
+ terminate: () => undefined,
+} as unknown as Client;
+
+const environment = labkit.createWebappRelayEnvironment({
+ auth: {
+ getAccessToken: () => null,
+ getAuthRequestCredentials: () => "omit",
+ getAuthSession: () => null,
+ hasAuthRequiredGraphqlErrors: () => false,
+ refreshStoredAuthSession: async () => null,
+ subscribeAuthState: () => () => undefined,
+ },
+ fetch: async () => ({
+ json: async () => ({ data: {} }),
+ ok: true,
+ status: 200,
+ }),
+ httpEndpoint: "https://fixture.invalid/graphql",
+ realtime: {
+ getClient: () => wsClient,
+ },
+});
+
+assert.equal(typeof labkit.loadRouteQuery, "function");
+assert.ok(environment instanceof relayRuntime.Environment);
+process.stdout.write(
+ "CommonJS entrypoint uses the application Relay runtime.\n",
+);
diff --git a/packages/webapp-graphql-relay/consumer-test/src/esm-resolution-check.ts b/packages/webapp-graphql-relay/consumer-test/src/esm-resolution-check.ts
new file mode 100644
index 0000000..6cfe55c
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/src/esm-resolution-check.ts
@@ -0,0 +1,32 @@
+import assert from "node:assert/strict";
+import { readFileSync, realpathSync } from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const LABKIT_PACKAGE = "@omgjs/labkit-webapp-graphql-relay";
+const resolvedEntry = realpathSync(
+ fileURLToPath(import.meta.resolve(LABKIT_PACKAGE)),
+);
+const normalizedEntry = resolvedEntry.replaceAll("\\", "/");
+
+assert.match(
+ normalizedEntry,
+ /\/dist\/esm\/src\/index\.js$/,
+ `exports.import resolved to an unexpected entry: ${resolvedEntry}`,
+);
+
+const esmPackageMarkerPath = path.resolve(
+ path.dirname(resolvedEntry),
+ "..",
+ "package.json",
+);
+const esmPackageMarker = JSON.parse(
+ readFileSync(esmPackageMarkerPath, "utf8"),
+) as { type?: string };
+assert.equal(
+ esmPackageMarker.type,
+ "module",
+ `${esmPackageMarkerPath} is not marked as an ESM package boundary.`,
+);
+
+process.stdout.write(`Native exports.import resolution: ${resolvedEntry}\n`);
diff --git a/packages/webapp-graphql-relay/consumer-test/src/esm-smoke.ts b/packages/webapp-graphql-relay/consumer-test/src/esm-smoke.ts
new file mode 100644
index 0000000..04a47ed
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/src/esm-smoke.ts
@@ -0,0 +1,36 @@
+import assert from "node:assert/strict";
+import {
+ createWebappRelayEnvironment,
+ loadRouteQuery,
+} from "@omgjs/labkit-webapp-graphql-relay";
+import type { Client } from "graphql-ws";
+import { Environment } from "relay-runtime";
+
+const wsClient = {
+ subscribe: () => () => undefined,
+ terminate: () => undefined,
+} as unknown as Client;
+
+const environment = createWebappRelayEnvironment({
+ auth: {
+ getAccessToken: () => null,
+ getAuthRequestCredentials: () => "omit",
+ getAuthSession: () => null,
+ hasAuthRequiredGraphqlErrors: () => false,
+ refreshStoredAuthSession: async () => null,
+ subscribeAuthState: () => () => undefined,
+ },
+ fetch: async () => ({
+ json: async () => ({ data: {} }),
+ ok: true,
+ status: 200,
+ }),
+ httpEndpoint: "https://fixture.invalid/graphql",
+ realtime: {
+ getClient: () => wsClient,
+ },
+});
+
+assert.equal(typeof loadRouteQuery, "function");
+assert.ok(environment instanceof Environment);
+process.stdout.write("ESM entrypoint uses the application Relay runtime.\n");
diff --git a/packages/webapp-graphql-relay/consumer-test/src/render-smoke.tsx b/packages/webapp-graphql-relay/consumer-test/src/render-smoke.tsx
new file mode 100644
index 0000000..7c58977
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/src/render-smoke.tsx
@@ -0,0 +1,34 @@
+import assert from "node:assert/strict";
+import { renderToString } from "react-dom/server";
+import { createFixtureTree, EXPECTED_VIEWER_NAME } from "./app";
+
+const READY_TIMEOUT_MS = 5_000;
+const fixture = createFixtureTree();
+
+try {
+ let timeout: ReturnType | undefined;
+ const readinessTimeout = new Promise((_resolve, reject) => {
+ timeout = setTimeout(() => {
+ reject(
+ new Error(
+ `Relay fixture data was not ready within ${READY_TIMEOUT_MS}ms.`,
+ ),
+ );
+ }, READY_TIMEOUT_MS);
+ });
+ try {
+ await Promise.race([fixture.ready, readinessTimeout]);
+ } finally {
+ clearTimeout(timeout);
+ }
+
+ const html = renderToString(fixture.tree);
+ assert.match(html, new RegExp(EXPECTED_VIEWER_NAME));
+ process.stdout.write(`Rendered fixture payload: ${EXPECTED_VIEWER_NAME}\n`);
+} finally {
+ fixture.dispose();
+}
+
+// Relay schedules internal retention/GC timers. The fixture has disposed every
+// public handle it owns, so do not let those implementation timers hold CI open.
+process.exit(0);
diff --git a/packages/webapp-graphql-relay/consumer-test/src/resolution-check.ts b/packages/webapp-graphql-relay/consumer-test/src/resolution-check.ts
new file mode 100644
index 0000000..b847766
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/src/resolution-check.ts
@@ -0,0 +1,88 @@
+import assert from "node:assert/strict";
+import { existsSync, readFileSync, realpathSync } from "node:fs";
+import path from "node:path";
+import { createRequire } from "node:module";
+
+const LABKIT_PACKAGE = "@omgjs/labkit-webapp-graphql-relay";
+const EXPECTED_RELAY_VERSION = "20.1.1";
+const applicationRequire = createRequire(import.meta.url);
+
+function findPackageRoot(entryPath: string, expectedName: string): string {
+ let currentPath = path.dirname(realpathSync(entryPath));
+ while (currentPath !== path.dirname(currentPath)) {
+ const manifestPath = path.join(currentPath, "package.json");
+ if (existsSync(manifestPath)) {
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
+ name?: string;
+ };
+ if (manifest.name === expectedName) {
+ return currentPath;
+ }
+ }
+ currentPath = path.dirname(currentPath);
+ }
+ throw new Error(
+ `Could not find the ${expectedName} package root from ${entryPath}.`,
+ );
+}
+
+function resolveCanonical(
+ requireFrom: NodeJS.Require,
+ packageName: string,
+): string {
+ return realpathSync(requireFrom.resolve(packageName));
+}
+
+function readPackageVersion(entryPath: string, packageName: string): string {
+ const packageRoot = findPackageRoot(entryPath, packageName);
+ return (
+ JSON.parse(
+ readFileSync(path.join(packageRoot, "package.json"), "utf8"),
+ ) as { version: string }
+ ).version;
+}
+
+const labkitEntry = resolveCanonical(applicationRequire, LABKIT_PACKAGE);
+const labkitRequire = createRequire(labkitEntry);
+const reactRelayEntry = resolveCanonical(applicationRequire, "react-relay");
+const reactRelayRequire = createRequire(reactRelayEntry);
+
+const resolutions: Record> = {
+ "react-relay": {
+ application: reactRelayEntry,
+ labkit: resolveCanonical(labkitRequire, "react-relay"),
+ },
+ "relay-runtime": {
+ application: resolveCanonical(applicationRequire, "relay-runtime"),
+ labkit: resolveCanonical(labkitRequire, "relay-runtime"),
+ reactRelay: resolveCanonical(reactRelayRequire, "relay-runtime"),
+ },
+};
+
+for (const [packageName, packageResolutions] of Object.entries(resolutions)) {
+ const uniquePaths = new Set(Object.values(packageResolutions));
+ assert.equal(
+ uniquePaths.size,
+ 1,
+ `${packageName} resolved to multiple implementations: ${JSON.stringify(packageResolutions)}`,
+ );
+ const applicationResolution = packageResolutions.application;
+ assert.ok(applicationResolution);
+ assert.equal(
+ readPackageVersion(applicationResolution, packageName),
+ EXPECTED_RELAY_VERSION,
+ );
+}
+
+const labkitRoot = findPackageRoot(labkitEntry, LABKIT_PACKAGE);
+for (const packageName of ["react-relay", "relay-runtime"]) {
+ assert.equal(
+ existsSync(path.join(labkitRoot, "node_modules", packageName)),
+ false,
+ `${packageName} is nested privately below the installed Labkit package.`,
+ );
+}
+
+process.stdout.write(
+ `${JSON.stringify({ labkitEntry, resolutions }, null, 2)}\n`,
+);
diff --git a/packages/webapp-graphql-relay/consumer-test/tsconfig.checks.json b/packages/webapp-graphql-relay/consumer-test/tsconfig.checks.json
new file mode 100644
index 0000000..8e9b622
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/tsconfig.checks.json
@@ -0,0 +1,9 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "outDir": "dist-checks",
+ "rootDir": "src"
+ },
+ "include": ["src/esm-resolution-check.ts", "src/resolution-check.ts"]
+}
diff --git a/packages/webapp-graphql-relay/consumer-test/tsconfig.cjs.json b/packages/webapp-graphql-relay/consumer-test/tsconfig.cjs.json
new file mode 100644
index 0000000..9b4dc06
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/tsconfig.cjs.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "ignoreDeprecations": "6.0",
+ "module": "CommonJS",
+ "moduleResolution": "Node",
+ "noEmit": false,
+ "outDir": "dist-cjs",
+ "rootDir": "src"
+ },
+ "include": ["src/cjs-smoke.ts"]
+}
diff --git a/packages/webapp-graphql-relay/consumer-test/tsconfig.json b/packages/webapp-graphql-relay/consumer-test/tsconfig.json
new file mode 100644
index 0000000..c33b1f5
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "jsx": "react-jsx",
+ "lib": ["DOM", "DOM.Iterable", "ES2023"],
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "noEmit": true,
+ "skipLibCheck": false,
+ "strict": true,
+ "target": "ES2023",
+ "types": ["node", "react", "react-dom"]
+ },
+ "include": [
+ "src/**/*.ts",
+ "src/**/*.tsx",
+ "vite.config.ts",
+ "vite.esm.config.ts",
+ "vite.ssr.config.ts"
+ ]
+}
diff --git a/packages/webapp-graphql-relay/consumer-test/vite.config.ts b/packages/webapp-graphql-relay/consumer-test/vite.config.ts
new file mode 100644
index 0000000..7ade7b0
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/vite.config.ts
@@ -0,0 +1,106 @@
+import { existsSync, readFileSync, realpathSync } from "node:fs";
+import path from "node:path";
+import { defineConfig, type Plugin } from "vite";
+
+const EXPECTED_RELAY_VERSION = "20.1.1";
+const LABKIT_PACKAGE_PATH =
+ "/@omgjs/labkit-webapp-graphql-relay/dist/esm/src/index.js";
+const RELAY_PACKAGES = ["react-relay", "relay-runtime"] as const;
+
+function findPackageRoot(moduleId: string, packageName: string): string | null {
+ let currentPath = moduleId.replace(/^\0/, "").split("?", 1)[0];
+ if (!path.isAbsolute(currentPath) || !existsSync(currentPath)) {
+ return null;
+ }
+
+ currentPath = path.dirname(currentPath);
+ while (currentPath !== path.dirname(currentPath)) {
+ const manifestPath = path.join(currentPath, "package.json");
+ if (existsSync(manifestPath)) {
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as {
+ name?: string;
+ };
+ if (manifest.name === packageName) {
+ return realpathSync(currentPath);
+ }
+ }
+ currentPath = path.dirname(currentPath);
+ }
+
+ return null;
+}
+
+function relayModuleIdentityPlugin(): Plugin {
+ return {
+ name: "labkit-relay-module-identity",
+ generateBundle() {
+ const moduleIds = [...this.getModuleIds()];
+ const normalizedModuleIds = moduleIds.map((moduleId) =>
+ moduleId.replaceAll("\\", "/"),
+ );
+ const labkitEsmEntries = normalizedModuleIds.filter((moduleId) =>
+ moduleId.includes(LABKIT_PACKAGE_PATH),
+ );
+ if (labkitEsmEntries.length !== 1) {
+ this.error(
+ `Expected Vite to select one Labkit ESM entry, found ${labkitEsmEntries.length}: ${labkitEsmEntries.join(", ")}`,
+ );
+ }
+ const report: Record = {
+ labkitEsmEntry: labkitEsmEntries[0],
+ };
+
+ for (const packageName of RELAY_PACKAGES) {
+ const roots = new Set();
+ let moduleCount = 0;
+ for (const moduleId of moduleIds) {
+ const root = findPackageRoot(moduleId, packageName);
+ if (root) {
+ roots.add(root);
+ moduleCount += 1;
+ }
+ }
+
+ if (roots.size !== 1) {
+ this.error(
+ `Expected one ${packageName} implementation in the Vite graph, found ${roots.size}: ${[
+ ...roots,
+ ].join(", ")}`,
+ );
+ }
+
+ const [packageRoot] = roots;
+ const manifest = JSON.parse(
+ readFileSync(path.join(packageRoot, "package.json"), "utf8"),
+ ) as { version?: string };
+ if (manifest.version !== EXPECTED_RELAY_VERSION) {
+ this.error(
+ `Expected ${packageName}@${EXPECTED_RELAY_VERSION}, found ${String(manifest.version)}`,
+ );
+ }
+
+ report[packageName] = {
+ moduleCount,
+ packageRoot,
+ version: manifest.version,
+ };
+ }
+
+ this.emitFile({
+ type: "asset",
+ fileName: "relay-module-identity.json",
+ source: `${JSON.stringify(report, null, 2)}\n`,
+ });
+ },
+ };
+}
+
+export default defineConfig({
+ build: {
+ manifest: true,
+ },
+ plugins: [relayModuleIdentityPlugin()],
+ resolve: {
+ preserveSymlinks: false,
+ },
+});
diff --git a/packages/webapp-graphql-relay/consumer-test/vite.esm.config.ts b/packages/webapp-graphql-relay/consumer-test/vite.esm.config.ts
new file mode 100644
index 0000000..cd0eaf1
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/vite.esm.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ build: {
+ emptyOutDir: true,
+ outDir: "dist-esm-smoke",
+ rollupOptions: {
+ output: {
+ entryFileNames: "esm-smoke.js",
+ },
+ },
+ ssr: "src/esm-smoke.ts",
+ },
+ ssr: {
+ noExternal: true,
+ },
+});
diff --git a/packages/webapp-graphql-relay/consumer-test/vite.ssr.config.ts b/packages/webapp-graphql-relay/consumer-test/vite.ssr.config.ts
new file mode 100644
index 0000000..7eb8117
--- /dev/null
+++ b/packages/webapp-graphql-relay/consumer-test/vite.ssr.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ build: {
+ emptyOutDir: true,
+ outDir: "dist-ssr",
+ rollupOptions: {
+ output: {
+ entryFileNames: "render-smoke.js",
+ },
+ },
+ ssr: "src/render-smoke.tsx",
+ },
+ ssr: {
+ noExternal: true,
+ },
+});
diff --git a/packages/webapp-graphql-relay/eslint.config.mjs b/packages/webapp-graphql-relay/eslint.config.mjs
index 5ae59e3..71b1005 100644
--- a/packages/webapp-graphql-relay/eslint.config.mjs
+++ b/packages/webapp-graphql-relay/eslint.config.mjs
@@ -4,4 +4,7 @@ import { createBrowserTypeScriptConfig } from "@omgjs/labkit-eslint-config";
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
-export default createBrowserTypeScriptConfig({ tsconfigRootDir });
+export default createBrowserTypeScriptConfig({
+ extraIgnores: ["consumer-test/**"],
+ tsconfigRootDir,
+});
diff --git a/packages/webapp-graphql-relay/package.json b/packages/webapp-graphql-relay/package.json
index 16c3194..4f95387 100644
--- a/packages/webapp-graphql-relay/package.json
+++ b/packages/webapp-graphql-relay/package.json
@@ -40,9 +40,7 @@
"dependencies": {
"@omgjs/labkit-auth-contract": "workspace:*",
"@omgjs/labkit-webapp-realtime": "workspace:*",
- "graphql-ws": "^6.0.8",
- "react-relay": "^20.1.1",
- "relay-runtime": "^20.1.1"
+ "graphql-ws": "^6.0.8"
},
"devDependencies": {
"@omgjs/labkit-eslint-config": "workspace:*",
@@ -51,9 +49,13 @@
"@types/relay-runtime": "^20.1.1",
"eslint": "^10.3.0",
"react": "^19.2.5",
+ "react-relay": "20.1.1",
+ "relay-runtime": "20.1.1",
"typescript": "^6.0.3"
},
"peerDependencies": {
- "react": "^16.9.0 || ^17 || ^18 || ^19"
+ "react": "^16.9.0 || ^17 || ^18 || ^19",
+ "react-relay": "20.1.1",
+ "relay-runtime": "20.1.1"
}
}
diff --git a/tasks/2026-08-11-1124_FIX_RELAY_PEER_RUNTIME_CONTRACT.md b/tasks/2026-08-11-1124_FIX_RELAY_PEER_RUNTIME_CONTRACT.md
new file mode 100644
index 0000000..56e324f
--- /dev/null
+++ b/tasks/2026-08-11-1124_FIX_RELAY_PEER_RUNTIME_CONTRACT.md
@@ -0,0 +1,478 @@
+# Fix Relay Peer Runtime Contract
+
+Status: in progress; implementation and CI release authorized on 2026-08-11.
+Created: 2026-08-11
+Classification: packaging/runtime bug with a breaking public package-contract
+change.
+Primary package: `@omgjs/labkit-webapp-graphql-relay`.
+Release guidance: major Rush change; the current `2.0.0` package is expected to
+become `3.0.0` when CI publishes the completed change.
+Related follow-up:
+[Harden TanStack Router and Relay Route-Query Lifecycles](./2026-08-11-1124_HARDEN_TANSTACK_RELAY_ROUTE_LIFECYCLES.md).
+
+## Decision Summary
+
+`@omgjs/labkit-webapp-graphql-relay` creates Relay environments and preloaded
+query references that are consumed by application-owned React providers and
+hooks. Before this fix, the package installed `react-relay` and `relay-runtime`
+as private production dependencies. A consumer could therefore execute one
+Relay implementation inside Labkit and another in application code.
+
+The correction is to make the consumer own one explicitly supported Relay
+version pair:
+
+- move `react-relay` and `relay-runtime` from `dependencies` to required,
+ non-optional `peerDependencies`;
+- keep the same pair in `devDependencies` so Labkit can build and test itself;
+- initially support the exact `20.1.1`/`20.1.1` pair;
+- prove canonical module identity through publish-equivalent consumer tests;
+- document direct consumer installation and migration;
+- release the new dependency expectation as a major package-contract change.
+
+Do not use a Vite alias, package-manager override, or application-specific
+dedupe setting as the library fix. Such settings may be diagnostic tools, but
+the published package must not own a private stateful Relay graph.
+
+## Confirmed Repository Evidence
+
+- Before this fix, the
+ [package manifest](../packages/webapp-graphql-relay/package.json) declared
+ `react-relay` and `relay-runtime` as `^20.1.1` production dependencies. The
+ range resolved to 20.1.1 in the Rush lockfile.
+- The [package source](../packages/webapp-graphql-relay/src/index.ts) imports
+ runtime values from both packages, creates Relay `Environment` instances,
+ and calls React Relay `loadQuery` through `loadRouteQuery`.
+- Public option and return types also reference Relay types, so type-resolution
+ behavior is part of the consumer contract.
+- `react-relay@20.1.1` depends on exactly `relay-runtime@20.1.1` in
+ [the lockfile](../common/config/rush/pnpm-lock.yaml). Two independent broad
+ peer ranges would not express that correlated pair requirement.
+- Labkit already declares React as a peer and development dependency in the
+ package manifest.
+- The pre-fix metadata contradicted the
+ [package groups documentation](../docs/package-groups.md), which assigns
+ React and Relay ownership to the application.
+- The package publishes CommonJS, ESM, and declaration entrypoints. All three
+ consumer surfaces must resolve the consumer-owned Relay pair.
+
+## Goals
+
+- Prevent supported consumers from installing or bundling a Labkit-private
+ `react-relay` or `relay-runtime` implementation.
+- Make the exact supported Relay pair explicit in package metadata, tests, and
+ documentation.
+- Produce a deterministic diagnostic before application rendering when a
+ consumer selects an unsupported or mismatched pair, according to the tested
+ package-manager mode.
+- Verify the packed npm artifact rather than relying only on workspace links or
+ source-level unit tests.
+- Prove that Labkit-created environments and query references interoperate with
+ application-owned `RelayEnvironmentProvider`, `usePreloadedQuery`, and Relay
+ runtime imports.
+- Preserve both CommonJS and ESM package entrypoints.
+- Give existing consumers an actionable migration path.
+- Prepare the correct Rush release metadata and let the repository's post-merge
+ CI publish the package.
+
+## Non-Goals
+
+- Do not claim Relay 21 or any other additional Relay pair is supported.
+- Do not use a broad or multi-major peer range until every admitted pair and
+ cross-pair combination has an enforceable compatibility strategy.
+- Do not redesign `loadRouteQuery` ownership, TanStack Router cache behavior,
+ browser history, retry behavior, or multi-query route lifecycles here. Those
+ belong to the related lifecycle task.
+- Do not add TanStack Router to the public package dependencies or peers.
+- Do not make `@types/react-relay`, `@types/relay-runtime`, or
+ `relay-compiler` runtime peers merely because a fixture needs them. Audit and
+ document their consumer development-time role separately.
+- Do not add bundler aliases, package-manager overrides, or forced hoisting as
+ the supported production solution.
+- Do not change unrelated GraphQL, authentication, or realtime behavior.
+- Do not manually edit package versions or changelogs.
+- Do not publish directly from the local workspace. Repository commits, pull
+ requests, and the configured post-merge CI release are in scope for this
+ execution.
+- Do not hand-edit generated Docusaurus docs or archived documentation.
+
+## Architecture And Contract Rules
+
+### Required peers
+
+Both Relay packages are required peers because the source imports them
+unconditionally. Marking either peer optional would replace a dependency
+diagnostic with a missing-module or incompatible-runtime failure.
+
+The initial supported pair is:
+
+| Package | Supported version |
+| --------------- | ----------------- |
+| `react-relay` | `20.1.1` |
+| `relay-runtime` | `20.1.1` |
+
+Use exact peer and development versions for this release. If independent
+testing proves another pair should be supported, stop and update the contract
+design before widening metadata. A range such as `^20.1.1` or a union of two
+majors cannot by itself guarantee that `react-relay` and the consumer's direct
+`relay-runtime` selection remain a matched pair.
+
+Keep the existing React peer unless the supported-pair tests prove its current
+range is incompatible. The fixture must select a React version inside the
+intersection of Labkit's and React Relay's declared peer ranges.
+
+### Meaning of runtime coherence
+
+For a supported consumer, all of the following must resolve the same canonical
+implementation of each Relay package:
+
+- application imports;
+- Labkit's CommonJS entrypoint;
+- Labkit's ESM entrypoint;
+- the `react-relay` dependency on `relay-runtime`;
+- the Vite production module graph.
+
+Peer metadata expresses the supported contract, but it cannot defeat consumer
+flags that ignore peers, aliases, overrides, or package managers that only warn
+on conflicts. Scope the guarantee to consumers that satisfy the required peers
+without such escape hatches.
+
+### Package-manager behavior
+
+Pin and record every package-manager mode in the fixture. At minimum cover:
+
+- npm as bundled with the repository's supported Node 24 runtime;
+- pnpm 10.33.2 with its normal isolated linker behavior;
+- strict-peer modes for deterministic negative-case failure.
+
+A normal unsupported install may either fail or emit a captured, actionable
+peer diagnostic depending on the manager and configuration. A strict negative
+fixture must fail before typecheck, build, or render. Do not assert that every
+package manager always hard-fails by default.
+
+## Publish-Equivalent Consumer Fixture
+
+Create package-owned private fixture sources outside the package's compiled
+`test` tree, for example:
+
+`packages/webapp-graphql-relay/consumer-test`
+
+The fixture and its runner must not be included in the published package. Do
+not place TypeScript fixture sources under the current `test/**/*.ts` include,
+because those files compile into `dist` and the package publishes
+`dist/**/*`.
+
+Add one stable repository command that:
+
+1. builds the publishable packages;
+2. creates a temporary release directory and independent `git clone --no-local`
+ under an exact `mktemp -d` root, then overlays the current checkout without
+ sharing mutable package sources or Git remotes;
+3. uses Rush 5.175.0's tarball flow only inside that disposable clone
+ (`rush publish --publish --pack --include-all --release-folder `),
+ which requires `--publish` to execute but diverts output to local tarballs
+ because `--pack` is present;
+4. keeps the executable publish-mode command isolated as defense in depth;
+ Rush 5.175.0's `--include-all --pack` branch does not apply pending change
+ files, but a future command regression or accidental flag must not mutate
+ the live checkout;
+5. never passes apply, commit, tagging, or registry flags, and confirms from
+ Rush output, source inspection, and the temporary release directory that
+ the command only invokes `pnpm pack` and does not publish to a registry;
+6. installs the local tarball and its local first-party dependency closure into
+ clean consumer directories;
+7. runs the npm and pnpm matrices without workspace linking;
+8. removes only the exact clone and temporary directories it created;
+9. preserves useful install logs as CI artifacts when a case fails.
+
+The consumer must import Labkit from its package root. It must not import
+`src`, use a workspace symlink, or silently substitute a registry copy of the
+package under test. Inspect the packed manifest to confirm Rush replaced every
+`workspace:*` reference appropriately.
+
+Keep the orchestration outside a nested Rush bulk command if Rush cannot safely
+invoke its pack flow recursively. Prefer a root script and a dedicated PR
+validation step after the normal Rush build/test/verify sequence.
+
+### Fixture application
+
+Use a small, domain-neutral React/Vite application containing:
+
+- one minimal GraphQL schema;
+- one fixture-owned Relay operation and compiler-generated artifact;
+- a deterministic in-memory or mocked GraphQL transport;
+- a Labkit-created Relay environment;
+- `loadRouteQuery` using the default React Relay loader;
+- an application-owned `RelayEnvironmentProvider`;
+- an application-owned component that reads the reference with
+ `usePreloadedQuery`;
+- an assertion that a known non-null field renders successfully.
+
+Pin compatible `react`, `react-dom`, `relay-compiler`,
+`@types/react-relay`, and `@types/relay-runtime` versions in the fixture. Do not
+use a production service, product schema, network sleeps, warmed store, or
+application-specific authentication.
+
+### Required consumer matrix
+
+| ID | Install shape | Expected result |
+| --- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| S1 | npm with both exact supported peers declared directly | Clean install, typecheck, CJS/ESM checks, Vite build, and render smoke pass without peer diagnostics |
+| S2 | isolated pnpm with both exact supported peers declared directly | Same result as S1 and one canonical Relay graph |
+| S3 | strict npm and pnpm modes with the supported pair | Both installs succeed without missing or invalid peers |
+| N1 | one synthetic or pinned unsupported coherent pair | Normal mode fails or emits the expected peer diagnostic; strict mode fails before build/render |
+| N2 | `react-relay` and `relay-runtime` selected from different pairs | The mismatch is diagnosed; strict mode fails before build/render |
+| N3 | one required Relay peer omitted | Missing-peer behavior is recorded, including manager auto-installation where applicable; the documented contract still requires a direct dependency |
+
+Do not use `--force`, legacy peer modes, aliases, overrides, hoisted linkers, or
+manual edits of installed packages to make a matrix case pass.
+
+Prefer tiny local packages with the Relay package names and deliberately
+unsupported versions for peer-resolution-only negative cases. Never execute
+those stubs. This keeps the permanent diagnostic test independent of an
+unvalidated or later-unpublished Relay release.
+
+### Resolution assertions
+
+For each supported install:
+
+- record package-manager dependency output;
+- resolve both Relay package paths from the application and from the installed
+ Labkit package context;
+- resolve `relay-runtime` from the installed `react-relay` context;
+- compare canonical real paths and versions;
+- assert there is no nested private Relay implementation below Labkit;
+- assert the Vite production graph contains only the supported implementation;
+- run a CommonJS require smoke test;
+- run an ESM import smoke test;
+- compile a consumer with `skipLibCheck` disabled;
+- run the real provider/query-hook rendering smoke test.
+
+Do not infer identity solely from matching version strings or successful
+rendering.
+
+## Work Plan
+
+### Phase 1 - Independent Baseline And Artifact Harness
+
+- [x] Read `AGENTS.md`, `.ai/conventions.md`, `.ai/architecture.md`, and the
+ task-specific task-file, documentation, and library-release rules before
+ implementation.
+- [x] Read the current package manifest, source imports, lockfile, entrypoints,
+ package docs, and release rules before editing.
+- [x] Inspect `git status --short` and preserve unrelated changes.
+- [x] Record the exact Node, npm, Rush, pnpm, React, and Relay versions used by
+ the repository.
+- [x] Capture the current workspace and packed dependency graphs, clearly
+ distinguishing manifest ranges from resolved versions.
+- [x] Establish the publish-equivalent pack command and prove that local
+ first-party tarballs, rather than registry or workspace copies, are under
+ test.
+- [x] Add the isolated consumer runner, fixture schema, operation, generated
+ artifact, and deterministic transport.
+- [x] Reproduce the current private-versus-consumer graph at the package
+ resolution boundary before changing metadata.
+- [x] Keep pre-fix diagnostic evidence, but do not make an unsupported runtime
+ rendering failure the permanent post-fix expectation.
+
+Phase 1 is complete when the repository can deterministically inspect and
+exercise the publish-equivalent artifact outside the Rush workspace graph.
+
+### Phase 2 - Correct Package Metadata
+
+- [x] Remove `react-relay` and `relay-runtime` from production dependencies.
+- [x] Add both as required exact `20.1.1` peer dependencies.
+- [x] Add both as exact `20.1.1` development dependencies.
+- [x] Preserve the compatible React peer and development setup.
+- [x] Regenerate Rush/pnpm install state using repository commands; do not hand
+ edit the lockfile.
+- [x] Build CommonJS, ESM, and declarations and confirm they retain bare Relay
+ imports resolved by the consumer.
+- [x] Inspect the tarball manifest and contents.
+- [x] Confirm that satisfying peers does not install a Labkit-private Relay
+ implementation.
+
+Phase 2 is complete when package metadata and the packed artifact express the
+same exact, consumer-owned Relay contract.
+
+### Phase 3 - Permanent Contract Tests And CI
+
+- [x] Implement every supported and negative matrix row.
+- [x] Add canonical path/version assertions for the application, Labkit,
+ React Relay, CommonJS, ESM, and Vite boundaries.
+- [x] Add consumer typechecking with strict library checks.
+- [x] Add the real provider/load/read rendering smoke test.
+- [x] Ensure the supported cases produce no peer warning or unexpected console
+ error.
+- [x] Ensure strict negative cases stop before rendering.
+- [x] Add a stable root command for the packed-consumer contract.
+- [x] Wire the command into PR validation outside any unsafe nested Rush
+ invocation.
+- [x] Keep the fast existing unit tests unchanged except where metadata or
+ fixture support requires a focused correction.
+
+Phase 3 is complete when a private dependency regression, unsupported pair, or
+entrypoint-specific split fails CI deterministically.
+
+### Phase 4 - Documentation And Migration
+
+Update the current source documentation:
+
+- [x] [Package README](../packages/webapp-graphql-relay/README.md): direct
+ install command, exact supported pair, singleton/coherence rule, and
+ package-manager diagnostic limitations.
+- [x] [Package reference](../docs/packages/webapp-graphql-relay.md): required
+ peers, supported versions, types/compiler expectations, and migration.
+- [x] [Package groups](../docs/package-groups.md): make the typical browser
+ install consistent with application-owned framework dependencies.
+- [x] [Quick-start install](../docs/quick-start/README.md): pin the compatible
+ runtime pair and align development-time Relay tooling.
+- [x] Review other current Relay installation or composition pages and update
+ only statements affected by this contract.
+- [x] Explain how consumers can inspect their graph with their package manager,
+ align direct dependencies, remove temporary overrides, and verify one
+ canonical pair.
+- [x] State that unsupported pairs are not compatibility targets merely because
+ a package manager allows installation with a warning.
+
+At task creation the documentation line is already `vNext`. Recheck before
+implementation. Do not create or edit archived docs unless a new documentation
+release line is intentionally started and the required tag preflight succeeds.
+Do not hand-edit `website-docusaurus/docs`.
+
+Phase 4 is complete when the package manifest, package README, root docs, and
+consumer fixture describe the same contract.
+
+### Phase 5 - Rush Release And Validation
+
+- [x] Create a major Rush change for only
+ `@omgjs/labkit-webapp-graphql-relay` with a message describing the new
+ required Relay peer contract and migration.
+- [x] Do not manually change the package version or generated changelogs.
+- [x] Verify Rush change metadata against the target branch without fetching or
+ committing through Rush.
+- [x] Run the focused package and packed-consumer commands while developing.
+- [x] Run the full required validation listed below.
+- [x] Record the exact supported pair, matrix results, tarball contents, and
+ any manager-specific diagnostic differences in this task.
+- [ ] Leave npm publication to the repository's post-merge CI release flow;
+ do not invoke a credentialed local publish.
+- [ ] After publication, verify a clean external consumer installs the expected
+ major version and one Relay graph without an override or alias.
+
+Phase 5 is complete only after CI publishes the release and clean external npm
+and pnpm consumers verify the published artifact. Local publication is not part
+of implementation completion.
+
+## Execution Record
+
+Implementation and the final packed-consumer run used Node `24.18.0`, npm
+`11.16.0`, Rush `5.175.0`, and the repository-pinned pnpm `10.33.2`. The
+workspace resolved React `19.2.6`; the external fixture pinned React and React
+DOM `19.2.5`. Every supported case used the exact
+`react-relay@20.1.1`/`relay-runtime@20.1.1` pair.
+
+The runner built in an independent `git clone --no-local`, removed its remote,
+and used Rush's `--publish --pack` branch only inside that clone. It verified
+that the live package manifest, changelogs, and Rush change files stayed
+byte-identical. A loopback scoped registry served the packed first-party
+runtime closure, and both package-manager lockfiles were checked for the local
+tarball URLs and SHA-512 integrity values. The target tarball contained the
+CommonJS entrypoint, ESM entrypoint and `type: module` marker, declarations, and
+README; it contained neither `consumer-test` nor a `workspace:` specifier. The
+packed manifest contained the exact required peers and exact development
+dependencies, with no production, optional, bundled, or optional-peer Relay
+declaration.
+
+Both normal npm and isolated pnpm supported consumers passed Relay compilation,
+strict TypeScript compilation with `skipLibCheck: false`, native package export
+resolution, CommonJS execution, Vite ESM execution and singleton graph checks,
+a browser production build, and a real provider/load/hook render. Strict npm
+and strict isolated pnpm supported installs also passed without a peer
+diagnostic.
+
+The final negative install matrix was:
+
+| Case | npm normal | npm strict | pnpm normal | pnpm strict |
+| -------------------------- | -------------- | -------------- | -------------- | ----------- |
+| Unsupported coherent pair | Failed | Failed | Warned | Failed |
+| Mismatched pair | Failed | Failed | Warned | Failed |
+| Omitted `react-relay` peer | Auto-installed | Auto-installed | Auto-installed | Failed |
+
+Auto-install behavior does not change the documented requirement that the
+application declare both exact peers directly. No negative fixture proceeded
+to typecheck, build, or render.
+
+Local repository validation on 2026-08-11 produced these results:
+
+- `npm run rush:install` passed. It retained the existing, non-failing
+ `@nestjs/apollo` playground-plugin peer warning in `server-graphql`.
+- `npm run rush:build` and `npm run rush:lint` passed for all 15 projects.
+- Serialized `rush test` and `rush verify` passed for all 15 projects.
+- `npm run site:docusaurus:check` passed its sync, typecheck, and production
+ build.
+- `trunk check -a -y` ran but returned the repository's pre-existing findings:
+ Dockerfile `hadolint` rules, `pinact` parse failures for older workflow action
+ tags, generated changelog Markdown style, and dependency advisories in the
+ Rush and Docusaurus lockfiles. Its unrelated autoformats were reverted.
+- A follow-up Trunk check of all files changed by this task passed after
+ excluding only the repository-wide `pinact`, `osv-scanner`, and `grype`
+ findings. The newly added upload action is pinned to the verified v7 commit
+ and is not one of the remaining `pinact` failures.
+- After the implementation commit, the target-branch/no-fetch Rush change
+ verification found the one package-scoped major change file. A read-only
+ `rush publish` preview selected no other update and resolved
+ `@omgjs/labkit-webapp-graphql-relay` from `2.0.0` to `3.0.0`.
+
+## Validation
+
+Run focused package and consumer checks first. Before handoff, run:
+
+```bash
+npm run rush:install
+npm run rush:build
+npm run rush:lint
+node common/scripts/install-run-rush.js test --parallelism 1
+node common/scripts/install-run-rush.js verify --parallelism 1
+npm run site:docusaurus:check
+trunk check -a -y
+```
+
+Also run the new packed-consumer command independently and verify the Rush
+change file with the repository's target-branch/no-fetch flow. Report any
+unavailable command or unrelated failure as a validation gap rather than
+hiding it.
+
+## Acceptance Criteria
+
+- [x] The packed manifest declares both Relay packages only as required peers
+ plus development dependencies, not private production dependencies.
+- [x] The initial supported pair is exactly 20.1.1/20.1.1 and no broader
+ compatibility is claimed.
+- [x] A publish-equivalent local tarball, not a workspace/source link, is the
+ authority for consumer assertions.
+- [x] The packed fixture uses local first-party artifacts and cannot silently
+ substitute the registry package under test.
+- [x] Clean npm and isolated pnpm consumers that declare the supported pair
+ install without peer diagnostics.
+- [x] Unsupported and mismatched cases produce the documented manager-specific
+ diagnostics and fail in strict mode before build/render; omitted-peer
+ behavior is recorded explicitly when a manager auto-installs the exact
+ peer instead of diagnosing it.
+- [x] Application, Labkit, React Relay, CommonJS, ESM, and Vite resolve one
+ canonical implementation for each Relay package in supported installs.
+- [x] Consumer typechecking passes with `skipLibCheck` disabled.
+- [x] A real application provider and hook consume a Labkit-created environment
+ and query reference successfully.
+- [x] The Vite production build contains no second Relay graph.
+- [x] Package unit, type, build, lint, and verify checks pass.
+- [x] Package README, current root docs, and migration guidance match the
+ released dependency contract.
+- [x] A major Rush change exists only for the affected public package.
+- [x] No package version, changelog, generated current-doc copy, or archived doc
+ was edited manually.
+- [x] The full repository validation and Trunk cleanup pass, or every remaining
+ gap is reported precisely.
+- [ ] The task records post-release clean-consumer verification without
+ performing a local publish.
+- [ ] This file is moved to `tasks/completed` only after all applicable items
+ are complete.
diff --git a/tasks/2026-08-11-1124_HARDEN_TANSTACK_RELAY_ROUTE_LIFECYCLES.md b/tasks/2026-08-11-1124_HARDEN_TANSTACK_RELAY_ROUTE_LIFECYCLES.md
new file mode 100644
index 0000000..9a56515
--- /dev/null
+++ b/tasks/2026-08-11-1124_HARDEN_TANSTACK_RELAY_ROUTE_LIFECYCLES.md
@@ -0,0 +1,515 @@
+# Harden TanStack Router And Relay Route-Query Lifecycles
+
+Status: planned; begin with characterization and a public-contract decision
+gate.
+Created: 2026-08-11
+Classification: integration hardening and compatibility improvement. It is not
+a confirmed library bug at task creation.
+Prerequisite:
+[Fix Relay Peer Runtime Contract](./2026-08-11-1124_FIX_RELAY_PEER_RUNTIME_CONTRACT.md).
+Release guidance: determine after the contract gate; tests alone do not require
+a public package release.
+
+## Decision Summary
+
+Build a deterministic, real TanStack Router/React/Relay integration matrix for
+the documented `loadRouteQuery` path. The matrix must characterize ownership
+before changing public source.
+
+TanStack routing remains application-owned. Keep TanStack dependencies in a
+private fixture unless evidence proves that a reusable public adapter is
+necessary. If the correct lifecycle cannot be expressed with the existing
+helper and a documented application-owned route recipe, stop at the public API
+gate and obtain maintainer approval before adding exports, changing signatures,
+or introducing a public TanStack dependency/package.
+
+This task is reclassified only from evidence:
+
+- it is a library bug if the documented helper fails in an explicitly
+ supported, single-runtime configuration;
+- it remains an improvement if the helper works but realistic coverage and
+ ownership documentation are missing;
+- it becomes an API feature/change if a new lifecycle controller, ownership
+ handle, or router adapter is required.
+
+Do not choose a Rush bump type until that outcome is recorded.
+
+## Prerequisite And Baseline
+
+Complete the Relay peer runtime contract first. Before interpreting any route
+lifecycle failure, the fixture must prove that application code, Labkit,
+`react-relay`, and `relay-runtime` resolve one canonical supported graph.
+
+The lifecycle fixture must fail its preflight on an unsupported or duplicated
+Relay runtime. A split or mismatched graph is package-contract evidence, not
+lifecycle evidence.
+
+At implementation time, record exact versions of:
+
+- Node and the selected test runner;
+- React and React DOM;
+- `react-relay`, `relay-runtime`, and `relay-compiler`;
+- `@tanstack/react-router`;
+- Vite and any browser automation or DOM harness.
+
+Read the official documentation for those exact versions and record the
+meanings of loader abort, preload promotion/expiry, `staleTime`,
+`preloadStaleTime`, `gcTime`, invalidation, history restoration, and cache
+clearing. Do not encode assumptions from another version.
+
+## Repository Context
+
+The current public helper in
+[the Relay package source](../packages/webapp-graphql-relay/src/index.ts):
+
+1. calls React Relay `loadQuery`;
+2. returns one `PreloadedQuery`;
+3. registers an idempotent disposal callback on one caller-provided abort
+ signal.
+
+The focused tests in
+[the package test](../packages/webapp-graphql-relay/test/index.test.ts) inject
+fake query loaders and prove abort disposal. They do not mount a real
+`RouterProvider`, `RelayEnvironmentProvider`, or `usePreloadedQuery` consumer,
+and they do not characterize router cache/history ownership.
+
+Labkit's [browser architecture](../docs/architecture.md) assigns routes,
+generated operations, hooks, and product behavior to the application. Labkit
+owns reusable Relay runtime mechanics and the documented behavior of its
+helper. The integration contract must preserve that boundary.
+
+## Goals
+
+- Define an evidence-backed ownership model for Relay query references created
+ by TanStack loaders.
+- Exercise the actual package root with a real router, React root, Relay
+ environment/provider, generated operation, and `usePreloadedQuery`.
+- Cover direct entry, full reload, in-app navigation, rapid replacement,
+ back/forward, preload promotion/expiry, invalidation, route errors, retry,
+ route abort, cache expiry, and final teardown.
+- Cover one reference and two independently completing references owned by one
+ route.
+- Run selected ownership transitions under React Strict Mode.
+- Prove that active consumers never receive a disposed reference under the
+ selected application-owned cache policy.
+- Prove that abandoned integration-owned references are eventually disposed at
+ most once.
+- Replace timing races with controlled network and lifecycle events.
+- Document supported router settings and the boundary between application and
+ Labkit responsibility.
+- Keep the permanent matrix small enough to be deterministic by testing each
+ distinct ownership transition rather than a full Cartesian product.
+
+## Non-Goals
+
+- Do not fix Relay package duplication or version compatibility in this task.
+- Do not depend on a product repository, product routes, authentication flow,
+ production GraphQL service, or domain schema.
+- Do not make TanStack Router a public dependency or peer of
+ `@omgjs/labkit-webapp-graphql-relay` without approval.
+- Do not silently broaden the current abort helper into a router cache manager.
+- Do not add a public export, change the current signature/semantics, or create
+ a public adapter package before the contract gate is approved.
+- Do not rely on arbitrary sleeps, network throttling, warmed Relay state,
+ private `PreloadedQuery` fields, or undocumented router internals.
+- Do not test every framework patch version or every possible cache setting.
+- Do not add SSR, streaming, hydration, React Server Components, or TanStack
+ Start coverage.
+- Do not redesign authentication, realtime, or general GraphQL transport
+ behavior.
+- Do not publish locally, manually edit versions/changelogs, or hand-edit
+ generated/archived documentation.
+
+## Ownership Model To Prove
+
+Treat network completion and resource ownership as separate concepts. Every
+query reference must follow one observable lifecycle:
+
+1. **Created**: a loader or preload receives a new reference.
+2. **Pending/preloaded**: an unresolved navigation or preload owns it.
+3. **Committed/active**: a mounted route can consume it.
+4. **Cached/replaced**: it is either intentionally kept live for reuse or is
+ replaced before another reference becomes visible.
+5. **Released**: it is disposed once and can never become visible again.
+
+The selected integration must establish these invariants:
+
+- a released reference is terminal;
+- repeated abort, leave, unmount, retry, cache, and teardown signals remain
+ idempotent;
+- predecessor UI retains a usable predecessor reference while replacement work
+ is pending if that UI remains mounted;
+- superseded pending work cannot commit after a late response;
+- a cached match contains a live reference or forces replacement before render;
+- an unused/expired preload is eventually released;
+- a promoted preload is not unnecessarily duplicated;
+- retry never reuses a failed or released reference;
+- each reference in a multi-query route has independent accounting;
+- partial construction/failure releases already-created siblings according to
+ the approved policy;
+- final router/provider teardown releases all integration-owned work,
+ listeners, and subscriptions;
+- correctness does not depend on response order or wall-clock delays.
+
+The statement "mounted consumers never receive disposed references" is an
+invariant of the tested reference integration, including its app-owned signal
+and cache policy. Do not attribute that entire guarantee to the current low-level
+helper unless the contract gate explicitly establishes it.
+
+## Private Fixture Architecture
+
+Create a dedicated, non-published Rush project at a depth allowed by
+`rush.json`, for example:
+
+`tools/webapp-graphql-relay-lifecycle-fixture`
+
+The project must:
+
+- use `private: true` and be intentionally excluded from publishing/version
+ policy;
+- use the `tools` review category and participate in Rush build/test/verify;
+- depend on the workspace Relay package through its public package entrypoint,
+ never `src`;
+- pin exact framework/test versions;
+- keep all TanStack, DOM, Vite, and browser dependencies private;
+- own its minimal schema, Relay compiler configuration, operations, and
+ generated artifacts;
+- expose no public exports;
+- use a deterministic controlled GraphQL transport;
+- provide a stable local command and CI entrypoint.
+
+Reuse only neutral infrastructure from the packed-consumer fixture. Do not make
+this large matrix part of the published package or its compiled `dist` tree.
+
+### Two test levels
+
+1. **Fast deterministic integration layer**
+ - real TanStack memory history and `RouterProvider`;
+ - real React root and selected Strict Mode cases;
+ - real Relay environment/provider/hook;
+ - controlled network observables;
+ - deterministic DOM harness;
+ - fake timers only for documented cache/preload timers, advanced explicitly.
+2. **Browser/Vite acceptance layer**
+ - Vite production/reference build;
+ - a headless browser for fresh direct entry, actual document reload,
+ back/forward, and teardown cases that a memory DOM cannot prove;
+ - the smallest representative subset of the matrix;
+ - a dedicated PR workflow step that installs only the required browser if
+ the repository has no existing browser runner.
+
+Before adding a new browser stack, inspect current tooling and record why it is
+needed. Real reload/history requirements may not be replaced by a synthetic
+component remount without documenting the reduced guarantee.
+
+### Neutral route tree
+
+Use a minimal route tree containing:
+
+- a landing route with no query;
+- a parameterized single-query route;
+- a parameterized two-query route;
+- a route-level error/retry boundary;
+- navigation controls used by browser acceptance.
+
+Routes, loader retention, retry UI, and cache settings remain fixture-owned
+application examples. Generated operations must use neutral fields and no
+product data.
+
+## Deterministic Harness And Evidence
+
+Build one reusable harness with a fresh router, history, DOM/root, Relay
+environment/store, and network for every test. Provide explicit controls to:
+
+- start, resolve, reject, complete, and cancel operations by name and variables;
+- advance documented cache/preload timers;
+- navigate, preload, invalidate, retry, go back/forward, clear cache, and
+ teardown;
+- resolve two queries in either order;
+- abandon work before, between, and after responses.
+
+Record a lifecycle ledger with stable IDs:
+
+- test run;
+- navigation/history action;
+- router match and loader invocation;
+- query reference;
+- network subscription;
+- mounted consumer.
+
+Record ordered events for loader start/finish, reference creation, abort,
+network subscribe/next/error/complete/cancel, mount/unmount, render/read,
+cache/retry action, and disposal where observable. Assert event ordering rather
+than elapsed time. Never record credentials, full payloads, or personal data.
+
+Use complementary observability:
+
+- controlled `RouteQueryLoader` contract tests may count disposal exactly;
+- real Relay integration tests must use the default loader and assert only
+ public outcomes and safely observable network/store behavior;
+- do not inspect undocumented query-reference fields merely to make an exact
+ counter possible.
+
+Fail on unexpected console output, React warnings, uncaught errors, unhandled
+rejections, work outside `act`, or open handles after teardown.
+
+## Required Lifecycle Matrix
+
+Do not generate a full Cartesian product. Implement every distinct transition
+below and apply alternate completion order/Strict Mode only where specified.
+
+| ID | Scenario | Required outcome |
+| --- | --- | --- |
+| B1 | Direct initial entry; one query suspends then succeeds | Correct data renders; the reference stays live while mounted |
+| B2 | Full browser reload on the single-query URL | A fresh runtime/reference renders the same correct identity without warmed state |
+| B3 | Signal already aborted and repeated abort notifications | The reference never renders and disposal is idempotent |
+| N1 | Rendered A navigates to B while B is pending | A remains usable while mounted; B commits correctly; A is eventually released |
+| N2 | Pending A is superseded by B | A is canceled/released; a late A response cannot commit |
+| N3 | Rapid A(1) -> A(2) -> B with out-of-order responses | Only the final match commits; all superseded work is released |
+| N4 | Route param or search dependency changes | Old/new identities cannot be confused and replacement follows the selected policy |
+| H1 | A -> B -> browser back inside the fresh-cache window | Back reuses a proven-live reference or reloads before render, exactly as documented |
+| H2 | Browser forward after H1 | Forward follows the same ownership/cache policy |
+| H3 | Return after cache expiry or explicit clear | A new reference is created; an expired/released reference is never reused |
+| H4 | Supported stale revalidation | Active data remains usable until replacement commits; no early release occurs |
+| P1 | Pending preload is promoted by navigation | The documented promotion/deduplication behavior occurs without early release |
+| P2 | Completed preload is promoted | Preloaded data renders under the documented freshness policy |
+| P3 | Preload is canceled or expires unused | It never renders and is eventually released |
+| R1 | Query failure, route error, then retry succeeds | Retry creates valid new work; failed work is not reused or leaked |
+| R2 | Active route invalidation/replacement | The current route follows the documented pending/replacement policy safely |
+| R3 | Retry/invalidation is superseded by navigation | Superseded retry work cannot commit and is released |
+| M1 | Two queries, first completes before second | Both results render; sibling completion does not release the other reference |
+| M2 | Two queries, second completes before first | Ownership and final rendering match M1 |
+| M3 | One query completes, then the route is abandoned while its sibling is pending | Both route-owned references are eventually released; pending work is canceled as supported |
+| M4 | One of two queries fails, then retry succeeds | No sibling leak or reuse of failed/released work |
+| M5 | Partial multi-query construction throws | Every already-created reference is cleaned up according to the approved contract |
+| S1 | B1 under React Strict Mode | No duplicate loader ownership, premature disposal, warning, or failure |
+| S2 | N1 and one multi-query transition under Strict Mode | Mount probing cannot release work still required by active UI |
+| T1 | Router/provider/root teardown | All integration-owned references, requests, listeners, and subscriptions are released |
+| T2 | Runtime/router recreated at the same location | No reference or store identity crosses from the old runtime |
+
+For history and stale-revalidation cases, choose and document one coherent
+strategy:
+
+- retain cached loader references until a documented final release event; or
+- disable their reuse and guarantee replacement before render.
+
+Do not accept accidental reload behavior or an arbitrary grace timeout as the
+contract.
+
+## Work Plan
+
+### Phase 1 - Upstream Contract And Characterization
+
+Make no public source changes in this phase.
+
+- [ ] Read `AGENTS.md`, `.ai/conventions.md`, `.ai/architecture.md`, and the
+ task-specific task-file, documentation, and library-release rules before
+ implementation.
+- [ ] Read current official TanStack and Relay documentation for the pinned
+ versions.
+- [ ] Inspect `git status --short` and preserve unrelated changes.
+- [ ] Complete the one-runtime preflight.
+- [ ] Record current `loadRouteQuery` behavior and existing unit coverage.
+- [ ] Create the private Rush fixture, neutral schema/operations, controlled
+ network, and lifecycle ledger.
+- [ ] Characterize at least B1, N1, N2, H1, P1, R1, M1, M2, and T1 against the
+ existing public helper.
+- [ ] Minimize each failure and distinguish Labkit behavior, fixture mistakes,
+ documented TanStack behavior, unsupported configuration, and upstream
+ defects.
+- [ ] Add an `Evidence And Decisions` section to this file with observed event
+ order and exact tested versions.
+- [ ] Do not land assertions that merely freeze accidental current behavior.
+
+Phase 1 is complete when the current ownership boundary is measured rather than
+assumed.
+
+### Gate A - Supported Ownership Model
+
+Before changing public source, record:
+
+- supported `staleTime`, `preloadStaleTime`, and `gcTime` assumptions;
+- whether loader values containing query references may be reused;
+- the final release event for navigation, preload, active route, cache, and
+ teardown owners;
+- whether the existing abort signal is sufficient;
+- how single-query and multi-query cleanup differ;
+- whether an app-owned route recipe using existing APIs satisfies every
+ invariant.
+
+If the current helper plus an explicit route recipe is sufficient, continue as
+a test/documentation improvement. If not, proceed to Gate B.
+
+### Gate B - Public API And Package Boundary
+
+Prefer the smallest architecture-preserving outcome:
+
+1. correct a proven internal bug without changing the public signature;
+2. document a safe application-owned recipe using existing APIs;
+3. add a router-agnostic lifecycle controller/ownership handle;
+4. introduce a dedicated TanStack adapter package if router-specific types or
+ events are unavoidable.
+
+Do not add TanStack to the core Relay package for convenience. If option 3 or 4
+is required, append a proposal containing:
+
+- proposed package and exported TypeScript signatures;
+- an ownership state diagram and final-release rule;
+- responsibilities for loader, route, mounted component, cache, and retry;
+- single-query and multi-query examples;
+- error, partial-construction, cancellation, and idempotency behavior;
+- migration from `loadRouteQuery`;
+- dependency/peer and CJS/ESM/type impact;
+- SemVer and documentation impact;
+- alternatives considered and rejected.
+
+Then stop and request maintainer approval. Do not implement the export, change
+the existing helper, add a public TanStack dependency, or register a new public
+package before approval is recorded.
+
+### Phase 2 - Implement The Approved Minimal Contract
+
+After Gate A or Gate B is resolved:
+
+- [ ] Implement only the approved ownership model.
+- [ ] Keep the core Relay package router-agnostic whenever possible.
+- [ ] Keep disposal idempotent and detach listeners when ownership transfer
+ makes them obsolete.
+- [ ] Never call `loadQuery` during React render.
+- [ ] Do not read, clone, or serialize private query-reference fields.
+- [ ] Treat multiple references as independent resources.
+- [ ] Clean up partial construction failures.
+- [ ] Preserve existing fetch policy and network-cache options unless an
+ approved public proposal says otherwise.
+- [ ] Add focused unit/state-machine tests before expanding the integration
+ suite.
+- [ ] Preserve a safe compatibility path if a new additive API is approved.
+
+Phase 2 is complete when the minimal approved contract is implemented and its
+unit-level ownership transitions pass.
+
+### Phase 3 - Complete The Integration And Browser Matrices
+
+- [ ] Implement every required matrix row.
+- [ ] Use controlled response order rather than race-prone sleeps.
+- [ ] Assert route and rendered data identity after every navigation.
+- [ ] Assert late responses cannot alter the selected final UI.
+- [ ] Assert exact disposal at controlled contract seams and public
+ cancellation/teardown outcomes in real Relay cases.
+- [ ] Execute both multi-query completion orders.
+- [ ] Execute selected transitions under Strict Mode.
+- [ ] Add the minimal Vite/browser subset for direct entry, reload, history,
+ and final teardown.
+- [ ] Give every test a fresh runtime and complete cleanup.
+- [ ] Remove temporary diagnostic logging after ledger assertions stabilize.
+
+If a documented upstream behavior prevents a required invariant, record a
+minimal reproduction and stop rather than hiding the behavior with a delay,
+retry loop, or cache workaround.
+
+### Phase 4 - Documentation
+
+After the ownership decision, update only affected current sources:
+
+- [ ] [Package README](../packages/webapp-graphql-relay/README.md).
+- [ ] [Package reference](../docs/packages/webapp-graphql-relay.md).
+- [ ] [GraphQL contract](../docs/graphql-contract.md), if the general query
+ ownership contract changes.
+- [ ] [Webapp composition](../docs/webapp-composition.md).
+- [ ] [Architecture](../docs/architecture.md), only if the Labkit/application
+ ownership boundary changes or needs a reusable rule.
+
+Document exact tested versions, supported cache/revalidation assumptions,
+initial/preload/navigation/history/retry behavior, multi-query composition,
+final release rules, app-owned responsibilities, and migration if applicable.
+
+Do not present fixture route files or generated operations as Labkit-owned.
+Do not hand-edit `website-docusaurus/docs` or `docs-versions`.
+
+### Phase 5 - Release And CI Decision
+
+Choose release metadata from the final diff:
+
+- private fixture/tests/CI only: no public release by default;
+- clarification of an existing released contract: follow repository guidance
+ and decide `none` versus patch explicitly;
+- backward-compatible runtime bug fix: patch;
+- additive public lifecycle API: minor;
+- incompatible signature, dependency, or behavior change: major;
+- new public adapter package: separate approved package/release design.
+
+- [ ] Register the private tool project and its build ordering intentionally in
+ `rush.json`.
+- [ ] Make fast deterministic tests part of Rush test/verify.
+- [ ] Add a dedicated browser command/workflow step only if the browser layer
+ cannot run safely inside the existing job.
+- [ ] Create Rush change metadata only for packages whose released contract
+ changes.
+- [ ] Do not manually edit package versions or changelogs.
+- [ ] Leave publication to post-merge CI.
+
+## Validation
+
+Run focused fixture/package commands while developing. Before handoff, run:
+
+```bash
+npm run rush:install
+npm run rush:build
+npm run rush:lint
+node common/scripts/install-run-rush.js test --parallelism 1
+node common/scripts/install-run-rush.js verify --parallelism 1
+```
+
+Run the dedicated browser command if one is added. If current docs change, also
+run:
+
+```bash
+npm run site:docusaurus:check
+```
+
+Finally run:
+
+```bash
+trunk check -a -y
+```
+
+The permanent matrix must have no arbitrary sleeps, random completion order,
+shared runtime between cases, expected console warnings, network dependency, or
+open handles. Report unavailable commands or unrelated failures precisely.
+
+## Acceptance Criteria
+
+- [ ] Exact tested framework/compiler/tool versions and upstream lifecycle
+ meanings are recorded.
+- [ ] The fixture rejects an unsupported or duplicated Relay graph before
+ lifecycle assertions.
+- [ ] A private non-published Rush fixture exercises the package root.
+- [ ] TanStack remains private test tooling unless a public boundary is
+ explicitly approved.
+- [ ] A real `RouterProvider`, `RelayEnvironmentProvider`, generated operation,
+ default `loadRouteQuery`, and `usePreloadedQuery` path render correct data.
+- [ ] The lifecycle ledger deterministically records creation, ownership,
+ network, routing, rendering, retry, and cleanup transitions.
+- [ ] Every required matrix row passes under the approved ownership policy.
+- [ ] Fresh direct entry and actual document reload do not depend on warmed
+ Relay or router state.
+- [ ] Active or cached routes never observe a released reference.
+- [ ] Superseded work cannot commit after a late response.
+- [ ] Back/forward and stale revalidation follow one explicit cache policy.
+- [ ] Unused preloads and evicted matches eventually release owned work.
+- [ ] Both multi-query completion orders and partial-failure paths are safe.
+- [ ] Strict Mode introduces no duplicate ownership or premature release.
+- [ ] Final teardown leaves no owned references, requests, listeners,
+ subscriptions, console errors, or open handles.
+- [ ] No test relies on private Relay fields, undocumented router internals, or
+ wall-clock timing.
+- [ ] Gate A evidence is recorded, and Gate B approval exists before any public
+ API/dependency/package change.
+- [ ] Package/root docs describe the final supported contract and app-owned
+ responsibilities.
+- [ ] Rush and CI wiring execute the permanent matrix deterministically.
+- [ ] Release metadata matches only the actual public impact.
+- [ ] Focused, full Rush, docs, browser, and Trunk validation pass or every gap
+ is reported.
+- [ ] This file is moved to `tasks/completed` only when every applicable item
+ is complete.