From 2b31db05e3b3bd3a9cbb4b90a94109ad49aca2cc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:48:41 +0000 Subject: [PATCH 1/3] fix(frames): morph drops a stale text node instead of relocating what follows An empty text hole renders `` with no text node. When a hole went from text to empty, reconcileChildren met the old text node against the new closing comment, inserted the comment, then relocated the next element (found by lookahead) in front of the stale text. Every emptied cell re-inserted its following subtree. A text node holds no client state, so when the new side has no text at that position the reconcile now drops it and lines up again. server-dbmon, 400 rows, 10% changing, 4x CPU: 28 popover subtrees re-inserted per tick -> 0; main-thread busy 771-910 -> 571-705 ms/s. Size: +71 B minified on the frames client and the server-component pages. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LZkrATgvFJjgFRdDB4Drcq --- .changeset/morph-drops-stale-text.md | 5 +++ packages/web/frames/src/frame-client.ts | 8 ++++ .../web/test/frames-hole-row-ops.spec.tsx | 33 ++++++++++++++++ scripts/size/floor-caps.json | 2 +- scripts/size/scenarios.js | 38 +++++++++++++++++-- 5 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 .changeset/morph-drops-stale-text.md diff --git a/.changeset/morph-drops-stale-text.md b/.changeset/morph-drops-stale-text.md new file mode 100644 index 0000000000..caee929576 --- /dev/null +++ b/.changeset/morph-drops-stale-text.md @@ -0,0 +1,5 @@ +--- +"@solidjs/web": patch +--- + +Frames morph: when a text hole empties, the reconcile drops the stale text node instead of relocating the next element in front of it, so the elements after an emptied text hole stay put instead of being re-inserted. diff --git a/packages/web/frames/src/frame-client.ts b/packages/web/frames/src/frame-client.ts index 11cd65d3ef..b607957953 100644 --- a/packages/web/frames/src/frame-client.ts +++ b/packages/web/frames/src/frame-client.ts @@ -3288,6 +3288,14 @@ function reconcileChildren( newChild = nextNew; continue; } + // A stale text node (a text hole that went empty) holds no client state: + // drop it so what follows lines up again, instead of relocating the + // next element in front of it. + if (old.nodeType === TEXT_NODE && newChild.nodeType !== TEXT_NODE) { + oldChild = old.nextSibling; + old.remove(); + continue; + } // Incompatible here — but a compatible element may sit further along: // content churn between versions (a placeholder where revealed content // was) shifts positions, and recreating a later match would destroy the diff --git a/packages/web/test/frames-hole-row-ops.spec.tsx b/packages/web/test/frames-hole-row-ops.spec.tsx index f4e810b44e..8d91200432 100644 --- a/packages/web/test/frames-hole-row-ops.spec.tsx +++ b/packages/web/test/frames-hole-row-ops.spec.tsx @@ -81,6 +81,39 @@ describe("row ops on a live For hole", () => { vi.restoreAllMocks(); }); + test("a text hole that empties drops its text node without moving what follows", async () => { + await prepareTier("wire"); + const { container, items, send, done } = await mount(); + send({ + type: "hole", + key: "lh:0", + digest: "d1", + base: "d0", + rows: { n: 3, r: [[1, "
  • 1.5q
  • "]] } + }); + await cycle(); + const span = items()[1].querySelector("span")!; + const added: string[] = []; + const observer = new MutationObserver(records => { + for (const r of records) for (const n of r.addedNodes) added.push(n.nodeName); + }); + observer.observe(container, { subtree: true, childList: true }); + send({ + type: "hole", + key: "lh:0", + digest: "d2", + base: "d1", + rows: { n: 3, r: [[1, "
  • q
  • "]] } + }); + await cycle(); + observer.disconnect(); + expect(items()[1].innerHTML).toBe("q"); + expect(items()[1].querySelector("span")).toBe(span); + // Dropping the stale text is the whole change: the span is not re-inserted. + expect(added).toEqual([]); + await done(); + }); + test("morphs only the changed row, appends and truncates at the tail", async () => { await prepareTier("wire"); const { items, send, done } = await mount(); diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index b6577b6698..6bb339b213 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -13,7 +13,7 @@ }, "page: base server components (hydrating + dynamic + frames + sf reference)": { "cap": "34.334 KB", - "minified": 106704 + "minified": 106772 }, "page: live server components (base + live/GET + action + isPending/latest)": { "cap": "38.146 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index dad791b7fe..d55f9cedc1 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -4263,8 +4263,16 @@ module.exports = [ // 10% of rows changing: 100 rows 305-319 -> 231-342; 400 rows 933-1001 // (saturated) -> 551-825. Wire (brotli q5) 1.7-1.9 -> 1.5-1.6 KB/s at // 100 rows, 6.8-8.1 -> 5.1-6.0 KB/s at 400. Cap set to that measurement. - limit: "11.838 KB", - capMinified: 35395, + // Morph drops a stale text node (2026-10-15): +71 B minified. When a + // text hole empties (`` has no text node), the reconcile + // removed nothing and relocated the next element in front of the stale + // text, so every cell that emptied re-inserted its following subtree. + // server-dbmon, 400 rows, 10% changing, 4x CPU: 28 popover subtrees + // re-inserted per tick -> 0; main-thread busy 771-910 -> 571-705 ms/s. + // Measured at 11,856 B brotli / 35,466 B minified. Cap set to that + // measurement. + limit: "11.856 KB", + capMinified: 35466, alias: framesAlias, external: framesExternal }, @@ -4566,6 +4574,14 @@ module.exports = [ // minified). A select value is reapplied when its options arrive, // including multiple. Cap set to that brotli, not above it. Hello world // on this merge is 9,939 B, under the 9.94 KB floor and under 10 KB. + // Size-Exception (morph drops a stale text node (2026-10-15): +71 B minified on the frozen page floor. When a + // text hole empties (`` has no text node), the reconcile + // removed nothing and relocated the next element in front of the stale + // text, so every cell that emptied re-inserted its following subtree. + // server-dbmon, 400 rows, 10% changing, 4x CPU: 28 popover subtrees + // re-inserted per tick -> 0; main-thread busy 771-910 -> 571-705 ms/s. + // Recorded minified (floor-caps.json) 106,704 -> 106,772; the brotli cap + // is unchanged. limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -5131,8 +5147,15 @@ module.exports = [ // #3928 (2026-10-09): a select value is reapplied when its options arrive, // including multiple. Merged with next @ 617d19511: 41,794 B brotli // (130,591 B minified). Cap set to that brotli, not above it. + // Morph drops a stale text node (2026-10-15): +71 B minified. When a + // text hole empties (`` has no text node), the reconcile + // removed nothing and relocated the next element in front of the stale + // text, so every cell that emptied re-inserted its following subtree. + // server-dbmon, 400 rows, 10% changing, 4x CPU: 28 popover subtrees + // re-inserted per tick -> 0; main-thread busy 771-910 -> 571-705 ms/s. + // Recorded minified 130,591 -> 130,653 (the brotli cap is unchanged). limit: "41.794 KB", - capMinified: 130591, + capMinified: 130653, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -5225,8 +5248,15 @@ module.exports = [ // #3928 (2026-10-09): a select value is reapplied when its options arrive, // including multiple. Merged with next @ 617d19511: 47,493 B brotli // (144,124 B minified). Cap set to that brotli, not above it. + // Morph drops a stale text node (2026-10-15): +71 B minified. When a + // text hole empties (`` has no text node), the reconcile + // removed nothing and relocated the next element in front of the stale + // text, so every cell that emptied re-inserted its following subtree. + // server-dbmon, 400 rows, 10% changing, 4x CPU: 28 popover subtrees + // re-inserted per tick -> 0; main-thread busy 771-910 -> 571-705 ms/s. + // Recorded minified 144,124 -> 144,190 (the brotli cap is unchanged). limit: "47.493 KB", - capMinified: 144124, + capMinified: 144190, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } From 674a2ac2a03f189b019951c1687680b4ea1c323b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 04:12:55 +0000 Subject: [PATCH 2/3] chore(size): note the maintainer's acceptance of the base page floor raise Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LZkrATgvFJjgFRdDB4Drcq --- scripts/size/scenarios.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index d55f9cedc1..6726bd20bb 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -4581,7 +4581,7 @@ module.exports = [ // server-dbmon, 400 rows, 10% changing, 4x CPU: 28 popover subtrees // re-inserted per tick -> 0; main-thread busy 771-910 -> 571-705 ms/s. // Recorded minified (floor-caps.json) 106,704 -> 106,772; the brotli cap - // is unchanged. + // is unchanged. Accepted by the maintainer (2026-10-15). limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], From e08c39039419505cef7b4be3de4e500b3b1a7623 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 05:45:24 +0000 Subject: [PATCH 3/3] chore(size): ratchet up caps to what next measures after #4067 createDeferred (#4067) grew the signals core: + until/refresh goes over its cap (8.07 -> 8.11 KB, 22,195 -> 22,255 B minified), and the four SC page scenarios sit within 1-6 B of the minified allowance. Re-record them at the merged tree's measurement: frozen page floors 106,772 -> 106,786 and 118,990 -> 119,009, router pages 130,653 -> 130,667 and 144,190 -> 144,205. Brotli caps on the pages are unchanged. Accepted by the maintainer (2026-10-15, "Ratchet up"). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01LZkrATgvFJjgFRdDB4Drcq --- scripts/size/floor-caps.json | 4 ++-- scripts/size/scenarios.js | 32 +++++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/scripts/size/floor-caps.json b/scripts/size/floor-caps.json index 80e377ba27..a2e6605a43 100644 --- a/scripts/size/floor-caps.json +++ b/scripts/size/floor-caps.json @@ -13,11 +13,11 @@ }, "page: base server components (hydrating + dynamic + frames + sf reference)": { "cap": "34.334 KB", - "minified": 106772 + "minified": 106786 }, "page: live server components (base + live/GET + action + isPending/latest)": { "cap": "38.146 KB", - "minified": 118990 + "minified": 119009 }, "server: floor (getRequestEvent + isServer)": { "cap": "1.34 KB", diff --git a/scripts/size/scenarios.js b/scripts/size/scenarios.js index 18250ac9b0..a1e67e9fd0 100644 --- a/scripts/size/scenarios.js +++ b/scripts/size/scenarios.js @@ -1357,8 +1357,13 @@ module.exports = [ // the truth helpers out of lanes.ts, this bundle carried all of lanes.js // (+4.8 KB minified / +1.6 KB brotli over the core floor). Measured at // 8.056 KB, minified 22195. - limit: "8.07 KB", - capMinified: 22195 + // Ratchet up (2026-10-15, merged with next @ b8617ecd): 8.07 -> 8.11 KB + // (22,195 -> 22,255 B minified), measured at 8,095 B. createDeferred + // (#4067) grew the signals core this bundle shares (+60 B minified); no + // lane code came back. Cap set at measured + 10 B rounded up to 0.01 KB. + // Accepted by the maintainer (2026-10-15, "Ratchet up"). + limit: "8.11 KB", + capMinified: 22255 }, { name: "app: render + one signal (the simple-app floor)", @@ -4632,7 +4637,8 @@ module.exports = [ // minified). A select value is reapplied when its options arrive, // including multiple. Cap set to that brotli, not above it. Hello world // on this merge is 9,939 B, under the 9.94 KB floor and under 10 KB. - // Size-Exception (morph drops a stale text node (2026-10-15): +71 B minified on the frozen page floor. When a + // Size-Exception (morph drops a stale text node, 2026-10-15): +71 B + // minified on the frozen page floor. When a // text hole empties (`` has no text node), the reconcile // removed nothing and relocated the next element in front of the stale // text, so every cell that emptied re-inserted its following subtree. @@ -4640,6 +4646,10 @@ module.exports = [ // re-inserted per tick -> 0; main-thread busy 771-910 -> 571-705 ms/s. // Recorded minified (floor-caps.json) 106,704 -> 106,772; the brotli cap // is unchanged. Accepted by the maintainer (2026-10-15). + // Ratchet up (2026-10-15, merged with next @ b8617ecd): 106,772 -> 106,786 + // recorded minified (floor-caps.json; the brotli cap is unchanged), re-recorded at what the tree measures after + // createDeferred (#4067) landed in the signals core. Accepted by the + // maintainer (2026-10-15, "Ratchet up"). limit: floorCaps["page: base server components (hydrating + dynamic + frames + sf reference)"], capMinified: floorMinified["page: base server components (hydrating + dynamic + frames + sf reference)"], @@ -4895,6 +4905,10 @@ module.exports = [ // merged with next @ 617d19511 and measured at 38,146 B (118,990 B // minified). A select value is reapplied when its options arrive, // including multiple. Cap set to that brotli, not above it. + // Ratchet up (2026-10-15, merged with next @ b8617ecd): 118,990 -> 119,009 + // recorded minified (floor-caps.json; the brotli cap is unchanged), re-recorded at what the tree measures after + // createDeferred (#4067) landed in the signals core. Accepted by the + // maintainer (2026-10-15, "Ratchet up"). limit: floorCaps["page: live server components (base + live/GET + action + isPending/latest)"], capMinified: floorMinified["page: live server components (base + live/GET + action + isPending/latest)"], @@ -5212,8 +5226,12 @@ module.exports = [ // server-dbmon, 400 rows, 10% changing, 4x CPU: 28 popover subtrees // re-inserted per tick -> 0; main-thread busy 771-910 -> 571-705 ms/s. // Recorded minified 130,591 -> 130,653 (the brotli cap is unchanged). + // Ratchet up (2026-10-15, merged with next @ b8617ecd): 130,653 -> 130,667 + // recorded minified (the brotli cap is unchanged), re-recorded at what the tree measures after + // createDeferred (#4067) landed in the signals core. Accepted by the + // maintainer (2026-10-15, "Ratchet up"). limit: "41.794 KB", - capMinified: 130653, + capMinified: 130667, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true } @@ -5313,8 +5331,12 @@ module.exports = [ // server-dbmon, 400 rows, 10% changing, 4x CPU: 28 popover subtrees // re-inserted per tick -> 0; main-thread busy 771-910 -> 571-705 ms/s. // Recorded minified 144,124 -> 144,190 (the brotli cap is unchanged). + // Ratchet up (2026-10-15, merged with next @ b8617ecd): 144,190 -> 144,205 + // recorded minified (the brotli cap is unchanged), re-recorded at what the tree measures after + // createDeferred (#4067) landed in the signals core. Accepted by the + // maintainer (2026-10-15, "Ratchet up"). limit: "47.493 KB", - capMinified: 144190, + capMinified: 144205, alias: pageAlias, conditions: solidConditions, compile: { hydratable: true }