Skip to content

Commit 6520723

Browse files
Merge pull request #1469 from square/sedwards/update-guidance
Update agent guidance with performance best practices and stale state examples
2 parents e43f7d8 + d24f3b6 commit 6520723

File tree

2 files changed

+88
-4
lines changed

2 files changed

+88
-4
lines changed

AGENTS.md

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,14 +194,95 @@ MyWorkflow.renderForTest {
194194

195195
## Common Pitfalls
196196

197-
1. **Don't capture stale state** — In action lambdas, always use the `state` property from the
198-
`Updater` receiver, never the `renderState` parameter from `render()`.
197+
1. **Don't capture stale state** — This is the most common and dangerous pitfall. The `renderState`
198+
parameter (and any local variable derived from it) is a snapshot from render time. By the time an
199+
action or eventHandler fires, the real state may have changed. **Always read from `state` on the
200+
`Updater` receiver inside action/eventHandler lambdas.**
201+
202+
```kotlin
203+
// BAD — direct capture of renderState
204+
onSave = context.eventHandler("onSave") {
205+
state = renderState.copy(saving = true) // STALE!
206+
}
207+
208+
// BAD — indirect capture via local variable (easy to miss!)
209+
val currentName = renderState.name
210+
val isAdmin = renderState.role == Role.ADMIN
211+
onSave = context.eventHandler("onSave") {
212+
state = state.copy(savedName = currentName) // STALE! currentName came from renderState
213+
}
214+
215+
// GOOD — read from `state` on the Updater receiver
216+
onSave = context.eventHandler("onSave") {
217+
state = state.copy(saving = true)
218+
}
219+
```
220+
221+
**With sealed state hierarchies**, use `safeAction` to safely narrow the state type — it no-ops
222+
if the state has changed to a different subtype by the time the action fires:
223+
224+
```kotlin
225+
private fun onConfirm(item: Item) = safeAction<MyState.Editing>("onConfirm") { editingState ->
226+
state = MyState.Confirmed(editingState.draft, item)
227+
}
228+
```
229+
230+
**Rule of thumb**: if a variable was assigned from `renderState` (directly or transitively), it
231+
must NOT be referenced inside an `action {}` or `eventHandler {}` lambda. Re-derive it from
232+
`state` inside the lambda instead.
233+
199234
2. **Don't perform side effects in `render()`**`render` may be called multiple times for the
200235
same state. Use `runningWorker` or `runningSideEffect` instead.
201236
3. **Don't use `runBlocking`** — Use coroutines and Workers for async work.
202237
4. **Don't forget `name` on `eventHandler`** — Required for Compose stability and debugging.
203238
5. **Don't emit more than one output per action** — Call `setOutput()` at most once.
204239

240+
## Performance Best Practices
241+
242+
### Render Rules
243+
244+
- **`render()` must be idempotent** — no long-running operations, disk access, or extensive object
245+
creation.
246+
- **`snapshotState()` must not serialize** — return a lazy `Snapshot`; serialization happens only
247+
when needed.
248+
- **One render per action** — populate all state data in `initialState()` to avoid unnecessary
249+
intermediate render passes.
250+
- **Don't render children to inform parent state** — extract shared logic into helper classes or
251+
`Scoped` objects accessible to both parent and child.
252+
- **Filter upstream signals at the source** — filter Worker streams before handling, not in the
253+
output handler, to prevent unnecessary renders.
254+
- **Hoist shared state** — when multiple leaf workflows share state, manage it at the lowest common
255+
ancestor and pass via props for a single render pass.
256+
257+
### Worker & Action Rules
258+
259+
- Only create Workers when state changes are expected.
260+
- Combine multiple Workers sharing the same source into a single Worker.
261+
- Avoid `Worker<Unit>` except for timers.
262+
- Use `combine()` / `combineTransform()` to consolidate multiple flow lookups into single-pass
263+
state updates.
264+
- Prefer `SharedFlow` over `StateFlow` for one-time events (avoids automatic re-emission on
265+
collection).
266+
267+
### eventHandler Rules
268+
269+
- Assign `eventHandler` directly to rendering callbacks only.
270+
- Use only when state changes are intended.
271+
- **Never nest `action()` calls within `eventHandler` lambdas.**
272+
273+
### Compose Stability
274+
275+
- Use stable types in renderings — prefer `ImmutableList` over `List`.
276+
- Don't pass `Lazy` delegates to composable functions.
277+
- Use `RenderContext#remember` to cache expensive view model computations when inputs are unchanged.
278+
279+
### Dependency Injection
280+
281+
- Use `dagger.Lazy<T>` for conditionally-used dependencies to defer expensive instantiation
282+
(especially useful behind feature flags).
283+
- Use factories only for dependency injection, not stateful object construction — stateful objects
284+
should be properties for runtime optimization compatibility.
285+
205286
## Naming Conventions
206287

207288
- **Workflow**: `[Feature]Workflow` (e.g., `LoginWorkflow`)

workflow-core/.agents/skills/create-workflow/SKILL.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,8 +248,11 @@ context.runningSideEffect("trackScreen") {
248248

249249
1. **Never perform side effects in `render()`**`render` is called multiple times per state.
250250
Use `runningWorker` or `runningSideEffect`.
251-
2. **Don't capture `renderState` in lambdas** — In `eventHandler` and `action` blocks, use the
252-
`state` property from the `Updater` receiver, not the `renderState` parameter.
251+
2. **Don't capture `renderState` in lambdas**`renderState` is a snapshot from render time and
252+
will be stale when the action fires. This includes **local variables derived from `renderState`**
253+
(easy to miss!). Always read from `state` on the `Updater` receiver inside action/eventHandler
254+
lambdas. For sealed state hierarchies, use `safeAction<SpecificState>("name")` which no-ops if
255+
the state type has changed. See AGENTS.md "Common Pitfalls" for detailed examples.
253256
3. **Always provide `name` to `eventHandler`** — Required for Compose stability and debugging.
254257
4. **Use `setOutput()` to emit output** — Call at most once per action.
255258
5. **Return `null` from `snapshotState`** unless you need state persistence across process death.

0 commit comments

Comments
 (0)