Skip to content

Commit facda2b

Browse files
committed
Protect tilia from exceptions in computed or observed
1 parent 2782764 commit facda2b

File tree

5 files changed

+237
-10
lines changed

5 files changed

+237
-10
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Simple documentation on the [README](./tilia/README.md).
1717
- 2025-08-08 **3.0.0-beta**
1818
- Rename `unwrap` for `lift`, change syntax for `signal` to expose setter. Might remove `carve` as it
1919
forces mutability in the exposed object and is hard to reason about.
20+
- Protect tilia from exceptions in computed: the exception is caught, logged to `console.error` and re-thrown at the end of the next flush.
2021
- 2025-08-08 **2.2.0**
2122
- Add `unwrap` to ease inserting a signal into a tilia object.
2223
- 2025-08-08 **2.1.1**

tilia/README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,12 @@
44

55
### Why Tilia for Domain-Driven Design ?
66

7-
Tilia’s minimal, expressive API lets you model application state and business logic in the language of your domain—without boilerplate or framework jargon. Features like `carve` encourage modular, feature-focused state that maps naturally to DDD’s bounded contexts. Computed properties and derived actions keep business logic close to your data, making code more readable, maintainable, and easier to evolve as your domain grows.
7+
Tilia’s minimal, expressive API lets you model application state and business
8+
logic in the language of your domain—without boilerplate or framework jargon.
9+
Features like `carve` encourage modular, feature-focused state that maps
10+
naturally to DDD’s bounded contexts. Computed properties and derived actions
11+
keep business logic close to your data, making code more readable, maintainable,
12+
and easier to evolve as your domain grows.
813

914
In short: Tilia helps you write code that matches your business, not your framework.
1015

@@ -16,6 +21,13 @@ For more information, check out the [**DDD section**](https://tiliajs.com/docs#d
1621

1722
Check the [**website**](https://tiliajs.com) for full documentation and more examples for both TypeScript and ReScript.
1823

24+
## Note on exceptions
25+
26+
If a computed or observe callback throws an exception, the exception is caught,
27+
logged to `console.error` and re-thrown at the end of the next flush. This is
28+
done to avoid breaking the application in case of a bug in the callback but
29+
still bubbling the error to the user.
30+
1931
## API for versin **2.x** (in case the website is not available)
2032

2133
(TypeScript version below)

tilia/src/Tilia.mjs

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,33 @@ var raise = (function (message) {
55
throw new Error(message)
66
});
77

8+
var reraise = (function (e) {
9+
console.error("Reraising exception after flush");
10+
throw e
11+
});
12+
13+
function cleanTrace(stack) {
14+
if (typeof stack !== "string") return stack;
15+
16+
const cleaned = ["Exception thrown in computed or observe"];
17+
let collapsing = false;
18+
19+
for (const line of stack.split("\n")) {
20+
if (/src\/Tilia\.mjs:\d+:\d+/.test(line)) {
21+
if (!collapsing) {
22+
cleaned.push(" [... tilia internals]");
23+
collapsing = true;
24+
}
25+
} else {
26+
cleaned.push(line);
27+
collapsing = false;
28+
}
29+
}
30+
31+
return cleaned.join("\n");
32+
}
33+
;
34+
835
var symbol = (function(s) {
936
return Symbol.for('tilia:' + s);
1037
});
@@ -38,6 +65,17 @@ function readonly(o, k) {
3865
}
3966
}
4067

68+
var setError = (function (root, e) {
69+
if (typeof e === "object" && e !== null) {
70+
if (e.stack) {
71+
console.error(cleanTrace(e.stack));
72+
} else {
73+
console.error(e);
74+
}
75+
root.error = e;
76+
}
77+
});
78+
4179
function observeKey(observed, key) {
4280
var w = observed.get(key);
4381
if (!(w === null || w === undefined)) {
@@ -76,9 +114,13 @@ function flush(root) {
76114
});
77115
gc.quarantine = gc.active;
78116
gc.active = new Set();
117+
}
118+
if (root.error === undefined) {
79119
return ;
80120
}
81-
121+
var e = root.error;
122+
root.error = undefined;
123+
reraise(e);
82124
}
83125

84126
function _clear(observer) {
@@ -337,7 +379,20 @@ function compile(root, observed, proxied, computes, isArray, target, key, callba
337379
observer.o = o;
338380
var previous = root.observer;
339381
root.observer = o;
340-
var v = callback();
382+
var v;
383+
try {
384+
v = callback();
385+
}
386+
catch (_e){
387+
setError(root, _e);
388+
_clear(o);
389+
if (previous === null || previous === undefined) {
390+
previous === null;
391+
} else {
392+
_clear(previous);
393+
}
394+
v = lastValue.v;
395+
}
341396
root.observer = previous;
342397
if (o_observing.length === 0) {
343398
_clear(o);
@@ -572,8 +627,14 @@ function makeObserve(root) {
572627
};
573628
root.observer = observer;
574629
var o = observer;
575-
callback();
576-
_ready(o, true);
630+
try {
631+
callback();
632+
return _ready(o, true);
633+
}
634+
catch (_e){
635+
setError(root, _e);
636+
return _clear(o);
637+
}
577638
};
578639
notify();
579640
};
@@ -684,6 +745,8 @@ function make(gcOpt) {
684745
observer: undefined,
685746
expired: new Set(),
686747
lock: false,
748+
error: undefined,
749+
id: Math.random(),
687750
gc: gc$1
688751
};
689752
var tilia = makeTilia(root);
@@ -787,4 +850,4 @@ export {
787850
_meta ,
788851
_ctx ,
789852
}
790-
/* indexKey Not a pure module */
853+
/* Not a pure module */

tilia/src/Tilia.res

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,33 @@ let raise: string => 'a = %raw(`function (message) {
1111
throw new Error(message)
1212
}`)
1313

14+
let reraise: 'a => 'b = %raw(`function (e) {
15+
console.error("Reraising exception after flush");
16+
throw e
17+
}`)
18+
19+
%%raw(`
20+
function cleanTrace(stack) {
21+
if (typeof stack !== "string") return stack;
22+
23+
const cleaned = ["Exception thrown in computed or observe"];
24+
let collapsing = false;
25+
26+
for (const line of stack.split("\n")) {
27+
if (/src\/Tilia\.mjs:\d+:\d+/.test(line)) {
28+
if (!collapsing) {
29+
cleaned.push(" [... tilia internals]");
30+
collapsing = true;
31+
}
32+
} else {
33+
cleaned.push(line);
34+
collapsing = false;
35+
}
36+
}
37+
38+
return cleaned.join("\n");
39+
}`)
40+
1441
module Proxy = {
1542
@new external make: ('a, 'b) => 'c = "Proxy"
1643
}
@@ -88,6 +115,7 @@ module Dict = {
88115
}
89116

90117
type dict<'a> = Dict.t<'a>
118+
type error
91119

92120
type state =
93121
| Pristine // Hasn't changed since value read.
@@ -131,13 +159,26 @@ and root = {
131159
mutable expired: Set.t<observer>,
132160
// If set to true, wait for end of batch before flush
133161
mutable lock: bool,
162+
mutable error: nullable<error>,
163+
id: float,
134164
// Garbage collection handling
135165
gc: gc,
136166
}
137167

138168
// List of watchers to which the the observer should add itself on ready
139169
and observing = array<watchers>
140170

171+
let setError: (root, 'a) => unit = %raw(`function (root, e) {
172+
if (typeof e === "object" && e !== null) {
173+
if (e.stack) {
174+
console.error(cleanTrace(e.stack));
175+
} else {
176+
console.error(e);
177+
}
178+
root.error = e;
179+
}
180+
}`)
181+
141182
type rec meta<'a> = {
142183
target: 'a,
143184
root: root,
@@ -207,6 +248,7 @@ let flush = root => {
207248
while Set.size(root.expired) > 0 {
208249
let expired = root.expired
209250
root.expired = Set.make()
251+
// We need to notify
210252
Set.forEach(expired, observer => observer.notify())
211253
}
212254
}
@@ -223,6 +265,11 @@ let flush = root => {
223265
gc.quarantine = gc.active
224266
gc.active = Set.make()
225267
}
268+
if root.error !== Undefined {
269+
let e = root.error
270+
root.error = Undefined
271+
reraise(e)
272+
}
226273
}
227274

228275
let _clear = (observer: observer) => {
@@ -512,7 +559,19 @@ and compile = (
512559

513560
let previous = root.observer
514561
root.observer = Value(o)
515-
let v = callback()
562+
let v = try {
563+
callback()
564+
} catch {
565+
| _e => {
566+
setError(root, %raw(`_e`))
567+
_clear(o)
568+
switch previous {
569+
| Value(previous) => _clear(previous)
570+
| _ => ()
571+
}
572+
lastValue.v
573+
}
574+
}
516575

517576
// We need to reset previous observer before calling setReady so that
518577
// setReady does not trigger flush.
@@ -700,8 +759,15 @@ let makeCarve = (root: root) => (fn: deriver<'a> => 'a) => {
700759
let makeObserve = (root: root) => (callback: unit => unit) => {
701760
let rec notify = () => {
702761
let o = _observe(root, notify)
703-
callback()
704-
_ready(o, true)
762+
try {
763+
callback()
764+
_ready(o, true)
765+
} catch {
766+
| _e => {
767+
setError(root, %raw(`_e`))
768+
_clear(o)
769+
}
770+
}
705771
}
706772
notify()
707773
}
@@ -813,7 +879,14 @@ let make = (~gc=defaultGc): tilia => {
813879
quarantine: Set.make(),
814880
threshold: gc,
815881
}
816-
let root = {observer: Undefined, expired: Set.make(), lock: false, gc}
882+
let root = {
883+
observer: Undefined,
884+
expired: Set.make(),
885+
lock: false,
886+
gc,
887+
error: Undefined,
888+
id: Math.random(),
889+
}
817890
// We need to use raw to hide the types here.
818891
let tilia = makeTilia(root)
819892
let _observe = _observe(root, ...)

0 commit comments

Comments
 (0)