Skip to content

2.0 rc: subFetch swaps the global Promise during hydration; user-cached import() becomes a never-settling MockPromise #4332

Closed as not planned

Description

@lovrozagar

Versions: solid-js 2.0.0-rc.18 (also present in 2.0.0-rc.19: dist/solid.js:429), @solidjs/web 2.0.0-rc.18, Vite 8.3.5.

Related: #4013, fixed by #4019 for lazy()/lazyModule(). This is the same root cause reaching user code that #4019 doesn't cover.

What happens

While hydration traces an adopted async computation, subFetch (dist/solid.js:422) sets the global Promise = MockPromise (:428) and window.fetch to return a MockPromise, runs the computation, then restores both. MockPromise's then/catch/finally/resolve/all return new mock promises that never settle.

Any user code that creates a promise during that window and keeps it gets a promise that never resolves. A common case is a module-level de-duplication cache for lazy imports. Vite's __vitePreload, which wraps every built import(), starts with let promise = Promise.resolve(), reading the global at call time, so the stored import() is a MockPromise.

Repro

// messages.js
const loads = new Map()
export function load(key) {
  let p = loads.get(key)
  if (!p) loads.set(key, (p = import("./catalog.js"))) // built: goes through __vitePreload
  return p
}

// component (SSR'd inside <Loading>, then hydrated)
const data = createMemo(() => load("x"))
  1. SSR the component inside <Loading> so the memo's value is serialized, then hydrate.
  2. During hydration the memo runs only to track reads, under subFetch, so load("x") stores a MockPromise in loads.
  3. Unmount, then mount another component that calls load("x").

Expected: it resolves with the module.
Actual: it stays pending forever. No request for catalog.js is made, and in an app this froze client navigation.

Suggested fix

Don't replace globals. Pass the mock explicitly where Solid needs it, or restore the globals before user code can create promises it keeps. At minimum, document that promises created inside a computation while it hydrates are fake and must not be cached.

Workaround

On the client, when the memo already holds the hydrated (serialized) value, write it into the cache, so later callers never touch the stored mock promise.

Activity

  1. ryansolid commented on Oct 28, 2026

    @ryansolid
    Member

    Thanks for the detailed report. This is working as designed. When a computation adopts the server's serialized value during hydration, the client still runs it once to record its dependencies. During that run, Promise and fetch are stubbed so that nothing is requested a second time. Without the stubs, the duplicate requests on load would come back, so we're keeping them. lazy() already avoids caching a load made during that run (#4019).

    The part that hangs is user code holding on to a promise created inside the compute, which includes an import() that goes through Vite's __vitePreload. We've documented the rule and its alternatives: cache the resolved value instead of the promise, start the load outside the compute, use lazy() for components, or for a data library, skip caching while isHydrating() and seed from takeHydrationValue.

    Closing as not planned for the runtime. The docs part is done.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions