import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; // BUG-698. A fixed `height` written only in `dvh` is dropped whole by any engine that // does not know the unit, and the box silently falls back to its content height. // // The classic guard — `height: 100vh; height: 100dvh;` in one rule — does NOT work in this // repo: Lightning CSS (Tailwind v4's minifier) collapses duplicate declarations of the same // property inside a rule and keeps only the last, so the `vh` line never reaches the browser. // Measured on the emitted chunk: `.group/sidebar-provider[data-viewport]` shipped as // `height:100dvh` alone even though the source carried the duplicate fallback. // // So the contract is: `dvh` heights live inside `@supports (height: 1dvh)`, and the plain // `vh` value is the base. `max-height` / `min-height` are deliberately out of scope — when // those are dropped the box merely loses a cap, it does not change size per content. // Comments are removed first: this file's own explanatory comment quotes the broken // `height: 100vh; height: 100dvh;` form, and the scanner must not read it as real CSS. const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8") .replace(/\/\*[\s\S]*?\*\//g, ""); const SUPPORTS_DVH = /@supports\s*\(\s*height\s*:\s*1dvh\s*\)\s*\{/g; function stripSupportsDvhBlocks(source: string): string { const kept: string[] = []; let cursor = 0; SUPPORTS_DVH.lastIndex = 0; for (let match = SUPPORTS_DVH.exec(source); match; match = SUPPORTS_DVH.exec(source)) { kept.push(source.slice(cursor, match.index)); let depth = 1; let index = match.index + match[0].length; while (depth > 0 && index < source.length) { if (source[index] === "{") depth += 1; else if (source[index] === "}") depth -= 1; index += 1; } cursor = index; SUPPORTS_DVH.lastIndex = index; } kept.push(source.slice(cursor)); return kept.join(""); } // Declarations of the `height` property exactly - not max-height, not min-height. const HEIGHT_DECLARATION = /(?:^|[;{])\s*height\s*:\s*([^;}]+)/g; function heightValues(source: string): string[] { const values: string[] = []; HEIGHT_DECLARATION.lastIndex = 0; for (let match = HEIGHT_DECLARATION.exec(source); match; match = HEIGHT_DECLARATION.exec(source)) { values.push(match[1].trim()); } return values; } test("every dvh height is guarded by a feature query", () => { const outsideSupports = stripSupportsDvhBlocks(css); const unguarded = heightValues(outsideSupports).filter((value) => value.includes("dvh")); assert.deepEqual( unguarded, [], `height declarations using dvh must sit inside @supports (height: 1dvh); found: ${unguarded.join(" | ")}`, ); }); test("the duplicate-declaration fallback is not used, because the minifier strips it", () => { const rules = css.match(/[^{}]+\{[^{}]*\}/g) ?? []; const doubled = rules.filter((rule) => { const values = heightValues(rule); return values.some((value) => value.includes("dvh")) && values.some((value) => !value.includes("dvh") && value.includes("vh")); }); assert.deepEqual( doubled, [], `Lightning CSS keeps only the last of duplicate declarations, so this fallback never ships. Use @supports (height: 1dvh) instead. Found: ${doubled.join(" | ")}`, ); }); // Known limitation: this checks that a vh base exists somewhere in the file, not that it // sits in the same at-rule scope. `.auth-page` only has a height inside // @media (max-width: 767px), so its dvh upgrade has to be nested in that media query too — // putting it at top level would newly constrain the desktop login page, and this test would // not catch it. Match the scope of the base rule by hand when adding a selector here. test("every selector upgraded to dvh keeps a vh base outside the feature query", () => { const outsideSupports = stripSupportsDvhBlocks(css); const guardedSelectors = new Set(); SUPPORTS_DVH.lastIndex = 0; for (let match = SUPPORTS_DVH.exec(css); match; match = SUPPORTS_DVH.exec(css)) { let depth = 1; let index = match.index + match[0].length; const start = index; while (depth > 0 && index < css.length) { if (css[index] === "{") depth += 1; else if (css[index] === "}") depth -= 1; index += 1; } const body = css.slice(start, index - 1); for (const rule of body.match(/[^{}]+\{[^{}]*\}/g) ?? []) { const [selector, declarations] = rule.split("{"); if (!heightValues(`{${declarations}`).some((value) => value.includes("dvh"))) continue; for (const one of selector.split(",")) guardedSelectors.add(one.trim()); } SUPPORTS_DVH.lastIndex = index; } assert.ok(guardedSelectors.size > 0, "expected at least one selector inside @supports (height: 1dvh)"); const missingBase: string[] = []; for (const selector of guardedSelectors) { const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // `(?![\w-])` stops `.account-modal` from matching `.account-modal-overlay`. const baseRules: string[] = outsideSupports.match(new RegExp(`${escaped}(?![\\w-])[^{}]*\\{[^{}]*\\}`, "g")) ?? []; const declaresVhHeight = baseRules.some((rule) => heightValues(rule).some((value) => value.includes("vh") && !value.includes("dvh"))); if (!declaresVhHeight) missingBase.push(selector); } assert.deepEqual( missingBase, [], `these selectors get a dvh height but no vh base, so an engine without dvh has no height at all: ${missingBase.join(" | ")}`, ); });