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
9 changes: 6 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,9 +186,12 @@ notes. Entries describe what users experience, not internal refactors.

### Changed

- The editor page fetches about a fifth less JavaScript before it can start
(281 KB instead of 350 KB compressed). Browser-agent text reading and the
install prompt now load when they are first needed.
- The editor page fetches less than half the JavaScript it did before it can
start (163 KB instead of 350 KB compressed). Browser-agent text reading and
the install prompt load when first needed, and an unused character-set
detector is no longer shipped.
- The local history page no longer downloads the editor to show its list
(122 KB instead of 148 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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ lib/ # 应用层(纯 TypeScript,只在本站点用)
session.ts # 会话身份:mint docId 并写进 ?saved=<id>
recovery.ts # 启动恢复条(Office Document Recovery 模型)
types.ts / index.ts # 记录形状与保留期工具 / 对外装配(含 savedToDisk 回调注入)
preferences.ts / relative-time.ts # 自动保存开关与"5 分钟前"——/history 只能从这两个、types、store 取东西;
# import recovery / autosave 会把整套编辑器拉进历史页(editor-startup-payload.spec 钉住)
history-page.ts # /history 页面(第三个 vite entry,见 history.html)
sw-update.ts # SW 更新策略的编辑器一侧(有文档打开时不提升 worker);落地页一侧在 public/sw-register.js
agent-plugin/ # Agent 协同编辑:editor-bridge(直调 window.editor)、tools、ui/
Expand Down
106 changes: 106 additions & 0 deletions docs/explorations/2026-10-24-jschardet-and-history-payload.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Startup payload, round two: jschardet and the history page

Follow-up to `2026-10-24-editor-startup-payload.md`. Measuring the startup
graph again, module by module through sourcemaps, turned up two more problems.

## jschardet: 332 KB of an entry chunk nobody called

After round one, the editor's entry chunk was 417 KB, and 466 KB of its source
was jschardet, a statistical charset detector.

Nothing in this app detects charsets statistically. CSV decoding uses
`decodeTextBytes` (`ranuts/utils/binary`), which tries a strict utf-8 decode,
then gb18030, then latin1, without jschardet. The import came from
`ranuts/utils/str.ts`, which imported `detect` from jschardet at the top for
three functions: `checkEncoding`, `arrayBufferToString` and `transformText`.
The same file holds `MessageCodec`, and `lib/events.ts` uses it at startup.

jschardet is CommonJS. Bundlers treat its import as a side effect and cannot
remove it, so every consumer of anything in `str.ts` shipped the whole
detector.

The fix belongs in ranuts (chaxus/ran#443, released in 0.5.1): the three
functions moved to `utils/charset.ts`. `ranuts/utils` exports the same names.
A ranuts test bundles a `MessageCodec` consumer and a `checkEncoding` consumer
through Vite, and checks that the detector appears only in the second.

Before the release, a consumer-side `treeshake.moduleSideEffects` override in
`vite.config.ts` confirmed the number: editor entry chunk 417 KB → 85 KB. That
override was dropped in favour of the upstream fix. This PR only upgrades
ranuts and ranui to 0.5.1 (ranview 0.2.1 comes with ranui).

The upgrade also re-syncs the ranui files this repo keeps in `public/`
(`ran-tokens.css`, `ranui-iife/*`). The steps are the same ones `bin/build.sh`
runs on every build.

## The history page loaded the editor

CLAUDE.md describes `/history` as not loading the editor. In practice it did,
through two imports:

- `formatRelativeTime` from `lib/history/recovery.ts`, which imports
`lib/document.ts` and so the whole open pipeline;
- `isAutosaveEnabled` / `setAutosaveEnabled` from `lib/history/autosave.ts`,
which imports `onlyoffice/save-stream.ts`.

Both helpers are pure. They now live in `lib/history/relative-time.ts` and
`lib/history/preferences.ts`. The history page writes the preference with
`writeAutosavePreference`. The editor-side `setAutosaveEnabled` keeps its old
behavior: it writes the preference, then stops the running session. On the
history page that second step was always a no-op, because no session runs
there.

## Measurements

The static startup graph, summed per chunk from `.vite/manifest.json`:

| Page | Before (main `47ee1d3a`) | After |
| ------------------------- | ------------------------- | ------------------------ |
| `/editor` | 866 KB raw / 280 KB gzip | 537 KB raw / 163 KB gzip |
| `/history` | 476 KB raw / 148 KB gzip | 400 KB raw / 122 KB gzip |
| `/editor` vs. pre-round-1 | 1102 KB raw / 350 KB gzip | 537 KB raw / 163 KB gzip |

## Tests and reverse validation

`test/e2e/editor-startup-payload.spec.ts` now covers both pages:

- `/editor` must not contain the agent tools, the PWA library, or jschardet's
charset table (`windows-1255`).
- `/history` must not contain the save pipeline's `Save conversion failed`, or
jschardet.

Reverse validation:

- **ranuts back at 0.5.0:** the editor case failed on jschardet in the entry
chunk.
- **History page importing `history/recovery` and `history/autosave` again:**
the history case failed on the save pipeline in the `document` chunk.

## Left for later

All seven site languages' message tables (about 215 KB of source in one shared
chunk) load on both pages, though each page uses one. Loading them per locale
changes `@ranuts/shared/i18n`, whose `t()` is synchronous and is used by three
sites. That is a shared-contract change, not a payload fix.

## The ranui upgrade hid the assistant's portals under the panel

The first CI run of this PR failed one case: `agent-dictation.spec.ts`, docx.
Hovering the voice privacy hint timed out because the composer textarea was on
top of it.

- **Cause:** in ranui 0.5.1 the floating controller writes the portal layer
inline, as `z-index: var(--ran-dropdown-host-z-index, var(--ran-z-dropdown,
1100))`. Our `.agent-speech-popover` and `r-dropdown[role='listbox']` rules
set a plain `z-index: 100000`, and an inline style beats a stylesheet rule.
So both portals dropped to 1100, under the assistant panel at 10000.
- **Bisected:** main with only ranui moved to 0.5.1 fails the same way. With
0.5.0 the hint's portal sits at 100000.
- **Fix:** set `--ran-dropdown-host-z-index: 100000` instead, which ranui's
own inline value reads.
- **Second victim:** select lists opened from the panel go through the same
controller, so they were also drawn under it, and no test noticed.
`agent-portal-layer.spec.ts` now opens a settings select and asserts its
list is the topmost element at its centre.
- **Reverse validation:** with the stylesheet change reverted, both the new
case and the dictation case fail.
6 changes: 3 additions & 3 deletions lib/history-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ import '../styles/history.css';
import { saveFileToDisk } from 'ranuts/utils';
import { applyDocumentLanguage, getLanguage, localeHomePath, t, withLocale } from '@ranuts/shared/i18n';
import { getDocumentMimeType } from '@ranuts/shared/document-utils';
import { formatRelativeTime } from './history/recovery';
import { formatRelativeTime } from './history/relative-time';
import { clearAllHistory, deleteDoc, getLatestSnapshot, historyUsage, listDocs, pruneExpired } from './history/store';
import { stashPendingFile } from './pending-open';
import { isAutosaveEnabled, setAutosaveEnabled } from './history/autosave';
import { isAutosaveEnabled, writeAutosavePreference } from './history/preferences';
import { daysUntilExpiry, hasUnsavedWork, type HistoryDoc } from './history/types';
import { confirmDialog } from './confirm-dialog';

Expand Down Expand Up @@ -280,7 +280,7 @@ function buildAutosaveToggle(refresh: () => void): HTMLElement {
.attr('type', 'checkbox')
.attr('role', 'switch')
.on('change', (event: Event) => {
setAutosaveEnabled((event.target as HTMLInputElement).checked);
writeAutosavePreference((event.target as HTMLInputElement).checked);
refresh();
})
.build() as HTMLInputElement;
Expand Down
13 changes: 2 additions & 11 deletions lib/history/autosave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@
* never clear the unsaved-changes warning, and never touch the user's file.
*/
import { t } from '@ranuts/shared/i18n';
import { localStorageGetItem, localStorageSetItem } from 'ranuts/utils';
import { requestSaveDocument } from '../onlyoffice/save-stream';
import { getReadonlyMode } from '../onlyoffice/readonly';
import { getLastEditAt, hasUnsavedChanges } from '../unsaved-guard';
import { isEmbedMode } from '../embed-mode';
import { captureSavePoint, type SavePoint } from '../onlyoffice/save-point';
import { extensionOf, markOpened, putSnapshot } from './store';
import { isAutosaveEnabled, writeAutosavePreference } from './preferences';
import type { HistoryDoc, HistoryOrigin } from './types';

/**
Expand Down Expand Up @@ -66,8 +66,6 @@ export const IDLE_GRACE_MS = 2_000;
/** Consecutive export failures before this session gives up. */
export const MAX_CONSECUTIVE_FAILURES = 3;

const AUTOSAVE_PREF_KEY = 'document-autosave-enabled';

export interface AutosaveSessionInput {
/**
* The document's identity for this editing session.
Expand Down Expand Up @@ -110,15 +108,8 @@ export function shouldSnapshot(state: SnapshotDecision): boolean {
return state.now - state.lastEditAt >= IDLE_GRACE_MS;
}

export function isAutosaveEnabled(): boolean {
// Default on. Off by default would mean the feature does not exist for
// almost everyone, and the loss it prevents happens before anyone goes
// looking through settings.
return localStorageGetItem(AUTOSAVE_PREF_KEY) !== 'false';
}

export function setAutosaveEnabled(enabled: boolean): void {
localStorageSetItem(AUTOSAVE_PREF_KEY, enabled ? 'true' : 'false');
writeAutosavePreference(enabled);
if (!enabled) stopAutosaveSession();
}

Expand Down
2 changes: 1 addition & 1 deletion lib/history/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ export {
beginAutosaveSession,
clearDocumentSession,
getActiveDocumentId,
isAutosaveEnabled,
setAutosaveEnabled,
stopAutosaveSession,
} from './autosave';
export { isAutosaveEnabled } from './preferences';
export { clearAllHistory, deleteDoc, getLatestSnapshot, listDocs } from './store';
export type { HistoryDoc } from './types';
20 changes: 20 additions & 0 deletions lib/history/preferences.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* The autosave on/off preference. Kept apart from the scheduler in
* ./autosave.ts so the /history page can read and write it without importing
* the save pipeline (and with it the editor stack).
*/
import { localStorageGetItem, localStorageSetItem } from 'ranuts/utils';

const AUTOSAVE_PREF_KEY = 'document-autosave-enabled';

export function isAutosaveEnabled(): boolean {
// Default on. Off by default would mean the feature does not exist for
// almost everyone, and the loss it prevents happens before anyone goes
// looking through settings.
return localStorageGetItem(AUTOSAVE_PREF_KEY) !== 'false';
}

/** Store the preference only; an editor page also stops its running session (see setAutosaveEnabled). */
export function writeAutosavePreference(enabled: boolean): void {
localStorageSetItem(AUTOSAVE_PREF_KEY, enabled ? 'true' : 'false');
}
27 changes: 0 additions & 27 deletions lib/history/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,6 @@ import { getLatestSnapshot, readDocumentRecord } from './store';
import type { HistoryDoc } from './types';
import { readActiveSourceRecord } from '../active-document-source';

/** A short "5 minutes ago", for the history list's timestamps. */
export function formatRelativeTime(timestamp: number, now = Date.now()): string {
const seconds = Math.round((timestamp - now) / 1000);
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['second', 60],
['minute', 60],
['hour', 24],
['day', 7],
['week', 5],
];
let value = seconds;
let unit: Intl.RelativeTimeFormatUnit = 'second';
for (const [name, size] of units) {
unit = name;
if (Math.abs(value) < size) break;
value = Math.round(value / size);
}
try {
return new Intl.RelativeTimeFormat(document.documentElement.lang || undefined, { numeric: 'auto' }).format(
value,
unit,
);
} catch {
return new Date(timestamp).toLocaleString();
}
}

/**
* Reopen the document from its newest snapshot.
*
Expand Down
26 changes: 26 additions & 0 deletions lib/history/relative-time.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** A short "5 minutes ago", for the history list's timestamps. */
export function formatRelativeTime(timestamp: number, now = Date.now()): string {
const seconds = Math.round((timestamp - now) / 1000);
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['second', 60],
['minute', 60],
['hour', 24],
['day', 7],
['week', 5],
];
let value = seconds;
let unit: Intl.RelativeTimeFormatUnit = 'second';
for (const [name, size] of units) {
unit = name;
if (Math.abs(value) < size) break;
value = Math.round(value / size);
}
try {
return new Intl.RelativeTimeFormat(document.documentElement.lang || undefined, { numeric: 'auto' }).format(
value,
unit,
);
} catch {
return new Date(timestamp).toLocaleString();
}
}
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@
"@ranuts/converter": "workspace:*",
"@ranuts/shared": "workspace:*",
"pdfjs-dist": "5.4.624",
"ranui": "0.5.0",
"ranuts": "0.5.0"
"ranui": "0.5.1",
"ranuts": "0.5.1"
}
}
4 changes: 2 additions & 2 deletions packages/chat-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
},
"dependencies": {
"marked": "16.4.2",
"ranui": "0.5.0",
"ranuts": "0.5.0"
"ranui": "0.5.1",
"ranuts": "0.5.1"
},
"devDependencies": {
"typescript": "^6.0.3"
Expand Down
4 changes: 2 additions & 2 deletions packages/converter/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
},
"dependencies": {
"@ranuts/shared": "workspace:*",
"ranui": "0.5.0",
"ranuts": "0.5.0"
"ranui": "0.5.1",
"ranuts": "0.5.1"
},
"devDependencies": {
"typescript": "^6.0.3"
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"prepare": "tsc -p tsconfig.json"
},
"dependencies": {
"ranuts": "0.5.0"
"ranuts": "0.5.1"
},
"devDependencies": {
"typescript": "^6.0.3"
Expand Down
Loading
Loading