Skip to content

Commit 9721cab

Browse files
committed
review pass
1 parent a3d6e0b commit 9721cab

24 files changed

Lines changed: 528 additions & 251 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ All build/test/lint commands live in one place: [`packages/scripts`](./packages/
1919
- Run tasks from a package dir: `npm run <task>`.
2020
- Do not call `tsc` / `vitest` / `eslint` directly — the tasks carry the right configs.
2121
- `npm run dev` from the repo root type-checks every package in one watching `tsc` process — sources, tests, `packages/common/utils` and `packages/scripts` (see [`tsconfig.dev.json`](./tsconfig.dev.json)). It also refreshes `dist/`, since each package's check project references the sibling `dist` projects it depends on.
22-
- `npm run build:dev` is the same thing without watching — one pass over everything, useful before a commit or in CI.
22+
- `npm run build:dev` is the same thing without watching — one pass over everything, useful before a commit.
2323
Reach for it whenever test files changed: `build` compiles sources only, and `test` runs them without type-checking, so a type error in a test slips past both.
2424
- Args after `--` are passed to the underlying command. Example — run one test:
2525

packages/common-mobx/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ All runtime dependencies are peer.
1313
* [ViewModels]('./src/viewModels/index.ts') – useful for MVVM pattern
1414

1515
* Structures for caching & observing: [`PromiseCacheObservable`](./src/structures/promiseCache.ts) — a thin `PromiseCache` preset wired to a mobx storage provider (also exported standalone as `mobxStorageProvider`, for composing custom presets).
16-
`cache.expire(key)` on a settled key wakes no reaction, so a reactive reader keeps serving the current value until something else makes it read; expiring a key with a fetch in flight does notify, since the loading state changes.
16+
17+
* `cache.expire(key)` notifies no observer.
18+
A reactive reader keeps serving the current value until something else makes it read.
1719
Same for `lazy.expireTracker.expire()` on `LazyPromiseObservable`.
1820

1921
* [`TransitionObserver`](./src/observing/transition.ts) – neat wrapper of mobx's `reaction`

packages/common/src/lazy/__tests__/mapped.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ describe('MappedLazyPromiseView', () => {
7777
expect(view.hasResolvedValue()).toBe(false);
7878
});
7979

80+
test('promise on a failed source resolves to the mapped fallback, not a rejection', async () => {
81+
const error = new Error('fail');
82+
const source = new LazyPromise<number>(() => setTimeoutAsync(10).then((): number => { throw error; }));
83+
const map = vi.fn((v: number | undefined) => v ?? -1);
84+
const view = new MappedLazyPromiseView(source, map);
85+
86+
const p = view.promise;
87+
await vi.advanceTimersByTimeAsync(10);
88+
89+
await expect(p).resolves.toBe(-1);
90+
expect(view.hasResolvedValue()).toBe(false);
91+
});
92+
8093
test('promise resolves to the mapped value', async () => {
8194
const source = new LazyPromise(() => setTimeoutAsync(10).then(() => 1));
8295
const view = new MappedLazyPromiseView(source, v => `n:${v}`);

packages/common/src/lazy/__tests__/promise.expireTracker.test.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ describe('LazyPromise.expireTracker', () => {
6060
expect(lazy.pendingState).toBeNull();
6161
});
6262

63-
test('a successful settle clears staleness — no second refetch', async () => {
63+
test('a successful settle clears staleness — no second refetch, for a mark set before the load starts', async () => {
6464
let counter = 0;
6565
const factory = vi.fn(() => delay(10).then(() => ++counter));
6666
const lazy = new LazyPromise(factory);
@@ -70,7 +70,7 @@ describe('LazyPromise.expireTracker', () => {
7070
await p1;
7171
expect(factory).toHaveBeenCalledTimes(1);
7272

73-
lazy.expireTracker.expire();
73+
lazy.expireTracker.expire(); // marked stale before any load is in flight
7474
const p2 = lazy.promise;
7575
await vi.advanceTimersByTimeAsync(10);
7676
await p2;
@@ -226,21 +226,71 @@ describe('LazyPromise.expireTracker', () => {
226226
expect(lazy.value).toBe(2);
227227
});
228228

229-
test('expire() mid-flight is absorbed by the in-flight load\'s settle', async () => {
229+
test('expire() mid-flight survives the in-flight load\'s settle', async () => {
230230
const factory = vi.fn(() => delay(10).then(() => 1));
231231
const lazy = new LazyPromise(factory);
232232

233233
const p1 = lazy.promise;
234234
lazy.expireTracker.expire();
235235

236236
await vi.advanceTimersByTimeAsync(10);
237-
await p1;
237+
await expect(p1).resolves.toBe(1);
238238

239-
expect(lazy.expireTracker.isExpired).toBeFalse();
239+
expect(lazy.expireTracker.isForceExpired).toBeTrue();
240+
expect(lazy.expireTracker.isExpired).toBeTrue();
240241
expect(factory).toHaveBeenCalledTimes(1);
241242
expect(lazy.pendingState).toBeNull();
242243
});
243244

245+
test('the mutation race: expire() mid-load survives the settle, and the next read re-invokes the factory', async () => {
246+
let counter = 0;
247+
const factory = vi.fn(() => delay(10).then(() => ++counter));
248+
const lazy = new LazyPromise(factory);
249+
250+
const p1 = lazy.promise;
251+
lazy.expireTracker.expire();
252+
253+
await vi.advanceTimersByTimeAsync(10);
254+
await expect(p1).resolves.toBe(1);
255+
expect(lazy.currentValue).toBe(1);
256+
expect(factory).toHaveBeenCalledTimes(1);
257+
258+
const p2 = lazy.promise; // the mark survived the settle — this read re-invokes the factory
259+
await vi.advanceTimersByTimeAsync(10);
260+
await expect(p2).resolves.toBe(2);
261+
expect(factory).toHaveBeenCalledTimes(2);
262+
});
263+
264+
test('a load slower than its own lifetime settles as fresh, not stale', async () => {
265+
const factory = vi.fn(() => delay(20).then(() => 1));
266+
const lazy = new LazyPromise(factory).withExpire(10);
267+
268+
const p1 = lazy.promise;
269+
await vi.advanceTimersByTimeAsync(20);
270+
await expect(p1).resolves.toBe(1);
271+
272+
expect(lazy.expireTracker.isForceExpired).toBeFalse();
273+
expect(lazy.expireTracker.isExpired).toBeFalse();
274+
expect(lazy.currentValue).toBe(1);
275+
276+
// the settle restarted the tracker — a read right after resolve must not trigger a second load
277+
void lazy.value;
278+
expect(factory).toHaveBeenCalledTimes(1);
279+
});
280+
281+
test('setInstance() after expireTracker.expire() clears the mark; a fetch settle does not', async () => {
282+
const lazy = new LazyPromise(() => Promise.resolve(1));
283+
284+
lazy.expireTracker.expire();
285+
expect(lazy.expireTracker.isForceExpired).toBeTrue();
286+
287+
lazy.setInstance(2);
288+
289+
expect(lazy.expireTracker.isForceExpired).toBeFalse();
290+
expect(lazy.expireTracker.isExpired).toBeFalse();
291+
expect(lazy.currentValue).toBe(2);
292+
});
293+
244294
test('a joined .value read mid-flight does not restart a shared tracker', async () => {
245295
const tracker = new ExpireTracker(1000);
246296
const lazy = new LazyPromise(() => delay(10).then(() => 1)).withExpire(tracker);

packages/common/src/lazy/__tests__/promise.state.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ describe('LazyPromise', () => {
2121
// --- Deferred state write races under withAsyncStateChange ---
2222

2323
test('revalidation resolving in the same microtask does not stick in "revalidating"', async () => {
24-
const expiredTracker = { isExpired: true, restart: vi.fn(), expire: vi.fn() };
24+
const expiredTracker = { isExpired: true, isForceExpired: false, restart: vi.fn(), expire: vi.fn() };
2525
let calls = 0;
2626
const lazy = new LazyPromise(() => {
2727
calls++;
@@ -43,7 +43,7 @@ describe('LazyPromise', () => {
4343
});
4444

4545
test('refresh() followed by a same-tick .value read does not downgrade "refreshing"', async () => {
46-
const expiredTracker = { isExpired: true, restart: vi.fn(), expire: vi.fn() };
46+
const expiredTracker = { isExpired: true, isForceExpired: false, restart: vi.fn(), expire: vi.fn() };
4747
let resolveRefresh!: (value: string) => void;
4848
let calls = 0;
4949
const lazy = new LazyPromise(() => {

packages/common/src/lazy/lazy.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import type { ILazy } from './types.js';
55

66
/**
77
* Synchronous lazy-loading container that initializes a value on first access.
8-
* The value is cached until reset or expired. Supports custom disposal and cache expiration.
8+
* The value is cached until reset or expired.
9+
* Supports custom disposal and cache expiration.
910
*/
1011
export class Lazy<T> implements ILazy<T>, IDisposable, IResettableModel {
1112

@@ -26,7 +27,10 @@ export class Lazy<T> implements ILazy<T>, IDisposable, IResettableModel {
2627
public get currentValue() { return this._instance; }
2728
public get error(): unknown { return this._error; }
2829

29-
/** The expiration tracker; once it expires, the next access resets and re-creates the value. Defaults to a never-expiring owned tracker. */
30+
/**
31+
* The expiration tracker; once it expires, the next access resets and re-creates the value.
32+
* Defaults to a never-expiring owned tracker.
33+
*/
3034
public get expireTracker(): IExpireTracker {
3135
return this._expireTracker;
3236
}
@@ -65,7 +69,7 @@ export class Lazy<T> implements ILazy<T>, IDisposable, IResettableModel {
6569
return this;
6670
}
6771

68-
/** Eagerly loads the value without accessing it. Useful for preloading. */
72+
/** Eagerly loads the value without accessing it. */
6973
public prewarm() {
7074
this.ensureInstance();
7175
return this;

packages/common/src/lazy/loadingState.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1-
import { DEFAULT_LOADING_STATE, type ILazyPromise, type LoadingStateStrategy, type PendingLoadState } from './types.js';
1+
import { DEFAULT_LOADING_STATE, type ILazyPromise, type LoadingStates, type LoadingStateStrategy, type PendingLoadState } from './types.js';
22
import { LazyPromiseView } from './view.js';
33

44
/**
55
* Single loading-state policy module.
66
*
7-
* A {@link PendingLoadState} classifies an in-flight load. A {@link LoadingStateStrategy} maps a
8-
* pending kind to the reported `isLoading`. A kind unnamed in the strategy falls through to a
9-
* caller-chosen fallback: an instance's own strategy falls back to the built-in defaults
10-
* ({@link DEFAULT_LOADING_STATE}), a view falls back to its source's report.
7+
* A {@link PendingLoadState} classifies an in-flight load.
8+
* A {@link LoadingStateStrategy} maps a pending kind to the reported `isLoading`.
9+
*
10+
* A kind unnamed in the strategy falls through to a caller-chosen fallback:
11+
* - an instance's own strategy falls back to the built-in defaults ({@link DEFAULT_LOADING_STATE})
12+
* - a view falls back to its source's report
1113
*/
1214

1315
/** Resolves `strategy[pending]`, falling back to `fallback()` when that entry is `undefined`. */
14-
export function resolveIsLoading<F extends boolean | null>(
16+
export function resolveIsLoading<F extends LoadingStates>(
1517
pending: PendingLoadState,
1618
strategy: LoadingStateStrategy | undefined,
1719
fallback: () => F,
@@ -26,9 +28,11 @@ export function deriveIsLoading(pending: PendingLoadState, strategy?: LoadingSta
2628
}
2729

2830
/**
29-
* The pending kind for an explicit `refresh()` call: `'loading'`/`'refreshing'` already in flight keep
30-
* their classification — a passive `'revalidating'` is escalated to `'refreshing'`, since an explicit
31-
* refresh() is a stronger signal. Otherwise derived from whether a value already exists.
31+
* The pending kind for an explicit `refresh()` call.
32+
*
33+
* - `'loading'`/`'refreshing'` already in flight keep their classification
34+
* - a passive `'revalidating'` is escalated to `'refreshing'`, since an explicit `refresh()` is a stronger signal
35+
* - otherwise derived from whether a value already exists
3236
*/
3337
export function refreshPendingKind(current: PendingLoadState | null, hasValue: boolean): PendingLoadState {
3438
if (current === 'loading' || current === 'refreshing') {

packages/common/src/lazy/mapped.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,20 @@ import { Getter } from '../types/getter.js';
22
import type { ILazyPromise, IResolvedLazyPromise } from './types.js';
33

44
/**
5-
* Derives an always-valued {@link ILazyPromise} from a source by running a total mapper on every read.
6-
* `value` and `currentValue` are the override points for a subclass that wants to memoize the mapped result.
5+
* Derives an always-valued {@link ILazyPromise} from a source, running a mapper that also handles `undefined` on every read.
6+
*
7+
* `value` and `currentValue` are the override points for memoizing the mapped result.
8+
* `promise` and `refresh()` resolve through `this.value`, so an override reaches them too.
79
*/
810
export class MappedLazyPromiseView<TSource, T, TSourceInitial extends TSource | undefined = undefined>
911
implements ILazyPromise<T, T> {
1012

1113
private readonly _sourceGetter: () => ILazyPromise<TSource, TSourceInitial>;
1214

15+
/**
16+
* @param source Evaluated on every access, including inside `promise` and `refresh()`'s
17+
* continuations — swapping its target mid-refresh resolves against the new source.
18+
*/
1319
constructor(
1420
source: Getter<ILazyPromise<TSource, TSourceInitial>>,
1521
protected readonly _map: (value: NoInfer<TSource | TSourceInitial> | undefined) => T,

packages/common/src/lazy/promise.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
IResolvedLazyPromise,
1111
LazyFactory,
1212
LazyPromiseOptions,
13+
LoadingStates,
1314
LoadingStateStrategy,
1415
PendingLoadState,
1516
} from './types.js';
@@ -76,8 +77,14 @@ export class LazyPromise<T, TInitial extends T | undefined = undefined> implemen
7677
this._loadingStrategy = storage.createValue<LoadingStateStrategy | undefined>(undefined);
7778
}
7879

79-
/** Current loading state: true = loading, false = loaded, null = not started. Pending states report per {@link withLoadingState}. */
80-
public get isLoading(): boolean | null {
80+
/**
81+
* The current loading state; see {@link LoadingStates}.
82+
* Does not trigger loading.
83+
*
84+
* A pending load's reported value comes from the configured strategy.
85+
* See {@link withLoadingState} and {@link LoadingStateStrategy}.
86+
*/
87+
public get isLoading(): LoadingStates {
8188
const pending = this._pending.value;
8289
if (pending !== null) {
8390
return deriveIsLoading(pending, this._loadingStrategy.value);
@@ -123,10 +130,12 @@ export class LazyPromise<T, TInitial extends T | undefined = undefined> implemen
123130

124131
/**
125132
* The expiration tracker driving revalidation; defaults to a never-expiring owned tracker.
126-
* Expiring while a load is already in flight is absorbed by that load's successful settle —
127-
* a resolved outcome restarts the tracker regardless of what happened to it in the meantime.
128-
* Invoking a load begins a fresh lifetime, so a failed load does not retry until the lifetime
129-
* elapses again.
133+
*
134+
* Restarts the lifetime:
135+
* - when a load starts, so a failed load waits one out before retrying
136+
* - when a load resolves, unless {@link IExpireTracker.isForceExpired}
137+
*
138+
* An `expire()` made mid-load therefore survives that load, and the next read revalidates.
130139
*/
131140
public get expireTracker(): IExpireTracker {
132141
return this._expireTracker;
@@ -194,18 +203,30 @@ export class LazyPromise<T, TInitial extends T | undefined = undefined> implemen
194203
}
195204

196205
public setInstance(res: T) {
206+
return this.setResolved(res, true);
207+
}
208+
209+
/**
210+
* Stores `res` as the resolved value.
211+
*
212+
* @param forceRestartExpiration Restarts the expiration lifetime even while
213+
* {@link IExpireTracker.isForceExpired}; otherwise that mark survives this write.
214+
*/
215+
private setResolved(res: T, forceRestartExpiration = false): T {
197216
const prepared = this._prepareValue(res);
198217

199218
this._transaction(() => {
200219
this.settle('resolved');
201-
this.clearError(); // clear error on successful set
220+
this.clearError();
202221
this._instance.value = prepared;
203222

204223
// refresh promise so it won't keep old callbacks, resolved with the freshest value
205224
this._promise = Promise.resolve(prepared);
206225
this._activeFactoryPromise = null;
207226

208-
this._expireTracker.restart();
227+
if (forceRestartExpiration || !this._expireTracker.isForceExpired) {
228+
this._expireTracker.restart();
229+
}
209230
});
210231

211232
return prepared;
@@ -318,7 +339,6 @@ export class LazyPromise<T, TInitial extends T | undefined = undefined> implemen
318339
}
319340

320341
// Restarting at invocation paces retries: a failed attempt still starts a lifetime.
321-
// A successful settle restarts again.
322342
this._expireTracker.restart();
323343

324344
let factoryResult: Promise<T> | T;
@@ -337,7 +357,7 @@ export class LazyPromise<T, TInitial extends T | undefined = undefined> implemen
337357
}
338358

339359
if (this._activeFactoryPromise === factoryPromise) {
340-
return this.setInstance(res);
360+
return this.setResolved(res);
341361
}
342362

343363
// Stale promise - return the latest active promise instead

0 commit comments

Comments
 (0)