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 b685d4b84e..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": 106704
+ "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 db9cc861f9..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)",
@@ -4321,8 +4326,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
},
@@ -4624,6 +4637,19 @@ 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. 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)"],
@@ -4879,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)"],
@@ -5189,8 +5219,19 @@ 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).
+ // 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: 130591,
+ capMinified: 130667,
alias: pageAlias,
conditions: solidConditions,
compile: { hydratable: true }
@@ -5283,8 +5324,19 @@ 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).
+ // 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: 144124,
+ capMinified: 144205,
alias: pageAlias,
conditions: solidConditions,
compile: { hydratable: true }