`lazy()`: remounting an already-loaded component renders empty (or the Suspense fallback) since 1.9.14
维护者通常 1 天内回复
评估
- 难度
- 2/5
- 预计耗时
- 1-3 小时
- 新手友好度
- 62/100
- Issue 类型
- 缺陷
- 描述清晰度
- 描述清楚
- 活跃度
- 活跃
- 技术栈
- javascript, typescript
- 领域
- frontend
调研方向
该 bug 位于 solid-js 源码中 lazy() 的客户端分支(dist/solid.js 构建产物指向该分支),其中 onCleanup 会在任一实例被销毁时清空共享的 comp 缓存。请阅读该分支以及在解析完成后设置 comp 的 load() 路径,然后检查是否有现有测试覆盖重试失败的导入。完成标准是:加载成功后重新挂载时同步渲染,导入失败后重新挂载仍会重试,且现有测试全部通过。
由索引模型根据 Issue 内容生成。
描述
Describe the bug
Since 1.9.14, disposing any instance of a lazy() component clears the module-level component cache shared by all instances. The next mount of the same, already-loaded component creates a new resource and renders "" until it resolves. Inside <Suspense>, the fallback shows again. In 1.9.13 the remount renders the component synchronously.
This shows up as a visible flash whenever a lazily loaded page or overlay is closed and opened again.
Your Example Website or App
https://playground.solidjs.com/anonymous/08d6df4c-9789-4c96-8910-bb818f3fb391
Steps to Reproduce the Bug or Issue
Run test with solid version 1.9.17 from playground url. If you test it locally with solid 1.9.13 works as expected
import { createRoot, lazy, Suspense } from "solid-js";
import { render } from "solid-js/web";
import { createSignal } from "solid-js";
const flush = () => new Promise<void>((r) => setTimeout(r, 0));
function App() {
const [lines, setLines] = createSignal<{ text: string; pass: boolean }[]>([]);
async function runTest() {
const out: { text: string; pass: boolean }[] = [];
const check = (label: string, actual: unknown) => {
const pass = actual === "loaded";
out.push({ text: `${label}: ${JSON.stringify(actual)}`, pass });
};
// ── Case 1: without Suspense ──────────────────────────────
const Lazy1 = lazy(() => Promise.resolve({ default: () => "loaded" }));
let first1 = createRoot((d) => ({ d, v: Lazy1({}) }));
await flush();
first1.d();
let second1 = createRoot((d) => ({ d, v: Lazy1({}) }));
const r1 =
typeof second1.v === "function" ? (second1.v as any)() : second1.v;
check("without Suspense – remount", r1);
second1.d();
// ── Case 2: with Suspense ─────────────────────────────────
const Lazy2 = lazy(() => Promise.resolve({ default: () => "loaded" }));
const mkView = () =>
Suspense({
fallback: "fallback",
get children() {
return Lazy2({});
},
});
let first2 = createRoot((d) => ({ d, v: mkView() }));
await flush();
first2.d();
let second2 = createRoot((d) => ({ d, v: mkView() }));
const r2 =
typeof second2.v === "function" ? (second2.v as any)() : second2.v;
check("with Suspense – remount", r2);
second2.d();
setLines(out);
}
return (
<div style="font-family:monospace;padding:1rem">
<h2>lazy() remount regression · solid-js 1.9.14–1.9.17</h2>
<p>
Expected: <code>"loaded"</code> both times (solid-js 1.9.13 ✅)
</p>
<button onClick={runTest}>▶ Run test</button>
<ul>
{lines().map((l) => (
<li style={`color:${l.pass ? "green" : "red"}`}>
{l.pass ? "✅" : "❌"} {l.text}
</li>
))}
</ul>
</div>
);
}
render(() => <App />, document.getElementById("app")!);
Expected behavior
Once the module has loaded, a remount renders the component synchronously, as in 1.9.13:
== solid-js 1.9.13
without Suspense first mount after load: "loaded", remount: "loaded"
with Suspense first mount after load: "loaded", remount: "loaded"
Actual, in 1.9.14 through 1.9.17:
== solid-js 1.9.14
without Suspense first mount after load: "loaded", remount: ""
with Suspense first mount after load: "loaded", remount: "fallback"
== solid-js 1.9.17
without Suspense first mount after load: "loaded", remount: ""
with Suspense first mount after load: "loaded", remount: "fallback"
Cause
1.9.14 added an onCleanup in the client branch of lazy() (dist/solid.js):
} else if (!comp) {
const [s] = createResource(() => (p || (p = fn())).then(mod => mod.default));
comp = s;
+ onCleanup(() => comp = undefined);
}
comp is shared by every instance of the lazy() component, but the cleanup is registered on the owner of whichever instance created the resource. Disposing that one instance therefore clears the cache for all of them, even after the module has loaded. The next mount takes the !comp branch again and waits for a new resource. The module promise p is still cached, so nothing is re-downloaded, but the new resource resolves asynchronously, which causes the empty render or Suspense fallback.
Possible fix
If the cleanup is meant to let a failed import be retried on the next mount, it could clear the cache only when it still points at this instance's resource and that resource hasn't loaded. For example, against 1.9.17, where load() already sets comp = () => mod.default once the module resolves:
- onCleanup(() => comp = undefined);
+ onCleanup(() => comp === s && (comp = undefined));
With this change, a remount after a successful load renders synchronously, and a remount after a failed import still retries.
Screenshots or Videos
No response
Platform
- OS: [macOS]
- solid-js: 1.9.14 to 1.9.17 (works in 1.9.13)
- Client build (
dist/solid.cjs/dist/solid.js), no SSR or hydration - Node 22 for the reproduction; we first saw it in a SolidJS app using the universal renderer
Additional context
No response
- 主要语言
- TypeScript
- 星标
- 36.2k
- 派生
- 1.1k
- 平均合并
- 10 小时 28 分钟
- 30 天内合并 PR
- 379
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 阅读贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
solidjs/solid 的其他 Issue
-
难度 4/5 3-5 天 新手友好度 51/100
维护者通常 1 天内回复
-
难度 4/5 3-5 天 新手友好度 45/100
维护者通常 1 天内回复
-
Hydration runs an async memo over latest(...) of a hydrated chain with undefined and re-asks可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭
难度 4/5 3-5 天 新手友好度 48/100
维护者通常 1 天内回复
-
[2.0 / next] A memo reading latest(a) beside b() computes a torn pass on a batched write; if that pass throws, the error sticks可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭
难度 4/5 1-2 天 新手友好度 48/100
维护者通常 1 天内回复
-
[2.0 / next] latest(m) returns undefined for an uninitialized async memo after its latest() input changes (A7)可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭
难度 4/5 1-2 天 新手友好度 55/100
维护者通常 1 天内回复
相似的 Issue
-
难度 2/5 1-3 小时 新手友好度 78/100
gnosis/gnosis_vpn#661 ·
维护者通常 1 天内回复
-
content-freshness: staleOwnedContent doc comments still say 'complete receipt' (follow-up to #7912)未关闭triage:bug-verified
难度 1/5 1 小时以内 新手友好度 92/100
维护者通常 1 天内回复
-
fix: reject sourceStorage/packagedStorage bucket that differs from the stack default (fail loud)未关闭
难度 2/5 1-3 小时 新手友好度 82/100
Eyevinn/open-videocore#1300 ·
维护者通常 1 天内回复
-
nextjs-tracking
难度 2/5 1-3 小时 新手友好度 67/100
cloudflare/vinext#4390 ·
维护者通常 1 天内回复
-
kind/bug
难度 2/5 1-3 小时 新手友好度 82/100
kubernetes-sigs/headlamp#8175 ·
维护者通常 2 天内回复