@solidrt/core 0.0.27 → 0.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/image.ts +111 -16
- package/src/renderer.ts +78 -7
- package/src/scroll.ts +20 -0
- package/src/types.d.ts +5 -0
- package/src/window.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.29",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"colord": "^2.9.3"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@solidrt/flux-types": "0.0.
|
|
30
|
+
"@solidrt/flux-types": "0.0.29"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@solidjs/signals": "2.0.0-beta.17",
|
package/src/image.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// top that fetches/decodes/uploads for you and swaps the texture when the source
|
|
4
4
|
// changes - the same relationship createTexture/createShader have to flux:gpu.
|
|
5
5
|
|
|
6
|
-
import { createMemo, onCleanup
|
|
6
|
+
import { createMemo, onCleanup } from "@solidjs/signals"
|
|
7
7
|
import { createTexture, destroyTexture } from "./gpu"
|
|
8
8
|
|
|
9
9
|
export type DecodedImage = {
|
|
@@ -24,6 +24,80 @@ export function decodeImage(bytes: Uint8Array): DecodedImage {
|
|
|
24
24
|
|
|
25
25
|
export type ImageSource = string | Uint8Array
|
|
26
26
|
|
|
27
|
+
// Shared loader for URL sources. Every mount of the same URL shares one
|
|
28
|
+
// fetch/decode/texture (refcounted; the texture is destroyed when the last
|
|
29
|
+
// mount releases it). Byte caching and fetch politeness live below, in the
|
|
30
|
+
// runtime's fetch layer (disk cache + per-host limit); this map exists for
|
|
31
|
+
// what a byte cache cannot provide, sharing the decoded GPU texture.
|
|
32
|
+
// Uint8Array sources bypass all of this: no key, per-mount texture.
|
|
33
|
+
type ImageEntry = {
|
|
34
|
+
refs: number
|
|
35
|
+
texture: number
|
|
36
|
+
promise: Promise<number>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let imageCache = new Map<string, ImageEntry>()
|
|
40
|
+
|
|
41
|
+
async function loadImage(url: string): Promise<number> {
|
|
42
|
+
// Images are assets: cache to disk, no freshness. Use a versioned URL (or
|
|
43
|
+
// fetch + decodeImage manually) when a URL's content must be re-checked.
|
|
44
|
+
let res = await fetch(url, { cache: "force-cache" })
|
|
45
|
+
if (!res.ok) throw new Error(`Image fetch failed: HTTP ${res.status} for ${url}`)
|
|
46
|
+
let bytes = await res.bytes()
|
|
47
|
+
let decoded: DecodedImage
|
|
48
|
+
try {
|
|
49
|
+
decoded = decodeImage(bytes)
|
|
50
|
+
} catch (e) {
|
|
51
|
+
throw new Error(`Image decode failed for ${url} (first bytes: ${sniffBytes(bytes)}): ${e}`)
|
|
52
|
+
}
|
|
53
|
+
return createTexture(decoded.data, decoded.width, decoded.height)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function acquireImage(url: string): ImageEntry {
|
|
57
|
+
let entry = imageCache.get(url)
|
|
58
|
+
if (!entry) {
|
|
59
|
+
let e: ImageEntry = { refs: 0, texture: -1, promise: undefined as never }
|
|
60
|
+
e.promise = loadImage(url).then(
|
|
61
|
+
id => {
|
|
62
|
+
// Everyone released while the load was in flight: nothing owns the
|
|
63
|
+
// texture, so drop it here instead of recording it.
|
|
64
|
+
if (e.refs === 0) {
|
|
65
|
+
destroyTexture(id)
|
|
66
|
+
imageCache.delete(url)
|
|
67
|
+
} else {
|
|
68
|
+
e.texture = id
|
|
69
|
+
}
|
|
70
|
+
return id
|
|
71
|
+
},
|
|
72
|
+
err => {
|
|
73
|
+
// Concurrent mounts shared this rejection; dropping the entry lets a
|
|
74
|
+
// later remount retry (a transient failure recovers with the network).
|
|
75
|
+
imageCache.delete(url)
|
|
76
|
+
throw err
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
// Awaiters observe the rejection; this keeps a fully-released failed
|
|
80
|
+
// entry from surfacing as an unhandled rejection.
|
|
81
|
+
e.promise.catch(() => {})
|
|
82
|
+
imageCache.set(url, e)
|
|
83
|
+
entry = e
|
|
84
|
+
}
|
|
85
|
+
entry.refs++
|
|
86
|
+
return entry
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function releaseImage(url: string): void {
|
|
90
|
+
let entry = imageCache.get(url)
|
|
91
|
+
if (!entry) return
|
|
92
|
+
entry.refs--
|
|
93
|
+
if (entry.refs > 0) return
|
|
94
|
+
if (entry.texture >= 0) {
|
|
95
|
+
destroyTexture(entry.texture)
|
|
96
|
+
imageCache.delete(url)
|
|
97
|
+
}
|
|
98
|
+
// Still pending: the settle handler above sees refs === 0 and cleans up.
|
|
99
|
+
}
|
|
100
|
+
|
|
27
101
|
/**
|
|
28
102
|
* Loads an image as an async computation and returns a reactive accessor for its
|
|
29
103
|
* GPU texture id. This is a SolidJS 2.0 async value: reading it suspends until
|
|
@@ -35,6 +109,13 @@ export type ImageSource = string | Uint8Array
|
|
|
35
109
|
* `<texture src={id()} />`; the texture carries its own pixel size, so no
|
|
36
110
|
* width/height is needed unless you want to scale it.
|
|
37
111
|
*
|
|
112
|
+
* URL loads are shared: mounts of the same URL reuse one fetch and one texture
|
|
113
|
+
* (freed when the last user is disposed). The bytes are fetched with
|
|
114
|
+
* `cache: "force-cache"` - images are assets, cached on disk with no
|
|
115
|
+
* freshness check - so use a versioned URL when the content behind a URL can
|
|
116
|
+
* change. A failed load rejects every mount sharing it; a later remount
|
|
117
|
+
* retries.
|
|
118
|
+
*
|
|
38
119
|
* For bytes you already hold (a `with { type: "binary" }` import, or anything in
|
|
39
120
|
* memory) this suspends needlessly: `decodeImage` + `createTexture` are both
|
|
40
121
|
* synchronous, so reach for them directly and skip the `<Loading>` boundary.
|
|
@@ -43,29 +124,43 @@ export type ImageSource = string | Uint8Array
|
|
|
43
124
|
*/
|
|
44
125
|
export function createImage(src: ImageSource | (() => ImageSource)): () => number {
|
|
45
126
|
let getSrc = typeof src === "function" ? src : () => src
|
|
46
|
-
let generation = 0
|
|
47
127
|
|
|
48
128
|
return createMemo<number>(async () => {
|
|
49
129
|
let source = getSrc()
|
|
50
|
-
let mine = ++generation
|
|
51
130
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
131
|
+
if (typeof source === "string") {
|
|
132
|
+
// Acquire and register cleanup synchronously, before the await: an
|
|
133
|
+
// onCleanup added after an await is orphaned because the reactive owner
|
|
134
|
+
// is not restored across it.
|
|
135
|
+
let entry = acquireImage(source)
|
|
136
|
+
onCleanup(() => releaseImage(source))
|
|
137
|
+
return await entry.promise
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Byte sources decode and upload synchronously; this run owns the texture.
|
|
55
141
|
let holder = { id: -1 }
|
|
56
142
|
onCleanup(() => {
|
|
57
143
|
if (holder.id >= 0) destroyTexture(holder.id)
|
|
58
144
|
})
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
let { data, width, height } = decodeImage(bytes)
|
|
68
|
-
holder.id = createTexture(data, width, height)
|
|
145
|
+
let decoded: DecodedImage
|
|
146
|
+
try {
|
|
147
|
+
decoded = decodeImage(source)
|
|
148
|
+
} catch (e) {
|
|
149
|
+
throw new Error(`Image decode failed (first bytes: ${sniffBytes(source)}): ${e}`)
|
|
150
|
+
}
|
|
151
|
+
holder.id = createTexture(decoded.data, decoded.width, decoded.height)
|
|
69
152
|
return holder.id
|
|
70
153
|
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// A payload that fails to decode is usually not an image at all (an HTML error
|
|
157
|
+
// page, a JSON error body); showing its first bytes makes that recognizable in
|
|
158
|
+
// the log without a debugger.
|
|
159
|
+
function sniffBytes(bytes: Uint8Array): string {
|
|
160
|
+
let head = ""
|
|
161
|
+
for (let i = 0; i < Math.min(bytes.length, 24); i++) {
|
|
162
|
+
let b = bytes[i] ?? 0
|
|
163
|
+
head += b >= 32 && b < 127 ? String.fromCharCode(b) : "."
|
|
164
|
+
}
|
|
165
|
+
return JSON.stringify(head)
|
|
71
166
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createRoot, onCleanup } from "@solidjs/signals"
|
|
2
2
|
import { createRenderer } from "@solidjs/universal"
|
|
3
|
+
import type { Element } from "solid-js"
|
|
3
4
|
import * as tree from "flux:rendertree"
|
|
4
5
|
import { attachWindow } from "./window"
|
|
5
6
|
import { setEventHandler, cleanupNodeHandlers, getFocusedNodeId, setFocus } from "./core"
|
|
@@ -87,6 +88,65 @@ function removeNode(parent: ProxyNode, node: ProxyNode): void {
|
|
|
87
88
|
}
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// ------ Leak sentinel (dev only) --------
|
|
92
|
+
|
|
93
|
+
// A node created but never inserted is unreachable by the remove -> destroy
|
|
94
|
+
// sweep, so it leaks permanently, natively and in the maps here. The usual
|
|
95
|
+
// cause is an element-valued prop read more than once: every read builds a
|
|
96
|
+
// fresh subtree and only the mounted one is ever freed. Rather than
|
|
97
|
+
// bookkeeping on the hot create/insert paths, orphans are derived from the
|
|
98
|
+
// proxy map itself: parentless, not the window root, and not awaiting the
|
|
99
|
+
// destroy sweep. window.ts runs the scan on a rendered frame every few
|
|
100
|
+
// seconds; dev bundles only (srt always defines process.env.NODE_ENV, so a
|
|
101
|
+
// production bundle folds the check into a constant early return).
|
|
102
|
+
const SENTINEL_INTERVAL_MS = 5000
|
|
103
|
+
let sentinelDue = 0
|
|
104
|
+
let warnedLeakTypes = new Set<string>()
|
|
105
|
+
|
|
106
|
+
export function scanForOrphans(now: number): void {
|
|
107
|
+
if (process.env.NODE_ENV === "production") return
|
|
108
|
+
if (now < sentinelDue) return
|
|
109
|
+
sentinelDue = now + SENTINEL_INTERVAL_MS
|
|
110
|
+
let counts = new Map<string, number>()
|
|
111
|
+
let total = 0
|
|
112
|
+
for (let node of nodes.values()) {
|
|
113
|
+
if (node.parent !== undefined || node.elementType === "window" || pendingDestroy.has(node.id)) continue
|
|
114
|
+
total += 1
|
|
115
|
+
counts.set(node.elementType, (counts.get(node.elementType) ?? 0) + 1)
|
|
116
|
+
}
|
|
117
|
+
if (total === 0) return
|
|
118
|
+
let fresh = [...counts].filter(([type]) => !warnedLeakTypes.has(type))
|
|
119
|
+
if (fresh.length === 0) return
|
|
120
|
+
for (let [type] of fresh) warnedLeakTypes.add(type)
|
|
121
|
+
let list = fresh.map(([type, n]) => `<${type}> x${n}`).join(", ")
|
|
122
|
+
console.warn(
|
|
123
|
+
`Leak sentinel: ${total} nodes are unreachable and will never be freed: ${list}. ` +
|
|
124
|
+
`The usual cause is reading an element-valued prop more than once (every read ` +
|
|
125
|
+
`builds a new subtree); read it once where it mounts, or resolve it with ` +
|
|
126
|
+
`children(). If these nodes are intentionally kept for later mounting, ignore ` +
|
|
127
|
+
`this. Element types already reported are not reported again.`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A property the native tree rejected must not take down the reactive system:
|
|
132
|
+
// a typo'd or not-yet-implemented prop poisons only itself. Warn once per
|
|
133
|
+
// element kind + property with a stack (the dev server remaps its frames to
|
|
134
|
+
// the .tsx source), then ignore further writes of the same pair.
|
|
135
|
+
let warnedUnknownProps = new Set<string>()
|
|
136
|
+
|
|
137
|
+
function setTreeProperty(node: ProxyNode, name: string, value: unknown): void {
|
|
138
|
+
try {
|
|
139
|
+
tree.setProperty(node.id, name, value)
|
|
140
|
+
} catch (e) {
|
|
141
|
+
if (!String(e).includes("unknown property")) throw e
|
|
142
|
+
let key = node.elementType + "." + name
|
|
143
|
+
if (warnedUnknownProps.has(key)) return
|
|
144
|
+
warnedUnknownProps.add(key)
|
|
145
|
+
let stack = new Error().stack ?? ""
|
|
146
|
+
console.warn(`Ignoring unknown property '${name}' on <${node.elementType}>\n${stack}`)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
90
150
|
// Applies a single prop to a node: routes events to the handler registry,
|
|
91
151
|
// parses color strings/gradients, and forwards everything else to the tree.
|
|
92
152
|
// Shared by the renderer's setProperty hook and by createElement, which since
|
|
@@ -103,16 +163,16 @@ function applyProp<T>(node: ProxyNode, name: string, value: T): void {
|
|
|
103
163
|
}
|
|
104
164
|
|
|
105
165
|
if (name === "color" && isGradient(value)) {
|
|
106
|
-
|
|
166
|
+
setTreeProperty(node, name, value)
|
|
107
167
|
return
|
|
108
168
|
}
|
|
109
169
|
|
|
110
170
|
if (name === "color" && typeof value === "string") {
|
|
111
|
-
|
|
171
|
+
setTreeProperty(node, name, parseColor(value))
|
|
112
172
|
return
|
|
113
173
|
}
|
|
114
174
|
|
|
115
|
-
|
|
175
|
+
setTreeProperty(node, name, value)
|
|
116
176
|
}
|
|
117
177
|
|
|
118
178
|
export let {
|
|
@@ -245,12 +305,23 @@ export function render(code: () => any) {
|
|
|
245
305
|
* The default mount is the window's flex root, so a portaled node that is not
|
|
246
306
|
* `position: "absolute"` will take flow space and displace app content. Position
|
|
247
307
|
* the portal root absolutely, or pass a `mount` target that does it for you.
|
|
308
|
+
*
|
|
309
|
+
* Returns null (nothing in place), so a component may return a portal directly.
|
|
310
|
+
*
|
|
311
|
+
* Portals cannot mount during the initial render: the default target is the
|
|
312
|
+
* window root, which exists only after the app's first build returns, so a
|
|
313
|
+
* portal created during that build throws. This is the contract, not a bug:
|
|
314
|
+
* portal content is overlay content, opened by a signal that starts false.
|
|
248
315
|
*/
|
|
249
|
-
export function createPortal(node:
|
|
316
|
+
export function createPortal(node: Element, mount?: ProxyNode): null {
|
|
250
317
|
let target = mount ?? windowRoot
|
|
251
318
|
if (!target) {
|
|
252
|
-
throw new Error("createPortal: no mount target (
|
|
319
|
+
throw new Error("createPortal: no mount target (portals cannot mount during the initial render; open them after mount)")
|
|
320
|
+
}
|
|
321
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
322
|
+
throw new Error("createPortal: node must be a single built element")
|
|
253
323
|
}
|
|
254
|
-
insertNode(target, node)
|
|
255
|
-
onCleanup(() => removeNode(target, node))
|
|
324
|
+
insertNode(target, node as ProxyNode)
|
|
325
|
+
onCleanup(() => removeNode(target, node as ProxyNode))
|
|
326
|
+
return null
|
|
256
327
|
}
|
package/src/scroll.ts
CHANGED
|
@@ -49,6 +49,14 @@ export function createScroll(
|
|
|
49
49
|
|
|
50
50
|
let [offset, setOffset] = createSignal<ScrollOffset>({ x: 0, y: 0 })
|
|
51
51
|
|
|
52
|
+
// A scroll viewport with no explicit main-axis size resolves to 0 in flex
|
|
53
|
+
// layout and its content silently vanishes - a classic trap (maxHeight alone
|
|
54
|
+
// does not size it either). Detect it at measure time and warn once, with a
|
|
55
|
+
// stack captured at creation so the warning points at the component that
|
|
56
|
+
// built the scroller (the dev server remaps the frames to .tsx).
|
|
57
|
+
let origin = new Error().stack ?? ""
|
|
58
|
+
let warnedCollapsed = false
|
|
59
|
+
|
|
52
60
|
// Last measured overflow, refreshed each layout. scrollBy/scrollTo clamp
|
|
53
61
|
// against these between layouts; onLayout re-clamps once new sizes are known.
|
|
54
62
|
let maxX = 0
|
|
@@ -72,6 +80,18 @@ export function createScroll(
|
|
|
72
80
|
let vb = getBoundingBox(vp)
|
|
73
81
|
let cb = getBoundingBox(ct)
|
|
74
82
|
if (!vb || !cb) return
|
|
83
|
+
if (!warnedCollapsed) {
|
|
84
|
+
let zeroY = canY && vb.height === 0 && cb.height > 0
|
|
85
|
+
let zeroX = canX && vb.width === 0 && cb.width > 0
|
|
86
|
+
if (zeroY || zeroX) {
|
|
87
|
+
warnedCollapsed = true
|
|
88
|
+
let axisName = zeroY ? "height" : "width"
|
|
89
|
+
console.warn(
|
|
90
|
+
`Scroll container resolved to ${axisName} 0, so its content is invisible. ` +
|
|
91
|
+
`Give it an explicit ${axisName} or flex; maxHeight/maxWidth alone does not size it.\n${origin}`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
75
95
|
maxX = Math.max(0, cb.width - vb.width)
|
|
76
96
|
maxY = Math.max(0, cb.height - vb.height)
|
|
77
97
|
let cur = offset()
|
package/src/types.d.ts
CHANGED
|
@@ -308,6 +308,11 @@ export interface TextProps extends Position, PaintProps, PointerProps {
|
|
|
308
308
|
h?: number
|
|
309
309
|
fontFamily?: "sans" | "mono" | (string & {})
|
|
310
310
|
fontSize?: number
|
|
311
|
+
/**
|
|
312
|
+
* Line height as a MULTIPLIER of fontSize, not pixels (the theme uses
|
|
313
|
+
* 1.3-1.6). A CSS-reflex pixel value like 22 makes each line box 22x the
|
|
314
|
+
* font size, rendering the text as blank space.
|
|
315
|
+
*/
|
|
311
316
|
lineHeight?: number
|
|
312
317
|
fontStyle?: "normal" | "italic"
|
|
313
318
|
fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
|
package/src/window.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { requestFrame } from "flux:rendertree"
|
|
|
3
3
|
import { renderFrame } from "srt:render"
|
|
4
4
|
import { on, once } from "srt:events"
|
|
5
5
|
import { getEventHandler, getFocusedNodeId, setFocus } from "./core"
|
|
6
|
+
import { scanForOrphans } from "./renderer"
|
|
6
7
|
|
|
7
8
|
// ------ Pointer capture -----------------
|
|
8
9
|
|
|
@@ -212,6 +213,7 @@ export function attachWindow(_nodeId: number) {
|
|
|
212
213
|
for (let fn of frames.values()) fn(t, frame, refreshRate)
|
|
213
214
|
}
|
|
214
215
|
flush()
|
|
216
|
+
scanForOrphans(t)
|
|
215
217
|
renderFrame()
|
|
216
218
|
}
|
|
217
219
|
|