Skip to content

Commit ba9de8f

Browse files
committed
2 parents 61512a5 + 482d944 commit ba9de8f

2 files changed

Lines changed: 203 additions & 1 deletion

File tree

lang/docs/universal-cbi-improvements.md

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,52 @@ Tests are golden-string comparisons of generated JS text (`lang/tests/compiler_p
411411
Add: (1) structural tests asserting `switch`/`while`/`throw`/`%` appear in output (or that a diagnostic
412412
fires); (2) a headless-DOM harness that executes the generated JS and asserts the hydrated DOM (this is
413413
how the todo demo was validated during this analysis); (3) SSR-content tests (`<h3>5 left</h3>`, not
414-
`<h3> left</h3>`); (4) move every `universal_failures.md` case into the suite.
414+
`<h3> left</h3>`); (4) move every `universal_failures.md` case into the suite.
415+
416+
### 3.10 Reactive render model: pitfalls that break conditional UI (verified, cdm `ErrorOverlay`)
417+
418+
While building `ErrorOverlay` (a global uncaught-error catcher) in
419+
`lang/libs/components/src/ErrorOverlay.ch`, two framework behaviors repeatedly prevented the
420+
overlay from ever appearing. Both are direct consequences of the runtime's
421+
**"component bodies run once; only derived/conditional JSX nodes re-render"** model (the
422+
`$_ucs` computed-signal patcher in `defaultUniversalSetup`).
423+
424+
**Pitfall A — `#css` style helpers are server-only and crash at hydration.**
425+
`#css { … }` helpers such as `error_overlay_styles(page : &mut HtmlPage) : *char` take the SSR
426+
`page` pointer. On the client the component is invoked as `factory(props)` with **no `page`
427+
argument**, so calling the helper throws during hydration → the component hits the error
428+
boundary (`$__uni_render_fallback`) and renders nothing. Symptom: the component is silently
429+
absent and nothing appears, with no visible error.
430+
- **Workaround (proven):** do not use `#css` helpers inside `#universal` components that must
431+
render on the client. Use inline `style={{ … }}` objects (string/number values) or plain
432+
`class="…"` strings, matching the existing app dialogs (e.g. `CdmApp`'s Tools dialog uses
433+
`class` + inline `style`). For reusable styling, emit the CSS once in a top-level
434+
`<style>`/theme and reference class names instead of per-component `#css` helpers.
435+
436+
**Pitfall B — visibility toggles must be a JSX conditional child, never control-flow
437+
`if`/`return`.**
438+
`if(!open) { return null }` is evaluated **once at mount** and is not a reactive binding, so
439+
flipping `open` later does nothing. Storing the conditional in a local `var overlay = open ? …
440+
: null` and then `return overlay` is also one-time — the local is not wrapped in a computed
441+
signal. Only a conditional used **directly as a JSX child expression** becomes a `$_ucs`
442+
computed that re-evaluates when its state dependencies change.
443+
- **Workaround (proven):** render `{open && errors.length > 0 ? <div …>…</div> : null}` inline
444+
as a child (wrap in an inert `<div style={{display:"contents"}}>` if a single root is
445+
needed). All state reads (`open`, `errors`, `selected`, `copied`) must happen *inside* that
446+
conditional so they subscribe. Never `return` a precomputed local.
447+
448+
**Net result:** once both pitfalls were avoided (inline styles + inlined conditional child),
449+
`window.__reportError(msg, stack)` and the `window.addEventListener("error" /
450+
"unhandledrejection")` handlers installed in the component's `useEffect` correctly flip
451+
`open` and the modal renders. This is the pattern every show/hide component (dialogs, toasts,
452+
dropdowns, modals) in this framework must follow.
453+
454+
Suggested roadmap additions:
455+
- (N) In `#universal` components, hard-warn (or reject) `#css` helper usage in positions that
456+
execute during client hydration — or make `#css` a no-op that returns a stable class name on
457+
the client.
458+
- (N) Document the "conditional child, not `if`/`return`" rule in the component authoring guide
459+
and ideally lint for early-`return`-on-state patterns.
415460

416461
---
417462

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// ErrorOverlay — a reusable global JavaScript error reporter.
2+
//
3+
// Drop `<ErrorOverlay />` anywhere in a `#universal` tree. On first client mount
4+
// it installs global handlers for `window.onerror`, the `error` event and
5+
// `unhandledrejection`, and shows a modal dialog with the full error message and
6+
// stack trace whenever an uncaught error occurs. A "Copy" button puts the whole
7+
// report on the clipboard so it can be pasted to a developer.
8+
//
9+
// It is intentionally dependency-free (no app imports) so it can live in the
10+
// shared `components` library and be reused by any Chemical webview app.
11+
//
12+
// IMPORTANT: visibility is expressed as a JSX conditional used DIRECTLY as a
13+
// child expression (`{open && errors.length > 0 ? <div>…</div> : null}`). This
14+
// framework runs component bodies once and only re-renders reactive (derived)
15+
// JSX nodes, so a control-flow `if(!open) return null` would never update when
16+
// `open` flips. The styling uses inline `style={{...}}` objects (not `#css`
17+
// helpers) because `#css` needs a server-only `page` pointer that is absent
18+
// during client hydration.
19+
20+
public #universal ErrorOverlay(props) {
21+
var title = props.title || "Application Error"
22+
var max = props.max || 10
23+
state errors = []
24+
state open = false
25+
state selected = 0
26+
state copied = false
27+
28+
useEffect(() => {
29+
var pushError = (info) => {
30+
var next = errors.concat([info])
31+
if(next.length > max) { next = next.slice(next.length - max) }
32+
errors = next
33+
selected = next.length - 1
34+
open = true
35+
}
36+
var onError = (e) => {
37+
var err = (e && e.error) ? e.error : null
38+
var msg = (e && e.message) ? e.message : "Unknown error"
39+
var stack = (err && err.stack) ? err.stack : msg
40+
if(!err && e && (e.filename || e.lineno)) {
41+
stack = stack + "\n at " + (e.filename || "") + ":" + (e.lineno || 0) + ":" + (e.colno || 0)
42+
}
43+
pushError({
44+
time: new Date().toISOString(),
45+
message: msg,
46+
stack: stack,
47+
source: (e && e.filename) ? e.filename : "window.error",
48+
line: (e && e.lineno) ? e.lineno : 0,
49+
col: (e && e.colno) ? e.colno : 0
50+
})
51+
}
52+
var onRejection = (e) => {
53+
var reason = (e && e.reason) ? e.reason : "Unhandled promise rejection"
54+
var msg = (reason && reason.message) ? reason.message : ("" + reason)
55+
var stack = (reason && reason.stack) ? reason.stack : msg
56+
pushError({
57+
time: new Date().toISOString(),
58+
message: msg,
59+
stack: stack,
60+
source: "unhandledrejection",
61+
line: 0,
62+
col: 0
63+
})
64+
}
65+
if(window.addEventListener) {
66+
window.addEventListener("error", onError)
67+
window.addEventListener("unhandledrejection", onRejection)
68+
}
69+
window.__reportError = (msg, stack) => {
70+
pushError({
71+
time: new Date().toISOString(),
72+
message: (msg ? ("" + msg) : "Reported error"),
73+
stack: (stack ? ("" + stack) : ("" + msg)),
74+
source: "manual",
75+
line: 0,
76+
col: 0
77+
})
78+
}
79+
return () => {
80+
if(window.removeEventListener) {
81+
window.removeEventListener("error", onError)
82+
window.removeEventListener("unhandledrejection", onRejection)
83+
}
84+
}
85+
}, [])
86+
87+
var dismiss = () => { open = false }
88+
var copyReport = () => {
89+
var cur = (errors.length > 0) ? errors[selected] : null
90+
var report = (cur ? (cur.message || "") : "") + "\n\n" + (cur ? (cur.stack || "") : "") + "\n\n[source: " + (cur ? (cur.source || "") : "") + " | time: " + (cur ? (cur.time || "") : "") + "]"
91+
try {
92+
if(navigator.clipboard && navigator.clipboard.writeText) {
93+
navigator.clipboard.writeText(report)
94+
}
95+
} catch (e) { }
96+
copied = true
97+
setTimeout(() => { copied = false }, 2000)
98+
}
99+
100+
return <div style={{ display: "contents" }}>
101+
{open && errors.length > 0 ? <div style={{
102+
position: "fixed",
103+
inset: "0",
104+
zIndex: "1000",
105+
display: "flex",
106+
alignItems: "flex-start",
107+
justifyContent: "center",
108+
padding: "2rem 1rem",
109+
background: "rgba(0,0,0,0.55)",
110+
overflow: "auto"
111+
}} onClick={dismiss}>
112+
<div style={{
113+
width: "100%",
114+
maxWidth: "46rem",
115+
borderRadius: "12px",
116+
border: "1px solid #e11d48",
117+
background: "#ffffff",
118+
color: "#111111",
119+
boxShadow: "0 10px 30px rgba(0,0,0,0.35)",
120+
display: "flex",
121+
flexDirection: "column",
122+
maxHeight: "80vh",
123+
overflow: "hidden",
124+
marginTop: "2rem"
125+
}} onClick={(e) => { e.stopPropagation() }}>
126+
<div style={{
127+
display: "flex",
128+
alignItems: "center",
129+
justifyContent: "space-between",
130+
gap: "0.75rem",
131+
padding: "0.875rem 1rem",
132+
borderBottom: "1px solid #e5e7eb",
133+
background: "rgba(225,29,72,0.08)"
134+
}}>
135+
<h3 style={{ fontWeight: 600, fontSize: "0.95rem", margin: 0 }}>{title}</h3>
136+
<button type="button" style={{ border: "none", background: "transparent", fontSize: "1.1rem", cursor: "pointer", color: "#111111" }} onClick={dismiss}>{"×"}</button>
137+
</div>
138+
<div style={{ padding: "1rem", overflow: "auto", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
139+
{errors.length > 1 ? <div style={{ display: "flex", flexWrap: "wrap", gap: "0.4rem" }}>
140+
{errors.map((er, i) => (
141+
<span style={{ fontSize: "0.72rem", padding: "0.15rem 0.5rem", borderRadius: "999px", border: "1px solid #e5e7eb", background: "#f3f4f6", cursor: "pointer" }} onClick={() => { selected = i }}>
142+
{("#" + (i + 1) + " " + (er.source || "err"))}
143+
</span>
144+
))}
145+
</div> : null}
146+
<p style={{ fontWeight: 600, fontSize: "0.9rem", color: "#e11d48", margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>{errors[selected].message}</p>
147+
<pre style={{ margin: 0, padding: "0.75rem", borderRadius: "8px", background: "#f3f4f6", border: "1px solid #e5e7eb", fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", fontSize: "0.8rem", lineHeight: "1.35rem", whiteSpace: "pre-wrap", wordBreak: "break-word", maxHeight: "40vh", overflow: "auto" }}>{errors[selected].stack}</pre>
148+
</div>
149+
<div style={{ display: "flex", alignItems: "center", justifyContent: "flex-end", gap: "0.5rem", padding: "0.75rem 1rem", borderTop: "1px solid #e5e7eb" }}>
150+
<span style={{ fontSize: "0.72rem", color: "#6b7280" }}>{"source: " + (errors[selected].source || "") + " @ " + (errors[selected].time || "")}</span>
151+
<button type="button" style={{ border: "1px solid #e5e7eb", background: "#ffffff", borderRadius: "8px", padding: "0.35rem 0.7rem", cursor: "pointer" }} onClick={copyReport}>{copied ? "Copied!" : "Copy report"}</button>
152+
<button type="button" style={{ border: "1px solid #e11d48", background: "#e11d48", color: "#ffffff", borderRadius: "8px", padding: "0.35rem 0.7rem", cursor: "pointer" }} onClick={dismiss}>{"Dismiss"}</button>
153+
</div>
154+
</div>
155+
</div> : null}
156+
</div>
157+
}

0 commit comments

Comments
 (0)