Debugging a WPForms Infinite Loading Issue: When a Tiny URL Assumption Broke the Form Builder

WordPress • Debugging Case Study

An infinite loading spinner looks simple from the outside, but underneath was a small URL-handling assumption in an ES module loader—and a malformed request that broke the entire builder.

01The symptom: trapped behind an infinite loading screen

An infinite loading spinner is one of those issues that looks deceptively simple from the outside, but can hide a surprisingly deep architectural problem underneath.

Recently, I investigated a WPForms Lite issue where the form builder interface refused to open. After navigating to WPForms → Add New, the page presented a blank canvas covered by an endless loading animation.

W

WordPress.org Support Forum
When adding new form, the page just loads ↗

WPForms Lite

FV
@fvance
Reported in Plugin Support

“I’m trying to add my first form with WPForms lite, and the page to create the form will not load, it just sits there with the loading symbol going around and around.

I’ve tried disabling the other plugins with no luck. The developer console returns 404 errors for a few different js files.”

Status: Unresolved • Console 404 errors logged

View original thread on WordPress.org →

Fig. 1 — The original issue report. The problem as described in the official WordPress.org support topic. Reinstalling and disabling plugins produced no effect.

The initial troubleshooting steps were standard:

  • Deactivate and reinstall the plugin to rule out corrupted asset files.
  • Deactivate all other plugins and switch themes to isolate conflicts.
  • Check whether hosting security rules, mod_security, or cloud firewalls were blocking requests.

None of these worked. Reinstalling a plugin only helps if the files on disk are damaged or altered. When the application logic itself generates invalid requests, reinstalling simply reinstalls the exact same behavior. To find the real answer, I had to look past the UI and trace what the browser was actually executing.

02Step 1: Inspecting the browser console

Opening DevTools (F12) immediately shifted the direction of the investigation. The Console tab was logging unhandled promise rejections and 404 Not Found errors for JavaScript module files required during builder startup.

When an interface fails to load, don’t ask “Why is the builder stuck?”. Ask: “Which request is failing, and why is the browser requesting that specific URL?”

The builder relied on ES modules to bootstrap its controls and UI components. If any core module failed to download, the initialization chain broke before rendering could begin. The problem wasn’t a broken PHP execution or a database deadlock—it was a client-side module resolution failure.

03Step 2: Following the network request

Inspecting the Network tab revealed that the failing JavaScript module requests were being dispatched with an unexpected, malformed path.

// 1. Expected asset path:
/wp-content/plugins/wpforms-lite/assets/js/admin/builder/applicators.min.js

// 2. Actual malformed HTTP request generated by the browser:
https://site.test/wp-admin/builder/modules//wp-content/plugins/wpforms-lite/assets/js/admin/builder/applicators.min.js

Fig. 2 — Malformed module request. Notice the double slash and the ./modules/ prefix prepended directly before the root /wp-content/ directory.

The expected URL was a clean asset path pointing into /wp-content/plugins/wpforms-lite/assets/.... Yet somewhere along the execution chain, the browser was resolving:

./modules//wp-content/plugins/...

Relative to the current admin URL (/wp-admin/admin.php?page=wpforms-builder...), this resolved into .../wp-admin/builder/modules//wp-content/..., which naturally triggered a 404 Not Found response from the web server.

The central question became: Why was ./modules/ being prepended to a path that already pointed directly to the plugin’s assets?

04Step 3: Following the JavaScript module loader

Searching through the builder’s minified source code (admin-builder.min.js) revealed the dynamic module-import routine responsible for loading feature bundles:

e = e.startsWith( wpforms_builder.content_url ) || /^https?:\/\//.test( e )
    ? e
    : "./modules/" + e;

return import( e + "?v=" + wpforms_builder.version );

Fig. 3 — The loader’s URL-resolution logic. If the module path doesn’t start with the literal content_url string or an explicit HTTP/S protocol, the loader assumes it must be an internal relative module name.

Two checks determine how a module path is treated:

  • e.startsWith( wpforms_builder.content_url ): Checks if the path begins with the site’s full content directory URL.
  • /^https?:\/\//.test( e ): A regular expression verifying if the path begins with an absolute protocol (http:// or https://).

If neither check succeeds, the loader assumes the string is merely a local module filename (such as choices.min.js) and prepends ./modules/ before passing it to native import().

05The edge case: root-relative URLs

Consider what happens when an environment or optimization plugin serves assets using root-relative URLs:

/wp-content/plugins/wpforms-lite/assets/js/admin/builder/applicators.min.js

This is a completely valid URL format across the modern web. It starts with a forward slash (/), instructing the browser to resolve from the domain root. However:

  • It does not begin with https:// or http:// → /^https?:\/\//.test(e) evaluates to false.
  • If wpforms_builder.content_url contains the full domain (e.g., https://site.test/wp-content), e.startsWith(...) also evaluates to false.
Module Input URL Type Regex Test Loader Behavior Network Result
https://site.test/wp-content/... Absolute URL true Loads as-is 200 OK
/wp-content/plugins/... Root-Relative false Prepends ./modules/ 404 Not Found
//cdn.site.test/assets/... Protocol-Relative false Prepends ./modules/ 404 Not Found
utils.min.js Local filename false Prepends ./modules/ 200 OK
Fig. 4 — URL evaluation matrix in the module loader. Because root-relative paths fail the regex test, they get treated as local filenames, resulting in double paths.

Because both tests returned false, the loader treated the root-relative path as a relative file, prepending ./modules/. This concatenated string created the broken request observed in the network trace.

06Why one 404 broke the entire builder

In traditional WordPress admin scripts, a failed <script> tag produces a console error, but the rest of the inline HTML and UI still renders. Why did this single module 404 freeze the whole interface?

The builder loads its dependencies using dynamic ES module imports inside a Promise.all() collection:

1. Dynamic Import Input URL
/wp-content/plugins/wpforms-lite/assets/.../applicators.min.js

Root-Relative Path

↓

2. Loader Regex Check: /^https?:\/\//
Fails to match protocol → Loader assumes path is a local file

Evaluates to: false

↓ Fallback Prepended

3. Malformed Module Request
.../builder/modules//wp-content/...
404 Not Found • Import Rejects
4. Unhandled Promise Rejection

Promise.all() aborts immediately. Cleanup hook never runs.

#wpforms-builder-overlay Stays Frozen

Fig. 5 — The cascade of failure. When dynamic import() encounters a 404, the promise rejects immediately. Because the loader lacked error recovery, the initialization routine halted, leaving the DOM overlay indefinitely visible.

By default, Promise.all() rejects immediately if any single promise rejects. When the root-relative module returned a 404, the promise rejected. As a result:

  • The completion callback that removes #wpforms-builder-overlay was never invoked.
  • The builder DOM controls were never instantiated.
  • No visible user-facing error message was rendered on the screen.

To the end-user, the software simply looked frozen in time: a spinning wheel with no hint of what went wrong.

07The fix: normalizing module URLs with a WordPress filter

Because the issue stems from root-relative paths failing the regex check, the solution is straightforward: normalize all module URLs to full absolute URLs before they reach the builder script.

WPForms Lite provides the wpforms_builder_js_modules filter hook, which passes the array of module URLs registered for the builder. By prefixing root-relative paths with site_url(), we ensure they pass /^https?:\/\//.test() without altering local internal filenames.

add_filter( 'wpforms_builder_js_modules', function( $modules ) {
    $site_url = untrailingslashit( site_url() );

    foreach ( $modules as $key => $url ) {
        // Target root-relative URLs (/...) while preserving protocol-relative (//...)
        if ( str_starts_with( $url, '/' ) && ! str_starts_with( $url, '//' ) ) {
            $modules[ $key ] = $site_url . $url;
        }
    }

    return $modules;
}, 99 );

Fig. 6 — The filter workaround. Adding this snippet to functions.php or an mu-plugin ensures all module URLs are fully qualified absolute URLs.

The condition str_starts_with( $url, '/' ) && ! str_starts_with( $url, '//' ) is essential:

  • It identifies true root-relative URLs that begin with a single slash (/wp-content/...).
  • It explicitly excludes protocol-relative URLs (//cdn.example.com/...) to prevent accidental double prefixes.

08Verification: restoring the builder

After placing the snippet in an active functions.php (or a must-use plugin) and performing a hard browser cache refresh, the change was instantaneous.

Module Loader Pipeline — Before Fix
Registered Module URL

/wp-content/plugins/…/applicators.min.js

Browser Request URL

…/builder/modules//wp-content/…404

Promise.all() State

Rejected • Builder Frozen

↓ filter applied ↓

Module Loader Pipeline — After Fix
Normalized Module URL

https://site.test/wp-content/…

Browser Request URL

https://site.test/wp-content/…200 OK

Promise.all() State

Resolved • Overlay Removed ✓

All dynamic imports resolve concurrently. The form builder renders fully interactive.

Fig. 7 — State comparison. Converting root-relative paths to fully qualified URLs allows the regex check to pass, unblocking the dynamic import chain.

Checking the Network tab confirmed that all previously failing requests now returned clean 200 OK responses. The builder initialization script concluded, the overlay was dismissed, and the drag-and-drop form editor became completely responsive.

09Lessons for engineering and debugging

This investigation served as a valuable case study in web architecture, defensive coding, and JavaScript module loaders:

1. Don’t assume every web URL is absolute

Modern WordPress environments are diverse. Between headless architectures, reverse proxies, staging clones, and root-relative optimization plugins, asset URLs do not always conform to https://example.com/asset.js. Code that parses or loads URLs must account for:

  • Full absolute URLs (https://...)
  • Root-relative URLs (/...)
  • Protocol-relative URLs (//...)
  • Path-relative identifiers (./... or bare specifiers)

2. A 404 does not always mean the file is missing on the server

When engineers or support teams see a 404 Not Found, the immediate instinct is often to check file permissions, reinstall the plugin, or verify server paths. However, if the server returns 404 because the client requested an invalid URL, no amount of file checking will reveal the flaw. Always verify what URL was requested before checking where the file lives.

3. Dynamic imports require resilient error boundaries

Dynamic import() is a powerful modern web feature, but binding multiple imports into an unhandled Promise.all() creates a fragile all-or-nothing failure state. A robust client-side architecture should:

  • Catch module import rejections individually with .catch().
  • Surface a clear diagnostic error notice to the user (e.g., “Failed to load module X. Check network connectivity or firewall rules.”).
  • Provide an inline retry button rather than leaving a permanent loading overlay.

10The technical support engineering workflow

This case study illustrates the exact mindset that distinguishes reactive troubleshooting from high-impact technical support engineering:

  1. User Symptom: The client observes an ambiguous symptom: “The builder won’t load.”
  2. Reproduce & Observe: Replicate the issue in an isolated environment under identical conditions.
  3. Console & Network Inspection: Bypass the visual UI and inspect the network layer for failing HTTP transactions.
  4. Identify the Anomaly: Spot the malformed URL pattern (./modules//wp-content/...) in failed requests.
  5. Trace Back to Code: Inspect the dynamic loader logic in admin-builder.min.js to locate the regex test.
  6. Isolate the Edge Case: Determine why root-relative paths fail the /^https?:\/\// check.
  7. Apply Targeted Fix: Intercept and normalize the URLs via WordPress filter (wpforms_builder_js_modules).
  8. Verify Resolution: Confirm HTTP 200 responses, error-free console logs, and restored builder functionality.

Transforming the Conversation: Following this workflow transforms a vague report (“WPForms is broken on my site”) into an actionable, precise engineering diagnosis: “The builder’s ES module loader regex rejects root-relative asset URLs, creating malformed paths that abort Promise.all().”

11Conclusion: asking the right question

The issue looked like a generic WordPress loading hang. It wasn’t fixed by clearing caches, deactivating plugins, or reinstalling software.

The root problem was a single subtle URL-handling assumption in JavaScript—one that worked fine on typical setups, but broke down on environments utilizing root-relative asset paths.

In technical support and engineering, the most critical skill isn’t having every solution memorized. It’s knowing what questions to ask when things go wrong:

Don’t just ask, “What can I try next?”
Ask: “What is the system actually doing right now?”

That is where real problem-solving begins.

Share the Post: