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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added
- **`WorkerDocxodus.prepare()` — comparison-path warmup** (consumer issue JSv4/crowdsourced-redlines-js#2). `createWorkerDocxodus()` warms the .NET WASM runtime but does **not** load the comparison assemblies — the runtime defers `Docxodus.*.wasm` and its `System.*.wasm` dependents until the first real comparison, so the first `compareDocuments()` paid an extra ~3s of pure assembly-load latency. The new `prepare(): Promise<void>` pays that cost up front: it runs a complete comparison inside the worker against two tiny seed documents constructed in-memory on the .NET side (no caller IO, no seed fixtures to ship), forcing every assembly the engine touches to resolve. After `await prepare()`, the next `compareDocuments()` / `compareDocumentsToHtml()` triggers no further `.wasm` fetches. The method is idempotent (repeated/concurrent calls share one in-flight warmup and resolve immediately once complete) and concurrent-safe (a `compareDocuments()` issued while a `prepare()` is in flight does not double-load assemblies). Implemented as a new `Warmup()` `[JSExport]` on `DocumentComparer`, a `"prepare"` worker message, and the `WorkerDocxodus.prepare()` proxy method. Tests: `npm/tests/worker-prepare.spec.ts` (verifies via page-level `.wasm` request monitoring that prepare loads `Docxodus.wasm`, the following compare loads none, idempotency resolves <50ms, and concurrent prepare+compare never double-loads).

## [6.1.0] - 2026-05-28

### Changed
Expand Down
30 changes: 30 additions & 0 deletions npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,36 @@ const metadata = await docxodus.getDocumentMetadata(docxFile);
docxodus.terminate();
```

#### First-call warmup

`createWorkerDocxodus()` warms the .NET WASM runtime, but the **comparison code
path is not exercised until your first `compareDocuments()`**. That first call
pays a one-time warmup cost (comparison-assembly initialization + JIT of the
diff/XML engine) — roughly **2× the latency** of every subsequent compare.

`prepare()` is an **optional** method that pays this cost up front. Call it once
after creating the worker — during app boot, or while the user is still picking
files — so the first user-triggered comparison is already hot. It does **not**
run automatically; if you skip it, the first compare simply absorbs the warmup
as before.

```typescript
const docxodus = await createWorkerDocxodus({ wasmBasePath: '/wasm/' });

// Optional: warm the comparison path ahead of the first user action.
await docxodus.prepare();

// Now hot — the first real compare runs at steady-state speed and triggers
// no further .wasm fetches.
const redlined = await docxodus.compareDocuments(original, modified);
```

`prepare()` is idempotent (repeated calls share one in-flight warmup and resolve
immediately once complete), needs no input documents or seed files of your own
(it builds tiny seed documents inside the worker), and is concurrent-safe —
issuing a `compareDocuments()` while a `prepare()` is still in flight will not
double-load assemblies.

### React Hooks

#### `useDocxodus(wasmBasePath?: string)`
Expand Down
28 changes: 28 additions & 0 deletions npm/src/docxodus.worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,23 @@ function handleSessionMoveAnnotation(
}
}

/**
* Handle prepare request — warm the comparison code path so the next
* compareDocuments triggers no further WASM assembly fetches.
*/
function handlePrepare(): { error?: string } {
const exports = ensureInitialized();
try {
const result = exports.DocumentComparer.Warmup();
if (isErrorResponse(result)) {
return parseError(result);
}
return {};
} catch (error) {
return { error: String(error) };
}
}

/**
* Handle getVersion request.
*/
Expand Down Expand Up @@ -647,6 +664,17 @@ self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
break;
}

case "prepare": {
const result = handlePrepare();
response = {
id: request.id,
type: "prepare",
success: !result.error,
error: result.error,
};
break;
}

case "sessionOpen": {
const sessionOpenRequest = request as WorkerSessionOpenRequest;
const result = handleSessionOpen(sessionOpenRequest);
Expand Down
24 changes: 24 additions & 0 deletions npm/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,12 @@ export interface DocxodusWasmExports {
) => string;
};
DocumentComparer: {
/**
* Force the comparison code path hot by running a real comparison against
* tiny in-memory seed documents. Returns "ok" or a JSON error object.
* Idempotent — assemblies load only once.
*/
Warmup: () => string;
CompareDocuments: (
originalBytes: Uint8Array,
modifiedBytes: Uint8Array,
Expand Down Expand Up @@ -2009,6 +2015,7 @@ export type WorkerRequestType =
| "getRevisions"
| "getDocumentMetadata"
| "getVersion"
| "prepare"
| "sessionOpen"
| "sessionClose"
| "sessionAddAnnotation"
Expand Down Expand Up @@ -2099,6 +2106,14 @@ export interface WorkerGetVersionRequest extends WorkerRequestBase {
type: "getVersion";
}

/**
* Warm up the comparison code path so the next compare triggers no further
* WASM assembly fetches. Carries no payload.
*/
export interface WorkerPrepareRequest extends WorkerRequestBase {
type: "prepare";
}

/**
* Open a DocxSession in the worker.
*/
Expand Down Expand Up @@ -2173,6 +2188,7 @@ export type WorkerRequest =
| WorkerGetRevisionsRequest
| WorkerGetDocumentMetadataRequest
| WorkerGetVersionRequest
| WorkerPrepareRequest
| WorkerSessionOpenRequest
| WorkerSessionCloseRequest
| WorkerSessionAddAnnotationRequest
Expand Down Expand Up @@ -2253,6 +2269,13 @@ export interface WorkerGetVersionResponse extends WorkerResponseBase {
version?: VersionInfo;
}

/**
* Response from prepare request. Carries no payload beyond success/error.
*/
export interface WorkerPrepareResponse extends WorkerResponseBase {
type: "prepare";
}

/**
* Response from sessionOpen request.
*/
Expand Down Expand Up @@ -2294,6 +2317,7 @@ export type WorkerResponse =
| WorkerGetRevisionsResponse
| WorkerGetDocumentMetadataResponse
| WorkerGetVersionResponse
| WorkerPrepareResponse
| WorkerSessionOpenResponse
| WorkerSessionCloseResponse
| WorkerSessionEditResponse;
Expand Down
46 changes: 46 additions & 0 deletions npm/src/worker-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
WorkerGetRevisionsResponse,
WorkerGetDocumentMetadataResponse,
WorkerGetVersionResponse,
WorkerPrepareResponse,
WorkerSessionOpenResponse,
WorkerSessionEditResponse,
WorkerDocxodusOptions,
Expand Down Expand Up @@ -210,6 +211,30 @@ export interface WorkerDocxodus {
*/
getVersion(): Promise<VersionInfo>;

/**
* Pre-warm the comparison code path.
*
* The 10s runtime warmup paid by {@link createWorkerDocxodus} does not load
* the comparison assemblies — the .NET WASM runtime defers
* `Docxodus.*.wasm` and its `System.*.wasm` dependents until the first
* {@link compareDocuments} call, which then costs ~3s of pure assembly-load
* latency. Call `prepare()` after creating the worker to pay that cost ahead
* of any user action; once it resolves, the next {@link compareDocuments}
* (or {@link compareDocumentsToHtml}) triggers no further `.wasm` fetches.
*
* Semantics:
* - **Idempotent.** Repeated calls share one in-flight warmup and resolve
* immediately once it has completed.
* - **No caller IO.** No seed files to fetch, no inputs to construct — the
* seed documents are built inside the worker.
* - **Concurrent-safe.** `prepare()` and `compareDocuments()` may be called
* in any order; a `compareDocuments()` issued while a `prepare()` is in
* flight does not double-load assemblies.
*
* @returns A Promise that resolves when the comparison path is fully hot.
*/
prepare(): Promise<void>;

/**
* Open a {@link WorkerDocxSession} for surgical annotation editing inside
* the worker. The document bytes are transferred to the worker (zero-copy).
Expand Down Expand Up @@ -289,6 +314,11 @@ export async function createWorkerDocxodus(
// Track if worker is active
let isWorkerActive = true;

// Cached warmup promise. Set on the first prepare() and reused thereafter so
// repeated/concurrent calls share a single in-flight (or completed) warmup.
// Reset to null on failure so a later prepare() can retry.
let preparePromise: Promise<void> | null = null;

// Handle worker messages
worker.onmessage = (event: MessageEvent<WorkerResponse | { type: "ready" }>) => {
const response = event.data;
Expand Down Expand Up @@ -455,6 +485,22 @@ export async function createWorkerDocxodus(
return response.version!;
},

prepare(): Promise<void> {
// Idempotent: hand back the existing warmup if one is in flight or done.
if (preparePromise) {
return preparePromise;
}
preparePromise = sendRequest<WorkerPrepareResponse>({
id: generateId(),
type: "prepare",
}).then(() => undefined);
// On failure, clear the cache so a subsequent prepare() can retry.
preparePromise.catch(() => {
preparePromise = null;
});
return preparePromise;
},

async openDocxSession(
document: File | Uint8Array,
settings?: DocxSessionSettings
Expand Down
Loading
Loading