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.