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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions docs/explorations/2026-10-24-select-and-mime-table.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 0 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
5 changes: 2 additions & 3 deletions lib/converter.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions packages/converter/src/document-converter.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 2 additions & 8 deletions packages/shared/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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`
Expand Down
16 changes: 8 additions & 8 deletions packages/shared/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,20 @@ 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';
```

## 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.
17 changes: 5 additions & 12 deletions packages/shared/src/document-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getMime } from 'ranuts/utils';
import type { DocumentType } from './document-types';

/**
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
*/
Expand Down
31 changes: 26 additions & 5 deletions test/e2e/editor-startup-payload.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 }) => {
Expand All @@ -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();
});
18 changes: 11 additions & 7 deletions test/unit/document-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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', () => {
Expand Down
4 changes: 0 additions & 4 deletions test/unit/onlyoffice-editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => (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,
Expand Down
Loading