frames: two unkeyed error records in one response make the node re-ask endlessly
Maintainer thường phản hồi trong vòng 1 ngày
Chưa có ai nhận issue này.
Đánh giá
- Độ khó
- 4/5
- Thời gian dự kiến
- 3-5 ngày
- Mức phù hợp với người mới
- 18/100
- Loại issue
- Lỗi
- Độ rõ ràng
- Khá rõ ràng
- Mức độ hoạt động
- Sôi nổi
- Công nghệ
- typescript
- Lĩnh vực
- frontend
Hướng nghiên cứu
Start in packages/web/frames/src/client.ts at the landing() function, where the surfaced error is keyed by response version and the failed tick triggers a re-ask. The reproduction is the two-record probe test in packages/web/test/frames-reask-loop.spec.tsx, and the cases to keep green are in frames-errored-reset-refetch.spec.tsx. Done means one fallback and one request for two unkeyed errors, a reset() still re-asks once, and the size budget table holds or has an agreed exception.
Do mô hình lập chỉ mục viết ra từ nội dung của issue.
Mô tả
Follow-up to #3922.
Repro
A frame response that carries two unkeyed error records:
{ "type": "start", "id": "srv", "version": 1 }
{ "type": "error", "id": "srv", "version": 1, "error": "boom" }
{ "type": "error", "id": "srv", "version": 1, "error": "bust" }
{ "type": "complete", "id": "srv", "version": 1 }
The page renders dynamic (or dynamicComponent) under <Errored> → <Loading>.
- On
next: 1 request. The<Errored>fallback rendersboom, then re-renders withbust. - With #3922: the node re-asks without end (the probe test below hits its 25-call cap). #3922's
landing()keys the surfaced error by response version. The second record ticks the mount'sfailedsignal, the node sees the version it already surfaced, and it treats the rerun as a re-ask. Each re-asked flight then repeats the same thing.
Reach
Our server can't produce this. A synchronous render failure sends one unkeyed error and then ends the stream (sink.error("", message), then sink.end()). Fragment errors are keyed. The transport closes a frame on its first unkeyed error, so the death sweep adds no second record. Only a non-conforming stream (hand-written, or another producer) can send two unkeyed errors in one response.
Why the cheap fixes fail
Any rerun of a node that has already thrown re-creates the <Errored> fallback, even if it throws the identical value. reportClientError also doesn't dedupe string errors, so the client error hook gets the error again. "Surface once" therefore means the second record must not rerun the node at all.
- Return the pending next landing instead of re-asking: stops the loop, but the fallback is re-created.
- Rethrow the error already surfaced (remember the tick at throw; a later tick at the same version rethrows): stops the loop, but the fallback is re-created and the error is reported again. Costs +21 B.
- Tick once per version in
failing()(per mount): versions are counted per address, so address A's v1 and address B's v1 coincide on one mount after a switch. B's later error (or B's seeded error on rebind) would never tick, and the node could hang on a landing promise for a flight nobody opens. - Gate on the address frame's
storeidentity (or its bareversion): an error that arrives after content in the same response shares that content's store and version, so the gate never fires. This breaks cases (e) and (e') offrames-errored-reset-refetch.spec.tsx.
Working mechanism
Each landing node gets a small memo that holds the address's error version (false when the address holds no error) and recomputes on each failed tick. The node reads that memo instead of the raw tick:
- a second record of the same response leaves the memo's value unchanged, so the node doesn't rerun;
- a new response's error changes the value, so the node reruns and throws;
reset()still recomputes the node directly, so it re-asks once.
With it, two records behave exactly like one: one fallback render showing boom, and 1 request. A later reset() makes exactly 1 more. The full packages/web suites pass (default 1279, server 1525, hydrate 463), as does test-types.
Conforming edge it changes (not yet tested)
A fresh node created over an address that another frame already shows as errored re-asks on its first run. If the mount's own frame then announces that same error (the rebind seed at commit), #3922 re-asks a second time; the gate re-asks once. This hasn't been reproduced in a test.
Size
The gate costs about +45 B minified over #3922, measured on #3922 merged with next (9f5c7a7e4). The most compact gate shape found was +36 B. #3922 leaves the frozen floor page: live server components 10 B over its recorded minified size, so about 10 B of the 20 B allowance is left. Minified / brotli, with and without the gate:
| Scenario | #3922 | with gate | brotli cap |
|---|---|---|---|
| app: render + one signal | 27,887 / 9,917 | 27,887 / 9,917 | 9,930 |
| frames: eager client consumer | 33,413 / 11,111 | 33,457 / 11,124 | 11,130 |
| page: base server components | 105,408 / 33,899 | 105,452 / 33,870 | 33,920 |
| page: live server components (frozen floor) | 117,451 / 37,573 | 117,496 / 37,665 | 37,590 |
| page: compiled base | 109,456 / 35,134 | 109,500 / 35,235 | 35,130 |
| page: compiled live | 122,928 / 40,682 | 122,972 / 40,715 | 40,660 |
| page: base + router | 129,170 / 41,251 | 129,214 / 41,278 | 41,260 |
| page: live + router | 142,467 / 46,941 | 142,511 / 46,962 | 46,930 |
The gate fails five scenarios: the frozen floor, both compiled pages and both router pages. Landing it needs either a size exception (including the floor) or offsetting savings elsewhere.
Full patch (against #3922 merged with next): the gate in landing() and the probe test
diff --git a/packages/web/frames/src/client.ts b/packages/web/frames/src/client.ts
index 39c2aa288..196a736c8 100644
--- a/packages/web/frames/src/client.ts
+++ b/packages/web/frames/src/client.ts
@@ -327,12 +327,12 @@ function landing<T>(host: any, address: string, value: T, failed: () => unknown)
// is applied: a fresh consumer of an errored address re-asks, it does not
// re-throw.
let frame: any, thrown: number | undefined;
- const errored = () =>
- (frame = host.get(address))?.error !== undefined &&
- (thrown === (thrown = frame.version) ? 1 : 2);
+ const version = () => (frame = host.get(address))?.error !== undefined && frame.version;
+ const errored = (v = version()) => v !== false && (thrown === (thrown = v) ? 1 : 2);
errored();
+ const response = createMemo(() => (failed(), version()));
return createMemo(() => {
- failed();
+ response();
const state = errored();
if ((state as number) > 1) throw frame.error;
const wait = host.landing(address);
diff --git a/packages/web/test/frames-reask-loop.spec.tsx b/packages/web/test/frames-reask-loop.spec.tsx
index ef300ffd5..aeea99a0b 100644
--- a/packages/web/test/frames-reask-loop.spec.tsx
+++ b/packages/web/test/frames-reask-loop.spec.tsx
@@ -136,6 +136,53 @@ describe.each(VIA)("a re-ask is one request — via %s", (_via, dyn) => {
expect(server.calls).toBe(surfaced);
m.cleanup();
});
+
+ test("a second error record in the same response neither surfaces again nor re-asks", async () => {
+ const { host } = makeHost();
+ installServerComponents(host);
+ let calls = 0;
+ vi.stubGlobal("fetch", async () => {
+ if (++calls > 25) return new Promise<Response>(() => {});
+ // Not what this server sends (one unkeyed error, then the stream
+ // ends), but a stream may carry two; both are the one response's.
+ return frameResponse("srv", [
+ { type: "start", id: "srv", version: 1 },
+ { type: "error", id: "srv", version: 1, error: "boom" },
+ { type: "error", id: "srv", version: 1, error: "bust" },
+ { type: "complete", id: "srv", version: 1 }
+ ]);
+ });
+ const getUser = createServerReference("reask-loop/two-records");
+ const Page = dyn(() => getUser() as any);
+ let reset: (() => void) | undefined;
+ const shown: string[] = [];
+ const m = mount(() => (
+ <Errored
+ fallback={(err, r) => {
+ reset = r;
+ return <span class="err">failed: {(shown.push(String(err())), String(err()))}</span>;
+ }}
+ >
+ <Loading fallback={<span class="shell">loading</span>}>
+ <Page />
+ </Loading>
+ </Errored>
+ ));
+ await pump(40);
+ expect(calls).toBe(1);
+ expect(m.div.querySelector(".err")!.textContent).toBe("failed: boom");
+ expect(m.div.querySelector(".shell")).toBeNull();
+ expect(shown).toEqual(["boom"]);
+
+ // A reset still re-asks: one request, and its flight's first error
+ // surfaces the same way.
+ reset!();
+ await pump(40);
+ expect(calls).toBe(2);
+ expect(m.div.querySelector(".err")!.textContent).toBe("failed: boom");
+ expect(m.div.querySelector(".shell")).toBeNull();
+ m.cleanup();
+ });
});
test("with no <Errored>, a mount over an errored preload halts on the next flight's equal error instead of re-asking", async () => {
— Drafted by Claude via Cursor for @ryansolid
- Ngôn ngữ chính
- TypeScript
- Star
- 36.1k
- Fork
- 1.1k
- Merge trung bình
- 12 giờ 10 phút
- Pull request đã merge (30 ngày)
- 338
Chuẩn bị môi trường
- Không có Dockerfile hay tệp Docker Compose
- Có mẫu pull request
- Đọc hướng dẫn đóng góp
Bắt đầu từ đâu
- Đọc hết issue, rồi đọc hướng dẫn đóng góp của dự án.
- Bình luận trên issue rằng bạn sẽ nhận — tránh hai người làm cùng một việc.
- Fork repository và làm thay đổi trên một nhánh.
- Mở pull request có tham chiếu số hiệu của issue.
Issue khác của solidjs/solid
-
`lazy()`: remounting an already-loaded component renders empty (or the Suspense fallback) since 1.9.14Có thể đã có người làm @itisrmk đã nhận 1 ngày trước. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 62/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 18/100
Maintainer thường phản hồi trong vòng 1 ngày
-
[2.0 / next] Error formatting and async iterator protocol failures escape memo error handlingĐang mở
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 22/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 5/5 Hơn một tuần Mức phù hợp với người mới 14/100
Maintainer thường phản hồi trong vòng 1 ngày
-
Độ khó 4/5 3-5 ngày Mức phù hợp với người mới 35/100
Maintainer thường phản hồi trong vòng 1 ngày
Tất cả issue của solidjs/solid
Issue tương tự
-
Độ khó 1/5 Dưới một giờ Mức phù hợp với người mới 65/100
521xueweihan/HelloGitHub#4025 ·
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 82/100
voidzero-dev/void#478 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
bug
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 69/100
Small-tailqwq/dsh-deep-whale#207 ·
Maintainer thường phản hồi trong vòng 1 ngày
-
The inspector never shows the instances field, so scaling out is unreachableCó thể đã có người làm @Mrkewl đã nhận hôm nay. Đang mở
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 76/100
xevrion/breakscale#96 ·
Maintainer thường phản hồi trong vòng 4 ngày
-
Độ khó 2/5 1-3 giờ Mức phù hợp với người mới 70/100
Flux159/mcp-server-kubernetes#384 ·