Skip to content

perf: defer heavy Dash SDK chunks to cut initial load time#28

Merged
PastaPastaPasta merged 5 commits into
dashpay:mainfrom
thephez:perf/improve-load-time
Jun 4, 2026
Merged

perf: defer heavy Dash SDK chunks to cut initial load time#28
PastaPastaPasta merged 5 commits into
dashpay:mainfrom
thephez:perf/improve-load-time

Conversation

@thephez
Copy link
Copy Markdown
Contributor

@thephez thephez commented Jun 3, 2026

Summary

  • Dynamically import @dashevo/evo-sdk, DAPI client, dashcore-lib, fee-estimator, DPNS, contract, and islock modules instead of bundling them into the initial chunk. An idle-time warmup (via requestIdleCallback / setTimeout fallback) primes them after first paint so user-initiated flows stay snappy, and the CAP widget is loaded on demand rather than from a blocking <script> tag in index.html.
  • Vite modulePreload.resolveDependencies now filters those heavy chunks out of the generated <link rel="modulepreload"> tags via a small shouldPreloadDashChunk helper, so the browser doesn't pre-fetch them on first paint.
  • Extract DPNS label utilities into src/platform/dpns-utils.ts with a dedicated Vitest suite, and split a reusable createCachedLoader out of loaders.ts (covered by tests for in-flight dedupe and post-failure retry).
  • Add scripts/check-build-artifacts.mjs (wired up as npm run test:build-artifacts and run in CI between Build and Test) that asserts the built index.html has no heavy modulepreload entries and that the entry chunk never statically imports a deferred module.
  • Minor CI/test housekeeping: npm test now runs once by default (vitest run) with test:watch for the watcher, and CI uses npm test directly instead of duplicating vitest flags.

Impact

Lighthouse on the same flow:

Metric Before After
Performance score 59 94
LCP 10.5 s 0.9 s
TTI 10.5 s 4.9 s
TBT 339 ms 172 ms
Total bytes 12.5 MB 5.3 MB
JS bootup 324 ms 116 ms

(Before run is prod bridge.thepasta.org; after run is local vite preview — environment isn't identical, but the byte-weight and main-thread numbers are environment-independent.)

Test plan

  • npm run build succeeds
  • npm test passes (loaders, vite-preload filter, DPNS utils, state)
  • npm run test:build-artifacts passes; manually verified it fails if either (a) the Vite modulePreload filter is removed or (b) a heavy SDK module is statically imported from the entry
  • Manual: create identity, top-up, contract publish, and ?contract= deep-link flows still work; warmup kicks in after first paint
  • Manual: network switch and custom devnet modal still re-initialize clients correctly

Summary by CodeRabbit

  • New Features

    • DPNS utilities: username validation, homograph-safe normalization, contested-name detection, and helper helpers for entries/status counts.
  • Bug Fixes

    • CAP widget no longer loaded at page head; now handled on-demand with retries and timeout handling.
  • Performance

    • Dash platform modules load lazily to improve initial startup.
    • Heavy Dash chunks excluded from automatic preload.
  • Chores

    • CI: added build artifact smoke check and new test scripts.
  • Tests

    • Added suites covering preload logic, loaders, DPNS utilities, CAP widget loading, and related flows.

thephez and others added 4 commits June 3, 2026 13:25
Dynamically import @dashevo/evo-sdk, dpns, contract, client, fee-estimator, and islock modules instead of eagerly bundling them with the main chunk, and load the CAP widget on demand rather than from a blocking script tag. An idle-time warmup primes these modules after first paint so user-initiated flows still feel instant, and the Vite modulePreload policy is adjusted to skip preload links for the heavy SDK chunks. DPNS label utilities are extracted to dpns-utils.ts with a Vitest suite, and the npm test script now runs once by default with test:watch reserved for the watcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a Vitest suite for the cached module loader and the Vite modulepreload filter to lock in the heavy-chunk exclusion contract, plus a check-build-artifacts.mjs script (wired up as the test:build-artifacts npm script) that asserts the built index.html and entry chunk never statically pull in the deferred Dash SDK chunks. Also covers getStepDescription so the user-facing key-generation copy can't silently drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wire the lazy-Dash-chunk guard into CI between the Build and Test steps so a regression in modulepreload filtering or a static SDK import in the entry bundle fails the pipeline. Failure output now uses the GitHub Actions ::error annotation and a plain banner so the cause is visible from both the workflow summary and raw logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The test script is already "vitest run", so passing --run --passWithNoTests through npm just duplicates the run flag and silently allows an empty suite. Letting npm test drive the invocation keeps CI honest if the suite ever disappears.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 3, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR defers EvoSDK and other heavy module imports via cached dynamic loaders, schedules on-demand warmup on user intent, extracts DPNS utilities with tests, makes the CAP widget load-on-demand, and adds a Vite preload filter plus a CI smoke-check to prevent preloading heavy Dash chunks.

Changes

Lazy SDK Loading and Build Optimization

Layer / File(s) Summary
Build artifact preload filtering and smoke check
src/config/vite-preload.ts, src/config/vite-preload.test.ts, scripts/check-build-artifacts.mjs, .github/workflows/ci.yml, package.json, vite.config.ts
shouldPreloadDashChunk filters heavy Dash-related chunks from Vite modulepreload. A new build smoke check script validates that preloaded links and static entry imports avoid those patterns. CI now runs the check and the test scripts are adjusted.
SDK module lazy loading infrastructure
src/platform/sdkModule.ts, src/platform/loaders.ts, src/platform/loaders.test.ts
New createCachedLoader and loadSdkModule utilities enable deferred dynamic imports with singleton promise caching and retry-on-failure. Module loaders for platform, dpns, contract, client, fee estimator, and islock are exported, plus warmDashModules() to preload all in parallel.
CAP widget on-demand loading
index.html, src/api/faucet.ts, src/api/faucet.test.ts
Removed eager CAP widget script from HTML head. solveCap now calls loadCapWidget() to inject the CDN script on-demand, reusing existing script elements, caching the load operation, handling timeout/error, and tests cover failure/retry and timeout.
DPNS utilities extraction and refactoring
src/platform/dpns-utils.ts, src/platform/dpns-utils.test.ts, src/platform/dpns.ts, src/ui/components.ts, src/ui/state.ts, src/ui/state.test.ts
New dpns-utils module exports label validation, homograph-safe conversion, contested-username detection, and entry aggregation helpers with comprehensive tests. dpns.ts re-exports and delegates to these utilities, and downstream UI imports are updated to use the new module. Step description label updated to "Preparing Dash Platform...".
Platform SDK modules lazy loading
src/platform/client.ts, src/platform/contract.ts, src/platform/identity.ts
createPlatformSdk becomes async and loads EvoSDK on-demand. publishContract and identity functions (registerIdentity, topUpIdentity, updateIdentity, sendToPlatformAddress) load SDK classes via loadSdkModule() at runtime. toSdkProof is now async and resolves AssetLockProof/OutPoint dynamically.
Main.ts lazy loading and client initialization
src/main.ts
ensureClients() gates network client initialization behind a cached promise, deferring setup until needed. Dash module warmup is triggered by user interaction (pointerenter/focus/touchstart) on mode-entry buttons before their click handlers execute. Network polling is guarded to only run when clients exist. Bridge flows (top-up, send-to-address, identity create), recheck, chainlock fallback, and contract/DPNS registration dynamically load platform functions instead of using static imports. Contract deep-link hydration is now async and uses dynamic fee-estimator loading.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • dashpay/dash-bridge#15: Adds the send_to_address mode and new sendToPlatformAddress implementation that this PR integrates via dynamic lazy loading in bridge flows.
  • dashpay/dash-bridge#14: Refactors identity flows in src/platform/identity.ts with typed AssetLockProofData that this PR's async toSdkProof and dynamic SDK loading must coordinate with.
  • dashpay/dash-bridge#23: Also refactors Dash Platform EvoSDK connection in src/platform/client.ts and downstream flows, overlapping with the same client/contract/identity integration points affected by this PR's lazy loading changes.

"🐇 I waited for the hover, then fetched with a smile,
The heavy chunks hid — they won't slow the first mile.
CI checks the build while SDKs nap tight,
CAP wakes on demand and DPNS looks right.
Warmup on intent — everything loads just in time."

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main objective: deferring heavy Dash SDK chunks to improve initial load time, which is the core performance optimization across all changes.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
scripts/check-build-artifacts.mjs (1)

52-53: ⚡ Quick win

Consider refactoring the static import regex for maintainability.

The staticImportPattern is complex and attempts to match both bare imports (import "...") and named imports (import X from "..."). While functionally correct, the duplicated chunk-name list and complex alternation make it fragile. Consider extracting the chunk names to a shared constant or building the regex programmatically.

♻️ Proposed refactor to reduce duplication
+const heavyChunkNames = 'evo-sdk|dapi-client|dashcore-lib|dapi-subscription|islock';
+const heavyChunkPattern = new RegExp(`(?:${heavyChunkNames})`);
-const heavyChunkPattern = /(?:evo-sdk|dapi-client|dashcore-lib|dapi-subscription|islock)/;

 // ...later...

-const staticImportPattern =
-  /\bimport\s*(?:["'][^"']*(?:evo-sdk|dapi-client|dashcore-lib|dapi-subscription|islock)[^"']*["']|[\w*{}\s,]+from\s*["'][^"']*(?:evo-sdk|dapi-client|dashcore-lib|dapi-subscription|islock)[^"']*["'])/;
+const staticImportPattern = new RegExp(
+  `\\bimport\\s*(?:["'][^"']*(?:${heavyChunkNames})[^"']*["']|[\\w*{}\\s,]+from\\s*["'][^"']*(?:${heavyChunkNames})[^"']*["'])`
+);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/check-build-artifacts.mjs` around lines 52 - 53, The
staticImportPattern regex is fragile due to duplicated chunk-name lists and
complex alternation; extract the list of module chunk names (e.g., "evo-sdk",
"dapi-client", "dashcore-lib", "dapi-subscription", "islock") into a single
constant (e.g., CHUNK_NAMES or CHUNK_ALTERNATION) and programmatically build the
regex for staticImportPattern so both bare imports (import "x") and
named/import-from forms (import ... from "x") reuse that single source of truth;
update the code that defines staticImportPattern to construct the final RegExp
using the shared constant and verify it still matches both import variants.
src/platform/sdkModule.ts (1)

5-12: ⚡ Quick win

Consider using createCachedLoader to eliminate duplication.

The manual promise-caching and rejection-handling logic here duplicates the pattern already extracted in createCachedLoader (loaders.ts lines 1-12). Replacing this with:

export const loadSdkModule = createCachedLoader(() => import('`@dashevo/evo-sdk`'));

would reduce duplication and ensure both SDK and module loaders follow the same retry/cache semantics.

♻️ Proposed refactor
+import { createCachedLoader } from './loaders.js';
+
 export type SdkModule = typeof import('`@dashevo/evo-sdk`');

-let sdkModulePromise: Promise<SdkModule> | null = null;
-
-export function loadSdkModule(): Promise<SdkModule> {
-  if (!sdkModulePromise) {
-    sdkModulePromise = import('`@dashevo/evo-sdk`').catch((err) => {
-      sdkModulePromise = null;
-      throw err;
-    });
-  }
-  return sdkModulePromise;
-}
+export const loadSdkModule = createCachedLoader(() => import('`@dashevo/evo-sdk`'));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/sdkModule.ts` around lines 5 - 12, The loadSdkModule
implementation duplicates the manual promise-caching and rejection-handling
logic already provided by createCachedLoader; replace the current loadSdkModule
function with a cached loader by exporting loadSdkModule using
createCachedLoader(() => import('`@dashevo/evo-sdk`')), so the module uses the
shared retry/cache semantics and removes the custom sdkModulePromise handling in
loadSdkModule; ensure you remove the old function and any related
sdkModulePromise variable to avoid dead code.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/api/faucet.ts`:
- Around line 122-150: In loadCapWidget, the retry hang happens because a failed
<script data-cap-widget> element remains in the DOM and subsequent querySelector
finds it but its load/error events already fired; update the error handling for
both the existing script branch (the existing variable used in querySelector)
and the newly created script (the script variable) so that on 'error' you remove
the failing element from the DOM (existing.remove() or script.remove()), reset
capWidgetPromise = null, and then reject with the error; ensure the .catch
cleanup remains (or consolidate cleanup into the error handlers) so retries will
re-inject a fresh tag and the promise can settle.
- Around line 136-142: Pin the CDN URL and add a load timeout for the script
element to avoid indefinite waiting: replace the unpinned script.src string used
when creating the script element (`script.src`) with a pinned version (e.g.
'`@cap.js/widget`@<version>') and wrap the load/error handlers with a timeout (use
setTimeout to reject after N ms). In the script load flow (the code that calls
`document.createElement('script')`, `script.addEventListener('load', ...)`,
`script.addEventListener('error', ...)`, and the Promise resolve/reject), ensure
you clear the timeout on both load and error, remove listeners, and call reject
with a descriptive Error on timeout so `solveCap` (or the function that awaits
this promise) will fail fast instead of hanging.

In `@src/config/vite-preload.test.ts`:
- Around line 6-11: Add one assertion to the existing test that verifies the new
"islock" heavy chunk is filtered: call shouldPreloadDashChunk with a
representative islock asset filename (e.g., 'assets/islock-<hash>.js') and
expect false; this ensures the updated HEAVY_DASH_CHUNK_PATTERN change is
covered by the test.

In `@src/config/vite-preload.ts`:
- Line 1: HEAVY_DASH_CHUNK_PATTERN currently omits the "islock" identifier
causing a mismatch with the smoke check's heavyChunkPattern; update the regular
expression referenced as HEAVY_DASH_CHUNK_PATTERN so it includes "islock"
alongside the existing alternatives (e.g.,
evo-sdk|dapi-client|dashcore-lib|dapi-subscription) so Vite's modulePreload
filter will also treat islock as a heavy chunk and keep behavior consistent with
scripts/check-build-artifacts.mjs.

---

Nitpick comments:
In `@scripts/check-build-artifacts.mjs`:
- Around line 52-53: The staticImportPattern regex is fragile due to duplicated
chunk-name lists and complex alternation; extract the list of module chunk names
(e.g., "evo-sdk", "dapi-client", "dashcore-lib", "dapi-subscription", "islock")
into a single constant (e.g., CHUNK_NAMES or CHUNK_ALTERNATION) and
programmatically build the regex for staticImportPattern so both bare imports
(import "x") and named/import-from forms (import ... from "x") reuse that single
source of truth; update the code that defines staticImportPattern to construct
the final RegExp using the shared constant and verify it still matches both
import variants.

In `@src/platform/sdkModule.ts`:
- Around line 5-12: The loadSdkModule implementation duplicates the manual
promise-caching and rejection-handling logic already provided by
createCachedLoader; replace the current loadSdkModule function with a cached
loader by exporting loadSdkModule using createCachedLoader(() =>
import('`@dashevo/evo-sdk`')), so the module uses the shared retry/cache semantics
and removes the custom sdkModulePromise handling in loadSdkModule; ensure you
remove the old function and any related sdkModulePromise variable to avoid dead
code.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e5268a0f-cc58-422c-b548-d61afb33a211

📥 Commits

Reviewing files that changed from the base of the PR and between 3d255c9 and 9b83619.

📒 Files selected for processing (21)
  • .github/workflows/ci.yml
  • index.html
  • package.json
  • scripts/check-build-artifacts.mjs
  • src/api/faucet.ts
  • src/config/vite-preload.test.ts
  • src/config/vite-preload.ts
  • src/main.ts
  • src/platform/client.ts
  • src/platform/contract.ts
  • src/platform/dpns-utils.test.ts
  • src/platform/dpns-utils.ts
  • src/platform/dpns.ts
  • src/platform/identity.ts
  • src/platform/loaders.test.ts
  • src/platform/loaders.ts
  • src/platform/sdkModule.ts
  • src/ui/components.ts
  • src/ui/state.test.ts
  • src/ui/state.ts
  • vite.config.ts
💤 Files with no reviewable changes (1)
  • index.html

Comment thread src/api/faucet.ts
Comment thread src/api/faucet.ts
Comment thread src/config/vite-preload.test.ts
Comment thread src/config/vite-preload.ts Outdated
Pin the CAP widget CDN to a specific version, add a 30s load timeout, and remove the script tag on failure so a retry can inject a fresh one. Also exclude the islock chunk from preload alongside the other heavy Dash modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/api/faucet.test.ts (1)

76-109: 💤 Low value

Optional: cover the reuse-existing-script and "loaded without exposing Cap" branches.

The two tests exercise error→retry and timeout, but loadCapWidget has two untested branches that are easy to regress: reusing an already-present script[data-cap-widget] (the existing path), and the load event firing while Cap is still undefined (rejects with "CAP widget loaded without exposing Cap"). Since the fake document already tracks scripts and querySelector, adding these cases is low-cost and locks in the loader's idempotency/guard behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/faucet.test.ts` around lines 76 - 109, Add two unit tests for
loadCapWidget/solveCap: one that simulates an existing <script data-cap-widget>
in the fake document (reuse-existing-script path) so calling solveCap or
loadCapWidget returns/uses the existing script rather than injecting another,
and assert no duplicate script is created and behavior is correct; and another
that simulates the <script> firing a 'load' event while the global Cap is still
undefined to exercise the "CAP widget loaded without exposing Cap" branch
(expect a rejection with that message and ensure the script is removed). Use the
existing helpers installFakeDocument, installCap, and reference
loadCapWidget/solveCap and the script[data-cap-widget] attribute to locate the
code paths to test.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/api/faucet.test.ts`:
- Around line 76-109: Add two unit tests for loadCapWidget/solveCap: one that
simulates an existing <script data-cap-widget> in the fake document
(reuse-existing-script path) so calling solveCap or loadCapWidget returns/uses
the existing script rather than injecting another, and assert no duplicate
script is created and behavior is correct; and another that simulates the
<script> firing a 'load' event while the global Cap is still undefined to
exercise the "CAP widget loaded without exposing Cap" branch (expect a rejection
with that message and ensure the script is removed). Use the existing helpers
installFakeDocument, installCap, and reference loadCapWidget/solveCap and the
script[data-cap-widget] attribute to locate the code paths to test.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d780cba1-2f35-4c1c-9408-6c8427a7f25f

📥 Commits

Reviewing files that changed from the base of the PR and between 9b83619 and 89ce89d.

📒 Files selected for processing (4)
  • src/api/faucet.test.ts
  • src/api/faucet.ts
  • src/config/vite-preload.test.ts
  • src/config/vite-preload.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/config/vite-preload.test.ts
  • src/config/vite-preload.ts
  • src/api/faucet.ts

@PastaPastaPasta PastaPastaPasta merged commit 64ae4e2 into dashpay:main Jun 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants