From e8ecbfaaed283798b6a33df7d7c4b45bb1a6a998 Mon Sep 17 00:00:00 2001 From: Mahyar Date: Sat, 27 Jun 2026 11:53:57 +0330 Subject: [PATCH 1/3] =?UTF-8?q?feat(core):=20resource=20cache=20phase=203?= =?UTF-8?q?=20=E2=80=94=20cacheTime=20single-runtime=20eviction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/nine-snails-walk.md | 12 + docs/Resources.md | 48 +- .../Resource-Cache-And-Stale-Semantics.md | 6 +- packages/core/src/core.test.ts | 562 ++++++++++++++++++ packages/core/src/resource.ts | 39 +- 5 files changed, 656 insertions(+), 11 deletions(-) create mode 100644 .changeset/nine-snails-walk.md diff --git a/.changeset/nine-snails-walk.md b/.changeset/nine-snails-walk.md new file mode 100644 index 0000000..c373688 --- /dev/null +++ b/.changeset/nine-snails-walk.md @@ -0,0 +1,12 @@ +--- +"@intent-framework/core": patch +--- + +feat(core): resource cacheTime — single-runtime stale-value eviction + +Adds `cache.cacheTime` option to ResourceCacheOptions. When set, a stale entry's +value is retained for the specified milliseconds before being evicted. Active +entry eviction resets status to idle, clears value/error/stale, and notifies +subscribers. Inactive keyed entry eviction silently removes the entry. Works per +key, per ResourceNode, within a single ScreenRuntime. No cross-navigation +survival. No SWR. diff --git a/docs/Resources.md b/docs/Resources.md index 54081e7..ce59ef8 100644 --- a/docs/Resources.md +++ b/docs/Resources.md @@ -251,7 +251,7 @@ On failure, `error` contains the error message and `status` is `"failed"`. See the [Inspect Screen and Diagnostics Guide](Inspect-Screen.md) for more detail. -## Cache options (Phase 1 + Phase 2) +## Cache options (Phase 1 + Phase 2 + Phase 3) Resources support optional `cache` configuration: @@ -260,7 +260,8 @@ const team = $.resource("team", { load: async ({ route }) => loadTeam(route.params.teamId), cache: { key: ({ route }) => route.params.teamId, // optional, derive cache key from context - staleTime: 5_000, // ms (optional, default: Infinity) + staleTime: 5_000, // ms (optional, default: undefined) + cacheTime: 30_000, // ms (optional, default: undefined, no time-based eviction) deduplicate: true, // boolean (optional, default: true when cache is set) }, }) @@ -275,6 +276,7 @@ const team = $.resource("team", { - status (idle/pending/ready/failed) - stale flag - staleTime timer +- cacheTime timer - in-flight promise (for deduplication) The `ResourceRef` always reflects the **active key** entry — the key from the most recent `load()` or `reload()` call. @@ -309,12 +311,12 @@ Key semantics: - `invalidate()` marks only the active entry stale. - `cache.deduplicate` deduplicates per active key. - `cache.staleTime` timers are per key. +- `cache.cacheTime` timers are per key. - Resources without `cache.key` behave exactly as before (single-entry behavior). -Scope limitations (Phase 2): +Scope limitations (Phase 2 + Phase 3): - **Single-runtime only.** Keyed entries live in one `ResourceNode` within one `ScreenRuntime`. -- **No `cacheTime`.** Entries persist for the node's lifetime. No time-based eviction. - **No SWR.** Stale data must be explicitly reloaded. - **No cross-navigation cache.** Disposing the runtime clears all entries. - **No dependency-tracked keys.** Key is derived from context at load time; automatically reacting to context changes is future work. @@ -336,6 +338,42 @@ Behavior: - **`dispose()`** clears all stale timers and prevents late notifications. - **With `cache.key`**, each key has an independent stale timer. +### cacheTime (Phase 3) + +`cache.cacheTime` is an optional number in milliseconds controlling how long a stale entry's value is retained before eviction. Default is `undefined` (no time-based eviction). + +`cacheTime` starts when an entry becomes stale — from `invalidate()`, action invalidation, or `staleTime` expiry. + +Before `cacheTime` expires: +- `status` remains `"ready"` +- `value` remains available +- `stale` is `true` + +When `cacheTime` expires (active entry eviction): +- `status` → `"idle"` +- `value` → `undefined` +- `error` → `undefined` +- `stale` → `false` +- Subscribers are notified + +When `cacheTime` expires (inactive keyed entry eviction): +- Entry is removed from the internal map +- No subscriber notification + +Behavior: +- **`load()`/`reload()`** clears the cacheTime timer, clears stale, starts a fresh load. +- **Successful load** clears the cacheTime timer (no longer stale). +- **Failed load** does NOT start cacheTime (preserves existing failure behavior). +- **`dispose()`** clears all cacheTime timers. +- **With `cache.key`**, each key has an independent cacheTime timer. +- **Works without `staleTime`** — manual `invalidate()` is sufficient to start cacheTime. +- **`cacheTime: 0`** evicts on the next event loop tick after staleness. + +Scope limitations (Phase 3): +- **Single-runtime only.** cacheTime does not survive runtime disposal. +- **No SWR.** Eviction does not trigger automatic reload. +- **No cross-navigation cache.** Disposing the runtime clears all entries. + ### deduplicate `cache.deduplicate` is an optional boolean. When `true`, concurrent `load()` and `reload()` calls while a load is already pending share the same in-flight promise: @@ -354,7 +392,6 @@ When `cache` is not set at all, deduplication is disabled to preserve backward c The following cache options remain as future work and are **not yet supported**: -- **`cacheTime`** — retention period for stale values before eviction - **`swr`** — stale-while-revalidate background refreshing - **Cross-navigation cache store** — persistent cache across screen navigations - **Dependency-tracked keys** — automatic key derivation and reload when context changes without explicit load/reload @@ -364,7 +401,6 @@ The following cache options remain as future work and are **not yet supported**: Resources are intentionally limited: - **No global cache.** Each runtime owns its resource nodes. There is no shared cache between runtimes. -- **No `cacheTime`.** There is no time-based retention of stale values beyond `staleTime` transitions. Keyed entries persist for the node's lifetime. - **No stale-while-revalidate (SWR).** Stale resources stay stale until explicitly reloaded. - **No Suspense integration.** Resources do not integrate with React Suspense or any other framework's loading boundaries. - **No server framework integration yet.** Resources live on screen definitions and runtimes. Server-side resource hydration is not implemented. diff --git a/docs/proposals/Resource-Cache-And-Stale-Semantics.md b/docs/proposals/Resource-Cache-And-Stale-Semantics.md index 55d4825..88756fa 100644 --- a/docs/proposals/Resource-Cache-And-Stale-Semantics.md +++ b/docs/proposals/Resource-Cache-And-Stale-Semantics.md @@ -1,6 +1,6 @@ # Resource Cache and Stale Semantics — Design Proposal -**Status:** Phase 1 implemented (staleTime + deduplicate), Phase 2 implemented (cache.key), Phase 3 designed (cacheTime single-runtime) +**Status:** Phase 1 implemented (staleTime + deduplicate), Phase 2 implemented (cache.key), Phase 3 implemented (cacheTime single-runtime) **Date:** 2026-06-27 **Author:** Big Pickle **Affected package:** `@intent-framework/core` @@ -8,7 +8,7 @@ > **Phase 1** (PR #119, `@intent-framework/core@0.1.0-alpha.8`): `cache.staleTime` and `cache.deduplicate`. > **Phase 2** (PR #123, `@intent-framework/core@0.1.0-alpha.9`): `cache.key` only, scoped to one runtime and one `ResourceNode`. -> **Phase 3** (PR #xxx, design): `cacheTime` single-runtime in-memory eviction, per `ResourceNode`, per key. +> **Phase 3** (PR #126, `@intent-framework/core@0.1.0-alpha.10`): `cacheTime` single-runtime in-memory eviction, per `ResourceNode`, per key. > **Phase 4+**: SWR, cross-navigation cache store, dependency-tracked keys. These remain design-only until implementation begins. --- @@ -1261,7 +1261,7 @@ No changeset is needed for this proposal — it is design-only. | Backward compat | Full — all existing tests pass without modification | | New exports | `ResourceKey` type only | -### Phase 3 (designed but not yet implemented) +### Phase 3 (implemented in `@intent-framework/core@0.1.0-alpha.10`) - **`cacheTime`** — single-runtime in-memory entry retention/eviction - Per-key eviction within a single `ResourceNode` diff --git a/packages/core/src/core.test.ts b/packages/core/src/core.test.ts index 28c9d7c..bac23b7 100644 --- a/packages/core/src/core.test.ts +++ b/packages/core/src/core.test.ts @@ -4050,3 +4050,565 @@ describe("resource cache phase 2 - key", () => { expect(resource.value).toBe("val-a") }) }) + +describe("resource cache phase 3 - cacheTime", () => { + // === cacheTime basic === + + it("cacheTime does nothing when unset", async () => { + const resource = createResourceNode("team", "team", async () => "data") + await resource.load() + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data") + resource.invalidate() + expect(resource.stale.current).toBe(true) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data") + await new Promise(r => setTimeout(r, 50)) + // Entry stays stale indefinitely (no cacheTime) + expect(resource.stale.current).toBe(true) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data") + }) + + it("cacheTime starts on manual invalidate() and evicts after timeout", async () => { + const resource = createResourceNode("team", "team", async () => "data", true, { cacheTime: 30 }) + await resource.load() + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data") + + resource.invalidate() + expect(resource.stale.current).toBe(true) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data") + + // Wait for cacheTime to expire + await new Promise(r => setTimeout(r, 50)) + expect(resource.status).toBe("idle") + expect(resource.value).toBeUndefined() + expect(resource.error).toBeUndefined() + expect(resource.stale.current).toBe(false) + }) + + it("cacheTime starts on staleTime expiry", async () => { + const resource = createResourceNode("team", "team", async () => "data", true, { staleTime: 20, cacheTime: 30 }) + await resource.load() + expect(resource.stale.current).toBe(false) + + // Wait for staleTime to fire + await new Promise(r => setTimeout(r, 30)) + expect(resource.stale.current).toBe(true) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data") + + // Wait for cacheTime to expire + await new Promise(r => setTimeout(r, 40)) + expect(resource.status).toBe("idle") + expect(resource.value).toBeUndefined() + expect(resource.stale.current).toBe(false) + }) + + it("cacheTime applies to non-keyed resources", async () => { + const resource = createResourceNode("team", "team", async () => "data", true, { cacheTime: 20 }) + await resource.load() + resource.invalidate() + await new Promise(r => setTimeout(r, 30)) + expect(resource.status).toBe("idle") + expect(resource.value).toBeUndefined() + }) + + it("cacheTime applies per key for keyed resources", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + cacheTime: 20, + }) + + await resource.load({ id: "a" }) + resource.invalidate() + expect(resource.stale.current).toBe(true) + + // Switch to key "b" — "a" is now inactive with a cacheTime timer + await resource.load({ id: "b" }) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data-b") + + // Wait for "a"'s cacheTime to expire (inactive eviction) + await new Promise(r => setTimeout(r, 30)) + + // Key "b" is unaffected + await resource.load({ id: "b" }) + expect(resource.value).toBe("data-b") + + // Switching back to "a" creates a fresh load (entry was evicted) + await resource.load({ id: "a" }) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data-a") + }) + + // === stale value remains available === + + it("stale value remains available before cacheTime expires", async () => { + const resource = createResourceNode("team", "team", async () => "hello", true, { cacheTime: 100 }) + await resource.load() + resource.invalidate() + expect(resource.stale.current).toBe(true) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("hello") + }) + + // === active entry eviction === + + it("active entry eviction sets status idle, clears value, error, stale", async () => { + const resource = createResourceNode("team", "team", async () => "data", true, { cacheTime: 20 }) + await resource.load() + resource.invalidate() + await new Promise(r => setTimeout(r, 30)) + expect(resource.status).toBe("idle") + expect(resource.value).toBeUndefined() + expect(resource.error).toBeUndefined() + expect(resource.stale.current).toBe(false) + }) + + it("active entry eviction notifies subscribers", async () => { + const resource = createResourceNode("team", "team", async () => "data", true, { cacheTime: 20 }) + await resource.load() + + const statuses: string[] = [] + const unsub = resource.subscribe(() => { + statuses.push(resource.status) + }) + + resource.invalidate() + await new Promise(r => setTimeout(r, 30)) + + expect(statuses).toContain("idle") + unsub() + }) + + // === inactive keyed entry eviction === + + it("inactive keyed entry eviction removes only that entry", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + cacheTime: 20, + }) + + await resource.load({ id: "a" }) + resource.invalidate() + + // Load key "b" — "a" becomes inactive with cacheTime timer + await resource.load({ id: "b" }) + expect(resource.value).toBe("data-b") + + // Wait for "a"'s cacheTime + await new Promise(r => setTimeout(r, 30)) + + // "b" is still healthy + await resource.load({ id: "b" }) + expect(resource.value).toBe("data-b") + + // Switching back to "a" triggers a fresh load (entry was evicted) + await resource.load({ id: "a" }) + expect(resource.value).toBe("data-a") + }) + + it("inactive keyed entry eviction does not notify subscribers", async () => { + let notifyCount = 0 + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + cacheTime: 20, + }) + + await resource.load({ id: "a" }) + resource.invalidate() + + const unsub = resource.subscribe(() => { notifyCount++ }) + + // Load key "b" — "a" becomes inactive + await resource.load({ id: "b" }) + const countBefore = notifyCount + + // Wait for "a"'s cacheTime to fire (inactive — no notification) + await new Promise(r => setTimeout(r, 30)) + expect(notifyCount).toBe(countBefore) + + unsub() + }) + + it("loading an evicted inactive key creates a fresh entry", async () => { + let callCount = 0 + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + callCount++ + return `data-${ctx.id}-${callCount}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + cacheTime: 20, + }) + + await resource.load({ id: "a" }) + expect(callCount).toBe(1) + resource.invalidate() + + // Switch to "b" + await resource.load({ id: "b" }) + expect(callCount).toBe(2) + + // Wait for "a"'s cacheTime + await new Promise(r => setTimeout(r, 30)) + + // Load "a" again — should be a fresh load + await resource.load({ id: "a" }) + expect(callCount).toBe(3) + expect(resource.value).toBe("data-a-3") + }) + + // === reload interaction === + + it("successful reload clears cacheTime timer and entry stays ready", async () => { + const resource = createResourceNode("team", "team", async () => "data", true, { cacheTime: 50 }) + await resource.load() + resource.invalidate() + expect(resource.stale.current).toBe(true) + + // Reload before cacheTime expires + await resource.reload() + expect(resource.stale.current).toBe(false) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data") + + // Wait past original cacheTime — entry should not be evicted since timer was cleared + await new Promise(r => setTimeout(r, 60)) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data") + }) + + it("reload after stale before cacheTime expires cancels eviction and reloads", async () => { + let loadCount = 0 + const resource = createResourceNode("team", "team", async () => { + loadCount++ + return `data${loadCount}` + }, true, { cacheTime: 100 }) + + await resource.load() + expect(loadCount).toBe(1) + + resource.invalidate() + expect(resource.stale.current).toBe(true) + + // Reload during cacheTime window + await resource.reload() + expect(loadCount).toBe(2) + expect(resource.value).toBe("data2") + expect(resource.stale.current).toBe(false) + + // Wait past original cacheTime — entry should not be evicted + await new Promise(r => setTimeout(r, 120)) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data2") + }) + + // === failed load === + + it("failed load does not start cacheTime", async () => { + const resource = createResourceNode("team", "team", async () => { + throw new Error("fail") + }, true, { cacheTime: 20 }) + + await resource.load() + expect(resource.status).toBe("failed") + + // Wait — should not become idle (cacheTime never started) + await new Promise(r => setTimeout(r, 30)) + expect(resource.status).toBe("failed") + expect(resource.stale.current).toBe(false) + }) + + it("failed reload preserves existing failure behavior", async () => { + let callCount = 0 + const resource = createResourceNode("team", "team", async () => { + callCount++ + throw new Error("fail") + }, true, { cacheTime: 20 }) + + await resource.load() + expect(resource.status).toBe("failed") + expect(callCount).toBe(1) + + // Reload — should try again and fail again + await resource.reload() + expect(resource.status).toBe("failed") + expect(callCount).toBe(2) + + // Wait — cacheTime should NOT have been started + await new Promise(r => setTimeout(r, 30)) + expect(resource.status).toBe("failed") + }) + + // === invalidate interaction === + + it("invalidate before cacheTime expiry does not create duplicate timers", async () => { + let notifyCount = 0 + const resource = createResourceNode("team", "team", async () => "data", true, { cacheTime: 100 }) + await resource.load() + + const unsub = resource.subscribe(() => { notifyCount++ }) + + resource.invalidate() + const countAfterFirst = notifyCount + + // Invalidate again while already stale — should be no-op + resource.invalidate() + expect(notifyCount).toBe(countAfterFirst) + + // Manually check entry state + expect(resource.stale.current).toBe(true) + + unsub() + }) + + // === repeated staleTime cycles === + + it("repeated staleTime cycles do not leak timers", async () => { + let loadCount = 0 + const resource = createResourceNode("team", "team", async () => { + loadCount++ + return `data${loadCount}` + }, true, { staleTime: 20, cacheTime: 100 }) + + await resource.load() + expect(loadCount).toBe(1) + + // Wait for staleTime to fire + await new Promise(r => setTimeout(r, 30)) + expect(resource.stale.current).toBe(true) + + // Reload — timer cycle resets + await resource.reload() + expect(resource.stale.current).toBe(false) + expect(loadCount).toBe(2) + + // Wait for staleTime again + await new Promise(r => setTimeout(r, 30)) + expect(resource.stale.current).toBe(true) + + // Reload again — still works, no leaked timers + await resource.reload() + expect(loadCount).toBe(3) + expect(resource.stale.current).toBe(false) + }) + + // === deduplicate interaction === + + it("deduplicate still works before cacheTime expiry", async () => { + let callCount = 0 + let resolveLoad!: (v: string) => void + const deferred = new Promise(resolve => { resolveLoad = resolve }) + + const resource = createResourceNode("team", "team", async () => { + callCount++ + return deferred + }, true, { cacheTime: 100, deduplicate: true }) + + const p1 = resource.load() + resource.invalidate() + const p2 = resource.load() + + // Both load calls should see the same in-flight promise (deduped) + resolveLoad("result") + await p1 + await p2 + expect(callCount).toBe(1) + expect(resource.status).toBe("ready") + }) + + it("deduplicate still works after active entry eviction", async () => { + let callCount = 0 + let resolveLoad!: (v: string) => void + const deferred = new Promise(resolve => { resolveLoad = resolve }) + + const resource = createResourceNode("team", "team", async () => { + callCount++ + if (callCount === 1) return "first" + return deferred + }, true, { cacheTime: 10, deduplicate: true }) + + await resource.load() + expect(callCount).toBe(1) + expect(resource.value).toBe("first") + + resource.invalidate() + + // Wait for eviction + await new Promise(r => setTimeout(r, 20)) + expect(resource.status).toBe("idle") + + // Fresh load — should work with deduplicate + const p1 = resource.load() + const p2 = resource.load() + + resolveLoad("fresh") + await Promise.all([p1, p2]) + expect(callCount).toBe(2) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("fresh") + }) + + // === no-arg reload after eviction === + + it("no-arg reload after active eviction uses lastContext and reloads the last key", async () => { + let lastCtx: unknown = undefined + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + lastCtx = ctx + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + cacheTime: 10, + }) + + await resource.load({ id: "abc" }) + expect(resource.value).toBe("data-abc") + + resource.invalidate() + await new Promise(r => setTimeout(r, 20)) + expect(resource.status).toBe("idle") + + // No-arg reload should use lastContext → key "abc" + await resource.reload() + expect(lastCtx).toHaveProperty("id", "abc") + expect(resource.value).toBe("data-abc") + expect(resource.status).toBe("ready") + }) + + // === dispose === + + it("dispose clears all cacheTime timers", async () => { + let notified = false + const resource = createResourceNode("team", "team", async () => "data", true, { cacheTime: 20 }) + await resource.load() + resource.invalidate() + + const unsub = resource.subscribe(() => { notified = true }) + + resource.dispose() + + // Wait past cacheTime — eviction should not fire after dispose + await new Promise(r => setTimeout(r, 30)) + expect(notified).toBe(false) + unsub() + }) + + it("dispose prevents late eviction notifications", async () => { + const resource = createResourceNode("team", "team", async () => "data", true, { cacheTime: 10 }) + await resource.load() + resource.invalidate() + + resource.dispose() + // No error/crash — dispose clears everything cleanly + }) + + // === cacheTime without staleTime === + + it("cacheTime works without staleTime (manual invalidate only)", async () => { + const resource = createResourceNode("team", "team", async () => "data", true, { cacheTime: 20 }) + await resource.load() + expect(resource.stale.current).toBe(false) + + // No staleTime — cacheTime not started until manual invalidate + await new Promise(r => setTimeout(r, 30)) + expect(resource.stale.current).toBe(false) + + // Manual invalidate triggers cacheTime + resource.invalidate() + await new Promise(r => setTimeout(r, 30)) + expect(resource.status).toBe("idle") + expect(resource.value).toBeUndefined() + }) + + // === cacheTime: 0 === + + it("cacheTime: 0 evicts on next tick", async () => { + const resource = createResourceNode("team", "team", async () => "data", true, { cacheTime: 0 }) + await resource.load() + resource.invalidate() + + // cacheTime: 0 should evict on the next tick + await new Promise(r => setTimeout(r, 10)) + expect(resource.status).toBe("idle") + expect(resource.value).toBeUndefined() + }) + + // === keyed active eviction isolation === + + it("keyed active eviction does not evict other keys", async () => { + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + cacheTime: 20, + }) + + await resource.load({ id: "a" }) + await resource.load({ id: "b" }) + + // Invalidate active key "b" + resource.invalidate() + expect(resource.stale.current).toBe(true) + + // Wait for "b"'s cacheTime (active eviction) + await new Promise(r => setTimeout(r, 30)) + expect(resource.status).toBe("idle") + + // Switch to "a" — should still be ready + await resource.load({ id: "a" }) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data-a") + }) + + // === in-flight promise after eviction === + + it("in-flight promise resolving after inactive eviction does not resurrect entry", async () => { + let resolveLoad!: (v: string) => void + const deferred = new Promise(resolve => { resolveLoad = resolve }) + let loadCountForA = 0 + + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + if (ctx.id === "a") { + loadCountForA++ + if (loadCountForA === 1) return deferred + } + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + cacheTime: 10, + }) + + // Start loading "a" with a deferred promise + const loadAPromise = resource.load({ id: "a" }) + + // Wait a bit to let the in-flight promise be stored + await new Promise(r => setTimeout(r, 5)) + expect(resource.status).toBe("pending") + + // Invalidate "a" and switch to "b" — "a" becomes inactive + resource.invalidate() + await resource.load({ id: "b" }) + + // Wait for "a"'s cacheTime to fire (inactive eviction — entry removed from Map) + await new Promise(r => setTimeout(r, 20)) + + // Now resolve "a"'s deferred promise + resolveLoad("a-data") + await loadAPromise + + // "a" entry was removed from Map, resolution should not resurrect it + // Switch back to "a" — should start a fresh load + await resource.load({ id: "a" }) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data-a") + }) +}) diff --git a/packages/core/src/resource.ts b/packages/core/src/resource.ts index 3e2e132..50bcbb2 100644 --- a/packages/core/src/resource.ts +++ b/packages/core/src/resource.ts @@ -35,6 +35,7 @@ export type AnyResourceNode = ResourceNode export type ResourceCacheOptions = { key?: (context: ResourceLoadContext) => ResourceKey staleTime?: number + cacheTime?: number deduplicate?: boolean } @@ -75,6 +76,7 @@ export function createResourceNode | null + cacheTimer: ReturnType | null inFlightPromise: Promise | null } @@ -85,6 +87,7 @@ export function createResourceNode { if (!entry.stale) { entry.stale = true + _startEntryCacheTimer(entry, key) if (entry === getActiveEntry()) { currentStale = true staleNotify() @@ -205,6 +209,32 @@ export function createResourceNode { + if (entry === getActiveEntry()) { + entry.value = undefined + entry.error = undefined + entry.status = "idle" + entry.stale = false + _clearEntryCacheTimer(entry) + syncFromActiveEntry() + } else { + entries.delete(key) + } + }, cache.cacheTime) + } + } + // Condition getters (read from synced variables) function getReady(): Condition { if (!_ready) { @@ -256,6 +286,8 @@ export function createResourceNode Date: Sat, 27 Jun 2026 12:59:15 +0330 Subject: [PATCH 2/3] fix(core): two resource cache runtime edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix 1 — in-flight promise after inactive eviction must not mutate detached entries. Add ownership check (entries.get(key) !== entry) before applying success/failure results, calling staleNotify(), or starting staleTime/cacheTime timers. Fix 2 — switching active key to an already in-flight entry must sync public state (syncFromEntry) before dedupe early return. Previously the dedupe path returned the in-flight promise without updating currentStatus/currentValue/currentError, leaving public getters showing the previous key's state. Add tests: - detached promise resolution does not notify subscribers - detached promise resolution does not start timers - switching to in-flight key syncs state before dedupe return --- packages/core/src/core.test.ts | 136 +++++++++++++++++++++++++++++++++ packages/core/src/resource.ts | 4 + 2 files changed, 140 insertions(+) diff --git a/packages/core/src/core.test.ts b/packages/core/src/core.test.ts index bac23b7..6b203f7 100644 --- a/packages/core/src/core.test.ts +++ b/packages/core/src/core.test.ts @@ -4611,4 +4611,140 @@ describe("resource cache phase 3 - cacheTime", () => { expect(resource.status).toBe("ready") expect(resource.value).toBe("data-a") }) + + it("resolving detached in-flight promise does not notify subscribers", async () => { + let resolveLoad!: (v: string) => void + const deferred = new Promise(resolve => { resolveLoad = resolve }) + + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + if (ctx.id === "a") return deferred + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + cacheTime: 10, + }) + + // Track node-level and stale notifications + let notifyCount = 0 + const unsub = resource.subscribe(() => { notifyCount++ }) + let staleNotifyCount = 0 + const staleUnsub = resource.stale.subscribe(() => { staleNotifyCount++ }) + + // Start loading "a" with a deferred promise + const loadAPromise = resource.load({ id: "a" }) + await new Promise(r => setTimeout(r, 5)) + + // Invalidate "a" and switch to "b" — "a" becomes inactive + resource.invalidate() + await resource.load({ id: "b" }) + + // Wait for "a"'s cacheTime to fire (inactive eviction) + await new Promise(r => setTimeout(r, 20)) + + const notifyBefore = notifyCount + const staleBefore = staleNotifyCount + + // Resolve "a"'s deferred — entry is detached, must be a no-op + resolveLoad("a-data") + await loadAPromise + + expect(notifyCount).toBe(notifyBefore) + expect(staleNotifyCount).toBe(staleBefore) + + unsub() + staleUnsub() + }) + + it("resolving detached in-flight promise does not start staleTime or cacheTime timers", async () => { + let resolveLoad!: (v: string) => void + const deferred = new Promise(resolve => { resolveLoad = resolve }) + let loadCountForA = 0 + + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + if (ctx.id === "a") { + loadCountForA++ + if (loadCountForA === 1) return deferred + } + return `data-${ctx.id}` + }, true, { + key: (ctx: { id: string }) => ctx.id, + cacheTime: 20, + }) + + // Start loading "a" with a deferred promise + const loadAPromise = resource.load({ id: "a" }) + await new Promise(r => setTimeout(r, 5)) + + // Invalidate "a" and switch to "b" — "a" becomes inactive with cacheTime timer + resource.invalidate() + await resource.load({ id: "b" }) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data-b") + + // Wait for "a"'s cacheTime to fire (inactive eviction) + await new Promise(r => setTimeout(r, 30)) + + // Resolve "a"'s deferred — entry is detached, no timers should start + resolveLoad("a-data") + await loadAPromise + + // "b" is still healthy (no side effects from detached resolution) + expect(resource.status).toBe("ready") + expect(resource.value).toBe("data-b") + + // "a" was not resurrected — loading it creates a fresh entry + await resource.load({ id: "a" }) + expect(resource.value).toBe("data-a") + + resource.dispose() + }) + + it("switching to a key with in-flight promise syncs state before dedupe return", async () => { + let resolveA!: (v: string) => void + const deferredA = new Promise(resolve => { resolveA = resolve }) + let resolveB!: (v: string) => void + const deferredB = new Promise(resolve => { resolveB = resolve }) + + let callCount = 0 + const resource = createResourceNode("team", "team", async (ctx: { id: string }) => { + callCount++ + if (ctx.id === "a") return deferredA + return deferredB + }, true, { + key: (ctx: { id: string }) => ctx.id, + deduplicate: true, + }) + + // 1. Start load for key A and leave it pending + resource.load({ id: "a" }) + await new Promise(r => setTimeout(r, 5)) + expect(resource.status).toBe("pending") + + // 2. Switch to key B and complete it + const loadB = resource.load({ id: "b" }) + resolveB("b-data") + await loadB + expect(resource.status).toBe("ready") + expect(resource.value).toBe("b-data") + expect(callCount).toBe(2) + + // 3. Call load for key A again while A is still in-flight (dedupe) + const loadA2 = resource.load({ id: "a" }) + + // 4. The node immediately shows key A pending, not key B ready + expect(resource.status).toBe("pending") + expect(resource.value).toBeUndefined() + + // 5. When A resolves, node shows key A value + resolveA("a-data") + await loadA2 + expect(resource.status).toBe("ready") + expect(resource.value).toBe("a-data") + + // Regression: same-key dedup still calls loader once + expect(callCount).toBe(2) + + // Regression: different-key loads ran independently (A=call 1, B=call 2) + // Already verified by callCount === 2 + }) }) diff --git a/packages/core/src/resource.ts b/packages/core/src/resource.ts index 50bcbb2..4fda30b 100644 --- a/packages/core/src/resource.ts +++ b/packages/core/src/resource.ts @@ -278,6 +278,7 @@ export function createResourceNode): Promise { const key = resolveKey(context) + const prevActiveKey = _activeKey _activeKey = key let entry = entries.get(key) @@ -289,6 +290,7 @@ export function createResourceNode) => TValue | Promise)(loadContext) ) + if (entries.get(key) !== entry) return entry!.value = result entry!.status = "ready" entry!.stale = false @@ -316,6 +319,7 @@ export function createResourceNode Date: Sat, 27 Jun 2026 15:45:02 +0330 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20fix=20Phase=203=20implementation=20?= =?UTF-8?q?PR=20reference=20(PR=20#126=20=E2=86=92=20PR=20#127)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/proposals/Resource-Cache-And-Stale-Semantics.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/proposals/Resource-Cache-And-Stale-Semantics.md b/docs/proposals/Resource-Cache-And-Stale-Semantics.md index 88756fa..16ff1c3 100644 --- a/docs/proposals/Resource-Cache-And-Stale-Semantics.md +++ b/docs/proposals/Resource-Cache-And-Stale-Semantics.md @@ -8,7 +8,7 @@ > **Phase 1** (PR #119, `@intent-framework/core@0.1.0-alpha.8`): `cache.staleTime` and `cache.deduplicate`. > **Phase 2** (PR #123, `@intent-framework/core@0.1.0-alpha.9`): `cache.key` only, scoped to one runtime and one `ResourceNode`. -> **Phase 3** (PR #126, `@intent-framework/core@0.1.0-alpha.10`): `cacheTime` single-runtime in-memory eviction, per `ResourceNode`, per key. +> **Phase 3** (PR #127, `@intent-framework/core@0.1.0-alpha.10`): `cacheTime` single-runtime in-memory eviction, per `ResourceNode`, per key. > **Phase 4+**: SWR, cross-navigation cache store, dependency-tracked keys. These remain design-only until implementation begins. --- @@ -764,7 +764,7 @@ When no `key` option is provided: ## Phase 3: CacheTime — Single-Runtime Design -**Status:** Design (not yet implemented) +**Status:** Design (PR #126), implemented in `@intent-framework/core@0.1.0-alpha.10` (PR #127) **Scope:** `cacheTime` option, in-memory entry retention/eviction within the lifetime of a single `ResourceNode`. No cross-navigation cache store. No SWR. No dependency-tracked keys. No server resources. ### Revisiting Phase 2 Q7 @@ -1261,7 +1261,7 @@ No changeset is needed for this proposal — it is design-only. | Backward compat | Full — all existing tests pass without modification | | New exports | `ResourceKey` type only | -### Phase 3 (implemented in `@intent-framework/core@0.1.0-alpha.10`) +### Phase 3 (implemented in `@intent-framework/core@0.1.0-alpha.10`, PR #127) - **`cacheTime`** — single-runtime in-memory entry retention/eviction - Per-key eviction within a single `ResourceNode`