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
47 changes: 42 additions & 5 deletions src/reactive/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ export interface EffectInspectionSnapshot {
const trackedEffects = new Map<symbol, EffectInspectionSnapshot>();
let effectInspectionEnabled = false;

/**
* Upper bound on synchronous self-triggered re-runs of a single effect.
* Effects that legitimately write their own dependencies settle within a few
* iterations; anything hitting this bound is a cyclic update.
*/
const MAX_SYNC_RERUNS = 100;

/** @internal */
export const __inspectTrackedEffects = (): EffectInspectionSnapshot[] => {
return [...trackedEffects.values()];
Expand Down Expand Up @@ -78,9 +85,7 @@ export const effect = (fn: () => void | CleanupFn): CleanupFn => {
scope._addDisposer(dispose);
}

const observer: Observer = () => {
if (isDisposed) return;

const runEffect = (): void => {
if (effectInspectionEnabled) {
const snapshot = trackedEffects.get(effectId);
trackedEffects.set(
Expand All @@ -99,9 +104,41 @@ export const effect = (fn: () => void | CleanupFn): CleanupFn => {
} catch (error) {
console.error('bQuery reactive: Error in effect', error);
}
};

if (isDisposed) {
clearEffectState();
let isRunning = false;
let reRunRequested = false;

const observer: Observer = () => {
if (isDisposed) return;

// An effect that writes a signal it also reads re-triggers itself while
// still executing. Recursing synchronously would overflow the stack, so
// defer the re-run to a bounded loop and warn if it never settles.
if (isRunning) {
reRunRequested = true;
return;
}

isRunning = true;
try {
let runs = 0;
do {
reRunRequested = false;
runEffect();
runs += 1;
} while (reRunRequested && !isDisposed && runs < MAX_SYNC_RERUNS);

if (reRunRequested && !isDisposed) {
console.warn(
'bQuery reactive: cyclic effect update detected (effect keeps re-triggering itself); further re-runs were skipped'
);
}
} finally {
isRunning = false;
if (isDisposed) {
clearEffectState();
}
}
};

Expand Down
55 changes: 55 additions & 0 deletions tests/signal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,61 @@ describe('effect', () => {
expect(latest).toBe(5);
});

it('does not overflow the stack when an effect writes a signal it reads (#166)', () => {
const count = signal(0);
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' '));

try {
expect(() => {
effect(() => {
count.value = count.value + 1;
});
}).not.toThrow();
expect(warnings.some((w) => w.includes('cyclic effect update'))).toBe(true);
} finally {
console.warn = originalWarn;
}
});

it('lets a self-writing effect settle without warning (#166)', () => {
const count = signal(0);
const originalWarn = console.warn;
const warnings: string[] = [];
console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(' '));

try {
effect(() => {
if (count.value < 5) {
count.value = count.value + 1;
}
});
expect(count.value).toBe(5);
expect(warnings).toEqual([]);
} finally {
console.warn = originalWarn;
}
});

it('guards self-triggering effects inside batch() too (#166)', () => {
const count = signal(0);
const originalWarn = console.warn;
console.warn = () => {};

try {
expect(() => {
batch(() => {
effect(() => {
count.value = count.value + 1;
});
});
}).not.toThrow();
} finally {
console.warn = originalWarn;
}
});

it('returns cleanup function', () => {
const count = signal(0);
let runCount = 0;
Expand Down