Skip to content

Commit 05e60f5

Browse files
committed
fix(core): keep detached TextNode renders from being silently dropped
TextNodeRenderable.requestRender() did `this.parent?.requestRender()`, which becomes a no-op when a node is transiently detached (parent === null) while still being mutated — e.g. during a SolidJS <For>/<Index> re-key. The render request is lost and streaming text freezes mid-update. Retain a RenderContext reference threaded down on every attach (add, replace, insertBefore, including StyledText fan-out), seeded in RootTextNodeRenderable, and fall back to it in requestRender() when parent is null. A node that was never attached still reaches no context.
1 parent 90db181 commit 05e60f5

2 files changed

Lines changed: 105 additions & 5 deletions

File tree

packages/core/src/renderables/TextNode.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { describe, expect, it } from "bun:test"
2-
import { TextNodeRenderable, isTextNodeRenderable } from "./TextNode.js"
2+
import { TextNodeRenderable, RootTextNodeRenderable, isTextNodeRenderable } from "./TextNode.js"
3+
import type { RenderContext } from "../types.js"
4+
import type { TextRenderable } from "./Text.js"
35
import { RGBA } from "../lib/RGBA.js"
46
import { StyledText, red, bold, t } from "../lib/styled-text.js"
57

@@ -1081,3 +1083,65 @@ describe("TextNodeRenderable", () => {
10811083
})
10821084
})
10831085
})
1086+
1087+
describe("TextNodeRenderable render request propagation", () => {
1088+
function makeCtx() {
1089+
let count = 0
1090+
const ctx = {
1091+
requestRender: () => {
1092+
count++
1093+
},
1094+
} as unknown as RenderContext
1095+
return { ctx, getCount: () => count }
1096+
}
1097+
1098+
function makeRoot(ctx: RenderContext) {
1099+
return new RootTextNodeRenderable(ctx, { id: "root" }, undefined as unknown as TextRenderable)
1100+
}
1101+
1102+
it("forwards a detached node's render request to the retained RenderContext", () => {
1103+
const { ctx, getCount } = makeCtx()
1104+
const root = makeRoot(ctx)
1105+
1106+
const child = new TextNodeRenderable({ id: "child" })
1107+
root.add(child)
1108+
1109+
// Simulate a transient detach during a list re-diff / reorder / re-key.
1110+
root.remove("child")
1111+
expect(child.parent).toBeNull()
1112+
1113+
// The detached node is still being mutated (e.g. streaming text deltas).
1114+
const before = getCount()
1115+
child.add("streamed delta")
1116+
1117+
// Without the ctx fallback this render request would be silently dropped.
1118+
expect(getCount()).toBeGreaterThan(before)
1119+
})
1120+
1121+
it("propagates the RenderContext to nested descendants so they self-heal when detached", () => {
1122+
const { ctx, getCount } = makeCtx()
1123+
const root = makeRoot(ctx)
1124+
1125+
const parent = new TextNodeRenderable({ id: "parent" })
1126+
const grandchild = new TextNodeRenderable({ id: "grandchild" })
1127+
parent.add(grandchild)
1128+
root.add(parent)
1129+
1130+
// Detach the whole subtree.
1131+
root.remove("parent")
1132+
expect(parent.parent).toBeNull()
1133+
1134+
const before = getCount()
1135+
grandchild.add("deep delta")
1136+
expect(getCount()).toBeGreaterThan(before)
1137+
})
1138+
1139+
it("does not reach the RenderContext for a node that was never attached", () => {
1140+
const { ctx, getCount } = makeCtx()
1141+
expect(typeof ctx.requestRender).toBe("function")
1142+
1143+
const orphan = new TextNodeRenderable({ id: "orphan" })
1144+
orphan.add("text")
1145+
expect(getCount()).toBe(0)
1146+
})
1147+
})

packages/core/src/renderables/TextNode.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export class TextNodeRenderable extends BaseRenderable {
4040
private _link?: { url: string }
4141
private _children: (string | TextNodeRenderable)[] = []
4242
public parent: TextNodeRenderable | null = null
43+
protected _ctx?: RenderContext
4344

4445
constructor(options: TextNodeOptions) {
4546
super(options)
@@ -61,7 +62,28 @@ export class TextNodeRenderable extends BaseRenderable {
6162

6263
public requestRender(): void {
6364
this.markDirty()
64-
this.parent?.requestRender()
65+
if (this.parent) {
66+
this.parent.requestRender()
67+
} else {
68+
// Detached (parent === null) but possibly still being mutated: fall back to
69+
// the retained RenderContext so the render request is not silently dropped.
70+
this._ctx?.requestRender()
71+
}
72+
}
73+
74+
/**
75+
* Propagate the active RenderContext down this text-node subtree. The reference is
76+
* retained even after the node is later detached (parent === null) so a still-updating
77+
* detached node can keep reaching the renderer via requestRender().
78+
*/
79+
protected setRenderContext(ctx: RenderContext | undefined): void {
80+
if (this._ctx === ctx) return
81+
this._ctx = ctx
82+
for (const child of this._children) {
83+
if (typeof child !== "string") {
84+
child.setRenderContext(ctx)
85+
}
86+
}
6587
}
6688

6789
public add(obj: TextNodeRenderable | StyledText | string, index?: number): number {
@@ -82,13 +104,15 @@ export class TextNodeRenderable extends BaseRenderable {
82104
if (index !== undefined) {
83105
this._children.splice(index, 0, obj)
84106
obj.parent = this
107+
obj.setRenderContext(this._ctx)
85108
this.requestRender()
86109
return index
87110
}
88111

89112
const insertIndex = this._children.length
90113
this._children.push(obj)
91114
obj.parent = this
115+
obj.setRenderContext(this._ctx)
92116
this.requestRender()
93117
return insertIndex
94118
}
@@ -97,14 +121,20 @@ export class TextNodeRenderable extends BaseRenderable {
97121
const textNodes = styledTextToTextNodes(obj)
98122
if (index !== undefined) {
99123
this._children.splice(index, 0, ...textNodes)
100-
textNodes.forEach((node) => (node.parent = this))
124+
textNodes.forEach((node) => {
125+
node.parent = this
126+
node.setRenderContext(this._ctx)
127+
})
101128
this.requestRender()
102129
return index
103130
}
104131

105132
const insertIndex = this._children.length
106133
this._children.push(...textNodes)
107-
textNodes.forEach((node) => (node.parent = this))
134+
textNodes.forEach((node) => {
135+
node.parent = this
136+
node.setRenderContext(this._ctx)
137+
})
108138
this.requestRender()
109139
return insertIndex
110140
}
@@ -116,6 +146,7 @@ export class TextNodeRenderable extends BaseRenderable {
116146
this._children[index] = obj
117147
if (typeof obj !== "string") {
118148
obj.parent = this
149+
obj.setRenderContext(this._ctx)
119150
}
120151
this.requestRender()
121152
}
@@ -138,10 +169,14 @@ export class TextNodeRenderable extends BaseRenderable {
138169
} else if (isTextNodeRenderable(child)) {
139170
this._children.splice(anchorIndex, 0, child)
140171
child.parent = this
172+
child.setRenderContext(this._ctx)
141173
} else if (child instanceof StyledText) {
142174
const textNodes = styledTextToTextNodes(child)
143175
this._children.splice(anchorIndex, 0, ...textNodes)
144-
textNodes.forEach((node) => (node.parent = this))
176+
textNodes.forEach((node) => {
177+
node.parent = this
178+
node.setRenderContext(this._ctx)
179+
})
145180
} else {
146181
throw new Error("Child must be a string, TextNodeRenderable, or StyledText instance")
147182
}
@@ -316,6 +351,7 @@ export class RootTextNodeRenderable extends TextNodeRenderable {
316351
) {
317352
super(options)
318353
this.textParent = textParent
354+
this._ctx = ctx
319355
}
320356

321357
public requestRender(): void {

0 commit comments

Comments
 (0)