|
| 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