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
43 changes: 35 additions & 8 deletions apps/app/bundle-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
"composer, render math). Each one reached the boot path through a barrel",
"re-export rather than a direct import, which type checking cannot catch,",
"so the check names them explicitly.",
"forbiddenBootModules applies the same exact-source gate to app modules",
"whose normally closed UI must never return to the boot closure.",
"",
"onDemandPackages goes one step further than the boot path: each package",
"maps to its gate, the source module a dynamic import() resolves to. The",
Expand All @@ -36,17 +38,19 @@
"",
"routeClosures ratchets, per lazy route, the static-import closure of the",
"route chunk minus the boot chunks: the JavaScript between 'app shell",
"painted' and 'route content painted'. SplitWorkspaceRoute is every thread,",
"compose and plugin-panel page, so its closure is the second number that",
"decides how slow bb feels on a phone. Same 10% ratchet; its forbiddenPackages",
"are the diff engine, math and terminal code that only a user action needs.",
"painted' and 'route content painted'. ProjectSettingsView forbids the path",
"dialog and remote browser needed only after opening a dialog.",
"SplitWorkspaceRoute is every thread, compose and plugin-panel page, so its",
"closure is the second number that decides how slow bb feels on a phone.",
"Same 10% ratchet; its forbiddenPackages are the diff engine, math and",
"terminal code that only a user action needs.",
"The composer (tiptap/prosemirror) is visible on every thread page and is",
"allowed until it moves behind a first-focus handoff. Run",
"`node scripts/why-eager.mjs --from=views/SplitWorkspaceRoute.tsx <package>`",
"to print the static chain that pulled a package into the closure."
],
"maxBootBytes": 1723617,
"maxBootBrotliBytes": 479067,
"maxBootBytes": 1718043,
"maxBootBrotliBytes": 482677,
"forbiddenBootPackages": [
"@pierre/diffs",
"@pierre/theming",
Expand All @@ -66,20 +70,41 @@
"prosemirror-model",
"prosemirror-state",
"prosemirror-view",
"preact",
"preact-render-to-string",
"rehype-katex",
"shiki"
],
"forbiddenBootModules": [
"src/components/dialogs/ProjectDeleteDialog.tsx",
"src/components/dialogs/ProjectPathDialog.tsx",
"src/components/dialogs/ProjectRenameDialog.tsx",
"src/components/dialogs/RemotePathBrowser.tsx"
],
"onDemandPackages": {
"@pierre/trees": "src/components/secondary-panel/ThreadSecondaryPanelWithStorage.tsx",
"katex": "src/components/ui/markdown-katex.ts",
"preact": "src/components/secondary-panel/ThreadSecondaryPanelWithStorage.tsx",
"preact-render-to-string": "src/components/secondary-panel/ThreadSecondaryPanelWithStorage.tsx",
"rehype-katex": "src/components/ui/markdown-katex.ts"
},
"routeClosures": {
"ProjectSettingsView": {
"maxBytes": 121009,
"maxBrotliBytes": 32864,
"forbiddenPackages": [],
"forbiddenModules": [
"src/components/dialogs/ProjectPathDialog.tsx",
"src/components/dialogs/RemotePathBrowser.tsx"
]
},
"SplitWorkspaceRoute": {
"maxBytes": 2533484,
"maxBrotliBytes": 671561,
"maxBytes": 2269199,
"maxBrotliBytes": 607310,
"forbiddenPackages": [
"@pierre/diffs",
"@pierre/theming",
"@pierre/trees",
"@shikijs/core",
"@shikijs/engine-javascript",
"@shikijs/engine-oniguruma",
Expand All @@ -89,6 +114,8 @@
"katex",
"mermaid",
"oniguruma-to-es",
"preact",
"preact-render-to-string",
"rehype-katex",
"shiki"
]
Expand Down
36 changes: 34 additions & 2 deletions apps/app/scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,16 @@ const MIN_PRECOMPRESS_BYTES = 1024;
* budget, so an unrun precompression step could hide real growth. Treat it as
* an error rather than guessing a size.
*/
function measureClosure(chunks, forbiddenPackages, brotliSizeOf) {
function measureClosure(
chunks,
forbiddenPackages,
forbiddenModules,
brotliSizeOf,
) {
const forbidden = new Set(forbiddenPackages);
const forbiddenSourceModules = new Set(forbiddenModules);
const offenders = new Map();
const moduleOffenders = new Map();
const missingBrotli = [];
let bytes = 0;
let brotliBytes = 0;
Expand All @@ -77,8 +84,21 @@ function measureClosure(chunks, forbiddenPackages, brotliSizeOf) {
if (!offenders.has(pkg)) offenders.set(pkg, []);
offenders.get(pkg).push(chunk.fileName);
}
for (const sourceModule of chunk.modules ?? []) {
if (!forbiddenSourceModules.has(sourceModule)) continue;
if (!moduleOffenders.has(sourceModule)) {
moduleOffenders.set(sourceModule, []);
}
moduleOffenders.get(sourceModule).push(chunk.fileName);
}
}
return { bytes, brotliBytes, missingBrotli, offenders };
return {
bytes,
brotliBytes,
missingBrotli,
offenders,
moduleOffenders,
};
}

const brotliSizeOf = (fileName) => {
Expand All @@ -89,6 +109,7 @@ const brotliSizeOf = (fileName) => {
const boot = measureClosure(
stats.bootChunks,
budget.forbiddenBootPackages,
budget.forbiddenBootModules ?? [],
brotliSizeOf,
);

Expand Down Expand Up @@ -173,6 +194,11 @@ for (const [pkg, chunks] of boot.offenders) {
`${pkg} is in the boot payload (${chunks.join(", ")}). It must load on demand.`,
);
}
for (const [sourceModule, chunks] of boot.moduleOffenders) {
failures.push(
`${sourceModule} is in the boot payload (${chunks.join(", ")}). It must load on demand.`,
);
}
failures.push(...onDemandFailures);

const failingRouteChunkLists = [];
Expand All @@ -189,6 +215,7 @@ for (const [routeName, routeBudget] of Object.entries(
const route = measureClosure(
closure.chunks,
routeBudget.forbiddenPackages,
routeBudget.forbiddenModules ?? [],
Comment thread
ymichael marked this conversation as resolved.
brotliSizeOf,
);
console.log(
Expand Down Expand Up @@ -222,6 +249,11 @@ for (const [routeName, routeBudget] of Object.entries(
`${pkg} is in the ${routeName} closure (${chunks.join(", ")}). It must load on demand (behind React.lazy or import()).`,
);
}
for (const [sourceModule, chunks] of route.moduleOffenders) {
routeFailures.push(
`${sourceModule} is in the ${routeName} closure (${chunks.join(", ")}). It must load on demand (behind React.lazy or import()).`,
);
}
if (routeFailures.length > 0) {
failures.push(...routeFailures);
failingRouteChunkLists.push([routeName, closure.chunks]);
Expand Down
39 changes: 38 additions & 1 deletion apps/app/src/bundle-budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ const chunks: BundleStatsChunkInput[] = [
imports: ["assets/boot-shared.js"],
}),
chunk("assets/boot-shared.js", {
moduleIds: ["/repo/node_modules/react/index.js"],
moduleIds: [
"/repo/node_modules/react/index.js",
"/repo/apps/app/src/components/dialogs/ProjectPathDialog.tsx",
],
}),
chunk("assets/SplitWorkspaceRoute.js", {
facadeModuleId: "/repo/apps/app/src/views/SplitWorkspaceRoute.tsx",
Expand Down Expand Up @@ -70,6 +73,9 @@ describe("computeBundleStats", () => {
"assets/boot-shared.js",
"assets/index.js",
]);
expect(stats.bootChunks[0]?.modules).toEqual([
"src/components/dialogs/ProjectPathDialog.tsx",
]);
const route = stats.routeClosures.SplitWorkspaceRoute;
if (route === undefined) throw new Error("expected the route closure");
expect(route.entry).toBe("assets/SplitWorkspaceRoute.js");
Expand Down Expand Up @@ -174,6 +180,37 @@ describe("check-bundle-budget", () => {
);
});

it("fails when a forbidden source module reaches the route closure", async () => {
const result = await runCheck(
await writeFixture({
...passingBudget,
routeClosures: {
SplitWorkspaceRoute: {
...passingBudget.routeClosures.SplitWorkspaceRoute,
forbiddenModules: ["src/lib/x.ts"],
},
},
}),
);
expect(result.code).toBe(1);
expect(result.output).toContain(
"src/lib/x.ts is in the SplitWorkspaceRoute closure (assets/route-only.js)",
);
});

it("fails when an on-demand source module reaches the boot closure", async () => {
const result = await runCheck(
await writeFixture({
...passingBudget,
forbiddenBootModules: ["src/components/dialogs/ProjectPathDialog.tsx"],
}),
);
expect(result.code).toBe(1);
expect(result.output).toContain(
"src/components/dialogs/ProjectPathDialog.tsx is in the boot payload (assets/boot-shared.js)",
);
});

it("fails when the route closure grows past its ratchet", async () => {
// Two 2 KiB chunks in the closure; a 3 KiB raw budget is exceeded while
// the brotli budget (2 x 100 B) is not.
Expand Down
2 changes: 2 additions & 0 deletions apps/app/src/check-bundle-budget.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const scriptPath = resolve(
const KATEX_GATE = "src/components/ui/markdown-katex.ts";

interface ChunkSpec {
modules?: string[];
packages?: string[];
imports?: string[];
facade?: string | null;
Expand All @@ -24,6 +25,7 @@ function chunk(fileName: string, spec: ChunkSpec = {}): BundleChunk {
return {
fileName,
bytes: 512,
modules: spec.modules ?? [],
packages: spec.packages ?? [],
imports: spec.imports ?? [],
facade: spec.facade ?? null,
Expand Down
11 changes: 10 additions & 1 deletion apps/app/src/components/dialogs/ConfirmDeleteDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export function ConfirmDeleteDialogContent({
interface ConfirmDeleteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
accessibleLabel?: string;
accessibleDescription?: string;
children: ReactNode;
}

Expand All @@ -75,11 +77,18 @@ interface ConfirmDeleteDialogProps {
export function ConfirmDeleteDialog({
open,
onOpenChange,
accessibleLabel,
accessibleDescription,
children,
}: ConfirmDeleteDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>{open ? children : null}</DialogContent>
<DialogContent
aria-label={accessibleLabel}
aria-description={accessibleDescription}
>
{open ? children : null}
</DialogContent>
</Dialog>
);
}
44 changes: 33 additions & 11 deletions apps/app/src/components/dialogs/ProjectMachineSetupDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
import {
lazy,
Suspense,
useEffect,
useState,
type FormEvent,
type ReactNode,
} from "react";
import {
getProjectPathValidationMessage,
normalizeProjectPathInput,
Expand All @@ -16,7 +23,6 @@ import {
import { Icon } from "@bb/shared-ui/icon";
import { Input } from "@bb/shared-ui/input";
import { cn } from "@bb/shared-ui/lib/utils";
import { RemotePathBrowser } from "@/components/dialogs/RemotePathBrowser";
import { useAddProjectSource } from "@/hooks/mutations/project-mutations";
import {
isHostPathMissing,
Expand All @@ -26,6 +32,12 @@ import { useHostCloneDefaultPath } from "@/hooks/queries/host-queries";
import { BbHttpError } from "@bb/sdk/browser";
import { getMutationErrorMessage } from "@/lib/mutation-errors";

const RemotePathBrowserChunk = lazy(() =>
import("@/components/dialogs/RemotePathBrowser").then(
({ RemotePathBrowser }) => ({ default: RemotePathBrowser }),
),
);

export interface ProjectMachineSetupDialogTarget {
projectId: string;
projectName: string;
Expand Down Expand Up @@ -306,15 +318,25 @@ export function ProjectMachineSetupDialogContent({
</div>
) : null}
{option === "folder" ? (
<RemotePathBrowser
hostId={target.hostId}
allowCreateFolder={false}
onDirectoryChange={(directory) => {
setFolderPath(directory);
setValidationMessage(null);
}}
disabled={pending}
/>
<Suspense
fallback={
<div
aria-hidden="true"
className="min-h-32"
data-remote-path-browser-placeholder=""
/>
}
>
<RemotePathBrowserChunk
hostId={target.hostId}
allowCreateFolder={false}
onDirectoryChange={(directory) => {
setFolderPath(directory);
setValidationMessage(null);
}}
disabled={pending}
/>
</Suspense>
) : null}
{pending && option === "clone" ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
Expand Down
36 changes: 35 additions & 1 deletion apps/app/src/components/dialogs/ProjectPathDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { Host } from "@bb/domain";
import { Dialog, DialogContent } from "@bb/shared-ui/dialog";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ProjectPathDialog } from "./ProjectPathDialog";
import {
ProjectPathDialogContent,
type ProjectPathDialogProps,
} from "./ProjectPathDialog";

vi.mock("@/components/dialogs/RemotePathBrowser", () => ({
RemotePathBrowser: ({
Expand Down Expand Up @@ -55,6 +59,36 @@ const offline = host({
});
const offlineKunst = host({ ...kunst, status: "disconnected" });

function ProjectPathDialog({
target,
pending = false,
platform,
hostId,
hostName,
hosts,
onOpenChange,
onSubmit,
}: ProjectPathDialogProps) {
return (
<Dialog open={target !== null} onOpenChange={onOpenChange}>
<DialogContent>
{target ? (
<ProjectPathDialogContent
key={target.kind === "create" ? "create" : target.projectId}
target={target}
pending={pending}
platform={platform}
hostId={hostId}
hostName={hostName}
hosts={hosts}
onSubmit={onSubmit}
/>
) : null}
</DialogContent>
</Dialog>
);
}

afterEach(() => {
cleanup();
vi.clearAllMocks();
Expand Down
Loading
Loading