Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ packages/cli/.mcp-fixture/
packages/cli/.island-fixture/
packages/cli/.overrides-fixture/
packages/cli/.prerender-fixture/
packages/cli/.prerender-app-root-fixture/
packages/cli/.mcp-host-fixture/
packages/cli/.roles-script-csp-fixture/

Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@ Semver applies from 1.0.0. A breaking change to a documented API needs a major

## [Unreleased]

Nothing yet.
### Fixed

- **render / cli:** a stylesheet's surface is read below the app root, never off its absolute path.
Under the scaffold container's `WORKDIR /app` every absolute path starts with an `app/` segment,
so every sheet — `site/` modules, `shared/global.scss`, `@ultimat3/ui`'s — classified as `app`,
`stylesFor('site')` was empty, and every prerendered and `site/` document shipped with no
`<link rel="stylesheet">`. The same held for any root with `site/`, `api/` or `shared/` above it.
`loadApp(root)` now names the root before it imports a module (`setStylesheetRoot`, new in
`@ultimat3/render/server`; the working directory when unset), sheets registered earlier are
reclassified, and a sheet under `node_modules/` is a package sheet whatever its directories are
called. Route and boundary classification already read root-relative paths and are unchanged.

## 22.2.0 - 2026-09-24

Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/app-load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import { isRouteConfig, pageComponentOf, registerRoute, routeEntries } from '@ul
// every app module below is loaded by the dynamic `import()` in this file. Before the render
// barrel split it came free with the line above; after it, the only other path to `/server` from
// here is six hops through `error-contract` → `fix-command` → the command registry, which is an
// accident one refactor away from compiling every app's `.tsx` to `React.createElement`.
import '@ultimat3/render/server';
// accident one refactor away from compiling every app's `.tsx` to `React.createElement`. The named
// import is a value import, so the side effect holds without the bare line it replaced.
import { setStylesheetRoot } from '@ultimat3/render/server';
import { APP_CONFIG_FILE } from './app-root';
import { collectDeclaredCodes } from './error-contract';
import type { Finding } from './output';
Expand Down Expand Up @@ -108,6 +109,10 @@ export function resetAppLoad(): void {
}

export async function loadApp(root: string): Promise<LoadedApp> {
// Before any import: a stylesheet's surface is read below the app root, never off its absolute
// path — under the container's `WORKDIR /app` that path's first segment is `app/`, and every
// sheet, the site's included, classified as app CSS.
setStylesheetRoot(root);
const files: string[] = [];
const findings: Finding[] = [];

Expand Down
74 changes: 74 additions & 0 deletions packages/cli/src/prerender-app-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// The static build under a root whose last segment is `app` — the scaffold container's
// `WORKDIR /app`. A real app on disk, loaded the way `x build` loads it, because the defect lived in
// how an IMPORTED stylesheet was classified: registering a sheet by hand would not reach it.

import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { rm } from 'node:fs/promises'; // why: Bun has no recursive remove, only a per-file delete.
// why: Bun exposes no path-join primitive; Bun.file and import() take one already joined.
import { join } from 'node:path';
import { clearRoutes } from '@ultimat3/render';
import { clearStylesheets, setStylesheetRoot } from '@ultimat3/render/server';
import { prerenderSite } from './prerender';

// Inside the package, so the fixture's `@ultimat3/*` imports resolve; `app` is the point.
const FIXTURE = join(import.meta.dir, '..', '.prerender-app-root-fixture');
const APP_ROOT = join(FIXTURE, 'app');

beforeEach(async () => {
clearRoutes();
await rm(FIXTURE, { recursive: true, force: true });
});

afterEach(async () => {
clearRoutes();
// Both registries are process-global, and this file writes into them.
clearStylesheets();
setStylesheetRoot(undefined);
await rm(FIXTURE, { recursive: true, force: true });
});

// Reproduced in production: the scaffold's Dockerfile builds and serves from `WORKDIR /app`, and the
// stylesheet loader read the surface off the ABSOLUTE path — whose first `app/` segment is the root
// itself. Every sheet classified as `app`, the site bundle was empty, and every prerendered page
// shipped with no `<link>`. A real app on disk, loaded the way `x build` loads it, under a root
// whose last segment is `app`.
describe('an app whose root is a directory named app/', () => {
test('the site page links its own stylesheet, carrying the global layer and not app/ CSS', async () => {
await Bun.write(
join(APP_ROOT, 'package.json'),
JSON.stringify({ name: 'app-root-fixture', version: '1.0.0' }),
);
await Bun.write(join(APP_ROOT, 'apps/web/shared/global.scss'), ':root{--space-9:9rem}\n');
await Bun.write(join(APP_ROOT, 'apps/web/site/page.module.scss'), '.hero{margin:3px}\n');
await Bun.write(join(APP_ROOT, 'apps/web/app/feed/panel.module.scss'), '.panel{margin:7px}\n');
await Bun.write(
join(APP_ROOT, 'apps/web/site/page.tsx'),
[
"import { defineRoute } from '@ultimat3/render';",
"import '../shared/global.scss';",
"import styles from './page.module.scss';",
"export const config = defineRoute({ render: 'static', hydrate: 'never', offline: 'precache',",
" meta: () => ({ title: 'Home', description: 'the landing page' }) });",
'export default function Home() { return <main class={styles.hero} />; }',
'',
].join('\n'),
);
// An app/ module with CSS of its own, so an empty site bundle is not the only way to fail.
await Bun.write(
join(APP_ROOT, 'apps/web/app/feed/panel.ts'),
"import styles from './panel.module.scss';\nexport const panel = styles.panel;\n",
);
const out = join(APP_ROOT, 'static');

const report = await prerenderSite({ root: APP_ROOT, out, origin: 'https://example.test' });

const html = await Bun.file(join(out, 'index.html')).text();
const href = /<link rel="stylesheet" href="(?<url>[^"]+)">/.exec(html)?.groups?.['url'] ?? '';
expect(href).toMatch(/^\/styles\/[0-9a-f]{8}\.css$/);
expect(report.styles).toContain(href);
const css = await Bun.file(join(out, href.slice(1))).text();
expect(css).toContain('margin:3px');
expect(css).toContain('--space-9:9rem');
expect(css).not.toContain('margin:7px');
});
});
48 changes: 47 additions & 1 deletion packages/cli/src/style-bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { rm } from 'node:fs/promises';
// why: Bun exposes no path API — nothing native joins a directory to a file.
import { join } from 'node:path';
import { clearStylesheets, loadStylesheet } from '@ultimat3/render/server';
import { clearStylesheets, loadStylesheet, setStylesheetRoot } from '@ultimat3/render/server';
import { STYLE_BASE_PATH, styleBundle, styleBundleOf, writeStyles } from './style-bundle';

const SITE = '/srv/demo/apps/web/site/page.module.scss';
Expand All @@ -24,6 +24,7 @@ beforeEach(() => {

afterEach(async () => {
clearStylesheets();
setStylesheetRoot(undefined);
await rm(OUT, { recursive: true, force: true });
});

Expand Down Expand Up @@ -100,3 +101,48 @@ describe('styleBundle', () => {
expect(await Bun.file(join(OUT, url.slice(1))).text()).toBe('.hero{color:red}');
});
});

// The container's shape: the app runs from `WORKDIR /app`, so every sheet's absolute path begins
// with an `app/` segment. The fingerprinted pipeline has to hold there exactly as it does in a
// checkout — the site gets a file of its own, and its name moves with its bytes and only with them.
describe('styleBundle, for an app served from /app', () => {
const SITE_SHEET = '/app/apps/web/site/page.module.scss';
const APP_SHEET = '/app/apps/web/app/feed/page.module.scss';
const GLOBAL_SHEET = '/app/apps/web/shared/global.scss';

beforeEach(() => {
setStylesheetRoot('/app');
});

test('the site gets its own content-hashed file, carrying the global layer and no app CSS', () => {
loadStylesheet(GLOBAL_SHEET, ':root{--space-9:9rem}');
loadStylesheet(SITE_SHEET, '.hero{color:red}');
loadStylesheet(APP_SHEET, '.feed{color:blue}');
const bundle = styleBundle();
const site = bundle.hrefFor('site') ?? '';
const app = bundle.hrefFor('app') ?? '';

expect(site).toMatch(new RegExp(`^${STYLE_BASE_PATH}/[0-9a-f]{8}\\.css$`));
expect(site).not.toBe(app);
const css = bundle.chunkAt(site)?.css ?? '';
expect(css).toContain('--space-9:9rem');
expect(css).toContain('color:red');
expect(css).not.toContain('color:blue');
});

test('the site URL is stable while its CSS is, and moves only when its CSS does', () => {
loadStylesheet(SITE_SHEET, '.hero{color:red}');
loadStylesheet(APP_SHEET, '.feed{color:blue}');
const first = styleBundle().hrefFor('site');

// The same bytes again, and an edit on the OTHER surface: neither is a reason to re-download.
loadStylesheet(SITE_SHEET, '.hero{color:red}');
loadStylesheet(APP_SHEET, '.feed{color:purple}');
expect(styleBundle().hrefFor('site')).toBe(first);

loadStylesheet(SITE_SHEET, '.hero{color:green}');
const moved = styleBundle().hrefFor('site') ?? '';
expect(moved).not.toBe(first);
expect(styleBundle().chunkAt(moved)?.css).toContain('color:green');
});
});
52 changes: 52 additions & 0 deletions packages/render/src/module-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
JSX_FACTORY_SPECIFIER,
loadStylesheet,
registeredStylesheets,
setStylesheetRoot,
stylesFor,
stylesheetsRevision,
transformTsx,
Expand All @@ -27,6 +28,7 @@ const occurrences = (haystack: string, needle: string): number => haystack.split

afterEach(() => {
clearStylesheets();
setStylesheetRoot(undefined);
});

describe('installRenderLoader', () => {
Expand Down Expand Up @@ -231,3 +233,53 @@ describe('stylesFor', () => {
expect(stylesFor('app').startsWith(GLOBAL_CSS)).toBe(true);
});
});

// The container runs the app from `WORKDIR /app`, so every absolute path a Bun plugin hands the
// loader starts with an `app/` segment. Classified from the absolute path, the site page, the
// global layer and the UI kit all landed on `app`, and every site document went out unstyled.
describe('an app served from /app', () => {
const ROOT = '/app';
const SITE_SHEET = '/app/apps/web/site/page.module.scss';
const APP_SHEET = '/app/apps/web/app/feed/page.module.scss';
const GLOBAL_SHEET = '/app/apps/web/shared/global.scss';
const KIT_SHEET = '/app/node_modules/@ultimat3/ui/src/stack.module.scss';

test('each sheet is classified by where it sits in the app, not by the root it sits under', () => {
setStylesheetRoot(ROOT);
loadStylesheet(SITE_SHEET, '.hero{color:red}');
loadStylesheet(APP_SHEET, '.feed{color:blue}');
loadStylesheet(GLOBAL_SHEET, GLOBAL_CSS);
loadStylesheet(KIT_SHEET, '.stack{display:flex}');

const surfaces = Object.fromEntries(registeredStylesheets().map((s) => [s.file, s.surface]));
expect(surfaces).toEqual({
[SITE_SHEET]: 'site',
[APP_SHEET]: 'app',
[GLOBAL_SHEET]: 'shared',
[KIT_SHEET]: null,
});
const site = stylesFor('site');
expect(site).toContain('color:red');
expect(site).toContain('--color-fg:');
expect(site).toContain('display:flex');
expect(site).not.toContain('color:blue');
});

// The order `x build` meets: a sheet can register before `loadApp` names the root, and a
// classification frozen at registration would keep the answer the wrong root gave.
test('naming the root reclassifies what registered before it, and moves the revision', () => {
setStylesheetRoot('/');
loadStylesheet(SITE_SHEET, '.hero{color:red}');
expect(stylesFor('site')).toBe('');

const before = stylesheetsRevision();
setStylesheetRoot(ROOT);
expect(stylesFor('site')).toContain('color:red');
expect(stylesheetsRevision()).toBeGreaterThan(before);

// Naming the same root again changes no answer, so it mints no new stylesheet URL.
const settled = stylesheetsRevision();
setStylesheetRoot(ROOT);
expect(stylesheetsRevision()).toBe(settled);
});
});
30 changes: 28 additions & 2 deletions packages/render/src/module-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { renderThrowable } from '@ultimat3/core';
import { compileStylesheet, isGlobalStylesheet, stripCharset } from './css-modules';
import { PrerenderFailedError } from './errors';
import type { Surface } from './surfaces';
import { surfaceOf } from './surfaces';
import { surfaceUnder } from './surfaces';

/**
* Why a transform at all: `tsconfig.json` says `jsx: 'preserve'`, which makes Bun fall back to the
Expand Down Expand Up @@ -101,6 +101,32 @@ export function registeredStylesheets(): readonly Stylesheet[] {
return [...stylesheets.values()];
}

/**
* The directory a sheet's surface is read below. Absent, the process's working directory — which
* is the app root in the container (`WORKDIR /app`) and under every `x` command run from one.
*/
let stylesheetRoot: string | undefined;

const surfaceOfSheet = (path: string): Surface | null =>
surfaceUnder(stylesheetRoot ?? process.cwd(), path);

/**
* Names the app root the registry classifies against; `loadApp` calls it before importing a module.
* Every sheet already registered is classified again, because a sheet can load before the root is
* named — and a classification frozen then would keep the answer the wrong root gave. The revision
* moves only when an answer does, so naming the same root twice mints no new stylesheet URL.
* `undefined` returns to the working directory.
*/
export function setStylesheetRoot(root: string | undefined): void {
stylesheetRoot = root;
for (const [path, sheet] of stylesheets) {
const surface = surfaceOfSheet(path);
if (surface === sheet.surface) continue;
stylesheets.set(path, { ...sheet, surface });
revision += 1;
}
}

/** Test seam: the registry is process-global because the module cache it mirrors is too. */
export function clearStylesheets(): void {
if (stylesheets.size > 0) revision += 1;
Expand Down Expand Up @@ -159,7 +185,7 @@ export function loadStylesheet(path: string, source: string): string {
if (stylesheets.get(path)?.css !== compiled.css) revision += 1;
stylesheets.set(path, {
file: path,
surface: surfaceOf(path),
surface: surfaceOfSheet(path),
global: isGlobalStylesheet(path),
css: compiled.css,
});
Expand Down
1 change: 1 addition & 0 deletions packages/render/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export {
installRenderLoader,
loadStylesheet,
registeredStylesheets,
setStylesheetRoot,
stylesFor,
stylesheetsRevision,
transformTsx,
Expand Down
49 changes: 48 additions & 1 deletion packages/render/src/surfaces.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, expect, test } from 'bun:test';
import { SurfaceBoundaryError } from './errors';
import { assertSurfaceBoundary, checkSurfaceBoundary, importGraph, surfaceOf } from './surfaces';
import {
assertSurfaceBoundary,
checkSurfaceBoundary,
importGraph,
surfaceOf,
surfaceUnder,
} from './surfaces';

describe('surfaceOf', () => {
test('reads the surface out of a monorepo path', () => {
Expand All @@ -11,6 +17,47 @@ describe('surfaceOf', () => {
});
});

// Reproduced in production: the scaffold's Dockerfile runs the app from `WORKDIR /app`, so every
// ABSOLUTE stylesheet path starts `/app/` — and the first `app/` segment won. The site page, the
// shared global layer and every package sheet classified as `app`, `stylesFor('site')` was empty,
// and every prerendered page shipped with no `<link>` at all.
describe('surfaceUnder', () => {
test('an app root named app/ does not make every file an app/ file', () => {
expect(surfaceUnder('/app', '/app/apps/web/site/page.module.scss')).toBe('site');
expect(surfaceUnder('/app', '/app/apps/web/shared/global.scss')).toBe('shared');
expect(surfaceUnder('/app', '/app/apps/web/app/feed/page.module.scss')).toBe('app');
expect(surfaceUnder('/app', '/app/packages/ui/src/card.module.scss')).toBe(null);
});

test('nor does a root with site/ somewhere above it', () => {
expect(surfaceUnder('/srv/site/x', '/srv/site/x/apps/web/app/feed/page.module.scss')).toBe(
'app',
);
expect(surfaceUnder('/srv/site/x/', '/srv/site/x/apps/web/shared/global.scss')).toBe('shared');
});

// A package sheet is carried by both graphs; `node_modules/lib/app/theme.scss` is not app code
// because the library happens to keep its sheets in a directory called `app`.
test('an installed package sheet has no surface, whatever directories it sits in', () => {
expect(surfaceUnder('/app', '/app/node_modules/@ultimat3/ui/src/card.module.scss')).toBe(null);
expect(surfaceUnder('/app', '/app/node_modules/some-lib/app/theme.scss')).toBe(null);
expect(
surfaceUnder('/app', '/app/apps/web/node_modules/@ultimat3/ui/src/stack.module.scss'),
).toBe(null);
});

test('a file outside the root is read as it always was', () => {
expect(surfaceUnder('/app', '/srv/demo/apps/web/site/page.module.scss')).toBe('site');
expect(surfaceUnder('/app', '/srv/demo/packages/ui/src/card.module.scss')).toBe(null);
// A sibling whose name merely STARTS with the root is not under it.
expect(surfaceUnder('/app', '/apple/apps/web/site/page.module.scss')).toBe('site');
});

test('Windows separators are read as POSIX ones', () => {
expect(surfaceUnder('C:\\app', 'C:\\app\\apps\\web\\site\\page.module.scss')).toBe('site');
});
});

describe('checkSurfaceBoundary', () => {
// The exact failure this prevents: <Button> grows a <Sparkline> that imports a charting
// library, and the marketing page silently inherits it three hops away.
Expand Down
Loading
Loading