16 KiB
AcroForm JavaScript performance
Status: implemented; proposed as an independent performance PR. Last reviewed and updated: 2026-10-19. Measurements below are historical synthetic evidence collected before this split, not fresh measurements on upstream main. No private document is needed for the benchmark.
Architecture
crates/ui-egui/src/forms_ui.rsqueuesEdit::SetFieldValuefor a text commit or checkbox toggle.crates/automation/src/forms.rs::form_filluses the same edit API.crates/engine/src/lib.rs::Session::applyruns edits on a copy, preserving rollback and undo.run_editcallspdfcraft_forms::set_value_withwith an engineJsRunner.crates/forms/src/lib.rsvalidates and writes the edited value, then runs one calculation pass./COprecedes calculated fields omitted from/CO. Each script sees prior writes. There is no dependency graph or dependency-based filtering: dynamic references and script side effects are evaluated on every pass.crates/engine/src/js.rs::JsRunner::runsnapshots the fields, invokespdfcraft_js::run_shared, then converts changed JavaScript field states into form updates.crates/js/src/lib.rs::run_sharedstarts a scoped worker per script.run_herebuilds a fresh Boa context, installs the API, evaluates document scripts and evaluates the action. No script or bytecode cache is present in this AcroForm path. XFA uses a separate session implementation.crates/forms/src/scripting.rs::apply_changesapplies script side effects before the next calculation. It rereads the field snapshot when side effects occur. Calculation results equal to the existing value already skip value writes and appearance generation.- Checkboxes write the field
/Vand widget/AS; they enter the same calculation pass as text edits. JavaScript exposes the export value orOff, andisBoxChecked. Session::refresh_scopedwrites an incremental working revision, rereads fields and rebuilds the render pool.crates/ui-egui/src/editing.rs::apply_editcompares old/new fields and invalidates affected pages (including dependent fields on other pages). It does not blindly invalidate every page for an ordinary AcroForm fill. Rendering uses page/tile requests; widget appearance streams are separate from cached page rasters.
Findings and changes
The dominant measured cost was the bridge, not executing calculation logic:
JsRunnerexpanded a sparseOutcome::changedinto a complete second snapshot.changesthen searched the first snapshot linearly for each field, even when JavaScript had not changed any field directly. That repeated quadratic comparison ran after every calculation. It now compares the sparse outcome only, with an indexed original snapshot when needed. Empty outcomes return immediately; unchanged writes still produce no form updates. The console/button path uses the same conversion.- Once that was removed, field-state construction dominated. Reading every widget's display flags on every script traversed the same immutable pre-edit COS snapshot. This workload exceeds COS's 1,024-object editing-cache cap, repeatedly reparsing widget dictionaries. The runner now memoizes only those scalar display readings by widget reference. Each edit creates a new runner; current values, read-only/required flags and options still come from the current field snapshot. No global cache or object-cache limit changes were needed.
All calculation actions still execute in the same order. No dependency filtering, script
removal, context reuse, bytecode caching or recursive scheduling changes were introduced.
Equal calculated values already skipped appearance regeneration; this behavior remains.
The Release investigation also found a full field-state copy inside the JavaScript host on
every script invocation. pdfcraft_js::run_shared now accepts an immutable shared snapshot;
Arc::make_mut detaches it on the first field write. The engine retains the initial snapshot
for comparing effects. Read-only calculations no longer copy every field again. Each script
still gets a fresh context and thread; exceptions, globals and uncommitted writes remain
isolated. Scripts that write fields retain the previous copy isolation. The existing borrowed
run API remains available. No new dependencies were added.
Measurements
Historically measured under Rust 1.99.1 on Linux x86_64 with the default optimized development profile, two-job builds. Serial benchmark runs had no concurrent compilation. These are synthetic engine measurements, not measurements of the original Pathfinder PDF or GUI input latency. Five edits: ranks 1, checkbox on, ranks 2, checkbox off, ranks 0. Every edit runs all 190 custom calculations among 3,129 fields on a six-page document. Widgets are colocated on the first page; the fixture is an engine calculation stress test, not a layout replica.
| Synchronous edit | Before (ms) | After (ms) |
|---|---|---|
| ranks 1 | 4,425.274 | 424.465 |
| checkbox on | 4,378.642 | 458.716 |
| ranks 2 | 4,291.131 | 521.721 |
| checkbox off | 4,391.942 | 492.851 |
| ranks 0 | 4,322.570 | 453.392 |
| Median | 4,378.642 | 458.716 |
Median speedup: 9.55×, approximately 89.5% less synchronous editing time. The synthetic
scripts are deliberately small; this does not predict the exact result for other documents.
Raw logs and the baseline test executable are retained locally in target/form-performance/.
Historical serial runs collected with optional instrumentation (mean per edit; inclusive nested spans). That instrumentation is not included in this PR:
| Phase | Before (ms) | After (ms) |
|---|---|---|
| JavaScript, including thread/context setup | 225.934 | 201.584 |
| Changed-field comparison | 2,814.805 | 0.024 |
| Entire script dispatch, all 190 scripts | 4,320.643 | 352.733 |
| Calculation pass, including changed appearances | 4,399.084 | 390.958 |
| Direct field write | 0.201 | 0.196 |
| Appearance generation, including Format | 61.225 | 22.981 |
| Form event, including direct write and calculations | 4,411.767 | 402.981 |
| Engine refresh/serialization | 23.499 | 21.826 |
| Whole engine edit | 4,442.144 | 431.539 |
An intermediate run after sparse comparison alone measured 1,193.419 ms/edit in field-state construction. After display memoization it measured 101.821 ms/edit. The intermediate run's whole engine edit averaged 1,592.101 ms. Appearance time also benefits from the changed cache working set; there was no change to which calculated appearances are generated.
Rendering and compositor time are not included in these edit numbers. The original sheet and GUI acceptance run remain required to quantify them.
Release comparison and remaining cost
Historical comparison used the repository's Release profile (thin LTO, one code-generation
unit) and an optional standalone profiler, which is not included in this PR.
A fresh in-memory fixture was exported with create-new semantics to
target/form-performance/synthetic-large.pdf (SHA-256
300431eeae117458d96e52877e12a9e63ea8dcbb8ad4500da717ba00ebb2f1e1). It remained unchanged.
The example alternated ranks between 1 and 2 for six samples per process, always executing
all 190 scripts. Page rendering was timed separately.
Initial matched comparison, before the additional shared-snapshot change:
| Build | Samples | Median engine edit (ms) |
|---|---|---|
| Development | 6 | 460.697 |
| Release | 6 | 408.358 |
Release alone reduced the median by about 11%. A detailed Release run measured 106.947 ms/edit constructing field states, 174.696 ms in JavaScript setup/execution, and 19.808 ms refreshing engine view data. Calculation-order setup was only 0.319 ms. That supported investigating the host's extra copy, rather than changing calculation scheduling or disabling calculations.
The pre-change Release profiler was retained, then compared with the shared-snapshot Release profiler in alternating runs (old, new, old, new). No compilation ran during measurement.
| Release version | Samples | Median edit (ms) | Range (ms) |
|---|---|---|---|
| Before shared snapshots | 12 | 381.380 | 353.468–447.833 |
| With shared snapshots | 12 | 267.063 | 258.545–313.777 |
The additional optimization reduced median engine time by 30.0% (1.43× speedup). This is a separate comparison from the earlier five-edit benchmark; do not combine their speedup factors. The page-render/setup cost in the initial matched comparison was about 6–9 ms for this deliberately overlapping synthetic layout, not for the original sheet.
After shared snapshots, a historical Release run with optional instrumentation averaged:
| Phase | Mean per edit (ms) |
|---|---|
| Field-state construction | 75.754 |
| JavaScript setup/execution | 83.198 |
| Complete script dispatch | 200.480 |
| Calculation pass | 237.780 |
| Calculated-value writes, including appearances | 25.089 |
| Appearances | 23.712 |
| All complete field enumerations (three per edit) | 27.387 |
| Calculation-order setup | 0.305 |
| Engine refresh | 19.128 |
| Whole engine edit | 272.807 |
These inclusive timings show that scheduling is now negligible on the fixture. The main remaining costs are constructing/disposing field snapshots and creating/executing isolated JavaScript contexts. Further snapshot caching or context reuse would need additional semantic work and real-document evidence; no dependency-based skipping or global/context reuse was introduced. The current shared-snapshot change was retained because its paired measurement showed a substantial improvement and isolation regressions passed.
Remaining limit: these numbers do not quantify real-document or compositor latency. A representative document and delayed field are still needed for a reproducible engine/render/UI comparison. Prior manual acceptance on the integrated release reported responsive field editing; that is not a timing measurement or independent validation of this split.
Reproduce
Use the same compiler, build profile and machine for before/after runs. The default development profile optimizes workspace crates at level 1 and dependencies at level 2. Build with two jobs and a separate target directory, then run the benchmark without concurrent compilation:
CARGO_TARGET_DIR=target/acroform-benchmark cargo test -p pdfcraft-engine -j 2 --lib acroform_interaction_benchmark --no-run
CARGO_TARGET_DIR=target/acroform-benchmark cargo test -p pdfcraft-engine -j 2 --lib acroform_interaction_benchmark -- --ignored --nocapture
The benchmark creates a six-page, 3,129-field/190-calculation workload from contributor-original
PDF objects in memory and times five synchronous edits with Instant. It also checks the final
calculated chain and an independent field. Widgets overlap on page one; this is an engine stress
test, not a realistic page layout. There is no machine-dependent timing threshold in CI.
To export a synthetic fixture for a standalone comparison, set PDFCRAFT_BENCH_EXPORT to a new
filename when running the ignored benchmark. Its parent must exist; export refuses to overwrite
any existing file. The ordinary benchmark writes no PDF.
Validation
Regression coverage includes shared-snapshot write/error/global isolation, duplicate field names, scripted display changes with a warm cache and a fresh edit, ordered checkbox/rank calculations, dynamic field lookup, property/value side effects, undo/save/reopen, and unchanged appearances. The existing JavaScript and engine suites cover the unchanged public scripting behavior.
Before the split, the ignored benchmark passed both before and after optimization. Earlier
scoped native/WASM checks, Clippy, layering, asset and parity checks, UI form/JavaScript tests,
and automation javascript_through_tools checks also passed on the mixed review history.
Those historical checks do not certify these independent branches or hosted CI.
Independent branch validation
Validated on upstream a12fe8f4116984ebbc870eb514346ea922233316 using a fresh, isolated
Cargo target directory, offline locked dependencies and two build jobs:
- All five focused regressions passed separately: duplicate names, warm/new-edit display caches, ordered checkbox/rank dependents, dynamic lookup/side effects and shared-snapshot write/error/global isolation.
cargo test -p pdfcraft-js -p pdfcraft-engine -p pdfcraft-forms -j 2: engine 285 unit and 9 integration tests, JavaScript 47 tests, and forms 80 tests passed. Two unit timing probes and two private-file acceptance tests were ignored in the regular suite.- The ignored 3,129-field/190-calculation benchmark was then run explicitly and passed; its final calculated chain and independent field remained correct. This is a diagnostic timing run, not a new before/after comparison on this upstream base.
- Scoped
cargo clippy --all-targets -- -D warningsand WASM checks of those three crates passed, as did formatting, whitespace, layering, asset and parity checks. javascript_through_toolspassed. A CLI built in the isolated target directory then reproduced bonuses 1 → 4 → 5 → 2 → 0; save/reopen andpage_renderpassed, and the synthetic page image was inspected.
These are local checks, not hosted CI. No GUI executable was rebuilt and no private PDF was used for this independent validation.
Rebase validation
Revalidated on upstream 932bf9c6bfdd63e2a21bb831696a3447b9def9cf (2026-10-18).
The performance implementation and all five focused regressions are unchanged from the
previously reviewed fix. Upstream's newer tests and CSV documentation are retained.
- Engine: 286 unit and 9 integration tests passed; JavaScript: 48 passed; forms: 82 passed. Two unit timing probes and two private-file acceptance tests remain ignored in the regular suite.
- All five focused regressions and the explicit 3,129-field/190-calculation benchmark passed.
- Scoped all-targets Clippy with
-D warnings, WASM, formatting, whitespace, layering, asset and parity checks passed. javascript_through_toolsand a freshly built CLI's fill/save/reopen/render smoke check passed. The bonus chain remained 1 → 4 → 5 → 2 → 0, and the synthetic render was inspected.
Validation used a separate Cargo target directory. No GUI build or private PDF was needed; these are local checks, not hosted CI or a new before/after performance comparison.
PR #3318 refresh
Rebased onto freshly fetched upstream main
e397efa85451be00f8d56df7649807fe31e019c7 on 2026-10-19. Both roadmap conflicts
retain upstream's menu, button-label and content-editing notes alongside the performance
notes. Every performance source, regression-test and parity hunk is exactly the original
performance patch applied to current upstream; upstream's additional tests are retained.
Main advanced after initial validation on 43550c44; the non-overlapping redaction update
was incorporated and all checks below were repeated on the final base.
- All five focused regressions passed.
- Engine: 287 unit and 15 integration tests passed. JavaScript: 49 tests passed. Forms: 84 tests passed. Doc tests passed. The regular suite still ignored two timing probes and two private-file tests; the synthetic benchmark was then run explicitly and passed.
- The 3,129-field/190-calculation benchmark preserved the expected calculated values. Its five diagnostic edits took 317.347–328.488 ms; this is not a new before/after comparison.
- Scoped all-targets Clippy with warnings denied, WASM, formatting, whitespace, layering, assets and parity passed.
- Automation JavaScript and CLI checks passed: bonuses 1 → 4 → 5 → 2 → 0, save/reopen, and page rendering. The synthetic render was inspected.
Builds used an isolated Cargo target outside the worktrees. No GUI executable was built, no private PDF was used, and the compatibility branch was not changed. These are local checks, not hosted CI or a new performance speedup claim.
Revision history
-
2026-10-19: refresh PR #3318 onto upstream
e397efa8, retain upstream roadmap additions and all performance changes, and repeat focused, scoped, benchmark and headless validation. -
2026-10-18: rebase onto upstream
932bf9c6; retain all performance source edits and regression tests; preserve upstream CSV documentation and repeat scoped native/WASM and headless checks. -
2026-10-18: separate performance changes for review on current upstream main; add duplicate-name and scripted-display coverage; keep historical optional instrumentation results as evidence only.
-
2026-10-18: retain shared snapshots after a paired 30% synthetic Release improvement and verify snapshot isolation.
-
2026-10-17: remove quadratic outcome conversion and repeated display reads; record the synthetic before/after benchmark.