Skip to content

Commit 2cae8db

Browse files
authored
feat: add resource-lifecycle example (#111)
1 parent ca5c52b commit 2cae8db

12 files changed

Lines changed: 356 additions & 2 deletions

File tree

docs/proposals/Examples-Roadmap.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Examples Roadmap — Design Proposal
22

3-
**Status:** Implementing — Example 1 (`choice-form`) done in #108
3+
**Status:** Implementing — Example 1 (`choice-form`) done in #108, Example 2 (`resource-lifecycle`) done in
44
**Date:** 2026-06-26
55
**Author:** Big Pickle
66
**Affected packages:** `@intent-framework/core`, `@intent-framework/dom`, `@intent-framework/router`, `@intent-framework/testing`
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Intent — Resource Lifecycle Example</title>
7+
<style>
8+
body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; line-height: 1.5; }
9+
h1:first-of-type { font-size: 1.25rem; }
10+
h2 { font-size: 1rem; margin-top: 2rem; }
11+
label { display: block; font-weight: 600; margin-top: 1rem; margin-bottom: 0.25rem; }
12+
input { width: 100%; box-sizing: border-box; padding: 0.5rem; font-size: 1rem; border: 1px solid #ccc; border-radius: 4px; }
13+
button { margin-right: 0.5rem; margin-bottom: 0.5rem; padding: 0.5rem 1rem; font-size: 1rem; cursor: pointer; border: 1px solid #aaa; border-radius: 4px; background: #fff; }
14+
button.primary { font-weight: 700; background: #1a73e8; color: #fff; border-color: #1a73e8; }
15+
button:disabled { opacity: 0.5; cursor: not-allowed; }
16+
pre { background: #f5f5f5; padding: 0.75rem; border-radius: 4px; font-size: 0.8rem; overflow-x: auto; }
17+
.proves { background: #e8f0fe; padding: 0.75rem; border-radius: 4px; margin-top: 1.5rem; }
18+
.proves ul { margin: 0.25rem 0; padding-left: 1.25rem; }
19+
</style>
20+
</head>
21+
<body>
22+
<h1>Intent: Resource Lifecycle</h1>
23+
<p>Demonstrates autoLoad, manual load, reload, invalidation, stale state, failed state, and action-driven invalidation.</p>
24+
<div id="root"></div>
25+
<h2>Resource Status</h2>
26+
<pre id="resource-status">Loading...</pre>
27+
<h2>Semantic Graph (<code>inspectScreen()</code>)</h2>
28+
<pre id="inspect">Loading...</pre>
29+
<div class="proves">
30+
<strong>What this proves:</strong>
31+
<ul>
32+
<li><code>autoLoad: true</code> resource reaches <code>ready</code> on runtime start.</li>
33+
<li><code>autoLoad: false</code> resource starts <code>idle</code>.</li>
34+
<li><code>resource.reload()</code> transitions through <code>pending</code> to <code>ready</code>.</li>
35+
<li><code>resource.invalidate()</code> marks stale without reloading.</li>
36+
<li><code>reload()</code> clears stale.</li>
37+
<li><code>resource.load()</code> manually triggers a deferred load.</li>
38+
<li><code>action.invalidates(resource)</code> marks the resource stale after successful action.</li>
39+
<li>A failed loader produces <code>failed</code> status and error.</li>
40+
<li>A failed action does not invalidate its targeted resources.</li>
41+
<li>Route context is available to resource loaders.</li>
42+
</ul>
43+
</div>
44+
<script type="module" src="/src/main.ts"></script>
45+
</body>
46+
</html>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "resource-lifecycle",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "vite build",
9+
"test": "vitest run",
10+
"typecheck": "tsc --noEmit",
11+
"lint": "tsc --noEmit"
12+
},
13+
"dependencies": {
14+
"@intent-framework/core": "workspace:*",
15+
"@intent-framework/dom": "workspace:*",
16+
"@intent-framework/router": "workspace:*"
17+
},
18+
"devDependencies": {
19+
"@intent-framework/testing": "workspace:*",
20+
"typescript": "^5.7.0",
21+
"vite": "^6.0.0",
22+
"vitest": "^3.1.1"
23+
}
24+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { describe, it, expect } from "vitest"
2+
import { testScreen } from "@intent-framework/testing"
3+
import { createScreenRuntime, inspectScreen } from "@intent-framework/core"
4+
import { ResourceDemo, teamLoadCount } from "./ResourceDemo.js"
5+
6+
const testServices = {
7+
route: { name: "demo", path: "/:id", params: { id: "team_1" } },
8+
navigate: () => {},
9+
} as const
10+
11+
describe("ResourceDemo", () => {
12+
it("autoLoad resource reaches ready", async () => {
13+
await testScreen(ResourceDemo, async app => {
14+
expect(app.resource("team").status()).toBe("ready")
15+
}, { services: testServices as any })
16+
})
17+
18+
it("autoLoad: false resource starts idle", async () => {
19+
await testScreen(ResourceDemo, async app => {
20+
expect(app.resource("auditLog").status()).toBe("idle")
21+
}, { services: testServices as any })
22+
})
23+
24+
it("manual load transitions resource to ready", async () => {
25+
await testScreen(ResourceDemo, async app => {
26+
expect(app.resource("auditLog").status()).toBe("idle")
27+
await app.resource("auditLog").load()
28+
expect(app.resource("auditLog").status()).toBe("ready")
29+
}, { services: testServices as any })
30+
})
31+
32+
it("reload increments load count", async () => {
33+
await testScreen(ResourceDemo, async app => {
34+
const before = teamLoadCount
35+
await app.resource("team").reload()
36+
expect(teamLoadCount).toBe(before + 1)
37+
}, { services: testServices as any })
38+
})
39+
40+
it("invalidate marks resource stale without reloading", async () => {
41+
await testScreen(ResourceDemo, async app => {
42+
const team = app.resource("team")
43+
expect(team.stale()).toBe(false)
44+
const before = teamLoadCount
45+
team.invalidate()
46+
expect(team.stale()).toBe(true)
47+
expect(teamLoadCount).toBe(before)
48+
}, { services: testServices as any })
49+
})
50+
51+
it("reload clears stale", async () => {
52+
await testScreen(ResourceDemo, async app => {
53+
const team = app.resource("team")
54+
team.invalidate()
55+
expect(team.stale()).toBe(true)
56+
await team.reload()
57+
expect(team.stale()).toBe(false)
58+
}, { services: testServices as any })
59+
})
60+
61+
it("action .invalidates() marks resource stale after success", async () => {
62+
await testScreen(ResourceDemo, async app => {
63+
const team = app.resource("team")
64+
expect(team.stale()).toBe(false)
65+
await app.act("Save team").run()
66+
expect(team.stale()).toBe(true)
67+
}, { services: testServices as any })
68+
})
69+
70+
it("failed loader produces failed status and error", async () => {
71+
await testScreen(ResourceDemo, async app => {
72+
expect(app.resource("unstableReport").status()).toBe("failed")
73+
}, { services: testServices as any })
74+
})
75+
76+
it("failed action does not invalidate resources", async () => {
77+
await testScreen(ResourceDemo, async app => {
78+
const team = app.resource("team")
79+
expect(team.stale()).toBe(false)
80+
await app.act("Broken save").run()
81+
expect(team.stale()).toBe(false)
82+
}, { services: testServices as any })
83+
})
84+
85+
it("inspectScreen reports resources with status/stale/error", async () => {
86+
const runtime = createScreenRuntime(ResourceDemo, { services: testServices as any })
87+
await runtime.start()
88+
const graph = runtime.graph
89+
const resources = graph.resources
90+
expect(resources).toHaveLength(3)
91+
const teamRes = resources.find(r => r.name === "team")!
92+
expect(teamRes.status).toBe("ready")
93+
expect(teamRes.hasValue).toBe(true)
94+
expect(teamRes.stale).toBe(false)
95+
const auditRes = resources.find(r => r.name === "auditLog")!
96+
expect(auditRes.status).toBe("idle")
97+
expect(auditRes.hasValue).toBe(false)
98+
expect(auditRes.stale).toBe(false)
99+
const failedRes = resources.find(r => r.name === "unstableReport")!
100+
expect(failedRes.status).toBe("failed")
101+
expect(failedRes.hasValue).toBe(false)
102+
expect(failedRes.stale).toBe(false)
103+
expect(failedRes.error).toBe("Report generation failed")
104+
runtime.dispose()
105+
})
106+
107+
it("no unrelated graph diagnostics are introduced", () => {
108+
const graph = inspectScreen(ResourceDemo)
109+
expect(graph.diagnostics).toHaveLength(0)
110+
})
111+
})
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { screen } from "@intent-framework/core"
2+
import type { AppServices } from "./types.js"
3+
4+
export let teamLoadCount = 0
5+
export let auditLogLoadCount = 0
6+
7+
export type Team = {
8+
id: string
9+
name: string
10+
members: number
11+
version: number
12+
}
13+
14+
export const ResourceDemo = screen<AppServices>("Resource Demo", $ => {
15+
const team = $.resource("team", {
16+
load: async ({ route }) => {
17+
teamLoadCount++
18+
return {
19+
id: route.params.id,
20+
name: "Core",
21+
members: 3,
22+
version: teamLoadCount,
23+
} satisfies Team
24+
},
25+
})
26+
27+
const auditLog = $.resource("auditLog", {
28+
load: async () => {
29+
auditLogLoadCount++
30+
return ["entry_1", "entry_2"]
31+
},
32+
autoLoad: false,
33+
})
34+
35+
$.resource("unstableReport", {
36+
load: async () => {
37+
throw new Error("Report generation failed")
38+
},
39+
})
40+
41+
const reloadTeam = $.act("Reload team")
42+
.does(async () => {
43+
await team.reload()
44+
})
45+
46+
const invalidateTeam = $.act("Invalidate team")
47+
.does(() => {
48+
team.invalidate()
49+
})
50+
51+
const saveTeam = $.act("Save team")
52+
.does(async () => {})
53+
.invalidates(team)
54+
55+
const brokenSave = $.act("Broken save")
56+
.does(async () => {
57+
throw new Error("Save failed")
58+
})
59+
.invalidates(team)
60+
61+
const loadAuditLog = $.act("Load audit log")
62+
.does(async () => {
63+
await auditLog.load()
64+
})
65+
66+
$.surface("main").contains(
67+
reloadTeam,
68+
invalidateTeam,
69+
saveTeam,
70+
brokenSave,
71+
loadAuditLog,
72+
)
73+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { renderRouter } from "@intent-framework/dom"
2+
import { inspectScreen } from "@intent-framework/core"
3+
import { demoRouter } from "./router.js"
4+
import { ResourceDemo } from "./ResourceDemo.js"
5+
6+
const root = document.getElementById("root")!
7+
renderRouter(demoRouter, {
8+
target: root,
9+
showScreenName: true,
10+
})
11+
12+
function updatePanels() {
13+
const statusEl = document.getElementById("resource-status")
14+
if (statusEl) {
15+
const info = ResourceDemo.resourceConfigs.flatMap(c => {
16+
const ref = c.ref
17+
if (!ref) return []
18+
return [{
19+
name: ref.name,
20+
status: ref.status,
21+
stale: ref.stale.current,
22+
error: ref.error instanceof Error ? ref.error.message : String(ref.error ?? ""),
23+
}]
24+
})
25+
statusEl.textContent = JSON.stringify(info, null, 2)
26+
}
27+
28+
const inspectEl = document.getElementById("inspect")
29+
if (inspectEl) {
30+
inspectEl.textContent = JSON.stringify(inspectScreen(ResourceDemo), null, 2)
31+
}
32+
}
33+
34+
setInterval(updatePanels, 200)
35+
setTimeout(updatePanels, 100)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { createRouter } from "@intent-framework/router"
2+
import { appPaths, type AppServices } from "./types.js"
3+
import { ResourceDemo } from "./ResourceDemo.js"
4+
5+
export const demoRouter = createRouter<AppServices>()
6+
.route("demo", appPaths.demo, ResourceDemo)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { RouteContext, RouterServices, RoutesFromPaths } from "@intent-framework/router"
2+
3+
export const appPaths = {
4+
demo: "/:id",
5+
} as const
6+
7+
export type AppRoutes = RoutesFromPaths<typeof appPaths>
8+
9+
export type AppServices = RouterServices<AppRoutes, {
10+
route: RouteContext<AppRoutes>
11+
}>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"extends": "../../tsconfig.json",
3+
"compilerOptions": {
4+
"lib": ["ES2022", "DOM"],
5+
"noUnusedLocals": false,
6+
"noUnusedParameters": false
7+
},
8+
"include": ["src"]
9+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { defineConfig } from "vite"
2+
3+
export default defineConfig({
4+
resolve: {
5+
alias: {
6+
"@intent-framework/core": new URL("../../packages/core/src/index.ts", import.meta.url).pathname,
7+
"@intent-framework/dom": new URL("../../packages/dom/src/index.ts", import.meta.url).pathname,
8+
"@intent-framework/router": new URL("../../packages/router/src/index.ts", import.meta.url).pathname,
9+
"@intent-framework/testing": new URL("../../packages/testing/src/index.ts", import.meta.url).pathname,
10+
},
11+
conditions: ["development", "browser"],
12+
},
13+
})

0 commit comments

Comments
 (0)