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
5 changes: 5 additions & 0 deletions .changeset/lazy-hydration-trace-mock-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"solid-js": patch
---

`lazy()` and `lazyModule()` no longer cache a loader promise created while hydration traces an adopted async compute. During the trace the global `Promise` is a never-settling stub, so a loader that builds its promise from it (as Vite's `__vitePreload` does) used to leave `.preload()` and later renders waiting forever (#4013). A load started during the trace is no longer cached, so the next call loads for real.
11 changes: 10 additions & 1 deletion packages/solid/src/client/component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { untrack, createMemo } from "@solidjs/signals";
import { IS_DEV, IS_OBSERVE, observedComponent } from "../client/core.js";
import { _lazyHydrationLookup, sharedConfig } from "./hydration.js";
import { _lazyHydrationLookup, sharedConfig, tracing } from "./hydration.js";
import type { Element as SolidElement } from "../types.js";

/**
Expand Down Expand Up @@ -150,6 +150,13 @@ export function lazy<T extends Component<any>>(
const load = () => {
if (p) return p;
const cur = (p = fn());
// Under a hydration trace the global Promise is the never-settling
// MockPromise stub; a loader that builds its promise from it (Vite's
// __vitePreload) must not cache that stub, or every later preload and
// render waits on it forever (#4013). Nothing loaded under a trace is
// kept: a real import() still lands through then() below (and the module
// registry dedupes the next call); the stub's then() is inert.
if (tracing) p = undefined;
cur.then(
(mod: any) => {
comp = () => (exportName ? mod[exportName] : mod.default) as T;
Expand Down Expand Up @@ -230,6 +237,8 @@ export function lazyModule<T extends Record<string, any>>(
const load = () => {
if (p) return p;
const cur = (p = fn());
// Same MockPromise-stub guard as lazy() (#4013).
if (tracing) p = undefined;
cur.then(
m => {
mod = m;
Expand Down
9 changes: 9 additions & 0 deletions packages/solid/src/client/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,10 +520,18 @@ const MockPromise = /* @__PURE__ */ (() => {
return MockPromise;
})();

/**
* @internal — subFetch's nesting depth. lazy()/lazyModule() read it so a
* loader promise made during a trace (possibly from the MockPromise stub)
* never becomes their cached module load (#4013).
*/
export let tracing = 0;

/** @internal — shared with the store adapter module (store-hydration.ts). */
export function subFetch<T>(fn: (prev?: T) => any, prev?: T) {
const ogFetch = fetch;
const ogPromise = Promise;
tracing++;
try {
window.fetch = () => new MockPromise() as any;
Promise = MockPromise as any;
Expand Down Expand Up @@ -554,6 +562,7 @@ export function subFetch<T>(fn: (prev?: T) => any, prev?: T) {
} finally {
window.fetch = ogFetch;
Promise = ogPromise;
tracing--;
}
}

Expand Down
59 changes: 58 additions & 1 deletion packages/solid/test/client-hydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
createStore,
createOptimisticStore
} from "../src/client/hydration.js";
import { lazy } from "../src/client/component.js";
import { lazy, lazyModule } from "../src/client/component.js";
import { Errored, Loading } from "../src/client/flow.js";

// Enable the hydration-aware wrappers
Expand Down Expand Up @@ -4888,4 +4888,61 @@ describe("adoption trace — Promise statics", () => {
expect(result).toBe("server");
expect(callback).not.toHaveBeenCalled();
});

// #4013: a loader building its promise from the global Promise (Vite's
// __vitePreload does) gets the trace's never-settling stub when preload()
// runs inside a traced compute. lazy() must not keep that stub as the
// module load, or every later preload()/render waits on it forever.
test("lazy().preload() after hydration settles when first called under the trace (#4013)", async () => {
startHydration({ t0: { s: 1, v: "ready" } });

const Lazy = lazy(
() => new Promise<any>(resolve => setTimeout(() => resolve({ default: () => "ok" }), 0))
);
let result: any;
createRoot(
() => {
result = createMemo(async () => {
await Lazy.preload();
return "ready";
})();
},
{ id: "t" }
);
flush();
stopHydration();
expect(result).toBe("ready");

const settled = await Promise.race([
Lazy.preload().then(mod => mod.default({})),
new Promise(r => setTimeout(() => r("timeout"), 50))
]);
expect(settled).toBe("ok");
});

test("lazyModule() after hydration settles when first called under the trace (#4013)", async () => {
startHydration({ t0: { s: 1, v: "ready" } });

const mod = lazyModule(
() => new Promise<any>(resolve => setTimeout(() => resolve({ value: "ok" }), 0))
);
createRoot(
() => {
createMemo(async () => {
await mod();
return "ready";
})();
},
{ id: "t" }
);
flush();
stopHydration();

const settled = await Promise.race([
mod().then(m => m.value),
new Promise(r => setTimeout(() => r("timeout"), 50))
]);
expect(settled).toBe("ok");
expect(mod.peek()).toEqual({ value: "ok" });
});
});
71 changes: 71 additions & 0 deletions packages/web/test/hydration/lazy-preload-trace-4013.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* @jsxImportSource @solidjs/web
* @vitest-environment jsdom
*
* #4013: hydration traces an adopted async memo with the global Promise
* swapped for a never-settling stub. A lazy() loader that builds its promise
* from the global Promise (Vite's __vitePreload does) returns that stub when
* `.preload()` runs inside the traced compute. lazy() must not keep it as the
* module load: `.preload()` after hydration has to settle.
*/
import { afterEach, describe, expect, test } from "vitest";
import { createMemo, flush, isHydrating, lazy } from "solid-js";
import { hydrate } from "@solidjs/web";

const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));

describe("#4013: lazy().preload() first called under the hydration trace", () => {
let dispose: (() => void) | undefined;
let container: HTMLDivElement | undefined;
afterEach(async () => {
dispose?.();
dispose = undefined;
await sleep(0);
container?.remove();
delete (globalThis as any)._$HY;
});

test("preload() after hydration resolves", async () => {
container = document.createElement("div");
document.body.appendChild(container);
// renderToStream output for App below: the memo settled to "ready" on
// the server and is serialized under its hydration id.
container.innerHTML = "ready";
const served: any = Promise.resolve("ready");
served.s = 1;
served.v = "ready";
(globalThis as any)._$HY = {
events: [],
completed: new WeakSet(),
r: { "0": served },
fe() {}
};

let Lazy!: ReturnType<typeof lazy<any>>;
function App() {
Lazy = lazy(
() => new Promise<any>(resolve => setTimeout(() => resolve({ default: () => "ok" }), 0))
);
const data = createMemo(async () => {
await Lazy.preload();
return "ready";
});
return <>{data()}</>;
}

dispose = hydrate(() => <App />, container);
flush();
// Adopted the serialized value (the client compute ran only as a trace).
expect(container.textContent).toBe("ready");
await sleep(10);
flush();
expect(isHydrating()).toBe(false);
expect(container.textContent).toBe("ready");

const settled = await Promise.race([
Lazy.preload().then((mod: any) => mod.default()),
sleep(50).then(() => "timeout")
]);
expect(settled).toBe("ok");
});
});
21 changes: 17 additions & 4 deletions scripts/size/scenarios.js
Original file line number Diff line number Diff line change
Expand Up @@ -2400,8 +2400,14 @@ module.exports = [
// `hydrate()`: a hydrate() started mid-parse waits for the shell-end
// marker. Cap set at measured + 10 B rounded up to 0.01 KB. Accepted by
// the maintainer (2026-10-13) with the renderToString floor raise.
limit: "29.50 KB",
capMinified: 93176,
// Size-Exception (a lazy() load started during the hydration trace is not
// cached, #4013, 2026-10-13): 29.50 -> 29.52 KB, measured at 29,506 B
// against `next` @ c9027a90's 29,440 (+66 B; 6 B over the cap; +34 B
// minified, 93,304 -> 93,338) — the same `lazy()`/`lazyModule()` guard
// as the compiled hydrating note. Cap set at measured + 10 B rounded up
// to 0.01 KB. Accepted by the maintainer 2026-10-13.
limit: "29.52 KB",
capMinified: 93338,
alias
},
{
Expand Down Expand Up @@ -3669,8 +3675,15 @@ module.exports = [
// them their transaction instead of the committed screen. Cap set at
// measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer
// 2026-10-13.
limit: "31.62 KB",
capMinified: 100927,
// Size-Exception (a lazy() load started during the hydration trace is not
// cached, #4013, 2026-10-13): 31.62 -> 31.64 KB, measured at 31,622 B
// against `next` @ c9027a90's 31,610 (+12 B; 2 B over the cap; +34 B
// minified, 100,927 -> 100,961) — `lazy()`/`lazyModule()` drop a loader
// promise built from the trace's never-settling Promise stub. Cap set at
// measured + 10 B rounded up to 0.01 KB. Accepted by the maintainer
// 2026-10-13.
limit: "31.64 KB",
capMinified: 100961,
alias
},
{
Expand Down
Loading