Skip to content

Commit 37a3cad

Browse files
authored
Merge pull request #57 from intent-framework/docs/resource-semantics-guide
docs: add resource semantics guide
2 parents 47b81cc + 940d486 commit 37a3cad

5 files changed

Lines changed: 274 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,8 @@ The demo shows a small team invite flow:
244244

245245
For a guided walkthrough, see [Demo Guide](docs/Demo.md).
246246

247+
For a deep dive into resource semantics — load, reload, invalidation, staleness, and runtime scoping — see the [Resources Guide](docs/Resources.md).
248+
247249
## What the demo demonstrates
248250

249251
The demo is intentionally small. It is a dagger, not a cathedral.

docs/Inspect-Screen.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ Each action includes: `id`, `semanticId`, `label`, `primary`, `enabled`, `blocke
4848

4949
Each resource includes: `id`, `semanticId`, `name`, `status`, `hasValue`, `stale`, `error`.
5050

51+
For a detailed explanation of resource lifecycle, runtime scoping, and invalidation, see the [Resources Guide](Resources.md).
52+
5153
Resources require passing runtime resource nodes:
5254

5355
```ts

docs/MVP-Checkpoint.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,10 @@ Do not jump into these without a very specific narrow PR:
142142
## Documented
143143

144144
-`inspectScreen()` diagnostic guide (`docs/Inspect-Screen.md`) — covers graph contents, all five current diagnostics, semantic ID behavior, development workflow, and current boundaries.
145+
- ✅ Resource semantics guide (`docs/Resources.md`) — covers resource lifecycle, runtime scoping, ResourceRef vs ResourceNode, invalidation, loader context, and current boundaries.
145146

146147
## Recommended next moves
147148

148149
1. ~~Add a tiny diagnostics/dev-inspection page or command that prints `inspectScreen()` output for demo screens without `MutationObserver`.~~ (Covered by the diagnostic guide documenting the `demo-panels.ts` pattern.)
149-
2. Improve resource semantics documentation: runtime-scoped resources, reload, stale, invalidation, context.
150+
2. ~~Improve resource semantics documentation: runtime-scoped resources, reload, stale, invalidation, context.~~ (Covered by docs/Resources.md.)
150151
3. Add one or two graph diagnostics that catch real authoring mistakes before adding any new package.

docs/Quickstart.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ Intent does not replace components for everything. It replaces components as the
225225
pnpm dev:canonical
226226
```
227227
2. See the [web demo](../examples/web-basic) for a full team invite flow with routing, resources, and diagnostics.
228-
3. Read the [Specification](Specification.md) for the architecture.
229-
4. Read the [Inspect Screen and Diagnostics Guide](Inspect-Screen.md) for a detailed walkthrough of `inspectScreen()`, diagnostics, and semantic IDs.
230-
5. Check the [MVP Checkpoint](MVP-Checkpoint.md) for current boundaries.
228+
3. Read the [Resources Guide](Resources.md) for resource semantics — load, reload, invalidation, runtime scoping.
229+
4. Read the [Specification](Specification.md) for the architecture.
230+
5. Read the [Inspect Screen and Diagnostics Guide](Inspect-Screen.md) for a detailed walkthrough of `inspectScreen()`, diagnostics, and semantic IDs.
231+
6. Check the [MVP Checkpoint](MVP-Checkpoint.md) for current boundaries.

docs/Resources.md

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
# Resource Semantics Guide
2+
3+
## What a resource is
4+
5+
A resource is semantic async state owned by a screen runtime.
6+
7+
It represents data a screen needs — a team roster, a user profile, a document. It is not a global cache or a data-fetching library. Resources are part of the product graph, like asks and actions.
8+
9+
The current API:
10+
11+
```ts
12+
const team = $.resource("team", {
13+
load: async ({ route }) => {
14+
return loadTeam(route.params.teamId)
15+
},
16+
})
17+
```
18+
19+
`$.resource(name, config)` returns a `ResourceRef<T, TServices>` — a definition-time handle. The loader is an async function that returns the resource value. `autoLoad` defaults to `true`.
20+
21+
## Minimal example
22+
23+
```ts
24+
import { screen } from "@intent-framework/core"
25+
26+
type AppServices = {
27+
navigate?: (name: string) => void
28+
}
29+
30+
const loadProfile = async (id: string): Promise<{ name: string; email: string }> => {
31+
const res = await fetch(`/api/profiles/${id}`)
32+
return res.json()
33+
}
34+
35+
export const ProfileScreen = screen<AppServices>("Profile", $ => {
36+
const profile = $.resource("profile", {
37+
load: async () => loadProfile("user_1"),
38+
})
39+
40+
const refresh = $.act("Refresh")
41+
.does(async () => {
42+
await profile.reload()
43+
})
44+
45+
$.surface("main").contains(refresh)
46+
})
47+
```
48+
49+
The resource loads when the runtime starts (autoload). The action calls `profile.reload()` to re-fetch. The loader receives runtime services as context.
50+
51+
## Runtime-scoped resources
52+
53+
Resource definitions live on the screen definition. Resource instances live inside a `ScreenRuntime`.
54+
55+
When you call `createScreenRuntime()` or `testScreen()`, the runtime creates fresh `ResourceNode` instances:
56+
57+
```ts
58+
const runtime1 = createScreenRuntime(ProfileScreen)
59+
await runtime1.start()
60+
// runtime1 has its own ResourceNode for "profile"
61+
62+
const runtime2 = createScreenRuntime(ProfileScreen)
63+
await runtime2.start()
64+
// runtime2 has a separate ResourceNode, independent of runtime1
65+
66+
runtime1.dispose()
67+
// runtime1's resource nodes disconnect; runtime2's nodes are unaffected
68+
```
69+
70+
**Key behaviors:**
71+
72+
- Starting a runtime creates new resource nodes from the screen's configs.
73+
- Disposing the runtime disconnects `ResourceRef` proxies from their nodes.
74+
- Two runtimes do not share live resource state. Loading a resource in one runtime does not affect another.
75+
76+
**Current limitation:** A `ResourceRef` proxies one connected runtime at a time. The last runtime to `start()` owns the ref. Full concurrent multi-mount semantics for the same ref across multiple active runtimes are future work.
77+
78+
## ResourceRef vs ResourceNode
79+
80+
| Concept | Role |
81+
|---------|------|
82+
| `ResourceRef` | Definition-time handle returned by `$.resource()`. Proxies status, value, conditions, and methods to whichever `ResourceNode` it is currently connected to. |
83+
| `ResourceNode` | Runtime-owned instance holding current status, value, error, stale state, and conditions. Created by `ScreenRuntime.start()`. |
84+
85+
Users hold `ResourceRef` handles (e.g. `const team = $.resource("team", ...)`). The ref connects to a runtime node automatically on `start()` and disconnects on `dispose()`.
86+
87+
## Loading lifecycle
88+
89+
### Statuses
90+
91+
A resource transitions through these statuses:
92+
93+
| Status | Meaning |
94+
|--------|---------|
95+
| `"idle"` | Not yet loaded. Initial state before any `load()` call. |
96+
| `"pending"` | Loader is running. |
97+
| `"ready"` | Loader completed successfully. `value` is set. |
98+
| `"failed"` | Loader threw an error. `error` is set. |
99+
100+
### Conditions
101+
102+
Each status has a corresponding `Condition`:
103+
104+
| Condition | True when |
105+
|-----------|-----------|
106+
| `resource.ready` | `status === "ready"` |
107+
| `resource.pending` | `status === "pending"` |
108+
| `resource.failed` | `status === "failed"` |
109+
| `resource.stale` | Resource was invalidated and has not been reloaded successfully since. |
110+
111+
Conditions are cached: `resource.ready === resource.ready`.
112+
113+
### Lifecycle steps
114+
115+
1. **Definition**`$.resource("name", { load, autoLoad })` creates a `ResourceRef`. No loading happens yet.
116+
2. **Runtime start**`ScreenRuntime.start()` creates `ResourceNode` instances, connects refs, and calls `load()` for resources with `autoLoad: true` (the default).
117+
3. **Manual load**`resource.load(context?)` runs the loader, sets status to `"pending"`, then `"ready"` or `"failed"`.
118+
4. **Reload**`resource.reload(context?)` works identically to `load()`. If no context is given, it reuses the last context from the previous load.
119+
5. **Invalidation**`resource.invalidate()` sets `stale` to `true`. It does not trigger a reload.
120+
6. **Successful load clears stale** — After `load()` or `reload()` succeeds, `stale` returns to `false` and `status` becomes `"ready"` again.
121+
7. **Failed load** — If the loader throws, `status` becomes `"failed"`, `error` is set, and `stale` is cleared (the resource is failed, not stale).
122+
123+
### autoLoad behavior
124+
125+
By default `autoLoad: true`. Set `autoLoad: false` to defer loading:
126+
127+
```ts
128+
const team = $.resource("team", {
129+
load: async () => loadTeam("team_1"),
130+
autoLoad: false,
131+
})
132+
133+
// Later, manually:
134+
await team.reload()
135+
```
136+
137+
## Action invalidation
138+
139+
Actions can mark resources as stale after successful execution:
140+
141+
```ts
142+
const save = $.act("Save")
143+
.does(async () => { /* mutate data */ })
144+
.invalidates(team, members)
145+
```
146+
147+
Rules:
148+
149+
- **Success invalidates.** After the action handler completes without throwing, each listed resource is invalidated (marked stale).
150+
- **Blocked actions do nothing.** If the action is blocked by a condition, `executeAct()` returns early and resources are not invalidated.
151+
- **Failed actions do nothing.** If the handler throws, invalidation is skipped.
152+
- **Invalidation marks stale only.** It does not reload. The resource stays `ready` (value is still available) but `stale` becomes `true`. Reload manually if needed.
153+
154+
## Loader context
155+
156+
Resource loaders receive runtime services as context:
157+
158+
```ts
159+
type AppServices = {
160+
route: RouteContext<AppRoutes>
161+
}
162+
163+
const team = $.resource("team", {
164+
load: async ({ route }) => {
165+
return loadTeam(route.params.teamId)
166+
},
167+
})
168+
```
169+
170+
The context is the same `ActionExecutionContext<TServices>` that actions receive:
171+
172+
- Autoload passes the runtime's services to the loader.
173+
- `resource.load(context)` passes the given context.
174+
- `resource.reload()` without arguments reuses the last context from the prior load.
175+
- `resource.reload(context)` passes the given context explicitly.
176+
177+
A loader that takes no context argument is also supported:
178+
179+
```ts
180+
$.resource("team", {
181+
load: async () => loadTeam("team_1"),
182+
})
183+
```
184+
185+
## Using resources in tests
186+
187+
The `@intent-framework/testing` package exposes resources through the `ScreenHandle`:
188+
189+
```ts
190+
import { test, expect } from "vitest"
191+
import { testScreen } from "@intent-framework/testing"
192+
import { ProfileScreen } from "./ProfileScreen.js"
193+
194+
test("resource loads and can be reloaded", async () => {
195+
await testScreen(ProfileScreen, async app => {
196+
const team = app.resource("team")
197+
expect(team.status()).toBe("ready")
198+
expect(team.stale()).toBe(false)
199+
200+
team.invalidate()
201+
expect(team.stale()).toBe(true)
202+
203+
await team.reload()
204+
expect(team.stale()).toBe(false)
205+
expect(team.status()).toBe("ready")
206+
})
207+
})
208+
```
209+
210+
The harness creates a runtime, autoloads resources, and provides methods to check status, load, reload, invalidate, and check staleness.
211+
212+
Resources with `autoLoad: false` stay idle until manually loaded:
213+
214+
```ts
215+
test("autoLoad: false resource stays idle", async () => {
216+
await testScreen(ProfileScreen, async app => {
217+
const profile = app.resource("profile")
218+
expect(profile.status()).toBe("idle")
219+
await profile.load()
220+
expect(profile.status()).toBe("ready")
221+
})
222+
})
223+
```
224+
225+
## inspectScreen and resources
226+
227+
Resources appear in `inspectScreen()` output when you pass runtime resource nodes:
228+
229+
```ts
230+
const runtime = createScreenRuntime(MyScreen)
231+
await runtime.start()
232+
const graph = inspectScreen(MyScreen, runtime.resources)
233+
console.log(graph.resources)
234+
```
235+
236+
Each inspected resource includes:
237+
238+
```json
239+
{
240+
"id": "resource_team",
241+
"semanticId": "resource:team",
242+
"name": "team",
243+
"status": "ready",
244+
"hasValue": true,
245+
"stale": false,
246+
"error": undefined
247+
}
248+
```
249+
250+
On failure, `error` contains the error message and `status` is `"failed"`.
251+
252+
See the [Inspect Screen and Diagnostics Guide](Inspect-Screen.md) for more detail.
253+
254+
## Current boundaries
255+
256+
Resources are intentionally limited:
257+
258+
- **No global cache.** Each runtime owns its resource nodes. There is no shared cache between runtimes.
259+
- **No cache keys.** Resource identity is by name within a screen. There is no key-based deduplication or caching.
260+
- **No staleTime.** Invalidation is manual or action-driven. There is no TTL-based staleness.
261+
- **No automatic background refetching.** Stale resources stay stale until explicitly reloaded.
262+
- **No Suspense integration.** Resources do not integrate with React Suspense or any other framework's loading boundaries.
263+
- **No server framework integration yet.** Resources live on screen definitions and runtimes. Server-side resource hydration is not implemented.
264+
- **No full concurrent multi-mount resource ref semantics.** A `ResourceRef` connects to one runtime at a time. The last runtime to start owns the ref.

0 commit comments

Comments
 (0)