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
10 changes: 10 additions & 0 deletions .github/workflows/ci-web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,11 @@ jobs:
- name: Generated bundle merge driver
run: python3 tests/test_install_git_hooks.py

# A budget miss must not hide the checks and tests below: the step records its outcome and
# the job fails on it in the last step. A failed build still stops everything after it.
- name: Check diff viewer eager JS budget
id: eager-js-budget
continue-on-error: true
run: node ./scripts/check-webviews-diff-budget.mjs

- name: Verify React Compiler for webviews
Expand Down Expand Up @@ -342,6 +346,12 @@ jobs:
CMUX_WEB_TEST_SHARD_COUNT: ${{ matrix.shard_count }}
run: ../scripts/ci/run-webviews-tests.sh

- name: Fail on the eager JS budget
if: ${{ !cancelled() && steps.eager-js-budget.outcome == 'failure' }}
run: |
echo "::error::The webviews eager JS budget failed; see the step 'Check diff viewer eager JS budget'."
exit 1

# React Doctor is independent of the per-file browser tests. Keep it on its
# own required lane so the long-running shard 1 does not serialize this
# diagnostic after the test suite.
Expand Down
11 changes: 11 additions & 0 deletions webviews/src/pages/markdown/highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ import type { CodeToken } from "./editor";
const LIGHT = "cmux-markdown-light";
const DARK = "cmux-markdown-dark";

/**
* Starts fetching Shiki's core and regex engine without waiting on them. They stay out of the
* page's first load (the markdown draws first); the page calls this at boot so they are warm by
* the time a code block asks for colors, and code does not stay plain any longer than its grammar
* takes to load.
*/
export function preloadHighlighter(): void {
void import("shiki/core").catch(() => undefined);
void import("shiki/engine/javascript").catch(() => undefined);
}

/** Shiki's language id for a fence info string (`ts`, `shell`, `TypeScript` ...), or null. */
export async function languageId(language: string): Promise<string | null> {
const { bundledLanguages, bundledLanguagesAlias } = await import("shiki/langs");
Expand Down
33 changes: 26 additions & 7 deletions webviews/src/pages/markdown/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// (host.ts lists the ops); the dev server installs a stand-in (devBridge.ts) before this runs.
// DESKTOP-FEEL (R139): the shared desktop layer loads first.
import "../shared/desktop";
import { lazy, Suspense, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { applyDiffViewerAppearance, followHostTerminalTheme, resolveDiffViewerAppearance } from "../../appearance";
import { createPageClient, type PageClient } from "../shared/pageClient";
Expand All @@ -12,7 +13,7 @@ import { DiagramLibraries, renderDiagram } from "./diagrams";
import { MarkdownEditor, type EditorLabel, type MarkdownEditorHost } from "./editor";
import table from "./generated/strings.json";
import type { ThemeRegistrationAny } from "shiki/core";
import { CodeHighlighter, codeThemes } from "./highlight";
import { CodeHighlighter, codeThemes, preloadHighlighter } from "./highlight";
import { MARKDOWN_FLUSH_OP, MARKDOWN_LIST_FILES_OP, MARKDOWN_RESOLVE_LINKS_OP, resolveImageURL } from "./host";
import type { LinkLabel } from "./linkEditing";
import { LinkRouter } from "./linkRouter";
Expand All @@ -25,8 +26,6 @@ import type { ReadingSettingsActions } from "./ReadingSettings";
import { MARKDOWN_READING, bindMarkdownLook, markdownCodeTheme } from "./settings";
import { MarkdownFontZoom, isMarkdownZoomCommand } from "./fontZoom";
import { L } from "./strings";
import { MarkdownEmptyState } from "../../viewer-empty/MarkdownEmptyState";
import { viewerEmptyStrings } from "../../viewer-empty/strings";
import { UiProvider, languageDirection } from "../../ui/UiProvider";
import "../shared/pageBase.css";
import "../../ui/ui.css";
Expand Down Expand Up @@ -126,6 +125,25 @@ function readingActions(store: MarkdownStore, zoom: MarkdownFontZoom): ReadingSe
};
}

/** The empty state as a lazy component, its chunk requested at once. */
function lazyEmptyState(client: PageClient, store: MarkdownStore): () => ReactNode {
const module = Promise.all([
import("../../viewer-empty/MarkdownEmptyState"),
import("../../viewer-empty/strings"),
]).then(([{ MarkdownEmptyState }, { viewerEmptyStrings }]) => {
const strings = viewerEmptyStrings();
return {
default: () => <MarkdownEmptyState client={client} strings={strings} open={(path) => store.openFile(path)} />,
};
});
const EmptyState = lazy(() => module);
return () => (
<Suspense fallback={null}>
<EmptyState />
</Suspense>
);
}

export function mountMarkdownPage(root: HTMLElement, client: PageClient | null = createPageClient()): MarkdownStore {
const store = new MarkdownStore(client);
const strings = createStrings(table);
Expand Down Expand Up @@ -219,10 +237,10 @@ export function mountMarkdownPage(root: HTMLElement, client: PageClient | null =
codeTheme: (names, appearance) =>
void host.setCodeThemes(codeThemes(names, appearance))?.then(() => editor?.refreshHighlight()),
});
const emptyStrings = viewerEmptyStrings();
const emptyState = client
? () => <MarkdownEmptyState client={client} strings={emptyStrings} open={(path) => store.openFile(path)} />
: undefined;
// The empty state (no file yet) and its string table load off the first paint's path: the
// import starts now, beside the host's config round trip that decides between a file and the
// empty state, so it is in hand before the empty phase renders (Suspense covers the rare miss).
const emptyState = client ? lazyEmptyState(client, store) : undefined;
createRoot(root).render(
<UiProvider container={root} popupHost={appPopupHost(client)} dir={languageDirection(strings.language)}>
<MarkdownPage
Expand All @@ -238,6 +256,7 @@ export function mountMarkdownPage(root: HTMLElement, client: PageClient | null =
</UiProvider>,
);
void store.start();
preloadHighlighter();
return store;
}

Expand Down
29 changes: 23 additions & 6 deletions webviews/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,23 @@ export default defineConfig({
// agent session does not load the diff renderer at startup.
// `markdown-vendor` (Milkdown, ProseMirror, remark) is shared by the agent pane's
// composer and the markdown editor page.
// The viewer pages' shared modules (appearance, page client, strings, terminal syntax theme,
// the empty states): one chunk named as before, so the code editor importing a new
// combination of them does not split the chunk the diff and markdown pages load.
{ name: "pickerModel", test: (id: string) => VIEWER_SHARED.test(id), priority: 0 },
// The viewer pages' shared modules, in two fixed chunks so the code editor importing a new
// combination of them does not split the chunks the diff and markdown pages load:
// `pageShared` (appearance, page client, i18n, terminal syntax theme, the host ops, `cx`) is on
// every page's first load; `pickerModel` (the empty states, the path picker model and
// their ~60 KB string table) loads only where a page imports it. The markdown page
// imports its empty state lazily, so this chunk stays out of its first load.
{ name: "pageShared", test: (id: string) => VIEWER_SHARED.test(id), priority: 0 },
{ name: "pickerModel", test: (id: string) => VIEWER_EMPTY.test(id), priority: 0 },
// Tiny text utilities that both shiki's hast serializer and the markdown page's
// mdast serializer use. Inside `shiki-core` they made the markdown page load all of
// shiki eagerly (~220 KB); the page loads shiki only once a code block needs colors.
{
name: "text-utils",
test: (id: string) => /\/node_modules\/(ccount|zwitch)\//.test(id),
priority: 5,
includeDependenciesRecursively: false,
},
...["shiki-core", "vendor", "diff-vendor", "markdown-vendor"].map((name, index) => ({
name,
test: (id: string) => sharedChunkName(id) === name,
Expand All @@ -199,9 +212,13 @@ export default defineConfig({
const EDITOR_OWN_COPIES =
/\/webviews\/src\/(pages\/shared\/(pageStreams\.ts|desktop\.ts|desktop\.css)|diff-languages\/(detect|pack)\.ts)$/;

/** The modules of the viewer pages' shared `pickerModel` chunk (diff, markdown and editor pages). */
/** The modules of the viewer pages' shared `pageShared` chunk (diff, markdown and editor pages). */
const VIEWER_SHARED =
/\/webviews\/src\/(appearance\.ts|syntax-colors\.ts|pierre-options\.ts|pages\/shared\/(pageClient|i18n)\.ts|viewer-empty\/(ops\.ts|drop\.ts|icons\.tsx|strings\.ts|time\.ts|EmptyState\.tsx|pickerModel\.ts|generated\/strings\.json))$/;
/\/webviews\/src\/(appearance\.ts|syntax-colors\.ts|pierre-options\.ts|pages\/shared\/(pageClient|i18n)\.ts|viewer-empty\/ops\.ts|ui\/cx\.ts)$/;

/** The viewer empty states' modules: the `pickerModel` chunk, lazy on the markdown page. */
const VIEWER_EMPTY =
/\/webviews\/src\/viewer-empty\/(drop\.ts|icons\.tsx|strings\.ts|time\.ts|EmptyState\.tsx|pickerModel\.ts|generated\/strings\.json)$/;

function lazyChunkName(id: string): string | null {
const diffLocale = id.match(/\/pages\/diff\/generated\/locales\/([^/]+)\.json$/);
Expand Down