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/morph-drops-stale-text.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions packages/web/frames/src/frame-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions packages/web/test/frames-hole-row-ops.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<li><!--$-->1.5<!--/--><span>q</span></li>"]] }
});
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, "<li><!--$--><!--/--><span>q</span></li>"]] }
});
await cycle();
observer.disconnect();
expect(items()[1].innerHTML).toBe("<!--$--><!--/--><span>q</span>");
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();
Expand Down
4 changes: 2 additions & 2 deletions scripts/size/floor-caps.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
64 changes: 58 additions & 6 deletions scripts/size/scenarios.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down Expand Up @@ -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
},
Expand Down Expand Up @@ -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)"],
Expand Down Expand Up @@ -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)"],
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }
Expand Down
Loading