diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d08b0676..dabf6ab74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -199,6 +199,8 @@ notes. Entries describe what users experience, not internal refactors. - The editor and history pages load only the interface language you use rather than all seven, so the editor starts on 105 KB instead of 162 KB compressed, and the history page on 61 KB instead of 121 KB. +- The editor no longer loads the assistant's dropdown control or an unused + file-type table at startup (84 KB instead of 105 KB compressed). - Voice controls stay in place while recording or finishing. Explanations appear without moving the draft, and cancelling preserves the text typed before dictation. - **Configured assistants reopen in chat.** Enable a downloaded model or reconnect diff --git a/docs/explorations/2026-10-24-select-and-mime-table.md b/docs/explorations/2026-10-24-select-and-mime-table.md new file mode 100644 index 000000000..c3a0c95f2 --- /dev/null +++ b/docs/explorations/2026-10-24-select-and-mime-table.md @@ -0,0 +1,124 @@ +# Startup payload, round four: ranui select and the MIME table + +Follow-up to `2026-10-24-active-locale-only.md`. After the locale split, two +more startup items were found by attributing startup bytes to their source +packages through the sourcemaps. ranui's `select` is about 7.6 KB gzip on its +own and is on the editor page. ranuts' full MIME table +(`ranuts/dist/src/utils/mimeType.js`, 30 KB raw / 6.8 KB gzip) is on both +pages. + +## Result + +The static startup graph, summed per chunk from `.vite/manifest.json` +(gzip -9): + +| Page | Before (main `75c2cd96`) | After | +| ---------- | ------------------------ | ----------------------- | +| `/editor` | 337 KB raw / 105 KB gzip | 241 KB raw / 84 KB gzip | +| `/history` | 168 KB raw / 55 KB gzip | 169 KB raw / 56 KB gzip | + +The ranui 0.5.2 upgrade landed separately in #294. Measured on #293's main +(`ee33a4ef`), it is what took history from 62 KB to 55 KB gzip. On top of it, +this change moves only the editor: + +- Removing the select import takes the editor from 105 KB to 90 KB gzip. +- Replacing `getExtensions` takes the editor from 90 KB to 84 KB gzip. + +## ranui select: moved off the editor startup + +`index.ts` imported `ranui/select` at top level. Nothing on the editor page +renders an `r-select` until the assistant opens. Every assistant module that +renders one either imports the element itself (`panel.ts`, +`model-configuration-form.ts`) or is imported only by `panel.ts` +(`settings-view.ts`, `speech-input.ts`). The import was removed. The element +now registers when the assistant panel loads, which is the first use. + +The saving is larger than the select chunk alone. ranui's floating controller +and other helpers that only select needed leave the startup graph with it. +History does not import `index.ts`, so its bytes stay the same. Its gzip sum +still grows by about 1 KB at this step, only because Rolldown now emits ranui's +button as a file of its own, and each file is compressed separately. + +## The MIME table: held by ranui, then by our own fallback + +`packages/shared/src/document-utils.ts` was the expected holder, through +`getMimeTypeFromExtension` → `getMime`. That function had no production caller +(the final `rg` returned zero outside `docs/` and an unrelated method of the +same name in the vendored `x2t_helper.js`). Only its unit tests used it, and it +was deleted. That alone saved nothing. + +The real holder was in ranui 0.5.1's packaged `dist/utils-*.js`, which built a +namespace object over all of ranui's utils, including an uncalled +`getMimeTypeFromExtension` of its own +(`getMime(\`.${ext}\`) || 'application/octet-stream'`). A namespace object +references every member, so a bundler cannot drop any of them. Minimal Vite +builds in this repo confirmed it: + +| Entry (ranui 0.5.1) | MIME table in output | +| -------------------------------------- | -------------------- | +| `import 'ranui/button'` | no | +| `import 'ranui/icon'` / `ranui/modal` | no | +| `import 'ranui/input'` | yes | +| `import 'ranui/message'` | yes | +| `saveFileToDisk` / `createSignal` only | no | + +Both pages need `ranui/input` and `ranui/message`. The function was deleted +upstream in ranui 0.5.2 (published with ranuts 0.5.2 and ranview 0.2.2), which +also adds size budgets for those two entries. This repo picked it up in #294. + +The editor also kept the table through its own code. `lib/converter.ts` and +`packages/converter` fell back to `getExtensions(file.type)` for a file with +an empty name. Both now call `getDocumentExtension`, which reverses +`DOCUMENT_MIME_MAP` in `@ranuts/shared/document-utils`. For every supported +format it returns the same extension `getExtensions` did, except `ppt`: +`getExtensions` answered `pot`, which is not in `DOCUMENT_TYPE_MAP`. + +## Tests + +`test/e2e/editor-startup-payload.spec.ts`: + +- The editor's startup scripts contain no string unique to ranui's select chunk + (`ran-select-dropdown-option`). +- On a fresh editor, `customElements.get('r-select')` is undefined until the + assistant opens. After the settings view opens, it is defined and a select is + visible. +- Neither page's startup scripts contain a string unique to the MIME table + (`application/vnd.lotus-approach`). + +`test/unit/document-utils.test.ts`: every supported extension round-trips +through `getDocumentMimeType` → `getDocumentExtension`, and a MIME type outside +the document formats gives `''`. + +Reverse validation, each run against a variant with one fix undone: + +1. With `import 'ranui/select'` back in `index.ts`, both select cases fail + (`ranui select is in …/panel-viewport-*.js`, and the element is defined + before the assistant opens). +2. With `getExtensions` back in `lib/converter.ts`, the editor case fails + (`ranuts mimeType table is in …/document-*.js`). +3. With ranui and ranuts back at 0.5.1, both pages' cases fail + (`ranuts mimeType table is in …/utils-*.js`). + +With all fixes in place: `lint:ts`, `format:check`, all 252 unit files (6817 +tests), and 82 E2E cases pass. The E2E cases cover startup payload, entry +paths, the filename matrix, app smoke, the main site, history, the language +menu, assistant layout and workspace, and the embed API. + +One full unit run failed on two tests under machine load: +`agent-artifacts-view` and the 8.9 s `wllama-bounded-file-read`. Both pass on +their own, and they passed in a second full run. + +## CI race in the select case + +The first CI run failed this case on `E2E (Docker image) shard 1`, with no +visible `r-select` in settings. With no saved model, the panel checks the local +model cache asynchronously and switches itself to settings when nothing is +cached (`lib/agent-plugin/ui/panel.ts`, the `externalEntry` block at the end). +The gear button toggles. When the check finished before the click, the click +toggled the panel back to chat. Forcing that ordering locally (waiting for +`data-view="settings"` before the click) reproduces the CI error exactly. + +The case now waits for the cache summary to settle, then presses the gear only +when the panel is not already in settings, as `agent-portal-layer.spec.ts` +does. With the same forced ordering it passes, and 40 repeated runs of the spec +pass. diff --git a/index.ts b/index.ts index 68ff6862f..135c32ff6 100644 --- a/index.ts +++ b/index.ts @@ -20,7 +20,6 @@ import { initAnalytics } from './lib/analytics'; import { createControlPanel, hideControlPanel, hideLanding, showControlPanel } from './lib/ui'; import 'ranui/button'; import 'ranui/card'; -import 'ranui/select'; import { initWebMcp } from './lib/web-mcp'; import { createAgentEntry } from './lib/agent-entry'; import { hasUnsavedChanges, installUnsavedChangesGuard } from './lib/unsaved-guard'; diff --git a/lib/converter.ts b/lib/converter.ts index 3c4bdaac3..3e9315a0a 100644 --- a/lib/converter.ts +++ b/lib/converter.ts @@ -1,8 +1,7 @@ -import { getExtensions } from 'ranuts/utils'; import { t } from '@ranuts/shared/i18n'; import { X2TConverter, isHtmlDocument, preprocessDocxRuby } from '@ranuts/converter'; import { createEditorInstance, loadEditorApi } from './onlyoffice-editor'; -import { getDocumentType } from '@ranuts/shared/document-utils'; +import { getDocumentExtension, getDocumentType } from '@ranuts/shared/document-utils'; // Export types export type { @@ -42,7 +41,7 @@ export async function handleDocumentOperation(options: { await loadEditorApi(); if (options.isCurrent && !options.isCurrent()) throw new DOMException('Document open superseded', 'AbortError'); const { isNew, fileName, file, readonly = false } = options; - const fileType = fileName.split('.').pop() || getExtensions(file?.type || '')[0] || ''; + const fileType = fileName.split('.').pop() || getDocumentExtension(file?.type || ''); const _docType = getDocumentType(fileType); // The type the editor is asked to open as; differs from the file's own // extension only for the CSV case below. diff --git a/packages/converter/src/document-converter.ts b/packages/converter/src/document-converter.ts index 3f252ce5e..255989ea1 100644 --- a/packages/converter/src/document-converter.ts +++ b/packages/converter/src/document-converter.ts @@ -1,11 +1,11 @@ -import { createObjectURL, getExtensions, isZipContainer, scriptOnLoad } from 'ranuts/utils'; +import { createObjectURL, isZipContainer, scriptOnLoad } from 'ranuts/utils'; import type { BinConversionResult, ConversionResult, DocumentType, EmscriptenModule, } from '@ranuts/shared/document-types'; -import { BASE_PATH, DOCUMENT_TYPE_MAP } from '@ranuts/shared/document-utils'; +import { BASE_PATH, DOCUMENT_TYPE_MAP, getDocumentExtension } from '@ranuts/shared/document-utils'; import { extractDocxMediaUrls } from './docx-zip'; import { MIME_MAP, hasEditorBinSignature, saveFileToDisk } from './file-meta'; import { canStreamWasm, fetchWasmResponse, x2tInstantiateError } from './x2t-loading'; @@ -474,7 +474,7 @@ export class X2TConverter { await this.initialize(); const fileName = file.name; - const fileExt = fileName.split('.').pop() || getExtensions(file?.type)[0] || ''; + const fileExt = fileName.split('.').pop() || getDocumentExtension(file?.type); const documentType = this.getDocumentType(fileExt); try { diff --git a/packages/shared/CLAUDE.md b/packages/shared/CLAUDE.md index 02dc32d9c..de1ec7d0d 100644 --- a/packages/shared/CLAUDE.md +++ b/packages/shared/CLAUDE.md @@ -7,13 +7,7 @@ not generic. Editor / converter / agent layers all import from here. ## Import (prefer subpaths) ```ts -import { - getDocumentType, - getMimeTypeFromExtension, - parseReadonly, - BASE_PATH, - DOCUMENT_TYPE_MAP, -} from '@ranuts/shared/document-utils'; +import { getDocumentType, parseReadonly, BASE_PATH, DOCUMENT_TYPE_MAP } from '@ranuts/shared/document-utils'; import type { DocumentType, ConversionResult, @@ -29,7 +23,7 @@ import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; ## What each module is for - **document-utils** — pure helpers: classify a file (`getDocumentType`), MIME from - extension, the deployment `BASE_PATH` (handles `/document/` on GitHub Pages vs `/`), + extension and back (`getDocumentMimeType` / `getDocumentExtension`), the deployment `BASE_PATH` (handles `/document/` on GitHub Pages vs `/`), and `parseReadonly` for the `?readonly` query flag. - **document-types** — shared interfaces, incl. x2t/Emscripten module shapes. - **i18n** — `t(key)` over the 7 complete shell locales in `SHELL_LOCALES` diff --git a/packages/shared/README.md b/packages/shared/README.md index fcac3e46c..fd06618c2 100644 --- a/packages/shared/README.md +++ b/packages/shared/README.md @@ -16,7 +16,7 @@ pnpm add @ranuts/shared Prefer subpaths; `.` is a barrel that re-exports all of them. ```ts -import { getDocumentType, getMimeTypeFromExtension, parseReadonly, BASE_PATH } from '@ranuts/shared/document-utils'; +import { getDocumentType, parseReadonly, BASE_PATH } from '@ranuts/shared/document-utils'; import type { DocumentType, ConversionResult } from '@ranuts/shared/document-types'; import { t, getLanguage, setLanguage, getOnlyOfficeLang } from '@ranuts/shared/i18n'; import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; @@ -24,12 +24,12 @@ import { getDocmentObj, setDocmentObj } from '@ranuts/shared/store'; ## Modules -| Subpath | Exports | -| ------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `./document-utils` | `getDocumentType`, `getMimeTypeFromExtension`, `getBasePath`, `BASE_PATH`, `DOCUMENT_TYPE_MAP`, `parseReadonly` | -| `./document-types` | `DocumentType`, `ConversionResult`, `BinConversionResult`, `SaveEvent`, `EmscriptenModule`, `EmscriptenFileSystem` | -| `./i18n` | `t`, `getLanguage`, `setLanguage`, `getOnlyOfficeLang`, `Language`, `I18nMessages` (zh/en/ja/ko/de/fr/es/pt/ru) | -| `./store` | `getDocmentObj`, `setDocmentObj` (ranuts signal over `{ fileName, file?, url? }`) | +| Subpath | Exports | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| `./document-utils` | `getDocumentType`, `getDocumentMimeType`, `getDocumentExtension`, `getBasePath`, `BASE_PATH`, `DOCUMENT_TYPE_MAP`, `parseReadonly` | +| `./document-types` | `DocumentType`, `ConversionResult`, `BinConversionResult`, `SaveEvent`, `EmscriptenModule`, `EmscriptenFileSystem` | +| `./i18n` | `t`, `getLanguage`, `setLanguage`, `getOnlyOfficeLang`, `Language`, `I18nMessages` (en/zh-CN/ja/ko/de/es/pt; non-English tables load on demand) | +| `./store` | `getDocmentObj`, `setDocmentObj` (ranuts signal over `{ fileName, file?, url? }`) | -Depends on `ranuts` (getMime, createSignal, cookie/query/localStorage helpers). +Depends on `ranuts` (createSignal, cookie/query/localStorage helpers). Builds to `dist/` via `prepare` on install. diff --git a/packages/shared/src/document-utils.ts b/packages/shared/src/document-utils.ts index 2721a0ad2..7382c7943 100644 --- a/packages/shared/src/document-utils.ts +++ b/packages/shared/src/document-utils.ts @@ -1,4 +1,3 @@ -import { getMime } from 'ranuts/utils'; import type { DocumentType } from './document-types'; /** @@ -39,17 +38,6 @@ export function getDocumentType(fileType: string): string | null { return Object.hasOwn(DOCUMENT_TYPE_MAP, type) ? DOCUMENT_TYPE_MAP[type] : null; } -/** - * Get MIME type from file extension (using ranuts getMime utility) - * @param extension - File extension - * @returns string - MIME type - */ -export function getMimeTypeFromExtension(extension: string): string { - // Use ranuts getMime for common image types, fallback to image/png - const mime = getMime(extension?.toLowerCase() || ''); - return mime || 'image/png'; -} - // Canonical MIME map for the document formats this project saves/exports. // Single source of truth -- lib/onlyoffice-editor.ts and @ranuts/converter // both delegate here instead of keeping their own copies. @@ -78,6 +66,11 @@ export function getDocumentMimeType(fileNameOrExt: string): string { return DOCUMENT_MIME_MAP[ext] || 'application/octet-stream'; } +/** The document extension a MIME type names; '' outside the supported document formats. */ +export function getDocumentExtension(mimeType: string): string { + return Object.keys(DOCUMENT_MIME_MAP).find((ext) => DOCUMENT_MIME_MAP[ext] === mimeType) ?? ''; +} + /** * Document type mapping */ diff --git a/test/e2e/editor-startup-payload.spec.ts b/test/e2e/editor-startup-payload.spec.ts index 78cbf47cb..b6aecbfaf 100644 --- a/test/e2e/editor-startup-payload.spec.ts +++ b/test/e2e/editor-startup-payload.spec.ts @@ -15,6 +15,11 @@ const JSCHARDET = { owner: 'jschardet', probe: 'windows-1255' }; // An error the save pipeline (lib/onlyoffice/save-stream.ts) raises: the // history page lists and reopens records, it never saves through the editor. const EDITOR_STACK = { owner: 'editor save pipeline', probe: 'Save conversion failed' }; +// ranuts' full MIME table. ranui stopped holding it in 0.5.2, and the app looks +// MIME types up in its own document table (getDocumentExtension). +const MIME_TABLE = { owner: 'ranuts mimeType table', probe: 'application/vnd.lotus-approach' }; +// ranui's select; only the assistant renders one, and it imports the element itself. +const RANUI_SELECT = { owner: 'ranui select', probe: 'ran-select-dropdown-option' }; // Two message tables the page does not speak: only English and the active // locale belong at startup (packages/shared/src/i18n.ts awaits the latter). const OTHER_LOCALES = [ @@ -43,16 +48,14 @@ async function expectAbsent(page: Page, deferred: Array<{ owner: string; probe: } } -test('the editor startup scripts leave the agent tools, the PWA prompt, jschardet and other locales out', async ({ - page, -}) => { +test('the editor startup scripts leave optional modules and other locales out', async ({ page }) => { await page.goto('/editor?new=docx'); - await expectAbsent(page, [AGENT_TOOLS, PWA_INSTALL, JSCHARDET, ...OTHER_LOCALES]); + await expectAbsent(page, [AGENT_TOOLS, PWA_INSTALL, JSCHARDET, MIME_TABLE, RANUI_SELECT, ...OTHER_LOCALES]); }); test('the history page startup scripts leave the editor stack, jschardet and other locales out', async ({ page }) => { await page.goto('/history'); - await expectAbsent(page, [EDITOR_STACK, JSCHARDET, ...OTHER_LOCALES]); + await expectAbsent(page, [EDITOR_STACK, JSCHARDET, MIME_TABLE, ...OTHER_LOCALES]); }); test('a page opened in another language renders that language without reloading', async ({ page }) => { @@ -67,3 +70,21 @@ test('the PWA install element is still defined and mounted after startup', async await expect(page.locator('pwa-install#pwa-install')).toBeAttached({ timeout: 15_000 }); await expect.poll(() => page.evaluate(() => Boolean(customElements.get('pwa-install')))).toBe(true); }); + +test('the select element is defined once the assistant opens, without being in startup', async ({ page }) => { + test.setTimeout(90_000); + await page.addInitScript(() => localStorage.setItem('document-assistant-enabled', 'true')); + await page.goto('/editor?new=docx&locale=en'); + await page.waitForFunction(() => Boolean(window.__ooFrames.readyEditor())); + expect(await page.evaluate(() => Boolean(customElements.get('r-select')))).toBe(false); + await page.frameLocator('iframe[name="frameEditor"]').locator('.agent-sidebar-entry').click(); + // With no saved model the panel checks the model cache and may switch to + // settings by itself; the gear toggles, so only press it when it would open them. + const panel = page.locator('.agent-runtime-panel'); + await expect(page.locator('.agent-model-cache > summary')).toContainText('(0)'); + if ((await panel.getAttribute('data-view')) !== 'settings') + await page.locator('.agent-panel-settings-toggle').click(); + await expect(panel).toHaveAttribute('data-view', 'settings'); + await expect.poll(() => page.evaluate(() => Boolean(customElements.get('r-select')))).toBe(true); + await expect(page.locator('.agent-panel-settings r-select:visible').first()).toBeVisible(); +}); diff --git a/test/unit/document-utils.test.ts b/test/unit/document-utils.test.ts index e266cf31c..911392756 100644 --- a/test/unit/document-utils.test.ts +++ b/test/unit/document-utils.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from 'vitest'; import { DOCUMENT_TYPE_MAP, getBasePath, + getDocumentExtension, + getDocumentMimeType, getDocumentType, - getMimeTypeFromExtension, parseReadonly, } from '@ranuts/shared/document-utils'; @@ -27,13 +28,16 @@ describe('document utils', () => { expect(getDocumentType(extension)).toBe(kind); }); - it('normalizes extension casing for MIME lookup', () => { - expect(getMimeTypeFromExtension('XLSX')).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); - }); + it.each(['docx', 'doc', 'odt', 'rtf', 'txt', 'pdf', 'xlsx', 'xls', 'ods', 'csv', 'pptx', 'ppt', 'odp'])( + 'names %s back from its own MIME type', + (extension) => { + expect(getDocumentExtension(getDocumentMimeType(extension))).toBe(extension); + }, + ); - it('uses image/png fallback for unknown MIME extensions', () => { - expect(getMimeTypeFromExtension('unknown')).toBe('image/png'); - expect(getMimeTypeFromExtension('')).toBe('image/png'); + it('names no extension for a MIME type outside the document formats', () => { + expect(getDocumentExtension('image/png')).toBe(''); + expect(getDocumentExtension('')).toBe(''); }); it('parses the readonly query value for preview mode', () => { diff --git a/test/unit/onlyoffice-editor.test.ts b/test/unit/onlyoffice-editor.test.ts index 16eeb7365..2dcdc7fec 100644 --- a/test/unit/onlyoffice-editor.test.ts +++ b/test/unit/onlyoffice-editor.test.ts @@ -13,10 +13,6 @@ vi.mock('@ranuts/shared/i18n', () => ({ // caller passed; that the real locale strings fill them is i18n.test.ts. t: vi.fn((key: string, vars?: Record) => (vars ? `${key}${JSON.stringify(vars)}` : key)), })); -vi.mock(import('@ranuts/shared/document-utils'), async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, getMimeTypeFromExtension: vi.fn().mockReturnValue('image/png') }; -}); import { awaitFontSystem,