brustjs 0.1.38-alpha → 0.1.40-alpha

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.
@@ -147,18 +147,41 @@ export function computed<T>(fn: () => T): Computed<T> {
147
147
  return read
148
148
  }
149
149
 
150
- export function effect(fn: () => void): () => void {
150
+ // `fn` may return a cleanup function (React `useEffect` semantics): it runs once
151
+ // before each re-run, and once when the effect is disposed. Cleanups run UNTRACKED
152
+ // (activeConsumer nulled) so a signal read inside a cleanup never registers a spurious
153
+ // dependency. A `void` return is unchanged — the existing no-cleanup path.
154
+ // biome-ignore lint/suspicious/noConfusingVoidType: `void | Destructor` is the React useEffect return shape — a callback with no `return` infers `void`, so swapping to `undefined` would type-error every cleanup-less caller.
155
+ export function effect(fn: () => void | (() => void)): () => void {
156
+ // biome-ignore lint/suspicious/noConfusingVoidType: holds `fn()`'s `void | (() => void)` result; `undefined` here would reject a `void` assignment.
157
+ let cleanup: void | (() => void)
158
+ // Run the pending cleanup with dependency tracking suspended.
159
+ const runCleanup = () => {
160
+ if (typeof cleanup !== 'function') return
161
+ const c = cleanup
162
+ cleanup = undefined
163
+ const prev = ctx.activeConsumer
164
+ ctx.activeConsumer = null
165
+ try {
166
+ c()
167
+ } catch (e) {
168
+ console.error('[brust] effect cleanup threw:', e)
169
+ } finally {
170
+ ctx.activeConsumer = prev
171
+ }
172
+ }
151
173
  const self: Consumer = {
152
174
  deps: new Set(),
153
175
  running: false,
154
176
  run() {
155
177
  if (self.running) return
156
178
  self.running = true
179
+ runCleanup() // previous run's cleanup fires before the new run
157
180
  clearDeps(self)
158
181
  const prev = ctx.activeConsumer
159
182
  ctx.activeConsumer = self
160
183
  try {
161
- fn()
184
+ cleanup = fn()
162
185
  } finally {
163
186
  ctx.activeConsumer = prev
164
187
  self.running = false
@@ -166,5 +189,19 @@ export function effect(fn: () => void): () => void {
166
189
  },
167
190
  }
168
191
  self.run()
169
- return () => clearDeps(self)
192
+ return () => {
193
+ // Guard the dispose path with the same re-entrancy flag as run(): a cleanup
194
+ // that writes a signal this effect still depends on (clearDeps runs AFTER
195
+ // runCleanup) would otherwise synchronously re-notify and re-run the body of
196
+ // an already-disposed effect. The flag drops that re-entrant run; a second
197
+ // dispose() is then a no-op (cleanup already consumed, deps already cleared).
198
+ if (self.running) return
199
+ self.running = true
200
+ try {
201
+ runCleanup() // final cleanup on dispose
202
+ clearDeps(self)
203
+ } finally {
204
+ self.running = false
205
+ }
206
+ }
170
207
  }