Keep craftEffect out of imperative synchronisation
assertCraftEffectNoImperativeSync prevents a craftEffect from writing a state/source or triggering another query, mutation or async process:
export function keepResourceTransitionsDeclarative(graph: ArchitectureGraph) {
assertCraftEffectNoImperativeSync(graph.graph);
}The syntax is valid — the placement is not
The following calls are valid Craft generator syntax. set, call and mutate return yieldable operations, so a generator consumes them with yield*:
function* submit() {
yield* searchResults.set(yield* rawResults());
yield* usersQuery.call(yield* searchTerm());
yield* saveMutation.mutate(yield* draft());
}The problem is putting the same code in a craftEffect. This is exactly the case rejected by assertCraftEffectNoImperativeSync:
craftEffect('sync', function* () {
yield* searchResults.set(yield* rawResults());
yield* usersQuery.call(yield* searchTerm());
yield* saveMutation.mutate(yield* draft());
});The rule is therefore not saying that yield* searchResults.set(...) is invalid TypeScript or invalid Craft syntax. It is saying that a reactive effect must not imperatively write or trigger another Craft primitive.
What it prevents
This effect creates three hidden edges in the Craft graph:
craftEffect('sync', function* () {
yield* searchResults.set(yield* rawResults());
yield* usersQuery.call(yield* searchTerm());
yield* saveMutation.mutate(yield* draft());
});The graph is effectively:
sync effect ──writes──▶ searchResults
├─calls────▶ usersQuery
└─calls────▶ saveMutationWhenever one of the values read by the effect changes, the effect can write state, start a query and start a mutation again. The direction of data flow is hidden in a callback, which can create feedback loops, duplicate requests or a mutation that runs merely because a signal was read.
Use the primitive that owns the relationship instead
If the query depends on searchTerm, make that dependency explicit with params:
const usersQuery =
yield *
query('usersQuery', {
params: searchTerm,
loader: ({ params }) => searchUsers(params),
});If the operation is a single explicit user action, a craftMethod may call one mutation after normalising the event:
const save = craftMethod('save', function* (event: Event) {
event.preventDefault();
yield* saveMutation.mutate(yield* draft());
});For several operations belonging to one event, emit a source$ directly from the submit or click handler and let each affected primitive react to it. A mutation-to-query relationship belongs in insertReactOnMutation, not beside the mutation call site:
const signOut$ = source$<void>('signOut$');
button({ click: () => signOut$.emit() }, 'Sign out');
const logout =
yield *
mutation('logout', {
method: signOut$.asReadonly(),
loader: logoutUser,
});
const session =
yield *
query(
'session',
{ params: () => 'current', loader: loadSession },
insertReactOnMutation(logout, {
optimisticUpdate: () => undefined,
}),
);craft-ts/no-imperative-craft-method-actions and craft-ts/no-imperative-storage-in-craft-method enforce this placement in the editor. Storage adapters and intentionally imperative facades remain valid in their craftService seam.
For a mutation-to-query relationship, use an insertion such as insertReactOnMutation. For a named external event, use on$. Use a computed value when searchResults is only a transformation of rawResults, instead of storing a second value and synchronising it.
Logging, focus and other effects that do not push into Craft primitives remain valid. The rule protects synchronization, not all side effects.