Skip to content
Closed
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
9 changes: 8 additions & 1 deletion apps/app/src/precompress-app-dist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@ describe("app asset precompression", () => {
const distDir = await mkdtemp(resolve(tmpdir(), "bb-precompress-test-"));
const compressibleBody = Buffer.from("compressible bb asset\n".repeat(400));
const assetPath = resolve(distDir, "app.js");
// The document itself: without a sidecar every cold navigation on the
// relayed mobile path ships the shell uncompressed through the tunnel.
const documentPath = resolve(distDir, "index.html");
const smallPath = resolve(distDir, "small.js");
const binaryPath = resolve(distDir, "image.png");
await Promise.all([
writeFile(assetPath, compressibleBody),
writeFile(documentPath, compressibleBody),
writeFile(smallPath, "small"),
writeFile(binaryPath, compressibleBody),
]);
Expand All @@ -41,13 +45,16 @@ describe("app asset precompression", () => {
distDir,
]);

expect(stdout).toContain("precompressed 1 files (1 br, 1 gzip)");
expect(stdout).toContain("precompressed 2 files (2 br, 2 gzip)");
await expect(
decompressBrotli(await readFile(`${assetPath}.br`)),
).resolves.toEqual(compressibleBody);
await expect(
decompressGzip(await readFile(`${assetPath}.gz`)),
).resolves.toEqual(compressibleBody);
await expect(
decompressBrotli(await readFile(`${documentPath}.br`)),
).resolves.toEqual(compressibleBody);
await expect(pathExists(`${smallPath}.br`)).resolves.toBe(false);
await expect(pathExists(`${binaryPath}.br`)).resolves.toBe(false);
});
Expand Down
94 changes: 93 additions & 1 deletion apps/app/src/vite-font-preload.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { resolveFontPreloadTags } from "../vite-font-preload.js";
import {
reorderHeadForFirstPaint,
resolveFontPreloadTags,
} from "../vite-font-preload.js";

const bundle = [
"assets/index-rXrqkkAU.js",
Expand Down Expand Up @@ -37,3 +42,90 @@ describe("resolveFontPreloadTags", () => {
expect(resolveFontPreloadTags(["assets/index-abc.js"], "/")).toEqual([]);
});
});

/** The head layout Vite emits: theme script, then entry + preloads + css. */
const builtHtml = [
"<!doctype html><html><head>",
'<script>localStorage.getItem("bb.theme")</script>',
'<script type="module" crossorigin src="/assets/index-rXrqkkAU.js"></script>',
'<link rel="modulepreload" crossorigin href="/assets/react-abc.js">',
'<link rel="modulepreload" crossorigin href="/assets/router-def.js">',
'<link rel="stylesheet" crossorigin href="/assets/index-CXWZ8ak3.css">',
"</head><body></body></html>",
].join("");

describe("reorderHeadForFirstPaint", () => {
const fontTags = resolveFontPreloadTags(bundle, "/");

it("moves the stylesheet and font preload ahead of the script and preload block", () => {
const html = reorderHeadForFirstPaint(builtHtml, fontTags);

const themeAt = html.indexOf("bb.theme");
const fontAt = html.search(/<link[^>]*as="font"/);
const stylesheetAt = html.search(/<link[^>]*rel="stylesheet"/);
const entryAt = html.search(/<script type="module"/);
const firstPreloadAt = html.search(/<link rel="modulepreload"/);

expect(themeAt).toBeLessThan(fontAt);
expect(fontAt).toBeLessThan(stylesheetAt);
expect(stylesheetAt).toBeLessThan(entryAt);
expect(stylesheetAt).toBeLessThan(firstPreloadAt);
// One stylesheet moved, not duplicated, and boosted for the relay path.
expect(html.match(/rel="stylesheet"/g)).toHaveLength(1);
expect(html).toContain('<link fetchpriority="high" rel="stylesheet"');
});

it("still front-loads the stylesheet when the font is not in the bundle", () => {
const html = reorderHeadForFirstPaint(builtHtml, []);
expect(html.search(/rel="stylesheet"/)).toBeLessThan(
html.search(/<script type="module"/),
);
expect(html).not.toContain('as="font"');
});

it("returns the document unchanged when there is nothing to front-load", () => {
const bare = "<html><head><script>1</script></head><body></body></html>";
expect(reorderHeadForFirstPaint(bare, [])).toBe(bare);
});

it("refuses to move the stylesheet ahead of the pre-paint theme script", () => {
const themeless = builtHtml.replace("bb.theme", "bb.other");
expect(() => reorderHeadForFirstPaint(themeless, fontTags)).toThrow(
/pre-paint theme script/,
);
});
});

const distIndexHtmlPath = resolve(import.meta.dirname, "../dist/index.html");

/**
* The built document, not a fixture: the tunnel serializes responses FIFO on
* one WebSocket, so discovery order in dist/index.html IS delivery order on
* the relayed mobile path. The render-blocking stylesheet and the font
* preload must be discovered before the modulepreload block, and the
* pre-paint theme script must still run before the stylesheet applies.
* Skipped when dist/ is absent (test runs without a build).
*/
describe.skipIf(!existsSync(distIndexHtmlPath))(
"emitted dist/index.html head order",
() => {
it("puts the stylesheet and font preload before every modulepreload, after the theme script", () => {
const html = readFileSync(distIndexHtmlPath, "utf8");
const stylesheetAt = html.search(/<link[^>]*rel="stylesheet"/);
const fontPreloadAt = html.search(/<link[^>]*as="font"/);
const firstModulepreloadAt = html.search(/<link rel="modulepreload"/);
// The head inline script that adds the `dark` class pre-paint; it must
// finish before the stylesheet applies or cold loads flash light mode.
const themeScriptAt = html.indexOf("bb.theme");

expect(stylesheetAt).toBeGreaterThan(-1);
expect(fontPreloadAt).toBeGreaterThan(-1);
expect(firstModulepreloadAt).toBeGreaterThan(-1);
expect(themeScriptAt).toBeGreaterThan(-1);

expect(themeScriptAt).toBeLessThan(stylesheetAt);
expect(stylesheetAt).toBeLessThan(firstModulepreloadAt);
expect(fontPreloadAt).toBeLessThan(firstModulepreloadAt);
});
},
);
92 changes: 85 additions & 7 deletions apps/app/vite-font-preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,86 @@ export function resolveFontPreloadTags(
];
}

function serializeTag(tag: HtmlTagDescriptor): string {
const attrs = Object.entries(tag.attrs ?? {})
.filter(([, value]) => value !== undefined && value !== false)
.map(([name, value]) => (value === true ? name : `${name}="${value}"`))
.join(" ");
return `<${tag.tag} ${attrs}>`;
}

/**
* Moves the render-blocking stylesheet and the font preload ahead of the
* modulepreload block in the built document.
*
* Vite appends its asset tags in [entry script, modulepreload…, stylesheet]
* order, which put the stylesheet 68th and the font preload last among the
* document's resources. The tunnel relay serializes responses FIFO on one
* WebSocket, so discovery order is delivery order: first paint waited for
* ~1.5 MB of JavaScript to clear the wire before the CSS arrived.
*
* The pre-paint theme script (`bb.theme` in index.html) must keep running
* before the stylesheet applies — otherwise every dark-mode cold load
* flashes the light palette — so this refuses to move the stylesheet ahead
* of it and fails the build rather than shipping the flash.
*/
export function reorderHeadForFirstPaint(
html: string,
fontPreloadTags: HtmlTagDescriptor[],
): string {
const stylesheets: string[] = [];
const withoutStylesheets = html.replace(
/[ \t]*<link[^>]*rel="stylesheet"[^>]*>\n?/g,
(tag) => {
stylesheets.push(tag.trim());
return "";
},
);

const block = [
...fontPreloadTags.map(serializeTag),
...stylesheets.map((tag) =>
tag.includes("fetchpriority")
? tag
: tag.replace("<link ", '<link fetchpriority="high" '),
),
].join("");
if (block === "") return html;

const anchor = firstPreloadableTagIndex(withoutStylesheets);
const themeScriptAt = withoutStylesheets.indexOf("bb.theme");
if (themeScriptAt === -1 || anchor <= themeScriptAt) {
throw new Error(
"bb:font-preload: the pre-paint theme script must precede the injected asset tags in index.html; refusing to move the stylesheet ahead of it",
);
}
return (
withoutStylesheets.slice(0, anchor) +
block +
withoutStylesheets.slice(anchor)
);
}

/** Where the browser's preload scanner meets the first script/preload tag. */
function firstPreloadableTagIndex(html: string): number {
const candidates = [
html.search(/<link rel="modulepreload"/),
html.search(/<script type="module"[^>]*src=/),
html.indexOf("</head>"),
].filter((index) => index >= 0);
if (candidates.length === 0) {
throw new Error("bb:font-preload: built index.html has no <head>");
}
return Math.min(...candidates);
}

/**
* Preloads the Inter latin woff2 from index.html. Without it the font request
* starts only once the CSS has parsed and the first text node needs it, which
* on a phone is after ~1.5 MB of JavaScript. Build-only: the dev server has no
* hashed asset to point at, and dev has no first-paint budget.
* Build-only head surgery for first paint: preloads the Inter latin woff2 and
* moves it plus the app stylesheet ahead of the modulepreload block (see
* reorderHeadForFirstPaint). Without the preload the font request starts only
* once the CSS has parsed and the first text node needs it, which on a phone
* is after ~1.5 MB of JavaScript. The dev server has no hashed asset to point
* at, and dev has no first-paint budget.
*/
export function fontPreload(): Plugin {
let base = "/";
Expand All @@ -59,9 +134,12 @@ export function fontPreload(): Plugin {
},
transformIndexHtml: {
order: "post",
handler(_html, ctx) {
if (ctx.bundle === undefined) return [];
return resolveFontPreloadTags(Object.keys(ctx.bundle), base);
handler(html, ctx) {
if (ctx.bundle === undefined) return html;
return reorderHeadForFirstPaint(
html,
resolveFontPreloadTags(Object.keys(ctx.bundle), base),
);
},
},
};
Expand Down
31 changes: 31 additions & 0 deletions apps/app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,37 @@ export const sharedViteConfig = {
// and let the browser fetch only the ones a menu actually renders.
assetsInlineLimit: (filePath) =>
filePath.includes("/workspace-open-target-icons/") ? false : undefined,
rolldownOptions: {
output: {
// Merge the boot payload's micro-chunks. Rolldown's automatic
// splitting left half the boot-path requests carrying ~2% of the
// bytes (sub-4 KB shared chunks, many below the 1 KiB precompress
// floor), and on the relayed mobile path every request is a full
// worker → DO → tunnel → laptop round trip. The `$initial` tag
// captures exactly the entry's static-import closure, so lazy-route
// and on-demand facades (and the budget's closure walk and
// forbidden-package gates over them) are untouched. Two groups so a
// release that only touches app code leaves the vendor chunk's hash
// — the bulk of the boot bytes — cacheable across updates.
advancedChunks: {
groups: [
{
name: "boot-vendor",
test: /node_modules/,
tags: ["$initial"],
priority: 2,
minSize: 12 * 1024,
},
{
name: "boot-app",
tags: ["$initial"],
priority: 1,
minSize: 12 * 1024,
},
],
},
},
},
},
optimizeDeps: {
// The terminal imports xterm lazily when the panel mounts. Pre-optimize
Expand Down
Loading
Loading