Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions src/concurrency/scheduling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,14 @@ const readDeferredSource = <T>(source: DeferredSource<T>): T =>
export function deferred<T>(
source: DeferredSource<T>,
options: DeferredOptions = {}
): ReadonlySignalHandle<T> {
): ReadonlySignalHandle<T> & { dispose: () => void } {
const mirror = signal<T>(untrack(() => readDeferredSource(source)));

effect(() => {
// The effect auto-registers with an active effectScope (so `scope.stop()`
// disposes it), but outside a scope there was no way to stop it — the effect
// and its scheduled timer/idle callback leaked for the source's lifetime.
// Expose an explicit `dispose()` so unscoped callers can clean up.
const disposeEffect = effect(() => {
const next = readDeferredSource(source);
const scheduled = scheduleDeferred(() => {
untrack(() => {
Expand All @@ -157,7 +161,16 @@ export function deferred<T>(
return () => scheduled.cancel();
});

return readonly(mirror);
const handle = readonly(mirror) as ReadonlySignalHandle<T> & { dispose: () => void };
Object.defineProperty(handle, 'dispose', {
value: (): void => {
// Stops the effect and runs its cleanup (cancelling any pending timer).
disposeEffect();
mirror.dispose();
},
enumerable: false,
});
return handle;
}

const isPromiseLike = (value: unknown): value is PromiseLike<unknown> =>
Expand Down
27 changes: 21 additions & 6 deletions src/reactive/persisted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import { signal, Signal } from './core';
import { effect } from './effect';
import { effectScope } from './scope';

/**
* Creates a signal that persists to localStorage.
Expand Down Expand Up @@ -61,13 +62,27 @@ export const persistedSignal = <T>(key: string, initialValue: T): Signal<T> => {

// Only set up persistence effect if localStorage is available
if (hasLocalStorage && storage) {
effect(() => {
try {
storage!.setItem(key, JSON.stringify(sig.value));
} catch {
// Ignore storage errors (quota exceeded, sandboxed iframes, etc.)
}
// Run the persistence effect in its own detached scope rather than letting
// it auto-register with the ambient effectScope. Otherwise `scope.stop()`
// would silently stop persistence while the returned signal keeps living.
// Persistence is instead tied to the signal's own lifetime: disposing the
// signal stops persistence and vice versa.
const persistenceScope = effectScope(true);
persistenceScope.run(() => {
effect(() => {
try {
storage!.setItem(key, JSON.stringify(sig.value));
} catch {
// Ignore storage errors (quota exceeded, sandboxed iframes, etc.)
}
});
});

const originalDispose = sig.dispose.bind(sig);
sig.dispose = (): void => {
persistenceScope.stop();
originalDispose();
};
}

return sig;
Expand Down
18 changes: 13 additions & 5 deletions src/reactive/scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,9 @@ class EffectScopeImpl implements ScopeInternal {
* needed, but keep the callback itself synchronous so cleanup registration
* stays deterministic.
*
* @param detached - When `true`, the scope is NOT auto-collected by an
* enclosing scope; its lifetime is fully independent (tie it to something
* else and call `stop()` yourself). Defaults to `false`.
* @returns A new {@link EffectScope}
*
* @example
Expand All @@ -207,13 +210,18 @@ class EffectScopeImpl implements ScopeInternal {
* scope.stop(); // logs "Custom cleanup", all effects stopped
* ```
*/
export const effectScope = (): EffectScope => {
export const effectScope = (detached = false): EffectScope => {
const scope = new EffectScopeImpl();

// If created inside another scope, auto-collect as a nested scope
const parent = getActiveScope();
if (hasScopeDisposer(parent)) {
parent._addDisposer(() => scope.stop());
// If created inside another scope, auto-collect as a nested scope — unless
// `detached` is requested, in which case the scope's lifetime is fully
// independent of any parent (e.g. a resource whose disposal is tied to
// something other than the ambient scope).
if (!detached) {
const parent = getActiveScope();
if (hasScopeDisposer(parent)) {
parent._addDisposer(() => scope.stop());
}
}

return scope;
Expand Down
17 changes: 17 additions & 0 deletions tests/concurrency-stable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,23 @@ describe('concurrency/deferred (#135)', () => {
await wait(30);
expect(total.value).toBe(12);
});

it('stops tracking its source after dispose() (#173)', async () => {
const query = signal('a');
const deferredQuery = deferred(query, { timeout: 10 });
expect(typeof deferredQuery.dispose).toBe('function');

query.value = 'b';
await wait(30);
expect(deferredQuery.value).toBe('b');

deferredQuery.dispose();

// After disposal, source changes no longer flow through.
query.value = 'c';
await wait(30);
expect(deferredQuery.value).toBe('b');
});
});

describe('concurrency/suspense (#135)', () => {
Expand Down
36 changes: 36 additions & 0 deletions tests/signal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,42 @@ describe('persistedSignal', () => {
localStorage.removeItem(key);
});

it('keeps persisting after an ambient scope stops (#173)', async () => {
const { persistedSignal } = await import('../src/reactive/signal');
const key = 'test-persisted-scope';
localStorage.removeItem(key);

let count!: ReturnType<typeof persistedSignal<number>>;
const scope = effectScope();
scope.run(() => {
count = persistedSignal(key, 0);
});

// Stopping the ambient scope must NOT silently stop persistence.
scope.stop();
count.value = 7;
expect(localStorage.getItem(key)).toBe('7');

localStorage.removeItem(key);
});

it('stops persisting after the signal is disposed (#173)', async () => {
const { persistedSignal } = await import('../src/reactive/signal');
const key = 'test-persisted-dispose';
localStorage.removeItem(key);

const count = persistedSignal(key, 0);
count.value = 1;
expect(localStorage.getItem(key)).toBe('1');

count.dispose();
count.value = 2;
// No further writes after dispose.
expect(localStorage.getItem(key)).toBe('1');

localStorage.removeItem(key);
});

it('falls back to in-memory signal when localStorage is unavailable', async () => {
// Capture original property descriptor to restore properly
const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'localStorage');
Expand Down